import Box3D from "box3d-wasm";
import type { B3Body, B3Filter, B3Shape, B3World, Box3DModule } from "box3d-wasm";
import type { ColliderDef, ColliderMaterial, Quat, Vec3 } from "@psx/shared";
import { CollisionGroup, QUAT_IDENTITY, VEC3_ZERO } from "@psx/shared";

/**
 * Thin ownership layer over box3d-wasm.
 *
 * box3d-wasm identifies everything in its event arrays by a numeric `userData`
 * tag, and it keeps *separate* counters for bodies and shapes — so an
 * auto-assigned body tag and shape tag can collide numerically. We therefore
 * assign every tag ourselves from one counter and keep the reverse maps here.
 */

export interface PhysicsEntity {
  /** Gameplay-side identifier, e.g. `zone:hallway-north` or `player`. */
  id: string;
  body: B3Body;
  shapes: B3Shape[];
}

export interface RayHit {
  point: Vec3;
  normal: Vec3;
  fraction: number;
  distance: number;
  entityId: string | null;
}

export interface SensorTransition {
  sensorId: string;
  visitorId: string;
}

export interface SensorEvents {
  begin: SensorTransition[];
  end: SensorTransition[];
}

export interface StaticBodyOptions {
  id: string;
  position?: Vec3;
  rotation?: Quat;
  colliders: ColliderDef[];
  material?: ColliderMaterial;
  group?: number;
}

export interface SensorOptions {
  id: string;
  position: Vec3;
  halfExtents: Vec3;
  group?: number;
}

export interface KinematicCapsuleOptions {
  id: string;
  position: Vec3;
  radius: number;
  height: number;
  group?: number;
}

export class PhysicsWorld {
  private module!: Box3DModule;
  private world!: B3World;

  private nextTag = 1;
  private readonly entitiesByShapeTag = new Map<number, PhysicsEntity>();
  private readonly entitiesById = new Map<string, PhysicsEntity>();

  /** True when the threaded "deluxe" wasm build was loaded (needs COOP/COEP). */
  threaded = false;

  async init(gravity: Vec3 = { x: 0, y: -18, z: 0 }): Promise<void> {
    this.module = await Box3D();
    this.threaded = this.module.threaded;
    this.world = new this.module.World({
      gravity,
      enableSleep: true,
      enableContinuous: true,
      ...(this.threaded ? { workerCount: Math.min(4, this.module.maxWorkers) } : {}),
    });
  }

  step(dt: number, subSteps = 4): void {
    this.world.step(dt, subSteps);
  }

  private allocTag(): number {
    return this.nextTag++;
  }

  private register(entity: PhysicsEntity): PhysicsEntity {
    this.entitiesById.set(entity.id, entity);
    for (const shape of entity.shapes) {
      this.entitiesByShapeTag.set(shape.getUserData(), entity);
    }
    return entity;
  }

  /**
   * Static level geometry. Box3D in this build has no triangle-mesh shape, so a
   * room is a compound of boxes and convex hulls — which is also cheaper and
   * closer to how PSX-era collision was actually authored.
   */
  createStatic(options: StaticBodyOptions): PhysicsEntity {
    const group = options.group ?? CollisionGroup.WORLD;
    const body = this.world.createBody({
      type: "static",
      position: options.position ?? VEC3_ZERO,
      rotation: options.rotation ?? QUAT_IDENTITY,
      userData: this.allocTag(),
      name: options.id,
    });

    const filter: B3Filter = { categoryBits: group, maskBits: 0xffffffff };
    const shapes = options.colliders.map((collider) =>
      this.addCollider(body, collider, filter, options.material),
    );

    return this.register({ id: options.id, body, shapes });
  }

  /** A non-colliding trigger volume: camera zones, interaction reach, doorways. */
  createSensor(options: SensorOptions): PhysicsEntity {
    const group = options.group ?? CollisionGroup.TRIGGER;
    const body = this.world.createBody({
      type: "static",
      position: options.position,
      userData: this.allocTag(),
      name: options.id,
    });

    const shape = body.createBox({
      halfExtents: options.halfExtents,
      isSensor: true,
      userData: this.allocTag(),
      filter: { categoryBits: group, maskBits: CollisionGroup.PLAYER | CollisionGroup.ENEMY },
    });
    shape.enableSensorEvents(true);

    return this.register({ id: options.id, body, shapes: [shape] });
  }

  /**
   * The player capsule. Kinematic rather than dynamic: the mover computes the
   * exact position each tick, so we want zero solver interference — no bouncing
   * off walls, no sliding down slopes we did not ask to slide down.
   */
  createKinematicCapsule(options: KinematicCapsuleOptions): PhysicsEntity {
    const group = options.group ?? CollisionGroup.PLAYER;
    const body = this.world.createBody({
      type: "kinematic",
      position: options.position,
      userData: this.allocTag(),
      name: options.id,
    });

    const shape = body.createCapsule({
      radius: options.radius,
      height: options.height,
      density: 1,
      friction: 0,
      userData: this.allocTag(),
      filter: { categoryBits: group, maskBits: 0xffffffff },
    });
    // Needed for the capsule to show up as a `visitor` in sensor events.
    shape.enableSensorEvents(true);
    shape.enableContactEvents(true);

    return this.register({ id: options.id, body, shapes: [shape] });
  }

  private addCollider(
    body: B3Body,
    collider: ColliderDef,
    filter: B3Filter,
    material?: ColliderMaterial,
  ): B3Shape {
    const common = {
      userData: this.allocTag(),
      filter,
      friction: material?.friction ?? 0.6,
      restitution: material?.restitution ?? 0,
      density: material?.density ?? 1,
    };

    switch (collider.kind) {
      case "box":
        return body.createBox({
          ...common,
          halfExtents: collider.halfExtents,
          center: collider.offset ?? VEC3_ZERO,
          rotation: collider.rotation ?? QUAT_IDENTITY,
        });
      case "sphere":
        return body.createSphere({
          ...common,
          radius: collider.radius,
          center: collider.offset ?? VEC3_ZERO,
        });
      case "capsule":
        return body.createCapsule({
          ...common,
          radius: collider.radius,
          height: collider.height,
          center: collider.offset ?? VEC3_ZERO,
        });
      case "hull": {
        const offset = collider.offset;
        const points = offset
          ? collider.points.map((p) => ({ x: p.x + offset.x, y: p.y + offset.y, z: p.z + offset.z }))
          : collider.points;
        return body.createHull({ ...common, points });
      }
    }
  }

  /**
   * Closest hit along a ray. This is the only spatial query box3d-wasm@0.2.0
   * binds — there is no shape cast or overlap test — so the character mover is
   * built entirely out of ray probes.
   */
  raycast(origin: Vec3, direction: Vec3, maxDistance: number, mask: number): RayHit | null {
    const translation = {
      x: direction.x * maxDistance,
      y: direction.y * maxDistance,
      z: direction.z * maxDistance,
    };
    const result = this.world.castRayClosest(origin, translation, {
      categoryBits: 0xffffffff,
      maskBits: mask,
    });
    if (!result.hit) return null;

    return {
      point: result.point,
      normal: result.normal,
      fraction: result.fraction,
      distance: result.fraction * maxDistance,
      entityId: this.entitiesByShapeTag.get(result.shapeUserData)?.id ?? null,
    };
  }

  /** Sensor begin/end transitions since the last call, resolved to entity ids. */
  drainSensorEvents(): SensorEvents {
    const raw = this.world.getSensorEvents();
    const resolve = (events: { sensorUserData: number; visitorUserData: number }[]) =>
      events.flatMap((event) => {
        const sensor = this.entitiesByShapeTag.get(event.sensorUserData);
        const visitor = this.entitiesByShapeTag.get(event.visitorUserData);
        return sensor && visitor ? [{ sensorId: sensor.id, visitorId: visitor.id }] : [];
      });

    return { begin: resolve(raw.begin), end: resolve(raw.end) };
  }

  get(id: string): PhysicsEntity | undefined {
    return this.entitiesById.get(id);
  }

  remove(id: string): void {
    const entity = this.entitiesById.get(id);
    if (!entity) return;

    for (const shape of entity.shapes) {
      this.entitiesByShapeTag.delete(shape.getUserData());
      shape.delete();
    }
    entity.body.destroy();
    entity.body.delete();
    this.entitiesById.delete(id);
  }

  /** Tear down every registered entity but keep the world alive (room swap). */
  clear(): void {
    for (const id of [...this.entitiesById.keys()]) this.remove(id);
  }

  dispose(): void {
    this.clear();
    this.world.destroy();
    this.world.delete();
  }

  get awakeBodyCount(): number {
    return this.world.getAwakeBodyCount();
  }
}
