import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, Debuff, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";

export const BOSS_MECHANIC_POOL = [
  { id: "basic-melee", name: "Basic Melee", instruction: "Maintain tank pressure." },
  { id: "bull-charge", name: "Bull Charge", instruction: "Clear the marked charge lane." },
  { id: "crushing-pounce", name: "Crushing Pounce", instruction: "Stack to split the impact." },
  { id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." },
  { id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
  { id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." },
  { id: "venom-purge", name: "Venom Purge", instruction: "Move apart, then cleanse Widow Venom before it expires." },
  { id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." },
  { id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
  { id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
  { id: "guardian-cross", name: "Guardian Cross", instruction: "Find a safe quadrant." },
  { id: "destruction-rush", name: "Destruction Rush", instruction: "Clear the marked rush lane." },
  { id: "ruin-quake", name: "Ruin Quake", instruction: "Leave the destruction circle." },
  { id: "destruction-pulse", name: "Destruction Pulse", instruction: "Step between the radial beams." },
  { id: "ricochet-rush", name: "Ricochet Rush", instruction: "Dodge both rebound lanes." },
  { id: "meteor-slam", name: "Meteor Slam", instruction: "Leave the impact and spreading flame." },
  { id: "burrow-rush", name: "Burrow Rush", instruction: "Cross the marked trail." },
  { id: "hourglass-eruption", name: "Hourglass Eruption", instruction: "Leave the eruptions and moving zone." },
  { id: "sidewinder-rush", name: "Sidewinder Rush", instruction: "Clear the surf lane." },
  { id: "crushing-tide", name: "Crushing Tide", instruction: "Spread before the claws close." },
  { id: "vine-scissors", name: "Vine Scissors", instruction: "Dodge the first and rotated crosses." },
  { id: "haunting-rifts", name: "Haunting Rifts", instruction: "Carry persistent rifts away from formation." },
  { id: "tri-burst", name: "Tri-Burst", instruction: "Move through the three expanding rings." },
  { id: "ultimate-skyfall", name: "Ultimate Skyfall", instruction: "Spread before the marked impacts." },
  {
    id: "meteor-spread",
    name: "Meteor Spread",
    instruction: "Break formation before the marked circles land.",
  },
  {
    id: "hollow-collapse",
    name: "Hollow Collapse",
    instruction: "Move inside the inner safe circle.",
  },
  {
    id: "aetheric-soak",
    name: "Aetheric Soak",
    instruction: "Stack in the marked circle to split the hit.",
  },
  {
    id: "prism-beam",
    name: "Prism Beam",
    instruction: "Clear the marked beam lane.",
  },
  {
    id: "memory-sequence",
    name: "Memory Sequence",
    instruction: "Healer only: watch four symbols, then cross matching tiles in order.",
  },
  {
    id: "soul-siphon",
    name: "Soul Siphon",
    instruction: "Healer only: run through the gold cleansing ward before the shade drains you.",
  },
] as const;

const MECHANIC_COPY_BY_ID = Object.fromEntries(
  BOSS_MECHANIC_POOL.map((mechanic) => [mechanic.id, mechanic]),
) as Record<BossMechanicId, (typeof BOSS_MECHANIC_POOL)[number]>;

export function bossMechanicName(id: BossMechanicId) {
  return MECHANIC_COPY_BY_ID[id].name;
}

export const POOLED_MECHANIC_TIMING = {
  activeDuration: 0.42,
  warningDuration: 1.4,
} as const;

export const BULL_CHARGE = {
  warning: 1.8,
  speed: 10.5,
  distance: 13.5,
  hitRadius: 1.35,
  damage: 18,
  knockdown: 0.75,
  cooldown: 4,
  aiClearance: 1.9,
  aiEvadeSpeed: 3.4,
} as const;

export const BULL_POUNCE = {
  stackDuration: 5,
  stackRadius: 2.2,
  sharedDamage: 200,
  leapDuration: 0.55,
  cooldown: 4,
} as const;

export const SKY_SWEEPER_BREATH = {
  telegraphDuration: 2,
  sweepDuration: 3.2,
  range: 10.5,
  halfAngle: Math.PI / 7,
  sweepArc: Math.PI * 0.95,
  tickDamage: 9,
  tickInterval: 0.45,
  cooldown: 4,
} as const;

export const VENOM_PURGE = {
  duration: 10,
  tickDamage: 5,
  castDuration: 2.5,
  poolRadius: 2,
  poolArmDelay: 1.25,
  poolDuration: 6,
  poolDamage: 8,
} as const;

const BINDING_WEB_SCUTTLE_SPEED = 3.2;

export const MEMORY_SEQUENCE = {
  sequenceLength: 4,
  flashDuration: 0.9,
  inputDuration: 7,
  tileSize: 2.15,
  raidwideDamage: 15,
} as const;

export const SOUL_SIPHON = {
  wardRadius: 1.3,
  wardDistance: ARENA_RADIUS - 1.05,
  ghostSpeed: 2.6,
  tickInterval: 0.7,
  tickDamage: 3,
  tickRamp: 1,
} as const;

export const MEMORY_SYMBOLS: Record<MemorySymbolId, { label: string; color: string }> = {
  triangle: { label: "Triangle", color: "#ff4d55" },
  cross: { label: "Cross", color: "#4fa8ff" },
  circle: { label: "Circle", color: "#65d67a" },
  square: { label: "Square", color: "#ffd34d" },
};

const MEMORY_TILE_LAYOUT: readonly { symbol: MemorySymbolId; center: WorldPosition }[] = [
  { symbol: "triangle", center: [-2.35, 1.35] },
  { symbol: "cross", center: [2.35, 1.35] },
  { symbol: "circle", center: [-2.35, -3.15] },
  { symbol: "square", center: [2.35, -3.15] },
];

const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
  ["triangle", "cross", "circle", "square"],
  ["circle", "triangle", "square", "cross"],
  ["square", "circle", "cross", "triangle"],
  ["cross", "square", "triangle", "circle"],
];

const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
function liveTarget(party: PartyMember[], targetId: MemberId) {
  return party.some((member) => member.id === targetId && member.hp > 0)
    ? targetId
    : party.find((member) => member.hp > 0)?.id ?? targetId;
}

function circleTelegraph({
  id,
  kind,
  name,
  center,
  radius,
  innerRadius,
  activatesAt,
  damage,
  targetId,
}: {
  id: string;
  kind: "spread" | "donut" | "soak";
  name: string;
  center: WorldPosition;
  radius: number;
  innerRadius?: number;
  activatesAt: number;
  damage: number;
  targetId?: MemberId;
}): PoolTelegraph {
  return {
    id,
    kind,
    name,
    center: [center[0], center[1]],
    radius,
    innerRadius,
    activatesAt,
    expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
    damage,
    targetId,
    resolved: false,
    hitIds: [],
  };
}

function beamTelegraph(id: string, center: WorldPosition, target: WorldPosition, activatesAt: number): PoolTelegraph {
  const angle = angleTo(center, target);
  const halfLength = 9.2;
  const dx = Math.sin(angle) * halfLength;
  const dz = Math.cos(angle) * halfLength;
  return {
    id,
    kind: "beam",
    name: "Prism Beam",
    center: [center[0], center[1]],
    radius: 0,
    start: [center[0] - dx, center[1] - dz],
    end: [center[0] + dx, center[1] + dz],
    width: 1.45,
    activatesAt,
    expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
    damage: 29,
    resolved: false,
    hitIds: [],
  };
}

function memorySequenceTelegraph(id: string, bossPosition: WorldPosition, count: number, inputStartsAt: number): PoolTelegraph {
  const sequence = MEMORY_SEQUENCES[count % MEMORY_SEQUENCES.length];
  return {
    id,
    kind: "memory",
    name: "Memory Sequence",
    center: [bossPosition[0], bossPosition[1]],
    radius: 0,
    activatesAt: inputStartsAt,
    inputStartsAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration,
    expiresAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration + MEMORY_SEQUENCE.inputDuration,
    damage: MEMORY_SEQUENCE.raidwideDamage,
    targetId: "aelia",
    sequence: [...sequence],
    tiles: MEMORY_TILE_LAYOUT.map((tile) => ({ symbol: tile.symbol, center: [tile.center[0], tile.center[1]] })),
    inputIndex: 0,
    resolved: false,
    hitIds: [],
  };
}

function oppositeWardPosition(healerPosition: WorldPosition, count: number): WorldPosition {
  const offsetX = healerPosition[0] - ARENA_CENTER[0];
  const offsetZ = healerPosition[1] - ARENA_CENTER[1];
  const offsetLength = Math.hypot(offsetX, offsetZ);
  const fallbackAngle = count % 2 === 0 ? 0 : Math.PI / 2;
  const directionX = offsetLength > 0.05 ? -offsetX / offsetLength : Math.sin(fallbackAngle);
  const directionZ = offsetLength > 0.05 ? -offsetZ / offsetLength : Math.cos(fallbackAngle);
  return clampToArena([
    ARENA_CENTER[0] + directionX * SOUL_SIPHON.wardDistance,
    ARENA_CENTER[1] + directionZ * SOUL_SIPHON.wardDistance,
  ], 0.25);
}

function soulSiphonTelegraph(id: string, healerPosition: WorldPosition, count: number, time: number): PoolTelegraph {
  const wardPosition = oppositeWardPosition(healerPosition, count);
  return {
    id,
    kind: "soul-siphon",
    name: "Soul Siphon",
    center: [wardPosition[0], wardPosition[1]],
    radius: 0,
    activatesAt: time,
    expiresAt: Number.POSITIVE_INFINITY,
    damage: 0,
    targetId: "aelia",
    soulSiphon: {
      targetId: "aelia",
      ghostPosition: clampToArena([healerPosition[0] - 0.85, healerPosition[1] + 0.85], 0.2),
      wardPosition,
      wardRadius: SOUL_SIPHON.wardRadius,
      nextDamageAt: time + SOUL_SIPHON.tickInterval,
      tickCount: 0,
    },
    resolved: false,
    hitIds: [],
  };
}

function beginPoolMechanic(
  motion: BossMotionState,
  party: PartyMember[],
  positions: BossMechanicContext["partyPositions"],
  time: number,
  requestedId: BossMechanicId,
) {
  const count = motion.poolMechanicCount + 1;
  const entry = MECHANIC_COPY_BY_ID[requestedId];
  const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
  let telegraphs: PoolTelegraph[];
  let targetId: MemberId | undefined;

  if (entry.id === "meteor-spread") {
    const targets = [0, 1, 2].map((offset) => liveTarget(party, TARGET_ORDER[(count + offset) % TARGET_ORDER.length]));
    telegraphs = targets.map((id, index) => circleTelegraph({
      id: `pool-spread-${count}-${index}`,
      kind: "spread",
      name: "Meteor Spread",
      center: positions[id],
      radius: 1.75,
      activatesAt: activatesAt + index * 0.42,
      damage: 24,
      targetId: id,
    }));
    targetId = targets[0];
  } else if (entry.id === "hollow-collapse") {
    telegraphs = [circleTelegraph({
      id: `pool-donut-${count}`,
      kind: "donut",
      name: "Hollow Collapse",
      center: motion.position,
      radius: 7.25,
      innerRadius: 2.15,
      activatesAt,
      damage: 27,
    })];
  } else if (entry.id === "aetheric-soak") {
    targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
    const soak = circleTelegraph({
      id: `pool-soak-${count}`,
      kind: "soak",
      name: "Aetheric Soak",
      center: positions[targetId],
      radius: 2.25,
      activatesAt: time + 1.75,
      damage: 0,
      targetId,
    });
    soak.totalDamage = 78;
    soak.minimumParticipants = 3;
    telegraphs = [soak];
  } else if (entry.id === "memory-sequence") {
    targetId = "aelia";
    telegraphs = [memorySequenceTelegraph(`pool-memory-${count}`, motion.position, count, time)];
  } else if (entry.id === "soul-siphon") {
    targetId = "aelia";
    telegraphs = [soulSiphonTelegraph(`pool-soul-siphon-${count}`, positions.aelia, count, time)];
  } else {
    targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
    telegraphs = [beamTelegraph(`pool-beam-${count}`, motion.position, positions[targetId], activatesAt)];
  }

  return {
    motion: {
      ...motion,
      poolMechanicCount: count,
      poolTelegraphs: telegraphs,
    },
    event: {
      at: time,
      message: `${entry.name}: ${entry.instruction}`,
      tone: "danger" as const,
      pulseKind: "boss" as const,
      targetId,
    },
  };
}

function memoryTileAt(telegraph: PoolTelegraph, position: WorldPosition) {
  const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
  return telegraph.tiles?.find((tile) =>
    Math.abs(position[0] - tile.center[0]) <= halfSize && Math.abs(position[1] - tile.center[1]) <= halfSize,
  );
}

function failMemorySequence(
  telegraph: PoolTelegraph,
  party: PartyMember[],
  positions: BossMechanicContext["partyPositions"],
  context: BossMechanicContext,
  events: BossMechanicResult["events"],
) {
  telegraph.resolved = true;
  telegraph.expiresAt = context.time;
  events.push({
    at: context.time,
    message: `${telegraph.name} fails — every party member takes ${telegraph.damage} raidwide damage.`,
    tone: "danger",
    pulseKind: "boss",
    targetId: "aelia",
  });
  return party.map((member) => member.hp > 0
    ? context.damageMember(member, telegraph.damage, positions[member.id], context.time)
    : member);
}

function resolveMemorySequence(
  telegraph: PoolTelegraph,
  party: PartyMember[],
  positions: BossMechanicContext["partyPositions"],
  context: BossMechanicContext,
  events: BossMechanicResult["events"],
) {
  if (!telegraph.sequence || !telegraph.tiles || telegraph.inputStartsAt === undefined || telegraph.inputIndex === undefined) {
    return failMemorySequence(telegraph, party, positions, context, events);
  }
  if (context.time < telegraph.inputStartsAt) return party;
  if (context.time >= telegraph.expiresAt) return failMemorySequence(telegraph, party, positions, context, events);

  const selectedTile = memoryTileAt(telegraph, positions.aelia);
  if (!selectedTile) {
    telegraph.lastHealerTileId = undefined;
  } else if (selectedTile.symbol !== telegraph.lastHealerTileId) {
    telegraph.lastHealerTileId = selectedTile.symbol;
    if (selectedTile.symbol !== telegraph.sequence[telegraph.inputIndex]) {
      return failMemorySequence(telegraph, party, positions, context, events);
    }
    telegraph.inputIndex += 1;
    if (telegraph.inputIndex === telegraph.sequence.length) {
      telegraph.resolved = true;
      telegraph.expiresAt = context.time;
      events.push({
        at: context.time,
        message: "Memory Sequence cleared by healer.",
        tone: "neutral",
        pulseKind: "boss",
        targetId: "aelia",
      });
      return party;
    }
  }

  return party;
}

function resolveSoulSiphon(
  telegraph: PoolTelegraph,
  party: PartyMember[],
  positions: BossMechanicContext["partyPositions"],
  context: BossMechanicContext,
  events: BossMechanicResult["events"],
) {
  const siphon = telegraph.soulSiphon;
  if (!siphon) {
    telegraph.resolved = true;
    telegraph.expiresAt = context.time;
    return party;
  }
  const healerPosition = positions[siphon.targetId];
  if (distance(healerPosition, siphon.wardPosition) <= siphon.wardRadius) {
    telegraph.resolved = true;
    telegraph.expiresAt = context.time;
    events.push({
      at: context.time,
      message: "Aelia reaches the cleansing ward. Soul Siphon collapses.",
      tone: "neutral",
      pulseKind: "purify",
      targetId: "aelia",
    });
    return party;
  }

  const nextSiphon = {
    ...siphon,
    ghostPosition: moveToward(siphon.ghostPosition, healerPosition, SOUL_SIPHON.ghostSpeed * context.delta),
  };
  let nextParty = party;
  const healerIndex = nextParty.findIndex((member) => member.id === siphon.targetId);
  if (healerIndex >= 0 && nextParty[healerIndex].hp > 0) {
    let nextDamageAt = nextSiphon.nextDamageAt;
    let tickCount = nextSiphon.tickCount;
    let healer = nextParty[healerIndex];
    while (nextDamageAt <= context.time + 0.001) {
      healer = context.damageMember(
        healer,
        SOUL_SIPHON.tickDamage + Math.min(tickCount, 4) * SOUL_SIPHON.tickRamp,
        healerPosition,
        nextDamageAt,
      );
      nextDamageAt += SOUL_SIPHON.tickInterval;
      tickCount += 1;
    }
    nextSiphon.nextDamageAt = nextDamageAt;
    nextSiphon.tickCount = tickCount;
    if (healer !== nextParty[healerIndex]) {
      nextParty = [...nextParty];
      nextParty[healerIndex] = healer;
    }
  }
  telegraph.soulSiphon = nextSiphon;
  return nextParty;
}

function isHit(telegraph: PoolTelegraph, position: WorldPosition) {
  if (telegraph.kind === "beam") {
    return !!telegraph.start && !!telegraph.end
      && pointToSegmentDistance(position, telegraph.start, telegraph.end) <= (telegraph.width ?? 0) * 0.5;
  }
  const distance = Math.hypot(position[0] - telegraph.center[0], position[1] - telegraph.center[1]);
  return distance <= telegraph.radius && distance >= (telegraph.innerRadius ?? 0);
}

function resolveSoak(
  telegraph: PoolTelegraph,
  party: PartyMember[],
  positions: BossMechanicContext["partyPositions"],
  context: BossMechanicContext,
  events: BossMechanicResult["events"],
) {
  const participants = party.filter((member) => member.hp > 0 && isHit(telegraph, positions[member.id]));
  const minimum = telegraph.minimumParticipants ?? 1;
  const failed = participants.length < minimum;
  const totalDamage = (telegraph.totalDamage ?? 0) * (failed ? 1.5 : 1);

  if (!participants.length) {
    const marked = party.find((member) => member.id === telegraph.targetId && member.hp > 0);
    if (!marked) return party;
    events.push({
      at: context.time,
      message: `${telegraph.name} fails — ${marked.name} takes ${Math.round(totalDamage)} damage alone.`,
      tone: "danger",
      pulseKind: "boss",
      targetId: marked.id,
    });
    return party.map((member) => member.id === marked.id
      ? context.damageMember(member, totalDamage, positions[member.id], context.time)
      : member);
  }

  const splitDamage = totalDamage / participants.length;
  events.push({
    at: context.time,
    message: failed
      ? `${telegraph.name} is under-soaked by ${participants.length}. ${Math.round(splitDamage)} damage each.`
      : `${telegraph.name} splits ${Math.round(totalDamage)} damage across ${participants.length} allies.`,
    tone: "danger",
    pulseKind: "boss",
    targetId: telegraph.targetId,
  });
  const participantIds = new Set(participants.map((member) => member.id));
  return party.map((member) => participantIds.has(member.id)
    ? context.damageMember(member, splitDamage, positions[member.id], context.time)
    : member);
}

function resolveTelegraph(
  telegraph: PoolTelegraph,
  party: PartyMember[],
  positions: BossMechanicContext["partyPositions"],
  context: BossMechanicContext,
  events: BossMechanicResult["events"],
) {
  if (telegraph.kind === "soak") return resolveSoak(telegraph, party, positions, context, events);
  const hitIds: MemberId[] = [];
  const next = party.map((member) => {
    if (member.hp <= 0 || !isHit(telegraph, positions[member.id])) return member;
    hitIds.push(member.id);
    return context.damageMember(member, telegraph.damage, positions[member.id], context.time, "hazard");
  });
  telegraph.hitIds = hitIds;
  events.push({
    at: context.time,
    message: hitIds.length
      ? `${telegraph.name} catches ${hitIds.length} ${hitIds.length === 1 ? "ally" : "allies"}.`
      : `${telegraph.name} misses the party.`,
    tone: "danger",
    pulseKind: "boss",
    targetId: telegraph.targetId,
  });
  return next;
}

export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
  const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
  if (!telegraphs.length) return null;
  const next = telegraphs.reduce((earliest, telegraph) => telegraph.activatesAt < earliest.activatesAt ? telegraph : earliest);
  if (next.kind === "memory") {
    const inputStartsAt = next.inputStartsAt ?? next.activatesAt;
    const showingSequence = time < inputStartsAt;
    return {
      name: showingSequence ? "Memory Sequence — watch boss" : "Memory Sequence — match tiles",
      remaining: Math.max(0, (showingSequence ? inputStartsAt : next.expiresAt) - time),
      cycle: showingSequence ? Math.max(0.01, inputStartsAt - next.activatesAt) : MEMORY_SEQUENCE.inputDuration,
      urgent: true,
    };
  }
  if (next.kind === "soul-siphon" && next.soulSiphon) {
    return {
      name: "Soul Siphon — reach cleansing ward",
      remaining: Math.max(0, next.soulSiphon.nextDamageAt - time),
      cycle: SOUL_SIPHON.tickInterval,
      urgent: true,
    };
  }
  return {
    name: next.kind === "soak" ? `${next.name} — stack` : next.kind === "donut" ? `${next.name} — move in` : next.kind === "spread" ? `${next.name} — spread` : `${next.name} — clear lane`,
    remaining: Math.max(0, next.activatesAt - time),
    cycle: Math.max(0.01, next.activatesAt - (next.activatesAt - POOLED_MECHANIC_TIMING.warningDuration)),
    urgent: true,
  };
}

interface MechanicRuntime {
  readonly context: BossMechanicContext;
  boss: BossMechanicResult["boss"];
  motion: BossMotionState;
  party: PartyMember[];
  events: BossMechanicResult["events"];
}

export interface BossMechanicDefinition {
  readonly id: BossMechanicId;
  readonly name: string;
  readonly instruction: string;
  readonly cooldown: number;
  readonly passive?: boolean;
  readonly start: (runtime: MechanicRuntime) => void;
  readonly advance: (runtime: MechanicRuntime) => void;
  upcoming?: (motion: BossMotionState, time: number) => UpcomingMechanic;
  readonly animationCue: (motion: BossMotionState) => BossAnimationCue;
}

function finishMechanic(runtime: MechanicRuntime, cooldown: number) {
  runtime.motion.activeMechanicId = null;
  runtime.motion.mode = "holding";
  runtime.motion.phaseEndsAt = 0;
  runtime.motion.nextMechanicAt = runtime.context.time + cooldown;
  runtime.motion.chargeHitIds = [];
  runtime.motion.mechanicHitIds = [];
  runtime.motion.tetherIds = [];
  runtime.motion.slashLanes = [];
}

function timedAdvance(runtime: MechanicRuntime, cooldown: number) {
  if (runtime.context.time >= runtime.motion.phaseEndsAt) finishMechanic(runtime, cooldown);
}

function defaultUpcoming(definition: Pick<BossMechanicDefinition, "name" | "cooldown">, motion: BossMotionState, time: number): UpcomingMechanic {
  const remaining = Math.max(0, (motion.activeMechanicId ? motion.phaseEndsAt : motion.nextMechanicAt) - time);
  return { name: definition.name, remaining, cycle: definition.cooldown, urgent: motion.activeMechanicId !== null || remaining < 2.5 };
}

function mechanicCopy(id: BossMechanicId) {
  return MECHANIC_COPY_BY_ID[id];
}

export function bossMechanicIsPassive(id: BossMechanicId) {
  return BOSS_MECHANIC_REGISTRY[id].passive === true;
}

interface LaneChargeConfig {
  id: BossMechanicId;
  warning: number;
  speed: number;
  distance: number;
  width: number;
  damage: number;
  knockdown: number;
  cooldown: number;
  targetOrder: readonly MemberId[];
}

function chargeEndpoint(start: WorldPosition, target: WorldPosition, travelDistance: number): WorldPosition {
  const angle = angleTo(start, target);
  return clampToArena([
    start[0] + Math.sin(angle) * travelDistance,
    start[1] + Math.cos(angle) * travelDistance,
  ]);
}

function laneChargeDefinition(config: LaneChargeConfig): BossMechanicDefinition {
  const copy = mechanicCopy(config.id);
  const definition: BossMechanicDefinition = {
    id: config.id,
    name: copy.name,
    instruction: copy.instruction,
    cooldown: config.cooldown,
    start(runtime) {
      const targetId = chooseLivingTarget(runtime.party, config.targetOrder, runtime.motion.mechanicCount);
      const end = chargeEndpoint(runtime.motion.position, runtime.context.partyPositions[targetId], config.distance);
      runtime.motion.mode = "telegraph";
      runtime.motion.chargeTargetId = targetId;
      runtime.motion.chargeStart = [...runtime.motion.position];
      runtime.motion.chargeEnd = end;
      runtime.motion.chargeHitIds = [];
      runtime.motion.phaseStartedAt = runtime.context.time;
      runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
      runtime.motion.slashLanes = [{
        id: `${config.id}-${runtime.motion.mechanicCount}`,
        start: [...runtime.motion.position],
        end,
        width: config.width,
        damage: config.damage,
      }];
      runtime.events.push({
        at: runtime.context.time,
        message: `${copy.name} targets ${memberName(runtime.party, targetId)}. ${copy.instruction}`,
        tone: "danger",
        pulseKind: "charge",
        targetId,
      });
    },
    advance(runtime) {
      const { context, motion } = runtime;
      if (motion.mode === "telegraph" && context.time >= motion.phaseEndsAt) {
        motion.mode = "charging";
        motion.phaseStartedAt = context.time;
        motion.phaseEndsAt = context.time + distance(motion.position, motion.chargeEnd) / config.speed;
        return;
      }
      if (motion.mode !== "charging") return;
      const previous = [...motion.position] as WorldPosition;
      motion.position = moveToward(motion.position, motion.chargeEnd, config.speed * context.delta);
      runtime.party = runtime.party.map((member) => {
        if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
        if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > config.width * 0.5) return member;
        motion.chargeHitIds.push(member.id);
        return {
          ...context.damageMember(member, config.damage, context.partyPositions[member.id], context.time),
          knockedUntil: context.time + config.knockdown,
        };
      });
      if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
        motion.position = [...motion.chargeEnd];
        finishMechanic(runtime, config.cooldown);
      }
    },
    animationCue: (motion) => motion.mode === "charging" ? "move" : "attack",
  };
  definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
  return definition;
}

interface CircleAttackConfig {
  id: BossMechanicId;
  warning: number;
  radius: number;
  damage: number;
  cooldown: number;
  kind: CircleHazard["kind"];
  targets: number;
  duration?: number;
  tickInterval?: number;
  centeredOnBoss?: boolean;
  stagger?: number;
}

function circleAttackDefinition(config: CircleAttackConfig): BossMechanicDefinition {
  const copy = mechanicCopy(config.id);
  const definition: BossMechanicDefinition = {
    id: config.id,
    name: copy.name,
    instruction: copy.instruction,
    cooldown: config.cooldown,
    start(runtime) {
      const activatesAt = runtime.context.time + config.warning;
      const targetIds = Array.from({ length: config.targets }, (_, offset) =>
        chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount + offset));
      const centers = config.centeredOnBoss
        ? [[...runtime.motion.position] as WorldPosition]
        : targetIds.map((targetId) => [...runtime.context.partyPositions[targetId]] as WorldPosition);
      runtime.motion.mode = config.id === "stormfall" ? "skyfall" : "golem_crownfall";
      runtime.motion.phaseStartedAt = runtime.context.time;
      runtime.motion.phaseEndsAt = activatesAt + (centers.length - 1) * (config.stagger ?? 0) + Math.max(0.32, config.duration ?? 0.32);
      runtime.motion.hazards.push(...centers.map((center, index) => createCircleHazard({
        id: `${config.id}-${runtime.motion.mechanicCount}-${index}`,
        kind: config.kind,
        center,
        radius: config.radius,
        activatesAt: activatesAt + index * (config.stagger ?? 0),
        duration: config.duration ?? 0.32,
        damage: config.damage,
        tickInterval: config.tickInterval,
      })));
      runtime.events.push({
        at: runtime.context.time,
        message: `${copy.name}: ${copy.instruction}`,
        tone: "danger",
        pulseKind: "skyfall",
        targetId: targetIds[0],
      });
    },
    advance(runtime) { timedAdvance(runtime, config.cooldown); },
    animationCue: () => "special",
  };
  definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
  return definition;
}

interface LaneAttackConfig {
  id: BossMechanicId;
  warning: number;
  width: number;
  damage: number;
  cooldown: number;
  angles: readonly number[];
  rotateFollowup?: number;
}

function laneFromAngle(id: string, center: WorldPosition, angle: number, width: number, damage: number): SlashLane {
  const dx = Math.sin(angle) * 9;
  const dz = Math.cos(angle) * 9;
  return { id, start: [center[0] - dx, center[1] - dz], end: [center[0] + dx, center[1] + dz], width, damage };
}

function resolveLanes(runtime: MechanicRuntime, name: string) {
  const hitIds: MemberId[] = [];
  runtime.party = runtime.party.map((member) => {
    if (member.hp <= 0) return member;
    const lane = runtime.motion.slashLanes.find((entry) =>
      pointToSegmentDistance(runtime.context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
    if (!lane) return member;
    hitIds.push(member.id);
    runtime.events.push({ at: runtime.context.time, message: `${member.name} is struck by ${name}.`, tone: "danger", pulseKind: "slash", targetId: member.id });
    return runtime.context.damageMember(member, lane.damage, runtime.context.partyPositions[member.id], runtime.context.time);
  });
  runtime.motion.mechanicHitIds.push(...hitIds);
}

function laneAttackDefinition(config: LaneAttackConfig): BossMechanicDefinition {
  const copy = mechanicCopy(config.id);
  const startLanes = (runtime: MechanicRuntime, rotation = 0) => {
    const targetId = chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount);
    const center = runtime.context.partyPositions[targetId];
    const aimed = angleTo(runtime.motion.position, center) + rotation;
    runtime.motion.slashLanes = config.angles.map((offset, index) =>
      laneFromAngle(`${config.id}-${runtime.motion.mechanicCount}-${index}-${rotation}`, center, aimed + offset, config.width, config.damage));
    runtime.motion.chargeTargetId = targetId;
  };
  const definition: BossMechanicDefinition = {
    id: config.id,
    name: copy.name,
    instruction: copy.instruction,
    cooldown: config.cooldown,
    start(runtime) {
      runtime.motion.mode = "mantis_line_telegraph";
      runtime.motion.phaseStartedAt = runtime.context.time;
      runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
      runtime.motion.chargeCount = 0;
      runtime.motion.mechanicHitIds = [];
      startLanes(runtime);
      runtime.events.push({ at: runtime.context.time, message: `${copy.name}: ${copy.instruction}`, tone: "danger", pulseKind: "slash", targetId: runtime.motion.chargeTargetId });
    },
    advance(runtime) {
      if (runtime.context.time < runtime.motion.phaseEndsAt) return;
      resolveLanes(runtime, copy.name);
      if (config.rotateFollowup && runtime.motion.chargeCount === 0) {
        runtime.motion.chargeCount = 1;
        runtime.motion.mode = "mantis_cross_telegraph";
        runtime.motion.phaseStartedAt = runtime.context.time;
        runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
        startLanes(runtime, config.rotateFollowup);
        runtime.events.push({ at: runtime.context.time, message: `${copy.name} rotates. Find new safe ground.`, tone: "danger", pulseKind: "slash" });
        return;
      }
      finishMechanic(runtime, config.cooldown);
    },
    animationCue: () => "attack",
  };
  definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
  return definition;
}

const BULL_TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];

const bullCharge = laneChargeDefinition({ id: "bull-charge", warning: BULL_CHARGE.warning, speed: BULL_CHARGE.speed, distance: BULL_CHARGE.distance, width: BULL_CHARGE.hitRadius * 2, damage: BULL_CHARGE.damage, knockdown: BULL_CHARGE.knockdown, cooldown: BULL_CHARGE.cooldown, targetOrder: BULL_TARGETS });
const destructionRush = laneChargeDefinition({ id: "destruction-rush", warning: 1.45, speed: 11.5, distance: 14, width: 2.5, damage: 27, knockdown: 0.55, cooldown: 3.8, targetOrder: BULL_TARGETS });
const burrowRush = laneChargeDefinition({ id: "burrow-rush", warning: 1.3, speed: 10.8, distance: 13, width: 2, damage: 25, knockdown: 0.35, cooldown: 3.8, targetOrder: ["aelia", "nia", "orin", "vale", "brann"] });
const sidewinderRush = laneChargeDefinition({ id: "sidewinder-rush", warning: 1.25, speed: 11, distance: 13.2, width: 2.3, damage: 24, knockdown: 0.42, cooldown: 3.7, targetOrder: BULL_TARGETS });

const crushingPounce: BossMechanicDefinition = {
  id: "crushing-pounce",
  name: bossMechanicName("crushing-pounce"),
  instruction: mechanicCopy("crushing-pounce").instruction,
  cooldown: 4,
  start(runtime) {
    const targetId = chooseLivingTarget(runtime.party, ["aelia", "nia", "orin", "vale", "brann"], runtime.motion.mechanicCount);
    runtime.motion.mode = "stacking";
    runtime.motion.pounceTargetId = targetId;
    runtime.motion.pounceCenter = [...runtime.context.partyPositions[targetId]];
    runtime.motion.phaseStartedAt = runtime.context.time;
    runtime.motion.phaseEndsAt = runtime.context.time + BULL_POUNCE.stackDuration;
    runtime.events.push({ at: runtime.context.time, message: `Crushing Pounce marks ${memberName(runtime.party, targetId)}. Stack to split the impact.`, tone: "danger", pulseKind: "pounce", targetId });
  },
  advance(runtime) {
    const { motion, context } = runtime;
    if (motion.mode === "stacking") {
      motion.pounceCenter = [...context.partyPositions[motion.pounceTargetId]];
      if (context.time < motion.phaseEndsAt) return;
      motion.mode = "pouncing";
      motion.chargeStart = [...motion.position];
      motion.chargeEnd = [...motion.pounceCenter];
      motion.phaseStartedAt = context.time;
      motion.phaseEndsAt = context.time + BULL_POUNCE.leapDuration;
      return;
    }
    if (motion.mode !== "pouncing") return;
    motion.position = moveToward(motion.position, motion.chargeEnd, 16 * context.delta);
    if (context.time < motion.phaseEndsAt && distance(motion.position, motion.chargeEnd) >= 0.08) return;
    const stackedIds = runtime.party.filter((member) => member.hp > 0 && distance(context.partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius).map((member) => member.id);
    const damage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
    runtime.party = runtime.party.map((member) => stackedIds.includes(member.id)
      ? context.damageMember(member, damage, context.partyPositions[member.id], context.time)
      : member);
    runtime.events.push({ at: context.time, message: `Crushing Pounce deals ${Math.round(damage)} damage across ${stackedIds.length} stacked allies.`, tone: "danger", pulseKind: "pounce", targetId: motion.pounceTargetId });
    finishMechanic(runtime, BULL_POUNCE.cooldown);
  },
  animationCue: (motion) => motion.mode === "pouncing" ? "special" : "attack",
};
crushingPounce.upcoming = (motion, time) => defaultUpcoming(crushingPounce, motion, time);

function instantTimedDefinition(id: BossMechanicId, cooldown: number, start: (runtime: MechanicRuntime) => void, cue: BossAnimationCue = "attack"): BossMechanicDefinition {
  const copy = mechanicCopy(id);
  const definition: BossMechanicDefinition = {
    id, name: copy.name, instruction: copy.instruction, cooldown,
    start(runtime) {
      runtime.motion.mode = "golem_shockwave";
      runtime.motion.phaseStartedAt = runtime.context.time;
      runtime.motion.phaseEndsAt = runtime.context.time + 0.55;
      start(runtime);
    },
    advance(runtime) { timedAdvance(runtime, cooldown); },
    animationCue: () => cue,
  };
  definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
  return definition;
}

const cinderNova = instantTimedDefinition("cinder-nova", 5, (runtime) => {
  runtime.party = runtime.party.map((member) => member.hp > 0
    ? runtime.context.damageMember(member, 13, runtime.context.partyPositions[member.id], runtime.context.time)
    : member);
  runtime.events.push({ at: runtime.context.time, message: "Cinder Nova strikes the party.", tone: "danger", pulseKind: "boss" });
}, "special");

const emberBrand = instantTimedDefinition("ember-brand", 5, (runtime) => {
  const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount);
  runtime.party = runtime.party.map((member) => member.id === targetId ? {
    ...member,
    debuffs: [...member.debuffs, { id: `ember-brand-${runtime.motion.mechanicCount}`, name: "Ember Brand", expiresAt: runtime.context.time + 7, nextTickAt: runtime.context.time + 1, tickDamage: 6 }],
  } : member);
  runtime.events.push({ at: runtime.context.time, message: `Ember Brand afflicts ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "debuff", targetId });
});

const bindingWeb: BossMechanicDefinition = {
  id: "binding-web", name: bossMechanicName("binding-web"), instruction: mechanicCopy("binding-web").instruction, cooldown: 4,
  start(runtime) {
    const pairs: readonly (readonly [MemberId, MemberId])[] = [["brann", "vale"], ["nia", "orin"], ["aelia", "nia"]];
    const pair = pairs[(runtime.motion.mechanicCount - 1) % pairs.length];
    const first = chooseLivingTarget(runtime.party, pair, 0);
    const second = chooseLivingTarget(runtime.party, pair.filter((id) => id !== first), 0);
    runtime.motion.mode = "tethering";
    runtime.motion.tetherIds = [first, second];
    runtime.motion.tetherBreakDistance = 6.8;
    runtime.motion.chargeStart = [...runtime.motion.position];
    const scuttleDirection = runtime.motion.mechanicCount % 2 === 0 ? -1 : 1;
    runtime.motion.chargeEnd = clampToArena([
      ARENA_CENTER[0] + runtime.motion.formationOffsetX + scuttleDirection * 4.2,
      ARENA_CENTER[1] - 1.25,
    ]);
    runtime.motion.phaseStartedAt = runtime.context.time;
    runtime.motion.phaseEndsAt = runtime.context.time + 4.5;
    runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first });
  },
  advance(runtime) {
    runtime.motion.position = moveToward(
      runtime.motion.position,
      runtime.motion.chargeEnd,
      BINDING_WEB_SCUTTLE_SPEED * runtime.context.delta,
    );
    const [first, second] = runtime.motion.tetherIds;
    if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) {
      runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
      finishMechanic(runtime, 4);
      return;
    }
    if (runtime.context.time < runtime.motion.phaseEndsAt) return;
    runtime.party = runtime.party.map((member) => runtime.motion.tetherIds.includes(member.id)
      ? { ...runtime.context.damageMember(member, 24, runtime.context.partyPositions[member.id], runtime.context.time), knockedUntil: runtime.context.time + 1.4 }
      : member);
    runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
    finishMechanic(runtime, 4);
  },
  animationCue: (motion) => distance(motion.position, motion.chargeEnd) > 0.08 ? "move" : "attack",
};
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);

const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
  const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
  runtime.motion.mode = "venom_cast";
  runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
  runtime.party = runtime.party.map((member) => targets.includes(member.id) ? {
    ...member,
    debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.bossId}-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage, sourceBossId: runtime.motion.bossId }],
  } : member);
  runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move apart, then cleanse.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
});

const stormBreath: BossMechanicDefinition = {
  id: "storm-breath", name: bossMechanicName("storm-breath"), instruction: mechanicCopy("storm-breath").instruction, cooldown: 4,
  start(runtime) {
    const aimed = angleTo(runtime.motion.position, runtime.context.partyPositions.brann);
    const direction = runtime.motion.mechanicCount % 2 === 0 ? 1 : -1;
    runtime.motion.mode = "breath_telegraph";
    runtime.motion.breathStartAngle = aimed - direction * Math.PI * 0.475;
    runtime.motion.breathEndAngle = aimed + direction * Math.PI * 0.475;
    runtime.motion.breathAngle = runtime.motion.breathStartAngle;
    runtime.motion.phaseStartedAt = runtime.context.time;
    runtime.motion.phaseEndsAt = runtime.context.time + SKY_SWEEPER_BREATH.telegraphDuration;
    runtime.motion.mechanicNextDamageAt = {};
    runtime.events.push({ at: runtime.context.time, message: "Storm Breath gathers. Rotate behind the sweep.", tone: "danger", pulseKind: "breath" });
  },
  advance(runtime) {
    const { motion, context } = runtime;
    if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
      motion.mode = "breath_sweeping";
      motion.phaseStartedAt = context.time;
      motion.phaseEndsAt = context.time + SKY_SWEEPER_BREATH.sweepDuration;
      return;
    }
    if (motion.mode !== "breath_sweeping") return;
    const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration));
    motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
    runtime.party = runtime.party.map((member) => {
      if (member.hp <= 0) return member;
      const dx = context.partyPositions[member.id][0] - motion.position[0];
      const dz = context.partyPositions[member.id][1] - motion.position[1];
      const memberAngle = Math.atan2(dx, dz);
      const exposed = Math.hypot(dx, dz) <= SKY_SWEEPER_BREATH.range && Math.abs(Math.atan2(Math.sin(memberAngle - motion.breathAngle), Math.cos(memberAngle - motion.breathAngle))) <= SKY_SWEEPER_BREATH.halfAngle;
      if (!exposed) return member;
      const nextAt = motion.mechanicNextDamageAt[member.id] ?? context.time;
      if (nextAt > context.time) return member;
      motion.mechanicNextDamageAt[member.id] = context.time + SKY_SWEEPER_BREATH.tickInterval;
      return context.damageMember(member, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], context.time);
    });
    if (context.time >= motion.phaseEndsAt) finishMechanic(runtime, SKY_SWEEPER_BREATH.cooldown);
  },
  animationCue: () => "attack",
};
stormBreath.upcoming = (motion, time) => defaultUpcoming(stormBreath, motion, time);

const stormfall = circleAttackDefinition({ id: "stormfall", warning: 2, radius: 1.8, damage: 30, cooldown: 4, kind: "skyfall", targets: 3, duration: 5, tickInterval: 1, stagger: 0.9 });
const crushingTide = circleAttackDefinition({ id: "crushing-tide", warning: 1.35, radius: 1.7, damage: 26, cooldown: 3.7, kind: "tidal_burst", targets: 3 });
const hauntingRifts = circleAttackDefinition({ id: "haunting-rifts", warning: 1.45, radius: 1.75, damage: 5, cooldown: 3.9, kind: "soul_rift", targets: 2, duration: 4.2, tickInterval: 0.8 });
const ultimateSkyfall = circleAttackDefinition({ id: "ultimate-skyfall", warning: 1.5, radius: 1.85, damage: 28, cooldown: 4, kind: "crownfall", targets: 3 });
const ruinQuake = circleAttackDefinition({ id: "ruin-quake", warning: 1.35, radius: 3.6, damage: 31, cooldown: 3.8, kind: "quake", targets: 1, centeredOnBoss: true });

const elementalBeam = laneAttackDefinition({ id: "elemental-beam", warning: 0.9, width: 1.65, damage: 32, cooldown: 3.4, angles: [0] });
const guardianCross = laneAttackDefinition({ id: "guardian-cross", warning: 0.9, width: 1.45, damage: 25, cooldown: 3.4, angles: [-Math.PI * 0.18, Math.PI * 0.18] });
const destructionPulse = laneAttackDefinition({ id: "destruction-pulse", warning: 1.2, width: 1.25, damage: 24, cooldown: 3.8, angles: [0, Math.PI / 3, -Math.PI / 3] });
const vineScissors = laneAttackDefinition({ id: "vine-scissors", warning: 1.25, width: 1.55, damage: 22, cooldown: 3.9, angles: [0, Math.PI / 2], rotateFollowup: Math.PI / 4 });

const ricochetRush: BossMechanicDefinition = {
  ...laneChargeDefinition({ id: "ricochet-rush", warning: 1.25, speed: 12.5, distance: 13, width: 2.25, damage: 22, knockdown: 0.4, cooldown: 3.6, targetOrder: ["orin", "nia", "aelia", "vale", "brann"] }),
  advance(runtime) {
    const motion = runtime.motion;
    if (motion.mode === "telegraph" && runtime.context.time >= motion.phaseEndsAt) {
      motion.mode = "charging";
      motion.chargeCount = 0;
      motion.phaseStartedAt = runtime.context.time;
      motion.phaseEndsAt = runtime.context.time + distance(motion.position, motion.chargeEnd) / 12.5;
      return;
    }
    if (motion.mode !== "charging") return;
    const previous = [...motion.position] as WorldPosition;
    motion.position = moveToward(motion.position, motion.chargeEnd, 12.5 * runtime.context.delta);
    runtime.party = runtime.party.map((member) => {
      if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(runtime.context.partyPositions[member.id], previous, motion.position) > 1.125) return member;
      motion.chargeHitIds.push(member.id);
      return runtime.context.damageMember(member, 22, runtime.context.partyPositions[member.id], runtime.context.time);
    });
    if (distance(motion.position, motion.chargeEnd) >= 0.08 && runtime.context.time < motion.phaseEndsAt) return;
    motion.hazards.push(createCircleHazard({ id: `ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, kind: "lava_pool", center: motion.chargeEnd, radius: 1.5, activatesAt: runtime.context.time, duration: 4.5, damage: 5, tickInterval: 0.8 }));
    if (motion.chargeCount === 0) {
      const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, motion.mechanicCount + 2);
      const start = [...motion.chargeEnd] as WorldPosition;
      const end = chargeEndpoint(start, runtime.context.partyPositions[targetId], 13);
      motion.position = start;
      motion.chargeStart = start;
      motion.chargeEnd = end;
      motion.chargeTargetId = targetId;
      motion.chargeHitIds = [];
      motion.chargeCount = 1;
      motion.phaseEndsAt = runtime.context.time + distance(start, end) / 12.5;
      motion.slashLanes = [{ id: `ricochet-${motion.mechanicCount}-1`, start, end, width: 2.25, damage: 22 }];
      runtime.events.push({ at: runtime.context.time, message: `Ricochet Rush rebounds toward ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
      return;
    }
    finishMechanic(runtime, 3.6);
  },
};

const meteorSlam: BossMechanicDefinition = {
  id: "meteor-slam", name: bossMechanicName("meteor-slam"), instruction: mechanicCopy("meteor-slam").instruction, cooldown: 3.6,
  start(runtime) {
    const activatesAt = runtime.context.time + 1.3;
    runtime.motion.mode = "cinderback_slam";
    runtime.motion.phaseStartedAt = runtime.context.time;
    runtime.motion.phaseEndsAt = activatesAt + 0.3;
    runtime.motion.hazards.push(createCircleHazard({ id: `meteor-slam-${runtime.motion.mechanicCount}`, kind: "quake", center: runtime.motion.position, radius: 3.1, activatesAt, duration: 0.3, damage: 29 }));
    for (let index = 0; index < 3; index += 1) {
      const angle = index / 3 * Math.PI * 2;
      runtime.motion.hazards.push(createCircleHazard({ id: `meteor-flame-${runtime.motion.mechanicCount}-${index}`, kind: "lava_pool", center: clampToArena([runtime.motion.position[0] + Math.sin(angle) * 3.7, runtime.motion.position[1] + Math.cos(angle) * 3.7]), radius: 1.5, activatesAt, duration: 4.5, damage: 5, tickInterval: 0.8 }));
    }
    runtime.events.push({ at: runtime.context.time, message: "Meteor Slam: leave the impact and spreading flame.", tone: "danger", pulseKind: "boss" });
  },
  advance(runtime) { timedAdvance(runtime, 3.6); },
  animationCue: () => "special",
};
meteorSlam.upcoming = (motion, time) => defaultUpcoming(meteorSlam, motion, time);

const hourglassEruption: BossMechanicDefinition = {
  id: "hourglass-eruption", name: bossMechanicName("hourglass-eruption"), instruction: mechanicCopy("hourglass-eruption").instruction, cooldown: 3.8,
  start(runtime) {
    const activatesAt = runtime.context.time + 1.55;
    for (let index = 0; index < 3; index += 1) {
      const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + index);
      runtime.motion.hazards.push(createCircleHazard({ id: `stinger-${runtime.motion.mechanicCount}-${index}`, kind: "stinger_eruption", center: runtime.context.partyPositions[targetId], radius: 1.75, activatesAt, duration: 0.32, damage: 27 }));
    }
    runtime.motion.hazards.push(createCircleHazard({ id: `hourglass-${runtime.motion.mechanicCount}`, kind: "hourglass", center: clampToArena([runtime.motion.position[0], runtime.motion.position[1] + 3]), radius: 2.55, activatesAt: activatesAt + 0.9, duration: 3.6, damage: 6, tickInterval: 0.75 }));
    runtime.motion.mode = "sandglass_hourglass";
    runtime.motion.phaseStartedAt = runtime.context.time;
    runtime.motion.phaseEndsAt = activatesAt + 4.5;
    runtime.events.push({ at: runtime.context.time, message: "Hourglass Eruption: leave the eruptions and moving zone.", tone: "danger", pulseKind: "skyfall" });
  },
  advance(runtime) { timedAdvance(runtime, 3.8); },
  animationCue: () => "special",
};
hourglassEruption.upcoming = (motion, time) => defaultUpcoming(hourglassEruption, motion, time);

const triBurst: BossMechanicDefinition = {
  id: "tri-burst", name: bossMechanicName("tri-burst"), instruction: mechanicCopy("tri-burst").instruction, cooldown: 4,
  start(runtime) {
    const firstActivation = runtime.context.time + 1.2;
    const bands = [{ innerRadius: 0, radius: 2.35 }, { innerRadius: 2.35, radius: 4.7 }, { innerRadius: 4.7, radius: 7.05 }];
    runtime.motion.mode = "golem_shockwave";
    runtime.motion.phaseStartedAt = runtime.context.time;
    runtime.motion.phaseEndsAt = firstActivation + 1.62;
    runtime.motion.hazards.push(...bands.map((band, index) => createCircleHazard({ id: `tri-burst-${runtime.motion.mechanicCount}-${index}`, kind: "royal_shockwave", center: runtime.motion.position, innerRadius: band.innerRadius, radius: band.radius, activatesAt: firstActivation + index * 0.65, duration: 0.3, damage: 19 })));
    runtime.events.push({ at: runtime.context.time, message: "Tri-Burst expands in three rings. Follow the safe bands.", tone: "danger", pulseKind: "boss" });
  },
  advance(runtime) { timedAdvance(runtime, 4); },
  animationCue: () => "special",
};
triBurst.upcoming = (motion, time) => defaultUpcoming(triBurst, motion, time);

function telegraphDefinition(id: BossMechanicId): BossMechanicDefinition {
  const copy = mechanicCopy(id);
  const definition: BossMechanicDefinition = {
    id, name: copy.name, instruction: copy.instruction, cooldown: 5,
    start(runtime) {
      const started = beginPoolMechanic(runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, id);
      runtime.motion = started.motion;
      runtime.motion.mode = "golem_crownfall";
      runtime.events.push(started.event);
    },
    advance(runtime) {
      for (const telegraph of runtime.motion.poolTelegraphs) {
        if (telegraph.kind === "memory") {
          if (!telegraph.resolved) runtime.party = resolveMemorySequence(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
          continue;
        }
        if (telegraph.kind === "soul-siphon") {
          if (!telegraph.resolved) runtime.party = resolveSoulSiphon(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
          continue;
        }
        if (telegraph.resolved || runtime.context.time < telegraph.activatesAt) continue;
        runtime.party = resolveTelegraph(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
        telegraph.resolved = true;
      }
      runtime.motion.poolTelegraphs = runtime.motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > runtime.context.time);
      if (!runtime.motion.poolTelegraphs.length) finishMechanic(runtime, 5);
    },
    upcoming(motion, time) { return upcomingPooledMechanic(motion, time) ?? defaultUpcoming(definition, motion, time); },
    animationCue: () => id === "soul-siphon" || id === "memory-sequence" ? "special" : "attack",
  };
  return definition;
}

const basicMelee: BossMechanicDefinition = {
  id: "basic-melee",
  name: bossMechanicName("basic-melee"),
  instruction: mechanicCopy("basic-melee").instruction,
  cooldown: 0,
  passive: true,
  start() {},
  advance(runtime) {
    applyMelee(runtime.boss, runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, 2.5, 15, runtime.context.damageMember);
  },
  animationCue: () => "idle",
};

export const BOSS_MECHANIC_REGISTRY: Record<BossMechanicId, BossMechanicDefinition> = {
  "basic-melee": basicMelee,
  "bull-charge": bullCharge,
  "crushing-pounce": crushingPounce,
  "cinder-nova": cinderNova,
  "ember-brand": emberBrand,
  "binding-web": bindingWeb,
  "venom-purge": venomPurge,
  "storm-breath": stormBreath,
  stormfall,
  "elemental-beam": elementalBeam,
  "guardian-cross": guardianCross,
  "destruction-rush": destructionRush,
  "ruin-quake": ruinQuake,
  "destruction-pulse": destructionPulse,
  "ricochet-rush": ricochetRush,
  "meteor-slam": meteorSlam,
  "burrow-rush": burrowRush,
  "hourglass-eruption": hourglassEruption,
  "sidewinder-rush": sidewinderRush,
  "crushing-tide": crushingTide,
  "vine-scissors": vineScissors,
  "haunting-rifts": hauntingRifts,
  "tri-burst": triBurst,
  "ultimate-skyfall": ultimateSkyfall,
  "meteor-spread": telegraphDefinition("meteor-spread"),
  "hollow-collapse": telegraphDefinition("hollow-collapse"),
  "aetheric-soak": telegraphDefinition("aetheric-soak"),
  "prism-beam": telegraphDefinition("prism-beam"),
  "memory-sequence": telegraphDefinition("memory-sequence"),
  "soul-siphon": telegraphDefinition("soul-siphon"),
};

function scheduledMechanicId(
  loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
  mechanicCount: number,
) {
  let activeCount = 0;
  for (const id of loadout) if (!BOSS_MECHANIC_REGISTRY[id].passive) activeCount += 1;
  if (!activeCount) throw new Error("Boss loadout requires at least one active mechanic.");
  let targetIndex = mechanicCount % activeCount;
  for (const id of loadout) {
    if (BOSS_MECHANIC_REGISTRY[id].passive) continue;
    if (targetIndex === 0) return id;
    targetIndex -= 1;
  }
  return loadout[0];
}

export function advanceMechanicLoadout(
  context: BossMechanicContext,
  loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
): BossMechanicResult {
  const runtime: MechanicRuntime = {
    context,
    boss: { ...context.boss },
    motion: cloneMotion(context.motion),
    party: context.party,
    events: [],
  };

  if (runtime.motion.mode === "holding") returnBossToArenaCenter(runtime.motion, context.delta, 2);

  for (const mechanicId of loadout) {
    const definition = BOSS_MECHANIC_REGISTRY[mechanicId];
    if (definition.passive) definition.advance(runtime);
  }

  if (runtime.motion.activeMechanicId) {
    BOSS_MECHANIC_REGISTRY[runtime.motion.activeMechanicId].advance(runtime);
  }

  if (!runtime.motion.activeMechanicId && context.time >= runtime.motion.nextMechanicAt) {
    const mechanicId = scheduledMechanicId(loadout, runtime.motion.mechanicCount);
    runtime.motion.mechanicCount += 1;
    runtime.motion.activeMechanicId = mechanicId;
    runtime.motion.nextMechanicAt = Number.POSITIVE_INFINITY;
    BOSS_MECHANIC_REGISTRY[mechanicId].start(runtime);
  }

  runtime.party = resolveCircleHazards(runtime.motion, runtime.party, context.partyPositions, context.time, context.damageMember, runtime.events);
  return { boss: runtime.boss, motion: runtime.motion, party: runtime.party, events: runtime.events };
}

export function upcomingLoadoutMechanic(
  loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
  motion: BossMotionState,
  time: number,
): UpcomingMechanic {
  const id = motion.activeMechanicId ?? scheduledMechanicId(loadout, motion.mechanicCount);
  const definition = BOSS_MECHANIC_REGISTRY[id];
  return definition.upcoming?.(motion, time) ?? defaultUpcoming(definition, motion, time);
}

export function bossAnimationCue(motion: BossMotionState): BossAnimationCue {
  if (motion.activeMechanicId) return BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion);
  const homeDx = motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX);
  const homeDz = motion.position[1] - ARENA_CENTER[1];
  return Math.hypot(homeDx, homeDz) > 0.08 ? "move" : "idle";
}

export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) {
  const next = cloneMotion(motion);
  next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + VENOM_PURGE.poolArmDelay, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
  return next;
}

export function handleMechanicDispel(
  motion: BossMotionState,
  memberId: MemberId,
  position: WorldPosition,
  time: number,
  debuffs: readonly Debuff[],
) {
  if (debuffs.some((debuff) => debuff.name === "Widow Venom" && debuff.sourceBossId === motion.bossId)) {
    return {
      motion: dropVenomPool(motion, memberId, position, time),
      message: "Widow Venom purged. A venom pool forms where the target stood.",
    };
  }
  return { motion, message: "Harmful magic removed." };
}
