import { AdditiveBlending, Color, Euler, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three";
import { Easing, Tween } from "@tweenjs/tween.js";
import { TweenC } from "@hitplay/playable_template";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
import { ResourceC } from "./ResourceC";
import { PropVfxC } from "./PropVfxC";

// A "Lootable" crate in the map glb is authored as:
//   Wooden_Box_XXX (root)
//     BoxCollider.NNN            <- the obstacle/hit box, also in the generic /collider/i list
//     Wooden_Box_States_XXX
//       Wooden_Box_XXX_S1        <- least damaged
//       Wooden_Box_XXX_S2
//       Wooden_Box_XXX_S3        <- most damaged/rubble
// Not every crate has all three stages — some start pre-damaged (S2/S3 only)
// or pre-broken (S3 only) as level-design variety, so "hit" always just
// advances to the next stage that exists, whatever that is.
interface StageVisual {
  node: Object3D; // the "_S1"/"_S2"/"_S3" group — toggled visible per stage
  mesh: Mesh | null; // the actual textured mesh inside it, for the hit-flash
  // A same-geometry clone rendered on top with additive blending, opacity
  // animated for the hit-flash — see buildStageVisual for why lerping the
  // real material's own color doesn't work here.
  flashMesh: Mesh | null;
}

export interface BreakableProp {
  root: Object3D;
  colliderNode: Object3D;
  baseRotation: Euler;
  stages: StageVisual[];
  stageIndex: number;
  hitsOnStage: number;
  physicsBody: PhysicsBody;
  hitTween: Tween<{ t: number }> | null;
}

const STAGE_NUMBER_PATTERN = /_S(\d+)$/i;
const COLLIDER_NAME_PATTERN = /collider/i;
const STATES_GROUP_PATTERN = /_states/i;
const SHADOW_MESH_PATTERN = /shadow/i;

// Two hits to knock a crate from one damage stage to the next (or to
// destroy it, on its last stage).
const HITS_PER_STAGE = 2;

// A hit that only advances a damage stage drops a small trickle; the hit
// that finally destroys the crate dumps out most of its loot at once.
const STAGE_HIT_RESOURCE_RANGE: [number, number] = [1, 2];
const DESTROY_RESOURCE_RANGE: [number, number] = [2, 5];

// Hit feedback: a brief white flash (in, then out) plus a lean away from
// wherever the hit came from, springing back to its resting angle.
const HIT_FLASH_UP_MS = 90;
const HIT_FLASH_DOWN_MS = 150;
const HIT_TILT_ANGLE = -0.16; // radians, ~7°
const FLASH_COLOR = new Color(0xffffff);

// Where the spark burst spawns, relative to the crate's own pivot: nudged
// toward the attacker (opposite hitDirection) so it sits near the surface
// facing them rather than dead-center inside the crate, and up to roughly
// where the bat actually connects rather than at the ground.
const CONTACT_POINT_INSET = 0.3;
const CONTACT_POINT_HEIGHT = 0.6;

// Disappear-on-destroy: sinks straight down into the ground while toppling
// away from the hit that destroyed it, instead of shrinking in place.
const DESTROY_SINK_MS = 250;
const DESTROY_SINK_DEPTH = 1.2; // world units — enough to fully submerge below the visible ground
const DESTROY_TILT_ANGLE = 0.6; // radians, ~34° — a pronounced topple, not a subtle lean

export class BreakablePropC {
  private static byColliderNode = new Map<Object3D, BreakableProp>();
  private static onDestroyed: ((colliderNode: Object3D) => void) | null = null;

  // Returns the collider nodes it took ownership of, so the caller (which
  // already built a generic /collider/i list for movement/camera raycasts)
  // knows not to also give those the same treatment as plain walls.
  static init(
    lootableRoot: Object3D | undefined,
    onDestroyed: (colliderNode: Object3D) => void
  ): Set<Object3D> {
    this.byColliderNode.clear();
    this.onDestroyed = onDestroyed;

    if (lootableRoot) {
      for (const crateRoot of lootableRoot.children) {
        const prop = this.buildProp(crateRoot);
        if (prop) this.byColliderNode.set(prop.colliderNode, prop);
      }
    }

    return new Set(this.byColliderNode.keys());
  }

  static getByColliderNode(node: Object3D): BreakableProp | undefined {
    return this.byColliderNode.get(node);
  }

  // HITS_PER_STAGE hits advance exactly one stage; past the last stage that
  // exists for this crate, the hit that would've been one more advance
  // fully destroys it instead. Every hit plays feedback; a stage advance
  // (or the destroying hit) also drops resources — more on that final hit.
  // `hitDirection` (attacker -> target, XZ, normalized) drives which way
  // the crate leans on a non-destroying hit.
  static hit(prop: BreakableProp, hitDirection: Vector3) {
    prop.hitsOnStage++;
    if (prop.hitsOnStage < HITS_PER_STAGE) {
      this.playHitFeedback(prop, hitDirection);
      return;
    }
    prop.hitsOnStage = 0;

    // A bit above the root's ground-level pivot, so the burst visually
    // comes from around the crate's body rather than the floor under it.
    const dropOrigin = prop.root.getWorldPosition(new Vector3()).add(new Vector3(0, 0.5, 0));

    const nextIndex = prop.stageIndex + 1;
    if (nextIndex < prop.stages.length) {
      prop.stages[prop.stageIndex].node.visible = false;
      prop.stages[nextIndex].node.visible = true;
      prop.stageIndex = nextIndex;
      ResourceC.spawnBurstInRange(dropOrigin, ...STAGE_HIT_RESOURCE_RANGE);
      this.playHitFeedback(prop, hitDirection);
      return;
    }

    ResourceC.spawnBurstInRange(dropOrigin, ...DESTROY_RESOURCE_RANGE);
    this.destroy(prop, hitDirection);
  }

  private static buildProp(root: Object3D): BreakableProp | null {
    const colliderNode = root.children.find((child) => COLLIDER_NAME_PATTERN.test(child.name));
    const statesGroup = root.children.find((child) => STATES_GROUP_PATTERN.test(child.name));
    if (!colliderNode || !statesGroup) return null;

    const stageNodes = [...statesGroup.children].sort(
      (a, b) => this.stageNumber(a) - this.stageNumber(b)
    );
    if (stageNodes.length === 0) return null;

    const stages: StageVisual[] = stageNodes.map((node, index) => {
      node.visible = index === 0;
      return this.buildStageVisual(node);
    });

    const physicsBody = new PhysicsBody(
      colliderNode,
      false,
      0,
      PhysicsLayer.Wall,
      PhysicsLayer.Player | PhysicsLayer.Enemy
    );

    return {
      root,
      colliderNode,
      baseRotation: root.rotation.clone(),
      stages,
      stageIndex: 0,
      hitsOnStage: 0,
      physicsBody,
      hitTween: null,
    };
  }

  // The crate mesh uses an unlit MeshBasicMaterial with color already at
  // pure white (confirmed by logging it — the texture supplies all the
  // actual color, the tint is just left at its default) — lerping that
  // color toward FLASH_COLOR (also white) is a no-op, which is why the
  // flash was invisible despite the tween running. Instead, a same-geometry
  // clone is layered on top with additive blending: animating ITS opacity
  // reads as a flash regardless of what color/texture the base mesh has.
  private static buildStageVisual(node: Object3D): StageVisual {
    const mesh = node.children.find(
      (child): child is Mesh => child instanceof Mesh && !SHADOW_MESH_PATTERN.test(child.name)
    );
    if (!mesh) return { node, mesh: null, flashMesh: null };

    const flashMesh = mesh.clone();
    flashMesh.material = new MeshBasicMaterial({
      color: FLASH_COLOR,
      transparent: true,
      opacity: 0,
      blending: AdditiveBlending,
      depthWrite: false,
    });
    // Shadows off — this is a same-shape overlay that's invisible almost
    // all the time, not a real surface; TestSceneC's later
    // setShadowsStateForChildren(map, true, true) would otherwise flip
    // these back on (see disableFlashShadows, called after that).
    flashMesh.castShadow = false;
    flashMesh.receiveShadow = false;
    node.add(flashMesh);

    return { node, mesh, flashMesh };
  }

  // TestSceneC's setShadowsStateForChildren(map, true, true) runs after
  // BreakablePropC.init() and force-enables shadows on every mesh under the
  // map, including the flash overlays built above — call this right after
  // that to turn them back off, so the flash meshes don't double the
  // currently-visible crates' shadow-casting cost for no visual benefit.
  static disableFlashShadows() {
    for (const prop of this.byColliderNode.values()) {
      for (const stage of prop.stages) {
        if (!stage.flashMesh) continue;
        stage.flashMesh.castShadow = false;
        stage.flashMesh.receiveShadow = false;
      }
    }
  }

  private static stageNumber(stageNode: Object3D): number {
    const match = STAGE_NUMBER_PATTERN.exec(stageNode.name);
    return match ? Number(match[1]) : 0;
  }

  // Kills any feedback still playing from a previous hit before starting a
  // new one — always relative to the crate's original resting rotation
  // (captured once at build time), never the current one, so interrupting
  // mid-lean can't drift the crate further off-angle with each hit.
  private static playHitFeedback(prop: BreakableProp, hitDirection: Vector3) {
    prop.hitTween?.stop();

    const contactPoint = prop.root.getWorldPosition(new Vector3());
    contactPoint.addScaledVector(hitDirection, -CONTACT_POINT_INSET);
    contactPoint.y += CONTACT_POINT_HEIGHT;
    PropVfxC.spawnHit(contactPoint);

    const stage = prop.stages[prop.stageIndex];
    const flashMaterial = stage.flashMesh?.material as MeshBasicMaterial | undefined;

    const tiltX = -hitDirection.z * HIT_TILT_ANGLE;
    const tiltZ = hitDirection.x * HIT_TILT_ANGLE;

    const apply = ({ t }: { t: number }) => {
      if (flashMaterial) flashMaterial.opacity = t;
      prop.root.rotation.set(
        prop.baseRotation.x + tiltX * t,
        prop.baseRotation.y,
        prop.baseRotation.z + tiltZ * t
      );
    };

    const flashIn = new Tween({ t: 0 }).to({ t: 1 }, HIT_FLASH_UP_MS).easing(Easing.Quadratic.Out).onUpdate(apply);
    const flashOut = new Tween({ t: 1 }).to({ t: 0 }, HIT_FLASH_DOWN_MS).easing(Easing.Quadratic.In).onUpdate(apply);
    // chain() only tells flashIn to call flashOut.start() on completion —
    // it does NOT add flashOut to any group. Without adding it here too,
    // flashOut marks itself "playing" but nothing ever calls its update(),
    // so it just freezes at t=1 (fully flashed/tilted) forever.
    flashIn.chain(flashOut);

    TweenC.add(flashIn);
    TweenC.add(flashOut);
    flashIn.start();
    prop.hitTween = flashIn;
  }

  private static destroy(prop: BreakableProp, hitDirection: Vector3) {
    prop.physicsBody.destroy();
    this.byColliderNode.delete(prop.colliderNode);
    this.onDestroyed?.(prop.colliderNode);
    prop.hitTween?.stop();
    PropVfxC.spawnDestroy(prop.root.getWorldPosition(new Vector3()));
    this.playDisappearAnimation(prop, hitDirection);
  }

  // Fully gone, not just its collider — sinks straight down into the ground
  // while toppling away from the hit that destroyed it (same lean
  // convention as playHitFeedback), only hidden once fully submerged.
  private static playDisappearAnimation(prop: BreakableProp, hitDirection: Vector3) {
    const root = prop.root;
    const baseY = root.position.y;
    const tiltX = -hitDirection.z * DESTROY_TILT_ANGLE;
    const tiltZ = hitDirection.x * DESTROY_TILT_ANGLE;

    const sink = new Tween({ t: 0 })
      .to({ t: 1 }, DESTROY_SINK_MS)
      .easing(Easing.Quadratic.In)
      .onUpdate(({ t }) => {
        root.position.y = baseY - DESTROY_SINK_DEPTH * t;
        root.rotation.set(
          prop.baseRotation.x + tiltX * t,
          prop.baseRotation.y,
          prop.baseRotation.z + tiltZ * t
        );
      })
      .onComplete(() => (root.visible = false));

    TweenC.add(sink);
    sink.start();
  }
}
