import {
  AdditiveBlending,
  Box3,
  BufferAttribute,
  BufferGeometry,
  Color,
  DoubleSide,
  Mesh,
  MeshBasicMaterial,
  Object3D,
  Vector3,
} from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";

// How long (seconds) a sampled point stays visible before fully fading —
// short and snappy on purpose (see Day 7 "keep effects short"). Governs the
// actual trail length in time, independent of framerate. Shortened from an
// earlier 0.15 — the longer tail read as too smooth/flowing for a bat swing
// (see chat: "не таким плавним").
const TRAIL_LIFETIME = 0.30;

// Safety cap on buffered points — real length is TRAIL_LIFETIME (above),
// this just sizes the backing arrays generously for high-refresh displays
// (120Hz * 0.1s = 12 samples) with headroom.
const MAX_POINTS = 24;

// Full ribbon width at the freshest point, in world units — shrinks toward
// zero as a point ages (see FADE_POWER below for the curve), so the trail
// tapers to a point at its tail instead of ending in a hard rectangle.
const TRAIL_WIDTH = 0.35;

// fade = (1 - age/TRAIL_LIFETIME) ^ FADE_POWER — 1 (linear) reads as a
// smooth, even gradient; a higher power holds close to full width/color
// near the head and then drops off sharply toward the tail, which reads as
// snappier/choppier rather than a silky ribbon (see chat: "не таким
// плавним"). Applied to both the per-point width and color intensity below.
const FADE_POWER = 4.5;

// Pale, slightly cool white — reads as air/wind displacement rather than a
// colored magic effect. Additive blending + fading the color itself toward
// black (not a separate alpha channel) is the same cheap trick
// BreakablePropC's hit-flash overlay uses, no custom shader needed.
const TRAIL_COLOR = new Color(0xdff3ff);
// Lowered from an earlier 0.85 — the trail read as too opaque/solid (see
// chat: "прозорішим").
const MATERIAL_OPACITY = 0.15;

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

// How far back from the bat's very tip the trail actually samples, as a
// fraction of the half-length along the bat's own length axis — 0 = exact
// tip corner, 1 = box center. See chat: "не на самий кінець бити".
const TIP_INSET_FRACTION = 0.25;

interface TrailPoint {
  position: Vector3;
  age: number;
}

// A hand-rolled screen-facing ribbon that follows the weapon anchor's world
// position while active, visualizing the swing's arc — not a three.quarks
// prefab, because this needs to track an *animated bone-driven* point every
// frame rather than play a fixed, designer-authored burst (see PropVfxC for
// that other kind of VFX). Same category of trick as BreakablePropC's
// flashMesh: plain geometry + additive blending, no custom shader.
export class WeaponTrailC {
  private static weapon: Object3D | null = null;
  private static points: TrailPoint[] = [];
  private static active = false;
  private static mesh: Mesh;
  private static geometry: BufferGeometry;
  // Captured once, after CameraFollowC has set its permanently-frozen look
  // angle (see CameraFollowC — the camera's rotation never changes again
  // after init) — the ribbon's width faces this fixed direction instead of
  // re-querying the camera every frame.
  private static viewDirection = new Vector3();
  // Where on the bat the trail actually samples, in the weapon node's own
  // local space — see computeLocalTipOffset. The node itself is pivoted at
  // the grip (see chat: the trail was following the hand, not the bat's
  // business end), so sampling weapon.getWorldPosition() directly anchored
  // the ribbon at the palm instead of the tip.
  private static localTipOffset = new Vector3();

  static init(weapon: Object3D) {
    this.weapon = weapon;
    this.localTipOffset = this.computeLocalTipOffset(weapon);
    CameraC_internal.getCamera().getWorldDirection(this.viewDirection);

    this.geometry = this.buildGeometry();
    const material = new MeshBasicMaterial({
      vertexColors: true,
      transparent: true,
      opacity: MATERIAL_OPACITY,
      blending: AdditiveBlending,
      depthWrite: false,
      side: DoubleSide,
      toneMapped: false,
    });

    this.mesh = new Mesh(this.geometry, material);
    this.mesh.visible = false;
    ThreeC.addToScene(this.mesh);

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

  // Toggled by PlayerC alongside isAttacking — the trail samples the
  // weapon's position every frame while true, and keeps aging out whatever
  // points it already has once false (so a swing that just ended still
  // trails off naturally instead of vanishing mid-air).
  static setActive(active: boolean) {
    this.active = active;
  }

  // The weapon node's own pivot is wherever the artist rigged it (the grip,
  // so it can be parented to the hand bone) — not necessarily "the tip".
  // Rather than guess a local offset, derive it from the actual geometry:
  // union the local-space bounding box of every mesh under the node, then
  // take the far end of its longest axis (the bat's length) as the tip,
  // centered on the other two axes. Computed once at init — a rigid prop's
  // own local transform relative to its parent bone doesn't change with
  // animation, so this is stable regardless of when in the swing it runs.
  private static computeLocalTipOffset(root: Object3D): Vector3 {
    root.updateWorldMatrix(true, true);

    const box = new Box3();
    const corner = new Vector3();
    root.traverse((child) => {
      if (!(child instanceof Mesh)) return;
      child.geometry.computeBoundingBox();
      const meshBox = child.geometry.boundingBox;
      if (!meshBox) return;

      for (const x of [meshBox.min.x, meshBox.max.x]) {
        for (const y of [meshBox.min.y, meshBox.max.y]) {
          for (const z of [meshBox.min.z, meshBox.max.z]) {
            corner.set(x, y, z);
            child.localToWorld(corner);
            root.worldToLocal(corner);
            box.expandByPoint(corner);
          }
        }
      }
    });

    if (box.isEmpty()) return new Vector3();

    const size = box.getSize(new Vector3());
    const lengthAxis: "x" | "y" | "z" =
      size.x >= size.y && size.x >= size.z ? "x" : size.y >= size.z ? "y" : "z";

    const tip = box.getCenter(new Vector3());
    const centerValue = tip[lengthAxis];
    const farValue = Math.abs(box.max[lengthAxis]) > Math.abs(box.min[lengthAxis]) ? box.max[lengthAxis] : box.min[lengthAxis];
    tip[lengthAxis] = farValue + (centerValue - farValue) * TIP_INSET_FRACTION;
    return tip;
  }

  private static sampleWeaponTip(): Vector3 {
    this.weapon!.updateWorldMatrix(true, false);
    return this.weapon!.localToWorld(this.localTipOffset.clone());
  }

  private static buildGeometry(): BufferGeometry {
    const segments = MAX_POINTS - 1;
    const geometry = new BufferGeometry();
    geometry.setAttribute("position", new BufferAttribute(new Float32Array(segments * 6 * 3), 3));
    geometry.setAttribute("color", new BufferAttribute(new Float32Array(segments * 6 * 3), 3));
    geometry.setDrawRange(0, 0);
    return geometry;
  }

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

    for (const point of this.points) point.age += delta;
    this.points = this.points.filter((point) => point.age < TRAIL_LIFETIME);

    if (this.active) {
      this.points.push({ position: this.sampleWeaponTip(), age: 0 });
      if (this.points.length > MAX_POINTS) this.points.shift();
    }

    this.rebuildGeometry();
  }

  private static rebuildGeometry() {
    const n = this.points.length;
    this.mesh.visible = n >= 2;
    if (n < 2) {
      this.geometry.setDrawRange(0, 0);
      return;
    }

    // One pass to derive each point's own tangent/perpendicular/fade first,
    // then a second pass to stitch adjacent points into quads — sharing a
    // point's edge between its two neighboring segments keeps the ribbon
    // continuous instead of faceted at every sample.
    const lefts: Vector3[] = [];
    const rights: Vector3[] = [];
    const fades: number[] = [];

    for (let i = 0; i < n; i++) {
      const point = this.points[i];
      const forward =
        i < n - 1
          ? this.points[i + 1].position.clone().sub(point.position)
          : point.position.clone().sub(this.points[i - 1].position);
      if (forward.lengthSq() < 1e-8) forward.copy(FALLBACK_TANGENT);
      forward.normalize();

      const perp = new Vector3().crossVectors(forward, this.viewDirection);
      if (perp.lengthSq() < 1e-8) perp.crossVectors(forward, UP_AXIS);
      perp.normalize();

      const fade = Math.pow(Math.max(0, 1 - point.age / TRAIL_LIFETIME), FADE_POWER);
      const halfWidth = (TRAIL_WIDTH / 2) * fade;

      lefts.push(point.position.clone().addScaledVector(perp, halfWidth));
      rights.push(point.position.clone().addScaledVector(perp, -halfWidth));
      fades.push(fade);
    }

    const positions = this.geometry.attributes.position.array as Float32Array;
    const colors = this.geometry.attributes.color.array as Float32Array;
    let vertex = 0;

    const write = (position: Vector3, fade: number) => {
      const base = vertex * 3;
      positions[base] = position.x;
      positions[base + 1] = position.y;
      positions[base + 2] = position.z;
      colors[base] = TRAIL_COLOR.r * fade;
      colors[base + 1] = TRAIL_COLOR.g * fade;
      colors[base + 2] = TRAIL_COLOR.b * fade;
      vertex++;
    };

    for (let i = 0; i < n - 1; i++) {
      write(lefts[i], fades[i]);
      write(rights[i], fades[i]);
      write(lefts[i + 1], fades[i + 1]);

      write(rights[i], fades[i]);
      write(lefts[i + 1], fades[i + 1]);
      write(rights[i + 1], fades[i + 1]);
    }

    this.geometry.attributes.position.needsUpdate = true;
    this.geometry.attributes.color.needsUpdate = true;
    this.geometry.setDrawRange(0, vertex);
    this.geometry.computeBoundingSphere();
  }
}
