import { Scene, Vector3, WebGLRenderer } from "three";
import type {
  ClientMessage,
  HostMessage,
  InteractableDef,
  PeerId,
  PlayerSnapshot,
  RoomDef,
  Vec3,
} from "@psx/shared";
import { emptySave, SAVE_FORMAT_VERSION, SNAPSHOT_INTERVAL, type SaveData } from "@psx/shared";
import { HostAuthority } from "../net/HostAuthority.js";
import type { NetSession } from "../net/NetSession.js";
import { RemotePlayers } from "../net/RemotePlayers.js";
import { CameraManager } from "../systems/CameraManager.js";
import { CharacterMover, DEFAULT_MOVER_CONFIG } from "../systems/CharacterMover.js";
import { FlagStore } from "../systems/FlagStore.js";
import { InputManager } from "../systems/InputManager.js";
import { InteractionSystem } from "../systems/InteractionSystem.js";
import { InventorySystem } from "../systems/InventorySystem.js";
import { PlayerController, type ControlScheme } from "../systems/PlayerController.js";
import { PSXPipeline } from "../post/PSXPipeline.js";
import { RoomBuilder, type BuiltRoom } from "../world/RoomBuilder.js";
import { PlayerAvatar } from "../world/PlayerAvatar.js";
import { CharacterModel } from "../world/CharacterModel.js";
import {
  CHARACTER_CATALOGUE,
  PLAYER_CHARACTER_ID,
  placementsForRoom,
} from "../world/characters.js";
import { getRoom } from "../world/rooms/index.js";
import { COMBINATIONS, ITEM_CATALOGUE } from "../world/items.js";
import { GameLoop, TICK_RATE } from "./GameLoop.js";
import { PhysicsWorld } from "./PhysicsWorld.js";

export const PLAYER_BODY_ID = "player";

export interface HudItem {
  id: string;
  name: string;
  description: string;
  quantity: number;
  isKeyItem: boolean;
  color: number;
}

export interface HudSnapshot {
  prompt: { label: string; verb: string } | null;
  message: string | null;
  items: HudItem[];
  capacity: number;
  usedSlots: number;
  controlScheme: ControlScheme;
}

export interface DebugSnapshot {
  fps: number;
  activeZone: string | null;
  grounded: boolean;
  position: { x: number; y: number; z: number };
  threadedPhysics: boolean;
  awakeBodies: number;
  net: { role: "solo" | "host" | "guest"; peers: number; drift: number } | null;
}

/**
 * Divergence between a guest's predicted position and the host's authoritative
 * one. Below `SOFT`, prediction was right and we leave it alone. Between the
 * two we ease across rather than snap, which hides ordinary jitter. Above
 * `HARD` prediction has genuinely failed — usually a collision the guest
 * resolved differently — and a visible jump beats staying wrong.
 */
const DRIFT_SOFT_CORRECT = 0.12;
const DRIFT_HARD_SNAP = 1.5;

const MESSAGE_DURATION = 4.5;
const DEBUG_INTERVAL = 0.25;

/**
 * Milestone 1 runtime: wires the systems together and owns the frame.
 *
 * Everything gameplay-relevant happens in `fixedUpdate` at 60 Hz; `render` only
 * interpolates and draws. Keeping that split clean now is what makes the
 * host-authoritative networking in Milestone 2 tractable — the host will run
 * exactly this `fixedUpdate` and ship the results.
 */
export class Game {
  private readonly renderer: WebGLRenderer;
  private readonly scene = new Scene();
  private readonly physics = new PhysicsWorld();
  private readonly flags = new FlagStore();
  private readonly inventory: InventorySystem;
  private readonly input = new InputManager();
  private readonly loop: GameLoop;

  private cameraManager!: CameraManager;
  private pipeline!: PSXPipeline;
  private roomBuilder!: RoomBuilder;
  private builtRoom: BuiltRoom | null = null;
  private interactions!: InteractionSystem;
  private mover!: CharacterMover;
  private controller!: PlayerController;
  private avatar!: PlayerAvatar;
  /** Harness-generated player character. Null until it loads, or if loading fails. */
  private character: CharacterModel | null = null;
  /** Ambient cast members for the active room (bodies, NPCs). */
  private ambientCharacters: CharacterModel[] = [];
  private ambientLoadToken = 0;

  private room: RoomDef | null = null;
  private detachInput: (() => void) | null = null;
  private resizeObserver: ResizeObserver | null = null;

  private message: string | null = null;
  private messageTimer = 0;
  private debugTimer = 0;
  private playtimeSeconds = 0;
  private hudDirty = true;
  private lastPromptId: string | null = null;

  // --- multiplayer (null in single player) ---------------------------------
  private net: NetSession | null = null;
  private hostAuthority: HostAuthority | null = null;
  private remotePlayers: RemotePlayers | null = null;
  private inputSendTimer = 0;
  private guestInputTick = 0;
  private lastDrift = 0;
  /** Guest-side read-only view of the host's party inventory. */
  private mirroredItems: Array<{ itemId: string; quantity: number }> | null = null;

  /** Set by the UI layer to receive state pushes. */
  onHud: ((snapshot: HudSnapshot) => void) | null = null;
  onDebug: ((snapshot: DebugSnapshot) => void) | null = null;

  /** Interpolation anchors, so rendering stays smooth between 60 Hz ticks. */
  private readonly previousPosition = new Vector3();
  private readonly renderPosition = new Vector3();
  private previousYaw = 0;

  constructor(private readonly canvas: HTMLCanvasElement) {
    this.renderer = new WebGLRenderer({
      canvas,
      // Antialiasing would sand off exactly the stair-stepping we are after.
      antialias: false,
      powerPreference: "high-performance",
    });
    // A low-res target upscaled by a fractional device ratio reintroduces
    // blurring, so we pin to 1 and let the PSX pass own the pixel grid.
    this.renderer.setPixelRatio(1);

    this.inventory = new InventorySystem(ITEM_CATALOGUE, COMBINATIONS, 8);
    this.loop = new GameLoop({
      fixedUpdate: (dt, tick) => this.fixedUpdate(dt, tick),
      render: (alpha, frameDt) => this.render(alpha, frameDt),
    });
  }

  async start(room: RoomDef, spawnPointId = "start"): Promise<void> {
    await this.physics.init({ x: 0, y: DEFAULT_MOVER_CONFIG.gravity, z: 0 });

    const { clientWidth, clientHeight } = this.canvas;
    this.cameraManager = new CameraManager(Math.max(1, clientWidth) / Math.max(1, clientHeight));
    this.pipeline = new PSXPipeline(this.renderer, this.scene, this.cameraManager.camera);
    this.roomBuilder = new RoomBuilder(this.scene, this.physics);
    this.interactions = new InteractionSystem(this.physics, this.inventory, this.flags);

    this.loadRoom(room, spawnPointId);

    this.cameraManager.onCut = () => this.controller.onCameraCut();
    this.inventory.subscribe(() => {
      this.hudDirty = true;
    });
    this.flags.subscribe((flag, value) => {
      if (value) this.applyFlagToWorld(flag);
    });

    this.detachInput = this.input.attach(window);
    this.observeResize();
    this.handleResize();

    this.loop.start();
    this.pushHud();

    // Loaded after the loop starts so a missing or malformed GLB costs a
    // placeholder avatar rather than the whole game.
    void this.loadCharacter();
  }

  private async loadCharacter(): Promise<void> {
    const def = CHARACTER_CATALOGUE.get(PLAYER_CHARACTER_ID);
    if (!def) return;

    try {
      const character = await CharacterModel.load(def.url, {
        snapResolution: this.pipeline.snapResolution,
      });

      this.character = character;
      this.scene.add(character.group);
      // Keep the block figure around as the fallback, just hidden.
      this.avatar.group.visible = false;
    } catch (cause) {
      console.warn("harness character failed to load; using placeholder avatar", cause);
    }
  }

  private clearAmbientCharacters(): void {
    for (const model of this.ambientCharacters) {
      this.scene.remove(model.group);
      model.dispose();
    }
    this.ambientCharacters = [];
  }

  /** Spawns harness cast members that belong in this room. */
  private async loadAmbientCharacters(roomId: string): Promise<void> {
    const token = ++this.ambientLoadToken;
    this.clearAmbientCharacters();

    const placements = placementsForRoom(roomId);
    if (placements.length === 0) return;

    const loaded: CharacterModel[] = [];
    await Promise.all(
      placements.map(async (placement) => {
        const def = CHARACTER_CATALOGUE.get(placement.characterId);
        if (!def) return;
        try {
          const model = await CharacterModel.load(def.url, {
            snapResolution: this.pipeline.snapResolution,
            // Living NPCs idle; crime-scene bodies use pose: "slumped".
            initialState: placement.pose ?? "idle",
          });
          model.setTransform(
            placement.position.x,
            placement.position.y,
            placement.position.z,
            placement.yaw,
          );
          // Pose can lift or drop the mesh; pin the soles to the floor.
          model.groundToFloor();
          loaded.push(model);
        } catch (cause) {
          console.warn(`ambient character ${placement.characterId} failed to load`, cause);
        }
      }),
    );

    // A faster room transition may have superseded this load.
    if (token !== this.ambientLoadToken) {
      for (const model of loaded) model.dispose();
      return;
    }

    for (const model of loaded) this.scene.add(model.group);
    this.ambientCharacters = loaded;
  }

  private loadRoom(room: RoomDef, spawnPointId: string): void {
    this.room = room;
    this.builtRoom?.dispose();
    this.cameraManager.clear();
    this.interactions.clear();
    this.clearAmbientCharacters();

    if (this.avatar) {
      this.scene.remove(this.avatar.group);
      this.avatar.dispose();
    }
    // The player character is not room-specific, so it survives a
    // transition — it is only re-parented, never rebuilt.
    if (this.character) this.scene.remove(this.character.group);
    // Drops the previous room's statics and sensors *and* the old player
    // capsule. Without this a transition leaves orphaned bodies in the world
    // that the mover's ray probes would still collide with.
    this.physics.clear();

    const activeFlags = new Set(this.flags.serialise());
    this.builtRoom = this.roomBuilder.build(room, activeFlags);
    this.cameraManager.loadZones(this.physics, room.cameraZones);
    this.interactions.load(room.interactables);

    const spawn =
      room.spawnPoints.find((point) => point.id === spawnPointId) ?? room.spawnPoints[0];
    if (!spawn) throw new Error(`Room ${room.id} has no spawn points`);

    const spawnPosition = new Vector3(spawn.position.x, spawn.position.y, spawn.position.z);

    this.physics.createKinematicCapsule({
      id: PLAYER_BODY_ID,
      position: {
        x: spawnPosition.x,
        y: spawnPosition.y + (DEFAULT_MOVER_CONFIG.cylinderHeight + DEFAULT_MOVER_CONFIG.radius * 2) / 2,
        z: spawnPosition.z,
      },
      radius: DEFAULT_MOVER_CONFIG.radius,
      height: DEFAULT_MOVER_CONFIG.cylinderHeight,
    });

    this.mover = new CharacterMover(this.physics, PLAYER_BODY_ID, spawnPosition);
    this.controller = new PlayerController(this.mover, this.input);
    this.controller.yaw = spawn.yaw;

    this.avatar = new PlayerAvatar({ totalHeight: this.mover.totalHeight });
    this.avatar.group.visible = this.character === null;
    this.scene.add(this.avatar.group);
    if (this.character) this.scene.add(this.character.group);

    this.previousPosition.copy(spawnPosition);
    this.renderPosition.copy(spawnPosition);
    this.previousYaw = spawn.yaw;

    void this.loadAmbientCharacters(room.id);
  }

  /** Resolve a door transition into a room from the campaign catalogue. */
  private transitionTo(roomId: string, spawnPointId: string): void {
    const next = getRoom(roomId);
    if (!next) {
      console.warn(`unknown room: ${roomId}`);
      this.setMessage("The door won't open.");
      return;
    }
    this.loadRoom(next, spawnPointId);
    this.setMessage(next.displayName);
  }

  /**
   * Enter co-op. Safe to call after `start`; the world is already built, and
   * remote players simply begin appearing in it.
   */
  attachNet(net: NetSession): void {
    this.net = net;
    this.remotePlayers = new RemotePlayers(this.scene, this.mover.totalHeight);

    if (net.isHost) {
      this.hostAuthority = new HostAuthority(this.physics);
    }

    net.onPeerJoinedWorld = (peerId) => {
      if (!this.hostAuthority || !this.room) return;
      const spawn = this.spawnFor(peerId);
      this.hostAuthority.addPlayer(peerId, spawn);
      // Bring the newcomer up to date on everything it missed.
      net.sendToPeer(peerId, {
        t: "sync",
        roomId: this.room.id,
        flags: this.flags.serialise(),
        despawned: this.interactions.serialise(),
        players: this.hostAuthority.buildSnapshot(this.hostPose()).players,
      });
    };

    net.onPeerLeftWorld = (peerId) => {
      this.hostAuthority?.removePlayer(peerId);
      this.remotePlayers?.remove(peerId);
    };

    this.hudDirty = true;
  }

  /** Peers spawn on distinct points so nobody materialises inside anyone else. */
  private spawnFor(peerId: PeerId): Vector3 {
    const points = this.room?.spawnPoints ?? [];
    const point = points[peerId % Math.max(1, points.length)];
    const base = point?.position ?? { x: 0, y: 0, z: 0 };
    // Fan out around the point when peers share one.
    const angle = (peerId / 4) * Math.PI * 2;
    return new Vector3(base.x + Math.sin(angle) * 0.6, base.y, base.z + Math.cos(angle) * 0.6);
  }

  private hostPose() {
    const state = this.mover.state;
    return {
      position: state.position,
      yaw: this.controller.yaw,
      speed: state.lastMoveDistance * 60,
      grounded: state.grounded,
    };
  }

  /** Host: apply a validated guest message. */
  handleClientMessage(message: ClientMessage, from: PeerId): void {
    if (!this.hostAuthority) return;

    switch (message.t) {
      case "input":
        this.hostAuthority.applyInput(from, message);
        break;
      case "interact":
        this.handleGuestInteract(from, message.interactableId);
        break;
      case "combine": {
        const text = this.combineItems(message.itemA, message.itemB);
        this.net?.sendToPeer(from, { t: "notice", text });
        this.broadcastInventory();
        break;
      }
      case "chat":
        this.net?.broadcastEvent({ t: "chat", from, text: message.text });
        break;
    }
  }

  /**
   * The host re-checks reach itself. Sensors only track the local player, so
   * proximity is verified against the guest's authoritative mover position —
   * a guest cannot open a door from across the map by naming its id.
   */
  private handleGuestInteract(peerId: PeerId, interactableId: string): void {
    const player = this.hostAuthority?.getPlayer(peerId);
    const def = this.room?.interactables.find((entry) => entry.id === interactableId);
    if (!player || !def || !this.net) return;

    if (!withinReach(player.mover.state.position, def)) {
      this.net.sendToPeer(peerId, { t: "notice", text: "You're too far away." });
      return;
    }

    const result = this.interactions.activateById(interactableId);
    if (!result) return;

    if (result.type !== "blocked") this.roomBuilder.removeInteractableVisual(interactableId);

    switch (result.type) {
      case "picked-up":
      case "flag-set":
      case "message":
      case "blocked":
        this.net.sendToPeer(peerId, { t: "notice", text: result.text });
        break;
      case "transition":
        break;
    }

    // World state is shared, so everyone hears about it, including the actor.
    this.net.broadcastEvent({ t: "flags", set: this.flags.serialise(), unset: [] });
    this.net.broadcastEvent({ t: "despawn", interactableIds: this.interactions.serialise() });
    this.broadcastInventory();
    this.hudDirty = true;
  }

  /**
   * Co-op shares one party inventory rather than giving each peer their own.
   * That matches how the genre's co-op modes work, and it keeps a single
   * authoritative copy on the host instead of four that can disagree.
   */
  private broadcastInventory(): void {
    this.net?.broadcastEvent({
      t: "inventory",
      peerId: 0,
      items: this.inventory.list().map((entry) => ({
        itemId: entry.def.id,
        quantity: entry.quantity,
      })),
    });
  }

  /** Guest: apply validated authoritative state from the host. */
  handleHostMessage(message: HostMessage): void {
    switch (message.t) {
      case "snapshot": {
        const now = performance.now() / 1000;
        this.remotePlayers?.ingestSnapshot(message.players, this.net?.localPeerId ?? -1, now);
        this.reconcileLocalPlayer(message.players);
        break;
      }
      case "sync":
        this.flags.restore(message.flags);
        this.interactions.restore(message.despawned);
        for (const id of message.despawned) this.roomBuilder.removeInteractableVisual(id);
        for (const flag of message.flags) this.applyFlagToWorld(flag);
        break;
      case "flags":
        for (const flag of message.set) {
          this.flags.set(flag);
        }
        break;
      case "despawn":
        this.interactions.restore(message.interactableIds);
        for (const id of message.interactableIds) this.roomBuilder.removeInteractableVisual(id);
        break;
      case "inventory":
        this.mirroredItems = message.items;
        this.hudDirty = true;
        break;
      case "notice":
        this.setMessage(message.text);
        break;
      case "chat":
        this.setMessage(`Player ${message.from}: ${message.text}`);
        break;
    }
  }

  /** Guest-side prediction error correction. */
  private reconcileLocalPlayer(players: PlayerSnapshot[]): void {
    const localPeerId = this.net?.localPeerId ?? -1;
    const mine = players.find((player) => player.peerId === localPeerId);
    if (!mine) return;

    const local = this.mover.state.position;
    const drift = Math.hypot(local.x - mine.p.x, local.y - mine.p.y, local.z - mine.p.z);
    this.lastDrift = drift;

    if (drift > DRIFT_HARD_SNAP) {
      this.mover.teleport(new Vector3(mine.p.x, mine.p.y, mine.p.z));
    } else if (drift > DRIFT_SOFT_CORRECT) {
      // Ease a fraction of the way each snapshot; repeated corrections converge
      // without the visible stutter of a hard set.
      local.lerp(new Vector3(mine.p.x, mine.p.y, mine.p.z), 0.25);
    }
  }

  private fixedUpdate(dt: number, _tick: number): void {
    this.playtimeSeconds += dt;
    this.previousPosition.copy(this.mover.state.position);
    this.previousYaw = this.controller.yaw;

    if (this.input.wasPressed("toggleControls")) {
      this.controller.toggleScheme();
      this.hudDirty = true;
    }

    this.controller.update(dt, this.cameraManager.getLookDirection());

    // Step physics after the mover has written its target transform, so sensor
    // overlaps are computed against where the player actually ended up.
    this.physics.step(dt);

    const sensorEvents = this.physics.drainSensorEvents();
    this.cameraManager.handleSensorEvents(sensorEvents, PLAYER_BODY_ID);
    this.interactions.handleSensorEvents(sensorEvents, PLAYER_BODY_ID);

    this.cameraManager.update(dt, this.mover.state.position);

    // Advance the walk cycle on the fixed tick, not per rendered frame, or the
    // stride runs at double speed on a 120 Hz display.
    this.avatar.update(this.mover.state.lastMoveDistance, dt);

    if (this.character) {
      // Edge-trigger on the same Space press the controller used, so the clip
      // starts once rather than re-arming its unlock timer every fixed tick.
      if (this.input.wasPressed("quickTurn")) {
        this.character.playQuickTurn(this.controller.config.quickTurnDuration);
      } else {
        this.character.setSpeed(this.mover.state.lastMoveDistance * TICK_RATE);
      }
      this.character.update(dt);
    }

    for (const ambient of this.ambientCharacters) ambient.update(dt);

    if (this.input.wasPressed("interact")) this.activateInteraction();

    if (this.hostAuthority) this.remotePlayers?.syncFromAuthority(this.hostAuthority, dt);

    const promptId = this.interactions.current?.id ?? null;
    if (promptId !== this.lastPromptId) {
      this.lastPromptId = promptId;
      this.hudDirty = true;
    }

    if (this.messageTimer > 0) {
      this.messageTimer = Math.max(0, this.messageTimer - dt);
      if (this.messageTimer === 0) {
        this.message = null;
        this.hudDirty = true;
      }
    }

    this.updateNetworking(dt);

    this.input.endTick();
    if (this.hudDirty) this.pushHud();
  }

  private updateNetworking(dt: number): void {
    if (!this.net) return;

    if (this.hostAuthority) {
      // Host: simulate every guest, then publish at SNAPSHOT_HZ.
      this.hostAuthority.update(dt);
      if (this.hostAuthority.shouldSnapshot(dt)) {
        const { tick, ack, players } = this.hostAuthority.buildSnapshot(this.hostPose());
        this.net.broadcastState({ t: "snapshot", tick, ack, players });
      }
      return;
    }

    // Guest: publish intent at the same rate the host publishes state. Sending
    // at 60 Hz would quadruple upstream traffic for no extra authority.
    this.inputSendTimer += dt;
    if (this.inputSendTimer < SNAPSHOT_INTERVAL) return;
    this.inputSendTimer -= SNAPSHOT_INTERVAL;

    const strafe = this.input.axis("left", "right");
    const advance = this.input.axis("back", "forward");
    const move =
      this.controller.scheme === "tank"
        ? { x: Math.sin(this.controller.yaw) * advance, z: Math.cos(this.controller.yaw) * advance }
        : { x: strafe, z: advance };

    this.net.sendInput({
      t: "input",
      tick: this.guestInputTick++,
      move,
      run: this.input.isHeld("run"),
      yaw: this.controller.yaw,
    });
  }

  private activateInteraction(): void {
    const target = this.interactions.current;

    // A guest owns no world state: it asks the host, which re-checks reach and
    // broadcasts the outcome back through `handleHostMessage`.
    if (this.net && !this.hostAuthority) {
      if (target) this.net.sendAction({ t: "interact", interactableId: target.id });
      return;
    }

    const result = this.interactions.activate();
    if (!result) return;

    switch (result.type) {
      case "message":
      case "blocked":
        this.setMessage(result.text);
        break;
      case "picked-up":
        if (target) this.roomBuilder.removeInteractableVisual(target.id);
        this.setMessage(result.text);
        break;
      case "flag-set":
        if (target) this.roomBuilder.removeInteractableVisual(target.id);
        this.setMessage(result.text);
        break;
      case "transition":
        this.transitionTo(result.roomId, result.spawnPointId);
        break;
    }

    // A host acting locally still owes the party an update.
    if (this.hostAuthority && this.net) {
      this.net.broadcastEvent({ t: "flags", set: this.flags.serialise(), unset: [] });
      this.net.broadcastEvent({ t: "despawn", interactableIds: this.interactions.serialise() });
      this.broadcastInventory();
    }
  }

  /** Flag-gated geometry despawns here so saves and live play take one path. */
  private applyFlagToWorld(flag: string): void {
    if (!this.room) return;
    for (const def of this.room.geometry) {
      if (def.removeOnFlag === flag) this.roomBuilder.removeGeometry(def.id);
    }
  }

  private setMessage(text: string): void {
    this.message = text;
    this.messageTimer = MESSAGE_DURATION;
    this.hudDirty = true;
  }

  private render(alpha: number, frameDt: number): void {
    // Interpolate between the last two ticks so motion is smooth above 60 Hz.
    this.renderPosition.lerpVectors(this.previousPosition, this.mover.state.position, alpha);
    const yaw = this.previousYaw + (this.controller.yaw - this.previousYaw) * alpha;

    this.avatar.setTransform(this.renderPosition.x, this.renderPosition.y, this.renderPosition.z, yaw);
    this.character?.setTransform(
      this.renderPosition.x,
      this.renderPosition.y,
      this.renderPosition.z,
      yaw,
    );

    // Guests interpolate remote peers; the host already placed them from its
    // own simulation during fixedUpdate.
    if (this.net && !this.hostAuthority) {
      this.remotePlayers?.update(performance.now() / 1000, frameDt);
    }

    this.pipeline.render();

    this.debugTimer += frameDt;
    if (this.debugTimer >= DEBUG_INTERVAL) {
      this.debugTimer = 0;
      this.onDebug?.({
        fps: Math.round(this.loop.fps),
        activeZone: this.cameraManager.activeZone,
        grounded: this.mover.state.grounded,
        position: {
          x: +this.mover.state.position.x.toFixed(2),
          y: +this.mover.state.position.y.toFixed(2),
          z: +this.mover.state.position.z.toFixed(2),
        },
        threadedPhysics: this.physics.threaded,
        awakeBodies: this.physics.awakeBodyCount,
        net: this.net
          ? {
              role: this.hostAuthority ? "host" : "guest",
              peers: this.net.connectedPeerCount,
              drift: +this.lastDrift.toFixed(2),
            }
          : null,
      });
    }
  }

  private pushHud(): void {
    this.hudDirty = false;
    const current = this.interactions.current;

    this.onHud?.({
      prompt: current ? { label: current.label, verb: promptVerb(current.action.kind) } : null,
      message: this.message,
      // A guest shows the host's party inventory, since the host holds the
      // only authoritative copy.
      items: this.mirroredItems
        ? this.mirroredItems.flatMap(({ itemId, quantity }) => {
            const def = ITEM_CATALOGUE.get(itemId);
            return def
              ? [
                  {
                    id: def.id,
                    name: def.name,
                    description: def.description,
                    quantity,
                    isKeyItem: def.isKeyItem ?? false,
                    color: def.iconColor ?? 0x888888,
                  },
                ]
              : [];
          })
        : this.inventory.list().map(({ def, quantity }) => ({
            id: def.id,
            name: def.name,
            description: def.description,
            quantity,
            isKeyItem: def.isKeyItem ?? false,
            color: def.iconColor ?? 0x888888,
          })),
      capacity: this.inventory.capacity,
      usedSlots: this.inventory.usedSlots,
      controlScheme: this.controller.scheme,
    });
  }

  /** Called by the inventory UI. Returns a human-readable outcome. */
  combineItems(itemA: string, itemB: string): string {
    const result = this.inventory.combine(itemA, itemB);
    if (!result) return "Those two don't go together.";
    const def = ITEM_CATALOGUE.get(result.itemId);
    this.setMessage(`Created ${def?.name ?? result.itemId}.`);
    return `Created ${def?.name ?? result.itemId}.`;
  }

  setControlScheme(scheme: ControlScheme): void {
    if (this.controller.scheme !== scheme) this.controller.toggleScheme();
    this.hudDirty = true;
  }

  private observeResize(): void {
    this.resizeObserver = new ResizeObserver(() => this.handleResize());
    if (this.canvas.parentElement) this.resizeObserver.observe(this.canvas.parentElement);
  }

  private handleResize(): void {
    const width = this.canvas.clientWidth || window.innerWidth;
    const height = this.canvas.clientHeight || window.innerHeight;

    this.cameraManager.setAspect(width / Math.max(1, height));
    this.pipeline.setSize(width, height);

    // Snap grid follows the virtual framebuffer so vertex wobble stays constant
    // in virtual pixels rather than scaling with the window.
    const snap = this.pipeline.snapResolution;
    this.roomBuilder.setSnapResolution(snap.x, snap.y);
  }

  serialise(): SaveData {
    const state = this.mover.state;
    return {
      version: SAVE_FORMAT_VERSION,
      campaign: "demo",
      player: {
        roomId: this.room?.id ?? "unknown",
        position: { x: state.position.x, y: state.position.y, z: state.position.z },
        yaw: this.controller.yaw,
        health: 100,
        inventory: this.inventory.serialise(),
      },
      flags: this.flags.serialise(),
      consumedInteractables: this.interactions.serialise(),
      playtimeSeconds: Math.round(this.playtimeSeconds),
      savedAt: new Date().toISOString(),
    };
  }

  /** Local-only for Milestone 1; Milestone 2 swaps the transport for Neon. */
  saveToLocalStorage(slot = 0): void {
    localStorage.setItem(`psx-save-${slot}`, JSON.stringify(this.serialise()));
    this.setMessage("Progress recorded.");
  }

  static blankSave(roomId: string): SaveData {
    return emptySave("demo", roomId);
  }

  dispose(): void {
    this.loop.stop();
    this.detachInput?.();
    this.resizeObserver?.disconnect();
    this.net?.close();
    this.hostAuthority?.clear();
    this.remotePlayers?.clear();
    this.builtRoom?.dispose();
    this.character?.dispose();
    this.clearAmbientCharacters();
    this.avatar.dispose();
    this.pipeline.dispose();
    this.physics.dispose();
    this.renderer.dispose();
  }
}

/** Host-side reach check, mirroring the sensor volume the local player uses. */
function withinReach(position: Vec3, def: InteractableDef): boolean {
  return (
    Math.abs(position.x - def.position.x) <= def.halfExtents.x &&
    Math.abs(position.y - def.position.y) <= def.halfExtents.y + 1 &&
    Math.abs(position.z - def.position.z) <= def.halfExtents.z
  );
}

function promptVerb(kind: string): string {
  switch (kind) {
    case "pickup":
      return "Take";
    case "door":
      return "Open";
    case "useItem":
      return "Use";
    default:
      return "Examine";
  }
}
