import {
  DC,
  DR,
  Dir,
  E,
  N,
  OPP,
  PieceKind,
  S,
  W,
  exitDir,
  isElbow,
  isOneway,
  isReservoir,
  onewayArrow,
  openings,
  BIT,
} from "../game/pieces";
import type { Cell, Game } from "../game/sim";
import type { Quality, Settings } from "../persist";

export interface Assets {
  hero: HTMLImageElement | null;
  metal: HTMLImageElement | null;
  concrete: HTMLImageElement | null;
  plasma: HTMLImageElement | null;
  logo: HTMLImageElement | null;
  victory: HTMLImageElement | null;
  overflow: HTMLImageElement | null;
}

export type Hit =
  | { kind: "cell"; r: number; c: number }
  | { kind: "dump" }
  | { kind: "release" }
  | { kind: "pause" }
  | { kind: "none" };

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  max: number;
  size: number;
  c: string;
  type: "spark" | "soft" | "drop";
}

export interface Layout {
  tile: number;
  bx: number;
  by: number;
  bw: number;
  bh: number;
  qx: number;
  qy: number;
  qs: number;
  dump: { x: number; y: number; w: number; h: number };
  release: { x: number; y: number; w: number; h: number };
  pause: { x: number; y: number; w: number; h: number };
}

const PAL = {
  flooz: "#3dffc8",
  floozHot: "#b8ffe8",
  floozCore: "#f4fffb",
  mag: "#ff5ad5",
  gold: "#e0b45c",
  danger: "#ff5a6a",
  ink: "#e8f0f4",
  muted: "#8b9aa3",
  metal0: "#12161c",
  metal1: "#3e4650",
  metal2: "#8b959f",
  metal3: "#d5dde4",
};

const PAL_CB = {
  ...PAL,
  flooz: "#6db3ff",
  floozHot: "#c8e4ff",
  floozCore: "#f3f9ff",
  mag: "#ffd36a",
};

function clamp(n: number, a: number, b: number): number {
  return Math.max(a, Math.min(b, n));
}

function edge(x: number, y: number, s: number, d: Dir): { x: number; y: number } {
  return { x: x + s / 2 + DC[d] * (s / 2), y: y + s / 2 + DR[d] * (s / 2) };
}

function pathLength(kind: PieceKind, s: number): number {
  if (isElbow(kind)) return (Math.PI * s) / 4;
  if (kind === "start" || kind === "end") return s * 0.5;
  return s;
}

export class Renderer {
  canvas: HTMLCanvasElement;
  ctx: CanvasRenderingContext2D;
  bloom: HTMLCanvasElement;
  bctx: CanvasRenderingContext2D;
  grain: HTMLCanvasElement;
  assets: Assets;
  layout: Layout | null = null;
  particles: Particle[] = [];
  shake = 0;
  flash = 0;
  flashColor = "255,80,90";
  time = 0;
  hover: { r: number; c: number } | null = null;
  banner: string | null = null;
  bannerAge = 0;
  private metalPat: CanvasPattern | null = null;
  private concPat: CanvasPattern | null = null;
  private plasmaPat: CanvasPattern | null = null;
  private lastW = 0;
  private lastH = 0;

  constructor(canvas: HTMLCanvasElement, assets: Assets) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d")!;
    this.bloom = document.createElement("canvas");
    this.bctx = this.bloom.getContext("2d")!;
    this.grain = document.createElement("canvas");
    this.grain.width = 128;
    this.grain.height = 128;
    const g = this.grain.getContext("2d")!;
    const img = g.createImageData(128, 128);
    for (let i = 0; i < img.data.length; i += 4) {
      const n = 80 + Math.random() * 80;
      img.data[i] = n;
      img.data[i + 1] = n;
      img.data[i + 2] = n;
      img.data[i + 3] = 28;
    }
    g.putImageData(img, 0, 0);
    this.assets = assets;
    if (assets.metal) this.metalPat = this.ctx.createPattern(assets.metal, "repeat");
    if (assets.concrete)
      this.concPat = this.ctx.createPattern(assets.concrete, "repeat");
    if (assets.plasma)
      this.plasmaPat = this.ctx.createPattern(assets.plasma, "repeat");
  }

  pal(cb: boolean): typeof PAL {
    return cb ? PAL_CB : PAL;
  }

  burst(
    x: number,
    y: number,
    n: number,
    color: string,
    speed = 80,
    type: Particle["type"] = "spark",
  ): void {
    for (let i = 0; i < n; i++) {
      const a = Math.random() * Math.PI * 2;
      const v = speed * (0.3 + Math.random());
      this.particles.push({
        x,
        y,
        vx: Math.cos(a) * v,
        vy: Math.sin(a) * v,
        life: 0,
        max: 0.35 + Math.random() * 0.55,
        size: type === "soft" ? 10 + Math.random() * 16 : 2 + Math.random() * 3,
        c: color,
        type,
      });
    }
  }

  drip(x: number, y: number, color: string): void {
    this.particles.push({
      x,
      y,
      vx: (Math.random() - 0.5) * 12,
      vy: 8 + Math.random() * 20,
      life: 0,
      max: 0.5,
      size: 2.5,
      c: color,
      type: "drop",
    });
  }

  punch(amt = 7): void {
    this.shake = Math.max(this.shake, amt);
  }

  showBanner(text: string): void {
    this.banner = text;
    this.bannerAge = 0;
  }

  spillFlash(): void {
    this.flash = 1;
    this.flashColor = "80,220,180";
    this.punch(16);
  }

  winFlash(): void {
    this.flash = 0.7;
    this.flashColor = "224,180,92";
  }

  resize(): void {
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    const w = window.innerWidth;
    const h = window.innerHeight;
    if (w === this.lastW && h === this.lastH && this.canvas.width) return;
    this.lastW = w;
    this.lastH = h;
    this.canvas.width = Math.floor(w * dpr);
    this.canvas.height = Math.floor(h * dpr);
    this.canvas.style.width = w + "px";
    this.canvas.style.height = h + "px";
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.bloom.width = Math.floor(w * 0.5);
    this.bloom.height = Math.floor(h * 0.5);
  }

  computeLayout(game: Game): Layout {
    const w = this.lastW;
    const h = this.lastH;
    const pad = Math.max(16, Math.min(w, h) * 0.03);
    const top = 64;
    const bottom = 28;
    const leftPanel = Math.min(168, w * 0.16);
    const rightPanel = Math.min(240, w * 0.2);
    const availW = w - pad * 2 - leftPanel - rightPanel - 20;
    const availH = h - top - bottom - pad;
    const tile = Math.floor(Math.min(availW / game.cols, availH / game.rows));
    const bw = tile * game.cols;
    const bh = tile * game.rows;
    const bx = pad + leftPanel + (availW - bw) / 2;
    const by = top + (availH - bh) / 2;
    const qs = Math.min(tile * 0.92, (h - top - 160) / 6);
    const qx = pad + (leftPanel - qs) / 2;
    const qy = by;
    return {
      tile,
      bx,
      by,
      bw,
      bh,
      qx,
      qy,
      qs,
      dump: {
        x: bx + bw + 18,
        y: by + bh - 160,
        w: rightPanel - 28,
        h: 40,
      },
      release: {
        x: bx + bw + 18,
        y: by + bh - 108,
        w: rightPanel - 28,
        h: 48,
      },
      pause: { x: w - 58, y: 14, w: 42, h: 42 },
    };
  }

  hit(mx: number, my: number, game: Game): Hit {
    const L = this.layout ?? this.computeLayout(game);
    const inside = (r: { x: number; y: number; w: number; h: number }) =>
      mx >= r.x && my >= r.y && mx <= r.x + r.w && my <= r.y + r.h;
    if (inside(L.pause)) return { kind: "pause" };
    if (inside(L.dump) && !game.spec.bonusSlide) return { kind: "dump" };
    if (inside(L.release) && game.phase === "countdown") return { kind: "release" };
    const c = Math.floor((mx - L.bx) / L.tile);
    const r = Math.floor((my - L.by) / L.tile);
    if (r >= 0 && c >= 0 && r < game.rows && c < game.cols)
      return { kind: "cell", r, c };
    return { kind: "none" };
  }

  tick(dt: number, settings: Settings): void {
    this.time += dt;
    this.bannerAge += dt;
    if (this.banner && this.bannerAge > 1.8) this.banner = null;
    this.shake *= Math.pow(0.04, dt);
    if (this.shake < 0.15) this.shake = 0;
    this.flash = Math.max(0, this.flash - dt * 1.8);
    if (!settings.particles) {
      this.particles = [];
      return;
    }
    for (const p of this.particles) {
      p.life += dt;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.vy += (p.type === "drop" ? 80 : 0) * dt;
      p.vx *= 0.98;
    }
    this.particles = this.particles.filter((p) => p.life < p.max);
  }

  draw(game: Game, settings: Settings, cursor: { x: number; y: number } | null): void {
    this.resize();
    const ctx = this.ctx;
    const w = this.lastW;
    const h = this.lastH;
    const L = this.computeLayout(game);
    this.layout = L;
    const pal = this.pal(settings.colorblind);

    let ox = 0,
      oy = 0;
    if (settings.shake && !settings.reducedMotion && this.shake > 0) {
      ox = (Math.random() - 0.5) * this.shake;
      oy = (Math.random() - 0.5) * this.shake;
    }
    ctx.setTransform(
      Math.min(2, window.devicePixelRatio || 1),
      0,
      0,
      Math.min(2, window.devicePixelRatio || 1),
      0,
      0,
    );
    /* reset then apply dpr in resize; we draw in CSS pixels via setTransform */
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    ctx.setTransform(dpr, 0, 0, dpr, ox * dpr, oy * dpr);

    this.drawBackdrop(w, h, game, pal);
    this.drawBoardWell(L, pal);
    this.drawCells(game, L, pal, settings, cursor);
    this.drawPipes(game, L, pal, false);
    if (settings.quality !== "low") this.drawBloom(game, L, pal, settings.quality);
    this.drawPipes(game, L, pal, true);
    this.drawParticles();
    this.drawQueue(game, L, pal);
    this.drawHud(game, L, pal, w, h, settings);
    this.drawGrain(w, h);
    this.drawVignette(w, h);
    if (this.flash > 0) {
      ctx.fillStyle = `rgba(${this.flashColor},${this.flash * 0.28})`;
      ctx.fillRect(-20, -20, w + 40, h + 40);
    }
  }

  private drawBackdrop(w: number, h: number, game: Game, pal: typeof PAL): void {
    const ctx = this.ctx;
    ctx.fillStyle = "#05070a";
    ctx.fillRect(0, 0, w, h);
    const img =
      game.phase === "lost"
        ? this.assets.overflow
        : game.phase === "won"
          ? this.assets.victory
          : this.assets.hero;
    if (img) {
      const s = Math.max(w / img.width, h / img.height);
      const iw = img.width * s;
      const ih = img.height * s;
      ctx.save();
      ctx.globalAlpha = game.phase === "lost" || game.phase === "won" ? 0.55 : 0.38;
      ctx.filter = "saturate(0.85) brightness(0.55)";
      ctx.drawImage(img, (w - iw) / 2, (h - ih) / 2, iw, ih);
      ctx.filter = "none";
      ctx.restore();
    }
    const g = ctx.createRadialGradient(
      w * 0.5,
      h * 0.45,
      40,
      w * 0.5,
      h * 0.5,
      Math.max(w, h) * 0.7,
    );
    g.addColorStop(0, "rgba(8,18,22,0.15)");
    g.addColorStop(1, "rgba(4,6,10,0.72)");
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, w, h);
    void pal;
  }

  private drawBoardWell(L: Layout, pal: typeof PAL): void {
    const ctx = this.ctx;
    const pad = 14;
    const x = L.bx - pad;
    const y = L.by - pad;
    const w = L.bw + pad * 2;
    const h = L.bh + pad * 2;
    ctx.save();
    round(ctx, x, y, w, h, 18);
    ctx.fillStyle = "#0b0e12";
    ctx.fill();
    if (this.metalPat) {
      ctx.save();
      ctx.globalAlpha = 0.35;
      ctx.fillStyle = this.metalPat;
      ctx.fill();
      ctx.restore();
    }
    ctx.strokeStyle = pal.gold;
    ctx.globalAlpha = 0.45;
    ctx.lineWidth = 1.25;
    ctx.stroke();
    ctx.globalAlpha = 1;
    round(ctx, L.bx - 4, L.by - 4, L.bw + 8, L.bh + 8, 8);
    ctx.fillStyle = "#07090c";
    ctx.fill();
    if (this.concPat) {
      ctx.save();
      ctx.globalAlpha = 0.55;
      ctx.fillStyle = this.concPat;
      ctx.fill();
      ctx.restore();
    }
    ctx.restore();
  }

  private drawCells(
    game: Game,
    L: Layout,
    pal: typeof PAL,
    settings: Settings,
    cursor: { x: number; y: number } | null,
  ): void {
    const ctx = this.ctx;
    const t = L.tile;
    for (let r = 0; r < game.rows; r++) {
      for (let c = 0; c < game.cols; c++) {
        const x = L.bx + c * t;
        const y = L.by + r * t;
        ctx.save();
        round(ctx, x + 3, y + 3, t - 6, t - 6, 7);
        ctx.fillStyle = "rgba(0,0,0,0.38)";
        ctx.fill();
        ctx.strokeStyle = "rgba(180,200,210,0.07)";
        ctx.lineWidth = 1;
        ctx.stroke();
        ctx.restore();
        const cell = game.cell(r, c);
        if (cell.piece === "block") this.drawBlock(x, y, t, pal);
      }
    }
    if (game.hole) {
      const x = L.bx + game.hole.c * t;
      const y = L.by + game.hole.r * t;
      ctx.fillStyle = "rgba(0,0,0,0.55)";
      ctx.fillRect(x + 4, y + 4, t - 8, t - 8);
      ctx.strokeStyle = "rgba(61,255,200,0.25)";
      ctx.strokeRect(x + 6, y + 6, t - 12, t - 12);
    }
    if (this.hover && settings.ghost && !game.spec.bonusSlide && game.next) {
      const { r, c } = this.hover;
      const cell = game.cell(r, c);
      if (
        !cell.locked &&
        cell.piece !== "start" &&
        cell.piece !== "end" &&
        cell.piece !== "block" &&
        cell.fill[0] <= 0 &&
        cell.fill[1] <= 0
      ) {
        const x = L.bx + c * t;
        const y = L.by + r * t;
        ctx.save();
        ctx.globalAlpha = 0.38;
        this.drawPiece(x, y, t, game.next, E, pal, false, 0, 0, 0, false, false);
        ctx.strokeStyle = pal.flooz;
        ctx.globalAlpha = 0.55;
        ctx.lineWidth = 2;
        ctx.strokeRect(x + 3, y + 3, t - 6, t - 6);
        ctx.restore();
      }
    }
    if (settings.lantern) {
      const f = game.forecast();
      ctx.save();
      ctx.globalAlpha = 0.18;
      for (const p of f.cells) {
        const x = L.bx + p.c * t + t / 2;
        const y = L.by + p.r * t + t / 2;
        ctx.fillStyle = f.danger ? pal.danger : pal.flooz;
        ctx.beginPath();
        ctx.arc(x, y, t * 0.08, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.restore();
    }
    void cursor;
  }

  private drawBlock(x: number, y: number, s: number, pal: typeof PAL): void {
    const ctx = this.ctx;
    const m = s * 0.08;
    ctx.fillStyle = "#1a1f26";
    ctx.fillRect(x + m, y + m, s - m * 2, s - m * 2);
    if (this.metalPat) {
      ctx.save();
      ctx.globalAlpha = 0.5;
      ctx.fillStyle = this.metalPat;
      ctx.fillRect(x + m, y + m, s - m * 2, s - m * 2);
      ctx.restore();
    }
    ctx.strokeStyle = pal.metal1;
    ctx.strokeRect(x + m, y + m, s - m * 2, s - m * 2);
    ctx.fillStyle = pal.metal2;
    const rivet = [
      [0.22, 0.22],
      [0.78, 0.22],
      [0.22, 0.78],
      [0.78, 0.78],
    ];
    for (const [rx, ry] of rivet) {
      ctx.beginPath();
      ctx.arc(x + s * rx, y + s * ry, s * 0.04, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  private drawPipes(game: Game, L: Layout, pal: typeof PAL, glowPass: boolean): void {
    const t = L.tile;
    for (let r = 0; r < game.rows; r++) {
      for (let c = 0; c < game.cols; c++) {
        const cell = game.cell(r, c);
        if (!cell.piece || cell.piece === "block") continue;
        const x = L.bx + c * t;
        const y = L.by + r * t;
        const live =
          game.flow && game.flow.r === r && game.flow.c === c
            ? game.flow
            : null;
        this.drawPiece(
          x,
          y,
          t,
          cell.piece,
          cell.port,
          pal,
          glowPass,
          cell.fill[0],
          cell.fill[1],
          cell.tank,
          cell.bonus,
          !!live,
          cell.from[0],
          cell.from[1],
        );
      }
    }
    if (game.flow && !glowPass) {
      const { r, c, enter, progress, axis } = game.flow;
      const cell = game.cell(r, c);
      const pt = this.pointOnPiece(
        L.bx + c * t,
        L.by + r * t,
        t,
        cell.piece!,
        enter,
        cell.port,
        progress,
        axis,
      );
      this.headGlow(pt.x, pt.y, pal);
      if (Math.random() < 0.4)
        this.drip(pt.x, pt.y, pal.flooz);
    }
    if (game.spillAt && game.phase === "lost") {
      const { r, c, dir } = game.spillAt;
      const p = edge(L.bx + c * t, L.by + r * t, t, dir);
      this.headGlow(p.x, p.y, pal);
    }
  }

  private headGlow(x: number, y: number, pal: typeof PAL): void {
    const ctx = this.ctx;
    const g = ctx.createRadialGradient(x, y, 0, x, y, 28);
    g.addColorStop(0, pal.floozCore);
    g.addColorStop(0.2, pal.flooz);
    g.addColorStop(1, "rgba(61,255,200,0)");
    ctx.save();
    ctx.globalCompositeOperation = "lighter";
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(x, y, 28, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }

  drawPiece(
    x: number,
    y: number,
    s: number,
    kind: PieceKind,
    port: Dir,
    pal: typeof PAL,
    glowPass: boolean,
    fill0: number,
    fill1: number,
    tank: number,
    bonus: boolean,
    live: boolean,
    from0: Dir | null = null,
    from1: Dir | null = null,
  ): void {
    const ctx = this.ctx;
    if (kind === "start") {
      this.drawValve(x, y, s, port, pal, fill0 || fill1, glowPass);
      return;
    }
    if (kind === "end") {
      this.drawDrain(x, y, s, port, pal, fill0 || fill1, glowPass);
      return;
    }

    const metal = bonus ? pal.gold : pal.metal2;
    const axes: { from: Dir; fill: number }[] = [];
    if (kind === "cross") {
      axes.push(
        { from: from0 ?? N, fill: fill0 },
        { from: from1 ?? W, fill: fill1 },
      );
    } else {
      const mask = openings(kind, port);
      let from: Dir = E;
      for (let d = 0; d < 4; d++) if (mask & BIT[d as Dir]) {
        from = d as Dir;
        break;
      }
      axes.push({ from: from0 ?? from, fill: fill0 });
    }

    for (const ax of axes) {
      this.strokePipe(x, y, s, kind, ax.from, port, pal, metal, glowPass, ax.fill, live);
    }

    if (isReservoir(kind) && !glowPass) {
      this.drawTank(x, y, s, pal, tank);
    }
    if (isOneway(kind) && !glowPass) {
      this.drawArrow(x, y, s, onewayArrow(kind) ?? E, pal);
    }
    if (!glowPass) this.drawFlanges(x, y, s, kind, port, pal, metal);
  }

  private strokePipe(
    x: number,
    y: number,
    s: number,
    kind: PieceKind,
    from: Dir,
    port: Dir,
    pal: typeof PAL,
    metal: string,
    glowPass: boolean,
    fill: number,
    live: boolean,
  ): void {
    const ctx = this.ctx;
    const len = pathLength(kind, s);
    const thick = s * 0.44;

    const stroke = (width: number, color: string, alpha = 1) => {
      ctx.save();
      ctx.lineWidth = width;
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      ctx.strokeStyle = color;
      ctx.globalAlpha = alpha;
      ctx.beginPath();
      this.trace(ctx, x, y, s, kind, from, port);
      ctx.stroke();
      ctx.restore();
    };

    if (glowPass) {
      if (fill > 0) {
        ctx.save();
        ctx.lineCap = "round";
        ctx.strokeStyle = pal.flooz;
        ctx.globalCompositeOperation = "lighter";
        ctx.lineWidth = thick * 1.15;
        ctx.globalAlpha = 0.55 + (live ? 0.2 : 0);
        ctx.setLineDash([fill * len, 1e6]);
        ctx.beginPath();
        this.trace(ctx, x, y, s, kind, from, port);
        ctx.stroke();
        ctx.lineWidth = thick * 0.45;
        ctx.strokeStyle = pal.floozCore;
        ctx.beginPath();
        this.trace(ctx, x, y, s, kind, from, port);
        ctx.stroke();
        ctx.restore();
      }
      return;
    }

    stroke(thick + 8, "#050608", 0.8);
    stroke(thick + 2, "#2a3138");
    stroke(thick, metal);
    stroke(thick * 0.82, pal.metal1);
    stroke(thick * 0.55, "#0a1014");

    if (fill > 0) {
      ctx.save();
      ctx.lineCap = "round";
      ctx.setLineDash([fill * len, 1e6]);
      ctx.lineWidth = thick * 0.48;
      ctx.strokeStyle = pal.flooz;
      ctx.beginPath();
      this.trace(ctx, x, y, s, kind, from, port);
      ctx.stroke();
      ctx.lineWidth = thick * 0.22;
      ctx.strokeStyle = pal.floozCore;
      ctx.beginPath();
      this.trace(ctx, x, y, s, kind, from, port);
      ctx.stroke();
      ctx.restore();
    } else {
      stroke(thick * 0.22, "rgba(80,140,140,0.18)");
    }

    ctx.save();
    ctx.lineCap = "round";
    ctx.strokeStyle = "rgba(255,255,255,0.28)";
    ctx.lineWidth = Math.max(2, s * 0.04);
    ctx.beginPath();
    this.trace(ctx, x, y, s, kind, from, port);
    ctx.stroke();
    ctx.restore();
  }

  private trace(
    ctx: CanvasRenderingContext2D,
    x: number,
    y: number,
    s: number,
    kind: PieceKind,
    from: Dir,
    port: Dir,
  ): void {
    const cx = x + s / 2;
    const cy = y + s / 2;
    const a = edge(x, y, s, from);
    const leave = exitDir(kind, from, port);
    ctx.moveTo(a.x, a.y);
    if (kind === "start") {
      ctx.moveTo(cx, cy);
      const b = edge(x, y, s, port);
      ctx.lineTo(b.x, b.y);
      return;
    }
    if (kind === "end") {
      ctx.lineTo(cx, cy);
      return;
    }
    if (isElbow(kind) && leave !== null) {
      const b = edge(x, y, s, leave);
      ctx.arcTo(cx, cy, b.x, b.y, s * 0.5);
      return;
    }
    if (leave !== null) {
      const b = edge(x, y, s, leave);
      ctx.lineTo(b.x, b.y);
    }
  }

  private pointOnPiece(
    x: number,
    y: number,
    s: number,
    kind: PieceKind,
    from: Dir,
    port: Dir,
    t: number,
    _axis: 0 | 1,
  ): { x: number; y: number } {
    const cx = x + s / 2;
    const cy = y + s / 2;
    const a = kind === "start" ? { x: cx, y: cy } : edge(x, y, s, from);
    const leave = kind === "start" ? port : exitDir(kind, from, port);
    if (leave === null) return a;
    const b = edge(x, y, s, leave);
    if (isElbow(kind)) {
      const ang0 = Math.atan2(a.y - cy, a.x - cx);
      const ang1 = Math.atan2(b.y - cy, b.x - cx);
      let d = ang1 - ang0;
      while (d > Math.PI) d -= Math.PI * 2;
      while (d < -Math.PI) d += Math.PI * 2;
      const ang = ang0 + d * t;
      const rad = s * 0.5;
      return { x: cx + Math.cos(ang) * rad, y: cy + Math.sin(ang) * rad };
    }
    return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
  }

  private drawFlanges(
    x: number,
    y: number,
    s: number,
    kind: PieceKind,
    port: Dir,
    pal: typeof PAL,
    metal: string,
  ): void {
    const ctx = this.ctx;
    const mask = openings(kind, port);
    for (let d = 0; d < 4; d++) {
      if (!(mask & BIT[d as Dir])) continue;
      const p = edge(x, y, s, d as Dir);
      ctx.beginPath();
      ctx.arc(p.x, p.y, s * 0.14, 0, Math.PI * 2);
      ctx.fillStyle = pal.metal0;
      ctx.fill();
      ctx.strokeStyle = metal;
      ctx.lineWidth = 2;
      ctx.stroke();
    }
  }

  private drawTank(x: number, y: number, s: number, pal: typeof PAL, tank: number): void {
    const ctx = this.ctx;
    const cx = x + s / 2;
    const cy = y + s / 2;
    ctx.save();
    ctx.beginPath();
    ctx.ellipse(cx, cy, s * 0.28, s * 0.34, 0, 0, Math.PI * 2);
    ctx.fillStyle = pal.metal0;
    ctx.fill();
    ctx.strokeStyle = pal.metal2;
    ctx.lineWidth = 3;
    ctx.stroke();
    ctx.beginPath();
    ctx.ellipse(cx, cy, s * 0.2, s * 0.26, 0, 0, Math.PI * 2);
    ctx.clip();
    ctx.fillStyle = "#071014";
    ctx.fillRect(x, y, s, s);
    const h = s * 0.52 * tank;
    ctx.fillStyle = pal.flooz;
    ctx.globalAlpha = 0.85;
    ctx.fillRect(cx - s, cy + s * 0.26 - h, s * 2, h);
    ctx.restore();
  }

  private drawArrow(x: number, y: number, s: number, d: Dir, pal: typeof PAL): void {
    const ctx = this.ctx;
    const cx = x + s / 2;
    const cy = y + s / 2;
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(((d - 1) * Math.PI) / 2);
    ctx.fillStyle = pal.gold;
    ctx.beginPath();
    ctx.moveTo(s * 0.16, 0);
    ctx.lineTo(-s * 0.08, -s * 0.1);
    ctx.lineTo(-s * 0.08, s * 0.1);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

  private drawValve(
    x: number,
    y: number,
    s: number,
    port: Dir,
    pal: typeof PAL,
    fill: number,
    glowPass: boolean,
  ): void {
    this.strokePipe(x, y, s, "start", OPP[port], port, pal, pal.metal3, glowPass, fill, fill > 0 && fill < 1);
    if (glowPass) return;
    const ctx = this.ctx;
    const cx = x + s / 2;
    const cy = y + s / 2;
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(this.time * 0.15);
    ctx.beginPath();
    ctx.arc(0, 0, s * 0.32, 0, Math.PI * 2);
    ctx.fillStyle = "#1c2228";
    ctx.fill();
    ctx.strokeStyle = pal.gold;
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.strokeStyle = pal.metal2;
    for (let i = 0; i < 6; i++) {
      ctx.rotate(Math.PI / 3);
      ctx.beginPath();
      ctx.moveTo(s * 0.08, 0);
      ctx.lineTo(s * 0.26, 0);
      ctx.lineWidth = 4;
      ctx.stroke();
    }
    ctx.restore();
    ctx.beginPath();
    ctx.arc(cx, cy, s * 0.08, 0, Math.PI * 2);
    ctx.fillStyle = fill > 0 ? pal.flooz : pal.metal3;
    ctx.fill();
  }

  private drawDrain(
    x: number,
    y: number,
    s: number,
    port: Dir,
    pal: typeof PAL,
    fill: number,
    glowPass: boolean,
  ): void {
    this.strokePipe(x, y, s, "end", port, port, pal, pal.metal2, glowPass, fill, false);
    if (glowPass) return;
    const ctx = this.ctx;
    const cx = x + s / 2;
    const cy = y + s / 2;
    ctx.beginPath();
    ctx.arc(cx, cy, s * 0.26, 0, Math.PI * 2);
    ctx.fillStyle = "#0a0d10";
    ctx.fill();
    ctx.strokeStyle = pal.metal2;
    ctx.lineWidth = 3;
    ctx.stroke();
    ctx.strokeStyle = pal.metal1;
    ctx.lineWidth = 1.5;
    for (let i = -2; i <= 2; i++) {
      ctx.beginPath();
      ctx.moveTo(cx - s * 0.16, cy + i * s * 0.06);
      ctx.lineTo(cx + s * 0.16, cy + i * s * 0.06);
      ctx.stroke();
    }
  }

  private drawBloom(game: Game, L: Layout, pal: typeof PAL, quality: Quality): void {
    const b = this.bctx;
    const bw = this.bloom.width;
    const bh = this.bloom.height;
    b.setTransform(1, 0, 0, 1, 0, 0);
    b.clearRect(0, 0, bw, bh);
    const sx = bw / this.lastW;
    const sy = bh / this.lastH;
    b.setTransform(sx, 0, 0, sy, 0, 0);
    const t = L.tile;
    b.lineCap = "round";
    b.globalCompositeOperation = "lighter";
    for (let r = 0; r < game.rows; r++) {
      for (let c = 0; c < game.cols; c++) {
        const cell = game.cell(r, c);
        if (!cell.piece || cell.piece === "block") continue;
        const fills =
          cell.piece === "cross" ? [cell.fill[0], cell.fill[1]] : [cell.fill[0]];
        const froms: Dir[] =
          cell.piece === "cross" ? [N, W] : [firstOpen(cell)];
        for (let i = 0; i < fills.length; i++) {
          const fill = fills[i]!;
          if (fill <= 0) continue;
          const x = L.bx + c * t;
          const y = L.by + r * t;
          const len = pathLength(cell.piece, t);
          b.strokeStyle = pal.flooz;
          b.lineWidth = t * 0.5;
          b.globalAlpha = 0.65;
          b.setLineDash([fill * len, 1e6]);
          b.beginPath();
          this.trace(b, x, y, t, cell.piece, froms[i]!, cell.port);
          b.stroke();
        }
      }
    }
    const ctx = this.ctx;
    ctx.save();
    ctx.globalCompositeOperation = "lighter";
    ctx.globalAlpha = 0.55;
    if (quality === "high") ctx.filter = "blur(10px)";
    else ctx.filter = "blur(5px)";
    ctx.drawImage(this.bloom, 0, 0, this.lastW, this.lastH);
    ctx.filter = "none";
    ctx.restore();
  }

  private drawParticles(): void {
    const ctx = this.ctx;
    ctx.save();
    ctx.globalCompositeOperation = "lighter";
    for (const p of this.particles) {
      const t = 1 - p.life / p.max;
      ctx.globalAlpha = t;
      ctx.fillStyle = p.c;
      if (p.type === "soft") {
        const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
        g.addColorStop(0, p.c);
        g.addColorStop(1, "transparent");
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
        ctx.fill();
      } else {
        ctx.fillRect(p.x, p.y, p.size, p.size);
      }
    }
    ctx.restore();
  }

  private drawQueue(game: Game, L: Layout, pal: typeof PAL): void {
    if (game.spec.bonusSlide) {
      this.panel(
        L.qx - 10,
        L.qy,
        L.qs + 20,
        90,
        "SLIDE",
        "Click a tile next to the gap.",
        pal,
      );
      return;
    }
    const ctx = this.ctx;
    const items = game.queue;
    const s = L.qs;
    let y = L.qy;
    this.label(L.qx, y - 18, "NEXT", pal.gold);
    for (let i = 0; i < items.length; i++) {
      const x = L.qx;
      round(ctx, x - 6, y - 6, s + 12, s + 12, 10);
      ctx.fillStyle = i === 0 ? "rgba(61,255,200,0.08)" : "rgba(0,0,0,0.35)";
      ctx.fill();
      ctx.strokeStyle = i === 0 ? pal.flooz : "rgba(224,180,92,0.25)";
      ctx.lineWidth = i === 0 ? 1.5 : 1;
      ctx.stroke();
      this.drawPiece(x, y, s, items[i]!, E, pal, false, 0, 0, 0, false, false);
      y += s + 10;
    }
    const d = L.dump;
    round(ctx, d.x, d.y, d.w, d.h, 8);
    ctx.fillStyle = "rgba(20,10,12,0.7)";
    ctx.fill();
    ctx.strokeStyle = "rgba(255,90,106,0.45)";
    ctx.stroke();
    ctx.fillStyle = pal.danger;
    ctx.font = "700 11px Manrope, sans-serif";
    ctx.textAlign = "center";
    ctx.fillText("DUMP  X", d.x + d.w / 2, d.y + d.h / 2 + 4);
  }

  private panel(
    x: number,
    y: number,
    w: number,
    h: number,
    title: string,
    body: string,
    pal: typeof PAL,
  ): void {
    const ctx = this.ctx;
    round(ctx, x, y, w, h, 10);
    ctx.fillStyle = "rgba(8,12,16,0.7)";
    ctx.fill();
    ctx.fillStyle = pal.gold;
    ctx.font = "700 12px Unbounded, sans-serif";
    ctx.textAlign = "left";
    ctx.fillText(title, x + 10, y + 22);
    ctx.fillStyle = pal.muted;
    ctx.font = "400 11px Manrope, sans-serif";
    wrapText(ctx, body, x + 10, y + 42, w - 20, 14);
  }

  private label(x: number, y: number, text: string, color: string): void {
    const ctx = this.ctx;
    ctx.fillStyle = color;
    ctx.font = "700 10px Unbounded, sans-serif";
    ctx.textAlign = "left";
    ctx.fillText(text, x, y);
  }

  private drawHud(
    game: Game,
    L: Layout,
    pal: typeof PAL,
    w: number,
    h: number,
    settings: Settings,
  ): void {
    const ctx = this.ctx;
    ctx.textAlign = "left";
    ctx.fillStyle = pal.ink;
    ctx.font = "800 22px Unbounded, sans-serif";
    ctx.fillText("LUMINA", 24, 36);
    ctx.fillStyle = pal.gold;
    ctx.font = "500 10px Manrope, sans-serif";
    ctx.fillText("A PIPE DREAM", 24, 52);

    ctx.textAlign = "center";
    ctx.fillStyle = pal.ink;
    ctx.font = "700 14px Unbounded, sans-serif";
    ctx.fillText(game.spec.name, w / 2, 32);
    ctx.fillStyle = pal.muted;
    ctx.font = "400 11px Manrope, sans-serif";
    ctx.fillText(game.spec.subtitle, w / 2, 48);

    const p = L.pause;
    round(ctx, p.x, p.y, p.w, p.h, 8);
    ctx.strokeStyle = pal.gold;
    ctx.stroke();
    ctx.fillStyle = pal.ink;
    ctx.fillRect(p.x + 14, p.y + 12, 5, 18);
    ctx.fillRect(p.x + 23, p.y + 12, 5, 18);

    const rx = L.bx + L.bw + 22;
    let ry = L.by;
    this.stat(rx, ry, "SCORE", String(game.score).padStart(6, "0"), pal);
    ry += 64;
    this.stat(rx, ry, "DISTANCE", `${game.distance} / ${game.min}`, pal);
    ry += 28;
    const pct = clamp(game.distance / Math.max(1, game.min), 0, 1);
    this.meter(rx, ry, 200, 12, pct, pal.flooz, pal);
    ry += 36;
    if (game.phase === "countdown") {
      this.stat(rx, ry, "VALVE", game.delayLeft.toFixed(1) + "s", pal);
      ry += 28;
      this.meter(
        rx,
        ry,
        200,
        12,
        game.delayLeft / Math.max(0.01, game.spec.delay),
        pal.gold,
        pal,
      );
    } else if (game.phase === "flowing") {
      const f = game.forecast();
      this.stat(
        rx,
        ry,
        f.danger ? "PRESSURE" : "SEALED",
        f.danger ? "OPEN END" : `${f.remaining} ahead`,
        pal,
        f.danger,
      );
    } else if (game.phase === "won") {
      this.stat(rx, ry, "STATUS", "CONTAINED", pal);
    } else if (game.phase === "lost") {
      this.stat(rx, ry, "STATUS", "OVERFLOW", pal, true);
    }
    ry += 56;
    this.mini(rx, ry, "CROSS", String(game.crossings), pal);
    this.mini(rx + 90, ry, "LOOPS", String(game.loops), pal);
    ry += 36;
    if (game.mode === "endless") {
      this.mini(rx, ry, "WAVE", String(game.endlessLevel), pal);
    }

    const rel = L.release;
    if (game.phase === "countdown") {
      round(ctx, rel.x, rel.y, rel.w, rel.h, 10);
      ctx.fillStyle = "rgba(224,180,92,0.12)";
      ctx.fill();
      ctx.strokeStyle = pal.gold;
      ctx.stroke();
      ctx.fillStyle = pal.gold;
      ctx.font = "700 13px Unbounded, sans-serif";
      ctx.textAlign = "center";
      ctx.fillText("OPEN VALVE  ␣", rel.x + rel.w / 2, rel.y + 30);
    }

    ctx.textAlign = "left";
    ctx.fillStyle = "rgba(180,200,210,0.45)";
    ctx.font = "400 11px Manrope, sans-serif";
    const hint = game.spec.bonusSlide
      ? "Click a pipe beside the gap to slide it  ·  Esc pause"
      : "Click to lay  ·  X dump  ·  Space open valve  ·  Esc pause";
    ctx.fillText(hint, 24, h - 14);

    for (const f of game.floaters) {
      const x = L.bx + f.c * L.tile + L.tile / 2;
      const y = L.by + f.r * L.tile + L.tile / 2 - f.age * 28;
      ctx.globalAlpha = clamp(1 - f.age / 1.15, 0, 1);
      ctx.fillStyle =
        f.kind === "loop"
          ? pal.gold
          : f.kind === "cross"
            ? pal.mag
            : f.kind === "warn"
              ? pal.danger
              : pal.flooz;
      ctx.font = "800 14px Unbounded, sans-serif";
      ctx.textAlign = "center";
      ctx.fillText(f.text, x, y);
      ctx.globalAlpha = 1;
    }
    if (this.banner) {
      const a = clamp(1 - (this.bannerAge - 1.1) / 0.7, 0, 1);
      ctx.save();
      ctx.globalAlpha = a;
      ctx.textAlign = "center";
      ctx.fillStyle = pal.gold;
      ctx.font = "800 36px Unbounded, sans-serif";
      ctx.fillText(this.banner, w / 2, h * 0.22);
      ctx.restore();
    }
    void settings;
  }

  private stat(
    x: number,
    y: number,
    k: string,
    v: string,
    pal: typeof PAL,
    danger = false,
  ): void {
    const ctx = this.ctx;
    ctx.textAlign = "left";
    ctx.fillStyle = pal.muted;
    ctx.font = "700 10px Unbounded, sans-serif";
    ctx.fillText(k, x, y);
    ctx.fillStyle = danger ? pal.danger : pal.ink;
    ctx.font = "700 22px Unbounded, sans-serif";
    ctx.fillText(v, x, y + 24);
  }

  private mini(x: number, y: number, k: string, v: string, pal: typeof PAL): void {
    const ctx = this.ctx;
    ctx.textAlign = "left";
    ctx.fillStyle = pal.muted;
    ctx.font = "700 9px Unbounded, sans-serif";
    ctx.fillText(k, x, y);
    ctx.fillStyle = pal.ink;
    ctx.font = "700 16px Unbounded, sans-serif";
    ctx.fillText(v, x, y + 18);
  }

  private meter(
    x: number,
    y: number,
    w: number,
    h: number,
    t: number,
    color: string,
    pal: typeof PAL,
  ): void {
    const ctx = this.ctx;
    round(ctx, x, y, w, h, h / 2);
    ctx.fillStyle = "rgba(255,255,255,0.06)";
    ctx.fill();
    ctx.save();
    round(ctx, x, y, w, h, h / 2);
    ctx.clip();
    ctx.fillStyle = color;
    ctx.fillRect(x, y, w * clamp(t, 0, 1), h);
    ctx.restore();
    ctx.strokeStyle = pal.metal1;
    ctx.stroke();
  }

  private drawGrain(w: number, h: number): void {
    const ctx = this.ctx;
    ctx.save();
    ctx.globalAlpha = 0.07;
    const p = ctx.createPattern(this.grain, "repeat");
    if (p) {
      ctx.fillStyle = p;
      ctx.fillRect(0, 0, w, h);
    }
    ctx.restore();
  }

  private drawVignette(w: number, h: number): void {
    const ctx = this.ctx;
    const g = ctx.createRadialGradient(
      w / 2,
      h / 2,
      Math.min(w, h) * 0.25,
      w / 2,
      h / 2,
      Math.max(w, h) * 0.72,
    );
    g.addColorStop(0, "rgba(0,0,0,0)");
    g.addColorStop(1, "rgba(0,0,0,0.55)");
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, w, h);
  }
}

function firstOpen(cell: Cell): Dir {
  const mask = openings(cell.piece ?? "block", cell.port);
  for (let d = 0; d < 4; d++) if (mask & BIT[d as Dir]) return d as Dir;
  return E;
}

function round(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  w: number,
  h: number,
  r: number,
): void {
  const rr = Math.min(r, w / 2, h / 2);
  ctx.beginPath();
  ctx.moveTo(x + rr, y);
  ctx.arcTo(x + w, y, x + w, y + h, rr);
  ctx.arcTo(x + w, y + h, x, y + h, rr);
  ctx.arcTo(x, y + h, x, y, rr);
  ctx.arcTo(x, y, x + w, y, rr);
  ctx.closePath();
}

function wrapText(
  ctx: CanvasRenderingContext2D,
  text: string,
  x: number,
  y: number,
  max: number,
  lh: number,
): void {
  const words = text.split(" ");
  let line = "";
  let yy = y;
  for (const w of words) {
    const test = line ? line + " " + w : w;
    if (ctx.measureText(test).width > max) {
      ctx.fillText(line, x, yy);
      line = w;
      yy += lh;
    } else line = test;
  }
  if (line) ctx.fillText(line, x, yy);
}
