/**
 * The parked car outside the front door, described without a renderer.
 *
 * `engine/officeExterior.ts` builds the apron and the vehicle in three.js. This
 * file is everything about that scene that is *arithmetic* rather than
 * geometry: how big a bay has to be for a given car, how far off square a real
 * car parks, which lamp is lit for a given telemetry reading, and the controller
 * settings that make a metre-scale vehicle behave like a vehicle rather than
 * like a map symbol.
 *
 * ### Why the split is worth a file
 *
 * Two reasons, and both of them have bitten this repo before.
 *
 * The first is testability. A rule like "the charge lamp is green while
 * charging and cool white once full" is one line of logic and eight lines of
 * mesh plumbing, and if they live together the only way to test the line is to
 * build a scene. `exteriorVehicleAppearance` is a pure function of a
 * `VehicleTelemetryState`, so the mapping a viewer actually reads is asserted
 * directly, and `officeExterior.ts` is left with nothing in it but geometry.
 *
 * The second is arithmetic that should never be restated. The bay is sized
 * *from the car* rather than from a pair of authored numbers, so a wider car
 * later gets a wider bay for free instead of getting a wider car in a bay it no
 * longer fits — the same reference-not-restatement rule `offices/sites.ts`
 * follows for coordinates.
 *
 * Nothing here imports three.js, the DOM, or the network.
 */

import type { VehicleControllerOptions } from "./vehicleController.ts";
import { VEHICLE_WHEEL_RADIUS_M } from "./vehicleSim.ts";
import type { VehicleTelemetryState } from "./vehicleTelemetry.ts";

// ---- Metre scale ----------------------------------------------------------

/**
 * What `VehicleController` needs to behave at 1 unit = 1 m.
 *
 * `travelScale` is the whole point and the controller documents it as exactly
 * this dial: the freeway board compresses route progress by 900 so that a
 * playable drive crosses California in minutes rather than in a working day,
 * and a car on a forecourt does not want any compression at all. The other
 * three follow from the first — at travelScale 1 the speeds and offsets in the
 * state are real metres in a real room, so a 58 m/s ceiling and a 5.4 m
 * guardrail are a motorway's numbers standing in a car park.
 *
 * There is no forked controller, and there should not be one. A second state
 * machine with the same responsibilities and different constants is how two
 * subtly different definitions of "assisted" end up shipping in one product.
 *
 * **This preset resolves no collisions.** `stepNormalized` resolves none today
 * and this changes nothing about that; a metre-scale car that has to negotiate
 * kerbs, bollards and walkers is a separate project and is explicitly out of
 * scope for this one.
 */
export const METRE_SCALE_VEHICLE_OPTIONS = {
  travelScale: 1,
  /** 50 km/h. A service road and a forecourt, not an interstate. */
  maximumSpeedMps: 13.9,
  /** Half the width of a two-lane apron road, so the edge is where the kerb is. */
  guardrailOffsetM: 3.2,
  /** Assistance loafs at a third of the posted limit; nothing here is a corridor. */
  assistedCruiseRatio: 0.35,
  /** See `VEHICLE_WHEEL_RADIUS_M` — the asset's wheel, not a rounded guess at it. */
  wheelRadiusM: VEHICLE_WHEEL_RADIUS_M,
} as const;

/**
 * Controller options for a metre-scale drive on `routeId`.
 *
 * Overrides are applied last so a caller can raise the ceiling for a wider road
 * without restating the other four.
 */
export function metreScaleVehicleOptions(
  routeId: string,
  overrides: Partial<VehicleControllerOptions> = {},
): VehicleControllerOptions {
  return { routeId, ...METRE_SCALE_VEHICLE_OPTIONS, ...overrides };
}

// ---- Is there ground outside the door? ------------------------------------

/**
 * What the arrival storey's floor actually stands on.
 *
 * `ground` is the normal case and the only one an apron can be built on:
 * `mateo-court`'s floor is 1.2 m above Mateo Street and `frontier-valley`'s is
 * 4 m above an airfield, and both of those are a kerb and a ramp — a car can be
 * driven to them, so a marked bay a few metres outside the façade is a bay on
 * the pavement.
 *
 * `air` is `lumbridge-hq`. Its floor is 188 m above the ground outside, which is
 * what `OfficeSite.elevation` measures and says so in as many words: "how far
 * this pack's level-0 floor sits above **the ground outside**". There is no
 * pavement out there to park on, and there is no version of a paved pad, a kerb,
 * a charge post and a two-and-a-half-tonne car at that height that is not a
 * claim about ground that is not there.
 *
 * ### Why this is a *whether*, not a *which*
 *
 * An earlier version of this file made the same distinction and spent it on
 * style: an elevated site got a "podium deck", the same bay and the same car on
 * a slab with a lower upstand. That reasoning went wrong at the vertical.
 * `ExteriorArrival.levelId` names "the storey whose floor this stall is measured
 * from", so the deck was built at the *studio's* floor — and a studio 188 m up a
 * Transbay tower put a paved pad and a Model X in open sky beside a wall. From
 * the arrival viewpoint it read as a car standing on top of a wall, which is
 * exactly what it was. Nothing about the kerb height fixed that, because the
 * defect was never the kerb.
 *
 * The two repairs that are not this one, and why:
 *
 * - **Drop the apron to the ground plane** (a sited office gets one at
 *   `-site.elevation`, CONTRACT §4) and park the car on the real podium. But the
 *   anchor's XZ is authored against the *studio's* walls — 12 × 9 m of floor
 *   plate — and the tower's footprint at street level is 48 × 42 m, so the two
 *   frames have no relationship at all below the storey the stall was measured
 *   from. It would also be thirty-odd draw calls of geometry four pixels tall.
 * - **Delete the anchor from the pack.** The anchor is not wrong; a podium kerb
 *   does exist at the foot of that tower. What is wrong is rendering it 188 m
 *   above itself. The pack keeps saying the true thing and this layer stops
 *   drawing the false one.
 *
 * Derived from `site.elevation` and nothing else, deliberately. The last time a
 * rule like this was written against an office id — a door marker gated on
 * `id === "sf"` — it would have pinned the Los Angeles building to San
 * Francisco. A pack that arrives over HTTP from a self-hoster gets the same
 * answer as a shipped one, for the same reason.
 */
export type ArrivalGround = "ground" | "air";

/**
 * Above this floor elevation there is nothing outside the door to park on.
 *
 * Thirty metres is about ten storeys — comfortably above anything a vehicle can
 * be driven to and comfortably below anything that could be mistaken for it.
 * Both numbers either side of it in the shipped packs (4 m and 188 m) are
 * nowhere near it, which is the property a threshold like this wants: it is not
 * doing fine discrimination, it is separating "there is a kerb here" from "this
 * is the sky".
 *
 * A non-finite elevation reads as `ground`, because a pack that failed to say
 * how high it is is far more likely to be at street level than up a tower, and
 * the failure mode of guessing wrong that way is a bay nobody walks past rather
 * than a car in mid-air.
 */
export const GROUND_ARRIVAL_MAX_ELEVATION_M = 30;

export function arrivalGroundFor(siteElevationM: number): ArrivalGround {
  return Number.isFinite(siteElevationM) && siteElevationM > GROUND_ARRIVAL_MAX_ELEVATION_M
    ? "air"
    : "ground";
}

/** Just enough of a vehicle to size a bay for it. */
export interface VehicleFootprint {
  /** Metres along the vehicle's own forward axis. */
  length: number;
  /** Metres across it, mirrors included. */
  width: number;
}

/**
 * Every dimension `officeExterior.ts` needs, in metres, all derived.
 *
 * There is one apron and it is a kerbside one, because the only sites that get
 * an apron at all are the ones with a kerb — see `ArrivalGround`. The second
 * variant this interface used to carry was a podium deck for an elevated site,
 * and it was answering the wrong question.
 */
export interface ApronMetrics {
  /** Thickness of the paved slab. The vehicle stands on top of it. */
  padThickness: number;
  padWidth: number;
  padDepth: number;
  /** The painted bay the vehicle sits in. */
  stallWidth: number;
  stallLength: number;
  /** Width of a painted line. */
  lineWidth: number;
  kerbHeight: number;
  kerbDepth: number;
  /** Charge post, in the bay's own frame: −X is the vehicle's left. */
  postWidth: number;
  postDepth: number;
  postHeight: number;
  postOffsetX: number;
  postOffsetZ: number;
  /** How far the status lamps sit up the post. */
  lampHeight: number;
  lampSize: number;
}

/**
 * Size a bay around a vehicle.
 *
 * The clearances are the ones a real marked bay uses: about 450 mm each side to
 * open a door against, and half a metre fore and aft so the painted rectangle
 * reads as a bay rather than as a box drawn round a car. Everything else is
 * measured off those two numbers, so there is exactly one place to change if
 * the vehicle changes.
 */
export function apronMetrics(vehicle: VehicleFootprint): ApronMetrics {
  const width = Math.max(1.2, vehicle.width);
  const length = Math.max(2.4, vehicle.length);
  const stallWidth = width + 0.9;
  const stallLength = length + 1.0;
  return {
    padThickness: 0.06,
    // A shoulder wide enough to walk round the car on, and deep enough that the
    // bay is not floating in the middle of nothing at an oblique camera.
    padWidth: stallWidth + 1.8,
    // The extra 3.4 m at the open end is the strip of carriageway the car drove
    // in over, which is what makes the bay read as being *on* a street.
    padDepth: stallLength + 3.4,
    stallWidth,
    stallLength,
    lineWidth: 0.1,
    // A full 135 mm step, which is what a kerb is.
    kerbHeight: 0.135,
    kerbDepth: 0.3,
    postWidth: 0.3,
    postDepth: 0.2,
    postHeight: 1.28,
    // Beside the vehicle's left rear quarter, which is where the charge port
    // is, so the cable has a plausible run rather than crossing the car.
    postOffsetX: -(stallWidth / 2 + 0.5),
    postOffsetZ: stallLength / 2 - 0.9,
    lampHeight: 1.02,
    lampSize: 0.062,
  };
}

// ---- Where the car actually stands ---------------------------------------

/** A pose in the pack's own plan frame. `yaw` is `object.rotation.y`. */
export interface ParkPose {
  x: number;
  z: number;
  yaw: number;
}

/**
 * How far off square a parked car is allowed to be.
 *
 * Nobody parks on the line. A car dead-centre in its bay at exactly the bay's
 * angle is the single clearest tell that a scene was generated, and it costs
 * one call to `rand` to fix. The bounds are deliberately small enough that the
 * result is still unambiguously *in* the bay — a tenth of a metre and a degree
 * — and small enough that a test can assert the anchor is honoured to within
 * half a metre and two degrees no matter what generator is handed in, including
 * `Math.random`.
 */
export const PARK_JITTER = {
  /** Metres across the bay. */
  lateralM: 0.11,
  /** Metres along it. */
  longitudinalM: 0.08,
  /** Radians. About 1.0°. */
  yawRad: 0.018,
} as const;

/**
 * Place a vehicle in its bay, slightly imperfectly.
 *
 * The jitter is applied in the *bay's* frame rather than the plan's, so a bay
 * at 90° gets a car nudged along its own length rather than sideways across it.
 */
export function parkPose(
  arrival: { position: { x: number; z: number }; rotation: number },
  rand: () => number,
): ParkPose {
  const lateral = (rand() * 2 - 1) * PARK_JITTER.lateralM;
  const longitudinal = (rand() * 2 - 1) * PARK_JITTER.longitudinalM;
  const yaw = arrival.rotation + (rand() * 2 - 1) * PARK_JITTER.yawRad;
  // Yaw zero faces −Z (see `interiors/types.ts`), so forward is (−sin, −cos)
  // and the vehicle's right is (cos, −sin). Both are the three.js sense, which
  // is why nothing here converts an angle.
  const forwardX = -Math.sin(arrival.rotation);
  const forwardZ = -Math.cos(arrival.rotation);
  const rightX = Math.cos(arrival.rotation);
  const rightZ = -Math.sin(arrival.rotation);
  return {
    x: arrival.position.x + rightX * lateral + forwardX * longitudinal,
    z: arrival.position.z + rightZ * lateral + forwardZ * longitudinal,
    yaw,
  };
}

// ---- Telemetry as something you can see ----------------------------------

/** One lamp, as a colour and how hard it is driven. */
export interface LampReading {
  /** 0xRRGGBB. */
  color: number;
  /** 0 (dark) to 1 (fully lit). */
  intensity: number;
}

export interface ExteriorVehicleAppearance {
  /** The charge post's lamp and the vehicle's charge-port ring. */
  charge: LampReading;
  climate: LampReading;
  lock: LampReading;
  /** Emissive strength of the cabin interior, 0..1. */
  cabinGlow: number;
  /** How full the post's charge bar reads, 0..1. */
  chargeFraction: number;
}

/** Lamp colours. Named because three of them are used twice. */
const LAMP_OFF = 0x2a3236;
const LAMP_CHARGING = 0x46d07a;
const LAMP_FULL = 0x9fd8ff;
const LAMP_COOLING = 0x58c8e8;
const LAMP_HEATING = 0xe0964a;
const LAMP_SETTLED = 0x7fbf8a;
const LAMP_STANDBY = 0xd8a13a;
const LAMP_OPEN = 0xe8eef0;

/**
 * The comfort band climate control is trying to hold the cabin inside.
 *
 * The state carries no setpoint — `VehicleTelemetryState` is the *observation*
 * a real API returns and a setpoint is a setting — so the lamp reads the band
 * rather than the target. That is also what a person standing next to the car
 * can tell: it is cooling, it is heating, or it has got there.
 */
const COMFORT_MIN_C = 18;
const COMFORT_MAX_C = 24;

function clamp01(value: number): number {
  return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
}

/**
 * How many distinct brightnesses a lamp is allowed to have.
 *
 * This is a *cache* constraint rather than an aesthetic one, and it is the
 * reason `lampTint` exists at all. An indicator in this repo is a material —
 * `materials.tinted("deviceIndicator", colour)` reaches both `color` and
 * `emissive`, so a lamp changes state by changing material, not by changing a
 * uniform on a shared one. That registry cache is keyed on the colour, so a
 * charge lamp whose brightness tracked the state of charge continuously would
 * mint a new material every step and never free one.
 *
 * Five steps is more than a lamp read from three metres away resolves, and it
 * bounds the whole exterior at forty cache entries in the worst case — of which
 * a running scene touches about six.
 */
export const LAMP_INTENSITY_STEPS = 5;

/**
 * Fold a lamp's intensity into its colour, quantised.
 *
 * Multiplying each channel is not a physically-motivated dimming curve; it is
 * the one that survives the tone mapping applied downstream, because
 * `deviceIndicator` drives `emissive` as well as `color` and ACES compresses the
 * top of the range rather than clipping it.
 */
export function lampTint(reading: LampReading): number {
  const steps = LAMP_INTENSITY_STEPS - 1;
  const level = Math.round(clamp01(reading.intensity) * steps) / steps;
  const r = Math.round(((reading.color >> 16) & 0xff) * level);
  const g = Math.round(((reading.color >> 8) & 0xff) * level);
  const b = Math.round((reading.color & 0xff) * level);
  return (r << 16) | (g << 8) | b;
}

/**
 * Turn one telemetry observation into the handful of scalars the exterior
 * renders.
 *
 * Pure, total, and deliberately the only place the mapping exists: a viewer
 * standing on the apron reads the car's state entirely off these five values,
 * and a test can assert what they will see without building a scene.
 *
 * Note what is *not* modelled: a flashing lamp. `apply()` is called on a change
 * of state rather than every frame, so anything that animates would need a
 * clock in the exterior layer, and a charge lamp that pulses is worth less than
 * a charge lamp that is honest about the state of charge — which is what
 * `chargeFraction` is for.
 */
export function exteriorVehicleAppearance(
  telemetry: VehicleTelemetryState,
): ExteriorVehicleAppearance {
  const socPct = clamp01(telemetry.socPct / 100) * 100;
  const full = socPct >= 99.5;

  const charge: LampReading = telemetry.pluggedIn
    ? full
      ? { color: LAMP_FULL, intensity: 1 }
      // Ramps up as the pack fills, so a glance at the post tells you roughly
      // how far along it is even before you read the bar.
      : { color: LAMP_CHARGING, intensity: 0.45 + 0.55 * clamp01(socPct / 100) }
    : { color: LAMP_OFF, intensity: 0.12 };

  let climate: LampReading = { color: LAMP_OFF, intensity: 0.1 };
  if (telemetry.climateOn) {
    const cabinC = Number.isFinite(telemetry.cabinC) ? telemetry.cabinC : COMFORT_MIN_C;
    if (cabinC > COMFORT_MAX_C) {
      climate = { color: LAMP_COOLING, intensity: clamp01(0.55 + (cabinC - COMFORT_MAX_C) / 20) };
    } else if (cabinC < COMFORT_MIN_C) {
      climate = { color: LAMP_HEATING, intensity: clamp01(0.55 + (COMFORT_MIN_C - cabinC) / 20) };
    } else {
      climate = { color: LAMP_SETTLED, intensity: 0.5 };
    }
  }

  // An unlocked car has its interior and marker lamps up; a locked one shows a
  // dim standby. This is the reading a person actually uses to tell whether a
  // car is theirs to open, which is why it gets a lamp of its own rather than
  // being folded into the cabin glow.
  const lock: LampReading = telemetry.locked
    ? { color: LAMP_STANDBY, intensity: 0.22 }
    : { color: LAMP_OPEN, intensity: 0.85 };

  const cabinGlow = telemetry.locked ? (telemetry.climateOn ? 0.35 : 0) : 0.9;

  return { charge, climate, lock, cabinGlow, chargeFraction: clamp01(socPct / 100) };
}
