/**
 * The interior palette, derived from the city's rather than invented beside it.
 *
 * The problem this solves is that an office and the city it stands in are two
 * scenes built by different code, and "they look like they belong together" is
 * the kind of property that survives exactly as long as one person is holding
 * both files open. So it is not left to taste: every `SurfaceRole` declares an
 * **HSL shift from a named entry of the city's `ScenePalette`**, and the
 * interior palette is computed from whatever the city actually ships. Recolour
 * `DEFAULT_PALETTE`, or hand a city its own `palette` override, and the offices
 * inside it move with it.
 *
 * ### The band
 *
 * Every derived role is clamped back into the city's own saturation and
 * lightness band — the min and max across the city palette's ten entries —
 * before it is returned. That clamp is the mechanism; the shifts are only
 * allowed to move a colour *within* the range the city already occupies, so no
 * office can be more chromatic than the world outside it.
 *
 * Lightness gets one declared concession, `LIGHTNESS_HEADROOM`. A city seen
 * from two kilometres up has no analogue for a black screen bezel or a sheet of
 * paper, and clamping the darkest role to San Francisco's darkest hillside
 * (L≈0.39) produced an office with no shadow in it and no white in it either.
 * The headroom is a single number applied to both ends, stated here, rather
 * than a per-role escape hatch — the moment roles can opt out of the band
 * individually, the band stops being a constraint and goes back to being a
 * vibe. Saturation gets no such concession and is clamped hard.
 */

import * as THREE from "three";
import { DEFAULT_PALETTE } from "../engine/terrain.ts";
import type { ScenePalette } from "../engine/types.ts";
import type { SurfaceRole } from "./materials.ts";

/**
 * How far outside the city's lightness range an interior role may sit.
 *
 * ### Why this is 0.22 and not 0.14
 *
 * It moved when `stage.ts` took the renderer off `NoToneMapping`. The old value
 * was set against a renderer that clipped at linear 1.0, and under a clipping
 * renderer the top of the range is not a range at all — every role above about
 * 0.9 albedo, lit by a sun the atmosphere drives past 2.3, displayed as exactly
 * the same white. Widening the band would have bought darker darks and, at the
 * other end, more roles indistinguishable from each other. So the number was
 * held down, and roles that wanted to be genuinely dark — `screenBezel` at
 * L≈0.24, `chairBase`, `deviceShell` — were clamped up into a mid-grey they did
 * not want to be.
 *
 * ACES's shoulder gives the top two stops back: linear 1.0, 2.0 and 4.0 now
 * display at about 0.90, 0.95 and 0.98 and stay separable all the way up. With
 * the top recoverable, the band can be widened for the sake of the bottom
 * without the top collapsing, and 0.22 is what lets the darkest roles reach
 * L≈0.17 — a charcoal, which is what a screen bezel and a microphone body
 * actually are.
 *
 * One asymmetry worth knowing, since this is documented as one number applied to
 * both ends. `bandOf` clamps `maxL` at 1.0, and the city palette's lightest
 * entry (`skyHorizon`, L≈0.89) already reached that ceiling at 0.14. So in
 * practice this number only ever bites at the dark end. It is still written as
 * one number, because the moment it becomes two somebody will tune them
 * independently and the band stops meaning anything.
 */
export const LIGHTNESS_HEADROOM = 0.22;

/**
 * The entries of `ScenePalette` that every pack is guaranteed to carry.
 *
 * `ScenePalette` has one optional colour — `alpine`, the bare rock above a tree
 * line, which only a board measured in hundreds of kilometres has any use for —
 * and an interior cannot descend from a colour that might not be there. Stated
 * as a type rather than as a runtime fallback, because the alternative is a
 * silent black carpet in somebody's office the first time an optional colour is
 * added to the palette.
 */
type RequiredPaletteKey = {
  [K in keyof ScenePalette]-?: undefined extends ScenePalette[K] ? never : K;
}[keyof ScenePalette];

/** One role's derivation: a city colour, and how far to move it. */
export interface RoleShift {
  /** Which entry of the city palette this role descends from. */
  from: RequiredPaletteKey;
  /** Hue shift in degrees. Wraps. */
  dh: number;
  /** Saturation shift, absolute, in 0..1. */
  ds: number;
  /** Lightness shift, absolute, in 0..1. */
  dl: number;
}

export type InteriorPalette = Record<SurfaceRole, number>;

/**
 * The derivation table.
 *
 * Read it as a sentence: carpet is the city's upland green-grey, nudged warm
 * and taken down a tenth; glazing is the horizon sky, barely touched, because
 * glass seen from inside is the sky. Where a role has no obvious ancestor it
 * descends from `flats`, which is the city's most neutral colour and the right
 * parent for anything that wants to be quiet.
 */
export const ROLE_SHIFTS: Record<SurfaceRole, RoleShift> = {
  // Floors
  floorSlab: { from: "flats", dh: 0, ds: 0, dl: -0.06 },
  carpet: { from: "upland", dh: 8, ds: 0.01, dl: -0.1 },
  carpetAccent: { from: "park", dh: -6, ds: 0.04, dl: -0.06 },
  woodFloor: { from: "sand", dh: -8, ds: 0.1, dl: -0.1 },
  polishedConcrete: { from: "flats", dh: 4, ds: -0.01, dl: -0.02 },
  tile: { from: "shore", dh: 6, ds: -0.01, dl: 0.14 },

  // Ceilings
  ceilingTile: { from: "sand", dh: 6, ds: -0.05, dl: 0.22 },
  ceilingBaffle: { from: "upland", dh: 10, ds: 0, dl: -0.14 },

  // The vertical shell
  plaster: { from: "shore", dh: 4, ds: -0.02, dl: 0.2 },
  plasterAccent: { from: "park", dh: -10, ds: 0.03, dl: 0.02 },
  skirting: { from: "upland", dh: 0, ds: 0, dl: -0.16 },
  glazing: { from: "skyHorizon", dh: -6, ds: 0.02, dl: -0.02 },
  glazingFrame: { from: "flats", dh: 6, ds: 0, dl: -0.22 },
  doorLeaf: { from: "sand", dh: -6, ds: 0.04, dl: -0.12 },

  // Partitions
  partitionFabric: { from: "sea", dh: 6, ds: -0.1, dl: 0.02 },
  partitionFrame: { from: "flats", dh: 2, ds: 0, dl: -0.18 },

  // Furniture
  deskSurface: { from: "sand", dh: -4, ds: 0.02, dl: -0.02 },
  deskFrame: { from: "upland", dh: 6, ds: -0.01, dl: -0.24 },
  tableTop: { from: "sand", dh: -10, ds: 0.06, dl: -0.08 },
  cabinet: { from: "shore", dh: 2, ds: 0, dl: 0.06 },
  shelf: { from: "sand", dh: -6, ds: 0.03, dl: -0.04 },
  chairShell: { from: "flats", dh: 6, ds: 0, dl: -0.26 },
  chairFabric: { from: "sea", dh: 10, ds: -0.06, dl: -0.1 },
  chairBase: { from: "upland", dh: 0, ds: -0.02, dl: -0.28 },
  upholstery: { from: "lake", dh: 14, ds: -0.08, dl: -0.04 },

  // Fittings
  metalTrim: { from: "flats", dh: 0, ds: -0.02, dl: 0.06 },
  screenBezel: { from: "upland", dh: 4, ds: -0.02, dl: -0.3 },
  screenDisplay: { from: "sea", dh: 4, ds: -0.12, dl: -0.18 },
  lightHousing: { from: "shore", dh: 4, ds: -0.02, dl: 0.1 },
  lightDiffuser: { from: "skyHorizon", dh: 6, ds: -0.2, dl: 0.1 },
  whiteboard: { from: "shore", dh: 8, ds: -0.03, dl: 0.26 },

  // Devices
  //
  // A desk microphone and a monitor speaker are the two darkest objects in a
  // studio and they are dark for a reason that is not styling: a hot LED and a
  // level meter have to read against their own body from three metres away. The
  // shell therefore goes as far down as the widened band allows (L≈0.20), and
  // the grille sits a hair under it so the two do not merge into one silhouette.
  //
  // `deviceIndicator` is the one role here whose *default* colour barely
  // matters. It descends from the city's park green at high lightness, which is
  // a credible "powered, idle" lamp, but the device render layer tints it per
  // state through `MaterialRegistry.tinted()` and that path bypasses the band
  // entirely. Authoring a saturated red here instead would not survive `bandOf`
  // anyway — saturation is clamped hard, by design, and an LED is exactly the
  // kind of thing that would talk somebody into softening that rule.
  deviceShell: { from: "flats", dh: 2, ds: -0.03, dl: -0.4 },
  deviceMesh: { from: "upland", dh: 6, ds: -0.04, dl: -0.34 },
  deviceIndicator: { from: "park", dh: -6, ds: 0.06, dl: 0.18 },
  // Near-white on purpose: `screenContent` carries the one texture in the
  // library that is *not* neutral (see `screenUI` in textures.ts), so this
  // colour has to get out of its way. A mid-grey here would multiply the drawn
  // interface down into mud.
  screenContent: { from: "shore", dh: 2, ds: -0.05, dl: 0.3 },

  // Objects
  foliage: { from: "park", dh: 4, ds: 0.06, dl: -0.06 },
  planter: { from: "shore", dh: -4, ds: 0.02, dl: -0.06 },
  paper: { from: "sand", dh: 4, ds: -0.06, dl: 0.24 },
  accent: { from: "sea", dh: -6, ds: 0.08, dl: 0 },
};

/** The saturation and lightness range a derived role must land in. */
export interface Band {
  minS: number;
  maxS: number;
  minL: number;
  maxL: number;
}

/** The saturation and lightness range the city palette actually occupies. */
function bandOf(city: ScenePalette): Band {
  const scratch = new THREE.Color();
  const hsl = { h: 0, s: 0, l: 0 };
  let minS = Infinity;
  let maxS = -Infinity;
  let minL = Infinity;
  let maxL = -Infinity;
  for (const hex of Object.values(city)) {
    scratch.setHex(hex).getHSL(hsl, THREE.SRGBColorSpace);
    if (hsl.s < minS) minS = hsl.s;
    if (hsl.s > maxS) maxS = hsl.s;
    if (hsl.l < minL) minL = hsl.l;
    if (hsl.l > maxL) maxL = hsl.l;
  }
  return {
    minS,
    maxS,
    minL: Math.max(0, minL - LIGHTNESS_HEADROOM),
    maxL: Math.min(1, maxL + LIGHTNESS_HEADROOM),
  };
}

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

/** Apply one shift to one city colour and clamp the result into the band. */
export function applyShift(hex: number, shift: RoleShift, band: Band): number {
  const color = new THREE.Color(hex);
  const hsl = { h: 0, s: 0, l: 0 };
  // Both ends of this must name the colour space. `getHSL` defaults to the
  // working space (linear-sRGB) while `setHSL` defaults to sRGB, so leaving
  // them implicit reads a colour in one space and writes it back in another —
  // which silently crushed every derived lightness toward black the first time
  // this was written.
  color.getHSL(hsl, THREE.SRGBColorSpace);
  const h = (((hsl.h + shift.dh / 360) % 1) + 1) % 1;
  const s = clamp(hsl.s + shift.ds, band.minS, band.maxS);
  const l = clamp(hsl.l + shift.dl, band.minL, band.maxL);
  return color.setHSL(h, s, l, THREE.SRGBColorSpace).getHex(THREE.SRGBColorSpace);
}

/**
 * The interior palette for a city palette.
 *
 * Pass `paletteFor(world)` to get the palette of a city that overrides some of
 * `DEFAULT_PALETTE`; pass nothing for the reference one.
 */
export function derivePalette(city: ScenePalette = DEFAULT_PALETTE): InteriorPalette {
  const band = bandOf(city);
  const out = {} as InteriorPalette;
  for (const key of Object.keys(ROLE_SHIFTS) as SurfaceRole[]) {
    const shift = ROLE_SHIFTS[key];
    out[key] = applyShift(city[shift.from], shift, band);
  }
  return out;
}

/**
 * The palette every office gets unless it is told otherwise — the one derived
 * from the engine's own `DEFAULT_PALETTE`. Computed once at module load, which
 * is thirty-five colour conversions and not worth deferring.
 */
export const DEFAULT_INTERIOR_PALETTE: InteriorPalette = derivePalette(DEFAULT_PALETTE);
