import { Vector3 } from "three";
import { MOVER_SOLID_MASK } from "@psx/shared";
import type { PhysicsWorld } from "../engine/PhysicsWorld.js";

/**
 * Kinematic capsule character controller.
 *
 * GDD §6.2 assumes "the Box3D character mover", but box3d-wasm@0.2.0 binds no
 * mover — and no shape cast or overlap query either. `castRayClosest` is the
 * only spatial query available, so this is a collide-and-slide mover built out
 * of ray probes: a fan of horizontal rays approximates the capsule sweep, and a
 * downward probe handles grounding, snapping and step-up.
 *
 * The capsule body in the physics world is kinematic and follows the position
 * we compute, so the solver never fights us — it exists so that sensors see the
 * player and so dynamic props get pushed correctly.
 */

export interface MoverConfig {
  radius: number;
  /** Height of the cylindrical section; total height is this + 2 * radius. */
  cylinderHeight: number;
  /** Ledges up to this tall are walked over rather than blocked. */
  stepHeight: number;
  maxSlopeDegrees: number;
  gravity: number;
  /** Keeps probes from starting exactly on a surface and self-intersecting. */
  skin: number;
  /** How far below the feet we look for ground before declaring a fall. */
  groundSnapDistance: number;
  /** Downward bias while grounded, so we stay glued walking down slopes. */
  groundStick: number;
}

export const DEFAULT_MOVER_CONFIG: MoverConfig = {
  radius: 0.3,
  cylinderHeight: 1.1,
  stepHeight: 0.35,
  maxSlopeDegrees: 50,
  gravity: -18,
  skin: 0.02,
  groundSnapDistance: 0.5,
  groundStick: -2,
};

export interface MoverState {
  /** Feet position — the point the capsule stands on. */
  position: Vector3;
  verticalVelocity: number;
  grounded: boolean;
  groundNormal: Vector3;
  /** Horizontal distance actually covered last tick, for animation blending. */
  lastMoveDistance: number;
}

/** How many times we re-project and retry after hitting a wall. */
const SLIDE_ITERATIONS = 4;
/** Lateral fan offsets, as a fraction of radius, for the horizontal probes. */
const PROBE_LATERAL = [0, 0.7, -0.7];

const scratchA = new Vector3();
const scratchB = new Vector3();
const scratchC = new Vector3();
const scratchD = new Vector3();

export class CharacterMover {
  readonly state: MoverState;
  private readonly minGroundY: number;

  constructor(
    private readonly physics: PhysicsWorld,
    private readonly bodyId: string,
    startPosition: Vector3,
    readonly config: MoverConfig = DEFAULT_MOVER_CONFIG,
  ) {
    this.state = {
      position: startPosition.clone(),
      verticalVelocity: 0,
      grounded: false,
      groundNormal: new Vector3(0, 1, 0),
      lastMoveDistance: 0,
    };
    this.minGroundY = Math.cos((config.maxSlopeDegrees * Math.PI) / 180);
  }

  get totalHeight(): number {
    return this.config.cylinderHeight + this.config.radius * 2;
  }

  /** Physics-world position of the capsule centre, given the current feet position. */
  get capsuleCenter(): Vector3 {
    return scratchD.copy(this.state.position).setY(this.state.position.y + this.totalHeight * 0.5);
  }

  /**
   * Advance one fixed tick.
   * `desiredVelocity` is a world-space horizontal velocity in units/second;
   * its Y component is ignored.
   */
  update(desiredVelocity: Vector3, dt: number): MoverState {
    const { config, state } = this;

    // --- vertical integration -------------------------------------------------
    if (state.grounded && state.verticalVelocity <= 0) {
      state.verticalVelocity = config.groundStick;
    } else {
      state.verticalVelocity += config.gravity * dt;
    }

    // --- horizontal collide and slide ----------------------------------------
    const displacement = scratchA.set(desiredVelocity.x * dt, 0, desiredVelocity.z * dt);
    const startPosition = scratchB.copy(state.position);
    this.slide(displacement);

    // A blocked move on flat ground may just be a low ledge — retry stepped up.
    if (state.grounded) {
      const covered = scratchC.copy(state.position).sub(startPosition).setY(0).length();
      const wanted = Math.hypot(desiredVelocity.x * dt, desiredVelocity.z * dt);
      if (wanted > 1e-5 && covered < wanted * 0.7) {
        this.tryStepUp(startPosition, desiredVelocity, dt, covered);
      }
    }

    state.lastMoveDistance = Math.hypot(
      state.position.x - startPosition.x,
      state.position.z - startPosition.z,
    );

    // --- vertical resolve -----------------------------------------------------
    this.resolveVertical(dt);
    this.syncBody(dt);
    return state;
  }

  /** Move along `displacement`, re-projecting onto each wall we touch. */
  private slide(displacement: Vector3): void {
    const remaining = displacement.clone();

    for (let i = 0; i < SLIDE_ITERATIONS; i++) {
      const distance = remaining.length();
      if (distance < 1e-6) return;

      const direction = remaining.clone().divideScalar(distance);
      const hit = this.probeHorizontal(direction, distance);

      if (!hit) {
        this.state.position.add(remaining);
        return;
      }

      // Advance up to the contact point, then slide along the wall plane.
      const travel = Math.max(0, hit.distance - this.config.skin);
      if (travel > 0) this.state.position.addScaledVector(direction, travel);

      remaining.addScaledVector(direction, -travel);
      const normal = hit.normal.setY(0).normalize();
      if (normal.lengthSq() < 1e-8) return;
      remaining.addScaledVector(normal, -remaining.dot(normal));
    }
  }

  /**
   * Fan of rays approximating a capsule sweep: three heights (ankle, waist,
   * shoulder) by three lateral offsets. Returns the nearest obstruction, with
   * `distance` already converted to free travel along `direction`.
   */
  private probeHorizontal(
    direction: Vector3,
    distance: number,
  ): { distance: number; normal: Vector3 } | null {
    const { radius, skin, cylinderHeight, stepHeight } = this.config;
    const feet = this.state.position;
    const tangent = new Vector3(-direction.z, 0, direction.x);

    // Ankle probe sits above stepHeight so low ledges fall through to step-up.
    const heights = [
      stepHeight + skin,
      radius + cylinderHeight * 0.5,
      radius + cylinderHeight - skin,
    ];

    let best: { distance: number; normal: Vector3 } | null = null;

    for (const height of heights) {
      for (const lateral of PROBE_LATERAL) {
        const lateralOffset = lateral * radius;
        // Forward extent from this ray's origin to the capsule surface.
        const forwardClearance = Math.sqrt(Math.max(0, radius * radius - lateralOffset * lateralOffset));
        const origin = {
          x: feet.x + tangent.x * lateralOffset,
          y: feet.y + height,
          z: feet.z + tangent.z * lateralOffset,
        };
        const maxDistance = distance + forwardClearance + skin;
        const hit = this.physics.raycast(origin, direction, maxDistance, MOVER_SOLID_MASK);
        if (!hit || hit.entityId === this.bodyId) continue;

        const free = hit.distance - forwardClearance;
        if (best === null || free < best.distance) {
          best = { distance: free, normal: new Vector3(hit.normal.x, hit.normal.y, hit.normal.z) };
        }
      }
    }

    return best !== null && best.distance < distance ? best : null;
  }

  /**
   * Re-run the blocked move from `stepHeight` above the start, then drop back
   * down. Accepted only if we end up further along and land on walkable ground.
   */
  private tryStepUp(
    startPosition: Vector3,
    desiredVelocity: Vector3,
    dt: number,
    coveredFlat: number,
  ): void {
    const { config, state } = this;
    const flatResult = state.position.clone();

    // Bail if there is no headroom to step into.
    const ceiling = this.physics.raycast(
      { x: startPosition.x, y: startPosition.y + this.totalHeight - config.skin, z: startPosition.z },
      { x: 0, y: 1, z: 0 },
      config.stepHeight + config.skin,
      MOVER_SOLID_MASK,
    );
    if (ceiling) return;

    state.position.copy(startPosition).setY(startPosition.y + config.stepHeight);
    this.slide(new Vector3(desiredVelocity.x * dt, 0, desiredVelocity.z * dt));

    const steppedFlat = Math.hypot(
      state.position.x - startPosition.x,
      state.position.z - startPosition.z,
    );
    if (steppedFlat <= coveredFlat + config.skin) {
      state.position.copy(flatResult);
      return;
    }

    // Find the surface we stepped onto.
    const drop = this.physics.raycast(
      { x: state.position.x, y: state.position.y + config.skin, z: state.position.z },
      { x: 0, y: -1, z: 0 },
      config.stepHeight * 2 + config.skin,
      MOVER_SOLID_MASK,
    );
    if (!drop || drop.normal.y < this.minGroundY) {
      state.position.copy(flatResult);
      return;
    }

    state.position.setY(drop.point.y);
    state.grounded = true;
    state.groundNormal.set(drop.normal.x, drop.normal.y, drop.normal.z);
    state.verticalVelocity = 0;
  }

  /** Ground probe, snap-to-floor and ceiling clamp. */
  private resolveVertical(dt: number): void {
    const { config, state } = this;
    const wasGrounded = state.grounded;
    const verticalStep = state.verticalVelocity * dt;

    if (verticalStep > 0) {
      // Rising: stop at the ceiling.
      const headroom = this.physics.raycast(
        { x: state.position.x, y: state.position.y + this.totalHeight - config.skin, z: state.position.z },
        { x: 0, y: 1, z: 0 },
        verticalStep + config.skin,
        MOVER_SOLID_MASK,
      );
      if (headroom) {
        state.position.y += Math.max(0, headroom.distance - config.skin);
        state.verticalVelocity = 0;
      } else {
        state.position.y += verticalStep;
      }
      state.grounded = false;
      return;
    }

    // Falling or glued: probe from just above the feet.
    const probeStart = config.stepHeight;
    // While grounded we look a little further so downhill slopes stay attached.
    const reach = probeStart + Math.abs(verticalStep) + (wasGrounded ? config.groundSnapDistance : config.skin);

    const ground = this.physics.raycast(
      { x: state.position.x, y: state.position.y + probeStart, z: state.position.z },
      { x: 0, y: -1, z: 0 },
      reach,
      MOVER_SOLID_MASK,
    );

    if (ground && ground.normal.y >= this.minGroundY) {
      const groundY = ground.point.y;
      const wouldFallTo = state.position.y + verticalStep;
      if (wouldFallTo <= groundY) {
        state.position.y = groundY;
        state.verticalVelocity = 0;
        state.grounded = true;
        state.groundNormal.set(ground.normal.x, ground.normal.y, ground.normal.z);
        return;
      }
    }

    state.position.y += verticalStep;
    state.grounded = false;
    state.groundNormal.set(0, 1, 0);
  }

  /** Drive the kinematic body so sensors and dynamic props see the player. */
  private syncBody(dt: number): void {
    const entity = this.physics.get(this.bodyId);
    if (!entity) return;
    const center = this.capsuleCenter;
    entity.body.setTargetTransform(
      { position: { x: center.x, y: center.y, z: center.z }, rotation: { x: 0, y: 0, z: 0, w: 1 } },
      dt,
    );
  }

  /** Hard placement — room transitions, loading a save, respawn. */
  teleport(position: Vector3): void {
    this.state.position.copy(position);
    this.state.verticalVelocity = 0;
    this.state.grounded = false;
    const entity = this.physics.get(this.bodyId);
    if (entity) {
      const center = this.capsuleCenter;
      entity.body.setTransform({
        position: { x: center.x, y: center.y, z: center.z },
        rotation: { x: 0, y: 0, z: 0, w: 1 },
      });
    }
  }
}
