import { CameraC_internal, JoystickC, ThreeC_internal } from "@hitplay/playable_template";
import { AnimationAction, AnimationMixer, LoopOnce, Mesh, Object3D, Raycaster, Vector3 } from "three";
import { Body } from "cannon-es";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
import { GameLoop } from "../core/GameLoop";

const ANIM_NAMES: Record<string, string[]> = {
  idle: ["idle", "Idle", "IDLE", "stand", "Stand"],
  walk: ["walk", "Walk", "WALK", "walking", "Walking"],
  run:  ["run",  "Run",  "RUN",  "running", "Running", "sprint", "Sprint"],
};

enum MoveState { Idle = "idle", Walk = "walk", Run = "run" }

const CHAR_RADIUS = 0.35;

// How long the bat keeps swinging after the killing hit, before going idle.
const ATTACK_FOLLOW_THROUGH = 0.25;

// How fast the feet ease toward the ground height (per second). Higher = snappier.
const GROUND_SMOOTH = 12;

// The two moments in the Loot clip where each swing connects (0..1 of the clip).
const IMPACT_PHASES = [0.38, 0.68];
// The bat can hit only within ±this of an impact phase — outside it (wind-up /
// follow-through) it deals no damage. Bigger = longer hit window.
const CONTACT_WINDOW = 0.14;

const _inputTarget = new Vector3();

export class PlayerC {
  static maxSpeed     = 4;
  static acceleration = 10;
  static rotateSpeed  = 8;

  private static mesh: Object3D;
  private static body: Body;                 // cannon physics body (collision + gravity)
  private static batInHand: Object3D | null = null;  // GLB node "Tool_1" — bat held in the hand
  private static batOnBack: Object3D | null = null;  // GLB node "Tool_2" — bat holstered on the back
  private static mixer: AnimationMixer;
  private static actions       = new Map<string, AnimationAction>();
  private static state         = MoveState.Idle;
  private static currentAction: AnimationAction | null = null;

  // Set once the player dies (invasion timer ran out): freezes input & combat.
  private static dead = false;

  // Attack state (auto-attacking a nearby crate).
  private static attacking      = false;
  private static attackTarget   = new Vector3();              // world point to face while attacking
  private static hasAttackTarget = false;
  private static attackAction: AnimationAction | null = null; // the "Loot" clip used as the swing

  // Swing hit-window tracking. _activeWindow = which IMPACT_PHASES window we're in
  // (-1 = none). _swingId bumps on entering a window, so CombatC knows it's a new swing.
  private static _activeWindow = -1;
  private static _swingId = 0;

  // Cached bat-mesh ends → its swinging tip in world space (used by getBatTip).
  private static _batEndA: Vector3 | null = null;
  private static _batEndB: Vector3 | null = null;
  private static _tipA = new Vector3();
  private static _tipB = new Vector3();

  // When the killing blow lands we don't cut the swing at the impact frame —
  // we let the bat follow through for this long, then return to idle.
  private static finishing = false;
  private static finishTimer = 0;

  // Desired planar velocity (x/z). The body's y is driven by ground-follow.
  static velocity  = new Vector3();
  private static inputDir = new Vector3();

  private static _camForward = new Vector3();
  private static _camRight   = new Vector3();
  private static _worldUp    = new Vector3(0, 1, 0);

  // Ground following — the map isn't flat (road sits above the sand), so a
  // downward ray finds the surface under the player each frame.
  private static groundObjects: Object3D[] = [];
  private static groundY = 0;                       // fallback surface (flat sand) if the ray misses
  private static _currentSurfaceY = 0;              // smoothed feet height (eases toward the sampled ground)
  private static _downRay  = new Raycaster();
  private static _rayFrom  = new Vector3();
  private static _rayDown  = new Vector3(0, -1, 0);

  static init(mesh: Object3D, groundObjects: Object3D[] = [], groundY = 0) {
    this.mesh = mesh;
    this.groundObjects = groundObjects;
    this.groundY = groundY;
    this._currentSurfaceY = groundY;
    this.createBody();
    this.setupWeapon();
    this.setupAnimations();
    this.setupJoystick();
    GameLoop.register((delta) => this.update(delta));
  }

  // Toggle the bat between the back (idle/normal) and the hand (attack).
  // Call setBatInHand(true) when the attack animation starts, and
  // setBatInHand(false) when it ends.
  static setBatInHand(inHand: boolean) {
    if (this.batInHand) this.batInHand.visible = inHand;
    if (this.batOnBack) this.batOnBack.visible = !inHand;
  }

  /**
   * Enter/leave the attack state. While attacking, the bat is in the hand, the
   * "Loot" swing animation loops, and the player faces `targetPos`. The
   * locomotion animation state machine is suspended until this is turned off.
   */
  static setAttacking(active: boolean, targetPos: Vector3 | null = null) {
    if (this.dead) return; // no combat once dead
    if (active && targetPos) { this.attackTarget.copy(targetPos); this.hasAttackTarget = true; }
    if (this.attacking === active) return;
    this.attacking = active;
    this.finishing = false; // any real start/stop cancels a pending follow-through
    this.setBatInHand(active);

    if (active && this.attackAction) {
      this.currentAction?.fadeOut(0.15);
      this.attackAction.reset().fadeIn(0.15).play();
      this.currentAction = this.attackAction;
      this._activeWindow = -1; // not in a live window yet this attack
      this._swingId = 0;
    } else if (!active) {
      this.hasAttackTarget = false;
      this.attackAction?.fadeOut(0.15);
      const idle = this.findAction(MoveState.Idle);
      if (idle) { idle.reset().fadeIn(0.15).play(); this.currentAction = idle; }
      this.state = MoveState.Idle; // let the locomotion machine take over again
    }
  }

  /**
   * Stop attacking, but only after the current swing follows through (so a
   * crate broken on the first swing still shows the bat completing the strike).
   * Used when there are no crates left in reach; for "player walked away" use
   * setAttacking(false), which stops at once.
   */
  static finishAttack() {
    if (!this.attacking || this.finishing) return;
    this.finishing = true;
    this.finishTimer = ATTACK_FOLLOW_THROUGH;
  }

  /** Kill the player: stop control/combat and play the one-shot Death clip. */
  static die() {
    if (this.dead) return;
    this.dead = true;
    this.attacking = false;
    this.finishing = false;
    this.hasAttackTarget = false;
    this.inputDir.set(0, 0, 0);
    this.velocity.set(0, 0, 0);
    this.body.velocity.set(0, 0, 0);

    // Find the "Death" clip (exact, then any name containing "death").
    let death = this.actions.get("Death");
    if (!death) for (const [n, a] of this.actions) { if (n.toLowerCase().includes("death")) { death = a; break; } }
    if (!death) return;

    this.currentAction?.fadeOut(0.2);
    death.reset();
    death.setLoop(LoopOnce, 1);
    death.clampWhenFinished = true; // hold the last (dead) frame
    death.fadeIn(0.2).play();
    this.currentAction = death;
  }

  /** True once the player has died. */
  static isDead(): boolean {
    return this.dead;
  }

  // ── Private ────────────────────────────────────────────────────────────────

  private static setupWeapon() {
    this.batInHand = this.mesh.getObjectByName("Tool_1") ?? null;
    this.batOnBack = this.mesh.getObjectByName("Tool_2") ?? null;
    this.setBatInHand(false); // normal state: bat rests on the back
  }

  // Cast a ray straight down from above the player and return the Y of the
  // topmost surface hit. Falls back to the flat sand level if nothing is hit.
  private static sampleGroundY(): number {
    if (this.groundObjects.length) {
      this._rayFrom.set(this.body.position.x, this.body.position.y + 5, this.body.position.z);
      this._downRay.set(this._rayFrom, this._rayDown);
      const hits = this._downRay.intersectObjects(this.groundObjects, true);
      if (hits.length) return hits[0].point.y;
    }
    return this.groundY;
  }

  private static createBody() {
    // Sphere collider (PhysicsLayer.Player makes PhysicsBody use a Sphere shape).
    const pb = new PhysicsBody(
      this.mesh,
      false,                 // not a trigger
      1,                     // dynamic
      PhysicsLayer.Player,
      PhysicsLayer.Wall,     // collide with floor + boundary walls
      CHAR_RADIUS
    );
    this.body = pb.getPhysicsBody();

    // A character must not topple — keep it upright and let us steer it directly.
    this.body.fixedRotation = true;
    this.body.updateMassProperties();
    this.body.linearDamping = 0; // we set planar velocity explicitly every frame

    // Collide with solids (walls + crates, all on the Wall layer) AND register
    // overlaps with trigger zones so resource/gather triggers fire.
    this.body.collisionFilterMask = PhysicsLayer.Wall | PhysicsLayer.Trigger;

    // Rest the sphere on the floor at the spawn point.
    this.body.position.set(this.mesh.position.x, CHAR_RADIUS + 0.05, this.mesh.position.z);
    this.body.velocity.set(0, 0, 0);
  }

  /** The player's cannon body — used by TriggerC to know who entered a zone. */
  static getBody(): Body {
    return this.body;
  }

  /** True while the player is actively moving (used to gate auto-attacks). */
  static isMoving(): boolean {
    return this.velocity.length() > 0.05;
  }

  private static setupAnimations() {
    const gltf = ThreeC_internal.getMesh("character");
    this.mixer = new AnimationMixer(this.mesh);
    ThreeC_internal.addAnimMixer(this.mixer);
    if (gltf.animations?.length) {
      console.log("[PlayerC] Available animations:", gltf.animations.map(a => a.name));
      gltf.animations.forEach(clip => {
        this.actions.set(clip.name, this.mixer.clipAction(clip));
      });
    }
    // Cache the "Loot" clip — reused as the crate-breaking swing.
    for (const [name, action] of this.actions) {
      if (name.toLowerCase().includes("loot")) { this.attackAction = action; break; }
    }
    // Play idle directly — transitionTo guards same-state calls so it would no-op here
    const idleAction = this.findAction(MoveState.Idle);
    if (idleAction) {
      idleAction.reset().play();
      this.currentAction = idleAction;
    }
  }

  private static setupJoystick() {
    JoystickC.onJoysticMove.addDelegate(({ event }) => {
      // @ts-ignore
      this.applyJoystickInput(event.data.vector.x, event.data.vector.y);
    });
    JoystickC.onJoysticEnd.addDelegate(() => {
      this.inputDir.set(0, 0, 0);
    });
  }

  private static applyJoystickInput(jx: number, jy: number) {
    const camera = CameraC_internal.camera;
    if (!camera) return;
    camera.getWorldDirection(this._camForward);
    this._camForward.y = 0;
    this._camForward.normalize();
    this._camRight.crossVectors(this._camForward, this._worldUp).normalize();
    this.inputDir
      .copy(this._camForward).multiplyScalar(jy)
      .addScaledVector(this._camRight, jx);

    // Rescale input magnitude with a curve.
    // exponent < 1 → light touch feels faster (e.g. 0.5 = sqrt)
    // exponent > 1 → more precision at low input, snappy at full push
    const CURVE = 0.5;
    const len = this.inputDir.length();
    if (len > 0.01) {
      this.inputDir.multiplyScalar(Math.pow(len, CURVE - 1));
      // equivalent to: newLen = len^CURVE, then normalize and scale
    }

    if (this.inputDir.lengthSq() > 1) this.inputDir.normalize();
  }

  private static update(delta: number) {
    // Dead: freeze in place (the Death clip plays via the global mixer). Still
    // follow the ground so the body doesn't float, but take no input/combat.
    if (this.dead) {
      this.velocity.set(0, 0, 0);
      this.body.velocity.set(0, 0, 0);
      const surfaceY = this.sampleGroundY();
      this._currentSurfaceY += (surfaceY - this._currentSurfaceY) * Math.min(1, GROUND_SMOOTH * delta);
      this.body.position.y = this._currentSurfaceY + CHAR_RADIUS;
      this.mesh.position.set(this.body.position.x, this._currentSurfaceY, this.body.position.z);
      return;
    }

    // Smoothly ramp the desired planar velocity toward the input target.
    _inputTarget.copy(this.inputDir).multiplyScalar(this.maxSpeed);
    this.velocity.lerp(_inputTarget, Math.min(1, this.acceleration * delta));

    // Drive the body on x/z; leave y to gravity so the player rests on the
    // floor and cannon resolves collisions with the boundary walls (sliding
    // along them comes for free).
    this.body.velocity.x = this.velocity.x;
    this.body.velocity.z = this.velocity.z;

    // Follow whatever surface is directly below (sand, raised road, etc.).
    // cannon still resolves x/z against the walls; we drive y ourselves. Ease
    // the feet toward the sampled height instead of snapping, so crossing a
    // curb is a smooth step-up rather than a one-frame jolt.
    const targetSurfaceY = this.sampleGroundY();
    this._currentSurfaceY += (targetSurfaceY - this._currentSurfaceY) * Math.min(1, GROUND_SMOOTH * delta);

    this.body.position.y = this._currentSurfaceY + CHAR_RADIUS;
    this.body.velocity.y = 0;

    // Sync the mesh to the body. The body origin is the sphere centre, so the
    // mesh (origin at the feet) sits at the surface itself.
    this.mesh.position.set(this.body.position.x, this._currentSurfaceY, this.body.position.z);

    const speed = this.velocity.length();

    // While attacking, face the crate and let the Loot animation run — the
    // locomotion state machine below is suspended so it can't override it.
    if (this.attacking) {
      if (this.hasAttackTarget) this.faceTowards(this.attackTarget.x, this.attackTarget.z, delta);
      // Which hit-window the Loot clip is in now (-1 = none → bat can't hit).
      const action = this.attackAction;
      if (action) {
        const duration = action.getClip().duration;
        const phase = duration > 0 ? (action.time % duration) / duration : 0; // 0..1 within the clip
        let windowIndex = -1;
        for (let i = 0; i < IMPACT_PHASES.length; i++) {
          if (Math.abs(phase - IMPACT_PHASES[i]) <= CONTACT_WINDOW) { windowIndex = i; break; }
        }
        if (windowIndex !== this._activeWindow) {
          if (windowIndex !== -1) this._swingId++; // entered a new window → new swing
          this._activeWindow = windowIndex;
        }
      }
      if (this.finishing) {
        this.finishTimer -= delta;
        if (this.finishTimer <= 0) this.setAttacking(false);
      }
      return;
    }

    if (speed > 0.05) {
      this.faceTowards(this.mesh.position.x + this.velocity.x, this.mesh.position.z + this.velocity.z, delta);
    }

    // Animation state machine
    if      (speed < 0.1)                  this.transitionTo(MoveState.Idle);
    else if (speed < this.maxSpeed * 0.6)  this.transitionTo(MoveState.Walk);
    else                                   this.transitionTo(MoveState.Run);

    // Proportional animation speed, normalized per-state so each state
    // goes from ~0.5x at entry to 1.0x at the top of its range.
    // This avoids the near-frozen look at low joystick input.
    if (this.currentAction && this.state !== MoveState.Idle) {
      const walkTop = this.maxSpeed * 0.6;
      const t = this.state === MoveState.Walk
        ? speed / walkTop                            // 0 → 1 within walk range
        : (speed - walkTop) / (this.maxSpeed - walkTop); // 0 → 1 within run range
      this.currentAction.setEffectiveTimeScale(0.4 + 0.3 * Math.min(1, t));
    }
  }

  /** Current swing id while the bat is in a hit window, or 0 when it can't hit. */
  static getActiveSwingId(): number {
    return this._activeWindow === -1 ? 0 : this._swingId;
  }

  /** World position of the bat tip, or null if the bat isn't in hand. */
  static getBatTip(out: Vector3): Vector3 | null {
    const bat = this.batInHand as Mesh | null;
    if (!bat || !bat.visible || !bat.geometry) return null;

    if (!this._batEndA || !this._batEndB) {
      if (!bat.geometry.boundingBox) bat.geometry.computeBoundingBox();
      const box = bat.geometry.boundingBox!;
      const centerX = (box.min.x + box.max.x) / 2, centerY = (box.min.y + box.max.y) / 2, centerZ = (box.min.z + box.max.z) / 2;
      const sizeX = box.max.x - box.min.x, sizeY = box.max.y - box.min.y, sizeZ = box.max.z - box.min.z;
      // The bat's longest axis is its length → its two ends are the box faces on that axis.
      if (sizeZ >= sizeX && sizeZ >= sizeY) { this._batEndA = new Vector3(centerX, centerY, box.min.z); this._batEndB = new Vector3(centerX, centerY, box.max.z); }
      else if (sizeX >= sizeY)              { this._batEndA = new Vector3(box.min.x, centerY, centerZ); this._batEndB = new Vector3(box.max.x, centerY, centerZ); }
      else                                  { this._batEndA = new Vector3(centerX, box.min.y, centerZ); this._batEndB = new Vector3(centerX, box.max.y, centerZ); }
    }

    bat.updateWorldMatrix(true, false);
    this._tipA.copy(this._batEndA).applyMatrix4(bat.matrixWorld);
    this._tipB.copy(this._batEndB).applyMatrix4(bat.matrixWorld);
    const farther = this._tipA.distanceToSquared(this.mesh.position) >= this._tipB.distanceToSquared(this.mesh.position)
      ? this._tipA : this._tipB;
    return out.copy(farther);
  }

  // Smoothly rotate the mesh's Y so it faces the given world x/z point.
  private static faceTowards(x: number, z: number, delta: number) {
    const deltaX = x - this.mesh.position.x;
    const deltaZ = z - this.mesh.position.z;
    if (deltaX * deltaX + deltaZ * deltaZ < 1e-4) return;
    const targetAngle = Math.atan2(deltaX, deltaZ);
    // Shortest signed turn into the -π..π range so we never spin the long way round.
    const angleDiff = ((targetAngle - this.mesh.rotation.y + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
    this.mesh.rotation.y += angleDiff * Math.min(1, this.rotateSpeed * delta);
  }

  private static findAction(state: MoveState): AnimationAction | null {
    for (const name of ANIM_NAMES[state]) {
      const action = this.actions.get(name);
      if (action) return action;
    }
    for (const [clipName, action] of this.actions) {
      if (clipName.toLowerCase().includes(state)) return action;
    }
    return null;
  }

  private static transitionTo(next: MoveState, fadeDuration = 0.2) {
    if (this.state === next) return;
    const action = this.findAction(next);
    if (!action) return;
    this.currentAction?.fadeOut(fadeDuration);
    action.reset().fadeIn(fadeDuration).play();
    this.currentAction = action;
    this.state = next;
  }
}
