// Replication — the state half of LeCodes multiplayer (docs/multiplayer-plan.md §3–4, phase 2):
// `Net.kind` (a replicated entity type: defaults = the snapshot layout + the client factory),
// `Net.input` (the per-tick input layout), the `Replicated` aspect (the server's handle on an entity;
// the SDK attaches it to the client's proxy), inputs client→server, 20 Hz snapshots server→client,
// spawn / despawn, the client's server-tick estimate + input lead control, snapshot interpolation
// for remotes and predict + delta-correct for the owner.
//
// The clock: `Net.tick` counts fixed steps (one per Jolt substep — core/Aspect.ts `updateFixed`).
// The server's tick IS the simulation tick. A client runs AHEAD of the server by `lead` ticks so its
// input for tick T is on the server before the server simulates T; the snapshot header tells the
// client how far ahead its newest input was (`lastInputTick − tick`) and the client nudges its own
// tick to keep that inside [2, 8]. Remotes render at `serverEstimate − 6` (100 ms behind, between
// two snapshots). The owner's proxy is its own prediction: same input, same code, same dt as the
// server; when a snapshot for tick T disagrees with the recorded prediction for T the body is
// shifted by the delta (later ticks keep their relative motion — no rewind) and the visual
// offset `rep.correction` absorbs the jump and decays.
//
// Wire (channel 1, unreliable, binary; a leading type byte):
//   1 inputs    C→S  u8 1 · u32 firstTick · u8 count · count × input record        (last 3 ticks)
//   2 snapshot  S→C  u8 2 · u32 tick · u32 lastInputTick · u8 missed · u16 count ·
//                    count × { u16 netId · u8 kind · state record }                (every 3rd tick)
// Channel 0 (reliable JSON): `$spawn {netId, kind, owner, state}` · `$despawn {netId}`.

import { Aspect } from "../core/Aspect"
import { Time } from "../core/time"
import { Vec3 } from "../math/vec"
import type { Node } from "../gl/Node"
import { Scene } from "../gl/Scene"
import { assignState, cloneDefaults, hashString, lerpState, makeLayout, packInto, unpackFrom, type Layout } from "./codec"
import { CH_UNRELIABLE, CH_RELIABLE, LOCAL_SLOT, NetPlayer, bridge, broadcastRaw, hooks, sendRaw, state, type NetRole } from "./core"

export type NetTransform = "pos" | "pos+rot" | "none"

/** The per-tick input layout: `const PlayerInput = Net.input({ moveX: 0, moveY: 0, jump: false })`. */
export type NetInput<T extends object = any> = { readonly layout: Layout; readonly defaults: T; readonly __t?: T }

/** A replicated entity type: `const PlayerKind = Net.kind('player', { pos: [0,0,0], hp: 100 }, { transform: 'pos', client })`. */
export type NetKind<S extends object = any> = {
  readonly name: string
  /** @internal wire id (registration order; the welcome carries the name table) */
  readonly id: number
  readonly layout: Layout
  readonly defaults: S
  /** Which state fields the SDK samples on the server and applies to a client proxy: `pos` (a
   *  `pos: [x,y,z]` field), `pos+rot` (+ a `rot: [x,y,z,w]` quaternion field), or `none`. */
  readonly transform: NetTransform
  /** Births the client-side proxy on spawn; the SDK attaches `Replicated` to what it returns. */
  readonly client?: (rep: NetEntity<S>, scene: Scene | null) => Node | void
  readonly __t?: S
  /** @internal scratch state for snapshot decoding */
  readonly _scratch: S
}

const TICK_MS = 1000 / 60
const SNAPSHOT_EVERY = 3        // 20 Hz
const INTERP_DELAY = 6          // ticks behind the estimated server tick (100 ms)
const LEAD_MIN = 2, LEAD_MAX = 8
const SNAPSHOT_BUDGET = 1000    // bytes — one unreliable datagram (creator-net caps messages at 1024)
const CORRECTION_TAU = 0.1      // seconds — the visual offset's decay time constant
const SNAP_DISTANCE = 2         // metres — a bigger disagreement is a teleport: no smoothing
const NO_INPUT = 0xffffffff
const VERTICAL_DEADBAND = 0.12  // metres — owner height disagreements below this are floor-snap noise

// ---- registries ---------------------------------------------------------------------------------------

const kinds = new Map<string, NetKind>()
const kindList: NetKind[] = []            // index = wire id
let clientKinds: (NetKind | undefined)[] = []   // client: wire id → kind, from the welcome's name table
let inputDef: NetInput | null = null
const entities = new Map<number, NetEntity>()
let nextNetId = 1

let tick = 0
let localInput: any = null                // what Net.setInput set this tick (every role but `server`)
const inputRing = new Map<number, any>()  // client: tick → values, resent with the next 2 sends
let estimate = -1                         // client: the server's tick, fractional, advanced per frame
let leadCooldown = 0
let pendingSnapshot: { tick: number; body: Uint8Array } | null = null   // server: built per 3rd tick, sent at frame end
let snapshotStart = 0                     // server: round-robin start when the budget cuts a snapshot
let warnedBudget = false
let warnedKind = false

/** Live counters (`Net.stats`): how the clocks and the prediction are doing. */
export const stats = {
  /** client: snapshots received */ snapshots: 0,
  /** client: how far ahead of the server's simulation our newest input was at the last snapshot */ ahead: 0,
  /** client: our tick minus the estimated server tick */ lead: 0,
  /** client: tick nudges (+1 / −1) by the lead control */ nudges: 0,
  /** owner: corrections applied, the largest one and their sum (metres) — `sum / corrections` = the typical one */ corrections: 0, maxCorrection: 0, sumCorrection: 0,
  /** server: inputs that arrived late (the last one was repeated), all clients */ missed: 0,
  /** bytes sent on the unreliable channel (snapshots / inputs) */ bytesOut: 0,
}

// The scene a client births proxies into: the active one. (Net replication implies a 3D scene —
// `transform` mirrors a Node's pose — so importing Scene here is honest, not a 2D-bundle tax.)
const activeScene = (): Scene | null => Scene.active

// ---- NetEntity: one replicated entity, the same object on every machine that knows it -----------------

type PlayerIn = { ring: Map<number, any>; last: any; lastTick: number; missed: number; cur: any }
const inOf = (p: NetPlayer): PlayerIn => (p._in ??= { ring: new Map(), last: null, lastTick: -1, missed: 0, cur: null })

export class NetEntity<S extends object = any> {
  /** Session-unique id, assigned by the server. */
  readonly netId: number
  readonly kind: NetKind<S>
  /** The owning player's id; 0 = the server's own (props, the game state). */
  readonly ownerId: number
  /** Server: the authoritative state you write (`pos` is sampled from the node). Client remote:
   *  interpolated. Client owner: the newest authoritative state. */
  readonly state: S
  /** The node carrying this entity (server: yours; client: what the kind's factory returned). */
  node: Node | null = null
  /** Owner on a client: the visual offset left by the last correction, decaying to zero — add it to
   *  what you DRAW (a child mesh, the camera), never to physics. */
  readonly correction = new Vec3()
  /** @internal remote: received states by tick, ascending */
  _ring: { tick: number; s: S }[] = []
  /** @internal owner: predicted position after each tick */
  _pred = new Map<number, [number, number, number]>()
  /** @internal owner: the body shift a snapshot asked for, applied at the next fixed step (where the
   *  node holds the post-step pose — in between it holds the render interpolation) */
  _shift: [number, number, number] | null = null
  /** @internal */
  _listeners: Array<(s: S) => void> = []

  constructor(netId: number, kind: NetKind<S>, ownerId: number, s: S) {
    this.netId = netId
    this.kind = kind
    this.ownerId = ownerId
    this.state = s
  }

  get owner(): NetPlayer | null {
    return this.ownerId ? state.players.find((p) => p.id === this.ownerId) ?? null : null
  }
  /** Our own player owns it (offline: everything owned by `Net.me`). */
  get isMine(): boolean { return this.ownerId !== 0 && state.me !== null && state.me.id === this.ownerId }
  /** This machine runs the simulation for it: the server always, a client for what it owns
   *  (prediction). A remote proxy on a client is interpolation only. */
  get simulated(): boolean { return state.isServer || this.isMine }

  /** The owner's input for the tick being simulated — the ring entry on the server (the last one
   *  repeated when it is late), the local input on the owner / host / offline. */
  input<T extends object>(inp: NetInput<T>): T {
    if (!state.isServer || this.isMine) return (localInput ?? inp.defaults) as T
    const p = this.owner
    return (p?._in?.cur ?? inp.defaults) as T
  }

  /** Client: `state` was refreshed (a snapshot landed / the interpolation moved). */
  on(event: "state", cb: (s: S) => void): this { if (!this._listeners.includes(cb)) this._listeners.push(cb); return this }
  off(event: "state", cb: (s: S) => void): this { const i = this._listeners.indexOf(cb); if (i >= 0) this._listeners.splice(i, 1); return this }
  /** @internal */
  _changed(): void { for (const fn of this._listeners) fn(this.state) }
}

// ---- declarations (Net.kind / Net.input) --------------------------------------------------------------

export const declareKind = <S extends object>(name: string, defaults: S, opts: { transform?: NetTransform; client?: NetKind<S>["client"] } = {}): NetKind<S> => {
  if (kinds.has(name)) throw new Error(`Net.kind('${name}') is already declared`)
  if (kindList.length >= 255) throw new Error("Net.kind: at most 255 kinds")
  const layout = makeLayout(defaults as Record<string, unknown>, `Net.kind('${name}')`)
  const transform = opts.transform ?? "none"
  const need = transform === "pos" ? ["pos"] : transform === "pos+rot" ? ["pos", "rot"] : []
  for (const key of need) {
    const f = layout.fields.find((x) => x.key === key)
    const len = key === "pos" ? 3 : 4
    if (!f || f.kind !== 2 || f.len !== len) throw new Error(`Net.kind('${name}'): transform '${transform}' needs a '${key}' field of ${len} numbers`)
  }
  const kind: NetKind<S> = { name, id: kindList.length, layout, defaults: cloneDefaults(defaults), transform, client: opts.client, _scratch: cloneDefaults(defaults) }
  kinds.set(name, kind)
  kindList.push(kind)
  return kind
}

export const declareInput = <T extends object>(defaults: T): NetInput<T> => {
  if (inputDef) throw new Error("Net.input: one input layout per game")
  const layout = makeLayout(defaults as Record<string, unknown>, "Net.input")
  inputDef = { layout, defaults: cloneDefaults(defaults) }
  localInput = cloneDefaults(defaults)
  return inputDef as NetInput<T>
}

export const setInput = <T extends object>(inp: NetInput<T>, values: Partial<T>): void => {
  if (inp !== inputDef) throw new Error("Net.setInput: not the declared Net.input")
  const v = cloneDefaults(inp.defaults)
  assignState(inp.layout, v, values)
  localInput = v
  if (state.role === "client") {
    inputRing.set(tick, v)
    for (const t of inputRing.keys()) if (t < tick - 8) inputRing.delete(t)
  }
}

export const currentTick = (): number => tick
export const allEntities = (): NetEntity[] => [...entities.values()]
export const entityById = (netId: number): NetEntity | undefined => entities.get(netId)

const schemaHash = (): string =>
  hashString(kindList.map((k) => `${k.name}=${k.layout.sig}`).join("|") + "#" + (inputDef?.layout.sig ?? ""))

// ---- spawn / despawn (server side; the client mirrors) ---------------------------------------------------

const samplePose = (e: NetEntity): void => {
  const t = e.kind.transform
  if (t === "none" || !e.node) return
  const s = e.state as any
  const p = e.node.position
  s.pos[0] = p.x; s.pos[1] = p.y; s.pos[2] = p.z
  if (t === "pos+rot") {
    const q = e.node.quaternion
    s.rot[0] = q.x; s.rot[1] = q.y; s.rot[2] = q.z; s.rot[3] = q.w
  }
}

const applyPose = (e: NetEntity): void => {
  const t = e.kind.transform
  if (t === "none" || !e.node) return
  const s = e.state as any
  e.node.position = s.pos
  if (t === "pos+rot") e.node.quaternion = s.rot
}

const spawnPayload = (e: NetEntity): string => {
  samplePose(e)
  return JSON.stringify(["$spawn", { netId: e.netId, kind: e.kind.name, owner: e.ownerId, state: e.state }])
}

const spawnOnServer = (kind: NetKind, owner: NetPlayer | null, node: Node): NetEntity => {
  const e = new NetEntity(nextNetId++, kind, owner?.id ?? 0, cloneDefaults(kind.defaults))
  e.node = node
  entities.set(e.netId, e)
  if (state.role !== "offline") broadcastRaw(spawnPayload(e), null)
  return e
}

const despawnOnServer = (e: NetEntity): void => {
  if (entities.get(e.netId) !== e) return
  entities.delete(e.netId)
  e.node = null
  if (state.role !== "offline") broadcastRaw(JSON.stringify(["$despawn", { netId: e.netId }]), null)
}

const onClientSpawn = (d: any): void => {
  if (!d || entities.has(d.netId)) return
  const kind = kinds.get(d.kind)
  if (!kind) { console.warn(`Net: spawn of unknown kind '${d.kind}' — is this the same bundle?`); return }
  const s = cloneDefaults(kind.defaults)
  assignState(kind.layout, s, d.state)
  const e = new NetEntity(d.netId, kind, d.owner | 0, s)
  entities.set(e.netId, e)
  if (!kind.client) return
  const node = kind.client(e, activeScene())
  if (!node) return
  e.node = node
  node.aspect(Replicated, { _rec: e })
  applyPose(e)
}

const onClientDespawn = (d: any): void => {
  const e = entities.get(d?.netId)
  if (!e) return
  entities.delete(e.netId)
  const node = e.node
  e.node = null
  node?.destroy()
}

// ---- the Replicated aspect ---------------------------------------------------------------------------

/**
 * The server's handle on a replicated entity — attach it to the node that IS the entity:
 * `new Node().aspect(Replicated, { kind: PlayerKind, owner: player })`. From then on the entity exists
 * on every client (the kind's `client` factory births the proxy there, and the SDK attaches this same
 * aspect to it), its state is packed into every snapshot, and `node.destroy()` despawns it everywhere.
 * `node.replicated.state` / `.isMine` / `.simulated` / `.input()` are the same on both sides.
 */
export class Replicated extends Aspect<"replicated", Node> {
  static readonly aspect = "replicated"

  /** Server: the entity type (required). */
  kind!: NetKind<any>
  /** Server: the owning player, or null for the server's own. */
  owner: NetPlayer | null = null
  /** @internal set by the SDK when it attaches the aspect to a client proxy */
  _rec: NetEntity | null = null

  protected onAttach(): void {
    if (this._rec) { this._rec.node = this.node; return }
    if (!state.isServer) {
      console.warn("Replicated: only the server spawns entities — on a client the kind's `client` factory births the proxy")
      return
    }
    if (!this.kind) throw new Error("Replicated needs { kind }")
    this._rec = spawnOnServer(this.kind, this.owner, this.node)
  }

  protected onDetach(): void {
    const e = this._rec
    if (!e) return
    this._rec = null
    if (state.isServer) despawnOnServer(e)
    else if (entities.get(e.netId) === e) { entities.delete(e.netId); e.node = null }
  }

  /** The entity itself (what the client factory received). */
  get entity(): NetEntity { return this._rec! }
  get netId(): number { return this._rec?.netId ?? 0 }
  get state(): any { return this._rec?.state }
  get isMine(): boolean { return this._rec?.isMine ?? false }
  get simulated(): boolean { return this._rec?.simulated ?? state.isServer }
  get ownerPlayer(): NetPlayer | null { return this._rec?.owner ?? null }
  get correction(): Vec3 { return this._rec?.correction ?? ZERO }
  /** The owner's input for the tick being simulated (see `NetEntity.input`). */
  input<T extends object>(inp: NetInput<T>): T { return this._rec ? this._rec.input(inp) : (localInput ?? inp.defaults) }
  // state-change listeners live on the entity: `node.replicated.entity.on('state', cb)` (the aspect's
  // own on/off are its aspect events)
}
const ZERO = new Vec3()

// ---- server: inputs in, snapshots out ------------------------------------------------------------------

const onServerBinary = (p: NetPlayer, channel: number, data: ArrayBuffer): void => {
  if (!inputDef || data.byteLength < 6) return
  const view = new DataView(data)
  if (view.getUint8(0) !== 1) return
  const first = view.getUint32(1, true)
  const count = view.getUint8(5)
  const s = inOf(p)
  let o = 6
  for (let i = 0; i < count; i++) {
    if (o + inputDef.layout.bytes > data.byteLength) break
    const t = first + i
    if (t > tick + 120) break   // garbage
    if (t > tick || (t === tick && s.cur === null)) {
      const v = cloneDefaults(inputDef.defaults)
      o = unpackFrom(view, o, inputDef.layout, v)
      s.ring.set(t, v)
    } else o += inputDef.layout.bytes   // already simulated — late
    if (t > s.lastTick) s.lastTick = t
  }
}

// At the top of each fixed step: the input every remote player gets for this tick.
const serverConsumeInputs = (): void => {
  for (const p of state.players) {
    if (p._slot === LOCAL_SLOT) continue
    const s = inOf(p)
    const v = s.ring.get(tick)
    if (v) { s.cur = v; s.last = v; s.ring.delete(tick) }
    else { s.cur = s.last; if (s.lastTick >= 0) { s.missed++; stats.missed++ } }
    for (const t of s.ring.keys()) if (t < tick) s.ring.delete(t)
  }
}

const buildSnapshot = (forTick: number): void => {
  const list = [...entities.values()]
  if (list.length === 0) { pendingSnapshot = null; return }
  let size = 0
  for (const e of list) size += 3 + e.kind.layout.bytes
  const buf = new ArrayBuffer(Math.min(size, SNAPSHOT_BUDGET))
  const view = new DataView(buf)
  let o = 0
  let written = 0
  const n = list.length
  if (size > SNAPSHOT_BUDGET && !warnedBudget) {
    warnedBudget = true
    console.warn(`Net: a full snapshot is ${size} B, over the ${SNAPSHOT_BUDGET} B datagram budget — entities are rotated through it (fewer or smaller kinds fix this)`)
  }
  for (let i = 0; i < n; i++) {
    const e = list[(snapshotStart + i) % n]
    const rec = 3 + e.kind.layout.bytes
    if (o + rec > buf.byteLength) break
    samplePose(e)
    view.setUint16(o, e.netId, true); o += 2
    view.setUint8(o, e.kind.id); o += 1
    o = packInto(view, o, e.kind.layout, e.state)
    written++
  }
  if (written < n) snapshotStart = (snapshotStart + written) % n
  pendingSnapshot = { tick: forTick, body: new Uint8Array(buf, 0, o), }
  ;(pendingSnapshot as any).count = written
}

const sendSnapshots = (): void => {
  const snap = pendingSnapshot
  pendingSnapshot = null
  if (!snap) return
  const count = (snap as any).count as number
  for (const p of state.players) {
    if (p._slot === LOCAL_SLOT) continue
    const s = inOf(p)
    const buf = new ArrayBuffer(12 + snap.body.byteLength)
    const view = new DataView(buf)
    view.setUint8(0, 2)
    view.setUint32(1, snap.tick, true)
    view.setUint32(5, s.lastTick >= 0 ? s.lastTick : NO_INPUT, true)
    view.setUint8(9, Math.min(255, s.missed))
    view.setUint16(10, count, true)
    new Uint8Array(buf, 12).set(snap.body)
    s.missed = 0
    stats.bytesOut += buf.byteLength
    sendRaw(p._slot, CH_UNRELIABLE, buf)
  }
}

// ---- client: snapshots in, inputs out, the clock, interpolation, correction ----------------------------

const rttTicks = (): number => (bridge()?.clientInfo(0)[1] ?? 0) / TICK_MS

const onClientBinary = (channel: number, data: ArrayBuffer): void => {
  if (data.byteLength < 12) return
  const view = new DataView(data)
  if (view.getUint8(0) !== 2) return
  const T = view.getUint32(1, true)
  const lastIn = view.getUint32(5, true)
  const count = view.getUint16(10, true)

  // the server clock as SEEN here: the newest snapshot's tick, advanced per frame. (Not + rtt/2 — the
  // interpolation only needs "ticks behind the newest arrival", and a half round trip is a guess
  // that a one-sided delay, like the dev simulator's, gets wrong.)
  const target = T
  if (estimate < 0 || Math.abs(target - estimate) > 30) estimate = target
  else estimate += (target - estimate) * 0.1

  // input lead: keep our newest input 2–8 ticks ahead of the server's simulation
  stats.snapshots++
  if (lastIn !== NO_INPUT) stats.ahead = lastIn - T
  // Behind (inputs arriving late): jump straight to the target lead — every tick spent behind is a
  // repeated input on the server and a correction here. Too far ahead: back off one tick at a time
  // (a repeated tick re-simulates once; cheap, and rare).
  if (leadCooldown > 0) leadCooldown--
  else if (lastIn !== NO_INPUT && inputRing.size > 0) {
    const ahead = lastIn - T
    if (ahead < LEAD_MIN) { tick += Math.min(30, LEAD_MIN + 2 - ahead); leadCooldown = 10; stats.nudges++ }
    else if (ahead > LEAD_MAX) { tick -= 1; leadCooldown = 10; stats.nudges++ }
  }

  let o = 12
  for (let i = 0; i < count; i++) {
    if (o + 3 > data.byteLength) break
    const netId = view.getUint16(o, true)
    const kind = clientKinds[view.getUint8(o + 2)]
    o += 3
    if (!kind) {
      if (!warnedKind) { warnedKind = true; console.warn("Net: snapshot with an unknown kind id — dropped") }
      return
    }
    if (o + kind.layout.bytes > data.byteLength) break
    const e = entities.get(netId)
    if (!e) { o += kind.layout.bytes; continue }   // its $spawn is still in flight
    if (e.isMine) {
      o = unpackFrom(view, o, kind.layout, e.state)
      reconcileOwner(e, T)
      e._changed()
    } else {
      const s = cloneDefaults(kind.defaults)
      o = unpackFrom(view, o, kind.layout, s)
      const ring = e._ring
      if (ring.length === 0 || T > ring[ring.length - 1].tick) ring.push({ tick: T, s })
      else if (!ring.some((r) => r.tick === T)) { ring.push({ tick: T, s }); ring.sort((a, b) => a.tick - b.tick) }
      if (ring.length > 32) ring.shift()
    }
  }
}

// The owner's prediction for tick T vs the server's state for T: the body is shifted by the
// difference (the ticks simulated since T keep their relative motion — no rewind), the view stays
// where it was via `correction`, and the later prediction records move along so the same error is
// not applied twice. The shift itself lands at the next fixed step (applyOwnerShift): only there
// does the node hold the post-step pose — in the frame phases it holds the render interpolation,
// and shifting THAT would bake up to a step of lag into the body and never converge.
const reconcileOwner = (e: NetEntity, T: number): void => {
  if (e.kind.transform === "none" || !e.node) return
  const pred = e._pred.get(T)
  for (const t of e._pred.keys()) if (t <= T) e._pred.delete(t)
  if (!pred) return
  const s = e.state as any
  const dx = s.pos[0] - pred[0], dz = s.pos[2] - pred[2]
  // Vertical deadband: a grounded character's height differs by a few cm between two machines
  // (landing phase, floor snap) and lifting the body by that much only makes it fall again — an
  // oscillation the correction itself feeds. Only a real height difference (a ledge, a jump) counts.
  let dy = s.pos[1] - pred[1]
  if (Math.abs(dy) < VERTICAL_DEADBAND) dy = 0
  const d = Math.hypot(dx, dy, dz)
  if (d < 0.005) return
  stats.corrections++
  stats.sumCorrection += d
  if (d > stats.maxCorrection) stats.maxCorrection = d
  for (const q of e._pred.values()) { q[0] += dx; q[1] += dy; q[2] += dz }
  const sh = e._shift ?? (e._shift = [0, 0, 0])
  sh[0] += dx; sh[1] += dy; sh[2] += dz
  if (d >= SNAP_DISTANCE) e.correction.set(0, 0, 0)
}

const applyOwnerShift = (e: NetEntity): void => {
  const sh = e._shift
  if (!sh || !e.node) return
  e._shift = null
  const p = e.node.position
  e.node.position = [p.x + sh[0], p.y + sh[1], p.z + sh[2]]
  const rec = e._pred.get(tick - 1)
  if (rec) { rec[0] += sh[0]; rec[1] += sh[1]; rec[2] += sh[2] }
  if (Math.hypot(sh[0], sh[1], sh[2]) < SNAP_DISTANCE) { e.correction.x -= sh[0]; e.correction.y -= sh[1]; e.correction.z -= sh[2] }
}

const clientFrame = (): void => {
  const dt = Time.unscaledDt
  if (estimate >= 0) estimate += dt * 60
  stats.lead = estimate >= 0 ? tick - estimate : 0
  const rt = estimate - INTERP_DELAY
  const k = Math.exp(-dt / CORRECTION_TAU)
  for (const e of entities.values()) {
    if (e.isMine) {
      const c = e.correction
      if (c.x !== 0 || c.y !== 0 || c.z !== 0) {
        c.x *= k; c.y *= k; c.z *= k
        if (Math.hypot(c.x, c.y, c.z) < 1e-3) c.set(0, 0, 0)
      }
      continue
    }
    const ring = e._ring
    if (ring.length === 0) continue
    const layout = e.kind.layout
    if (rt <= ring[0].tick) lerpState(layout, ring[0].s, ring[0].s, 0, e.state)
    else if (rt >= ring[ring.length - 1].tick) lerpState(layout, ring[ring.length - 1].s, ring[ring.length - 1].s, 0, e.state)
    else {
      let i = ring.length - 2
      while (i > 0 && ring[i].tick > rt) i--
      const a = ring[i], b = ring[i + 1]
      lerpState(layout, a.s, b.s, (rt - a.tick) / (b.tick - a.tick), e.state)
    }
    while (ring.length > 2 && ring[1].tick < rt - 30) ring.shift()
    applyPose(e)
    e._changed()
  }
}

const sendInputs = (): void => {
  if (!inputDef || inputRing.size === 0) return
  const first = tick - 2
  const buf = new ArrayBuffer(6 + 3 * inputDef.layout.bytes)
  const view = new DataView(buf)
  view.setUint8(0, 1)
  view.setUint32(1, first, true)
  view.setUint8(5, 3)
  let o = 6
  let last: any = localInput ?? inputDef.defaults
  for (let t = first; t <= tick; t++) {
    const v = inputRing.get(t)
    if (v) last = v
    o = packInto(view, o, inputDef.layout, last)
  }
  stats.bytesOut += buf.byteLength
  sendRaw(0, CH_UNRELIABLE, buf)
}

// ---- the frame hooks (core/Aspect.ts phase hooks, installed by startSession) -----------------------------

const frameEarly = (): void => {
  if (state.role === "client" && state.status === "online") clientFrame()
}

const frameFixed = (): void => {
  tick++
  if (state.isServer) {
    serverConsumeInputs()
    if (state.role !== "offline" && (tick - 1) % SNAPSHOT_EVERY === 0) buildSnapshot(tick - 1)
  } else if (state.status === "online") {
    // prediction records: the state after step tick−1 has landed on the entities by now; then the
    // shift the last snapshot asked for (the record just taken moves with it)
    for (const e of entities.values()) {
      if (!e.isMine || !e.node || e.kind.transform === "none") continue
      const p = e.node.position
      e._pred.set(tick - 1, [p.x, p.y, p.z])
      if (e._pred.size > 128) for (const t of e._pred.keys()) { if (e._pred.size <= 128) break; e._pred.delete(t) }
      applyOwnerShift(e)
    }
  }
}

const frameLate = (): void => {
  if (state.isServer) sendSnapshots()
  else if (state.status === "online") sendInputs()
}

// ---- session lifecycle (called by Net.listen / Net.connect / the reset) ----------------------------------

export const installReplication = (): void => {
  hooks.frameEarly = frameEarly
  hooks.frameFixed = frameFixed
  hooks.frameLate = frameLate
  hooks.helloExtra = () => ({ schema: schemaHash() })
  hooks.helloCheck = (d) => (d?.schema !== schemaHash() ? "schema" : null)
  hooks.welcomeExtra = () => ({ tick, kinds: kindList.map((k) => k.name) })
  hooks.afterWelcome = (p) => { for (const e of entities.values()) sendRaw(p._slot, CH_RELIABLE, spawnPayload(e)) }
  hooks.onWelcome = (d) => {
    clientKinds = ((d?.kinds ?? []) as string[]).map((n) => kinds.get(n))
    // yojimbo has no round-trip sample yet at the welcome: assume ~100 ms and let the lead control
    // settle the rest from the snapshots' feedback
    const rtt = Math.max(rttTicks(), 6)
    estimate = (d?.tick ?? 0) + rtt / 2
    tick = Math.ceil((d?.tick ?? 0) + rtt + 4)
    leadCooldown = 10
  }
  hooks.clientReserved = (name, d) => { if (name === "$spawn") onClientSpawn(d); else if (name === "$despawn") onClientDespawn(d) }
  hooks.serverBinary = onServerBinary
  hooks.clientBinary = onClientBinary
  hooks.playerLeft = (p) => { for (const e of [...entities.values()]) if (e.ownerId === p.id) { if (e.node) e.node.destroy(); else despawnOnServer(e) } }
  hooks.reset = (role: NetRole) => {
    if (role === "client") {
      // the proxies were ours to make — take them down; the server's own nodes are the game's
      for (const e of [...entities.values()]) { entities.delete(e.netId); const n = e.node; e.node = null; n?.destroy() }
      estimate = -1
    }
    inputRing.clear()
    pendingSnapshot = null
    leadCooldown = 0
    for (const p of state.players) p._in = null
  }
}
