/**
 * Real daylight for a building that knows where it stands.
 *
 * CONTRACT.md §4 gives lighting one owner — `Atmosphere` — and says an office
 * gets a fixed rig instead, with daylight through the windows named as "a later
 * refinement, not a v1 coupling". This is that refinement, and it is written to
 * keep the rule it is extending: **nothing here computes light.** `Atmosphere`
 * still owns that. This takes the `LightingState` it produced for a place on the
 * earth and answers the two questions an interior asks that a city never does.
 *
 * ### One: which way is the building pointing
 *
 * `Atmosphere` works in the city's frame, where −Z is true north because a city
 * pack is a map. An office is a *building*, and buildings are rotated to face
 * streets. `OfficeSite.heading` is the compass bearing the pack's −Z actually
 * points along, and until the sun is turned by it, "the daylight side" in a
 * pack's comments is a label rather than a fact — the light would come through
 * whichever wall the author happened to draw at the top of the page.
 *
 * ### Two: where does the weather start
 *
 * A city's fog begins a kilometre away and that is fine, because a city is
 * ninety kilometres across. An office is fifty metres across, and the same fog
 * would sit *inside the room*, greying out the far wall and the people at it.
 * So the colour is kept and the distances are replaced: clear air out to the
 * building's own scale, haze beyond it, saturated long before the horizon plane
 * ends. That is what turns a flat backdrop into a view.
 *
 * ### Three: is anything burning near here
 *
 * A third question, added when the boards learned about real fire. `smokeLoad`
 * is a single scalar — how much drawn fire this building is downwind of — and it
 * moves exactly the three numbers a smoke plume actually moves: the haze goes
 * brown, it starts closer, and the sun through it goes orange and dim.
 *
 * **It costs nothing.** No geometry, no draw call, no second pass, and no light:
 * every one of those three is a parameter this function already returned, and
 * `Atmosphere` still owns the rig (CONTRACT.md §4). A courtyard under smoke is
 * the same scene with three different numbers in it.
 *
 * At zero it is bit-identical to what this file returned before any of it
 * existed, which is asserted rather than assumed. That matters more than it
 * sounds: a haze that creeps in at a "load" of nothing would put a brown sky
 * over Los Angeles on a day when nothing is burning, which is the exact failure
 * `src/server/fires.ts`'s promotion gate exists to prevent one layer up. The
 * gate decides whether a fire is real; this file is only allowed to draw what
 * the gate already agreed to.
 *
 * A pack with no `site` never reaches this file and keeps the fixed rig, which
 * is the promise the format makes: you can author a floor plan without owning a
 * coordinate.
 */

import type { LightingState } from "../engine/types.ts";
import type { OfficeSite } from "./types.ts";

/**
 * Where the clear air ends and the haze begins, in metres from the camera.
 *
 * It has to clear the whole *camera orbit*, and the orbit is centred on an
 * authored viewpoint's target rather than on the middle of the building. The
 * camera pulls back to 1.8 spans — 97 m for the 54 m hangar — and from an
 * off-centre target the far roof corner is another fifty or so beyond that, so
 * the real worst case is about 149 m rather than the 110 m the building's width
 * alone suggests. 150 m clears it, and clears the tower pack's 137 m with room
 * to spare.
 *
 * Panning moves the target, so no finite number is a guarantee. It does not need
 * to be: the fog ramps at 0.025 % per metre, so being a few metres inside it is
 * imperceptible rather than a visible grey wall.
 */
const FOG_NEAR_M = 150;

/**
 * Where haze becomes total. Well inside `HORIZON_EXTENT` in `officeScene.ts`, on
 * purpose: the ground plane has to reach full fog colour before its own edge, or
 * the horizon ends in a visible seam rather than in distance.
 */
const FOG_FAR_M = 4200;

/**
 * The colour smoke turns the air, and how far in it comes.
 *
 * A drab yellow-brown rather than a grey: wildfire smoke scatters the blue out
 * of daylight long before it dims it, which is why a smoke day looks *wrong*
 * from indoors rather than merely overcast. Grey reads as weather; this does
 * not.
 *
 * `SMOKE_FOG_NEAR_M` is where the haze starts at full load — 45 m, which is
 * inside the far wall of a 36 m courtyard block, so the far range of the yard
 * goes soft while the room you are standing in does not. `FOG_NEAR_M`'s own note
 * explains why 150 m was the floor for clear air; this is the one condition
 * under which coming inside that is the right answer rather than a bug.
 */
const SMOKE_FOG_COLOR = 0x8a6a45;
const SMOKE_FOG_NEAR_M = 45;

/** The colour the sun goes through smoke, and how much of it survives. */
const SMOKE_SUN_COLOR = 0xd9762e;
const SMOKE_SUN_DIM = 0.45;

/**
 * How much of the way to each of those a full load takes you.
 *
 * Less than all of it, on purpose. `smokeLoad` is a scalar somebody computed
 * from acreage and distance, and a scalar that can saturate the frame is a
 * scalar whose top end has to be exactly right. Capping the *effect* rather than
 * the input means the difference between a bad fire and a catastrophic one is
 * still visible, and that a bug in the number upstream cannot black out a
 * courtyard.
 */
const SMOKE_FOG_MIX = 0.85;
const SMOKE_SUN_MIX = 0.8;

/**
 * Turn a city-frame lighting state into an office-frame one.
 *
 * The input is whatever `Atmosphere.apply()` produced for `site.lat/lng` at the
 * instant being rendered. The output differs in exactly two ways — the sun is
 * rotated into the building's frame, and the fog is moved outdoors — and is
 * otherwise the same object's values, because everything else `Atmosphere`
 * decided is as true inside a building as outside one.
 */
export function officeDaylight(
  state: LightingState,
  site: OfficeSite,
  smokeLoad = 0,
): LightingState {
  // Clamped rather than trusted. This number arrives from a fire layer reading a
  // feed off somebody else's machine, and the one thing it must never do is
  // reach through this function into the rig.
  const smoke = Number.isFinite(smokeLoad) ? Math.min(1, Math.max(0, smokeLoad)) : 0;

  const fog = state.fog === null
    ? null
    : {
        color: smoke === 0
          ? state.fog.color
          : mixHex(state.fog.color, SMOKE_FOG_COLOR, smoke * SMOKE_FOG_MIX),
        near: FOG_NEAR_M + (SMOKE_FOG_NEAR_M - FOG_NEAR_M) * smoke * SMOKE_FOG_MIX,
        far: FOG_FAR_M,
      };

  return {
    ...state,
    sun: {
      ...state.sun,
      direction: intoBuildingFrame(state.sun.direction, site.heading),
      color: smoke === 0
        ? state.sun.color
        : mixHex(state.sun.color, SMOKE_SUN_COLOR, smoke * SMOKE_SUN_MIX),
      // Smoke does not switch the sun off, and a courtyard that went dark would
      // read as dusk rather than as smoke. The colour is what says "fire"; the
      // dimming only stops it looking like a bright orange afternoon.
      intensity: state.sun.intensity * (1 - SMOKE_SUN_DIM * smoke * SMOKE_SUN_MIX),
    },
    fog,
    /**
     * The sky's horizon stop is pinned to the fog colour, which is what makes
     * the horizon a horizon instead of a seam.
     *
     * The sky is a **screen-space** gradient: `applyLighting` paints it top to
     * bottom of the viewport, and it does not tilt with the camera. The ground
     * plane, meanwhile, converges on the fog colour at the distance the fog
     * saturates. So the two meet at whatever screen row the world horizon
     * happens to fall on — which moves every time you orbit — and unless the
     * colours they meet with are the same, that line is a visible step.
     *
     * Matching them makes the join invisible wherever it lands, with no
     * per-frame work and no second piece of geometry. It costs the sky a little
     * of the atmosphere's chosen horizon tint; a step across the middle of the
     * frame costs more.
     */
    sky: state.sky === null || fog === null ? state.sky : { ...state.sky, horizon: fog.color },
  };
}

/**
 * The sentence that goes with a brown sky.
 *
 * `null` at zero, and a sentence at anything above it, because an unexplained
 * brown sky is worse than a clear one. The whole argument for driving haze off
 * real fire is that the room is telling you something true about outside; a
 * viewer who cannot tell the smoke from a rendering bug has been told nothing.
 *
 * It says **where the number came from and what it is not**. It is not a
 * measurement of the air in this courtyard — nobody has an air sensor there, and
 * the one the upstream store carries is unusable for reasons recorded in
 * `ARCHITECTURE.md`. It is the drawn fire set, at a distance, and the sentence
 * says so rather than letting a viewer read a haze slider as an AQI.
 */
export function smokeCaption(load: number): string | null {
  const smoke = Number.isFinite(load) ? Math.min(1, Math.max(0, load)) : 0;
  if (smoke <= 0) return null;
  const strength = smoke < 0.34 ? "Thin" : smoke < 0.67 ? "Hazy" : "Heavy";
  return (
    `${strength} smoke: the sky here is drawn from the fires currently on the board, ` +
    "by size and distance. It is not a measurement of the air at this building."
  );
}

/**
 * The colour of the light a building makes for itself.
 *
 * Warm, and warmer than daylight on purpose: an office at night is lit at
 * something like 3500 K against a 5500 K sun, and the shift is most of what
 * makes an interior at night read as *interior* rather than as a badly exposed
 * afternoon. It is also what stops the night rig looking like a dimmer switch
 * on the day rig, which is what a neutral lift would give.
 */
const HOUSE_COLOR = 0xffe4bd;

/** Ambient and hemisphere added at full darkness. */
const HOUSE_AMBIENT = 0.5;
const HOUSE_HEMISPHERE = 0.85;

/**
 * Add the building's own lights to a rig that has run out of sun.
 *
 * Kept here, next to the other adaptation of a `LightingState` for an interior,
 * and kept **out** of `luminaires.ts` — that file drives the glowing panels and
 * decides how much artificial light there is, and this one applies it, because
 * CONTRACT.md §4 gives the rig one owner and two files writing lights is exactly
 * what that rule exists to prevent.
 *
 * `level` is `Luminaires.houseLevel()`: 0 in daylight, 1 once the sun is down.
 * At 0 this returns the state unchanged, so a daylit office pays nothing and
 * looks identical to before any of this existed.
 *
 * Note what is **not** touched: `sun`. The sun is where the sun is, and at
 * midnight it is below the floor contributing nothing. Interior light is
 * ambient and hemispherical because that is what a ceiling of diffusers
 * actually produces — a room lit from a hundred soft sources has almost no
 * directional term, which is why offices at night have such flat shadows.
 */
export function withHouseLights(state: LightingState, level: number): LightingState {
  const t = Math.min(1, Math.max(0, level));
  if (t === 0) return state;

  return {
    ...state,
    ambient: {
      // Blended toward the interior colour rather than replaced, so dusk — when
      // both are running — does not jump between two different whites.
      color: mixHex(state.ambient.color, HOUSE_COLOR, t),
      intensity: state.ambient.intensity + HOUSE_AMBIENT * t,
    },
    hemisphere: {
      sky: mixHex(state.hemisphere.sky, HOUSE_COLOR, t),
      // The floor of a lit office bounces its own light back up, and leaving the
      // ground term at the night sky's near-black is what makes a figure's legs
      // vanish while their head is lit.
      ground: mixHex(state.hemisphere.ground, HOUSE_COLOR, t * 0.6),
      intensity: state.hemisphere.intensity + HOUSE_HEMISPHERE * t,
    },
  };
}

/**
 * Blend two packed 0xRRGGBB colours.
 *
 * Per channel on the raw bytes, which is not a perceptual blend and does not
 * need to be: both ends are near-white and the path between them stays there.
 * Doing it by hand avoids constructing two `THREE.Color`s per frame in a module
 * that deliberately imports no three.js.
 */
function mixHex(from: number, to: number, t: number): number {
  const mix = (shift: number) => {
    const a = (from >> shift) & 0xff;
    const b = (to >> shift) & 0xff;
    return Math.round(a + (b - a) * t) & 0xff;
  };
  return (mix(16) << 16) | (mix(8) << 8) | mix(0);
}

/**
 * Rotate a world-frame direction into the building's frame.
 *
 * The world frame is the city's: **−Z is true north, +X is east**. The building
 * frame is the pack's, whose −Z points along the compass bearing `heading`.
 *
 * Both frames measure a bearing as `atan2(x, −z)`, so a direction at world
 * bearing `B` is at building bearing `B − heading`, and expanding
 * `sin(B − h)` and `−cos(B − h)` gives the two lines below. It is a rotation of
 * `−heading` about +Y, written out rather than delegated to a `Vector3` because
 * this module deliberately imports no three.js — the same reason `plan.ts`
 * imports none, and what keeps it testable without a GL context.
 *
 * `y` is untouched: rotating about the vertical cannot change how high the sun
 * is, only where on the compass it sits.
 */
export function intoBuildingFrame(
  direction: [number, number, number],
  heading: number,
): [number, number, number] {
  const [x, y, z] = direction;
  const h = (heading * Math.PI) / 180;
  const cos = Math.cos(h);
  const sin = Math.sin(h);
  return [x * cos + z * sin, y, -x * sin + z * cos];
}
