/**
 * Renderer-independent playable actor simulation.
 *
 * DOM, touch and gamepad adapters reduce their input to `ActorActionSnapshot`.
 * This state machine then advances on a deterministic fixed clock and publishes
 * metre-scale transforms that any Three.js rig can consume. At yaw zero actors
 * face -Z, matching the procedural assets under `assets/actors`.
 */

const TWO_PI = Math.PI * 2;

export type ActorKind = "humanoid" | "dog" | "crow";
export type ActorMode = "ground" | "flight";
export type ActorModeRequest = "none" | ActorMode;
export type ActorKindRequest = "none" | ActorKind;
export type CrowFlightPoseState = "flap" | "glide" | "bank" | "tuck" | "perch";

export interface CrowWind {
  /** Positive X blows east/right in the actor's metre-space adapter. */
  xMps: number;
  /** Positive Z blows toward the actor's rear at yaw zero. */
  zMps: number;
}

export interface CrowThermal {
  x: number;
  z: number;
  radiusM: number;
  /** Maximum deterministic updraft at the core. */
  liftMps: number;
}

/** JSON-safe appearance fields. URLs are references; render resources stay outside simulation. */
export interface ActorProfile {
  handle?: string;
  pronouns?: string;
  faceImageUrl?: string;
  appearance?: {
    skinTone?: string;
    primaryColor?: string;
    accentColor?: string;
    hairColor?: string;
    bodyShape?: "slim" | "average" | "broad";
  };
}

/** Identity follows the player when their visible actor kind changes. */
export interface ActorIdentity {
  id: string;
  displayName: string;
  authenticated: boolean;
  profile: ActorProfile;
}

/** Device-neutral held inputs and one-shot requests. All axes normalize to [-1, 1]. */
export interface ActorActionSnapshot {
  /** Ground forward/back; flight airspeed demand. */
  forward: number;
  /** Ground strafe. Ignored in flight. */
  right: number;
  /** Yaw left/right. */
  turn: number;
  /** Crow nose-up/nose-down request. */
  pitch: number;
  /** Crow vertical thrust independent of its nose. */
  climb: number;
  sprint: boolean;
  glide: boolean;
  modeRequest: ActorModeRequest;
  kindRequest: ActorKindRequest;
  reset: boolean;
}

export const NEUTRAL_ACTOR_ACTIONS: Readonly<ActorActionSnapshot> = Object.freeze({
  forward: 0,
  right: 0,
  turn: 0,
  pitch: 0,
  climb: 0,
  sprint: false,
  glide: false,
  modeRequest: "none",
  kindRequest: "none",
  reset: false,
});

export interface ActorPosition {
  x: number;
  y: number;
  z: number;
}

export interface ActorHorizontalBounds {
  minX: number;
  maxX: number;
  minZ: number;
  maxZ: number;
}

export interface ActorControllerOptions {
  kind: ActorKind;
  identity: ActorIdentity;
  position?: Partial<ActorPosition>;
  yaw?: number;
  /** Used only when spawning a crow in flight. */
  pitch?: number;
  mode?: ActorMode;
  groundY?: number;
  minFlightAltitude?: number;
  maxFlightAltitude?: number;
  walkSpeedMps?: number;
  runSpeedMps?: number;
  dogSpeedScale?: number;
  groundTurnRateRadPerSecond?: number;
  flightTurnRateRadPerSecond?: number;
  maximumFlightPitchRad?: number;
  minimumFlightSpeedMps?: number;
  maximumFlightSpeedMps?: number;
  glideSpeedMps?: number;
  maximumClimbSpeedMps?: number;
  /** Constant air-mass velocity. Defaults to still air. */
  crowWind?: Partial<CrowWind>;
  /** Bounded deterministic radial updrafts in actor metre space. */
  crowThermals?: readonly CrowThermal[];
  /** Normalized reserve used by powered flapping. Defaults to one. */
  crowInitialEnergy?: number;
  /** Reserve spent per second at a representative powered cruise. */
  crowEnergyDrainPerSecond?: number;
  /** Reserve recovered per second while gliding or perched. */
  crowEnergyRecoveryPerSecond?: number;
  /** Optional rectangle for a board or office adapter without collision geometry. */
  horizontalBounds?: ActorHorizontalBounds;
  fixedStepSeconds?: number;
  /** Caps catch-up after a sleeping tab. */
  maxFrameDeltaSeconds?: number;
}

export interface ActorControllerState extends ActorPosition {
  kind: ActorKind;
  mode: ActorMode;
  identity: Readonly<ActorIdentity>;
  yaw: number;
  pitch: number;
  speedMps: number;
  verticalSpeedMps: number;
  /** Renderer request for a cyclic walk/flap pose, wrapped to [0, 2π). */
  posePhase: number;
  /** Renderer request for pose intensity, in [0, 1]. */
  poseAmount: number;
  gliding: boolean;
  /** Signed visual/turn bank in radians. */
  roll: number;
  /** Normalized powered-flight reserve in [0, 1]. */
  flightEnergy: number;
  /** Aerodynamic and thermal vertical contribution before pilot climb. */
  liftMps: number;
  thermalLiftMps: number;
  windXMps: number;
  windZMps: number;
  crowPose: CrowFlightPoseState;
  perched: boolean;
  altitudeBoundContact: "none" | "minimum" | "maximum";
  distanceM: number;
  elapsedSteps: number;
}

export interface ActorControllerSnapshot extends Omit<ActorControllerState, "identity"> {
  identity: ActorIdentity;
}

export interface TimedActorInputFrame {
  steps: number;
  actions?: Partial<ActorActionSnapshot>;
}

export interface ActorReplayResult {
  trajectory: readonly ActorControllerSnapshot[];
  final: ActorControllerSnapshot;
}

interface ResolvedOptions {
  kind: ActorKind;
  identity: Readonly<ActorIdentity>;
  position: ActorPosition;
  yaw: number;
  pitch: number;
  mode: ActorMode;
  groundY: number;
  minFlightAltitude: number;
  maxFlightAltitude: number;
  walkSpeedMps: number;
  runSpeedMps: number;
  dogSpeedScale: number;
  groundTurnRateRadPerSecond: number;
  flightTurnRateRadPerSecond: number;
  maximumFlightPitchRad: number;
  minimumFlightSpeedMps: number;
  maximumFlightSpeedMps: number;
  glideSpeedMps: number;
  maximumClimbSpeedMps: number;
  crowWind: CrowWind;
  crowThermals: readonly CrowThermal[];
  crowInitialEnergy: number;
  crowEnergyDrainPerSecond: number;
  crowEnergyRecoveryPerSecond: number;
  horizontalBounds?: ActorHorizontalBounds;
  fixedStepSeconds: number;
  maxFrameDeltaSeconds: number;
}

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

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

function positive(value: number | undefined, fallback: number, name: string): number {
  const result = finiteOr(value, fallback);
  if (!(result > 0)) throw new RangeError(`${name} must be finite and positive`);
  return result;
}

function wrapAngle(value: number): number {
  return ((value + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI;
}

function wrapPhase(value: number): number {
  return ((value % TWO_PI) + TWO_PI) % TWO_PI;
}

function moveToward(value: number, target: number, maximumDelta: number): number {
  if (value < target) return Math.min(value + maximumDelta, target);
  if (value > target) return Math.max(value - maximumDelta, target);
  return value;
}

function validKind(value: unknown): value is ActorKind {
  return value === "humanoid" || value === "dog" || value === "crow";
}

function copyProfile(profile: ActorProfile): ActorProfile {
  const appearance = profile.appearance
    ? {
        ...(profile.appearance.skinTone === undefined ? {} : { skinTone: profile.appearance.skinTone }),
        ...(profile.appearance.primaryColor === undefined ? {} : { primaryColor: profile.appearance.primaryColor }),
        ...(profile.appearance.accentColor === undefined ? {} : { accentColor: profile.appearance.accentColor }),
        ...(profile.appearance.hairColor === undefined ? {} : { hairColor: profile.appearance.hairColor }),
        ...(profile.appearance.bodyShape === undefined ? {} : { bodyShape: profile.appearance.bodyShape }),
      }
    : undefined;
  return {
    ...(profile.handle === undefined ? {} : { handle: profile.handle }),
    ...(profile.pronouns === undefined ? {} : { pronouns: profile.pronouns }),
    ...(profile.faceImageUrl === undefined ? {} : { faceImageUrl: profile.faceImageUrl }),
    ...(appearance === undefined ? {} : { appearance }),
  };
}

function checkedIdentity(identity: ActorIdentity): Readonly<ActorIdentity> {
  if (!identity || typeof identity.id !== "string" || identity.id.length === 0) {
    throw new RangeError("actor identity must have a non-empty string id");
  }
  if (typeof identity.displayName !== "string" || typeof identity.authenticated !== "boolean") {
    throw new RangeError("actor identity must contain a display name and authentication flag");
  }
  const profile = identity.profile ?? {};
  const strings = [
    profile.handle,
    profile.pronouns,
    profile.faceImageUrl,
    profile.appearance?.skinTone,
    profile.appearance?.primaryColor,
    profile.appearance?.accentColor,
    profile.appearance?.hairColor,
  ];
  if (strings.some((item) => item !== undefined && typeof item !== "string")) {
    throw new RangeError("actor profile fields must be strings");
  }
  if (
    profile.appearance?.bodyShape !== undefined &&
    profile.appearance.bodyShape !== "slim" &&
    profile.appearance.bodyShape !== "average" &&
    profile.appearance.bodyShape !== "broad"
  ) throw new RangeError("actor body shape is invalid");
  const copy: ActorIdentity = {
    id: identity.id,
    displayName: identity.displayName,
    authenticated: identity.authenticated,
    profile: copyProfile(profile),
  };
  if (copy.profile.appearance) Object.freeze(copy.profile.appearance);
  Object.freeze(copy.profile);
  return Object.freeze(copy);
}

function copyIdentity(identity: Readonly<ActorIdentity>): ActorIdentity {
  return {
    id: identity.id,
    displayName: identity.displayName,
    authenticated: identity.authenticated,
    profile: copyProfile(identity.profile),
  };
}

function checkedBounds(bounds: ActorHorizontalBounds | undefined): ActorHorizontalBounds | undefined {
  if (!bounds) return undefined;
  if (
    !Number.isFinite(bounds.minX) ||
    !Number.isFinite(bounds.maxX) ||
    !Number.isFinite(bounds.minZ) ||
    !Number.isFinite(bounds.maxZ) ||
    bounds.minX >= bounds.maxX ||
    bounds.minZ >= bounds.maxZ
  ) {
    throw new RangeError("actor horizontal bounds must be finite and ordered");
  }
  return { ...bounds };
}

function checkedCrowWind(value: Partial<CrowWind> | undefined): CrowWind {
  const xMps = finiteOr(value?.xMps, 0);
  const zMps = finiteOr(value?.zMps, 0);
  // A malformed adapter cannot inject hurricane-scale displacement into an
  // authoritative actor step. The generous cap still covers severe weather.
  return { xMps: clamp(xMps, -60, 60), zMps: clamp(zMps, -60, 60) };
}

function checkedCrowThermals(value: readonly CrowThermal[] | undefined): readonly CrowThermal[] {
  if (!value) return Object.freeze([]);
  if (value.length > 32) throw new RangeError("crowThermals supports at most 32 updrafts");
  const checked = value.map((thermal) => {
    if (
      !Number.isFinite(thermal?.x) || !Number.isFinite(thermal?.z) ||
      !Number.isFinite(thermal?.radiusM) || thermal.radiusM <= 0 ||
      !Number.isFinite(thermal?.liftMps) || thermal.liftMps < 0
    ) throw new RangeError("crow thermal fields must be finite with positive radius and non-negative lift");
    return Object.freeze({
      x: thermal.x,
      z: thermal.z,
      radiusM: clamp(thermal.radiusM, 0.5, 10_000),
      liftMps: clamp(thermal.liftMps, 0, 20),
    });
  });
  return Object.freeze(checked);
}

/** Clamp malformed adapter input before it can poison authoritative state. */
export function normalizeActorActions(
  actions: Partial<ActorActionSnapshot> | undefined,
): ActorActionSnapshot {
  let forward = clamp(finiteOr(actions?.forward, 0), -1, 1);
  let right = clamp(finiteOr(actions?.right, 0), -1, 1);
  const groundLength = Math.hypot(forward, right);
  if (groundLength > 1) {
    forward /= groundLength;
    right /= groundLength;
  }
  const mode = actions?.modeRequest;
  const kind = actions?.kindRequest;
  return {
    forward,
    right,
    turn: clamp(finiteOr(actions?.turn, 0), -1, 1),
    pitch: clamp(finiteOr(actions?.pitch, 0), -1, 1),
    climb: clamp(finiteOr(actions?.climb, 0), -1, 1),
    sprint: actions?.sprint === true,
    glide: actions?.glide === true,
    modeRequest: mode === "ground" || mode === "flight" ? mode : "none",
    kindRequest: validKind(kind) ? kind : "none",
    reset: actions?.reset === true,
  };
}

function resolveOptions(options: ActorControllerOptions): ResolvedOptions {
  if (!validKind(options.kind)) throw new RangeError("unknown actor kind");
  const groundY = finiteOr(options.groundY, 0);
  const minFlightAltitude = positive(options.minFlightAltitude, 0.75, "minFlightAltitude");
  const maxFlightAltitude = positive(options.maxFlightAltitude, 120, "maxFlightAltitude");
  if (maxFlightAltitude <= minFlightAltitude) {
    throw new RangeError("maxFlightAltitude must exceed minFlightAltitude");
  }
  const mode: ActorMode = options.kind === "crow" && options.mode === "flight" ? "flight" : "ground";
  const position = {
    x: finiteOr(options.position?.x, 0),
    y: mode === "flight"
      ? clamp(finiteOr(options.position?.y, groundY + minFlightAltitude), groundY + minFlightAltitude, groundY + maxFlightAltitude)
      : groundY,
    z: finiteOr(options.position?.z, 0),
  };
  const maximumFlightSpeedMps = positive(options.maximumFlightSpeedMps, 16, "maximumFlightSpeedMps");
  const minimumFlightSpeedMps = positive(options.minimumFlightSpeedMps, 4, "minimumFlightSpeedMps");
  if (maximumFlightSpeedMps < minimumFlightSpeedMps) {
    throw new RangeError("maximumFlightSpeedMps must not be below minimumFlightSpeedMps");
  }
  const bounds = checkedBounds(options.horizontalBounds);
  if (bounds) {
    position.x = clamp(position.x, bounds.minX, bounds.maxX);
    position.z = clamp(position.z, bounds.minZ, bounds.maxZ);
  }
  return {
    kind: options.kind,
    identity: checkedIdentity(options.identity),
    position,
    yaw: wrapAngle(finiteOr(options.yaw, 0)),
    pitch: mode === "flight" ? finiteOr(options.pitch, 0) : 0,
    mode,
    groundY,
    minFlightAltitude,
    maxFlightAltitude,
    walkSpeedMps: positive(options.walkSpeedMps, 1.7, "walkSpeedMps"),
    runSpeedMps: positive(options.runSpeedMps, 4.5, "runSpeedMps"),
    dogSpeedScale: positive(options.dogSpeedScale, 1.25, "dogSpeedScale"),
    groundTurnRateRadPerSecond: positive(options.groundTurnRateRadPerSecond, 2.8, "groundTurnRateRadPerSecond"),
    flightTurnRateRadPerSecond: positive(options.flightTurnRateRadPerSecond, 1.75, "flightTurnRateRadPerSecond"),
    maximumFlightPitchRad: clamp(positive(options.maximumFlightPitchRad, 0.72, "maximumFlightPitchRad"), 0.1, Math.PI / 2 - 0.05),
    minimumFlightSpeedMps,
    maximumFlightSpeedMps,
    glideSpeedMps: clamp(positive(options.glideSpeedMps, 7.5, "glideSpeedMps"), minimumFlightSpeedMps, maximumFlightSpeedMps),
    maximumClimbSpeedMps: positive(options.maximumClimbSpeedMps, 5, "maximumClimbSpeedMps"),
    crowWind: checkedCrowWind(options.crowWind),
    crowThermals: checkedCrowThermals(options.crowThermals),
    crowInitialEnergy: clamp(finiteOr(options.crowInitialEnergy, 1), 0, 1),
    crowEnergyDrainPerSecond: clamp(
      positive(options.crowEnergyDrainPerSecond, 0.006, "crowEnergyDrainPerSecond"),
      0.0001,
      1,
    ),
    crowEnergyRecoveryPerSecond: clamp(
      positive(options.crowEnergyRecoveryPerSecond, 0.012, "crowEnergyRecoveryPerSecond"),
      0.0001,
      1,
    ),
    horizontalBounds: bounds,
    fixedStepSeconds: clamp(positive(options.fixedStepSeconds, 1 / 60, "fixedStepSeconds"), 1 / 240, 0.1),
    maxFrameDeltaSeconds: clamp(positive(options.maxFrameDeltaSeconds, 0.25, "maxFrameDeltaSeconds"), 0.05, 1),
  };
}

export class ActorController {
  private readonly options: ResolvedOptions;
  private readonly current: ActorControllerState;
  private accumulator = 0;

  constructor(options: ActorControllerOptions) {
    this.options = resolveOptions(options);
    this.options.pitch = clamp(this.options.pitch, -this.options.maximumFlightPitchRad, this.options.maximumFlightPitchRad);
    this.current = {
      kind: this.options.kind,
      mode: this.options.mode,
      identity: this.options.identity,
      ...this.options.position,
      yaw: this.options.yaw,
      pitch: this.options.pitch,
      speedMps: 0,
      verticalSpeedMps: 0,
      posePhase: 0,
      poseAmount: 0,
      gliding: false,
      roll: 0,
      flightEnergy: this.options.crowInitialEnergy,
      liftMps: 0,
      thermalLiftMps: 0,
      windXMps: this.options.crowWind.xMps,
      windZMps: this.options.crowWind.zMps,
      crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch",
      perched: this.options.kind === "crow" && this.options.mode === "ground",
      altitudeBoundContact: "none",
      distanceM: 0,
      elapsedSteps: 0,
    };
  }

  fixedStepSeconds(): number {
    return this.options.fixedStepSeconds;
  }

  /** Stable allocation-free view. Treat nested identity as read-only and frozen. */
  state(): Readonly<ActorControllerState> {
    return this.current;
  }

  /** Detached JSON-safe state for persistence, networking and tests. */
  snapshot(): ActorControllerSnapshot {
    return { ...this.current, identity: copyIdentity(this.current.identity) };
  }

  /** Restore a trusted JSON snapshot for deterministic environment checkpointing. */
  restore(snapshot: ActorControllerSnapshot): void {
    const numeric = Object.entries(snapshot)
      .filter(([, value]) => typeof value === "number")
      .every(([, value]) => Number.isFinite(value));
    if (
      !numeric || !validKind(snapshot.kind) ||
      (snapshot.mode !== "ground" && snapshot.mode !== "flight") ||
      (snapshot.altitudeBoundContact !== "none" &&
        snapshot.altitudeBoundContact !== "minimum" && snapshot.altitudeBoundContact !== "maximum") ||
      !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
    ) throw new RangeError("actor snapshot is incompatible or invalid");
    Object.assign(this.current, snapshot, { identity: checkedIdentity(snapshot.identity) });
    this.applyHorizontalBounds();
    if (this.current.mode === "flight" && this.current.kind === "crow") this.applyAltitudeBounds();
    this.accumulator = 0;
  }

  /** Replace profile/identity without changing kind, pose or position. */
  setIdentity(identity: ActorIdentity): void {
    this.current.identity = checkedIdentity(identity);
  }

  /** Change visible actor while preserving identity and finite world position. */
  setActorKind(kind: ActorKind): void {
    if (!validKind(kind)) throw new RangeError("unknown actor kind");
    if (kind === this.current.kind) return;
    this.current.kind = kind;
    this.current.speedMps = 0;
    this.current.verticalSpeedMps = 0;
    this.current.pitch = 0;
    this.current.poseAmount = 0;
    this.current.gliding = false;
    this.current.roll = 0;
    this.current.liftMps = 0;
    this.current.thermalLiftMps = 0;
    this.current.crowPose = kind === "crow" && this.current.mode === "ground" ? "perch" : "flap";
    this.current.perched = kind === "crow" && this.current.mode === "ground";
    this.current.altitudeBoundContact = "none";
    if (kind !== "crow") this.land();
  }

  /** Request a locomotion mode without consuming a simulation step. Non-crows cannot fly. */
  setMode(mode: ActorMode): void {
    if (mode === "flight" && this.current.kind === "crow") this.takeOff();
    else this.land();
  }

  /** Restore the original spawn, identity and kind and clear fractional time. */
  reset(): void {
    this.accumulator = 0;
    Object.assign(this.current, this.options.position, {
      kind: this.options.kind,
      mode: this.options.mode,
      identity: this.options.identity,
      yaw: this.options.yaw,
      pitch: this.options.pitch,
      speedMps: 0,
      verticalSpeedMps: 0,
      posePhase: 0,
      poseAmount: 0,
      gliding: false,
      roll: 0,
      flightEnergy: this.options.crowInitialEnergy,
      liftMps: 0,
      thermalLiftMps: 0,
      windXMps: this.options.crowWind.xMps,
      windZMps: this.options.crowWind.zMps,
      crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch",
      perched: this.options.kind === "crow" && this.options.mode === "ground",
      altitudeBoundContact: "none",
      distanceM: 0,
      elapsedSteps: 0,
    });
  }

  tick(deltaSeconds: number, actions: Partial<ActorActionSnapshot> = NEUTRAL_ACTOR_ACTIONS): number {
    if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
    const normalized = normalizeActorActions(actions);
    if (normalized.reset) {
      this.reset();
      return 0;
    }
    this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
    let steps = 0;
    while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
      this.stepNormalized(normalized);
      this.accumulator -= this.options.fixedStepSeconds;
      steps += 1;
    }
    return steps;
  }

  stepFixed(actions: Partial<ActorActionSnapshot> = NEUTRAL_ACTOR_ACTIONS): void {
    const normalized = normalizeActorActions(actions);
    if (normalized.reset) {
      this.reset();
      return;
    }
    this.stepNormalized(normalized);
  }

  private stepNormalized(actions: ActorActionSnapshot): void {
    if (actions.kindRequest !== "none") this.setActorKind(actions.kindRequest);
    if (actions.modeRequest === "ground") this.land();
    else if (actions.modeRequest === "flight" && this.current.kind === "crow") this.takeOff();

    if (this.current.mode === "flight" && this.current.kind === "crow") this.stepFlight(actions);
    else this.stepGround(actions);
    this.current.elapsedSteps += 1;
  }

  private stepGround(actions: ActorActionSnapshot): void {
    const dt = this.options.fixedStepSeconds;
    this.current.yaw = wrapAngle(
      this.current.yaw + actions.turn * this.options.groundTurnRateRadPerSecond * dt,
    );
    const input = Math.hypot(actions.forward, actions.right);
    const kindScale = this.current.kind === "dog" ? this.options.dogSpeedScale : 1;
    const maximum = (actions.sprint ? this.options.runSpeedMps : this.options.walkSpeedMps) * kindScale;
    const speed = maximum * input;
    const dx = (actions.right * Math.cos(this.current.yaw) - actions.forward * Math.sin(this.current.yaw)) * maximum * dt;
    const dz = (-actions.right * Math.sin(this.current.yaw) - actions.forward * Math.cos(this.current.yaw)) * maximum * dt;
    const beforeX = this.current.x;
    const beforeZ = this.current.z;
    this.current.x += dx;
    this.current.z += dz;
    this.applyHorizontalBounds();
    const moved = Math.hypot(this.current.x - beforeX, this.current.z - beforeZ);
    this.current.distanceM += moved;
    this.current.speedMps = speed;
    this.current.verticalSpeedMps = 0;
    this.current.y = this.options.groundY;
    this.current.pitch = 0;
    this.current.gliding = false;
    this.current.roll = moveToward(this.current.roll, 0, 7 * dt);
    this.current.liftMps = 0;
    this.current.thermalLiftMps = 0;
    this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap";
    this.current.perched = this.current.kind === "crow";
    this.current.flightEnergy = clamp(
      this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * 2.5 * dt,
      0,
      1,
    );
    this.current.altitudeBoundContact = "none";
    this.current.poseAmount = input;
    const strideLength = this.current.kind === "dog" ? 0.54 : 0.78;
    this.current.posePhase = wrapPhase(this.current.posePhase + (moved / strideLength) * TWO_PI);
  }

  private stepFlight(actions: ActorActionSnapshot): void {
    const dt = this.options.fixedStepSeconds;
    const idleSoaring =
      !actions.glide && Math.abs(actions.forward) < 0.02 && Math.abs(actions.turn) < 0.02 &&
      Math.abs(actions.pitch) < 0.02 && Math.abs(actions.climb) < 0.02;
    const speedFraction = clamp(
      this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001),
      0,
      2,
    );
    const targetRoll = actions.turn * 0.62;
    this.current.roll = moveToward(this.current.roll, targetRoll, 2.7 * dt);
    this.current.yaw = wrapAngle(
      this.current.yaw +
        (
          actions.turn * this.options.flightTurnRateRadPerSecond * (0.68 + speedFraction * 0.22) +
          Math.sin(this.current.roll) * 0.34
        ) * dt,
    );
    const targetPitch = actions.pitch * this.options.maximumFlightPitchRad;
    this.current.pitch = moveToward(this.current.pitch, targetPitch, 1.9 * dt);

    const throttle = (actions.forward + 1) / 2;
    const tucking = actions.glide && actions.forward < -0.6 && actions.pitch < -0.1;
    const recovering = actions.glide;
    if (recovering) {
      this.current.flightEnergy = clamp(
        this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * dt,
        0,
        1,
      );
    } else {
      const effort = 0.35 + throttle * 0.65 + Math.max(0, actions.climb) * 0.55;
      this.current.flightEnergy = clamp(
        this.current.flightEnergy - this.options.crowEnergyDrainPerSecond * effort * dt,
        0,
        1,
      );
    }
    const energyAuthority = 0.52 + this.current.flightEnergy * 0.48;
    const poweredTarget = this.options.minimumFlightSpeedMps +
      (this.options.maximumFlightSpeedMps - this.options.minimumFlightSpeedMps) * throttle * energyAuthority;
    const targetSpeed = tucking
      ? Math.min(this.options.maximumFlightSpeedMps, this.options.glideSpeedMps * 1.45)
      : actions.glide ? this.options.glideSpeedMps : poweredTarget;
    const response = actions.glide ? (tucking ? 3.1 : 2.2) : 5.5 * energyAuthority;
    this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, response * dt);
    // Parasitic drag rises with the square of airspeed. It is small enough that
    // a healthy powered bird holds its requested target but makes a tucked dive
    // finite instead of a perpetual acceleration source.
    const dragMps2 = 0.0045 * this.current.speedMps * this.current.speedMps;
    this.current.speedMps = clamp(
      this.current.speedMps - dragMps2 * dt * (actions.glide ? 0.7 : 0.25),
      0,
      this.options.maximumFlightSpeedMps,
    );

    let thermalLiftMps = 0;
    for (const thermal of this.options.crowThermals) {
      const normalizedDistance = Math.hypot(
        this.current.x - thermal.x,
        this.current.z - thermal.z,
      ) / thermal.radiusM;
      if (normalizedDistance >= 1) continue;
      const falloff = 1 - normalizedDistance;
      thermalLiftMps += thermal.liftMps * falloff * falloff;
    }
    this.current.thermalLiftMps = clamp(thermalLiftMps, 0, this.options.maximumClimbSpeedMps * 1.5);
    const liftRatio = this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001);
    const wingLiftMps = (liftRatio * liftRatio - 0.92) * (actions.glide ? 0.72 : 0.38);
    const flapLiftMps = actions.glide ? 0 : (0.18 + throttle * 0.46) * energyAuthority;
    const tuckPenaltyMps = tucking ? 1.35 : 0;
    this.current.liftMps = wingLiftMps + flapLiftMps + this.current.thermalLiftMps - tuckPenaltyMps;
    const pitchLift = Math.sin(this.current.pitch) * this.current.speedMps;
    const baselineSink = actions.glide ? (tucking ? 0.95 : 0.62) : 0.18;
    const targetVertical =
      pitchLift +
      actions.climb * this.options.maximumClimbSpeedMps * energyAuthority +
      this.current.liftMps -
      baselineSink;
    this.current.verticalSpeedMps = moveToward(
      this.current.verticalSpeedMps,
      clamp(
        targetVertical,
        -this.options.maximumClimbSpeedMps * 1.35,
        this.options.maximumClimbSpeedMps + this.current.thermalLiftMps,
      ),
      8 * dt,
    );

    const horizontalSpeed = this.current.speedMps * Math.cos(this.current.pitch);
    const beforeX = this.current.x;
    const beforeY = this.current.y;
    const beforeZ = this.current.z;
    this.current.x += (-Math.sin(this.current.yaw) * horizontalSpeed + this.options.crowWind.xMps) * dt;
    this.current.z += (-Math.cos(this.current.yaw) * horizontalSpeed + this.options.crowWind.zMps) * dt;
    this.current.y += this.current.verticalSpeedMps * dt;
    this.applyHorizontalBounds();
    this.applyAltitudeBounds();
    this.current.distanceM += Math.hypot(
      this.current.x - beforeX,
      this.current.y - beforeY,
      this.current.z - beforeZ,
    );
    this.current.gliding = actions.glide;
    this.current.perched = false;
    this.current.crowPose = tucking
      ? "tuck"
      : actions.glide
        ? Math.abs(this.current.roll) > 0.16 ? "bank" : "glide"
        : idleSoaring ? "glide" : "flap";
    this.current.poseAmount = actions.glide || idleSoaring ? (tucking ? 0.42 : 0.72) : 0.55 + throttle * 0.45;
    if (!actions.glide) {
      // A tired bird loses cadence before it loses basic control authority.
      const flapRate = 3.5 + throttle * (2.2 + energyAuthority * 1.8);
      this.current.posePhase = wrapPhase(this.current.posePhase + flapRate * TWO_PI * dt);
    }
  }

  private land(): void {
    this.current.mode = "ground";
    this.current.y = this.options.groundY;
    this.current.pitch = 0;
    this.current.roll = 0;
    this.current.verticalSpeedMps = 0;
    this.current.liftMps = 0;
    this.current.thermalLiftMps = 0;
    this.current.gliding = false;
    this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap";
    this.current.perched = this.current.kind === "crow";
    this.current.altitudeBoundContact = "none";
  }

  private takeOff(): void {
    this.current.mode = "flight";
    this.current.y = Math.max(this.current.y, this.options.groundY + this.options.minFlightAltitude);
    this.current.crowPose = "flap";
    this.current.perched = false;
    this.current.altitudeBoundContact = "none";
  }

  private applyHorizontalBounds(): void {
    const bounds = this.options.horizontalBounds;
    if (!bounds) return;
    this.current.x = clamp(this.current.x, bounds.minX, bounds.maxX);
    this.current.z = clamp(this.current.z, bounds.minZ, bounds.maxZ);
  }

  private applyAltitudeBounds(): void {
    const minimum = this.options.groundY + this.options.minFlightAltitude;
    const maximum = this.options.groundY + this.options.maxFlightAltitude;
    if (this.current.y <= minimum) {
      this.current.y = minimum;
      this.current.verticalSpeedMps = Math.max(0, this.current.verticalSpeedMps);
      this.current.altitudeBoundContact = "minimum";
    } else if (this.current.y >= maximum) {
      this.current.y = maximum;
      this.current.verticalSpeedMps = Math.min(0, this.current.verticalSpeedMps);
      this.current.altitudeBoundContact = "maximum";
    } else {
      this.current.altitudeBoundContact = "none";
    }
  }
}

/** Execute an exact, allocation-friendly input recording for tests or playback. */
export function replayActorInputs(
  options: ActorControllerOptions,
  frames: readonly TimedActorInputFrame[],
): ActorReplayResult {
  const controller = new ActorController(options);
  const trajectory: ActorControllerSnapshot[] = [controller.snapshot()];
  for (const frame of frames) {
    const steps = Number.isSafeInteger(frame.steps) && frame.steps > 0 ? frame.steps : 0;
    for (let index = 0; index < steps; index += 1) {
      controller.stepFixed(frame.actions);
      trajectory.push(controller.snapshot());
    }
  }
  return { trajectory, final: controller.snapshot() };
}
