// combat/enemies.js (Lane B) — the enemy framework and its round-1 archetypes.
//
// Enemies live in the SAME spline space as the player: (s, x, y). That's the whole trick —
// a seeker's pursuit is three float subtractions, not a pathfind, and "is it in front of
// me" is `e.s > player.s`. World position is derived once per frame for rendering and hit
// tests, never stored as truth.
//
// Behaviours are one flat update pass over a live array. Visuals are one InstancedMesh per
// type (ART_BIBLE emissive colours), so the whole roster is 3 draws + 1 for darts. When
// Lane D's GLBs land, swap the pool's geometry and the logic above never notices.
//
// Determinism: every random here comes from the seeded rng('enemies') stream.

import * as THREE from 'three';
import { BALANCE as B } from './balance.js';
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';

const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3();
const _dir = new THREE.Vector3(), _v = new THREE.Vector3();
const FORWARD = new THREE.Vector3(0, 0, 1);
const BUCKET = 20;                     // u of s per broad-phase bucket

// Round-1 roster. `geo` is the procedural fallback; D's model swaps in via assets.get.
const ARCHETYPES = {
  floater: {
    cfg: B.enemies.floater,
    geo: () => new THREE.IcosahedronGeometry(B.enemies.floater.radius, 1),
    color: EMISSIVE.hostile,
  },
  seeker: {
    cfg: B.enemies.seeker,
    geo: () => new THREE.ConeGeometry(B.enemies.seeker.radius, B.enemies.seeker.radius * 2.6, 6),
    color: EMISSIVE.hostile,
  },
  turret: {
    cfg: B.enemies.turret,
    geo: () => new THREE.CylinderGeometry(B.enemies.turret.radius * 0.5, B.enemies.turret.radius, B.enemies.turret.radius * 1.6, 7),
    color: EMISSIVE.hostile,
  },
};

export function createEnemies({ scene, world, bus, rng, assets = null }) {
  const rand = rng ? rng('enemies') : () => 0.5;
  const pools = {};
  let nextId = 1;
  const live = [];
  const buckets = new Map();
  let onDeath = null, onBlast = null;      // set by combat/index.js
  const _playerPos = new THREE.Vector3();  // player world pos, resolved once per frame

  for (const [type, def] of Object.entries(ARCHETYPES)) {
    const glb = assets?.get?.('models', type) ?? null;
    const geo = glb?.geometry ?? def.geo();
    const mat = emissiveMat(def.color);
    const mesh = new THREE.InstancedMesh(geo, mat, def.cfg.pool);
    mesh.frustumCulled = false;
    mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    mesh.count = 0;
    scene.add(mesh);
    pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [] };
  }

  // --- darts: the turret's homing projectile (hostile => white-hot) ------------------
  const dartGeo = new THREE.ConeGeometry(B.darts.radius, B.darts.radius * 3, 5);
  dartGeo.rotateX(Math.PI / 2);                       // author along +Z like every projectile
  const dartMat = emissiveMat(EMISSIVE.hostileShot, { additive: true });
  const dartMesh = new THREE.InstancedMesh(dartGeo, dartMat, B.darts.pool);
  dartMesh.frustumCulled = false;
  dartMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
  dartMesh.count = 0;
  scene.add(dartMesh);
  const darts = Array.from({ length: B.darts.pool }, () => ({
    alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(),
    s: 0, sSpeed: 0, life: 0, turn: 0, damage: 0,
  }));

  const discToWorld = (out, frame, x, y) =>
    out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);

  // --- spawn ------------------------------------------------------------------------
  // Charter API: spawn(type, {s, theta, rho}). Level events give us only an `s` and a
  // count, so theta/rho are filled from the seeded stream — same seed, same encounter.
  function spawn(type, { s = 0, theta = null, rho = null } = {}) {
    const pool = pools[type];
    if (!pool) { console.warn(`[combat] unknown enemy type "${type}"`); return null; }
    if (pool.slots.length >= pool.cfg.pool) return null;      // pool is the population cap

    const cfg = pool.cfg;
    s = THREE.MathUtils.clamp(s, 0, world.length);
    const th = theta ?? rand() * Math.PI * 2;
    const wall = world.wallRho(s, th) - cfg.radius;
    // turrets bolt to the wall; everything else floats somewhere in the lumen
    const r = rho ?? (type === 'turret' ? wall : wall * (0.25 + rand() * 0.6));

    const e = {
      id: nextId++, type, cfg,
      s, x: Math.sin(th) * r, y: Math.cos(th) * r,     // theta = atan2(x, y), per the frame convention
      vs: 0, vx: 0, vy: 0,
      hp: cfg.hp, radius: cfg.radius, alive: true,
      pos: new THREE.Vector3(), flash: 0, phase: rand() * Math.PI * 2,
      cooldown: rand() * (cfg.fireEvery ?? 1),          // desync turret volleys
      auraT: 0,
    };
    live.push(e);
    pool.slots.push(e);
    bus.emit('enemy:spawn', { id: e.id, type });
    return e;
  }

  // --- damage / death ---------------------------------------------------------------
  let comboN = 0, comboT = 0;
  function hurt(e, dmg, kind = 'hit') {
    if (!e.alive) return;
    e.hp -= dmg;
    e.flash = 1;
    if (e.hp > 0) { bus.emit('audio:cue', { name: 'enemy_hit' }); return; }
    kill(e, kind);
  }

  function kill(e, kind) {
    e.alive = false;
    comboN++; comboT = B.combo.window;
    bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: e.cfg.score, kind });
    bus.emit('combo', { n: comboN });
    bus.emit('audio:cue', { name: 'enemy_die' });
    onDeath?.(e);
  }

  // --- broad phase ------------------------------------------------------------------
  function rebuildBuckets() {
    buckets.clear();
    for (const e of live) {
      const b = Math.floor(e.s / BUCKET);
      let arr = buckets.get(b);
      if (!arr) buckets.set(b, (arr = []));
      arr.push(e);
    }
  }

  // Visits every live enemy whose s-bucket could contain something within `radius` of `s`.
  // Callers still do the exact world-space distance test — this only narrows the field.
  function forEachNear(s, radius, fn) {
    const lo = Math.floor((s - radius - BUCKET) / BUCKET);
    const hi = Math.floor((s + radius + BUCKET) / BUCKET);
    for (let b = lo; b <= hi; b++) {
      const arr = buckets.get(b);
      if (!arr) continue;
      for (const e of arr) if (e.alive) fn(e);
    }
  }

  // --- behaviours -------------------------------------------------------------------
  function update(dt, player) {
    const ps = player.state;
    // resolve the player's world position ONCE — darts and turrets all want it, and
    // world.sample() allocates (see NOTES → Lane A: an out-param sample would kill the
    // last of our per-frame garbage).
    discToWorld(_playerPos, world.sample(ps.s), ps.x, ps.y);

    for (const e of live) {
      if (!e.alive) continue;
      e.flash = Math.max(0, e.flash - dt * 6);
      const cfg = e.cfg;

      if (e.type === 'floater') {
        // drifts and bobs; taxes the lane it occupies rather than chasing
        e.phase += dt * cfg.bob;
        e.s += Math.sin(e.phase) * cfg.drift * dt;
        e.x += Math.cos(e.phase * 0.7) * cfg.drift * dt;
        e.y += Math.sin(e.phase * 0.9) * cfg.drift * dt;

        if (ps.alive) {
          const d = Math.hypot(e.s - ps.s, e.x - ps.x, e.y - ps.y);
          e.auraT -= dt;
          if (d < cfg.auraRadius + player.radius && e.auraT <= 0) {
            // discrete ticks, not per-frame: continuous player:damage would spam the bus
            // and pin E's damage flash on (see flight/player.js damage/drain split)
            player.damage(cfg.auraDps * cfg.auraTick, 'aura');
            e.auraT = cfg.auraTick;
          }
        }
      } else if (e.type === 'seeker') {
        // pursue in spline space — a chase for three float subtractions
        const ds = ps.s - e.s, dx = ps.x - e.x, dy = ps.y - e.y;
        const d = Math.hypot(ds, dx, dy) || 1e-5;
        if (ps.alive && Math.abs(ds) < cfg.aggroS) {
          const k = Math.min(1, cfg.turn * dt);
          e.vs += ((ds / d) * cfg.speed - e.vs) * k;
          e.vx += ((dx / d) * cfg.speed - e.vx) * k;
          e.vy += ((dy / d) * cfg.speed - e.vy) * k;
        }
        e.s += e.vs * dt; e.x += e.vx * dt; e.y += e.vy * dt;

        if (ps.alive && d < cfg.radius + player.radius) {
          player.damage(cfg.contact, 'contact');
          onBlast?.(e.pos, 1.6, 0.25);
          kill(e, 'contact');                      // pepsin dissolves on the hit (GDD)
          continue;
        }
      } else if (e.type === 'turret') {
        // wall-mounted; fires only at what's in range and still ahead of it
        e.cooldown -= dt;
        if (ps.alive && e.cooldown <= 0 && Math.abs(e.s - ps.s) < cfg.rangeS) {
          fireDart(e);
          e.cooldown = cfg.fireEvery;
        }
      }

      // everything stays inside the tube
      const rho = Math.hypot(e.x, e.y);
      const maxR = world.wallRho(e.s, Math.atan2(e.x, e.y)) - e.radius * 0.5;
      if (rho > maxR && rho > 1e-5) {
        const k = maxR / rho;
        e.x *= k; e.y *= k;
        if (e.type === 'seeker') { e.vx *= 0.4; e.vy *= 0.4; }
      }
      e.s = THREE.MathUtils.clamp(e.s, 0, world.length);
      discToWorld(e.pos, world.sample(e.s), e.x, e.y);
    }

    updateDarts(dt, player);
    reap();
    rebuildBuckets();
    render();

    if (comboT > 0 && (comboT -= dt) <= 0 && comboN) { comboN = 0; bus.emit('combo', { n: 0 }); }
  }

  function fireDart(e) {
    const d = darts.find((x) => !x.alive);
    if (!d) return;
    d.alive = true;
    d.pos.copy(e.pos);
    _dir.copy(_playerPos).sub(e.pos).normalize();     // aim at where the player is now
    d.vel.copy(_dir).multiplyScalar(e.cfg.dartSpeed);
    d.life = e.cfg.dartLife;
    d.s = e.s;
    d.sSpeed = d.vel.dot(world.sample(e.s).tan);
    d.turn = e.cfg.dartTurn;
    d.damage = e.cfg.dartDamage;
    bus.emit('audio:cue', { name: 'dart' });
  }

  function updateDarts(dt, player) {
    const ps = player.state;
    let n = 0;
    for (const d of darts) {
      if (!d.alive) continue;
      // Turn-rate-limited homing: swing the velocity toward the player by at most turn·dt
      // radians. Turn radius (dartSpeed/dartTurn = 13.3 u) is wider than the tube itself,
      // so a dart can never corner you — it only punishes flying straight. That's the design.
      if (ps.alive) {
        const speed = d.vel.length();
        _dir.copy(_playerPos).sub(d.pos);
        if (_dir.lengthSq() > 1e-8 && speed > 1e-5) {
          _dir.normalize();
          _v.copy(d.vel).divideScalar(speed);            // current heading, unit
          const angle = Math.acos(THREE.MathUtils.clamp(_v.dot(_dir), -1, 1));
          if (angle > 1e-4) {
            // nlerp toward the target heading, capped at the turn budget for this frame.
            // At these step sizes it tracks slerp closely and costs no allocation.
            const t = Math.min(1, (d.turn * dt) / angle);
            _v.lerp(_dir, t).normalize();
            d.vel.copy(_v).multiplyScalar(speed);
          }
        }
      }
      d.pos.addScaledVector(d.vel, dt);
      d.s += d.sSpeed * dt;
      d.life -= dt;

      let done = d.life <= 0;
      if (!done && ps.alive) {
        const r = player.radius + B.darts.radius;
        if (d.pos.distanceToSquared(_playerPos) <= r * r) {
          if (player.damage(d.damage, 'dart')) onBlast?.(d.pos, 1.2, 0.2);
          done = true;
        }
      }
      if (!done && world.collide(d.pos, B.darts.radius)) done = true;
      if (done) { d.alive = false; continue; }

      _dir.copy(d.vel).normalize();
      _q.setFromUnitVectors(FORWARD, _dir);
      dartMesh.setMatrixAt(n++, _m.compose(d.pos, _q, _scale.set(1, 1, 1)));
    }
    dartMesh.count = n;
    dartMesh.instanceMatrix.needsUpdate = true;
  }

  function reap() {
    for (let i = live.length - 1; i >= 0; i--) if (!live[i].alive) live.splice(i, 1);
    for (const pool of Object.values(pools))
      for (let i = pool.slots.length - 1; i >= 0; i--) if (!pool.slots[i].alive) pool.slots.splice(i, 1);
  }

  function render() {
    for (const [type, pool] of Object.entries(pools)) {
      let n = 0;
      for (const e of pool.slots) {
        if (!e.alive) continue;
        if (type === 'seeker') {
          _dir.set(e.vs, e.vx, e.vy);
          _q.setFromUnitVectors(FORWARD, _dir.lengthSq() > 1e-6 ? _dir.normalize() : FORWARD);
        } else {
          _q.setFromAxisAngle(FORWARD, e.phase);
        }
        pool.mesh.setMatrixAt(n++, _m.compose(e.pos, _q, _scale.setScalar(1)));
      }
      pool.mesh.count = n;
      pool.mesh.instanceMatrix.needsUpdate = true;
      // one flash uniform per type: a hit reads as the whole cohort blinking, which is
      // wrong. Round 2: per-instance colour attribute. Noted in NOTES.
      const hot = pool.slots.some((e) => e.alive && e.flash > 0.5);
      pool.mat.uniforms.uFlash.value = hot ? 0.6 : 0;
    }
  }

  return {
    spawn, hurt, forEachNear, update,
    get count() { return live.length; },
    get live() { return live; },
    setBlastFx(fn) { onBlast = fn; },
    setOnDeath(fn) { onDeath = fn; },
    clear() { for (const e of live) e.alive = false; for (const d of darts) d.alive = false; reap(); },
    dispose() {
      for (const pool of Object.values(pools)) {
        scene.remove(pool.mesh); pool.mesh.dispose(); pool.geo.dispose(); pool.mat.dispose();
      }
      scene.remove(dartMesh); dartMesh.dispose(); dartGeo.dispose(); dartMat.dispose();
      live.length = 0; buckets.clear();
    },
  };
}
