/**
 * Every surface in the library, keyed on what it *is* rather than what colour
 * it happens to be.
 *
 * `SurfaceRole` is a closed union on purpose. A registry keyed on strings would
 * let an asset ask for `"grey"` and get one, and then the day somebody wants a
 * warm office there would be forty files to edit and no list of what to edit.
 * Keyed on roles, the whole appearance of the world is `palette.ts` plus the
 * table below, and an asset that asks for `deskSurface` gets whatever a desk
 * surface is *here* — which is what makes eighteen independently-written asset
 * builders look like one library.
 *
 * The roles are named after the object, never after the finish: `partitionFabric`
 * and not `blueFelt`, `screenDisplay` and not `black`. Same rule as
 * `Marker.colorKey` one level in (ARCHITECTURE.md §3.3) — this module renders
 * surfaces and takes no position on what they mean.
 *
 * There is exactly one `THREE.Material` per role per registry, and every mesh in
 * the office shares it. That sharing is half of the draw-call budget; the other
 * half is `parts.ts` merging geometry per material.
 */

import * as THREE from "three";
import { DEFAULT_INTERIOR_PALETTE, type InteriorPalette } from "./palette.ts";
import { TextureBin, type TextureKind } from "./textures.ts";

/**
 * The closed set of surfaces this library knows how to be.
 *
 * Adding a role is a three-line change — here, in `ROLE_SPECS`, and in
 * `ROLE_SHIFTS` in `palette.ts` — and the compiler will not let you forget the
 * third.
 */
export type SurfaceRole =
  // Floors
  | "floorSlab"
  | "carpet"
  | "carpetAccent"
  | "woodFloor"
  | "polishedConcrete"
  | "tile"
  // Ceilings
  | "ceilingTile"
  | "ceilingBaffle"
  // The vertical shell
  | "plaster"
  | "plasterAccent"
  | "skirting"
  | "glazing"
  | "glazingFrame"
  | "doorLeaf"
  // Partitions
  | "partitionFabric"
  | "partitionFrame"
  // Furniture
  | "deskSurface"
  | "deskFrame"
  | "tableTop"
  | "cabinet"
  | "shelf"
  | "chairShell"
  | "chairFabric"
  | "chairBase"
  | "upholstery"
  // Fittings
  | "metalTrim"
  | "screenBezel"
  | "screenDisplay"
  | "lightHousing"
  | "lightDiffuser"
  | "whiteboard"
  // Devices
  | "deviceShell"
  | "deviceMesh"
  | "deviceIndicator"
  | "screenContent"
  // Objects
  | "foliage"
  | "planter"
  | "paper"
  | "accent";

/**
 * `low` is flat Lambert with no maps — the same material class the city uses,
 * and the setting that makes an office open on an integrated GPU. `medium` and
 * `high` are physically-shaded and differ only in texture resolution.
 */
export type MaterialQuality = "low" | "medium" | "high";

/**
 * `MeshPhysicalMaterial` is a subclass of `MeshStandardMaterial`, so it needs no
 * arm of its own here — but it is worth knowing it is in the union, because
 * `glazing` is one at `medium` and `high` and a `MeshStandardMaterial` at `low`.
 */
export type SurfaceMaterial = THREE.MeshStandardMaterial | THREE.MeshLambertMaterial;

interface RoleSpec {
  /** 0 = mirror, 1 = chalk. Ignored at `low` quality. */
  roughness: number;
  /** Ignored at `low` quality. */
  metalness: number;
  texture?: TextureKind;
  /** Fraction of the role's own colour emitted. Screens and diffusers only. */
  glow?: number;
  /**
   * Emit through `texture` rather than flat across the surface.
   *
   * Only meaningful with a `texture` that carries content rather than grain, and
   * `screenContent` is the only such role. It is the difference between a
   * monitor and a light box: with a flat `glow` the whole panel emits and the
   * drawn interface is a pattern printed on a lamp, and with the map bound to
   * `emissiveMap` the lit pixels emit and the chrome around them does not.
   */
  emissiveFromMap?: boolean;
  /**
   * A coverage map, and the threshold a fragment has to clear to be drawn.
   *
   * Cutout, not blend. `alphaTest` discards below the threshold and leaves the
   * material opaque, so a leaf still writes depth, still sorts like solid
   * geometry and still casts a correctly-shaped shadow — three's depth material
   * copies `alphaMap` and `alphaTest` across for exactly this. Making foliage
   * `transparent` instead would buy a soft edge and cost the shadow, the depth
   * write and the sort order, on the one class of object there are hundreds of.
   */
  alphaTexture?: TextureKind;
  alphaTest?: number;
  /** Opacity below 1 makes the material transparent. */
  opacity?: number;
  /** Leaf cards and glass want both faces. */
  doubleSided?: boolean;
  /**
   * Refract through the surface instead of blending over it.
   *
   * `MeshPhysicalMaterial`'s transmission is the difference between glass and a
   * grey film: it takes the *lit* colour of what is behind the surface, tints it
   * by `color`, bends it by `ior` over `thickness`, and — the part that actually
   * sells it — leaves a specular highlight and an environment reflection on top
   * that a 22%-opacity blend cannot have. `roughness` becomes frosting rather
   * than a matte grey, which is what a fritted partition wants.
   *
   * It costs a copy of the render target per transmissive draw, which is why it
   * is `medium` and `high` only and why exactly one role uses it.
   */
  transmission?: number;
  /** Refractive index. 1.5 is soda-lime glass. Only read with `transmission`. */
  ior?: number;
  /** Metres of glass the refraction is integrated over. Only read with `transmission`. */
  thickness?: number;
}

const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
  floorSlab: { roughness: 0.9, metalness: 0, texture: "polishedConcrete" },
  carpet: { roughness: 0.98, metalness: 0, texture: "carpetLoop" },
  carpetAccent: { roughness: 0.98, metalness: 0, texture: "carpetLoop" },
  woodFloor: { roughness: 0.55, metalness: 0, texture: "woodPlank" },
  polishedConcrete: { roughness: 0.4, metalness: 0.05, texture: "polishedConcrete" },
  tile: { roughness: 0.3, metalness: 0, texture: "tileGrid" },

  ceilingTile: { roughness: 0.95, metalness: 0, texture: "ceilingTile" },
  ceilingBaffle: { roughness: 0.9, metalness: 0, texture: "fabricWeave" },

  plaster: { roughness: 0.92, metalness: 0, texture: "plasterPaint" },
  plasterAccent: { roughness: 0.92, metalness: 0, texture: "plasterPaint" },
  skirting: { roughness: 0.6, metalness: 0 },
  // Glass writes no depth. With it on, anything behind a window disappears
  // depending on which mesh the sorter happens to draw first, and a meeting
  // room made of glass is exactly the case where that is most visible.
  //
  // That reasoning survives the move to transmission unchanged, and it has to be
  // said out loud because three.js *encourages* the opposite: a transmissive
  // material is drawn in the transmission pass and the usual advice is to let it
  // write depth. Here it must not. An office is a box of glass boxes — a meeting
  // room seen through a corridor screen through an external window is three
  // sheets deep — and depth-writing glass makes whichever sheet the sorter
  // reached first erase the other two. The `opacity` stays as well: it is what
  // `low` quality falls back to, and it is what keeps the frame visible against
  // the glass in the ghosted wall-occlusion copy.
  glazing: {
    roughness: 0.05,
    // Was 0.1, and had to go: three.js scales transmission by `1 - metalness`
    // because a metal is opaque by definition, so a tenth of metalness is a
    // tenth of the glass quietly turned back into a mirror.
    metalness: 0,
    opacity: 0.22,
    doubleSided: true,
    transmission: 0.92,
    ior: 1.5,
    // Millimetres, not metres of solid glass: `thickness` scales the volumetric
    // tint, and a 6 mm pane that tints like a 6 m aquarium is the classic way
    // this parameter goes wrong.
    thickness: 0.006,
  },
  glazingFrame: { roughness: 0.35, metalness: 0.7 },
  doorLeaf: { roughness: 0.6, metalness: 0 },

  partitionFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" },
  partitionFrame: { roughness: 0.4, metalness: 0.6 },

  deskSurface: { roughness: 0.45, metalness: 0, texture: "woodPlank" },
  deskFrame: { roughness: 0.4, metalness: 0.65 },
  tableTop: { roughness: 0.4, metalness: 0, texture: "woodPlank" },
  cabinet: { roughness: 0.6, metalness: 0.05 },
  shelf: { roughness: 0.55, metalness: 0, texture: "woodPlank" },
  chairShell: { roughness: 0.55, metalness: 0.05 },
  chairFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" },
  chairBase: { roughness: 0.35, metalness: 0.75 },
  upholstery: { roughness: 0.92, metalness: 0, texture: "fabricWeave" },

  metalTrim: { roughness: 0.3, metalness: 0.85 },
  screenBezel: { roughness: 0.5, metalness: 0.2 },
  screenDisplay: { roughness: 0.2, metalness: 0, glow: 0.4 },
  lightHousing: { roughness: 0.4, metalness: 0.5 },
  lightDiffuser: { roughness: 0.9, metalness: 0, glow: 0.85 },
  whiteboard: { roughness: 0.15, metalness: 0, texture: "whiteboard" },

  /**
   * The four device roles.
   *
   * `deviceMesh` is a grille or a windscreen — the perforated part — and it is
   * double-sided because you can see through it to the inside of the housing at
   * a glancing angle, which is most of what makes a speaker look like a speaker.
   * `deviceIndicator` is the only role in the table with a `glow` of 1: an LED
   * is a light source rather than a lit surface, and under the tone curve
   * `stage.ts` now runs, a full-strength emissive reads as a lamp instead of
   * saturating to the same white as the housing beside it.
   */
  deviceShell: { roughness: 0.42, metalness: 0.28 },
  deviceMesh: { roughness: 0.52, metalness: 0.8, doubleSided: true },
  deviceIndicator: { roughness: 0.35, metalness: 0, glow: 1 },
  screenContent: {
    roughness: 0.18,
    metalness: 0,
    texture: "screenUI",
    glow: 0.9,
    emissiveFromMap: true,
  },

  // A leaf is a quad with a leaf cut out of it. See `leafAlpha` in textures.ts
  // for why that is worth a texture channel, and `alphaTest` at 0.5 for why the
  // threshold sits in the middle of a hard-edged drawing rather than at its toe.
  foliage: {
    roughness: 0.8,
    metalness: 0,
    doubleSided: true,
    alphaTexture: "leafAlpha",
    alphaTest: 0.5,
  },
  planter: { roughness: 0.7, metalness: 0 },
  paper: { roughness: 0.9, metalness: 0 },
  accent: { roughness: 0.6, metalness: 0.1 },
};

/**
 * Authored `SurfaceId` strings to roles.
 *
 * An office pack carries `SurfaceId` — a loose namespaced string like
 * `"tera:carpet.loop"` — because a pack is data and must not depend on this
 * module to be parsed or stored (`interiors/types.ts`). Resolution happens here,
 * once, and unknown ids fall back rather than throwing: a pack with one typo in
 * it should still open, the same way an unregistered `AssetId` gets a
 * placeholder box.
 *
 * The general rule is that the first dot-segment after the namespace is the
 * role, so `tera:carpet.loop`, `tera:carpet.broadloom` and a self-hoster's
 * `acme:carpet.whatever` all land on `carpet` for free. This table is only for
 * the names where that reads badly.
 */
const SURFACE_ALIASES: Record<string, SurfaceRole> = {
  paint: "plaster",
  plasterboard: "plaster",
  wall: "plaster",
  wood: "woodFloor",
  timber: "woodFloor",
  concrete: "polishedConcrete",
  glass: "glazing",
  ceiling: "ceilingTile",
  felt: "partitionFabric",
  fabric: "partitionFabric",
  laminate: "deskSurface",
  steel: "metalTrim",
  metal: "metalTrim",
  aluminium: "metalTrim",
  screen: "screenDisplay",
  plant: "foliage",
};

const ROLE_NAMES = new Set(Object.keys(ROLE_SPECS));

export interface MaterialRegistryOptions {
  palette?: InteriorPalette;
  quality?: MaterialQuality;
  /**
   * Share a bin with another registry — two registries in one page (an office
   * being previewed beside the one you are in) should not draw the carpet
   * twice. The registry disposes only a bin it made itself.
   */
  textures?: TextureBin;
}

export class MaterialRegistry {
  readonly palette: InteriorPalette;
  readonly quality: MaterialQuality;
  readonly textures: TextureBin;

  private readonly ownsTextures: boolean;
  private readonly base = new Map<SurfaceRole, SurfaceMaterial>();
  private readonly ghosts = new Map<SurfaceRole, SurfaceMaterial>();
  private readonly tints = new Map<string, SurfaceMaterial>();

  constructor(options: MaterialRegistryOptions = {}) {
    this.palette = options.palette ?? DEFAULT_INTERIOR_PALETTE;
    this.quality = options.quality ?? "high";
    this.ownsTextures = options.textures === undefined;
    this.textures = options.textures ?? new TextureBin(this.quality);
  }

  /** The one shared material for a role. Do not mutate it. */
  get(role: SurfaceRole): SurfaceMaterial {
    const hit = this.base.get(role);
    if (hit) return hit;
    const made = this.create(role, this.palette[role]);
    made.name = role;
    this.base.set(role, made);
    return made;
  }

  /**
   * A translucent copy of a role, for the wall-occlusion fade — the walls
   * between the camera and where you are looking go ghost rather than being
   * hidden, so the floorplan stays readable from outside.
   *
   * The map is dropped deliberately: carpet grain at 18% opacity is visual
   * noise on top of whatever it is supposed to be letting you see. Depth
   * writing goes with it, for the same reason glazing does not write depth.
   *
   * `alphaMap` is deliberately *not* dropped with it. The colour map is
   * decoration and the coverage map is shape — a ghosted leaf with its cutout
   * removed is not a faint leaf, it is the flat green shard the cutout exists to
   * get rid of, at 18% opacity.
   */
  ghostOf(role: SurfaceRole): SurfaceMaterial {
    const hit = this.ghosts.get(role);
    if (hit) return hit;
    const ghost = this.get(role).clone();
    ghost.name = `${role}:ghost`;
    ghost.map = null;
    // The relief goes with the colour map and for the same reason. It also has
    // to: a normal map on a surface that is 82% see-through is a lighting cue
    // for a surface nobody is being asked to look at.
    if ("normalMap" in ghost) ghost.normalMap = null;
    // A ghost is a hint, not a window. Leaving transmission on would put the
    // occlusion fade — which exists to be cheap and is redrawn as the camera
    // moves — through the transmission pass and its render-target copy.
    if ("transmission" in ghost) {
      (ghost as THREE.MeshPhysicalMaterial).transmission = 0;
    }
    ghost.transparent = true;
    ghost.opacity = 0.18;
    ghost.depthWrite = false;
    ghost.side = THREE.FrontSide;
    this.ghosts.set(role, ghost);
    return ghost;
  }

  /**
   * A role recoloured for one instance — what an asset calls once the caller's
   * palette has turned a `Prop.colorKey` into a number. Cached, because a
   * hundred chairs in three colours should still be three materials.
   */
  tinted(role: SurfaceRole, color: number): SurfaceMaterial {
    const key = `${role}:${color.toString(16)}`;
    const hit = this.tints.get(key);
    if (hit) return hit;
    const made = this.create(role, color);
    made.name = key;
    this.tints.set(key, made);
    return made;
  }

  /**
   * A role drawn with a different layout of its own texture.
   *
   * Only `screenContent` has more than one today (`SCREEN_UI_VARIANTS` of them),
   * and this exists because of a constraint one layer up rather than a wish for
   * variety: `furnish.ts` batches props per kind and draws `ctx.rand` **once per
   * kind**, so a screen asset cannot roll for a layout per instance. Variety has
   * to arrive as a parameter, from a pack authoring separate batches, which
   * means it has to arrive as a separate material — one material per layout, all
   * of them cached here, and the draw-call cost is one call per layout actually
   * used rather than one per screen.
   *
   * `color` is optional so the common case reads `variant(role, n)`; pass one to
   * get a tinted layout, which is the same shape `tinted` offers.
   */
  variant(role: SurfaceRole, variant: number, color?: number): SurfaceMaterial {
    const texture = ROLE_SPECS[role].texture;
    const count = texture ? this.textures.variants(texture) : 1;
    const index = count <= 1 ? 0 : (((variant % count) + count) % count) | 0;
    const hue = color ?? this.palette[role];
    // Variant 0 with the role's own colour *is* the base material. Minting a
    // second identical one would be a second draw call for the same picture.
    if (index === 0 && color === undefined) return this.get(role);
    const key = `${role}:${hue.toString(16)}:${index}`;
    const hit = this.tints.get(key);
    if (hit) return hit;
    const made = this.create(role, hue, index);
    made.name = key;
    this.tints.set(key, made);
    return made;
  }

  /**
   * Turn an authored `SurfaceId` into a role. Unknown ids give `fallback`.
   *
   * `undefined` in gives `fallback` too, so a caller can pass an optional field
   * straight through: `materials.resolve(room.floor, "carpet")`.
   */
  resolve(surface: string | undefined, fallback: SurfaceRole): SurfaceRole {
    if (!surface) return fallback;
    const local = surface.includes(":") ? surface.slice(surface.indexOf(":") + 1) : surface;
    const head = local.split(".")[0] ?? "";
    if (ROLE_NAMES.has(head)) return head as SurfaceRole;
    return SURFACE_ALIASES[head] ?? fallback;
  }

  /** Convenience for the common `resolve` then `get`. */
  forSurface(surface: string | undefined, fallback: SurfaceRole): SurfaceMaterial {
    return this.get(this.resolve(surface, fallback));
  }

  private create(role: SurfaceRole, color: number, variant = 0): SurfaceMaterial {
    const spec = ROLE_SPECS[role];
    const map = spec.texture ? this.textures.get(spec.texture, variant) : null;
    const alphaMap = spec.alphaTexture ? this.textures.get(spec.alphaTexture) : null;
    // Relief comes from the same kind as the colour, and the bin answers `null`
    // for the kinds that have none — a whiteboard and a display are flat, and
    // `low` quality has no maps at all. No role opts in separately: a surface
    // either has a texture or it does not, and asking for the grain without the
    // relief that produced it is not a combination worth spelling.
    const normalMap = spec.texture ? this.textures.normal(spec.texture) : null;
    const transparent = spec.opacity !== undefined && spec.opacity < 1;

    const shared = {
      color,
      map,
      // `alphaTest` is only set when there is a map to test against. Left on
      // with a null `alphaMap` at `low` quality it would test the material's
      // flat opacity of 1 against the threshold on every fragment — which
      // passes, but compiles a branch into the shader for nothing.
      alphaMap,
      alphaTest: alphaMap ? (spec.alphaTest ?? 0.5) : 0,
      side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
      transparent,
      opacity: spec.opacity ?? 1,
      depthWrite: !transparent,
      // White rather than the role's colour when the map is doing the emitting:
      // `emissive` multiplies `emissiveMap`, so anything but white would tint
      // the drawn interface a second time on top of `color` already tinting it.
      emissive: spec.emissiveFromMap ? 0xffffff : spec.glow ? color : 0x000000,
      emissiveMap: spec.emissiveFromMap ? map : null,
      emissiveIntensity: spec.glow ?? 0,
    };

    // `low` is flat Lambert: no maps, no roughness, no transmission. The
    // `normalMap` is not merely unused there — `MeshLambertMaterial` does have
    // one, but the whole point of `low` is to compile the cheap shader.
    if (this.quality === "low") return new THREE.MeshLambertMaterial(shared);

    const physical = {
      ...shared,
      normalMap,
      roughness: spec.roughness,
      metalness: spec.metalness,
    };

    if (spec.transmission === undefined) return new THREE.MeshStandardMaterial(physical);
    return new THREE.MeshPhysicalMaterial({
      ...physical,
      transmission: spec.transmission,
      ior: spec.ior ?? 1.5,
      thickness: spec.thickness ?? 0.01,
      // Transmission carries the see-through, so the blend must not do it a
      // second time. Left transparent at 0.22 the sheet would be four fifths
      // invisible *and* refracting the fifth that was left, which reads as a
      // smear rather than as glass. `depthWrite` stays false regardless — see
      // the note on the role.
      transparent: false,
      opacity: 1,
      depthWrite: false,
    });
  }

  dispose(): void {
    for (const m of this.base.values()) m.dispose();
    for (const m of this.ghosts.values()) m.dispose();
    for (const m of this.tints.values()) m.dispose();
    this.base.clear();
    this.ghosts.clear();
    this.tints.clear();
    if (this.ownsTextures) this.textures.dispose();
  }
}
