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

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

// Rate-limited ramps (units/second²) instead of snapping straight to
// MOVE_SPEED — reaching full speed takes MOVE_SPEED/ACCELERATION seconds
// (~0.33s), and stopping takes MOVE_SPEED/DECELERATION (~0.2s). Braking
// faster than starting is the usual game-feel choice: it keeps stops feeling
// responsive while movement still has some weight to it.
const ACCELERATION = 12;
const DECELERATION = 20;

function moveTowards(current: number, target: number, maxDelta: number): number {
  if (Math.abs(target - current) <= maxDelta) return target;
  return current + Math.sign(target - current) * maxDelta;
}

// 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

// A near-full reversal (stick flicked to roughly the opposite direction)
// snaps the facing instantly instead of turning gradually at MAX_TURN_SPEED
// — rotating through a slow half-circle reads as sluggish for a direction
// flip specifically. Smaller corrections still use the capped gradual turn.
// ~150°, not a full 180°, so a nearly-opposite flick still snaps too.
const OPPOSITE_TURN_THRESHOLD = Math.PI * (150 / 180);

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 240°-wide cone (120° 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.6; // +50%

// The attack loops continuously (see updateCombat) — the "Loot" clip's swing
// actually connects twice per loop (two distinct bat impacts), so each loop
// lands two damage pulses, not one, timed to these fractions of the clip
// rather than right at the loop seam so the bat visually connects first.
// Tune these against the actual clip if the impacts don't line up visually.
const HIT_TIME_FRACTIONS = [0.40,0.60];

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

interface Obstacle {
  node: Object3D;
  box: Box3;
}

interface NearbyObstacle {
  node: Object3D;
  center: Vector3;
}

interface ZoneTarget {
  center: Vector3;
  prop: BreakableProp;
}

export class PlayerC {
  static object: Object3D;

  private static moveInput = new Vector2();
  // Last non-zero input direction (normalized) — kept around so
  // deceleration still has a direction to coast along after the stick is
  // released and moveInput snaps to zero.
  private static moveDirection = new Vector2(0, 1);
  private static currentSpeed = 0;
  private static obstacles: Obstacle[] = [];
  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 attackElapsed = 0;
  private static nextHitTime = 0;
  // Counts up across the whole continuous attack (not reset per loop) —
  // used to pick which of HIT_TIME_FRACTIONS is next and which loop it
  // falls in, so timing stays correct across loop seams.
  private static hitsThisAttack = 0;

  // The point the character is currently locked onto facing (a nearby
  // breakable it's stopped next to), or null while just moving/idling with
  // nothing to face. Exposed for CameraFollowC — see getFacingTarget().
  private static facingTarget: Vector3 | null = null;

  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) => ({ node: collider, box: new Box3().setFromObject(collider) }))
      .filter((obstacle) => obstacle.box.max.y - obstacle.box.min.y >= MIN_OBSTACLE_HEIGHT);

    this.bindJoystick();

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

  // Called when a breakable prop's collider is torn down (see
  // BreakablePropC.destroy) so it stops blocking movement.
  static removeObstacle(node: Object3D) {
    this.obstacles = this.obstacles.filter((obstacle) => obstacle.node !== node);
  }

  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();
  }

  // The in-hand bat prop's own node — WeaponTrailC samples its world
  // position every frame while attacking to build the swing trail. Its
  // pivot is whatever the artist rigged it to (likely the grip, not the
  // business end) — this hands over the raw node rather than guessing a
  // local tip offset; retune with a local-space offset in WeaponTrailC if
  // the trail visibly anchors at the handle instead of the bat's tip.
  static getWeaponAnchor(): Object3D {
    return this.batInHand;
  }

  // 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);
    // Loops continuously while attacking — see updateCombat().
    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) {
    // Movement always runs, attacking or not — moving is what interrupts an
    // attack (see updateCombat), so it can never be locked out.
    const nearbyObstacle = this.findNearbyObstacle();
    const stopped = this.updateMovement(delta, nearbyObstacle);

    this.updateCombat(delta, stopped);

    if (!this.isAttacking) {
      if (this.currentSpeed > 0) {
        // Match the walk cycle's playback speed to actual current speed, not
        // just whether the stick is pushed — otherwise the animation would
        // freeze into Idle the instant input stops while the character is
        // still coasting to a stop, or run at full pace during the initial
        // ramp-up while barely moving.
        this.walkAction.timeScale = this.currentSpeed / MOVE_SPEED;
        this.playAction(this.walkAction);
      } else {
        this.playAction(this.idleAction);
      }
    }
  }

  // Stick-driven movement, facing, and turning — runs every frame regardless
  // of combat state (moving is what cancels an attack, see updateCombat, so
  // it can't be the thing gated off during one). Returns whether the
  // character is now fully stopped (no input AND speed decayed to zero),
  // the bar updateCombat uses to allow an attack to start or continue.
  private static updateMovement(delta: number, nearbyObstacle: NearbyObstacle | null): boolean {
    const inputMagnitude = this.moveInput.length();

    // Movement direction tracks the stick independently of facing below, so
    // walking around/past an obstacle in range still works even though
    // facing itself may be locked onto it once stopped.
    if (inputMagnitude > 0) {
      this.moveDirection.copy(this.moveInput).divideScalar(inputMagnitude);

      // Direct world-space direction -> angle: 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 facing needs
      // the same mirror to keep "stick right" mean "screen right". Facing
      // follows the raw stick immediately — it's intent, not momentum, so it
      // shouldn't wait on the speed ramp below.
      const angle = Math.atan2(-this.moveInput.x, this.moveInput.y);
      this.facingRotation.setFromAxisAngle(UP_AXIS, angle);
    }

    // Ramp actual speed toward the stick's target instead of snapping to it:
    // moveDirection (not moveInput) drives the step below, so releasing the
    // stick keeps coasting along the last heading while DECELERATION brings
    // this to zero, instead of stopping dead the instant input hits zero.
    const targetSpeed = inputMagnitude * MOVE_SPEED;
    const rate = targetSpeed > this.currentSpeed ? ACCELERATION : DECELERATION;
    this.currentSpeed = moveTowards(this.currentSpeed, targetSpeed, rate * delta);

    if (this.currentSpeed > 0) {
      const step = this.currentSpeed * 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.moveDirection.x * step, 0);
      this.tryMove(0, this.moveDirection.y * step);
    }

    // Only take over facing once fully stopped (no input AND speed decayed
    // to zero) — otherwise the face would snap onto the obstacle mid-stride
    // or mid-coast while the body is still visibly heading somewhere else.
    const stopped = inputMagnitude === 0 && this.currentSpeed <= 0;
    if (stopped && nearbyObstacle) {
      this.faceTowards(nearbyObstacle.center);
      this.facingTarget = nearbyObstacle.center;
    } else {
      this.facingTarget = null;
    }

    // Turn toward the current facing target (obstacle or movement) 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 — except
    // for a near-180° reversal, which snaps immediately (see chat).
    if (this.object.quaternion.angleTo(this.facingRotation) > OPPOSITE_TURN_THRESHOLD) {
      this.object.quaternion.copy(this.facingRotation);
    } else {
      this.object.quaternion.rotateTowards(this.facingRotation, MAX_TURN_SPEED * delta);
    }

    return stopped;
  }

  // The point the character is currently locked onto facing (a nearby
  // breakable it's stopped next to), or null otherwise — CameraFollowC uses
  // this to pan toward the same point, since it's a fixed world position
  // (not a rotation), so it can't produce the arc/orbit motion a
  // facing/quaternion-driven look-ahead did (see CameraFollowC chat notes).
  static getFacingTarget(): Vector3 | null {
    return this.facingTarget;
  }

  private static faceTowards(point: Vector3) {
    const toPoint = point.clone().sub(this.object.position);
    toPoint.y = 0;
    if (toPoint.lengthSq() < 1e-6) return; // standing right on top of it — keep whatever we're already facing

    const angle = Math.atan2(toPoint.x, toPoint.z);
    this.facingRotation.setFromAxisAngle(UP_AXIS, angle);
  }

  // The attack loops continuously and hits *every* breakable target in the
  // zone each cycle (not just the one the character turned to face) — it
  // keeps going, unattended, until the zone is empty. The only things that
  // stop it early are the player moving again or running out of targets;
  // there's no commitment to "finish the swing" the way a discrete attack
  // would have.
  private static updateCombat(delta: number, stopped: boolean) {
    const zoneTargets = this.findBreakableTargetsInZone();

    if (this.isAttacking) {
      if (!stopped || zoneTargets.length === 0) {
        this.isAttacking = false;
        WeaponTrailC.setActive(false);
        this.equipPistol();
        return;
      }

      this.attackElapsed += delta;
      if (this.attackElapsed >= this.nextHitTime) {
        zoneTargets.forEach((target) => BreakablePropC.hit(target.prop, this.hitDirectionTo(target.center)));
        this.hitsThisAttack++;
        this.nextHitTime = this.hitTimeFor(this.hitsThisAttack);
      }
      return;
    }

    if (!stopped || zoneTargets.length === 0) return;

    const nearest = this.nearestZoneTarget(zoneTargets);
    if (!this.isFacing(nearest.center)) return;

    this.isAttacking = true;
    WeaponTrailC.setActive(true);
    this.attackElapsed = 0;
    this.hitsThisAttack = 0;
    this.nextHitTime = this.hitTimeFor(0);
    this.equipBat();
    this.playAction(this.attackAction);
  }

  // Time (seconds since the attack started) the Nth hit lands — cycles
  // through HIT_TIME_FRACTIONS within each loop of the clip, advancing a
  // full extra loop every time the cycle wraps.
  private static hitTimeFor(hitIndex: number): number {
    const duration = this.attackAction.getClip().duration;
    const loopsCompleted = Math.floor(hitIndex / HIT_TIME_FRACTIONS.length);
    const fraction = HIT_TIME_FRACTIONS[hitIndex % HIT_TIME_FRACTIONS.length];
    return duration * (loopsCompleted + fraction);
  }

  private static nearestZoneTarget(targets: ZoneTarget[]): ZoneTarget {
    return targets.reduce((closest, target) =>
      this.object.position.distanceToSquared(target.center) <
      this.object.position.distanceToSquared(closest.center)
        ? target
        : closest
    );
  }

  // Every breakable prop within interaction range AND within the frontal
  // swing arc (same cone as isFacing, see chat: hitting crates standing
  // behind the player didn't make sense for a forward bat swing) —
  // "unlimited targets in the hit zone" per the design, just not a full
  // 360° one. Non-breakable obstacles (walls) are excluded: there's
  // nothing for the attack to do to them.
  private static findBreakableTargetsInZone(): ZoneTarget[] {
    const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
    const targets: ZoneTarget[] = [];

    for (const obstacle of this.obstacles) {
      if (!obstacle.box.intersectsBox(bounds)) continue;
      const prop = BreakablePropC.getByColliderNode(obstacle.node);
      if (!prop) continue;
      const center = obstacle.box.getCenter(new Vector3());
      if (!this.isFacing(center)) continue;
      targets.push({ center, prop });
    }

    return targets;
  }

  // Returns the nearest obstacle (breakable or not) within interaction
  // range, purely so idle facing (see updateMovement) has something to turn
  // toward — unrelated to which targets an attack actually hits.
  private static findNearbyObstacle(): NearbyObstacle | null {
    const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
    const touching = this.obstacles.find((obstacle) => obstacle.box.intersectsBox(bounds));
    return touching ? { node: touching.node, center: touching.box.getCenter(new Vector3()) } : null;
  }

  // Attacker -> target, flattened to XZ and normalized — how BreakablePropC
  // decides which way a hit crate should lean. Zero vector (standing right
  // on top of the target) is a valid, harmless result: no lean either way.
  private static hitDirectionTo(point: Vector3): Vector3 {
    const direction = point.clone().sub(this.object.position);
    direction.y = 0;
    if (direction.lengthSq() > 1e-6) direction.normalize();
    return direction;
  }

  private static isFacing(point: Vector3): boolean {
    const toPoint = point.clone().sub(this.object.position);
    toPoint.y = 0;
    if (toPoint.lengthSq() < 1e-6) return true; // standing right on top of it

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

    return forward.dot(toPoint) > FACING_DOT_THRESHOLD;
  }

  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((obstacle) => obstacle.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);
  }
}
