import { describe, expect, it } from "vitest";
import { createBossMotionState, createBossState } from "./bossMechanics";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { freshParty } from "./data";
import {
  advancePartyCombat,
  createPartyCombatState,
  PARTY_ABILITY_LOADOUTS,
  tankAuraProtects,
  type AiCombatantId,
  type PartyAbilityId,
  type PartyCombatTarget,
} from "./partyCombat";
import type { BossId, MemberId, PartyMember, WorldPosition } from "./types";

const BASE_POSITIONS: Record<MemberId, WorldPosition> = {
  aelia: [0, 4.5],
  brann: [0, 0],
  nia: [-3, 2],
  orin: [3, 2],
  vale: [0, -2],
};

interface SimulationOptions {
  duration?: number;
  movingIds?: readonly AiCombatantId[];
  activeIds?: readonly AiCombatantId[];
  targetPositions?: readonly WorldPosition[];
  stopOnVictory?: boolean;
  upcomingMechanicRemaining?: number;
}

function createTargets(bossIds: readonly BossId[], positions?: readonly WorldPosition[]): PartyCombatTarget[] {
  return bossIds.map((bossId, index) => {
    const motion = createBossMotionState(bossId);
    motion.position = positions?.[index]
      ? [positions[index][0], positions[index][1]]
      : bossIds.length > 1
        ? [index === 0 ? -2.4 : 2.4, -4]
        : [0, -4];
    return { instanceId: `sim-${index}-${bossId}`, boss: createBossState(bossId), motion };
  });
}

function simulate(bossIds: readonly BossId[], options: SimulationOptions = {}) {
  const activeIds = options.activeIds ?? ["brann", "nia", "orin", "vale"];
  const party = freshParty().map((member) => member.id === "aelia" || activeIds.includes(member.id as AiCombatantId)
    ? member
    : { ...member, hp: 0 });
  let combat = createPartyCombatState(party);
  const targets = createTargets(bossIds, options.targetPositions);
  const damageBySource: Partial<Record<AiCombatantId, number>> = {};
  const usedAbilities = new Set<PartyAbilityId>();
  const secondaryEvents: PartyAbilityId[] = [];
  const step = 0.1;
  let time = 0;

  while (time < (options.duration ?? 180)) {
    const nextTime = Number((time + step).toFixed(4));
    const oldPositions = structuredClone(BASE_POSITIONS);
    const positions = structuredClone(BASE_POSITIONS);
    for (const memberId of options.movingIds ?? []) oldPositions[memberId] = [positions[memberId][0] - 0.1, positions[memberId][1]];
    const result = advancePartyCombat(combat, {
      oldTime: time,
      time: nextTime,
      party,
      oldPositions,
      positions,
      targets,
      upcomingMechanicRemaining: options.upcomingMechanicRemaining ?? Number.POSITIVE_INFINITY,
    });
    combat = result.state;
    for (const actor of Object.values(combat.combatants)) {
      if (actor.visualAction) usedAbilities.add(actor.visualAction.abilityId);
    }
    for (const event of result.events) {
      const target = targets.find((entry) => entry.instanceId === event.targetInstanceId)!;
      target.boss.hp = Math.max(0, target.boss.hp - event.amount);
      damageBySource[event.sourceId] = (damageBySource[event.sourceId] ?? 0) + event.amount;
      if (event.secondary) secondaryEvents.push(event.abilityId);
    }
    time = nextTime;
    if (options.stopOnVictory !== false && targets.every((target) => target.boss.hp <= 0)) break;
  }

  return { time, targets, combat, damageBySource, usedAbilities, secondaryEvents };
}

describe("party ability combat", () => {
  it("defines five unique ability slots for every AI party class", () => {
    for (const loadout of Object.values(PARTY_ABILITY_LOADOUTS)) {
      expect(loadout).toHaveLength(5);
      expect(new Set(loadout).size).toBe(5);
    }
  });

  it("does not damage a boss before an ability impact lands", () => {
    const result = simulate(["bulldrome"], { duration: 0.1, stopOnVictory: false });
    expect(result.targets[0].boss.hp).toBe(result.targets[0].boss.maxHp);
    expect(Object.values(result.damageBySource)).toHaveLength(0);
  });

  it("reduces ranged damage while moving without reducing it to zero", () => {
    const stationary = simulate(["bulldrome"], { duration: 30, activeIds: ["nia", "orin"], stopOnVictory: false });
    const moving = simulate(["bulldrome"], { duration: 30, activeIds: ["nia", "orin"], movingIds: ["nia", "orin"], stopOnVictory: false });
    const stationaryDamage = (stationary.damageBySource.nia ?? 0) + (stationary.damageBySource.orin ?? 0);
    const movingDamage = (moving.damageBySource.nia ?? 0) + (moving.damageBySource.orin ?? 0);
    expect(movingDamage).toBeGreaterThan(0);
    expect(movingDamage).toBeLessThan(stationaryDamage * 0.7);
  });

  it("lets Vale cleave only when both bosses are inside melee radius", () => {
    const clustered = simulate(["broodfang-spider", "tempestscale-dragon"], { duration: 30, activeIds: ["vale"], stopOnVictory: false });
    const separated = simulate(["broodfang-spider", "tempestscale-dragon"], {
      duration: 30,
      activeIds: ["vale"],
      targetPositions: [[0, -4], [7, -4]],
      stopOnVictory: false,
    });
    expect(clustered.secondaryEvents.length).toBeGreaterThan(0);
    expect(separated.secondaryEvents).toHaveLength(0);
    expect(clustered.damageBySource.vale).toBeGreaterThan(separated.damageBySource.vale ?? 0);
  });

  it("activates Brann's moving six-second Bulwark aura before incoming damage", () => {
    const result = simulate(["bulldrome"], { duration: 0.1, activeIds: ["brann"], stopOnVictory: false, upcomingMechanicRemaining: 1 });
    expect(result.combat.combatants.brann.visualAction?.abilityId).toBe("bulwark_march");
    expect(result.combat.tankAura.expiresAt).toBe(6);
    expect(tankAuraProtects([2.9, 0], [0, 0], result.combat.tankAura, 5.9)).toBe(true);
    expect(tankAuraProtects([3.1, 0], [0, 0], result.combat.tankAura, 5.9)).toBe(false);
    expect(tankAuraProtects([0, 0], [0, 0], result.combat.tankAura, 6)).toBe(false);
  });

  it("uses every button in each five-ability DPS rotation during a full fight", () => {
    const result = simulate(["bulldrome", "broodfang-spider"]);
    const movementPhase = simulate(["bulldrome"], { duration: 5, activeIds: ["nia"], movingIds: ["nia"], stopOnVictory: false });
    const usedAbilities = new Set([...result.usedAbilities, ...movementPhase.usedAbilities]);
    for (const memberId of ["nia", "orin", "vale"] as const) {
      expect([...usedAbilities]).toEqual(expect.arrayContaining([...PARTY_ABILITY_LOADOUTS[memberId]]));
    }
  });
});

describe("dual-boss damage simulations", () => {
  const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
    AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const),
  );

  it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
    const result = simulate([first, second]);
    expect(result.targets.every((target) => target.boss.hp <= 0)).toBe(true);
    expect(result.time).toBeGreaterThan(35);
    expect(result.time).toBeLessThan(150);
    for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(result.damageBySource[memberId]).toBeGreaterThan(0);
  });
});
