/**
 * The two new texture kinds, and the bin that parameterises them.
 *
 * Drawing is done against a recording 2D context rather than a real one. There
 * is no canvas under `node --test`, and a real rasteriser would only let these
 * tests assert about pixels — which is a picture, which is exactly the thing
 * nobody should be asserting equality on. What *can* be pinned, and matters, is
 * the structure: that six genuinely different layouts exist rather than one
 * drawn six times, that the same variant is byte-identical run to run (the
 * drawings are seeded, and a `Math.random` slipping in would make an office
 * different on every reload), that the leaf stays inside the quad it is cut out
 * of, and that each kind gets the wrap mode and colour space its *use* demands.
 *
 * That last one is the subtle failure this file is really guarding. An
 * `alphaMap` sampled through an sRGB decode shifts every coverage value — a
 * cutout authored at 0.5 arrives at 0.21 — and the symptom is half a leaf, at
 * runtime, with nothing in the source looking wrong.
 */

import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";

import {
  NORMAL_MAP_KINDS,
  SCREEN_UI_VARIANTS,
  TextureBin,
  type TextureKind,
} from "../../assets/textures.ts";

// ---- A 2D context that records instead of rasterising -----------------------

/** One entry per drawing, in the order the canvases were created. */
const logs: string[][] = [];

interface FakeCanvas {
  width: number;
  height: number;
  getContext(id: string): unknown;
}

function installFakeDocument(): void {
  const document = {
    createElement(tag: string): FakeCanvas {
      if (tag !== "canvas") throw new Error(`unexpected element ${tag}`);
      const canvas: FakeCanvas = {
        width: 0,
        height: 0,
        getContext(): unknown {
          const log: string[] = [];
          logs.push(log);
          return makeContext(canvas, log);
        },
      };
      return canvas;
    },
  };
  (globalThis as { document?: unknown }).document = document;
}

function makeContext(canvas: FakeCanvas, log: string[]): unknown {
  const record = (name: string, ...args: number[]) => {
    log.push(`${name}(${args.map((n) => n.toFixed(3)).join(",")})`);
  };
  const style = (name: string, value: unknown) => {
    log.push(`${name}=${String(value)}`);
  };

  const ctx = {
    _fillStyle: "",
    set fillStyle(v: unknown) {
      style("fillStyle", v);
      ctx._fillStyle = String(v);
    },
    get fillStyle() {
      return ctx._fillStyle;
    },
    set strokeStyle(v: unknown) {
      style("strokeStyle", v);
    },
    set lineWidth(v: number) {
      style("lineWidth", v);
    },
    set lineCap(v: string) {
      style("lineCap", v);
    },
    set globalCompositeOperation(v: string) {
      style("composite", v);
    },
    fillRect: (x: number, y: number, w: number, h: number) => record("fillRect", x, y, w, h),
    beginPath: () => log.push("beginPath()"),
    closePath: () => log.push("closePath()"),
    moveTo: (x: number, y: number) => record("moveTo", x, y),
    lineTo: (x: number, y: number) => record("lineTo", x, y),
    arc: (x: number, y: number, r: number) => record("arc", x, y, r),
    arcTo: (x1: number, y1: number, x2: number, y2: number, r: number) =>
      record("arcTo", x1, y1, x2, y2, r),
    bezierCurveTo: (a: number, b: number, c: number, d: number, e: number, f: number) =>
      record("bezierCurveTo", a, b, c, d, e, f),
    fill: () => log.push("fill()"),
    stroke: () => log.push("stroke()"),
    getImageData: (_x: number, _y: number, w: number, h: number) => {
      log.push(`getImageData(${w},${h})`);
      return { data: new Uint8ClampedArray(w * h * 4).fill(255), width: w, height: h };
    },
    putImageData: () => log.push("putImageData()"),
  };
  void canvas;
  return ctx;
}

installFakeDocument();

/** Draw one texture in isolation and hand back the texture and its op log. */
function draw(
  kind: TextureKind,
  variant = 0,
  quality: "low" | "medium" | "high" = "high",
): { texture: THREE.Texture | null; log: string[]; canvas: FakeCanvas | null } {
  const before = logs.length;
  const bin = new TextureBin(quality);
  const texture = bin.get(kind, variant);
  const log = logs[before] ?? [];
  const canvas = (texture?.image as FakeCanvas | undefined) ?? null;
  return { texture, log, canvas };
}

// ---- screenUI ---------------------------------------------------------------

test("screenUI publishes at least the four variants the assets workstream needs", () => {
  assert.ok(SCREEN_UI_VARIANTS >= 4, `only ${SCREEN_UI_VARIANTS} screen layouts`);
  assert.equal(new TextureBin("high").variants("screenUI"), SCREEN_UI_VARIANTS);
  // Everything that tiles has exactly one.
  assert.equal(new TextureBin("high").variants("carpetLoop"), 1);
});

test("every screen variant is a genuinely different drawing", () => {
  const signatures = new Set<string>();
  for (let v = 0; v < SCREEN_UI_VARIANTS; v++) {
    const { log } = draw("screenUI", v);
    assert.ok(log.length > 60, `variant ${v} drew only ${log.length} operations`);
    signatures.add(log.join("|"));
  }
  // A single layout drawn six times with a different seed would still differ,
  // so this is the weaker half of the claim; the stronger half is that the
  // module has six distinct layout functions, which the count below pins.
  assert.equal(signatures.size, SCREEN_UI_VARIANTS, "two screen variants draw the same picture");
});

test("a screen variant is the same picture every time it is drawn", () => {
  // The drawings are seeded from the variant index. A `Math.random` anywhere in
  // this path would give a studio a different set of monitors on every reload,
  // and would break the byte-identical-for-a-seed property the asset library
  // promises everywhere else.
  for (const v of [0, 3, 5]) {
    const first = draw("screenUI", v).log.join("|");
    const second = draw("screenUI", v).log.join("|");
    assert.equal(first, second, `screen variant ${v} is not deterministic`);
  }
});

test("a screen is drawn in the proportions of a screen", () => {
  const { canvas } = draw("screenUI", 1);
  assert.ok(canvas);
  const aspect = (canvas?.width ?? 0) / (canvas?.height ?? 1);
  assert.ok(Math.abs(aspect - 16 / 9) < 0.02, `screen canvas aspect ${aspect.toFixed(3)}`);
});

test("a screen clamps and carries colour", () => {
  const { texture } = draw("screenUI", 2);
  assert.ok(texture);
  // One image on one quad. Repeat wrapping here means a UV that overshoots by a
  // hair draws the right edge of the interface against the left one.
  assert.equal(texture?.wrapS, THREE.ClampToEdgeWrapping);
  assert.equal(texture?.wrapT, THREE.ClampToEdgeWrapping);
  // Content, not coverage: this is the one drawing in the library with its own
  // colour, and it is authored in sRGB.
  assert.equal(texture?.colorSpace, THREE.SRGBColorSpace);
  assert.equal(texture?.name, "screenUI#2");
});

test("a screen draws no text and no logo", () => {
  // ARCHITECTURE.md §3.1: a screen drawing a recognisable interface is a screen
  // drawing somebody's trademark. There is no `fillText` in the fake context at
  // all, so a drawing that reached for one would throw — this asserts the
  // intent explicitly so the next person does not add one.
  for (let v = 0; v < SCREEN_UI_VARIANTS; v++) {
    const { log } = draw("screenUI", v);
    assert.ok(!log.some((op) => op.startsWith("fillText") || op.startsWith("drawImage")));
  }
});

// ---- leafAlpha --------------------------------------------------------------

test("the leaf is coverage, not colour", () => {
  const { texture } = draw("leafAlpha");
  assert.ok(texture);
  // An sRGB decode on an alphaMap shifts every coverage value: a cutout drawn
  // at 0.5 arrives at 0.21 and `alphaTest` eats half the leaf.
  assert.equal(texture?.colorSpace, THREE.NoColorSpace);
  assert.equal(texture?.wrapS, THREE.ClampToEdgeWrapping);
  assert.equal(texture?.wrapT, THREE.ClampToEdgeWrapping);
});

test("the leaf is drawn as a shape, black ground first", () => {
  const { log } = draw("leafAlpha");
  const firstFill = log.findIndex((op) => op.startsWith("fillRect"));
  assert.ok(firstFill > 0);
  assert.equal(log[firstFill - 1], "fillStyle=#000000", "the ground under the cutout must be empty");
  assert.ok(
    log.some((op) => op.startsWith("bezierCurveTo")),
    "a leaf outline drawn without curves is a rectangle with a different name",
  );
  // The serrations are bitten out and the mode is put back, or every drawing
  // after this one on the same context would erase instead of paint.
  const cut = log.indexOf("composite=destination-out");
  const restore = log.indexOf("composite=source-over");
  assert.ok(cut > 0, "no serrations were cut");
  assert.ok(restore > cut, "the composite mode was left in destination-out");
});

test("the leaf stays inside the quad it is cut out of", () => {
  const { log, canvas } = draw("leafAlpha");
  const w = canvas?.width ?? 0;
  const h = canvas?.height ?? 0;
  assert.ok(w > 0 && h > 0);

  const numbers = (op: string): number[] =>
    (op.slice(op.indexOf("(") + 1, -1).match(/-?\d+\.\d+/g) ?? []).map(Number);

  for (const op of log) {
    if (op.startsWith("arc(")) {
      const [x = 0, y = 0, r = 0] = numbers(op);
      assert.ok(x - r >= -0.5 && x + r <= w + 0.5, `serration off the left/right edge: ${op}`);
      assert.ok(y - r >= -0.5 && y + r <= h + 0.5, `serration off the top/bottom edge: ${op}`);
    } else if (op.startsWith("bezierCurveTo") || op.startsWith("moveTo")) {
      const values = numbers(op);
      for (let i = 0; i < values.length; i += 2) {
        assert.ok((values[i] ?? 0) >= -0.5 && (values[i] ?? 0) <= w + 0.5, `x out of bounds: ${op}`);
        assert.ok(
          (values[i + 1] ?? 0) >= -0.5 && (values[i + 1] ?? 0) <= h + 0.5,
          `y out of bounds: ${op}`,
        );
      }
    }
  }
});

test("the leaf is cut even at low quality, where every other map is skipped", () => {
  // `low` means no *shading* maps, and it is the setting that makes an office
  // open on an integrated GPU. Skipping the cutout there does not buy a cheaper
  // plant, it buys the flat green shard back.
  const leaf = draw("leafAlpha", 0, "low");
  assert.ok(leaf.texture, "leafAlpha must survive low quality");
  assert.ok((leaf.canvas?.width ?? 0) >= 128);

  const carpet = draw("carpetLoop", 0, "low");
  assert.equal(carpet.texture, null, "low quality must still skip the shading maps");
});

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

test("the bin draws each kind and variant at most once", () => {
  const bin = new TextureBin("high");
  const before = logs.length;
  const a = bin.get("screenUI", 1);
  const b = bin.get("screenUI", 1);
  assert.equal(a, b);
  assert.equal(logs.length - before, 1, "the same variant was drawn twice");

  const c = bin.get("screenUI", 4);
  assert.notEqual(a, c);
  assert.equal(logs.length - before, 2);

  // Wrapping, so a caller can hand it a running prop index.
  assert.equal(bin.get("screenUI", 1 + SCREEN_UI_VARIANTS), a);
  assert.equal(logs.length - before, 2);
  bin.dispose();
});

test("a variant index on a kind that has none is ignored", () => {
  const bin = new TextureBin("high");
  const before = logs.length;
  assert.equal(bin.get("carpetLoop", 0), bin.get("carpetLoop", 7));
  assert.equal(logs.length - before, 1);
  bin.dispose();
});

test("the tiling kinds are unchanged: square, repeating, sRGB", () => {
  for (const kind of [
    "carpetLoop",
    "woodPlank",
    "polishedConcrete",
    "ceilingTile",
    "plasterPaint",
    "fabricWeave",
    "tileGrid",
    "whiteboard",
  ] as TextureKind[]) {
    const { texture, canvas } = draw(kind);
    assert.ok(texture, `${kind} did not draw`);
    assert.equal(canvas?.width, canvas?.height, `${kind} is no longer square`);
    assert.equal(texture?.wrapS, THREE.RepeatWrapping, `${kind} stopped tiling`);
    assert.equal(texture?.wrapT, THREE.RepeatWrapping, `${kind} stopped tiling`);
    assert.equal(texture?.colorSpace, THREE.SRGBColorSpace);
    assert.equal(texture?.name, kind, `${kind} gained a variant suffix it did not ask for`);
  }
});

// ---- The relief channel ------------------------------------------------------

/**
 * These read pixels, which the tests above deliberately refuse to do — and the
 * difference is that a normal map is not a picture. It is a field of measured
 * directions with a defined encoding, and the three things that can be wrong
 * with it are all arithmetic: it can be un-normalised, it can be flipped in one
 * axis (a floor that lights as though it were embossed inside out), or it can
 * have a discontinuity at the tile seam. All three are checkable, none of them
 * is a judgement about how a carpet ought to look, and none of them shows up in
 * a screenshot until a low sun rakes across the floor.
 *
 * They also run *without* the fake canvas: relief comes off an authored height
 * field rather than off the drawing, which is what lets it exist under
 * `node --test` at all.
 */

/** Every kind the spec asked for relief on, in the order the spec named them. */
const RELIEF_KINDS: TextureKind[] = [
  "carpetLoop",
  "woodPlank",
  "fabricWeave",
  "plasterPaint",
  "tileGrid",
  "ceilingTile",
];

function normalTexture(kind: TextureKind, quality: "low" | "medium" | "high" = "high") {
  const texture = new TextureBin(quality).draw(kind, "normal");
  assert.ok(texture, `${kind} has no normal map`);
  const image = texture.image as { width: number; height: number; data: Uint8Array };
  return { texture, ...image };
}

test("every kind the spec named has relief, and nothing else does", () => {
  assert.deepEqual([...NORMAL_MAP_KINDS].sort(), [...RELIEF_KINDS].sort());
  const bin = new TextureBin("high");
  // A whiteboard, a display and an alpha cutout are flat. Binding a normal map
  // to them would be inventing texture that is not on the object.
  for (const kind of ["polishedConcrete", "whiteboard", "screenUI", "leafAlpha"] as TextureKind[]) {
    assert.equal(bin.normal(kind), null, `${kind} grew a normal map`);
  }
});

test("the centre of every relief map is flat, within a texel of tolerance", () => {
  for (const kind of RELIEF_KINDS) {
    const { width, data } = normalTexture(kind);
    const i = ((width / 2) * width + width / 2) * 4;
    const [r, g, b] = [data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0];
    // The middle of the tile is the middle of a plank, the bottom of a grout
    // line or the crest of a carpet row depending on the kind — a stationary
    // point of the height field in every case, so the surface there points
    // straight up and encodes as (128, 128, 255).
    assert.ok(Math.abs(r - 128) <= 6, `${kind} centre R is ${r}`);
    assert.ok(Math.abs(g - 128) <= 6, `${kind} centre G is ${g}`);
    assert.ok(Math.abs(b - 255) <= 6, `${kind} centre B is ${b}`);
  }
});

test("every texel of every relief map is a unit vector pointing out of the surface", () => {
  for (const kind of RELIEF_KINDS) {
    const { width, height, data } = normalTexture(kind);
    let worst = 0;
    for (let i = 0; i < width * height; i++) {
      const x = ((data[i * 4] ?? 0) / 255) * 2 - 1;
      const y = ((data[i * 4 + 1] ?? 0) / 255) * 2 - 1;
      const z = ((data[i * 4 + 2] ?? 0) / 255) * 2 - 1;
      // Out of the surface, never into it: a negative Z is a normal facing away
      // from the viewer, which shades as a hole.
      assert.ok(z > 0, `${kind} has a texel whose normal points into the surface`);
      worst = Math.max(worst, Math.abs(Math.hypot(x, y, z) - 1));
      assert.equal(data[i * 4 + 3], 255, `${kind} has a non-opaque texel`);
    }
    // 1/255 per channel of quantisation, tripled and rounded up.
    assert.ok(worst < 0.02, `${kind} normals are off unit length by ${worst.toFixed(4)}`);
  }
});

test("relief has something in it — a flat normal map is a wasted texture unit", () => {
  for (const kind of RELIEF_KINDS) {
    const { width, height, data } = normalTexture(kind);
    let peak = 0;
    for (let i = 0; i < width * height; i++) {
      peak = Math.max(
        peak,
        Math.abs((data[i * 4] ?? 0) - 128),
        Math.abs((data[i * 4 + 1] ?? 0) - 128),
      );
    }
    assert.ok(peak >= 8, `${kind} relief peaks at ${peak}/128 and reads as flat`);
  }
});

test("relief wraps at the tile seam", () => {
  // The colour maps are built to tile; a normal map that does not would put a
  // hard lighting crease every two metres across a floor, which is worse than no
  // relief at all because it moves with the sun.
  //
  // The comparison is against the *local* step, not against zero. Column 0 and
  // column 511 are one texel apart under wrapping, and in the wall of a grout
  // line one texel is a big step — legitimately. What would not be legitimate is
  // the step across the seam being larger than the steps either side of it,
  // which is exactly what a field sampled at `(x + 0.5) / size` instead of
  // `x / size` produces.
  for (const kind of RELIEF_KINDS) {
    const { width, height, data } = normalTexture(kind);
    const texel = (x: number, y: number): number[] => {
      const i = (y * width + x) * 4;
      return [data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0];
    };
    const spread = (a: number[], b: number[]): number =>
      Math.max(...a.map((value, c) => Math.abs(value - (b[c] ?? 0))));

    for (let y = 0; y < height; y += 17) {
      const seam = spread(texel(width - 1, y), texel(0, y));
      const local = Math.max(
        spread(texel(0, y), texel(1, y)),
        spread(texel(width - 2, y), texel(width - 1, y)),
      );
      assert.ok(seam <= local + 4, `${kind} row ${y}: seam step ${seam} vs local ${local}`);
    }
    for (let x = 0; x < width; x += 17) {
      const seam = spread(texel(x, height - 1), texel(x, 0));
      const local = Math.max(
        spread(texel(x, 0), texel(x, 1)),
        spread(texel(x, height - 2), texel(x, height - 1)),
      );
      assert.ok(seam <= local + 4, `${kind} column ${x}: seam step ${seam} vs local ${local}`);
    }
  }
});

test("relief is resolution-independent, so medium and high light the same", () => {
  // The gradient is taken per unit UV rather than per texel. Get that wrong and
  // the same floor is twice as steep at `medium` as at `high`, which is a
  // quality setting that changes the art rather than the cost.
  //
  // `fabricWeave` is excluded, and the exclusion is the finding rather than a
  // fudge: its drawing rules 128 threads across the tile, which is two texels at
  // 256² — below what a half-resolution map can carry at all. The weave
  // therefore genuinely disappears from the relief at `medium`, the same way the
  // 1-pixel thread lines alias out of the colour map at `medium`. That is a
  // graceful loss of detail, which is what a quality setting is for; every other
  // kind's features are coarse enough to survive both and are held to within a
  // few percent.
  for (const kind of RELIEF_KINDS.filter((kind) => kind !== "fabricWeave")) {
    const strength = (quality: "medium" | "high") => {
      const { width, height, data } = normalTexture(kind, quality);
      let sum = 0;
      for (let i = 0; i < width * height; i++) {
        sum += Math.hypot((data[i * 4] ?? 0) - 128, (data[i * 4 + 1] ?? 0) - 128);
      }
      return sum / (width * height);
    };
    const medium = strength("medium");
    const high = strength("high");
    // A third, not a few percent: a grout line is a couple of texels wide even
    // at 512 and softens measurably at 256. The bug this is really guarding
    // against — differentiating per texel instead of per unit UV — is a factor
    // of two, and no amount of softening reaches that.
    assert.ok(
      Math.abs(medium - high) <= Math.max(0.3, high * 0.35),
      `${kind} relief is ${medium.toFixed(2)} at medium and ${high.toFixed(2)} at high`,
    );
  }
});

test("relief is set up as a sampled map, not as raw data", () => {
  for (const kind of RELIEF_KINDS) {
    const { texture } = normalTexture(kind);
    assert.equal(texture.wrapS, THREE.RepeatWrapping, `${kind} relief stopped tiling`);
    assert.equal(texture.wrapT, THREE.RepeatWrapping, `${kind} relief stopped tiling`);
    // A direction is not a colour. An sRGB decode would bend every normal.
    assert.equal(texture.colorSpace, THREE.NoColorSpace, `${kind} relief is being decoded`);
    // `DataTexture` defaults to nearest and no mipmaps, which on a floor running
    // to the horizon is a field of shimmering static.
    assert.equal(texture.generateMipmaps, true, `${kind} relief has no mipmaps`);
    assert.equal(texture.minFilter, THREE.LinearMipmapLinearFilter);
    assert.equal(texture.magFilter, THREE.LinearFilter);
  }
});

test("low quality has no relief at all, the same way it has no colour maps", () => {
  const bin = new TextureBin("low");
  for (const kind of RELIEF_KINDS) {
    assert.equal(bin.normal(kind), null, `${kind} drew relief at low quality`);
  }
});

test("the bin builds each relief map at most once", () => {
  const bin = new TextureBin("high");
  const first = bin.normal("tileGrid");
  assert.ok(first);
  assert.equal(bin.normal("tileGrid"), first);
  // `draw` is the uncached door and must stay uncached, or the tests above
  // would be asserting about one shared texture.
  assert.notEqual(bin.draw("tileGrid", "normal"), first);
  bin.dispose();
});
