/**
 * What the car outside the studio knows about itself.
 *
 * A parked vehicle is a *state feed*, not a simulation of driving: state of
 * charge, cabin temperature, whether it is plugged in, whether it is locked,
 * whether climate is running, how far it will go and how far it has gone. Every
 * real vehicle API in existence returns roughly that list, and the exterior
 * layer renders roughly that list — a charge lamp, a warm cabin, a locked car.
 *
 * ### The seam is the deliverable
 *
 * Lumbridge has the real vehicle APIs and will connect them later. Guessing at
 * their schema now would be worth nothing and would be wrong; what is worth
 * something is the shape of the hole they drop into. So this file publishes an
 * interface — {@link VehicleTelemetrySource} — with a deterministic simulator
 * and an empty source behind it, and a real adapter becomes a third
 * implementation of the same five methods rather than a change to anything that
 * reads them. That is the identical trade `src/adapters/` makes for weather and
 * flights, and `src/devices/` makes for the mic and the speaker.
 *
 * ### Why the physics is fixed-step and reads no clock
 *
 * The arena wraps this module directly (`src/arena/studioOps.ts`), and the whole
 * argument for the arena being honest is that it drives the *same* simulator the
 * renderer drives rather than a headless reimplementation of it. That imposes
 * two rules, and they are why `stepFixed` exists at all:
 *
 * - **No wall clock inside `stepFixed`.** `observedAt` is `epochMs` plus the
 *   elapsed simulated milliseconds. A `Date.now()` in here would make `replay()`
 *   impossible and would make two runs of the same seed differ.
 * - **No parameter properties.** Node's type stripping refuses a module using
 *   `constructor(private readonly x: T)` outright (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`),
 *   and the arena imports this file under exactly that runtime. There are no
 *   classes in here at all, which is the simplest way to keep the promise.
 *
 * ### The cross-variable coupling, which is the point
 *
 * `ambientC` is drivable from the live weather observation
 * ({@link SimulatedVehicleTelemetry.setAmbientC}), and it reaches the battery by
 * two independent paths: a hot or cold ambient derates the pack's range
 * directly, and it widens the gap climate control has to close, which costs
 * kilowatts. A 38 °C afternoon in the Arts District therefore genuinely leaves
 * the car with fewer kilometres than the same state of charge does in
 * February — which is the sort of coupling that makes a multi-variable
 * environment more than five independent ones sharing a step function.
 *
 * Nothing here imports three.js, the DOM, or the network.
 */

/**
 * Where the vehicle is, if anybody actually knows.
 *
 * Optional, and `null` from both sources in this file, on purpose. A simulator
 * that invented a GPS fix would be publishing a *coordinate nobody observed*
 * under the same field a real API fills with somebody's actual position, and
 * the distance between those two things is the whole of the disclosure
 * discipline this repo applies to presence data. The parked demo car's position
 * is the pack's authored `arrival` anchor — a fact about the building, not an
 * observation about the vehicle — so it lives in `interiors/types.ts` and not
 * here. The field exists so a real adapter has somewhere honest to put a fix.
 */
export interface VehicleLocation {
  lat: number;
  lng: number;
}

/**
 * One observation of the vehicle.
 *
 * Strictly JSON-serialisable, for the same reason `DeviceState` is: it crosses
 * a wire, it goes in a snapshot, and it is compared for equality in a replay.
 */
export interface VehicleTelemetryState {
  /** State of charge, 0..100. */
  socPct: number;
  /** Cabin air temperature in degrees Celsius. */
  cabinC: number;
  pluggedIn: boolean;
  locked: boolean;
  climateOn: boolean;
  /** Estimated remaining range, derated for ambient. See `rangeFor`. */
  rangeKm: number;
  odometerKm: number;
  /** Milliseconds since the epoch. Simulated, never `Date.now()`. */
  observedAt: number;
  /** True when nobody observed this — always true for everything in this file. */
  synthetic: boolean;
  /** See {@link VehicleLocation}. `null` from every source here. */
  location?: VehicleLocation | null;
}

/**
 * The ops a caller may send.
 *
 * `trip` is the odd one and is worth naming: it is how the odometer moves. A
 * parked car's odometer does not change by itself, and a simulator that drifted
 * it upward would be inventing journeys, so distance arrives from whoever
 * actually moved the car — `VehicleController`, or an arena policy — in metres
 * travelled since the last report. Nothing in the exterior layer sends it,
 * because nothing in the exterior layer drives.
 */
export type VehicleTelemetryCommandOp = "lock" | "charge" | "climate" | "climateSetpointC" | "trip";

export interface VehicleTelemetryCommand {
  op: VehicleTelemetryCommandOp;
  /** Boolean for `lock`/`charge`/`climate`; a number for the other two. */
  value: boolean | number;
}

/**
 * A vehicle state feed.
 *
 * Exactly five members, and deliberately the same five `createSimulatedDevices`
 * publishes, so a consumer that can drive one simulator can drive the other.
 */
export interface VehicleTelemetrySource {
  /** A fresh, detached observation. See the note in `createSimulatedVehicleTelemetry`. */
  current(): VehicleTelemetryState;
  command(cmd: VehicleTelemetryCommand): void;
  stepFixed(): void;
  snapshot(): unknown;
  restore(s: unknown): void;
}

/**
 * The simulator, which knows one thing the interface does not: what the weather
 * is doing.
 *
 * `setAmbientC` is additive rather than part of {@link VehicleTelemetrySource}
 * because a real vehicle API *reports* the outside temperature and would have
 * nothing to do with a setter. It is the same shape `createSimulatedDevices`
 * uses for `setOccupancy`, and for the same reason: the environment writing into
 * a simulation is not a command to the thing being simulated.
 */
export interface SimulatedVehicleTelemetry extends VehicleTelemetrySource {
  /**
   * Publish the outside air temperature, in degrees Celsius, from whatever
   * weather observation the caller has. Takes effect on the next `stepFixed`.
   */
  setAmbientC(celsius: number): void;
}

export interface SimulatedVehicleTelemetryOptions {
  seed: number;
  fixedStepSeconds: number;
  ambientC: number;
  /**
   * Epoch milliseconds the first observation is stamped with. Defaults to 0 so
   * a test's expected sequence is a plain list of numbers rather than a
   * function of when it ran.
   */
  epochMs?: number;
  /** Overrides the seeded starting charge. 0..100. */
  initialSocPct?: number;
  /** Overrides the seeded starting odometer, in kilometres. */
  initialOdometerKm?: number;
}

// ---- Physical constants ---------------------------------------------------
//
// Every number below is a rounded, public, order-of-magnitude figure for a
// large electric crossover. None of them is a measurement of a specific
// vehicle and none of them is anyone's specification sheet: the asset this
// drives is `assets/vehicles/modelX.ts`, which ARCHITECTURE.md §3.1 requires to
// be unbadged, and the telemetry that stands next to it is held to the same
// standard. What matters for the environment is that the *relationships* are
// right — that charging tapers, that heat costs range, that climate costs
// charge — and those are properties of chemistry rather than of a brand.

/** Usable pack energy, kWh. */
const USABLE_CAPACITY_KWH = 95;

/** Range at 100 % in mild conditions, km. Derated by `rangeFor`. */
const NOMINAL_RANGE_KM = 560;

/**
 * Peak charge power at the apron post, kW.
 *
 * The apron the exterior layer builds carries a DC post rather than a wall box,
 * which is what makes the taper visible at all: an 11 kW home charger is flat
 * from 0 to 100 % and would make the interesting half of this file dead code.
 */
const MAX_CHARGE_KW = 150;

/**
 * Where the taper begins, and how fast it falls, in percentage points.
 *
 * A lithium pack cannot take full current near the top: past roughly half
 * charge the constant-current phase gives way to constant-voltage and the
 * accepted power falls away. The floor stops the last percent taking an
 * afternoon.
 */
const TAPER_START_PCT = 55;
const TAPER_SPAN_PCT = 50;
const TAPER_FLOOR = 0.08;

/** What the car draws just being awake, kW. */
const PARASITIC_KW = 0.32;

/** Climate draw: a fixed fan-and-compressor cost plus work proportional to the gap. */
const HVAC_BASE_KW = 0.55;
const HVAC_KW_PER_KELVIN = 0.22;
const HVAC_MAX_KW = 6.5;

/**
 * How fast a sealed cabin equalises with the outside, per second.
 *
 * A ten-minute time constant. Slower than a room because a car is a small
 * volume behind a lot of glass, and faster than a building because it has
 * almost no thermal mass.
 */
const CABIN_RELAX_PER_SECOND = 1 / 600;

/** How fast climate control closes the gap to setpoint, per second. */
const HVAC_PULL_PER_SECOND = 1 / 210;

/** Comfort setpoint the car preconditions to, and the range it will accept. */
const DEFAULT_SETPOINT_C = 21;
const MIN_SETPOINT_C = 15;
const MAX_SETPOINT_C = 28;

/** The ambient the pack is happiest at, and how fast range falls away from it. */
const IDEAL_AMBIENT_C = 21;
const RANGE_DERATE_PER_KELVIN = 0.011;
const RANGE_DERATE_FLOOR = 0.62;

/**
 * Compressor duty ripple while climate is running.
 *
 * A climate system is not a resistor: it cycles, and the instantaneous draw
 * wanders around its mean by a fifth or so. This is the only place randomness
 * touches the physics, it is bounded strictly above zero, and it is drawn from
 * the seeded generator so a replay reproduces it exactly.
 */
const DUTY_MIN = 0.86;
const DUTY_SPAN = 0.28;

const SNAPSHOT_VERSION = 1;

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value));
}

function finiteOr(value: unknown, fallback: number): number {
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}

/** A seeded PRNG whose entire state is one uint32, so a snapshot can carry it. */
function mulberry32Step(state: number): { state: number; value: number } {
  const a = (state + 0x6d2b79f5) >>> 0;
  let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return { state: a, value: ((t ^ (t >>> 14)) >>> 0) / 4294967296 };
}

/**
 * The fraction of {@link MAX_CHARGE_KW} the pack will accept at this charge.
 *
 * Monotonically non-increasing in `socPct`, which is the property the
 * environment depends on and the one a test pins: the rise above 80 % is
 * strictly slower than the rise below 60 %, because that is the single most
 * visible fact about charging an electric car and an agent that plans a
 * departure has to learn it.
 */
export function chargeTaper(socPct: number): number {
  if (socPct <= TAPER_START_PCT) return 1;
  return clamp(1 - (socPct - TAPER_START_PCT) / TAPER_SPAN_PCT, TAPER_FLOOR, 1);
}

/**
 * Estimated range for a charge and an outside temperature.
 *
 * Symmetric about {@link IDEAL_AMBIENT_C} because both ends of the thermometer
 * cost the same kind of energy for different reasons — a cold pack has higher
 * internal resistance, a hot one spends the difference on cooling.
 */
export function rangeFor(socPct: number, ambientC: number): number {
  const derate = clamp(
    1 - Math.abs(ambientC - IDEAL_AMBIENT_C) * RANGE_DERATE_PER_KELVIN,
    RANGE_DERATE_FLOOR,
    1,
  );
  return (clamp(socPct, 0, 100) / 100) * NOMINAL_RANGE_KM * derate;
}

/**
 * Clamp and vet a command from any adapter before it reaches the simulation,
 * returning `null` for anything it refuses.
 *
 * The same contract `normalizeDeviceCommand` publishes: `null` means the caller
 * should do nothing rather than the caller should show an error. A UI that sent
 * `{ op: "climate", value: 3 }` has a bug, and the honest response to a bug in
 * a control panel is an inert control, not a mutated vehicle.
 */
export function normalizeVehicleTelemetryCommand(
  command: VehicleTelemetryCommand | null | undefined,
): VehicleTelemetryCommand | null {
  if (!command || typeof command !== "object") return null;
  const { op, value } = command;
  switch (op) {
    case "lock":
    case "charge":
    case "climate":
      return typeof value === "boolean" ? { op, value } : null;
    case "climateSetpointC":
      return typeof value === "number" && Number.isFinite(value)
        ? { op, value: clamp(value, MIN_SETPOINT_C, MAX_SETPOINT_C) }
        : null;
    case "trip":
      // Metres, and never negative: an odometer does not run backwards, and a
      // negative trip is the shape a sign error arrives in.
      return typeof value === "number" && Number.isFinite(value) && value >= 0
        ? { op, value }
        : null;
    default:
      return null;
  }
}

interface SimulationState {
  elapsedSteps: number;
  prng: number;
  socPct: number;
  cabinC: number;
  pluggedIn: boolean;
  locked: boolean;
  climateOn: boolean;
  setpointC: number;
  odometerKm: number;
}

/**
 * A deterministic fixed-step vehicle state feed.
 *
 * Same seed and same command sequence gives the same state sequence, on any
 * machine, forever — which is what lets a rollout be replayed and a snapshot be
 * resumed. `current()` returns a **fresh** object every call rather than a
 * stable one, because the first thing every caller does is collect a sequence
 * of them and compare it, and a stable object turns that into a list of a
 * thousand aliases of the final state.
 */
export function createSimulatedVehicleTelemetry(
  options: SimulatedVehicleTelemetryOptions,
): SimulatedVehicleTelemetry {
  const dt = clamp(finiteOr(options.fixedStepSeconds, 0.1), 1 / 240, 10);
  const epochMs = finiteOr(options.epochMs, 0);
  let ambientC = clamp(finiteOr(options.ambientC, IDEAL_AMBIENT_C), -40, 60);

  // The seed is consumed once, here, and never again outside `stepFixed`. Two
  // studios seeded differently therefore have visibly different cars parked
  // outside them — one nearly full and plugged in, one half empty and not —
  // without either of them being authored.
  let seeded = (options.seed | 0) >>> 0;
  const draw = (): number => {
    const next = mulberry32Step(seeded);
    seeded = next.state;
    return next.value;
  };
  const startSoc = draw();
  const startCabinOffset = draw();
  const startOdometer = draw();
  const startPlugged = draw();

  const state: SimulationState = {
    elapsedSteps: 0,
    prng: seeded,
    socPct: clamp(finiteOr(options.initialSocPct, 38 + startSoc * 44), 0, 100),
    cabinC: ambientC + (startCabinOffset * 6 - 3),
    pluggedIn: startPlugged < 0.6,
    locked: true,
    climateOn: false,
    setpointC: DEFAULT_SETPOINT_C,
    odometerKm: Math.max(0, finiteOr(options.initialOdometerKm, 4_000 + Math.floor(startOdometer * 46_000))),
  };

  function nextRandom(): number {
    const next = mulberry32Step(state.prng);
    state.prng = next.state;
    return next.value;
  }

  return {
    current(): VehicleTelemetryState {
      return {
        socPct: state.socPct,
        cabinC: state.cabinC,
        pluggedIn: state.pluggedIn,
        locked: state.locked,
        climateOn: state.climateOn,
        rangeKm: rangeFor(state.socPct, ambientC),
        odometerKm: state.odometerKm,
        // Integer step count times the step, rather than an accumulator, so
        // 36,000 steps of 0.1 s is exactly an hour and not 3,599.9997 seconds.
        observedAt: epochMs + state.elapsedSteps * dt * 1000,
        synthetic: true,
        location: null,
      };
    },

    command(cmd: VehicleTelemetryCommand): void {
      const normalized = normalizeVehicleTelemetryCommand(cmd);
      if (!normalized) return;
      // `value` is a `boolean | number` and the op does not narrow it, so each
      // arm re-checks the type it wants. That is not belt and braces: it is the
      // one place a hand-built command from a console can arrive, and a
      // `climateOn` set to the number 1 renders as "on" and snapshots as garbage.
      const { value } = normalized;
      switch (normalized.op) {
        case "lock":
          if (typeof value === "boolean") state.locked = value;
          break;
        case "charge":
          if (typeof value === "boolean") state.pluggedIn = value;
          break;
        case "climate":
          if (typeof value === "boolean") state.climateOn = value;
          break;
        case "climateSetpointC":
          if (typeof value === "number") state.setpointC = value;
          break;
        case "trip": {
          if (typeof value !== "number") break;
          // Metres in, kilometres on the dial. The energy for the distance is
          // charged at the *derated* efficiency, so the same lap of the block
          // costs more on a hot afternoon than it does at dawn.
          const km = value / 1000;
          const perKm = USABLE_CAPACITY_KWH / Math.max(1, rangeFor(100, ambientC));
          state.odometerKm += km;
          state.socPct = clamp(state.socPct - (km * perKm * 100) / USABLE_CAPACITY_KWH, 0, 100);
          break;
        }
      }
    },

    stepFixed(): void {
      let climateKw = 0;
      if (state.climateOn) {
        const gapK = Math.abs(state.setpointC - state.cabinC);
        const duty = DUTY_MIN + nextRandom() * DUTY_SPAN;
        climateKw = Math.min(HVAC_MAX_KW, HVAC_BASE_KW + gapK * HVAC_KW_PER_KELVIN) * duty;
      }
      const houseLoadKw = climateKw + PARASITIC_KW;

      // A full pack on a live post does not discharge and then top itself back
      // up: the post carries the house load directly, which is why you can
      // precondition a plugged-in car for an hour and find it still at 100 %.
      //
      // This is not a rounding detail. Without it the state of charge
      // oscillates around the cap forever — 100, 99.9995, 100 — every step, and
      // the exterior's charge lamp flickers between "charging" and "full" for
      // as long as the tab is open. The taper is evaluated against the charge
      // the step *started* at, which is also what a charger does.
      const chargeKw = !state.pluggedIn
        ? 0
        : state.socPct < 100
          ? MAX_CHARGE_KW * chargeTaper(state.socPct)
          : houseLoadKw;

      const netKw = chargeKw - houseLoadKw;
      state.socPct = clamp(
        state.socPct + ((netKw * (dt / 3600)) / USABLE_CAPACITY_KWH) * 100,
        0,
        100,
      );

      // The cabin always leaks toward outside; climate control pulls against
      // that leak rather than replacing it, which is why a 38 °C afternoon
      // never quite reaches the setpoint and keeps costing kilowatts trying.
      let cabin = state.cabinC + (ambientC - state.cabinC) * CABIN_RELAX_PER_SECOND * dt;
      if (state.climateOn) {
        cabin += (state.setpointC - cabin) * HVAC_PULL_PER_SECOND * dt;
      }
      state.cabinC = cabin;
      state.elapsedSteps += 1;
    },

    setAmbientC(celsius: number): void {
      ambientC = clamp(finiteOr(celsius, ambientC), -40, 60);
    },

    snapshot(): unknown {
      return {
        v: SNAPSHOT_VERSION,
        elapsedSteps: state.elapsedSteps,
        prng: state.prng,
        socPct: state.socPct,
        cabinC: state.cabinC,
        pluggedIn: state.pluggedIn,
        locked: state.locked,
        climateOn: state.climateOn,
        setpointC: state.setpointC,
        odometerKm: state.odometerKm,
        ambientC,
      };
    },

    /**
     * Adopt a snapshot, or ignore it.
     *
     * Deliberately never throws, and deliberately never half-applies. This is
     * the same call `createSimulatedDevices` makes and it is made for the same
     * reason: a snapshot arrives from a file, a wire or another process, and a
     * throw inside a scene restore is a dead tab with a stack trace in the
     * console. The arena does not rely on this for integrity — it pins the
     * environment with its own checksum and source hashes — so the honest
     * behaviour here is to leave the simulation exactly as it was.
     */
    restore(snapshot: unknown): void {
      if (!snapshot || typeof snapshot !== "object") return;
      const s = snapshot as Record<string, unknown>;
      if (s.v !== SNAPSHOT_VERSION) return;
      if (
        typeof s.elapsedSteps !== "number" || !Number.isSafeInteger(s.elapsedSteps) ||
        s.elapsedSteps < 0 ||
        typeof s.prng !== "number" || !Number.isSafeInteger(s.prng) ||
        typeof s.socPct !== "number" || !Number.isFinite(s.socPct) ||
        typeof s.cabinC !== "number" || !Number.isFinite(s.cabinC) ||
        typeof s.setpointC !== "number" || !Number.isFinite(s.setpointC) ||
        typeof s.odometerKm !== "number" || !Number.isFinite(s.odometerKm) ||
        typeof s.ambientC !== "number" || !Number.isFinite(s.ambientC) ||
        typeof s.pluggedIn !== "boolean" || typeof s.locked !== "boolean" ||
        typeof s.climateOn !== "boolean"
      ) return;
      state.elapsedSteps = s.elapsedSteps;
      state.prng = s.prng >>> 0;
      state.socPct = clamp(s.socPct, 0, 100);
      state.cabinC = s.cabinC;
      state.pluggedIn = s.pluggedIn;
      state.locked = s.locked;
      state.climateOn = s.climateOn;
      state.setpointC = clamp(s.setpointC, MIN_SETPOINT_C, MAX_SETPOINT_C);
      state.odometerKm = Math.max(0, s.odometerKm);
      ambientC = clamp(s.ambientC, -40, 60);
    },
  };
}

/**
 * A source that observes nothing.
 *
 * Not an error state and not an empty panel: every reading is at rest and
 * `synthetic` is true, so an instrument bound to this renders as an instrument
 * with nothing on it rather than disappearing. That is the same call
 * `src/devices/adapter.ts` makes when a deployment refuses — the shape of the
 * thing on screen should not change depending on whether a feed answered — and
 * it is what a self-hoster with no vehicle API sees.
 *
 * Every number in the returned state is a placeholder. None of them is an
 * observation, which is exactly what `synthetic: true` is there to say.
 */
export function createNullVehicleTelemetry(): VehicleTelemetrySource {
  const resting = (): VehicleTelemetryState => ({
    socPct: 0,
    cabinC: 0,
    pluggedIn: false,
    locked: true,
    climateOn: false,
    rangeKm: 0,
    odometerKm: 0,
    observedAt: 0,
    synthetic: true,
    location: null,
  });
  return {
    current: resting,
    command: () => {},
    stepFixed: () => {},
    snapshot: () => ({ v: SNAPSHOT_VERSION, kind: "null" }),
    restore: () => {},
  };
}
