import { Object3D, Raycaster, Vector3 } from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";

// How fast the camera closes the gap to its target position/look point, per
// second. Higher = snappier, lower = floatier. Framerate-independent (see
// the exponential smoothing in update()), so the feel is the same at 30fps
// and 60fps instead of drifting with frame time.
const POSITION_DAMPING = 5;
const LOOK_DAMPING = 8;

// Keeps the camera a bit off any obstacle it lands on, and never lets it
// collapse onto the target itself.
const OBSTACLE_SKIN = 0.4;
const MIN_DISTANCE = 1.5;

// How far (world units) the whole camera rig — position and look-at point
// alike — pans toward wherever the character is currently facing, to open
// up more of the space ahead of them instead of centering them dead-on.
// Matches PlayerC's own forward convention (+z at rest); the two aren't
// coupled in code, but there's only one character to stay in sync with.
const LOOK_AHEAD_DISTANCE = 1.2;

// The character's body can spin at up to 540°/s (PlayerC.MAX_TURN_SPEED),
// which would yank the look-ahead point around just as fast if it followed
// the raw facing direction. Smoothing the direction itself, separately from
// (and slower than) the position/look damping below, is what actually makes
// the pan gentle regardless of how fast the character turns.
const LOOK_AHEAD_DAMPING = 2;

const FORWARD_AXIS = new Vector3(0, 0, 1);

export class CameraFollowC {
  private static target: Object3D | null = null;
  private static offset = new Vector3();
  private static obstacles: Object3D[] = [];
  private static eyeHeight = 0;
  private static smoothedLookAt = new Vector3();
  // The actual (x,y,z) offset, not a direction — see update() for why that
  // distinction matters.
  private static smoothedLookAhead = FORWARD_AXIS.clone().multiplyScalar(LOOK_AHEAD_DISTANCE);
  private static raycaster = new Raycaster();

  static init(target: Object3D, offset: Vector3, obstacles: Object3D[] = [], eyeHeight = 0) {
    this.target = target;
    this.offset = offset.clone();
    this.obstacles = obstacles;
    this.eyeHeight = eyeHeight;
    this.smoothedLookAt.copy(target.position);

    UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
  }

  private static update(delta: number) {
    if (!this.target) return;

    const camera = CameraC_internal.getCamera();
    const targetPosition = this.target.position;

    const facing = FORWARD_AXIS.clone().applyQuaternion(this.target.quaternion);
    facing.y = 0;
    facing.normalize();
    const desiredLookAhead = facing.multiplyScalar(LOOK_AHEAD_DISTANCE);

    // Damping the raw offset vector (not a unit direction re-normalized
    // every frame) is what keeps this a straight-line move through the
    // character on a direction change, instead of an arc: re-normalizing
    // pins the vector's length at LOOK_AHEAD_DISTANCE the whole time, which
    // forces it to sweep around a circle of that radius as the direction
    // changes. Left as plain (x,y,z) lerp, it can shrink through zero and
    // grow back out the other way — a straight line, not a curve.
    const lookAheadT = 1 - Math.exp(-LOOK_AHEAD_DAMPING * delta);
    this.smoothedLookAhead.lerp(desiredLookAhead, lookAheadT);
    const lookAhead = this.smoothedLookAhead;

    const desiredPosition = targetPosition.clone().add(this.offset).add(lookAhead);
    this.avoidObstacles(targetPosition, desiredPosition);

    // Exponential (framerate-independent) damping instead of a fixed lerp
    // factor per frame — a fixed factor changes speed with the frame rate
    // and reads as jerky; this keeps the same feel regardless of fps.
    const positionT = 1 - Math.exp(-POSITION_DAMPING * delta);
    camera.position.lerp(desiredPosition, positionT);

    const lookT = 1 - Math.exp(-LOOK_DAMPING * delta);
    this.smoothedLookAt.lerp(targetPosition.clone().add(lookAhead), lookT);
    camera.lookAt(this.smoothedLookAt);
  }

  // Only pulls the camera in when an obstacle would actually hide the
  // character. The check ray starts at (roughly) eye height instead of the
  // character's center/feet, so something that doesn't reach that high never
  // triggers a zoom — the character is still visible over it. When it does
  // trigger, the ray's own hit distance (measured from the character, not
  // from the camera) is what places the camera, so it lands right next to
  // the obstacle on the character's side instead of collapsing onto the
  // character whenever they aren't standing flush against it.
  private static avoidObstacles(targetPosition: Vector3, desired: Vector3) {
    if (this.obstacles.length === 0) return;

    const eyePosition = targetPosition.clone().add(new Vector3(0, this.eyeHeight, 0));
    const toDesired = desired.clone().sub(eyePosition);
    const distance = toDesired.length();
    if (distance < 1e-4) return;

    const direction = toDesired.divideScalar(distance);
    this.raycaster.set(eyePosition, direction);
    this.raycaster.far = distance;

    const hits = this.raycaster.intersectObjects(this.obstacles, true);
    if (hits.length === 0) return;

    const safeDistance = Math.max(hits[0].distance - OBSTACLE_SKIN, MIN_DISTANCE);
    desired.copy(eyePosition).addScaledVector(direction, safeDistance);
  }
}
