import {
  AnimationAction,
  AnimationClip,
  AnimationMixer,
  Box3,
  LoopRepeat,
  Object3D,
  Quaternion,
  Vector2,
  Vector3,
} from "three";
import { JoystickC, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";

const MOVE_SPEED = 4; // world units per second
const START_POSITION = { x: 1, y: 0, z: -8 }; // start of the road, the other end, feet on the ground

// Collision box used for movement — a rough silhouette of the rigged model
// (bbox is ~0.9 x 1.72 x 1.0), not its exact bounds.
const PLAYER_SIZE = new Vector3(0.8, 1.7, 0.8);

export const PLAYER_HEIGHT = PLAYER_SIZE.y;

// Colliders shorter than this are treated as ground, not obstacles: the
// map's floor collider is a ~0.3-unit-thin slab spanning almost the whole
// map, while real props (crates, walls) stand ~2 units tall.
const MIN_OBSTACLE_HEIGHT = 0.5;

// Max turn rate, in radians/second — a hard cap on angular speed, not a
// proportional ease. Exponential damping (like CameraFollowC uses for
// position) closes a fixed percentage of the remaining angle per second, so
// a big gap (e.g. a 180° reversal) still covers a huge arc in the very
// first frame and reads as a snap. A constant max speed turns a small
// correction and a full reversal at the same rate, which reads as smooth.
const MAX_TURN_SPEED = Math.PI * 3; // ~540°/s

const ANIMATION_FADE = 0.2; // seconds, crossfade between idle/walk/attack

// How far off-center (in the XZ plane) an obstacle can be from the
// character's forward direction and still count as "facing it".
// dot(forward, toObstacle) > this — 0.5 is a 120°-wide cone (60° each side).
const FACING_DOT_THRESHOLD = 0.5;

// tryMove() rejects the whole step that would overlap an obstacle, so the
// player always stops just short of actually touching it (up to one frame's
// movement worth of gap) — Box3.intersectsBox on the exact collision box
// would basically never see contact. This extra margin, checked only for
// the attack/interaction range (not movement), covers that gap.
const INTERACTION_REACH = 0.4;

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

export class PlayerC {
  static object: Object3D;

  private static moveInput = new Vector2();
  private static obstacles: Box3[] = [];
  private static facingRotation = new Quaternion();

  private static mixer: AnimationMixer;
  private static idleAction: AnimationAction;
  private static walkAction: AnimationAction;
  private static attackAction: AnimationAction;
  private static currentAction: AnimationAction;
  private static isAttacking = false;

  private static pistol: Object3D;
  private static batInHand: Object3D;
  private static batOnBack: Object3D;

  static init(colliders: Object3D[] = []) {
    // `object` is a plain container: movement/rotation logic below assumes
    // its own +z is "forward" (see update()), which happens to already
    // match the loaded model's rest pose — see createCharacter().
    this.object = new Object3D();
    this.object.position.set(START_POSITION.x, START_POSITION.y, START_POSITION.z);
    this.object.add(this.createCharacter());
    ThreeC.addToScene(this.object);

    this.obstacles = colliders
      .map((collider) => new Box3().setFromObject(collider))
      .filter((box) => box.max.y - box.min.y >= MIN_OBSTACLE_HEIGHT);

    this.bindJoystick();

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

  private static createCharacter() {
    const character = ThreeC.getObject("character");

    // "Bullet" is a separate rig bundled in the same file for the (not yet
    // implemented) shooting animations — hide it until it's actually used.
    const bullet = character.getObjectByName("Bullet");
    if (bullet) bullet.visible = false;

    ThreeC.setShadowsStateForChildren(character, true, false);

    this.setupAnimations(character);
    this.setupWeapons(character);

    return character;
  }

  private static setupWeapons(character: Object3D) {
    const pistol = character.getObjectByName("Character_Pistol");
    const batInHand = character.getObjectByName("Tool_1");
    const batOnBack = character.getObjectByName("Tool_2");

    if (!pistol || !batInHand || !batOnBack) {
      throw new Error("character mesh is missing the pistol/bat weapon props");
    }

    this.pistol = pistol;
    this.batInHand = batInHand;
    this.batOnBack = batOnBack;

    this.equipPistol();
  }

  // Exactly one of {pistol, bat} is ever in hand, and the other is stowed —
  // the pistol has no separate "in hand" prop to show (see PlayerC chat
  // notes), so its own single mesh just toggles at its holster spot instead.
  private static equipPistol() {
    this.pistol.visible = true;
    this.batInHand.visible = false;
    this.batOnBack.visible = true;
  }

  private static equipBat() {
    this.pistol.visible = false;
    this.batInHand.visible = true;
    this.batOnBack.visible = false;
  }

  private static setupAnimations(character: Object3D) {
    this.mixer = new AnimationMixer(character);
    ThreeC.addAnimMixer(this.mixer);

    const clips: AnimationClip[] = ThreeC.getAnimations("character");
    const idleClip = AnimationClip.findByName(clips, "Idle");
    const walkClip = AnimationClip.findByName(clips, "SlowWalk");
    const attackClip = AnimationClip.findByName(clips, "Loot");

    if (!idleClip || !walkClip || !attackClip) {
      throw new Error("character mesh is missing the Idle/SlowWalk/Loot animation clips");
    }

    this.idleAction = this.mixer.clipAction(idleClip);
    this.walkAction = this.mixer.clipAction(walkClip);
    this.attackAction = this.mixer.clipAction(attackClip);
    this.attackAction.setLoop(LoopRepeat, Infinity);

    this.currentAction = this.idleAction;
    this.currentAction.play();
  }

  private static playAction(action: AnimationAction) {
    if (this.currentAction === action) return;

    action.reset().fadeIn(ANIMATION_FADE).play();
    this.currentAction.fadeOut(ANIMATION_FADE);
    this.currentAction = action;
  }

  private static bindJoystick() {
    JoystickC.onJoysticMove.addDelegate(({ data }) => {
      // nipplejs clamps the drag to a circle (not per-axis) whenever
      // `follow: false` — see JoystickC.init in beforeResourcesLoadedCb —
      // so `vector` is already length <= 1 and diagonals aren't faster.
      this.moveInput.set(data.vector.x, data.vector.y);
    });

    JoystickC.onJoysticEnd.addDelegate(() => {
      this.moveInput.set(0, 0);
    });
  }

  private static update(delta: number) {
    const inputMagnitude = this.moveInput.length();

    if (inputMagnitude > 0) {
      // Matches the position mapping below (-x -> x, y -> z): the angle a
      // movement vector needs to rotate the character's default +z-facing
      // front to point the same way. x is negated because the camera now
      // looks toward +Z (see TestSceneC's CAMERA_OFFSET) — that's a 180°
      // yaw from the default view, which mirrors world +X to screen-left,
      // so moving/facing needs the same mirror to keep "stick right" mean
      // "screen right".
      const angle = Math.atan2(-this.moveInput.x, this.moveInput.y);
      this.facingRotation.setFromAxisAngle(UP_AXIS, angle);

      const step = MOVE_SPEED * delta;

      // Move one axis at a time so a wall blocking one direction still lets
      // the player slide along it, instead of getting fully stuck.
      this.tryMove(-this.moveInput.x * step, 0);
      this.tryMove(0, this.moveInput.y * step);
    }

    // Turn toward the last movement direction and hold it while idle,
    // rather than resetting to face forward the moment input stops.
    // rotateTowards caps the step at MAX_TURN_SPEED * delta radians instead
    // of interpolating a percentage of the remaining angle. Moving or
    // turning away from the obstacle is exactly what breaks updateCombat()'s
    // facing check below — no separate "walked/turned away" case needed.
    this.object.quaternion.rotateTowards(this.facingRotation, MAX_TURN_SPEED * delta);

    this.updateCombat();

    if (!this.isAttacking) {
      if (inputMagnitude > 0) {
        // Match the walk cycle's playback speed to how hard the stick is
        // pushed, not just whether it's pushed — otherwise a small nudge
        // still plays the animation at full speed while the character
        // barely moves, and the feet visibly skate across the ground.
        this.walkAction.timeScale = inputMagnitude;
        this.playAction(this.walkAction);
      } else {
        this.playAction(this.idleAction);
      }
    }
  }

  // Attacking requires standing next to a destructible AND facing it — pure
  // proximity (e.g. backing into a crate) shouldn't swing the bat. Re-reads
  // both conditions every frame, so walking or turning away — or the
  // obstacle later being removed by a destruction system — stops it
  // automatically, with no separate "stop" case to maintain.
  private static updateCombat() {
    const obstacleCenter = this.getFacingObstacleCenter();
    const isAttacking = obstacleCenter !== null;

    if (isAttacking === this.isAttacking) return;
    this.isAttacking = isAttacking;

    if (isAttacking) {
      this.equipBat();
      this.playAction(this.attackAction);
    } else {
      this.equipPistol();
    }
  }

  // Returns the center of the destructible obstacle the player is touching
  // AND facing, or null if there isn't one.
  private static getFacingObstacleCenter(): Vector3 | null {
    const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
    const touching = this.obstacles.find((box) => box.intersectsBox(bounds));
    if (!touching) return null;

    const center = touching.getCenter(new Vector3());

    const toObstacle = center.clone().sub(this.object.position);
    toObstacle.y = 0;
    if (toObstacle.lengthSq() < 1e-6) return center; // standing right on top of it — count as facing

    toObstacle.normalize();
    const forward = FORWARD_AXIS.clone().applyQuaternion(this.object.quaternion);
    forward.y = 0;
    forward.normalize();

    return forward.dot(toObstacle) > FACING_DOT_THRESHOLD ? center : null;
  }

  private static tryMove(dx: number, dz: number) {
    const nextPosition = this.object.position.clone();
    nextPosition.x += dx;
    nextPosition.z += dz;

    const nextBounds = this.getCollisionBounds(nextPosition);
    const blocked = this.obstacles.some((box) => box.intersectsBox(nextBounds));
    if (blocked) return;

    this.object.position.copy(nextPosition);
  }

  // position is anchored at the feet, but the collision box should be
  // centered on the body
  private static getCollisionBounds(position: Vector3): Box3 {
    const center = position.clone();
    center.y += PLAYER_SIZE.y / 2;
    return new Box3().setFromCenterAndSize(center, PLAYER_SIZE);
  }
}
