import { converter, toGamut } from "culori";
import {
  Application,
  BufferImageSource,
  Container,
  Graphics,
  Sprite,
  Text,
  TextStyle,
  Texture,
} from "pixi.js";

type RGB = [number, number, number];

const toOklch = converter("oklch");
const toRgb = converter("rgb");
const gamutMap = toGamut("rgb", "oklch");

const COLORS: Array<{ label: string; rgb: RGB }> = [
  { label: "black", rgb: [0, 0, 0] },
  { label: "gray", rgb: [80, 80, 80] },
  { label: "white", rgb: [210, 210, 210] },
  { label: "red", rgb: [150, 25, 25] },
  { label: "orange", rgb: [170, 75, 15] },
  { label: "yellow", rgb: [170, 150, 15] },
  { label: "green", rgb: [25, 110, 35] },
  { label: "cyan", rgb: [15, 130, 150] },
  { label: "blue", rgb: [20, 35, 140] },
  { label: "purple", rgb: [60, 20, 100] },
  { label: "magenta", rgb: [140, 15, 110] },
];

const NOISE_DENSITY = 0.1;
const TILE_SIZE = 50;
const PAD = 12;
const LABEL_H = 16;
const AXIS_LABEL_H = LABEL_H + 4;
const TAB_H = 32;
const TAB_PAD_X = 14;
const DEMO_FONT_SIZE = 14;

function monoStyle(fill: number): TextStyle {
  return new TextStyle({
    fill,
    fontSize: DEMO_FONT_SIZE,
    fontFamily: "monospace",
  });
}

const AXIS_STYLE = monoStyle(0x666666);

const AXIS_LABEL_W = (() => {
  const maxW = Math.max(
    ...COLORS.map((c) => new Text({ text: c.label, style: AXIS_STYLE }).width),
  );
  return Math.ceil(maxW) + PAD;
})();

function rgbToNorm(c: RGB): { r: number; g: number; b: number; mode: "rgb" } {
  return { mode: "rgb", r: c[0] / 255, g: c[1] / 255, b: c[2] / 255 };
}

function normToRgb255(c: { r?: number; g?: number; b?: number }): RGB {
  return [
    Math.round((c.r ?? 0) * 255),
    Math.round((c.g ?? 0) * 255),
    Math.round((c.b ?? 0) * 255),
  ];
}

function oklchToRgb(oklch: NonNullable<ReturnType<typeof toOklch>>): RGB {
  return normToRgb255(toRgb(gamutMap(oklch)!)!);
}

/** Return bg unchanged and fg adjusted to match bg's oklch luminance. */
function equalLuminance(bg: RGB, fg: RGB): { bg: RGB; fg: RGB } {
  const bgOklch = toOklch(rgbToNorm(bg))!;
  const fgOklch = toOklch(rgbToNorm(fg))!;
  const fgMapped = gamutMap({ ...fgOklch, l: bgOklch.l ?? 0 })!;
  return { bg, fg: normToRgb255(toRgb(fgMapped)!) };
}

function rgbaBufferToTexture(
  data: Uint8Array,
  width: number,
  height: number,
): Texture {
  return new Texture({
    source: new BufferImageSource({
      resource: data,
      width,
      height,
      format: "rgba8unorm",
    }),
  });
}

function makeNoiseTileTexture(bg: RGB, fg: RGB, size: number): Texture {
  const d = new Uint8Array(size * size * 4);
  for (let i = 0; i < size * size; i++) {
    const c = Math.random() < NOISE_DENSITY ? fg : bg;
    d[i * 4] = c[0];
    d[i * 4 + 1] = c[1];
    d[i * 4 + 2] = c[2];
    d[i * 4 + 3] = 255;
  }
  return rgbaBufferToTexture(d, size, size);
}

function makeNoiseTileTexture3(
  c1: RGB,
  c2: RGB,
  c3: RGB,
  size: number,
  density = NOISE_DENSITY,
): Texture {
  const d = new Uint8Array(size * size * 4);
  for (let i = 0; i < size * size; i++) {
    const r = Math.random();
    const c = r < density / 2 ? c2 : r < density ? c3 : c1;
    d[i * 4] = c[0];
    d[i * 4 + 1] = c[1];
    d[i * 4 + 2] = c[2];
    d[i * 4 + 3] = 255;
  }
  return rgbaBufferToTexture(d, size, size);
}

function makeNoiseTileTextureDomino(
  bg: RGB,
  lighter: RGB,
  darker: RGB,
  size: number,
  density = NOISE_DENSITY,
): Texture {
  const d = new Uint8Array(size * size * 4);
  const used = new Uint8Array(size * size);

  const isFree = (x: number, y: number) =>
    x < size && y < size && !used[y * size + x];

  const claim = (x: number, y: number, c: RGB) => {
    used[y * size + x] = 1;
    const i = (y * size + x) * 4;
    d[i] = c[0];
    d[i + 1] = c[1];
    d[i + 2] = c[2];
    d[i + 3] = 255;
  };

  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      if (used[y * size + x]) continue;

      const r = Math.random();
      const c = r < density / 2 ? lighter : r < density ? darker : bg;
      claim(x, y, c);

      const canH = isFree(x + 1, y);
      const canV = isFree(x, y + 1);
      if (canH && canV ? Math.random() < 0.5 : canH) claim(x + 1, y, c);
      else if (canV) claim(x, y + 1, c);
    }
  }

  return rgbaBufferToTexture(d, size, size);
}

// All 7 tetrominoes × rotations, offsets from anchor, extending only right/down
const TETROMINO_SHAPES = [
  // I
  [
    [0, 0],
    [1, 0],
    [2, 0],
    [3, 0],
  ],
  [
    [0, 0],
    [0, 1],
    [0, 2],
    [0, 3],
  ],
  // O
  [
    [0, 0],
    [1, 0],
    [0, 1],
    [1, 1],
  ],
  // T
  [
    [0, 0],
    [1, 0],
    [2, 0],
    [1, 1],
  ],
  [
    [0, 0],
    [0, 1],
    [1, 1],
    [0, 2],
  ],
  [
    [1, 0],
    [0, 1],
    [1, 1],
    [2, 1],
  ],
  [
    [1, 0],
    [0, 1],
    [1, 1],
    [1, 2],
  ],
  // S
  [
    [1, 0],
    [2, 0],
    [0, 1],
    [1, 1],
  ],
  [
    [0, 0],
    [0, 1],
    [1, 1],
    [1, 2],
  ],
  // Z
  [
    [0, 0],
    [1, 0],
    [1, 1],
    [2, 1],
  ],
  [
    [1, 0],
    [0, 1],
    [1, 1],
    [0, 2],
  ],
  // L
  [
    [0, 0],
    [0, 1],
    [0, 2],
    [1, 2],
  ],
  [
    [0, 0],
    [1, 0],
    [2, 0],
    [0, 1],
  ],
  [
    [0, 0],
    [1, 0],
    [1, 1],
    [1, 2],
  ],
  [
    [2, 0],
    [0, 1],
    [1, 1],
    [2, 1],
  ],
  // J
  [
    [1, 0],
    [1, 1],
    [0, 2],
    [1, 2],
  ],
  [
    [0, 0],
    [0, 1],
    [1, 1],
    [2, 1],
  ],
  [
    [0, 0],
    [1, 0],
    [0, 1],
    [0, 2],
  ],
  [
    [0, 0],
    [1, 0],
    [2, 0],
    [2, 1],
  ],
] as const;

// L-triomino orientations as offsets from anchor, extending only right/down
const CORNER_SHAPES = [
  [
    [0, 0],
    [1, 0],
    [0, 1],
  ],
  [
    [0, 0],
    [1, 0],
    [1, 1],
  ],
  [
    [0, 0],
    [0, 1],
    [1, 1],
  ],
] as const;

type Shape = ReadonlyArray<readonly [number, number]>;

function makeNoiseTileTextureShapes(
  bg: RGB,
  lighter: RGB,
  darker: RGB,
  size: number,
  shapes: ReadonlyArray<Shape>,
  density = NOISE_DENSITY,
): Texture {
  const d = new Uint8Array(size * size * 4);
  const used = new Uint8Array(size * size);
  const order = Array.from({ length: shapes.length }, (_, i) => i);

  const isFree = (x: number, y: number) =>
    x < size && y < size && !used[y * size + x];

  const claim = (x: number, y: number, c: RGB) => {
    used[y * size + x] = 1;
    const i = (y * size + x) * 4;
    d[i] = c[0];
    d[i + 1] = c[1];
    d[i + 2] = c[2];
    d[i + 3] = 255;
  };

  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      if (used[y * size + x]) continue;

      const r = Math.random();
      const c = r < density / 2 ? lighter : r < density ? darker : bg;

      order.sort(() => Math.random() - 0.5);
      const shape = order
        .map((i) => shapes[i])
        .find((s) => s.every(([dx, dy]) => isFree(x + dx, y + dy)));

      if (shape) {
        for (const [dx, dy] of shape) claim(x + dx, y + dy, c);
      } else {
        claim(x, y, c);
        const canH = isFree(x + 1, y);
        const canV = isFree(x, y + 1);
        if (canH && canV ? Math.random() < 0.5 : canH) claim(x + 1, y, c);
        else if (canV) claim(x, y + 1, c);
      }
    }
  }

  return rgbaBufferToTexture(d, size, size);
}

function addRowLabel(
  container: Container,
  text: string,
  row: number,
  gridOriginY: number,
) {
  const lbl = new Text({ text, style: AXIS_STYLE });
  lbl.x = AXIS_LABEL_W - lbl.width;
  lbl.y = gridOriginY + row * (TILE_SIZE + PAD) + (TILE_SIZE - LABEL_H) / 2;
  container.addChild(lbl);
}

function buildGrid(
  getPair: (bg: RGB, fg: RGB) => { bg: RGB; fg: RGB },
): Container {
  const container = new Container();

  const gridOriginX = AXIS_LABEL_W;
  const gridOriginY = AXIS_LABEL_H;

  COLORS.forEach(({ label }, col) => {
    const lbl = new Text({ text: label, style: AXIS_STYLE });
    lbl.x = gridOriginX + col * (TILE_SIZE + PAD);
    lbl.y = 0;
    container.addChild(lbl);
  });

  COLORS.forEach(({ label: bgLabel, rgb: bgRgb }, row) => {
    addRowLabel(container, bgLabel, row, gridOriginY);

    COLORS.forEach(({ rgb: fgRgb }, col) => {
      const pair = getPair(bgRgb, fgRgb);
      const sprite = new Sprite(
        makeNoiseTileTexture(pair.bg, pair.fg, TILE_SIZE),
      );
      sprite.x = gridOriginX + col * (TILE_SIZE + PAD);
      sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
      container.addChild(sprite);
    });
  });

  return container;
}

const CHROMA_OFFSETS = Array.from({ length: COLORS.length }, (_, i) =>
  parseFloat((-0.2 + (i / (COLORS.length - 1)) * 0.4).toFixed(4)),
);

const THREE_TONE_OFFSETS = Array.from({ length: COLORS.length }, (_, i) =>
  parseFloat(((i / (COLORS.length - 1)) * 0.2).toFixed(4)),
);

const LUMINANCE_LEVELS = Array.from({ length: COLORS.length }, (_, i) =>
  parseFloat((i / (COLORS.length - 1)).toFixed(4)),
);

const THREE_TONE_LUM_OFFSET = 0.12;

const PAGE_BG_L = toOklch(rgbToNorm([17, 17, 17]))!.l ?? 0;
const NOISE_ALG_FILL = 0.07;
const FILL_FRACTIONS = Array.from({ length: COLORS.length }, (_, i) =>
  parseFloat(((i / (COLORS.length - 1)) * 0.33).toFixed(4)),
);

function addChromaSubgrid(
  container: Container,
  originY: number,
  offsets: number[],
  makeTile: (
    bgRgb: RGB,
    bgOklch: ReturnType<typeof toOklch>,
    offset: number,
  ) => Texture,
) {
  const gridOriginX = AXIS_LABEL_W;
  const gridOriginY = originY + AXIS_LABEL_H;

  offsets.forEach((offset, col) => {
    const pct = Math.round(offset * 100);
    const lbl = new Text({
      text: pct > 0 ? `+${pct}` : `${pct}`,
      style: AXIS_STYLE,
    });
    lbl.x = gridOriginX + col * (TILE_SIZE + PAD);
    lbl.y = originY;
    container.addChild(lbl);
  });

  COLORS.forEach(({ label: bgLabel, rgb: bgRgb }, row) => {
    addRowLabel(container, bgLabel, row, gridOriginY);

    const bgOklch = toOklch(rgbToNorm(bgRgb))!;

    offsets.forEach((offset, col) => {
      const sprite = new Sprite(makeTile(bgRgb, bgOklch, offset));
      sprite.x = gridOriginX + col * (TILE_SIZE + PAD);
      sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
      container.addChild(sprite);
    });
  });
}

function buildEqualChromaGrid(): Container {
  const container = new Container();
  addChromaSubgrid(container, 0, CHROMA_OFFSETS, (bgRgb, bgOklch, offset) => {
    const fgL = Math.max(0, Math.min(1, (bgOklch!.l ?? 0) + offset));
    const fg = oklchToRgb({ ...bgOklch!, l: fgL });
    return makeNoiseTileTexture(bgRgb, fg, TILE_SIZE);
  });
  return container;
}

function buildThreeToneGrid(): Container {
  const container = new Container();
  addChromaSubgrid(
    container,
    0,
    THREE_TONE_OFFSETS,
    (bgRgb, bgOklch, offset) => {
      const lighterL = Math.min(1, (bgOklch!.l ?? 0) + offset);
      const darkerL = Math.max(0, (bgOklch!.l ?? 0) - offset);
      const lighter = oklchToRgb({ ...bgOklch!, l: lighterL });
      const darker = oklchToRgb({ ...bgOklch!, l: darkerL });
      return makeNoiseTileTexture3(bgRgb, lighter, darker, TILE_SIZE);
    },
  );
  return container;
}

function buildLuminanceSweepGrid(): Container {
  const container = new Container();

  const gridOriginX = AXIS_LABEL_W;
  const gridOriginY = AXIS_LABEL_H;

  LUMINANCE_LEVELS.forEach((l, col) => {
    const lbl = new Text({
      text: `${Math.round(l * 100)}`,
      style: AXIS_STYLE,
    });
    lbl.x = gridOriginX + col * (TILE_SIZE + PAD);
    lbl.y = 0;
    container.addChild(lbl);
  });

  COLORS.forEach(({ label: colorLabel, rgb: colorRgb }, row) => {
    addRowLabel(container, colorLabel, row, gridOriginY);

    const baseOklch = toOklch(rgbToNorm(colorRgb))!;

    LUMINANCE_LEVELS.forEach((l, col) => {
      const bgRgb = oklchToRgb({ ...baseOklch, l });
      const lighterL = Math.min(1, l + THREE_TONE_LUM_OFFSET);
      const darkerL = Math.max(0, l - THREE_TONE_LUM_OFFSET);
      const lighter = oklchToRgb({ ...baseOklch, l: lighterL });
      const darker = oklchToRgb({ ...baseOklch, l: darkerL });
      const sprite = new Sprite(
        makeNoiseTileTexture3(bgRgb, lighter, darker, TILE_SIZE),
      );
      sprite.x = gridOriginX + col * (TILE_SIZE + PAD);
      sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
      container.addChild(sprite);
    });
  });

  return container;
}

function buildFillFractionGrid(): Container {
  const container = new Container();

  const gridOriginX = AXIS_LABEL_W;
  const gridOriginY = AXIS_LABEL_H;

  FILL_FRACTIONS.forEach((frac, col) => {
    const lbl = new Text({
      text: `${Math.round(frac * 100)}%`,
      style: AXIS_STYLE,
    });
    lbl.x = gridOriginX + col * (TILE_SIZE + PAD);
    lbl.y = 0;
    container.addChild(lbl);
  });

  COLORS.forEach(({ label: colorLabel, rgb: colorRgb }, row) => {
    addRowLabel(container, colorLabel, row, gridOriginY);

    const baseOklch = toOklch(rgbToNorm(colorRgb))!;
    const bgRgb = oklchToRgb({ ...baseOklch, l: PAGE_BG_L });
    const lighterL = Math.min(1, PAGE_BG_L + THREE_TONE_LUM_OFFSET);
    const darkerL = Math.max(0, PAGE_BG_L - THREE_TONE_LUM_OFFSET);
    const lighter = oklchToRgb({ ...baseOklch, l: lighterL });
    const darker = oklchToRgb({ ...baseOklch, l: darkerL });

    FILL_FRACTIONS.forEach((frac, col) => {
      const sprite = new Sprite(
        makeNoiseTileTexture3(bgRgb, lighter, darker, TILE_SIZE, frac * 2),
      );
      sprite.x = gridOriginX + col * (TILE_SIZE + PAD);
      sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
      container.addChild(sprite);
    });
  });

  return container;
}

function noiseStats(lumOffset: number, fillFraction: number) {
  const sigma = lumOffset * Math.sqrt(2 * fillFraction);
  return { sigma, uniformRange: sigma * Math.sqrt(3) };
}

function sampleNormal(): number {
  return (
    Math.sqrt(-2 * Math.log(Math.random())) *
    Math.cos(2 * Math.PI * Math.random())
  );
}

type NoiseContext = {
  bg: RGB;
  lighter: RGB;
  darker: RGB;
  mapL: (l: number) => RGB;
  sigma: number;
  uniformRange: number;
  centerL: number;
  density: number;
};

type NoiseAlgorithm = {
  label: string;
  generate: (ctx: NoiseContext, size: number) => Texture;
};

function makeNoiseTileTextureContinuous(
  mapL: (l: number) => RGB,
  sampleL: () => number,
  size: number,
): Texture {
  const d = new Uint8Array(size * size * 4);
  for (let i = 0; i < size * size; i++) {
    const c = mapL(sampleL());
    d[i * 4] = c[0];
    d[i * 4 + 1] = c[1];
    d[i * 4 + 2] = c[2];
    d[i * 4 + 3] = 255;
  }
  return rgbaBufferToTexture(d, size, size);
}

const NOISE_DENSITY_ALG = NOISE_ALG_FILL * 2;

const NOISE_ALGORITHMS: NoiseAlgorithm[] = [
  {
    label: "none",
    generate: ({ bg }, size) => makeNoiseTileTexture(bg, bg, size),
  },
  {
    label: "random",
    generate: ({ bg, lighter, darker, density }, size) =>
      makeNoiseTileTexture3(bg, lighter, darker, size, density),
  },
  {
    label: "domino",
    generate: ({ bg, lighter, darker, density }, size) =>
      makeNoiseTileTextureDomino(bg, lighter, darker, size, density),
  },
  {
    label: "corner",
    generate: ({ bg, lighter, darker, density }, size) =>
      makeNoiseTileTextureShapes(
        bg,
        lighter,
        darker,
        size,
        CORNER_SHAPES,
        density,
      ),
  },
  {
    label: "tetromino",
    generate: ({ bg, lighter, darker, density }, size) =>
      makeNoiseTileTextureShapes(
        bg,
        lighter,
        darker,
        size,
        TETROMINO_SHAPES,
        density,
      ),
  },
  {
    label: "uniform",
    generate: ({ mapL, uniformRange, centerL }, size) =>
      makeNoiseTileTextureContinuous(
        mapL,
        () => centerL + (Math.random() * 2 - 1) * uniformRange,
        size,
      ),
  },
  {
    label: "normal",
    generate: ({ mapL, sigma, centerL }, size) =>
      makeNoiseTileTextureContinuous(
        mapL,
        () => centerL + sampleNormal() * sigma,
        size,
      ),
  },
];

function buildNoiseAlgorithmGrid(): Container {
  const container = new Container();

  const gridOriginX = AXIS_LABEL_W;
  const gridOriginY = AXIS_LABEL_H;

  const colLabels = NOISE_ALGORITHMS.map(
    ({ label }) => new Text({ text: label, style: AXIS_STYLE }),
  );
  const maxColLabelW = Math.max(...colLabels.map((l) => l.width));
  const colStride = Math.max(TILE_SIZE + PAD, Math.ceil(maxColLabelW) + PAD);

  colLabels.forEach((lbl, col) => {
    lbl.x = gridOriginX + col * colStride;
    lbl.y = 0;
    container.addChild(lbl);
  });

  const { sigma, uniformRange } = noiseStats(
    THREE_TONE_LUM_OFFSET,
    NOISE_ALG_FILL,
  );

  COLORS.forEach(({ label: colorLabel, rgb: colorRgb }, row) => {
    addRowLabel(container, colorLabel, row, gridOriginY);

    const baseOklch = toOklch(rgbToNorm(colorRgb))!;
    const mapL = (l: number): RGB =>
      oklchToRgb({ ...baseOklch, l: Math.max(0, Math.min(1, l)) });
    const ctx: NoiseContext = {
      bg: mapL(PAGE_BG_L),
      lighter: mapL(PAGE_BG_L + THREE_TONE_LUM_OFFSET),
      darker: mapL(PAGE_BG_L - THREE_TONE_LUM_OFFSET),
      mapL,
      sigma,
      uniformRange,
      centerL: PAGE_BG_L,
      density: NOISE_DENSITY_ALG,
    };

    NOISE_ALGORITHMS.forEach(({ generate }, col) => {
      const sprite = new Sprite(generate(ctx, TILE_SIZE));
      sprite.x = gridOriginX + col * colStride;
      sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
      container.addChild(sprite);
    });
  });

  return container;
}

// ─── Explorer Tab ────────────────────────────────────────────────────────────

const EXPLORER_GRID_SIZE = 20;
const CONTROL_ROW_H = 22;
const CONTROL_ROW_GAP = 8;
const CONTROL_LABEL_W = 90;
const EXPLORER_STORAGE_PREFIX = "scratchProceduralTexture.explorer.";

const EXPLORER_LUM_STEPS = [
  PAGE_BG_L,
  0.1,
  0.15,
  0.2,
  0.3,
  0.4,
  0.5,
  0.6,
  0.7,
  0.8,
  0.9,
  1.0,
];
const EXPLORER_RANGE_STEPS = [0.02, 0.05, 0.08, 0.12, 0.16, 0.2];
const EXPLORER_FILL_STEPS = [
  0.007, 0.01, 0.014, 0.02, 0.05, 0.07, 0.1, 0.15, 0.2, 0.33,
];
const EXPLORER_TILE_COUNTS = [1, 2, 4, 8, 16];

function buildSelectorRow(
  label: string,
  options: string[],
  initialIndex: number,
  onChange: (i: number) => void,
  makeTile?: (i: number) => Texture,
): {
  container: Container;
  setIndex: (i: number) => void;
  refreshTiles: (newMakeTile: (i: number) => Texture) => void;
  rowHeight: number;
} {
  const hasTiles = makeTile !== undefined;
  const rowHeight = hasTiles ? TILE_SIZE + LABEL_H + 4 : CONTROL_ROW_H;

  const row = new Container();
  const dimStyle = monoStyle(0x666666);
  const activeStyle = monoStyle(0xdddddd);

  let current = initialIndex;
  const optionTexts: Text[] = [];
  const tileSprites: Sprite[] = [];
  const tileTextures: Texture[] = [];
  const tileBorders: { gfx: Graphics; spriteX: number }[] = [];

  function drawTileBorder(
    entry: { gfx: Graphics; spriteX: number },
    active: boolean,
  ) {
    entry.gfx.clear();
    if (active) {
      entry.gfx
        .rect(entry.spriteX - 1, -1, TILE_SIZE + 2, TILE_SIZE + 2)
        .stroke({ color: 0xaaaaaa, width: 2 });
    }
  }

  const lbl = new Text({ text: label + ":", style: dimStyle });
  row.addChild(lbl);

  let x = CONTROL_LABEL_W;
  options.forEach((opt, i) => {
    const txt = new Text({
      text: opt,
      style: i === current ? activeStyle : dimStyle,
    });

    const colW = Math.ceil(
      hasTiles ? Math.max(TILE_SIZE, txt.width + 4) : txt.width + 8,
    );

    if (hasTiles) {
      const spriteX = x + Math.round((colW - TILE_SIZE) / 2);

      const tex = makeTile!(i);
      tileTextures.push(tex);
      const sprite = new Sprite(tex);
      sprite.x = spriteX;
      row.addChild(sprite);
      tileSprites.push(sprite);

      const borderGfx = new Graphics();
      const borderEntry = { gfx: borderGfx, spriteX };
      tileBorders.push(borderEntry);
      drawTileBorder(borderEntry, i === current);
      row.addChild(borderGfx);
      txt.x = x + Math.round((colW - txt.width) / 2);
      txt.y = TILE_SIZE + 4;
    } else {
      txt.x = x;
    }
    row.addChild(txt);
    optionTexts.push(txt);

    const hit = new Graphics()
      .rect(hasTiles ? x : x - 4, -2, colW, rowHeight + 2)
      .fill({ color: 0, alpha: 0 });
    hit.eventMode = "static";
    hit.cursor = "pointer";
    hit.on("pointerdown", () => {
      if (current === i) return;
      optionTexts[current].style = dimStyle;
      if (tileBorders[current]) drawTileBorder(tileBorders[current], false);
      current = i;
      optionTexts[current].style = activeStyle;
      if (tileBorders[current]) drawTileBorder(tileBorders[current], true);
      onChange(i);
    });
    row.addChild(hit);

    x += hasTiles ? colW + PAD : colW + 2;
  });

  const setIndex = (i: number) => {
    if (i === current) return;
    optionTexts[current].style = dimStyle;
    if (tileBorders[current]) drawTileBorder(tileBorders[current], false);
    current = i;
    optionTexts[current].style = activeStyle;
    if (tileBorders[current]) drawTileBorder(tileBorders[current], true);
  };

  const refreshTiles = (newMakeTile: (i: number) => Texture) => {
    options.forEach((_, i) => {
      tileTextures[i]?.destroy(true);
      const newTex = newMakeTile(i);
      tileTextures[i] = newTex;
      if (tileSprites[i]) tileSprites[i].texture = newTex;
    });
  };

  return { container: row, setIndex, refreshTiles, rowHeight };
}

function buildExplorerTab(): Container {
  const container = new Container();

  const storageGet = (key: string, def: number, max: number): number => {
    const v = localStorage.getItem(EXPLORER_STORAGE_PREFIX + key);
    if (v === null) return def;
    const n = parseInt(v, 10);
    return isNaN(n) ? def : Math.max(0, Math.min(max, n));
  };
  const storageSave = (key: string, v: number) =>
    localStorage.setItem(EXPLORER_STORAGE_PREFIX + key, String(v));

  const state = {
    colorIndex: storageGet(
      "color",
      Math.max(
        0,
        COLORS.findIndex((c) => c.label === "cyan"),
      ),
      COLORS.length - 1,
    ),
    lumIndex: storageGet("lum", 0, EXPLORER_LUM_STEPS.length - 1),
    rangeIndex: storageGet(
      "range",
      Math.max(0, EXPLORER_RANGE_STEPS.indexOf(THREE_TONE_LUM_OFFSET)),
      EXPLORER_RANGE_STEPS.length - 1,
    ),
    fillIndex: storageGet(
      "fill",
      Math.max(0, EXPLORER_FILL_STEPS.indexOf(NOISE_ALG_FILL)),
      EXPLORER_FILL_STEPS.length - 1,
    ),
    algoIndex: storageGet(
      "algo",
      Math.max(
        0,
        NOISE_ALGORITHMS.findIndex((a) => a.label === "tetromino"),
      ),
      NOISE_ALGORITHMS.length - 1,
    ),
    tileCountIndex: storageGet("tileCount", 2, EXPLORER_TILE_COUNTS.length - 1),
  };

  function buildCtx(
    cIdx: number,
    lIdx: number,
    rIdx: number,
    fIdx: number,
  ): NoiseContext {
    const { rgb: colorRgb } = COLORS[cIdx];
    const lum = EXPLORER_LUM_STEPS[lIdx];
    const range = EXPLORER_RANGE_STEPS[rIdx];
    const fill = EXPLORER_FILL_STEPS[fIdx];
    const baseOklch = toOklch(rgbToNorm(colorRgb))!;
    const mapL = (l: number): RGB =>
      oklchToRgb({ ...baseOklch, l: Math.max(0, Math.min(1, l)) });
    const { sigma, uniformRange } = noiseStats(range, fill);
    return {
      bg: mapL(lum),
      lighter: mapL(lum + range),
      darker: mapL(lum - range),
      mapL,
      sigma,
      uniformRange,
      centerL: lum,
      density: fill * 2,
    };
  }

  const makeColorTile = (i: number) =>
    NOISE_ALGORITHMS[state.algoIndex].generate(
      buildCtx(i, state.lumIndex, state.rangeIndex, state.fillIndex),
      TILE_SIZE,
    );
  const makeLumTile = (i: number) =>
    NOISE_ALGORITHMS[state.algoIndex].generate(
      buildCtx(state.colorIndex, i, state.rangeIndex, state.fillIndex),
      TILE_SIZE,
    );
  const makeRangeTile = (i: number) =>
    NOISE_ALGORITHMS[state.algoIndex].generate(
      buildCtx(state.colorIndex, state.lumIndex, i, state.fillIndex),
      TILE_SIZE,
    );
  const makeFillTile = (i: number) =>
    NOISE_ALGORITHMS[state.algoIndex].generate(
      buildCtx(state.colorIndex, state.lumIndex, state.rangeIndex, i),
      TILE_SIZE,
    );
  const makeAlgoTile = (i: number) =>
    NOISE_ALGORITHMS[i].generate(
      buildCtx(
        state.colorIndex,
        state.lumIndex,
        state.rangeIndex,
        state.fillIndex,
      ),
      TILE_SIZE,
    );

  type RefreshFn = (m: (i: number) => Texture) => void;
  let refreshColorTiles: RefreshFn = () => {};
  let refreshLumTiles: RefreshFn = () => {};
  let refreshRangeTiles: RefreshFn = () => {};
  let refreshFillTiles: RefreshFn = () => {};
  let refreshAlgoTiles: RefreshFn = () => {};

  const tilesetContainer = new Container();
  const gridContainer = new Container();
  let currentTiles: Texture[] = [];

  function regenerate() {
    for (const tex of currentTiles) tex.destroy(true);
    currentTiles = [];
    tilesetContainer.removeChildren();
    gridContainer.removeChildren();

    const tileCount = EXPLORER_TILE_COUNTS[state.tileCountIndex];
    const algo = NOISE_ALGORITHMS[state.algoIndex];
    const noiseCtx = buildCtx(
      state.colorIndex,
      state.lumIndex,
      state.rangeIndex,
      state.fillIndex,
    );

    currentTiles = Array.from({ length: tileCount }, () =>
      algo.generate(noiseCtx, TILE_SIZE),
    );

    currentTiles.forEach((tex, i) => {
      const sprite = new Sprite(tex);
      sprite.x = i * (TILE_SIZE + PAD);
      tilesetContainer.addChild(sprite);
    });

    for (let row = 0; row < EXPLORER_GRID_SIZE; row++) {
      for (let col = 0; col < EXPLORER_GRID_SIZE; col++) {
        const tex =
          currentTiles[Math.floor(Math.random() * currentTiles.length)];
        const sprite = new Sprite(tex);
        sprite.x = col * TILE_SIZE;
        sprite.y = row * TILE_SIZE;
        gridContainer.addChild(sprite);
      }
    }

    refreshColorTiles(makeColorTile);
    refreshLumTiles(makeLumTile);
    refreshRangeTiles(makeRangeTile);
    refreshFillTiles(makeFillTile);
    refreshAlgoTiles(makeAlgoTile);
  }

  const controlsContainer = new Container();
  container.addChild(controlsContainer);

  let rowY = 0;
  function addRow(result: { container: Container; rowHeight: number }) {
    result.container.y = rowY;
    controlsContainer.addChild(result.container);
    rowY += result.rowHeight + CONTROL_ROW_GAP;
  }

  const colorRow = buildSelectorRow(
    "color",
    COLORS.map((c) => c.label),
    state.colorIndex,
    (i) => {
      state.colorIndex = i;
      storageSave("color", i);
      regenerate();
    },
    makeColorTile,
  );
  addRow(colorRow);
  const setColor = colorRow.setIndex;
  refreshColorTiles = colorRow.refreshTiles;

  const lumRow = buildSelectorRow(
    "lum",
    EXPLORER_LUM_STEPS.map((v, i) => (i === 0 ? "bg" : v.toFixed(2))),
    state.lumIndex,
    (i) => {
      state.lumIndex = i;
      storageSave("lum", i);
      regenerate();
    },
    makeLumTile,
  );
  addRow(lumRow);
  const setLum = lumRow.setIndex;
  refreshLumTiles = lumRow.refreshTiles;

  const rangeRow = buildSelectorRow(
    "range",
    EXPLORER_RANGE_STEPS.map((v) => v.toFixed(2)),
    state.rangeIndex,
    (i) => {
      state.rangeIndex = i;
      storageSave("range", i);
      regenerate();
    },
    makeRangeTile,
  );
  addRow(rangeRow);
  const setRange = rangeRow.setIndex;
  refreshRangeTiles = rangeRow.refreshTiles;

  const fillRow = buildSelectorRow(
    "fill",
    EXPLORER_FILL_STEPS.map((v) => {
      const pct = v * 100;
      return pct < 2
        ? `${parseFloat(pct.toPrecision(2))}%`
        : `${Math.round(pct)}%`;
    }),
    state.fillIndex,
    (i) => {
      state.fillIndex = i;
      storageSave("fill", i);
      regenerate();
    },
    makeFillTile,
  );
  addRow(fillRow);
  const setFill = fillRow.setIndex;
  refreshFillTiles = fillRow.refreshTiles;

  const algoRow = buildSelectorRow(
    "algo",
    NOISE_ALGORITHMS.map((a) => a.label),
    state.algoIndex,
    (i) => {
      state.algoIndex = i;
      storageSave("algo", i);
      regenerate();
    },
    makeAlgoTile,
  );
  addRow(algoRow);
  const setAlgo = algoRow.setIndex;
  refreshAlgoTiles = algoRow.refreshTiles;

  const tilesRow = buildSelectorRow(
    "tiles",
    EXPLORER_TILE_COUNTS.map(String),
    state.tileCountIndex,
    (i) => {
      state.tileCountIndex = i;
      storageSave("tileCount", i);
      regenerate();
    },
  );
  addRow(tilesRow);
  const setTileCount = tilesRow.setIndex;

  const NOISE_ALG_PRESET = {
    colorIndex: Math.max(
      0,
      COLORS.findIndex((c) => c.label === "cyan"),
    ),
    lumIndex: 0,
    rangeIndex: Math.max(
      0,
      EXPLORER_RANGE_STEPS.indexOf(THREE_TONE_LUM_OFFSET),
    ),
    fillIndex: Math.max(0, EXPLORER_FILL_STEPS.indexOf(NOISE_ALG_FILL)),
    algoIndex: Math.max(
      0,
      NOISE_ALGORITHMS.findIndex((a) => a.label === "tetromino"),
    ),
    tileCountIndex: state.tileCountIndex,
  };

  function applyPreset() {
    setColor(NOISE_ALG_PRESET.colorIndex);
    setLum(NOISE_ALG_PRESET.lumIndex);
    setRange(NOISE_ALG_PRESET.rangeIndex);
    setFill(NOISE_ALG_PRESET.fillIndex);
    setAlgo(NOISE_ALG_PRESET.algoIndex);
    setTileCount(NOISE_ALG_PRESET.tileCountIndex);
    Object.assign(state, NOISE_ALG_PRESET);
    for (const [k, v] of Object.entries(NOISE_ALG_PRESET))
      storageSave(k.replace("Index", ""), v);
    regenerate();
  }

  const regenStyle = monoStyle(0x555555);
  const regenHoverStyle = monoStyle(0xdddddd);
  function makeBtn(text: string, onClick: () => void, x: number) {
    const btn = new Text({ text, style: regenStyle });
    btn.x = x;
    btn.y = rowY;
    btn.eventMode = "static";
    btn.cursor = "pointer";
    btn.on("pointerover", () => {
      btn.style = regenHoverStyle;
    });
    btn.on("pointerout", () => {
      btn.style = regenStyle;
    });
    btn.on("pointerdown", onClick);
    controlsContainer.addChild(btn);
    return btn;
  }

  const regenBtn = makeBtn("[ regenerate ]", regenerate, CONTROL_LABEL_W);
  const presetBtn = makeBtn(
    "[ tetromino cyan ]",
    applyPreset,
    regenBtn.x + Math.ceil(regenBtn.width) + 16,
  );
  const copyBtn = makeBtn(
    "[ copy params ]",
    () => {
      const params = {
        color: COLORS[state.colorIndex].label,
        rgb: COLORS[state.colorIndex].rgb,
        lum: EXPLORER_LUM_STEPS[state.lumIndex],
        range: EXPLORER_RANGE_STEPS[state.rangeIndex],
        fill: EXPLORER_FILL_STEPS[state.fillIndex],
        algo: NOISE_ALGORITHMS[state.algoIndex].label,
        tileCount: EXPLORER_TILE_COUNTS[state.tileCountIndex],
      };
      navigator.clipboard.writeText(JSON.stringify(params, null, 2));
      copyBtn.text = "[   copied!   ]";
      setTimeout(() => {
        copyBtn.text = "[ copy params ]";
      }, 1500);
    },
    presetBtn.x + Math.ceil(presetBtn.width) + 16,
  );
  rowY += CONTROL_ROW_H + CONTROL_ROW_GAP;

  const controlsH = rowY;

  const tilemapContainer = new Container();
  container.addChild(tilemapContainer);

  const modeLabel = new Text({ text: "", style: monoStyle(0x555555) });
  tilemapContainer.addChild(modeLabel);

  tilesetContainer.y = CONTROL_ROW_H + 8;
  tilemapContainer.addChild(tilesetContainer);

  gridContainer.y = CONTROL_ROW_H + 8 + TILE_SIZE + PAD;
  tilemapContainer.addChild(gridContainer);

  let currentLayoutMode = "";
  function applyLayout() {
    const isLandscape = window.innerWidth > window.innerHeight;
    const mode = isLandscape ? "landscape" : "portrait";
    if (mode === currentLayoutMode) return;
    currentLayoutMode = mode;
    modeLabel.text = mode;
    if (isLandscape) {
      const cw = Math.ceil(controlsContainer.width);
      tilemapContainer.x = Math.round(cw + PAD);
      tilemapContainer.y = 0;
    } else {
      tilemapContainer.x = 0;
      tilemapContainer.y = Math.round(controlsH + PAD);
    }
  }

  applyLayout();
  window.addEventListener("resize", applyLayout);

  regenerate();
  return container;
}

function buildTabs(
  app: Application,
  tabs: Array<{ label: string; build: () => Container }>,
  defaultLabel = tabs[0].label,
) {
  const activeStyle = monoStyle(0xdddddd);
  const dimStyle = monoStyle(0x555555);

  const STORAGE_KEY = "scratchProceduralTexture.activeTab";
  const savedLabel = localStorage.getItem(STORAGE_KEY);
  const savedIndex = tabs.findIndex((t) => t.label === savedLabel);

  const tabBar = new Container();
  app.stage.addChild(tabBar);

  const contentY = TAB_H;
  const defaultIndex = Math.max(
    0,
    tabs.findIndex((t) => t.label === defaultLabel),
  );
  let activeIndex = savedIndex >= 0 ? savedIndex : defaultIndex;

  const contents: (Container | null)[] = tabs.map(() => null);

  function getContent(i: number): Container {
    if (!contents[i]) {
      const c = tabs[i].build();
      c.x = PAD;
      c.y = contentY + PAD;
      c.visible = false;
      app.stage.addChild(c);
      contents[i] = c;
    }
    return contents[i]!;
  }

  getContent(activeIndex).visible = true;

  const bg = new Graphics();
  tabBar.addChild(bg);

  const tabLabels: Text[] = [];
  let x = PAD;

  tabs.forEach(({ label }, i) => {
    const lbl = new Text({
      text: label,
      style: i === activeIndex ? activeStyle : dimStyle,
    });
    lbl.x = Math.round(x + TAB_PAD_X);
    lbl.y = Math.round((TAB_H - lbl.height) / 2);

    const tabW = lbl.width + TAB_PAD_X * 2;

    const hit = new Graphics()
      .rect(x, 0, tabW, TAB_H)
      .fill({ color: 0, alpha: 0 });
    hit.eventMode = "static";
    hit.cursor = "pointer";
    hit.on("pointerdown", () => {
      getContent(activeIndex).visible = false;
      tabLabels[activeIndex].style = dimStyle;
      activeIndex = i;
      getContent(activeIndex).visible = true;
      tabLabels[activeIndex].style = activeStyle;
      localStorage.setItem(STORAGE_KEY, tabs[activeIndex].label);
      redrawBg();
    });

    tabBar.addChild(hit);
    tabBar.addChild(lbl);
    tabLabels.push(lbl);
    x += tabW;
  });

  const tabPositions = tabLabels.map((lbl) => ({
    x: lbl.x - TAB_PAD_X,
    w: lbl.width + TAB_PAD_X * 2,
  }));

  function redrawBg() {
    bg.clear();
    tabPositions.forEach(({ x: tx, w }, i) => {
      bg.rect(tx, 0, w, TAB_H).fill(i === activeIndex ? 0x222222 : 0x111111);
    });
  }

  redrawBg();
}

async function setup(app: Application) {
  await app.init({ background: "#111111", resizeTo: window, antialias: true });
  document.getElementById("pixi-container")!.appendChild(app.canvas);
}

(async () => {
  const app = new Application();
  await setup(app);

  buildTabs(
    app,
    [
      { label: "original", build: () => buildGrid((bg, fg) => ({ bg, fg })) },
      {
        label: "equal luminance",
        build: () => buildGrid((bg, fg) => equalLuminance(bg, fg)),
      },
      { label: "equal chroma", build: () => buildEqualChromaGrid() },
      { label: "three-tone", build: () => buildThreeToneGrid() },
      { label: "luminance", build: () => buildLuminanceSweepGrid() },
      { label: "fill fraction", build: () => buildFillFractionGrid() },
      { label: "noise", build: () => buildNoiseAlgorithmGrid() },
      { label: "explorer", build: () => buildExplorerTab() },
    ],
    "explorer",
  );
})();