import { Vector3 } from "three";

import type { Vec3 } from "../core/vector";

import {
  FIRST_PERSON_PLAYER_SHAPE,
  cloneFirstPersonPlayerShape,
  getFirstPersonPlayerEyeHeight
} from "./player-collision";
import {
  resolvePlayerStartActionInputs,
  resolvePlayerStartLookInput,
} from "./player-input-bindings";
import {
  createIdleRuntimeLocomotionState,
  stepPlayerLocomotion
} from "./player-locomotion";
import { createPlayerControllerTelemetry } from "./player-controller-telemetry";
import { smoothGroundedStairHeight } from "./stair-height-smoothing";
import type {
  NavigationController,
  NavigationControllerDeactivateOptions,
  PlayerControllerTelemetry,
  RuntimeControllerContext,
  RuntimeLocomotionState,
  RuntimeTargetLookInputResult
} from "./navigation-controller";
import type { RuntimePlayerMovement } from "./runtime-scene-build";

const LOOK_SENSITIVITY = 0.008;
const GAMEPAD_LOOK_SPEED = 2.8;
const DEFAULT_CAMERA_DISTANCE = 4.5;
const MIN_CAMERA_DISTANCE = 1.5;
const MAX_CAMERA_DISTANCE = 7;
const DEFAULT_PITCH_RADIANS = 0.35;
const MIN_PITCH_RADIANS = -Math.PI * 0.3;
const MAX_PITCH_RADIANS = Math.PI * 0.45;
export const THIRD_PERSON_CAMERA_COLLISION_RADIUS = 0.2;
const CAMERA_COLLISION_RECOVERY_SPEED = 6.5;
const CAMERA_COLLISION_DISTANCE_EPSILON = 1e-4;
const CAMERA_PIVOT_EYE_HEIGHT_FACTOR = 0.85;
const TARGET_ASSIST_YAW_SPEED = 2.2;
const TARGET_ASSIST_ORBIT_PITCH_RETURN_SPEED = 5.5;
const TARGET_ASSIST_VERTICAL_LOOK_SPEED = 3.4;
const TARGET_ASSIST_VERTICAL_LOOK_LIMIT = 1.25;
const TARGET_ASSIST_VERTICAL_COLLISION_FADE_START_RATIO = 0.28;
const TARGET_ASSIST_VERTICAL_COLLISION_FADE_END_RATIO = 0.72;
const TARGET_LOOK_OFFSET_GAMEPAD_SPEED = 1.15;
const TARGET_LOOK_OFFSET_POINTER_SENSITIVITY = 0.004;
const TARGET_LOOK_OFFSET_RETURN_SPEED = 5.5;
const TARGET_LOOK_OFFSET_YAW_LIMIT = 0.75;
const TARGET_LOOK_OFFSET_PITCH_LIMIT = 0.42;
const POINTER_TARGET_LOOK_INPUT_SCALE = 0.06;

function clampPitch(pitchRadians: number): number {
  return Math.max(
    MIN_PITCH_RADIANS,
    Math.min(MAX_PITCH_RADIANS, pitchRadians)
  );
}

function clampCameraDistance(distance: number): number {
  return Math.max(
    MIN_CAMERA_DISTANCE,
    Math.min(MAX_CAMERA_DISTANCE, distance)
  );
}

function normalizeAngleRadians(angle: number): number {
  return Math.atan2(Math.sin(angle), Math.cos(angle));
}

function dampAngleRadians(
  current: number,
  target: number,
  strength: number,
  dt: number
): number {
  if (dt <= 0 || strength <= 0) {
    return current;
  }

  const alpha = 1 - Math.exp(-strength * dt);
  return current + normalizeAngleRadians(target - current) * alpha;
}

function dampScalar(
  current: number,
  target: number,
  strength: number,
  dt: number
): number {
  if (dt <= 0 || strength <= 0) {
    return current;
  }

  const alpha = 1 - Math.exp(-strength * dt);

  return current + (target - current) * alpha;
}

function smoothStep01(value: number): number {
  const t = Math.max(0, Math.min(1, value));

  return t * t * (3 - 2 * t);
}

function toEyePosition(feetPosition: Vec3, eyeHeight: number): Vec3 {
  return {
    x: feetPosition.x,
    y: feetPosition.y + eyeHeight,
    z: feetPosition.z
  };
}

function cloneRuntimePlayerMovement(
  movement: RuntimePlayerMovement
): RuntimePlayerMovement {
  return {
    templateKind: movement.templateKind,
    moveSpeed: movement.moveSpeed,
    maxSpeed: movement.maxSpeed,
    maxStepHeight: movement.maxStepHeight,
    capabilities: {
      jump: movement.capabilities.jump,
      sprint: movement.capabilities.sprint,
      crouch: movement.capabilities.crouch
    },
    jump: {
      speed: movement.jump.speed,
      bufferMs: movement.jump.bufferMs,
      coyoteTimeMs: movement.jump.coyoteTimeMs,
      variableHeight: movement.jump.variableHeight,
      maxHoldMs: movement.jump.maxHoldMs,
      moveWhileJumping: movement.jump.moveWhileJumping,
      moveWhileFalling: movement.jump.moveWhileFalling,
      directionOnly: movement.jump.directionOnly,
      bunnyHop: movement.jump.bunnyHop,
      bunnyHopBoost: movement.jump.bunnyHopBoost
    },
    sprint: {
      speedMultiplier: movement.sprint.speedMultiplier
    },
    crouch: {
      speedMultiplier: movement.crouch.speedMultiplier
    }
  };
}

export class ThirdPersonNavigationController implements NavigationController {
  readonly id = "thirdPerson" as const;

  private context: RuntimeControllerContext | null = null;
  private readonly pressedKeys = new Set<string>();
  private readonly lookAtVector = new Vector3();
  private feetPosition = {
    x: 0,
    y: 0,
    z: 0
  };
  private standingPlayerShape = cloneFirstPersonPlayerShape(
    FIRST_PERSON_PLAYER_SHAPE
  );
  private activePlayerShape = cloneFirstPersonPlayerShape(
    FIRST_PERSON_PLAYER_SHAPE
  );
  private yawRadians = 0;
  private cameraYawRadians = 0;
  private pitchRadians = DEFAULT_PITCH_RADIANS;
  private targetLookOffsetYawRadians = 0;
  private targetLookOffsetPitchRadians = 0;
  private targetAssistLookOffsetY = 0;
  private cameraDistance = DEFAULT_CAMERA_DISTANCE;
  private verticalVelocity = 0;
  private grounded = false;
  private jumpPressed = false;
  private locomotionState: RuntimeLocomotionState =
    createIdleRuntimeLocomotionState("flying");
  private inWaterVolume = false;
  private inFogVolume = false;
  private dragging = false;
  private pointerLookInputPending = false;
  private lastPointerClientX = 0;
  private lastPointerClientY = 0;
  private initializedFromSpawn = false;
  private previousTelemetry: PlayerControllerTelemetry | null = null;
  private latestJumpStarted = false;
  private latestHeadBump = false;
  private smoothedFeetY = 0;
  private smoothedCameraCollisionDistance: number | null = null;
  private previousPlanarDisplacement = {
    x: 0,
    y: 0,
    z: 0
  };
  private jumpBufferRemainingMs = 0;
  private coyoteTimeRemainingMs = 0;
  private jumpHoldRemainingMs = 0;

  activate(ctx: RuntimeControllerContext): void {
    this.context = ctx;

    if (!this.initializedFromSpawn) {
      const runtimeScene = ctx.getRuntimeScene();
      const spawn = runtimeScene.spawn;
      this.feetPosition = {
        ...spawn.position
      };
      this.standingPlayerShape = cloneFirstPersonPlayerShape(
        runtimeScene.playerCollider
      );
      this.activePlayerShape = cloneFirstPersonPlayerShape(
        runtimeScene.playerCollider
      );
      this.yawRadians = (spawn.yawDegrees * Math.PI) / 180;
      this.cameraYawRadians = this.yawRadians;
      this.pitchRadians = DEFAULT_PITCH_RADIANS;
      this.targetLookOffsetYawRadians = 0;
      this.targetLookOffsetPitchRadians = 0;
      this.targetAssistLookOffsetY = 0;
      this.cameraDistance = DEFAULT_CAMERA_DISTANCE;
      this.verticalVelocity = 0;
      this.grounded = false;
      this.jumpPressed = false;
      this.smoothedFeetY = this.feetPosition.y;
      this.locomotionState = createIdleRuntimeLocomotionState(
        runtimeScene.playerCollider.mode === "none" ? "flying" : "airborne"
      );
      this.inWaterVolume = false;
      this.inFogVolume = false;
      this.initializedFromSpawn = true;
    }

    window.addEventListener("keydown", this.handleKeyDown);
    window.addEventListener("keyup", this.handleKeyUp);
    window.addEventListener("blur", this.handleBlur);
    ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
    ctx.domElement.addEventListener("wheel", this.handleWheel, {
      passive: false
    });
    ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
    window.addEventListener("pointermove", this.handlePointerMove);
    window.addEventListener("pointerup", this.handlePointerUp);

    ctx.setRuntimeMessage(
      "Third Person active. Drag to orbit the camera, use the right stick for gamepad camera look, move with your authored bindings, and scroll to zoom."
    );
    this.updateCameraTransform(0);
    this.publishTelemetry();
  }

  deactivate(
    ctx: RuntimeControllerContext,
    _options: NavigationControllerDeactivateOptions = {}
  ): void {
    void _options;
    window.removeEventListener("keydown", this.handleKeyDown);
    window.removeEventListener("keyup", this.handleKeyUp);
    window.removeEventListener("blur", this.handleBlur);
    ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
    ctx.domElement.removeEventListener("wheel", this.handleWheel);
    ctx.domElement.removeEventListener("contextmenu", this.handleContextMenu);
    window.removeEventListener("pointermove", this.handlePointerMove);
    window.removeEventListener("pointerup", this.handlePointerUp);
    this.pressedKeys.clear();
    this.dragging = false;
    this.jumpPressed = false;
    this.latestJumpStarted = false;
    this.latestHeadBump = false;
    this.previousPlanarDisplacement = {
      x: 0,
      y: 0,
      z: 0
    };
    this.jumpBufferRemainingMs = 0;
    this.coyoteTimeRemainingMs = 0;
    this.jumpHoldRemainingMs = 0;
    this.previousTelemetry = null;
    this.smoothedCameraCollisionDistance = null;
    this.pointerLookInputPending = false;
    ctx.setRuntimeMessage(null);
    ctx.setPlayerControllerTelemetry(null);
    this.context = null;
  }

  resetSceneState(): void {
    this.pressedKeys.clear();
    this.feetPosition = {
      x: 0,
      y: 0,
      z: 0
    };
    this.yawRadians = 0;
    this.cameraYawRadians = 0;
    this.pitchRadians = DEFAULT_PITCH_RADIANS;
    this.targetLookOffsetYawRadians = 0;
    this.targetLookOffsetPitchRadians = 0;
    this.targetAssistLookOffsetY = 0;
    this.cameraDistance = DEFAULT_CAMERA_DISTANCE;
    this.verticalVelocity = 0;
    this.grounded = false;
    this.jumpPressed = false;
    this.smoothedFeetY = 0;
    this.smoothedCameraCollisionDistance = null;
    this.standingPlayerShape = cloneFirstPersonPlayerShape(
      FIRST_PERSON_PLAYER_SHAPE
    );
    this.activePlayerShape = cloneFirstPersonPlayerShape(
      FIRST_PERSON_PLAYER_SHAPE
    );
    this.locomotionState = createIdleRuntimeLocomotionState("flying");
    this.inWaterVolume = false;
    this.inFogVolume = false;
    this.dragging = false;
    this.pointerLookInputPending = false;
    this.lastPointerClientX = 0;
    this.lastPointerClientY = 0;
    this.initializedFromSpawn = false;
    this.previousTelemetry = null;
    this.latestJumpStarted = false;
    this.latestHeadBump = false;
    this.previousPlanarDisplacement = {
      x: 0,
      y: 0,
      z: 0
    };
    this.jumpBufferRemainingMs = 0;
    this.coyoteTimeRemainingMs = 0;
    this.jumpHoldRemainingMs = 0;
  }

  update(dt: number): void {
    if (this.context === null) {
      return;
    }

    const runtimeScene = this.context.getRuntimeScene();
    this.standingPlayerShape = cloneFirstPersonPlayerShape(
      runtimeScene.playerCollider
    );
    const playerMovement = runtimeScene.playerMovement;
    const lookInput = resolvePlayerStartLookInput(
      runtimeScene.playerInputBindings
    );
    const inputState = resolvePlayerStartActionInputs(
      this.pressedKeys,
      runtimeScene.playerInputBindings
    );

    const cameraDrivenExternally = this.context.isCameraDrivenExternally() === true;
    const lookInputActive = lookInput.horizontal !== 0 || lookInput.vertical !== 0;
    const manualLookInputActive =
      lookInputActive || this.pointerLookInputPending;
    this.pointerLookInputPending = false;
    let targetLookResult: RuntimeTargetLookInputResult | null = null;

    if (!cameraDrivenExternally && lookInputActive) {
      targetLookResult =
        this.context.handleRuntimeTargetLookInput?.({
          horizontal: lookInput.horizontal,
          vertical: lookInput.vertical
        }) ?? null;
    } else if (!cameraDrivenExternally) {
      targetLookResult =
        this.context.handleRuntimeTargetLookInput?.({
          horizontal: 0,
          vertical: 0
        }) ?? null;
    }

    if (
      !cameraDrivenExternally &&
      lookInputActive &&
      targetLookResult?.activeTargetLocked === true
    ) {
      if (
        targetLookResult.switchedTarget !== true &&
        targetLookResult.switchInputHeld !== true
      ) {
        this.applyTargetLookOffsetDelta(
          -lookInput.horizontal * TARGET_LOOK_OFFSET_GAMEPAD_SPEED * dt,
          -lookInput.vertical * TARGET_LOOK_OFFSET_GAMEPAD_SPEED * dt
        );
      }
    } else if (!cameraDrivenExternally && lookInputActive) {
      this.cameraYawRadians -= lookInput.horizontal * GAMEPAD_LOOK_SPEED * dt;
      this.pitchRadians = clampPitch(
        this.pitchRadians - lookInput.vertical * GAMEPAD_LOOK_SPEED * dt
      );
    }

    if (
      cameraDrivenExternally ||
      !manualLookInputActive ||
      targetLookResult?.activeTargetLocked !== true ||
      targetLookResult.switchedTarget === true ||
      targetLookResult.switchInputHeld === true
    ) {
      this.targetLookOffsetYawRadians = dampScalar(
        this.targetLookOffsetYawRadians,
        0,
        TARGET_LOOK_OFFSET_RETURN_SPEED,
        dt
      );
      this.targetLookOffsetPitchRadians = dampScalar(
        this.targetLookOffsetPitchRadians,
        0,
        TARGET_LOOK_OFFSET_RETURN_SPEED,
        dt
      );
    }

    if (!cameraDrivenExternally && !manualLookInputActive) {
      const targetAssist =
        this.context.resolveThirdPersonTargetAssist?.() ?? null;

      if (targetAssist !== null) {
        const targetYaw = Math.atan2(
          targetAssist.targetPosition.x - this.feetPosition.x,
          targetAssist.targetPosition.z - this.feetPosition.z
        );
        this.pitchRadians = dampScalar(
          this.pitchRadians,
          DEFAULT_PITCH_RADIANS,
          TARGET_ASSIST_ORBIT_PITCH_RETURN_SPEED * targetAssist.strength,
          dt
        );
        this.cameraYawRadians = dampAngleRadians(
          this.cameraYawRadians,
          targetYaw,
          TARGET_ASSIST_YAW_SPEED * targetAssist.strength,
          dt
        );
        const eyeHeight = getFirstPersonPlayerEyeHeight(this.activePlayerShape);
        const pivotY =
          this.smoothedFeetY + eyeHeight * CAMERA_PIVOT_EYE_HEIGHT_FACTOR;
        const targetLookOffsetY = Math.max(
          -TARGET_ASSIST_VERTICAL_LOOK_LIMIT,
          Math.min(
            TARGET_ASSIST_VERTICAL_LOOK_LIMIT,
            targetAssist.targetPosition.y - pivotY
          )
        );
        this.targetAssistLookOffsetY = dampScalar(
          this.targetAssistLookOffsetY,
          targetLookOffsetY,
          TARGET_ASSIST_VERTICAL_LOOK_SPEED * targetAssist.strength,
          dt
        );
      } else {
        this.targetAssistLookOffsetY = dampScalar(
          this.targetAssistLookOffsetY,
          0,
          TARGET_ASSIST_VERTICAL_LOOK_SPEED,
          dt
        );
      }
    } else if (cameraDrivenExternally) {
      this.targetAssistLookOffsetY = dampScalar(
        this.targetAssistLookOffsetY,
        0,
        TARGET_ASSIST_VERTICAL_LOOK_SPEED,
        dt
      );
    }

    const movementYawRadians =
      cameraDrivenExternally
        ? this.context.getCameraYawRadians()
        : this.cameraYawRadians;

    const locomotionStep = stepPlayerLocomotion(
      {
        dt,
        feetPosition: this.feetPosition,
        movementYawRadians,
        airDirectionYawRadians: this.yawRadians,
        standingShape: this.standingPlayerShape,
        verticalVelocity: this.verticalVelocity,
        previousLocomotionState: this.locomotionState,
        previousPlanarDisplacement: this.previousPlanarDisplacement,
        jumpBufferRemainingMs: this.jumpBufferRemainingMs,
        coyoteTimeRemainingMs: this.coyoteTimeRemainingMs,
        jumpHoldRemainingMs: this.jumpHoldRemainingMs,
        crouched: this.locomotionState.crouched,
        wasJumpPressed: this.jumpPressed,
        input: inputState,
        movement: playerMovement,
        resolveMotion: (feetPosition, motion, shape) =>
          this.context?.resolveFirstPersonMotion(feetPosition, motion, shape) ??
          null,
        resolveVolumeState: (feetPosition) =>
          this.context?.resolvePlayerVolumeState(feetPosition) ?? {
            inWater: false,
            inFog: false,
            waterSurfaceHeight: null
          },
        probeGround: (feetPosition, shape, maxDistance) =>
          this.context?.probePlayerGround?.(feetPosition, shape, maxDistance) ?? {
            grounded: false,
            distance: null,
            normal: null,
            slopeDegrees: null
          },
        canOccupyShape: (feetPosition, shape) =>
          this.context?.canOccupyPlayerShape?.(feetPosition, shape) ?? true
      }
    );

    if (locomotionStep === null) {
      this.updateCameraTransform(dt);
      this.publishTelemetry();
      return;
    }

    this.feetPosition = locomotionStep.feetPosition;
    this.activePlayerShape = locomotionStep.activeShape;
    this.verticalVelocity = locomotionStep.verticalVelocity;
    this.jumpBufferRemainingMs = locomotionStep.jumpBufferRemainingMs;
    this.coyoteTimeRemainingMs = locomotionStep.coyoteTimeRemainingMs;
    this.jumpHoldRemainingMs = locomotionStep.jumpHoldRemainingMs;
    this.jumpPressed = locomotionStep.jumpPressed;
    this.latestJumpStarted = locomotionStep.jumpStarted;
    this.latestHeadBump = locomotionStep.headBump;
    this.locomotionState = locomotionStep.locomotionState;
    this.previousPlanarDisplacement = locomotionStep.planarDisplacement;
    this.grounded = locomotionStep.locomotionState.grounded;
    this.inWaterVolume = locomotionStep.inWaterVolume;
    this.inFogVolume = locomotionStep.inFogVolume;
    this.smoothedFeetY = smoothGroundedStairHeight({
      currentSmoothedFeetY: this.smoothedFeetY,
      targetFeetY: this.feetPosition.y,
      grounded: this.grounded,
      dt,
      maxStepHeight: playerMovement.maxStepHeight
    });

    if (
      Math.hypot(
        locomotionStep.planarDisplacement.x,
        locomotionStep.planarDisplacement.z
      ) > 1e-5
    ) {
      this.yawRadians = Math.atan2(
        locomotionStep.planarDisplacement.x,
        locomotionStep.planarDisplacement.z
      );
    }

    this.updateCameraTransform(dt);
    this.publishTelemetry();
  }

  teleportTo(feetPosition: Vec3, yawDegrees: number) {
    this.feetPosition = {
      ...feetPosition
    };
    this.yawRadians = (yawDegrees * Math.PI) / 180;
    this.cameraYawRadians = this.yawRadians;
    this.pitchRadians = DEFAULT_PITCH_RADIANS;
    this.targetLookOffsetYawRadians = 0;
    this.targetLookOffsetPitchRadians = 0;
    this.targetAssistLookOffsetY = 0;
    this.verticalVelocity = 0;
    this.grounded = false;
    this.jumpPressed = false;
    this.smoothedFeetY = this.feetPosition.y;
    this.smoothedCameraCollisionDistance = null;
    this.activePlayerShape = cloneFirstPersonPlayerShape(
      this.context?.getRuntimeScene().playerCollider ?? FIRST_PERSON_PLAYER_SHAPE
    );
    this.standingPlayerShape = cloneFirstPersonPlayerShape(
      this.context?.getRuntimeScene().playerCollider ?? FIRST_PERSON_PLAYER_SHAPE
    );
    this.locomotionState = createIdleRuntimeLocomotionState(
      this.activePlayerShape.mode === "none" ? "flying" : "airborne"
    );
    this.previousTelemetry = null;
    this.latestJumpStarted = false;
    this.latestHeadBump = false;
    this.previousPlanarDisplacement = {
      x: 0,
      y: 0,
      z: 0
    };
    this.jumpBufferRemainingMs = 0;
    this.coyoteTimeRemainingMs = 0;
    this.jumpHoldRemainingMs = 0;
    this.inWaterVolume = false;
    this.inFogVolume = false;
    this.updateCameraTransform(0);
    this.publishTelemetry();
  }

  private applyTargetLookOffsetDelta(yawDelta: number, pitchDelta: number) {
    const nextYaw = this.targetLookOffsetYawRadians + yawDelta;
    const nextPitch = this.targetLookOffsetPitchRadians + pitchDelta;
    const clampedYaw = Math.max(
      -TARGET_LOOK_OFFSET_YAW_LIMIT,
      Math.min(TARGET_LOOK_OFFSET_YAW_LIMIT, nextYaw)
    );
    const clampedPitch = Math.max(
      -TARGET_LOOK_OFFSET_PITCH_LIMIT,
      Math.min(TARGET_LOOK_OFFSET_PITCH_LIMIT, nextPitch)
    );
    const boundaryReached = clampedYaw !== nextYaw || clampedPitch !== nextPitch;

    this.targetLookOffsetYawRadians = clampedYaw;
    this.targetLookOffsetPitchRadians = clampedPitch;

    if (boundaryReached) {
      this.context?.handleRuntimeTargetLookBoundaryReached?.();
      this.cameraYawRadians += this.targetLookOffsetYawRadians;
      this.pitchRadians = clampPitch(
        this.pitchRadians + this.targetLookOffsetPitchRadians
      );
      this.targetLookOffsetYawRadians = 0;
      this.targetLookOffsetPitchRadians = 0;
    }
  }

  private resolveSmoothedCameraCollisionPosition(
    pivot: Vec3,
    desiredCameraPosition: Vec3,
    resolvedCameraPosition: Vec3,
    dt: number
  ): Vec3 {
    const desiredDelta = {
      x: desiredCameraPosition.x - pivot.x,
      y: desiredCameraPosition.y - pivot.y,
      z: desiredCameraPosition.z - pivot.z
    };
    const desiredDistance = Math.hypot(
      desiredDelta.x,
      desiredDelta.y,
      desiredDelta.z
    );

    if (desiredDistance <= CAMERA_COLLISION_DISTANCE_EPSILON) {
      this.smoothedCameraCollisionDistance = null;
      return resolvedCameraPosition;
    }

    const resolvedDistance = Math.hypot(
      resolvedCameraPosition.x - pivot.x,
      resolvedCameraPosition.y - pivot.y,
      resolvedCameraPosition.z - pivot.z
    );
    const previousDistance = this.smoothedCameraCollisionDistance;
    const nextDistance =
      previousDistance === null ||
      dt <= 0 ||
      resolvedDistance < previousDistance
        ? resolvedDistance
        : dampScalar(
            previousDistance,
            resolvedDistance,
            CAMERA_COLLISION_RECOVERY_SPEED,
            dt
          );
    const clampedDistance = Math.min(
      Math.max(0, nextDistance),
      Math.min(resolvedDistance, desiredDistance)
    );

    this.smoothedCameraCollisionDistance = clampedDistance;

    return {
      x: pivot.x + (desiredDelta.x / desiredDistance) * clampedDistance,
      y: pivot.y + (desiredDelta.y / desiredDistance) * clampedDistance,
      z: pivot.z + (desiredDelta.z / desiredDistance) * clampedDistance
    };
  }

  private updateCameraTransform(dt = 0) {
    if (this.context === null) {
      return;
    }

    const eyeHeight = getFirstPersonPlayerEyeHeight(this.activePlayerShape);
    const pivot = {
      x: this.feetPosition.x,
      y: this.smoothedFeetY + eyeHeight * CAMERA_PIVOT_EYE_HEIGHT_FACTOR,
      z: this.feetPosition.z
    };
    const resolvedCameraYawRadians =
      this.cameraYawRadians + this.targetLookOffsetYawRadians;
    const resolvedPitchRadians = clampPitch(
      this.pitchRadians + this.targetLookOffsetPitchRadians
    );
    const horizontalDistance =
      Math.cos(resolvedPitchRadians) * this.cameraDistance;
    const desiredCameraPosition = {
      x: pivot.x - Math.sin(resolvedCameraYawRadians) * horizontalDistance,
      y: pivot.y + Math.sin(resolvedPitchRadians) * this.cameraDistance,
      z: pivot.z - Math.cos(resolvedCameraYawRadians) * horizontalDistance
    };
    const rawResolvedCameraPosition =
      this.context.resolveThirdPersonCameraCollision(
        pivot,
        desiredCameraPosition,
        THIRD_PERSON_CAMERA_COLLISION_RADIUS
      );
    const resolvedCameraPosition = this.resolveSmoothedCameraCollisionPosition(
      pivot,
      desiredCameraPosition,
      rawResolvedCameraPosition,
      dt
    );
    const resolvedCameraDistance = Math.hypot(
      resolvedCameraPosition.x - pivot.x,
      resolvedCameraPosition.y - pivot.y,
      resolvedCameraPosition.z - pivot.z
    );
    const collisionDistanceRatio =
      resolvedCameraDistance / Math.max(this.cameraDistance, Number.EPSILON);
    const targetAssistVerticalCollisionScale = smoothStep01(
      (collisionDistanceRatio -
        TARGET_ASSIST_VERTICAL_COLLISION_FADE_START_RATIO) /
        (TARGET_ASSIST_VERTICAL_COLLISION_FADE_END_RATIO -
          TARGET_ASSIST_VERTICAL_COLLISION_FADE_START_RATIO)
    );

    this.context.camera.position.set(
      resolvedCameraPosition.x,
      resolvedCameraPosition.y,
      resolvedCameraPosition.z
    );
    this.lookAtVector.set(
      pivot.x,
      pivot.y +
        this.targetAssistLookOffsetY * targetAssistVerticalCollisionScale,
      pivot.z
    );
    this.context.camera.lookAt(this.lookAtVector);
  }

  private publishTelemetry() {
    if (this.context === null) {
      return;
    }

    const eyePosition = toEyePosition(
      this.feetPosition,
      getFirstPersonPlayerEyeHeight(this.activePlayerShape)
    );
    const cameraVolumeState = this.context.resolvePlayerVolumeState({
      x: this.context.camera.position.x,
      y: this.context.camera.position.y,
      z: this.context.camera.position.z
    });
    const cameraSubmerged =
      cameraVolumeState.inWater &&
      cameraVolumeState.waterSurfaceHeight !== null &&
      this.context.camera.position.y < cameraVolumeState.waterSurfaceHeight;

    const telemetry = createPlayerControllerTelemetry({
      feetPosition: {
        ...this.feetPosition
      },
      eyePosition,
      yawDegrees: (this.yawRadians * 180) / Math.PI,
      grounded: this.grounded,
      locomotionState: this.locomotionState,
      movement: cloneRuntimePlayerMovement(
        this.context.getRuntimeScene().playerMovement
      ),
      inWaterVolume: this.inWaterVolume,
      cameraSubmerged,
      inFogVolume: this.inFogVolume,
      pointerLocked: false,
      spawn: this.context.getRuntimeScene().spawn,
      previousLocomotionState: this.previousTelemetry?.locomotionState ?? null,
      previousInWaterVolume: this.previousTelemetry?.inWaterVolume ?? false,
      jumpStarted: this.latestJumpStarted,
      headBump: this.latestHeadBump
    });

    this.context.setPlayerControllerTelemetry(telemetry);
    this.previousTelemetry = telemetry;
    this.latestJumpStarted = false;
    this.latestHeadBump = false;
  }

  private handleKeyDown = (event: KeyboardEvent) => {
    this.pressedKeys.add(event.code);
  };

  private handleKeyUp = (event: KeyboardEvent) => {
    this.pressedKeys.delete(event.code);
  };

  private handleBlur = () => {
    this.pressedKeys.clear();
    this.dragging = false;
  };

  private handlePointerDown = (event: PointerEvent) => {
    if (
      event.button !== 0 ||
      this.context?.isInputSuspended() === true ||
      this.context?.isCameraDrivenExternally() === true
    ) {
      return;
    }

    this.dragging = true;
    this.lastPointerClientX = event.clientX;
    this.lastPointerClientY = event.clientY;
  };

  private handlePointerMove = (event: PointerEvent) => {
    if (
      !this.dragging ||
      this.context?.isInputSuspended() === true ||
      this.context?.isCameraDrivenExternally() === true
    ) {
      return;
    }

    const deltaX = event.clientX - this.lastPointerClientX;
    const deltaY = event.clientY - this.lastPointerClientY;
    this.lastPointerClientX = event.clientX;
    this.lastPointerClientY = event.clientY;

    if (deltaX === 0 && deltaY === 0) {
      return;
    }

    this.pointerLookInputPending = true;

    const targetLookResult =
      this.context?.handleRuntimeTargetLookInput?.({
        horizontal: deltaX * POINTER_TARGET_LOOK_INPUT_SCALE,
        vertical: -deltaY * POINTER_TARGET_LOOK_INPUT_SCALE
      }) ?? null;

    if (targetLookResult?.activeTargetLocked === true) {
      if (
        targetLookResult.switchedTarget !== true &&
        targetLookResult.switchInputHeld !== true
      ) {
        this.applyTargetLookOffsetDelta(
          -deltaX * TARGET_LOOK_OFFSET_POINTER_SENSITIVITY,
          deltaY * TARGET_LOOK_OFFSET_POINTER_SENSITIVITY
        );
      }
      return;
    }

    this.cameraYawRadians -= deltaX * LOOK_SENSITIVITY;
    this.pitchRadians = clampPitch(
      this.pitchRadians + deltaY * LOOK_SENSITIVITY
    );
  };

  private handlePointerUp = () => {
    this.dragging = false;
    this.context?.handleRuntimeTargetLookInput?.({
      horizontal: 0,
      vertical: 0
    });
  };

  private handleWheel = (event: WheelEvent) => {
    if (
      this.context?.isInputSuspended() === true ||
      this.context?.isCameraDrivenExternally() === true
    ) {
      return;
    }

    event.preventDefault();
    this.cameraDistance = clampCameraDistance(
      this.cameraDistance + event.deltaY * 0.01
    );
  };

  private handleContextMenu = (event: MouseEvent) => {
    event.preventDefault();
  };
}
