import { create } from "zustand";
import {
  advanceBossMechanics,
  createBossMotionState,
  createBossState,
  handleBossDispel,
  upcomingMechanic,
} from "./bossMechanics";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
import { normalizeEncounterBossIds } from "./bossSelection";
import { clampToArena, constrainBossMotion } from "./arena";
import { cloneMotion } from "./bosses/shared";
import { freshParty } from "./data";
import { distance } from "./geometry";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import { isPartyWiped } from "./partyState";
import {
  RUN_BUFFS,
  bossHealthMultiplier,
  compileRunModifiers,
  increaseRunBuffRank,
  runAbilityCastTime,
  runAbilityCooldown,
  runAbilityManaCost,
  selectRunBuffDraft,
  selectRandomBossPair,
  selectRogueTrialsBosses,
  selectUnseenBosses,
  ROGUE_TRIALS_TRIO_ROUND,
  type CompiledRunModifiers,
} from "./roguelike";
import { createDefaultGearProgress, type GearProgress } from "./progression/gear";
import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects";
import { passiveInfusionUnlocked } from "./progression/infusions";
import { DIFFICULTY_BY_SLUG, normalizeDifficultySlug, type DifficultySlug } from "./progression/loot";
import type {
  ActiveCast,
  AbilityId,
  BarrierState,
  BossMotionState,
  BossState,
  BossId,
  BottomTab,
  GamePhase,
  HealerClassId,
  InventoryItem,
  MemberId,
  PartyMember,
  RunBuffId,
  RunBuffRanks,
  RunMode,
  ScenePulse,
  WorldPosition,
} from "./types";

export interface CombatLogEntry {
  id: number;
  time: number;
  message: string;
  tone: "good" | "danger" | "neutral";
}

export interface AdditionalBossState {
  instanceId: string;
  boss: BossState;
  motion: BossMotionState;
}

export interface GameState {
  bossId: BossId;
  bossInstanceId: string;
  paused: boolean;
  pauseSelection: "resume" | "exit";
  healerClassId: HealerClassId;
  playerName: string;
  phase: GamePhase;
  runMode: RunMode;
  round: number;
  seenBossIds: BossId[];
  endlessMode: boolean;
  endlessBossKills: number;
  endlessSpawnSequence: number;
  endlessChoiceSelection: "continue" | "quit";
  runBuffRanks: RunBuffRanks;
  draftBuffIds: RunBuffId[];
  selectedRunBuffId: RunBuffId | null;
  runBuffInputUnlockAt: number;
  passiveRunBuffId: RunBuffId | null;
  runModifiers: CompiledRunModifiers;
  healingMultiplier: number;
  difficultySlug: DifficultySlug;
  difficultyDamageMultiplier: number;
  gearProgress: GearProgress;
  gearModifiers: EncounterGearModifiers;
  time: number;
  party: PartyMember[];
  boss: BossState;
  additionalBosses: AdditionalBossState[];
  partyPositions: Record<MemberId, WorldPosition>;
  bossMotion: BossMotionState;
  partyCombat: PartyCombatState;
  partyDamageEvents: PartyDamageEvent[];
  mana: number;
  maxMana: number;
  selectedMemberId: MemberId;
  cooldowns: Record<AbilityId, number>;
  globalCooldownUntil: number;
  activeTab: BottomTab;
  selectedItemId: string;
  inventory: InventoryItem[];
  combatLog: CombatLogEntry[];
  scenePulse: ScenePulse;
  playerPosition: [number, number];
  activeCast: ActiveCast | null;
  barrier: BarrierState;
  configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[], runMode?: RunMode, gearProgress?: GearProgress, difficultySlug?: DifficultySlug) => void;
  startEncounter: () => void;
  restart: () => void;
  tick: (delta: number) => void;
  castAbility: (abilityId: AbilityId) => boolean;
  selectMember: (memberId: MemberId) => void;
  cycleMember: (direction: 1 | -1) => void;
  setActiveTab: (tab: BottomTab) => void;
  selectItem: (itemId: string) => void;
  setPlayerPosition: (position: [number, number]) => void;
  setPaused: (paused: boolean) => void;
  togglePause: () => void;
  setPauseSelection: (selection: "resume" | "exit") => void;
  setSelectedRunBuff: (buffId: RunBuffId) => void;
  chooseRunBuff: (buffId: RunBuffId) => boolean;
  continueRoguelikeRound: () => boolean;
  startRogueTrialsEndless: () => boolean;
  setEndlessChoiceSelection: (selection: "continue" | "quit") => void;
}

const emptyCooldowns = (): Record<AbilityId, number> => ({
  mend: 0,
  renew: 0,
  shield: 0,
  purify: 0,
  radiance: 0,
  barrier: 0,
});

export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;

export const BARRIER_RADIUS = 3;
export const BARRIER_DAMAGE_REDUCTION = 0.3;

const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
  const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
  return normalizeEncounterBossIds(requested);
};

function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
  const motion = cloneMotion(createBossMotionState(bossId));
  const offset = count === 3 ? [-3.7, 0, 3.7][index] : count === 2 ? [-2.65, 2.65][index] : 0;
  motion.formationOffsetX = offset;
  motion.position[0] += offset;
  motion.chargeStart[0] += offset;
  motion.chargeEnd[0] += offset;
  motion.pounceCenter[0] += offset;
  const stagger = index * 2.4;
  if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
  return constrainBossMotion(motion);
}

function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number): AdditionalBossState {
  const boss = createBossState(bossId);
  boss.maxHp = Math.round(boss.maxHp * healthMultiplier);
  boss.hp = boss.maxHp;
  const stagger = index * 0.8;
  if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
  return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
}

const freshPartyPositions = (bossIds: readonly BossId[]): Record<MemberId, WorldPosition> => {
  const bossPosition = createBossMotionState(bossIds[0]).position;
  if (bossIds.length > 1) bossPosition[0] = 0;
  const formation = combatFormation(bossPosition);
  return {
    aelia: clampToArena([0, 4.5]),
    brann: clampToArena(formation.brann),
    nia: clampToArena(formation.nia),
    orin: clampToArena(formation.orin),
    vale: clampToArena(formation.vale),
  };
};

export function damageMember(member: PartyMember, amount: number): PartyMember {
  const absorbed = Math.min(member.absorb, amount);
  return {
    ...member,
    absorb: Math.max(0, member.absorb - absorbed),
    hp: Math.max(0, member.hp - (amount - absorbed)),
  };
}

export function healMember(member: PartyMember, amount: number): PartyMember {
  if (member.hp <= 0) return member;
  return { ...member, hp: Math.min(member.maxHp, member.hp + amount) };
}

export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) {
  return barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS;
}

function lowestHealthIndexes(
  party: readonly PartyMember[],
  excludedIndex: number,
  count: number,
  predicate: (member: PartyMember) => boolean = () => true,
): number[] {
  return party
    .map((member, index) => ({ member, index }))
    .filter(({ member, index }) => index !== excludedIndex && member.hp > 0 && predicate(member))
    .sort((left, right) => (left.member.hp / left.member.maxHp) - (right.member.hp / right.member.maxHp) || left.index - right.index)
    .slice(0, count)
    .map(({ index }) => index);
}

function applyRenewAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers) {
  if (party[index].hp <= 0) return;
  party[index] = {
    ...party[index],
    renewExpiresAt: time + 8 + modifiers.renewDurationBonus,
    renewNextTickAt: time + 1,
  };
}

function addHealerAbsorb(
  party: PartyMember[],
  index: number,
  baseAmount: number,
  healingPower: number,
  modifiers: CompiledRunModifiers,
) {
  if (party[index].hp <= 0 || baseAmount <= 0) return 0;
  const amount = baseAmount * healingPower * modifiers.shieldAbsorbMultiplier;
  party[index] = {
    ...party[index],
    absorb: Math.min(party[index].maxHp, party[index].absorb + amount),
  };
  return amount;
}

function damageMemberAt(
  member: PartyMember,
  amount: number,
  position: WorldPosition,
  barrier: BarrierState,
  time: number,
  partyCombat?: PartyCombatState,
  tankPosition?: WorldPosition,
  incomingDamageMultiplier = 1,
  gearModifiers?: EncounterGearModifiers,
  kind: "direct" | "hazard" = "direct",
  shieldDamageTakenMultiplier = 1,
) {
  amount *= incomingDamageMultiplier;
  if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1;
  const protectedByTank = partyCombat && tankPosition
    ? tankAuraProtects(position, tankPosition, partyCombat.tankAura, time)
    : false;
  const reduction = Math.max(
    barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0,
    protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 0,
  );
  const shieldMultiplier = member.absorb > 0 ? shieldDamageTakenMultiplier : 1;
  return damageMember(member, amount * (1 - reduction) * shieldMultiplier);
}

function addLog(
  log: CombatLogEntry[],
  time: number,
  message: string,
  tone: CombatLogEntry["tone"] = "neutral",
): CombatLogEntry[] {
  const next = [{ id: Date.now() + Math.random(), time, message, tone }, ...log];
  return next.slice(0, 12);
}


function initialState(
  healerClassId: HealerClassId = "priest",
  playerName = "Aelia",
  inventory: InventoryItem[] = createClassInventory(healerClassId),
  requestedBossIds: BossId | readonly BossId[] = "bulldrome",
  runMode: RunMode = "encounter",
  round = 1,
  runBuffRanks: RunBuffRanks = {},
  gearProgress: GearProgress = createDefaultGearProgress(),
  requestedDifficultySlug: DifficultySlug = "initiate",
  seenBossIds: readonly BossId[] = [],
) {
  const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug);
  const difficulty = DIFFICULTY_BY_SLUG[difficultySlug];
  const bossIds = normalizeBossIds(requestedBossIds);
  const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
    bossId,
    index,
    bossIds.length,
    bossHealthMultiplier(round) * difficulty.healthMultiplier,
  ));
  const primary = encounterBosses[0];
  const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId);
  const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null;
  const runModifiers = compileRunModifiers(runBuffRanks, passiveInfusionId);
  const draftBuffIds = runMode !== "encounter" ? selectRunBuffDraft(runBuffRanks, passiveInfusionId) : [];
  const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers);
  const maxMana = 100;
  return {
    bossId: primary.boss.id,
    bossInstanceId: primary.instanceId,
    paused: false,
    pauseSelection: "resume" as const,
    healerClassId,
    playerName,
    phase: "briefing" as GamePhase,
    runMode,
    round,
    seenBossIds: [...new Set([...seenBossIds, ...bossIds])],
    endlessMode: false,
    endlessBossKills: 0,
    endlessSpawnSequence: 0,
    endlessChoiceSelection: "continue" as const,
    runBuffRanks: { ...runBuffRanks },
    draftBuffIds,
    selectedRunBuffId: draftBuffIds[0] ?? null,
    runBuffInputUnlockAt: 0,
    passiveRunBuffId: passiveInfusionId,
    runModifiers,
    healingMultiplier: gearModifiers.aelia.healingPower,
    difficultySlug,
    difficultyDamageMultiplier: difficulty.damageMultiplier,
    gearProgress,
    gearModifiers,
    time: 0,
    party,
    boss: primary.boss,
    additionalBosses: encounterBosses.slice(1),
    partyPositions: freshPartyPositions(bossIds),
    bossMotion: primary.motion,
    partyCombat: createPartyCombatState(party),
    partyDamageEvents: [] as PartyDamageEvent[],
    mana: maxMana,
    maxMana,
    selectedMemberId: "brann" as MemberId,
    cooldowns: emptyCooldowns(),
    globalCooldownUntil: 0,
    activeTab: "combat" as BottomTab,
    selectedItemId: inventory[0]?.id ?? "",
    inventory: structuredClone(inventory),
    combatLog: [] as CombatLogEntry[],
    scenePulse: { id: 0, kind: "mend" as const },
    playerPosition: [0, 4.5] as [number, number],
    activeCast: null as ActiveCast | null,
    barrier: { center: [0, 4.5], expiresAt: 0, nextHealAt: 0 } as BarrierState,
  };
}

export const useGameStore = create<GameState>((set, get) => ({
  ...initialState(),

  configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate") => {
    set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug));
  },

  startEncounter: () => {
    const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds } = get();
    const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
    set({
      ...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds),
      phase: "combat",
      activeTab: "combat",
      combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }],
    });
  },

  restart: () => {
    const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug } = get();
    const bossIds = runMode === "rogue-trials"
      ? selectRandomBossPair()
      : [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
    set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug));
  },

  selectMember: (selectedMemberId) => set({ selectedMemberId }),

  cycleMember: (direction) => {
    const { party, selectedMemberId } = get();
    const living = party.filter((member) => member.hp > 0);
    if (!living.length) return;
    const currentIndex = living.findIndex((member) => member.id === selectedMemberId);
    const nextIndex = (Math.max(0, currentIndex) + direction + living.length) % living.length;
    set({ selectedMemberId: living[nextIndex].id });
  },

  setActiveTab: (activeTab) => set({ activeTab }),
  selectItem: (selectedItemId) => set({ selectedItemId }),
  setPaused: (paused) => set({ paused, pauseSelection: "resume" }),
  togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
  setPauseSelection: (pauseSelection) => set({ pauseSelection }),
  setSelectedRunBuff: (selectedRunBuffId) => set((state) =>
    state.phase === "intermission"
      && !isRunBuffInputLocked(state)
      && state.draftBuffIds.includes(selectedRunBuffId)
      ? { selectedRunBuffId }
      : state
  ),
  chooseRunBuff: (buffId) => {
    const state = get();
    if (state.phase !== "intermission" || isRunBuffInputLocked(state) || !state.draftBuffIds.includes(buffId)) return false;
    const runBuffRanks = increaseRunBuffRank(state.runBuffRanks, buffId);
    const round = state.round + 1;
    const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
    const bossIds = state.runMode === "rogue-trials"
      ? selectRogueTrialsBosses(round, state.seenBossIds)
      : selectRandomBossPair(previousBossIds);
    const abilityName = HEALER_CLASSES[state.healerClassId].abilities[RUN_BUFFS[buffId].abilityId].name;
    set({
      ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, state.runMode, round, runBuffRanks, state.gearProgress, state.difficultySlug, state.seenBossIds),
      phase: "combat",
      activeTab: "combat",
      combatLog: [{
        id: Date.now(),
        time: 0,
        message: `${abilityName}: ${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
        tone: "good",
      }],
    });
    return true;
  },
  continueRoguelikeRound: () => {
    const state = get();
    if (state.phase !== "intermission" || isRunBuffInputLocked(state) || state.draftBuffIds.length > 0) return false;
    const round = state.round + 1;
    const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
    const bossIds = state.runMode === "rogue-trials"
      ? selectRogueTrialsBosses(round, state.seenBossIds)
      : selectRandomBossPair(previousBossIds);
    set({
      ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, state.runMode, round, state.runBuffRanks, state.gearProgress, state.difficultySlug, state.seenBossIds),
      phase: "combat",
      activeTab: "combat",
      combatLog: [{
        id: Date.now(),
        time: 0,
        message: `All blessings mastered. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
        tone: "good",
      }],
    });
    return true;
  },
  startRogueTrialsEndless: () => {
    const state = get();
    if (state.runMode !== "rogue-trials"
      || state.round !== ROGUE_TRIALS_TRIO_ROUND
      || state.phase !== "victory"
      || state.endlessMode) return false;
    const bossIds = selectRogueTrialsBosses(ROGUE_TRIALS_TRIO_ROUND, []);
    const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
    const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
    const encounterBosses = bossIds.map((bossId, index) => {
      const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier);
      return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
    });
    const primary = encounterBosses[0];
    set({
      bossId: primary.boss.id,
      bossInstanceId: primary.instanceId,
      boss: primary.boss,
      bossMotion: primary.motion,
      additionalBosses: encounterBosses.slice(1),
      phase: "combat",
      endlessMode: true,
      endlessBossKills: 0,
      endlessSpawnSequence: encounterBosses.length,
      partyCombat: createPartyCombatState(state.party),
      partyDamageEvents: [],
      partyPositions: freshPartyPositions(bossIds),
      playerPosition: [0, 4.5],
      activeCast: null,
      activeTab: "combat",
      combatLog: [{
        id: Date.now(),
        time: state.time,
        message: `${encounterBosses.map((entry) => entry.boss.name).join(", ")} enter the endless trial.`,
        tone: "danger",
      }],
    });
    return true;
  },
  setEndlessChoiceSelection: (endlessChoiceSelection) => set({ endlessChoiceSelection }),
  setPlayerPosition: (playerPosition) => set((state) => {
    playerPosition = clampToArena(playerPosition);
    const current = state.playerPosition;
    const partyCurrent = state.partyPositions.aelia;
    if (current[0] === playerPosition[0]
      && current[1] === playerPosition[1]
      && partyCurrent[0] === playerPosition[0]
      && partyCurrent[1] === playerPosition[1]) {
      return state;
    }
    return {
      playerPosition,
      partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
    };
  }),

  castAbility: (abilityId) => {
    const state = get();
    if (state.phase !== "combat") return false;
    if (state.paused) return false;
    if (state.activeCast) return false;
    const healer = state.party.find((member) => member.id === "aelia");
    if (!healer || healer.hp <= 0) return false;

    const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
    const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers);
    const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId);
    const selected = state.party[selectedIndex];

    if (state.cooldowns[abilityId] > state.time + 0.01) return false;
    if (state.globalCooldownUntil > state.time + 0.001) return false;
    if (state.mana < manaCost) {
      set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") });
      return false;
    }
    if (ability.targeting === "ally" && (!selected || selected.hp <= 0)) return false;
    if (abilityId === "purify" && selected.debuffs.length === 0) {
      set({ combatLog: addLog(state.combatLog, state.time, `${selected.name} has nothing to ${ability.name}.`) });
      return false;
    }

    if (abilityId === "mend") {
      set({
        activeCast: {
          abilityId: "mend",
          targetId: selected.id,
          startedAt: state.time,
          completesAt: state.time + runAbilityCastTime("mend", ability.castTime ?? 0.5, state.runModifiers),
        },
        mana: Math.max(0, state.mana - manaCost),
        globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
        combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`),
      });
      return true;
    }

    let party = state.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
    let message = ability.name;
    let barrier = state.barrier;
    let bossMotion = state.bossMotion;
    let additionalBosses = state.additionalBosses;

    switch (abilityId) {
      case "renew":
        applyRenewAt(party, selectedIndex, state.time, state.runModifiers);
        for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) {
          applyRenewAt(party, index, state.time, state.runModifiers);
        }
        message = `${ability.name} placed on ${selected.name}.`;
        break;
      case "shield": {
        const amount = addHealerAbsorb(party, selectedIndex, 36, state.gearModifiers.aelia.healingPower, state.runModifiers);
        for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) {
          addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers);
        }
        message = `${selected.name} gains ${Math.round(amount)} absorption.`;
        break;
      }
      case "purify": {
        const cleanseIndexes = [
          selectedIndex,
          ...lowestHealthIndexes(party, selectedIndex, state.runModifiers.purifyExtraTargets, (member) => member.debuffs.length > 0),
        ];
        const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
        for (const index of cleanseIndexes) {
          const target = party[index];
          const dispelledDebuffs = target.debuffs;
          const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
          bossMotion = primaryDispel.motion;
          additionalBosses = additionalBosses.map((entry) => {
            const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
            return { ...entry, motion: dispel.motion };
          });
          party[index] = { ...party[index], debuffs: [] };
          if (state.runModifiers.purifyAppliesRenew) applyRenewAt(party, index, state.time, state.runModifiers);
          if (state.runModifiers.purifyAppliesShield) {
            addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers);
          }
        }
        message = primaryNames.includes("Widow Venom")
          ? "Widow Venom purged. A venom pool forms where the target stood."
          : `${primaryNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`;
        break;
      }
      case "radiance":
        party = party.map((member) => healMember(member, 22 * state.healingMultiplier));
        if (state.runModifiers.radianceAppliesRenew) {
          for (let index = 0; index < party.length; index += 1) applyRenewAt(party, index, state.time, state.runModifiers);
        }
        if (state.runModifiers.radianceAbsorb > 0) {
          for (let index = 0; index < party.length; index += 1) {
            addHealerAbsorb(party, index, state.runModifiers.radianceAbsorb, state.gearModifiers.aelia.healingPower, state.runModifiers);
          }
        }
        message = `${ability.name} heals the full party.`;
        break;
      case "barrier":
        barrier = {
          center: [...state.partyPositions.aelia],
          expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus,
          nextHealAt: state.time + 1,
        };
        message = `${ability.name} protects a 3m circle for ${8 + state.runModifiers.barrierDurationBonus} seconds.`;
        break;
    }

    const cooldowns = {
      ...state.cooldowns,
      [abilityId]: ability.cooldown > 0
        ? state.time + runAbilityCooldown(abilityId, ability.cooldown, state.runModifiers) * state.gearModifiers.aelia.cooldown
        : 0,
    };
    const pulse: ScenePulse = {
      id: state.scenePulse.id + 1,
      kind: abilityId,
      targetId: abilityId === "barrier" ? "aelia" : selected?.id,
    };

    set({
      party,
      cooldowns,
      globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
      barrier,
      bossMotion,
      additionalBosses,
      mana: Math.max(0, state.mana - manaCost),
      combatLog: addLog(state.combatLog, state.time, message, "good"),
      scenePulse: pulse,
    });
    return true;
  },

  tick: (delta) => {
    const state = get();
    if (state.phase !== "combat" || state.paused || delta <= 0) return;

    const oldTime = state.time;
    const time = oldTime + Math.min(delta, 2);
    // Debuffs are copied only by the periodic-debuff pass below. Copying them here
    // as well doubled short-lived allocations for every simulation step.
    let party = state.party.map((member) => ({ ...member }));
    let boss = { ...state.boss };
    let bossInstanceId = state.bossInstanceId;
    let bossMotion = { ...state.bossMotion };
    let additionalBosses = state.additionalBosses.map((entry) => ({
      ...entry,
      boss: { ...entry.boss },
      motion: cloneMotion(entry.motion),
    }));
    let partyPositions = state.partyPositions;
    let combatLog = state.combatLog;
    let pulse = state.scenePulse;
    let activeCast = state.activeCast ? { ...state.activeCast } : null;
    let partyCombat = state.partyCombat;
    let partyDamageEvents = state.partyDamageEvents;
    let barrier = { ...state.barrier };
    let endlessBossKills = state.endlessBossKills;
    let endlessSpawnSequence = state.endlessSpawnSequence;

    if (!party.some((member) => member.id === "aelia" && member.hp > 0)) activeCast = null;

    if (state.endlessMode) {
      const slots: AdditionalBossState[] = [
        { instanceId: bossInstanceId, boss, motion: bossMotion },
        ...additionalBosses,
      ];
      for (let index = 0; index < slots.length; index += 1) {
        if (slots[index].boss.hp > 0) continue;
        const defeatedAt = slots[index].boss.defeatedAt ?? oldTime;
        slots[index].boss.defeatedAt = defeatedAt;
        if (time < defeatedAt + BOSS_DEATH_DESPAWN_SECONDS) continue;
        const activeBossIds = slots
          .filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
          .map((entry) => entry.boss.id);
        const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds], Math.random, activeBossIds)[0];
        endlessSpawnSequence += 1;
        const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
        const replacement = createEncounterBoss(
          replacementId,
          index,
          slots.length,
          bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
        );
        slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
        combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
      }
      boss = slots[0].boss;
      bossInstanceId = slots[0].instanceId;
      bossMotion = slots[0].motion;
      additionalBosses = slots.slice(1);
    }

    if (activeCast && activeCast.completesAt <= time) {
      const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
      const target = party[targetIndex];
      if (target?.hp > 0) {
        const healing = 38 * state.healingMultiplier;
        party[targetIndex] = healMember(target, healing);
        for (const index of lowestHealthIndexes(party, targetIndex, state.runModifiers.mendExtraTargets)) {
          party[index] = healMember(party[index], healing * 0.5);
        }
        const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
        combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good");
        pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id };
      }
      activeCast = null;
    }

    party = party.map((member) => {
      let next = member;
      if (next.renewExpiresAt > oldTime && next.renewNextTickAt <= time) {
        let tickAt = next.renewNextTickAt;
        const lastTickAt = Math.min(time, next.renewExpiresAt);
        while (tickAt <= lastTickAt + 0.001) {
          next = healMember(next, 7 * state.healingMultiplier * state.runModifiers.renewHealingMultiplier);
          tickAt += 1;
        }
        next = {
          ...next,
          renewExpiresAt: time >= next.renewExpiresAt ? 0 : next.renewExpiresAt,
          renewNextTickAt: time >= next.renewExpiresAt ? 0 : tickAt,
        };
      }

      const activeDebuffs = next.debuffs
        .map((debuff) => {
          let updated = { ...debuff };
          while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) {
            next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, "direct", state.runModifiers.shieldDamageTakenMultiplier);
            updated.nextTickAt += 1;
          }
          return updated;
        })
        .filter((debuff) => debuff.expiresAt > time);
      return { ...next, debuffs: activeDebuffs };
    });

    if (state.runModifiers.barrierHealingPerSecond > 0) {
      while (barrier.nextHealAt <= time && barrier.nextHealAt < barrier.expiresAt) {
        const pulseAt = barrier.nextHealAt;
        const healing = state.runModifiers.barrierHealingPerSecond * state.healingMultiplier;
        party = party.map((member) => barrierProtects(state.partyPositions[member.id], barrier, pulseAt)
          ? healMember(member, healing)
          : member);
        barrier.nextHealAt += 1;
      }
    }

    const livingMotions = [
      ...(boss.hp > 0 ? [bossMotion] : []),
      ...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
    ];
    partyPositions = updatePartyPositions(state.partyPositions, livingMotions, party, time, time - oldTime, undefined, {
      brann: state.gearModifiers.brann.moveSpeed,
      nia: state.gearModifiers.nia.moveSpeed,
      orin: state.gearModifiers.orin.moveSpeed,
      vale: state.gearModifiers.vale.moveSpeed,
    });

    const encounterBosses: AdditionalBossState[] = [
      { instanceId: bossInstanceId, boss, motion: bossMotion },
      ...additionalBosses,
    ];
    for (let index = 0; index < encounterBosses.length; index += 1) {
      const encounterBoss = encounterBosses[index];
      if (encounterBoss.boss.hp <= 0) continue;
      const partyBeforeMechanic = party;
      const mechanicResult = advanceBossMechanics({
        boss: encounterBoss.boss,
        motion: encounterBoss.motion,
        party,
        partyPositions,
        time,
        delta: time - oldTime,
        damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind, state.runModifiers.shieldDamageTakenMultiplier),
      });
      encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
      party = mechanicResult.party.map((member) => {
        const previous = partyBeforeMechanic.find((candidate) => candidate.id === member.id);
        if (!previous || member.knockedUntil <= previous.knockedUntil || member.knockedUntil <= time) return member;
        return { ...member, knockedUntil: time + (member.knockedUntil - time) * state.gearModifiers[member.id].stunDuration };
      });
      for (const event of mechanicResult.events) {
        combatLog = addLog(combatLog, event.at, event.message, event.tone);
        if (event.pulseKind) {
          pulse = { id: pulse.id + 1, kind: event.pulseKind, targetId: event.targetId };
        }
      }
    }

    const mechanicRemaining = encounterBosses
      .filter((entry) => entry.boss.hp > 0)
      .map((entry) => upcomingMechanic(entry.boss, entry.motion, time).remaining);
    const partyCombatResult = advancePartyCombat(partyCombat, {
      oldTime,
      time,
      party,
      oldPositions: state.partyPositions,
      positions: partyPositions,
      targets: encounterBosses,
      upcomingMechanicRemaining: mechanicRemaining.length ? Math.min(...mechanicRemaining) : Number.POSITIVE_INFINITY,
      gearModifiers: aiCombatModifiers(state.gearModifiers),
    });
    partyCombat = partyCombatResult.state;
    partyDamageEvents = [...partyCombatResult.events].reverse().concat(partyDamageEvents).slice(0, 24);
    for (const event of partyCombatResult.events) {
      const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId);
      if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
    }
    for (const entry of encounterBosses) {
      if (entry.boss.hp <= 0 && entry.boss.defeatedAt === undefined) entry.boss.defeatedAt = time;
    }
    boss = encounterBosses[0].boss;
    bossInstanceId = encounterBosses[0].instanceId;
    bossMotion = encounterBosses[0].motion;
    additionalBosses = encounterBosses.slice(1);
    const healer = party.find((member) => member.id === "aelia")!;
    if (healer.hp <= 0) activeCast = null;
    const partyWiped = isPartyWiped(party);
    let phase: GamePhase = state.phase;
    let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
    let newlyDefeatedBossCount = 0;
    if (state.endlessMode) {
      for (let index = 0; index < encounterBosses.length; index += 1) {
        const current = encounterBosses[index];
        const previous = index === 0
          ? { instanceId: state.bossInstanceId, boss: state.boss }
          : state.additionalBosses[index - 1];
        if (current.boss.hp > 0 || previous?.instanceId !== current.instanceId || previous.boss.hp <= 0) continue;
        newlyDefeatedBossCount += 1;
        combatLog = addLog(combatLog, time, `${current.boss.name} falls. Endless kill ${endlessBossKills + newlyDefeatedBossCount}.`, "good");
      }
    }
    endlessBossKills += newlyDefeatedBossCount;
    if (state.endlessMode && partyWiped) {
      phase = "defeat";
      combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the party fell.`, "danger");
    } else if (state.endlessMode) {
      phase = "combat";
    } else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
      const rogueTrialsComplete = state.runMode === "rogue-trials" && state.round === ROGUE_TRIALS_TRIO_ROUND;
      phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
      if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
      combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
    } else if (partyWiped) {
      phase = "defeat";
      combatLog = addLog(combatLog, time, `The party falls. ${boss.name} claims the vault.`, "danger");
    }

    set({
      time,
      party,
      bossId: boss.id,
      bossInstanceId,
      boss,
      additionalBosses,
      partyCombat,
      partyDamageEvents,
      partyPositions,
      bossMotion,
      phase,
      endlessBossKills,
      endlessSpawnSequence,
      runBuffInputUnlockAt,
      mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
      activeCast,
      combatLog,
      scenePulse: pulse,
      barrier,
    });
  },
}));

export type GameSnapshot = Omit<GameState,
  | "configureHealer"
  | "startEncounter"
  | "restart"
  | "tick"
  | "castAbility"
  | "selectMember"
  | "cycleMember"
  | "setActiveTab"
  | "selectItem"
  | "setPlayerPosition"
  | "setPaused"
  | "togglePause"
  | "setPauseSelection"
  | "setSelectedRunBuff"
  | "chooseRunBuff"
  | "continueRoguelikeRound"
  | "startRogueTrialsEndless"
  | "setEndlessChoiceSelection"
>;

export function getGameSnapshot(): GameSnapshot {
  const {
    configureHealer: _configureHealer,
    startEncounter: _startEncounter,
    restart: _restart,
    tick: _tick,
    castAbility: _castAbility,
    selectMember: _selectMember,
    cycleMember: _cycleMember,
    setActiveTab: _setActiveTab,
    selectItem: _selectItem,
    setPlayerPosition: _setPlayerPosition,
    setPaused: _setPaused,
    togglePause: _togglePause,
    setPauseSelection: _setPauseSelection,
    setSelectedRunBuff: _setSelectedRunBuff,
    chooseRunBuff: _chooseRunBuff,
    continueRoguelikeRound: _continueRoguelikeRound,
    startRogueTrialsEndless: _startRogueTrialsEndless,
    setEndlessChoiceSelection: _setEndlessChoiceSelection,
    ...snapshot
  } = useGameStore.getState();
  return snapshot;
}

export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns: Record<AbilityId, number>) {
  return Math.max(0, cooldowns[abilityId] - time);
}

export function isRunBuffInputLocked(
  state: Pick<GameState, "phase" | "runBuffInputUnlockAt">,
  now = Date.now(),
) {
  return state.phase === "intermission" && now < state.runBuffInputUnlockAt;
}

export { upcomingMechanic };

export function upcomingEncounterMechanic(state: Pick<GameState, "boss" | "bossMotion" | "additionalBosses" | "time">) {
  const candidates = [
    ...(state.boss.hp > 0 ? [{ boss: state.boss, motion: state.bossMotion }] : []),
    ...state.additionalBosses.filter((entry) => entry.boss.hp > 0),
  ].map((entry) => upcomingMechanic(entry.boss, entry.motion, state.time));
  return candidates.reduce((next, candidate) => candidate.remaining < next.remaining ? candidate : next, candidates[0]);
}
