import type { PlayerSnapshot, Vec3 } from "@psx/shared";
import { angleDelta, SNAPSHOT_INTERVAL } from "@psx/shared";

/**
 * Entity interpolation for remote players — GDD §8.
 *
 * Snapshots arrive at 15 Hz over an unreliable channel, so rendering them
 * directly would be visibly steppy and would stutter on every dropped packet.
 * Instead we render remote peers slightly in the past — far enough back that
 * two snapshots almost always bracket the render time — and interpolate.
 *
 * The delay is two snapshot intervals (~133ms). One interval is the minimum
 * that can bracket at all; two absorbs a single dropped packet without the
 * avatar freezing, which is the common case on a home connection.
 */
export const INTERPOLATION_DELAY = SNAPSHOT_INTERVAL * 2;

/** Past this far behind, we stop interpolating and hold the last known pose. */
const MAX_EXTRAPOLATION = 0.25;

interface TimedSnapshot {
  time: number;
  snapshot: PlayerSnapshot;
}

export interface InterpolatedPose {
  position: Vec3;
  yaw: number;
  speed: number;
  grounded: boolean;
  /** False when the buffer has run dry and the pose is stale. */
  fresh: boolean;
}

export class SnapshotBuffer {
  private readonly buffer: TimedSnapshot[] = [];
  /** Two intervals of history is all interpolation can reach back through. */
  private readonly capacity = 32;

  push(snapshot: PlayerSnapshot, time: number): void {
    // Reordering is expected on an unordered channel; drop anything older than
    // what we already hold rather than interpolating backwards through it.
    const newest = this.buffer[this.buffer.length - 1];
    if (newest && time <= newest.time) return;

    this.buffer.push({ time, snapshot });
    if (this.buffer.length > this.capacity) this.buffer.shift();
  }

  /** Sample the pose to render at wall-clock `now`. */
  sample(now: number): InterpolatedPose | null {
    if (this.buffer.length === 0) return null;

    const renderTime = now - INTERPOLATION_DELAY;
    const oldest = this.buffer[0]!;
    const newest = this.buffer[this.buffer.length - 1]!;

    // Buffer has not filled yet — show the oldest we have rather than nothing.
    if (renderTime <= oldest.time) return pose(oldest.snapshot, true);

    // Ahead of everything we hold: the sender stalled or packets were lost.
    if (renderTime >= newest.time) {
      const staleness = renderTime - newest.time;
      return pose(newest.snapshot, staleness < MAX_EXTRAPOLATION);
    }

    for (let i = this.buffer.length - 1; i > 0; i--) {
      const after = this.buffer[i]!;
      const before = this.buffer[i - 1]!;
      if (renderTime < before.time || renderTime > after.time) continue;

      const span = after.time - before.time;
      const t = span > 1e-6 ? (renderTime - before.time) / span : 0;
      return interpolate(before.snapshot, after.snapshot, t);
    }

    return pose(newest.snapshot, true);
  }

  clear(): void {
    this.buffer.length = 0;
  }

  get size(): number {
    return this.buffer.length;
  }
}

function pose(snapshot: PlayerSnapshot, fresh: boolean): InterpolatedPose {
  return {
    position: { ...snapshot.p },
    yaw: snapshot.yaw,
    speed: snapshot.spd,
    grounded: snapshot.grounded,
    fresh,
  };
}

function interpolate(a: PlayerSnapshot, b: PlayerSnapshot, t: number): InterpolatedPose {
  return {
    position: {
      x: a.p.x + (b.p.x - a.p.x) * t,
      y: a.p.y + (b.p.y - a.p.y) * t,
      z: a.p.z + (b.p.z - a.p.z) * t,
    },
    // Shortest path, so a peer turning through ±π does not spin the long way.
    yaw: a.yaw + angleDelta(a.yaw, b.yaw) * t,
    speed: a.spd + (b.spd - a.spd) * t,
    grounded: t < 0.5 ? a.grounded : b.grounded,
    fresh: true,
  };
}
