import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
  chargeTaper,
  createNullVehicleTelemetry,
  createSimulatedVehicleTelemetry,
  normalizeVehicleTelemetryCommand,
  rangeFor,
  type VehicleTelemetryState,
} from "../../transport/vehicleTelemetry.ts";

const STEP = 0.1;

function run(
  source: { current(): VehicleTelemetryState; stepFixed(): void },
  steps: number,
): VehicleTelemetryState[] {
  const trace: VehicleTelemetryState[] = [];
  for (let i = 0; i < steps; i += 1) {
    source.stepFixed();
    trace.push(source.current());
  }
  return trace;
}

function plugged(seed: number, ambientC = 24) {
  const source = createSimulatedVehicleTelemetry({
    seed,
    fixedStepSeconds: STEP,
    ambientC,
    initialSocPct: 22,
  });
  source.command({ op: "charge", value: true });
  return source;
}

describe("simulated vehicle telemetry", () => {
  it("is bit-identical for the same seed over a long episode", () => {
    const a = createSimulatedVehicleTelemetry({ seed: 7, fixedStepSeconds: STEP, ambientC: 31 });
    const b = createSimulatedVehicleTelemetry({ seed: 7, fixedStepSeconds: STEP, ambientC: 31 });
    // Climate on for both, so the compressor ripple — the only randomness that
    // reaches the physics — is actually exercised rather than skipped.
    a.command({ op: "climate", value: true });
    b.command({ op: "climate", value: true });
    assert.deepEqual(run(a, 600), run(b, 600));
  });

  it("gives two seeds two different cars", () => {
    const a = createSimulatedVehicleTelemetry({ seed: 7, fixedStepSeconds: STEP, ambientC: 20 });
    const b = createSimulatedVehicleTelemetry({ seed: 8, fixedStepSeconds: STEP, ambientC: 20 });
    assert.notEqual(a.current().socPct, b.current().socPct);
    assert.notEqual(a.current().odometerKm, b.current().odometerKm);
  });

  it("resumes a snapshot into an identical remaining sequence", () => {
    const live = createSimulatedVehicleTelemetry({ seed: 42, fixedStepSeconds: STEP, ambientC: 34 });
    live.command({ op: "climate", value: true });
    live.command({ op: "charge", value: true });
    run(live, 150);
    const checkpoint = live.snapshot();
    const remainder = run(live, 250);

    const resumed = createSimulatedVehicleTelemetry({
      // Deliberately a different seed and a different ambient: everything that
      // matters has to arrive through the snapshot, not through the options.
      seed: 999,
      fixedStepSeconds: STEP,
      ambientC: -5,
    });
    resumed.restore(checkpoint);
    assert.deepEqual(run(resumed, 250), remainder);
  });

  it("survives a JSON round trip, because that is how a snapshot travels", () => {
    const live = createSimulatedVehicleTelemetry({ seed: 3, fixedStepSeconds: STEP, ambientC: 12 });
    run(live, 40);
    const checkpoint = JSON.parse(JSON.stringify(live.snapshot()));
    const expected = run(live, 60);
    const resumed = createSimulatedVehicleTelemetry({ seed: 3, fixedStepSeconds: STEP, ambientC: 12 });
    resumed.restore(checkpoint);
    assert.deepEqual(run(resumed, 60), expected);
  });

  it("ignores a snapshot it cannot read rather than half-applying it", () => {
    const source = createSimulatedVehicleTelemetry({ seed: 5, fixedStepSeconds: STEP, ambientC: 18 });
    run(source, 20);
    const before = source.current();
    for (const rubbish of [null, undefined, 7, "nope", {}, { v: 2 }, { v: 1, socPct: "hot" }]) {
      source.restore(rubbish);
      assert.deepEqual(source.current(), before);
    }
  });

  it("reads no wall clock: observedAt is the elapsed simulated time", () => {
    const source = createSimulatedVehicleTelemetry({
      seed: 11,
      fixedStepSeconds: STEP,
      ambientC: 21,
      epochMs: 1_700_000_000_000,
    });
    assert.equal(source.current().observedAt, 1_700_000_000_000);
    run(source, 36_000);
    // Exactly an hour, not 3,599.9997 seconds: the step count is an integer and
    // the elapsed time is derived from it rather than accumulated.
    assert.equal(source.current().observedAt, 1_700_000_000_000 + 3_600_000);
  });

  it("never claims to have observed anything", () => {
    const source = createSimulatedVehicleTelemetry({ seed: 1, fixedStepSeconds: STEP, ambientC: 21 });
    assert.equal(source.current().synthetic, true);
    // No invented GPS fix. See the note on `VehicleLocation`.
    assert.equal(source.current().location, null);
  });
});

describe("charging physics", () => {
  it("rises monotonically while plugged in with no climate load", () => {
    const source = plugged(19);
    let previous = source.current().socPct;
    for (let i = 0; i < 900; i += 1) {
      source.stepFixed();
      const next = source.current().socPct;
      assert.ok(next > previous, `step ${i}: ${next} should exceed ${previous}`);
      previous = next;
    }
  });

  it("tapers: the rise above 80 % is strictly slower than below 60 %", () => {
    function riseFrom(socPct: number): number {
      const source = createSimulatedVehicleTelemetry({
        seed: 4, fixedStepSeconds: STEP, ambientC: 21, initialSocPct: socPct,
      });
      source.command({ op: "charge", value: true });
      const start = source.current().socPct;
      run(source, 200);
      return source.current().socPct - start;
    }
    const low = riseFrom(40);
    const high = riseFrom(85);
    assert.ok(low > 0 && high > 0);
    assert.ok(high < low, `rise at 85 % (${high}) must be slower than at 40 % (${low})`);
    // And the curve underneath it is monotone, which is the property the
    // environment actually depends on when it plans a departure.
    assert.equal(chargeTaper(20), 1);
    assert.ok(chargeTaper(60) > chargeTaper(80));
    assert.ok(chargeTaper(80) > chargeTaper(95));
    assert.ok(chargeTaper(100) > 0);
  });

  it("charges a 20 K climate delta out of the net gain", () => {
    function gain(climateOn: boolean): number {
      // 41 °C outside against a 21 °C setpoint: a genuinely hot LA afternoon.
      const source = createSimulatedVehicleTelemetry({
        seed: 6, fixedStepSeconds: STEP, ambientC: 41, initialSocPct: 30,
      });
      source.command({ op: "charge", value: true });
      if (climateOn) source.command({ op: "climate", value: true });
      const start = source.current().socPct;
      run(source, 600);
      return source.current().socPct - start;
    }
    const idle = gain(false);
    const conditioning = gain(true);
    assert.ok(conditioning < idle);
    // "Measurably" rather than "at all": a difference smaller than a tenth of a
    // percent over a minute would pass a strict inequality and be invisible.
    assert.ok(idle - conditioning > 0.05, `climate should cost more than ${idle - conditioning}`);
  });

  it("holds at a full pack instead of oscillating around the cap", () => {
    // The regression this pins: charging stops at 100 % but the car keeps
    // drawing its parasitic load, so without the post carrying that load the
    // charge falls below the cap and charging restarts — forever, at every
    // step, flickering the exterior's charge lamp for as long as the tab is
    // open. Climate is on as well, because a plugged-in car preconditioning at
    // 100 % is the case where the load is largest.
    const source = createSimulatedVehicleTelemetry({
      seed: 2, fixedStepSeconds: 5, ambientC: 38, initialSocPct: 99,
    });
    source.command({ op: "charge", value: true });
    source.command({ op: "climate", value: true });
    const trace = run(source, 4_000);
    assert.equal(source.current().socPct, 100);
    const settled = trace.slice(200).map((state) => state.socPct);
    assert.deepEqual([...new Set(settled)], [100]);
  });
});

describe("climate and range physics", () => {
  it("relaxes the cabin toward ambient when nothing is running", () => {
    const hot = createSimulatedVehicleTelemetry({ seed: 13, fixedStepSeconds: 1, ambientC: 40 });
    const startGap = Math.abs(hot.current().cabinC - 40);
    run(hot, 1_800);
    const endGap = Math.abs(hot.current().cabinC - 40);
    assert.ok(endGap < startGap * 0.2, `cabin should approach ambient: ${startGap} -> ${endGap}`);
  });

  it("drives the cabin toward the setpoint against the outside", () => {
    const source = createSimulatedVehicleTelemetry({
      seed: 21, fixedStepSeconds: 1, ambientC: 41,
    });
    run(source, 900);
    const drifted = source.current().cabinC;
    source.command({ op: "climate", value: true });
    source.command({ op: "climateSetpointC", value: 19 });
    run(source, 900);
    assert.ok(source.current().cabinC < drifted, "preconditioning must cool a hot cabin");
    // And it never fully wins, because the cabin keeps leaking to a 41 °C
    // outside — which is exactly why it keeps costing kilowatts.
    assert.ok(source.current().cabinC > 19);
  });

  it("costs range at both ends of the thermometer", () => {
    const mild = rangeFor(80, 21);
    assert.ok(rangeFor(80, 41) < mild);
    assert.ok(rangeFor(80, -6) < mild);
    assert.equal(rangeFor(0, 21), 0);
    // Range is reported through `current()`, not only by the helper.
    const source = createSimulatedVehicleTelemetry({
      seed: 8, fixedStepSeconds: STEP, ambientC: 41, initialSocPct: 80,
    });
    assert.equal(source.current().rangeKm, rangeFor(80, 41));
    source.setAmbientC(21);
    assert.ok(source.current().rangeKm > rangeFor(80, 41));
  });

  it("moves the odometer only when somebody reports a distance", () => {
    const source = createSimulatedVehicleTelemetry({
      seed: 9, fixedStepSeconds: STEP, ambientC: 21, initialOdometerKm: 12_000,
    });
    run(source, 500);
    assert.equal(source.current().odometerKm, 12_000);
    const beforeSoc = source.current().socPct;
    source.command({ op: "trip", value: 4_000 });
    assert.equal(source.current().odometerKm, 12_004);
    assert.ok(source.current().socPct < beforeSoc, "a trip has to cost charge");
  });

  it("charges a hot trip more than a mild one", () => {
    function cost(ambientC: number): number {
      const source = createSimulatedVehicleTelemetry({
        seed: 9, fixedStepSeconds: STEP, ambientC, initialSocPct: 70,
      });
      const before = source.current().socPct;
      source.command({ op: "trip", value: 20_000 });
      return before - source.current().socPct;
    }
    assert.ok(cost(41) > cost(21));
  });
});

describe("command normalisation", () => {
  it("refuses everything it cannot make sense of", () => {
    assert.equal(normalizeVehicleTelemetryCommand(null), null);
    assert.equal(normalizeVehicleTelemetryCommand(undefined), null);
    // Wrong value type for the op.
    assert.equal(normalizeVehicleTelemetryCommand({ op: "lock", value: 1 }), null);
    assert.equal(normalizeVehicleTelemetryCommand({ op: "climateSetpointC", value: true }), null);
    // An op nobody declared.
    assert.equal(
      normalizeVehicleTelemetryCommand({ op: "launch" as never, value: true }),
      null,
    );
    // An odometer does not run backwards.
    assert.equal(normalizeVehicleTelemetryCommand({ op: "trip", value: -10 }), null);
    assert.equal(normalizeVehicleTelemetryCommand({ op: "trip", value: Number.NaN }), null);
  });

  it("clamps a setpoint into the range the car will accept", () => {
    assert.deepEqual(
      normalizeVehicleTelemetryCommand({ op: "climateSetpointC", value: 90 }),
      { op: "climateSetpointC", value: 28 },
    );
    assert.deepEqual(
      normalizeVehicleTelemetryCommand({ op: "climateSetpointC", value: -40 }),
      { op: "climateSetpointC", value: 15 },
    );
  });

  it("returns a fresh object so a caller cannot alias the command it sent", () => {
    const sent = { op: "lock", value: false } as const;
    const normalized = normalizeVehicleTelemetryCommand(sent);
    assert.notEqual(normalized, sent);
    assert.deepEqual(normalized, { op: "lock", value: false });
  });

  it("leaves the simulation untouched for a refused command", () => {
    const source = createSimulatedVehicleTelemetry({ seed: 17, fixedStepSeconds: STEP, ambientC: 21 });
    const before = source.current();
    source.command({ op: "climate", value: 1 as never });
    source.command({ op: "nonsense" as never, value: true });
    assert.deepEqual(source.current(), before);
  });

  it("actually applies the commands it accepts", () => {
    const source = createSimulatedVehicleTelemetry({ seed: 17, fixedStepSeconds: STEP, ambientC: 21 });
    source.command({ op: "lock", value: false });
    source.command({ op: "charge", value: true });
    source.command({ op: "climate", value: true });
    const state = source.current();
    assert.equal(state.locked, false);
    assert.equal(state.pluggedIn, true);
    assert.equal(state.climateOn, true);
  });
});

describe("the null source", () => {
  it("renders as an instrument at rest rather than as an absence", () => {
    const source = createNullVehicleTelemetry();
    const state = source.current();
    assert.equal(state.synthetic, true);
    assert.equal(state.locked, true);
    assert.equal(state.pluggedIn, false);
    assert.equal(state.climateOn, false);
    assert.equal(state.socPct, 0);
    assert.equal(state.rangeKm, 0);
  });

  it("absorbs every call without moving", () => {
    const source = createNullVehicleTelemetry();
    const before = source.current();
    source.command({ op: "charge", value: true });
    source.stepFixed();
    source.restore(source.snapshot());
    assert.deepEqual(source.current(), before);
  });

  it("hands out a fresh state each call, like the simulator does", () => {
    const source = createNullVehicleTelemetry();
    assert.notEqual(source.current(), source.current());
  });
});
