/**
 * Every surface texture in the library, drawn at runtime on a 2D canvas.
 *
 * Nothing here is fetched, imported or base64'd, and that is a licensing
 * decision before it is a graphics one (CONTRACT.md §3): a repo that ships no
 * binary art has nothing in it whose provenance anyone has to take on trust.
 *
 * Three properties are load-bearing and easy to lose:
 *
 *   1. **Textures are neutral, not coloured.** Each one is drawn near-white and
 *      modulates *downward*, so `material.color` supplies the hue and the map
 *      supplies only the grain. One `carpetLoop` texture therefore serves every
 *      palette. Baking the colour in would have made the cache key
 *      `kind × colour` and given a self-hoster who recolours their world eight
 *      new uploads to the GPU for no visible gain.
 *   2. **They tile.** Value noise is not periodic, so a naively-drawn 2 m tile
 *      shows a hard seam every 2 m across a floor. `tileableNoise` blends the
 *      four wrapped samples so the edges match; see the comment there.
 *   3. **They are cheap to build.** The noise is evaluated on a coarse grid and
 *      bilinearly upsampled rather than per-pixel — four `fbm` calls per pixel
 *      at 512² is about sixteen million `Math.sin` calls and roughly a second
 *      of blocked main thread, which is not a price a floor is worth.
 *
 * The noise itself is the engine's — `fbm` and `seededRandom` are imported from
 * `engine/world.ts` rather than reimplemented, so the city and the office are
 * grained by the same function.
 */

import * as THREE from "three";
import { fbm, seededRandom } from "../engine/world.ts";

/**
 * How many metres one repeat of a texture covers.
 *
 * Geometry that carries a textured surface must therefore generate UVs in
 * metres divided by this — `PartBin.metricQuad` and `PartBin.wallRun` do, which
 * is why a 12 m wall and a 3 m wall show the same size of grain. Unit
 * primitives with 0..1 UVs will stretch, and are only used for roles that carry
 * no map.
 */
export const TEXTURE_TILE_METRES = 2;

export type TextureKind =
  | "carpetLoop"
  | "woodPlank"
  | "polishedConcrete"
  | "ceilingTile"
  | "plasterPaint"
  | "fabricWeave"
  | "tileGrid"
  | "whiteboard";

export type TextureQuality = "low" | "medium" | "high";

/** `low` draws nothing at all: the materials fall back to flat colour. */
const RESOLUTION: Record<TextureQuality, number> = { low: 0, medium: 256, high: 512 };

// ---- Noise ----------------------------------------------------------------

/**
 * A tileable noise field, sampled on a coarse grid.
 *
 * `fbm` is not periodic, so the four-way blend below is what makes the left
 * edge equal the right edge: each point is mixed with its wrapped neighbours
 * weighted by how close it is to them, which is exactly zero contribution in
 * the middle of the tile and a perfect match at the seam.
 */
function tileableNoise(res: number, scale: number, offset: number): Float32Array {
  const field = new Float32Array(res * res);
  for (let y = 0; y < res; y++) {
    const v = y / res;
    for (let x = 0; x < res; x++) {
      const u = x / res;
      const a = fbm(offset + u * scale, offset + v * scale);
      const b = fbm(offset + (u - 1) * scale, offset + v * scale);
      const c = fbm(offset + u * scale, offset + (v - 1) * scale);
      const d = fbm(offset + (u - 1) * scale, offset + (v - 1) * scale);
      const top = a * (1 - u) + b * u;
      const bottom = c * (1 - u) + d * u;
      field[y * res + x] = top * (1 - v) + bottom * v;
    }
  }
  return field;
}

/** Bilinear read of a wrapped coarse field, in 0..1 texture space. */
function sampleField(field: Float32Array, res: number, u: number, v: number): number {
  const fx = u * res;
  const fy = v * res;
  const x0 = Math.floor(fx);
  const y0 = Math.floor(fy);
  const tx = fx - x0;
  const ty = fy - y0;
  const xa = ((x0 % res) + res) % res;
  const ya = ((y0 % res) + res) % res;
  const xb = (xa + 1) % res;
  const yb = (ya + 1) % res;
  const a = field[ya * res + xa] ?? 0;
  const b = field[ya * res + xb] ?? 0;
  const c = field[yb * res + xa] ?? 0;
  const d = field[yb * res + xb] ?? 0;
  return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
}

/**
 * Multiply the canvas down by a noise field.
 *
 * `amount` is the depth of the darkest dip, as a fraction. Everything stays at
 * or below the colour already on the canvas, which is what keeps the map a
 * tint-preserving multiplier rather than something that lightens a dark
 * palette back toward white.
 */
function grain(
  ctx: CanvasRenderingContext2D,
  size: number,
  scale: number,
  amount: number,
  offset: number,
): void {
  const res = 64;
  const field = tileableNoise(res, scale, offset);
  const image = ctx.getImageData(0, 0, size, size);
  const data = image.data;
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const n = sampleField(field, res, x / size, y / size);
      // fbm's four octaves land in roughly 0..0.94 with a mean near 0.47.
      const k = 1 - amount * Math.min(1, Math.max(0, n / 0.94));
      const i = (y * size + x) * 4;
      data[i] = (data[i] ?? 0) * k;
      data[i + 1] = (data[i + 1] ?? 0) * k;
      data[i + 2] = (data[i + 2] ?? 0) * k;
    }
  }
  ctx.putImageData(image, 0, 0);
}

// ---- The drawings ---------------------------------------------------------

type Draw = (ctx: CanvasRenderingContext2D, size: number) => void;

const DRAW: Record<TextureKind, Draw> = {
  /** Loop pile: dense fine speckle, plus the faint rows a loop carpet lays in. */
  carpetLoop(ctx, size) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, 26, 0.16, 3.1);
    grain(ctx, size, 90, 0.1, 11.7);
    const rand = seededRandom(0x9e11);
    ctx.strokeStyle = "rgba(0,0,0,0.035)";
    ctx.lineWidth = 1;
    for (let y = 0; y < size; y += 4) {
      ctx.beginPath();
      ctx.moveTo(0, y + rand() * 1.5);
      ctx.lineTo(size, y + rand() * 1.5);
      ctx.stroke();
    }
  },

  /** Boards along +U, with grain stretched hard along the board. */
  woodPlank(ctx, size) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, 4, 0.1, 21.4);
    const rand = seededRandom(0x7a03);
    const boards = 5;
    const pitch = size / boards;
    // Grain lines run along the board, which is the whole reason wood reads as
    // wood; a rotationally symmetric noise reads as stone.
    ctx.lineWidth = 1;
    for (let b = 0; b < boards; b++) {
      const y0 = b * pitch;
      for (let i = 0; i < 26; i++) {
        const y = y0 + rand() * pitch;
        ctx.strokeStyle = `rgba(0,0,0,${0.02 + rand() * 0.05})`;
        ctx.beginPath();
        ctx.moveTo(0, y);
        for (let x = 0; x <= size; x += size / 8) {
          ctx.lineTo(x, y + Math.sin(x / 37 + b * 2.3) * 1.6);
        }
        ctx.stroke();
      }
      ctx.strokeStyle = "rgba(0,0,0,0.12)";
      ctx.beginPath();
      ctx.moveTo(0, y0);
      ctx.lineTo(size, y0);
      ctx.stroke();
    }
  },

  /** Power-floated slab: broad mottle and a scatter of exposed aggregate. */
  polishedConcrete(ctx, size) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, 6, 0.09, 5.9);
    grain(ctx, size, 40, 0.05, 31.2);
    const rand = seededRandom(0x51c0);
    for (let i = 0; i < size * 1.5; i++) {
      const r = 0.5 + rand() * 1.4;
      ctx.fillStyle = `rgba(0,0,0,${0.03 + rand() * 0.06})`;
      ctx.beginPath();
      ctx.arc(rand() * size, rand() * size, r, 0, Math.PI * 2);
      ctx.fill();
    }
  },

  /** Mineral fibre tile: a 600 mm grid — one tile per 600 mm at a 2 m repeat. */
  ceilingTile(ctx, size) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, 70, 0.07, 13.3);
    const rand = seededRandom(0x0ce1);
    for (let i = 0; i < size * 3; i++) {
      ctx.fillStyle = `rgba(0,0,0,${0.05 + rand() * 0.08})`;
      ctx.fillRect(rand() * size, rand() * size, 1, 1);
    }
    // 2 m of repeat covers a little over three 600 mm tiles; three is the
    // number that tiles cleanly, and nobody counts ceiling tiles.
    const cells = 3;
    const pitch = size / cells;
    ctx.strokeStyle = "rgba(0,0,0,0.16)";
    ctx.lineWidth = Math.max(1, size / 256);
    for (let i = 0; i < cells; i++) {
      ctx.beginPath();
      ctx.moveTo(i * pitch, 0);
      ctx.lineTo(i * pitch, size);
      ctx.moveTo(0, i * pitch);
      ctx.lineTo(size, i * pitch);
      ctx.stroke();
    }
  },

  /** Emulsion over plasterboard: almost nothing, which is the point. */
  plasterPaint(ctx, size) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, 9, 0.045, 8.8);
    grain(ctx, size, 120, 0.03, 27.6);
  },

  /** Upholstery weave: two crossed sets of threads, low contrast. */
  fabricWeave(ctx, size) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, 34, 0.1, 17.2);
    ctx.lineWidth = 1;
    const pitch = Math.max(2, Math.round(size / 128));
    ctx.strokeStyle = "rgba(0,0,0,0.05)";
    for (let x = 0; x < size; x += pitch) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, size);
      ctx.stroke();
    }
    ctx.strokeStyle = "rgba(0,0,0,0.07)";
    for (let y = 0; y < size; y += pitch) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(size, y);
      ctx.stroke();
    }
  },

  /** Square tile with a grout line — kitchens, WCs, entrance mats. */
  tileGrid(ctx, size) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, 14, 0.05, 4.4);
    const cells = 4;
    const pitch = size / cells;
    ctx.strokeStyle = "rgba(0,0,0,0.2)";
    ctx.lineWidth = Math.max(2, size / 128);
    for (let i = 0; i < cells; i++) {
      ctx.beginPath();
      ctx.moveTo(i * pitch, 0);
      ctx.lineTo(i * pitch, size);
      ctx.moveTo(0, i * pitch);
      ctx.lineTo(size, i * pitch);
      ctx.stroke();
    }
  },

  /** A wiped-down board: faint ghosting, no writing. */
  whiteboard(ctx, size) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, 5, 0.03, 9.1);
    const rand = seededRandom(0x0b0a);
    ctx.lineCap = "round";
    for (let i = 0; i < 14; i++) {
      ctx.strokeStyle = `rgba(0,0,0,${0.012 + rand() * 0.018})`;
      ctx.lineWidth = 4 + rand() * 10;
      const y = rand() * size;
      ctx.beginPath();
      ctx.moveTo(rand() * size * 0.4, y);
      ctx.lineTo(size * 0.5 + rand() * size * 0.5, y + (rand() - 0.5) * 20);
      ctx.stroke();
    }
  },
};

// ---- The bin --------------------------------------------------------------

/**
 * Draws each texture at most once and hands out the same `THREE.Texture` to
 * every material that wants it.
 *
 * `get` returns `null` rather than throwing when there is no canvas to draw on.
 * That happens for real: the server workspace and the CI typecheck run under
 * Node, and an asset module that explodes on import there would make the
 * zero-config boot in CONTRACT.md §5.1 impossible to test.
 */
export class TextureBin {
  readonly quality: TextureQuality;
  private readonly cache = new Map<TextureKind, THREE.Texture | null>();

  constructor(quality: TextureQuality = "high") {
    this.quality = quality;
  }

  get(kind: TextureKind): THREE.Texture | null {
    const hit = this.cache.get(kind);
    if (hit !== undefined) return hit;
    const texture = this.draw(kind);
    this.cache.set(kind, texture);
    return texture;
  }

  private draw(kind: TextureKind): THREE.Texture | null {
    const size = RESOLUTION[this.quality];
    if (size === 0 || typeof document === "undefined") return null;

    const canvas = document.createElement("canvas");
    canvas.width = size;
    canvas.height = size;
    // Every material passes through `grain()`, which reads the full canvas back
    // before uploading it to Three.js. Declare that workload so Chromium keeps
    // the 2D surface on its read-optimised path instead of warning once per
    // generated office material.
    const ctx = canvas.getContext("2d", { willReadFrequently: true });
    if (!ctx) return null;

    DRAW[kind](ctx, size);

    const texture = new THREE.CanvasTexture(canvas);
    texture.name = kind;
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.colorSpace = THREE.SRGBColorSpace;
    texture.anisotropy = 4;
    texture.needsUpdate = true;
    return texture;
  }

  dispose(): void {
    for (const texture of this.cache.values()) texture?.dispose();
    this.cache.clear();
  }
}
