/**
 * 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"
  // 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";

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;
  /** Opacity below 1 makes the material transparent. */
  opacity?: number;
  /** Leaf cards and glass want both faces. */
  doubleSided?: boolean;
}

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.
  glazing: { roughness: 0.05, metalness: 0.1, opacity: 0.22, doubleSided: true },
  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" },

  foliage: { roughness: 0.8, metalness: 0, doubleSided: true },
  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.
   */
  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;
    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;
  }

  /**
   * 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): SurfaceMaterial {
    const spec = ROLE_SPECS[role];
    const map = spec.texture ? this.textures.get(spec.texture) : null;
    const transparent = spec.opacity !== undefined && spec.opacity < 1;

    const shared = {
      color,
      map,
      side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
      transparent,
      opacity: spec.opacity ?? 1,
      depthWrite: !transparent,
      emissive: spec.glow ? color : 0x000000,
      emissiveIntensity: spec.glow ?? 0,
    };

    if (this.quality === "low") return new THREE.MeshLambertMaterial(shared);
    return new THREE.MeshStandardMaterial({
      ...shared,
      roughness: spec.roughness,
      metalness: spec.metalness,
    });
  }

  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();
  }
}
