import { describe, expect, it } from "vitest";
import type { HealerAbilityId } from "../types";
import {
  MAX_ACTIVE_ROSTER,
  MAX_RUN_GEAR_ENHANCEMENT,
  MAX_SPELL_RANK,
  PARTY_RECRUITS_PER_WAVE,
  PARTY_RARITY_ORDER,
  TOTAL_BOSS_COUNT,
  abilityLoadoutFromSpellIds,
  applyRewardChoice,
  assignRosterToCombatSlots,
  autoEquipRunGear,
  challengeObjective,
  compareRunGear,
  createRandomState,
  createRpgRoguelikeRun,
  createRunGearItem,
  equippedRunGear,
  generateRewardChest,
  generateRunShop,
  nextPartyRarity,
  reduceRpgRoguelikeRun,
  selectCurrentBossId,
  type PartyRosterMember,
  type RewardChoice,
  type RpgRoguelikeAction,
  type RpgRoguelikeRunState,
  type RunGearItem,
} from ".";

function dispatch(state: RpgRoguelikeRunState, action: RpgRoguelikeAction) {
  return reduceRpgRoguelikeRun(state, action);
}

function finishDrafts(seed: number | string = 42): RpgRoguelikeRunState {
  let state = createRpgRoguelikeRun({ seed });
  while (state.phase === "party-draft") {
    const draft = state.partyDraft!;
    const tank = draft.offers.find((offer) => offer.role === "Tank");
    const ordered = tank ? [tank, ...draft.offers.filter((offer) => offer !== tank)] : draft.offers;
    for (const offer of ordered) {
      if (state.roster.length >= MAX_ACTIVE_ROSTER) break;
      if ((state.partyDraft?.recruitedThisWaveIds.length ?? 0) >= PARTY_RECRUITS_PER_WAVE) break;
      state = dispatch(state, { type: "party-recruit", candidateId: offer.candidateId });
    }
    state = dispatch(state, { type: "party-next-wave" });
  }
  while (state.phase === "spell-draft") {
    for (const spellId of state.spellDraft!.offers.slice(0, 2)) state = dispatch(state, { type: "spell-pick", spellId });
    state = dispatch(state, { type: "spell-next-wave" });
  }
  return state;
}

function usableReward(state: RpgRoguelikeRunState, choice: RewardChoice) {
  if (choice.kind === "spell-rank") return state.selectedSpellIds.includes(choice.spellId)
    && choice.nextRank > (state.spellRanks[choice.spellId] ?? 0)
    && choice.nextRank <= MAX_SPELL_RANK;
  if (choice.kind === "member-rarity") {
    const member = state.roster.find((candidate) => candidate.instanceId === choice.memberId);
    return Boolean(member && nextPartyRarity(member.rarity) === choice.nextRarity);
  }
  if (choice.kind === "run-gear") {
    const current = equippedRunGear(state.equipment, choice.item.ownerId, choice.item.slotId)?.enhancement ?? -1;
    return choice.item.enhancement > current && choice.item.enhancement <= MAX_RUN_GEAR_ENHANCEMENT;
  }
  return choice.amount > 0;
}

describe("RPG Roguelike deterministic domain", () => {
  it("replays identical offers, route, state, and RNG cursor from one seed", () => {
    const left = finishDrafts("deterministic-run");
    const right = finishDrafts("deterministic-run");
    expect(left).toEqual(right);
    expect(left.random.draws).toBeGreaterThan(0);
    expect(JSON.parse(JSON.stringify(left))).toEqual(left);
  });

  it("enforces party wave/global caps, supports replacement, and guarantees tank-capable offers", () => {
    let state = createRpgRoguelikeRun({ seed: 7 });
    expect(state.partyDraft!.offers).toHaveLength(5);
    expect(state.partyDraft!.offers.some((offer) => offer.role === "Tank")).toBe(true);
    const firstFour = state.partyDraft!.offers.slice(0, 4);
    for (const offer of firstFour) state = dispatch(state, { type: "party-recruit", candidateId: offer.candidateId });
    expect(state.roster).toHaveLength(3);

    const removed = state.roster[0];
    expect(dispatch(state, { type: "party-remove", memberId: removed.instanceId })).toBe(state);
    state = dispatch(state, { type: "party-next-wave" });
    expect(state.partyDraft!.waveIndex).toBe(1);
    expect(state.partyDraft!.offers.some((offer) => offer.role === "Tank")).toBe(true);

    state = dispatch(state, { type: "party-recruit", candidateId: state.partyDraft!.offers[0].candidateId });
    expect(state.roster).toHaveLength(4);
    state = dispatch(state, { type: "party-remove", memberId: removed.instanceId });
    expect(state.roster).toHaveLength(3);
    expect(state.partyDraft!.recruitedThisWaveIds).toHaveLength(1);
    state = dispatch(state, { type: "party-recruit", candidateId: state.partyDraft!.offers[1].candidateId });
    expect(state.roster).toHaveLength(4);
    const before = state;
    state = dispatch(state, { type: "party-recruit", candidateId: state.partyDraft!.offers[2].candidateId });
    expect(state).toBe(before);
  });

  it("assigns a drafted tank to Brann while preserving four distinct instances", () => {
    const state = finishDrafts(19);
    const assignment = assignRosterToCombatSlots(state.roster);
    expect(assignment.brann?.role).toBe("Tank");
    expect(Object.values(assignment).filter(Boolean)).toHaveLength(4);
    expect(new Set(Object.values(assignment).map((member) => member?.instanceId))).toHaveLength(4);
  });

  it("will not finalize four companions without a tank", () => {
    let state = createRpgRoguelikeRun({ seed: 91 });
    while (state.phase === "party-draft" && state.partyDraft!.waveIndex < 2) {
      for (const offer of state.partyDraft!.offers) {
        if (state.roster.length >= 4 || state.partyDraft!.recruitedThisWaveIds.length >= 3) break;
        state = dispatch(state, { type: "party-recruit", candidateId: offer.candidateId });
      }
      state = dispatch(state, { type: "party-next-wave" });
    }
    const tankless = {
      ...state,
      roster: state.roster.map((member) => ({ ...member, role: "Damage" as const })),
    };
    expect(tankless.roster).toHaveLength(4);
    expect(dispatch(tankless, { type: "party-next-wave" })).toBe(tankless);
  });

  it("caps spell picks, refunds a current-wave removal, and emits mixed spells in pick order", () => {
    let state = createRpgRoguelikeRun({ seed: 22 });
    while (state.phase === "party-draft") {
      for (const offer of state.partyDraft!.offers) {
        if (state.roster.length >= 4 || state.partyDraft!.recruitedThisWaveIds.length >= 3) break;
        state = dispatch(state, { type: "party-recruit", candidateId: offer.candidateId });
      }
      state = dispatch(state, { type: "party-next-wave" });
    }
    const firstWave = state.spellDraft!.offers;
    state = dispatch(state, { type: "spell-pick", spellId: firstWave[0] });
    state = dispatch(state, { type: "spell-pick", spellId: firstWave[1] });
    expect(dispatch(state, { type: "spell-remove", spellId: firstWave[0] })).toBe(state);
    state = dispatch(state, { type: "spell-next-wave" });
    const offers = state.spellDraft!.offers;
    state = dispatch(state, { type: "spell-pick", spellId: offers[0] });
    state = dispatch(state, { type: "spell-pick", spellId: offers[1] });
    const capped = dispatch(state, { type: "spell-pick", spellId: offers[2] });
    expect(capped).toBe(state);
    state = dispatch(state, { type: "spell-remove", spellId: offers[0] });
    state = dispatch(state, { type: "spell-pick", spellId: offers[2] });
    expect(state.selectedSpellIds).toEqual([firstWave[0], firstWave[1], offers[1], offers[2]]);
    expect(state.abilityLoadout).toEqual({
      ability1: firstWave[0],
      ability2: firstWave[1],
      ability3: offers[2],
      ability4: offers[1],
    });

    const mixed: HealerAbilityId[] = ["priest-mend", "druid-rejuvenation", "shaman-chain-heal"];
    expect(abilityLoadoutFromSpellIds(mixed)).toEqual({
      ability1: "priest-mend",
      ability2: "druid-rejuvenation",
      ability3: "shaman-chain-heal",
    });
  });

  it("builds three acts of three bosses plus a finale, with nine challenges and three shops", () => {
    let state = finishDrafts(9001);
    expect(state.bossRoute).toHaveLength(TOTAL_BOSS_COUNT);
    let challenges = 0;
    let shops = 0;
    let safety = 0;
    while (state.phase !== "victory" && safety++ < 200) {
      if (state.phase === "challenge-briefing") {
        challenges += 1;
        state = dispatch(state, { type: "challenge-start" });
      } else if (state.phase === "challenge-active") {
        state = dispatch(state, { type: "challenge-complete", metrics: { bricksBroken: 999, bossKills: 999, kills: 999 } });
      } else if (state.phase === "boss-briefing") {
        expect(selectCurrentBossId(state)).not.toBeNull();
        state = dispatch(state, { type: "boss-start" });
      } else if (state.phase === "boss-combat") {
        state = dispatch(state, { type: "boss-won" });
      } else if (state.phase === "boss-cleared") {
        state = dispatch(state, { type: "reward-open" });
      } else if (state.phase === "reward") {
        state = dispatch(state, { type: "reward-choose", choiceId: state.pendingReward!.choices[0].id });
      } else if (state.phase === "shop") {
        shops += 1;
        state = dispatch(state, { type: "shop-leave" });
      } else {
        throw new Error(`Unexpected phase ${state.phase}`);
      }
    }
    expect(state.phase).toBe("victory");
    expect(state.bossesDefeated).toBe(10);
    expect(challenges).toBe(9);
    expect(shops).toBe(3);
  });

  it("bounds repeat challenge scaling and treats challenge failure as soft", () => {
    expect(challengeObjective("blockbreaker", 0).target).toBe(30);
    expect(challengeObjective("blockbreaker", 1).target).toBe(45);
    expect(challengeObjective("blockbreaker", 2).target).toBe(60);
    expect(challengeObjective("blockbreaker", 99).target).toBe(60);
    expect(challengeObjective("hockey", 99).target).toBe(3);

    let state = finishDrafts(77);
    const currency = state.currency;
    state = dispatch(state, { type: "challenge-start" });
    state = dispatch(state, { type: "challenge-complete" });
    expect(state.phase).toBe("boss-briefing");
    expect(state.lastChallengeResult?.succeeded).toBe(false);
    expect(state.currency).toBe(currency);
    expect(state.nextChestQualityBonus).toBe(0);

    let simultaneous = finishDrafts(78);
    simultaneous = dispatch(simultaneous, { type: "challenge-start" });
    const objective = simultaneous.currentChallenge!.objective;
    const metrics = objective.metric === "bricksBroken"
      ? { bricksBroken: objective.target }
      : objective.metric === "bossKills"
        ? { bossKills: objective.target }
        : { kills: objective.target };
    simultaneous = dispatch(simultaneous, { type: "challenge-complete", metrics, forcedFailure: true });
    expect(simultaneous.lastChallengeResult?.succeeded).toBe(false);
    expect(simultaneous.nextChestQualityBonus).toBe(0);
  });

  it("generates exactly three distinct, usable chest choices and applies the selected upgrade", () => {
    const state = finishDrafts(300);
    const generated = generateRewardChest(createRandomState(1), state, 2);
    expect(generated.chest.choices).toHaveLength(3);
    expect(new Set(generated.chest.choices.map((choice) => choice.id))).toHaveLength(3);
    expect(generated.chest.choices.every((choice) => usableReward(state, choice))).toBe(true);
    const choice = generated.chest.choices[0];
    const applied = applyRewardChoice(state, choice);
    if (choice.kind === "spell-rank") expect(applied.spellRanks[choice.spellId]).toBe(1);
    else if (choice.kind === "member-rarity") {
      expect(applied.roster.find((member) => member.instanceId === choice.memberId)?.rarity).toBe(choice.nextRarity);
    } else if (choice.kind === "run-gear") {
      expect(equippedRunGear(applied.equipment, choice.item.ownerId, choice.item.slotId)).toEqual(choice.item);
    } else expect(applied.currency).toBe(state.currency + choice.amount);
  });

  it("caps run-only gear at +5 and keeps displaced gear in the sellable bag", () => {
    const lower = createRunGearItem("lower", "player", "weapon", 2);
    const capped = createRunGearItem("capped", "player", "weapon", 99);
    expect(capped.enhancement).toBe(5);
    const first = autoEquipRunGear({}, [], lower);
    const second = autoEquipRunGear(first.equipment, first.bag, capped);
    expect(equippedRunGear(second.equipment, "player", "weapon")?.enhancement).toBe(5);
    expect(second.bag).toEqual([lower]);
    const weaker = createRunGearItem("weaker", "player", "weapon", 4);
    const third = autoEquipRunGear(second.equipment, second.bag, weaker);
    expect(third.equipped).toBe(false);
    expect(third.bag).toContain(weaker);
  });

  it("describes current and replacement gear buffs using combat-facing stat names", () => {
    const playerWeapon = createRunGearItem("player-current", "player", "weapon", 2);
    const playerUpgrade = createRunGearItem("player-upgrade", "player", "weapon", 5);
    const companionUpgrade = createRunGearItem("companion-upgrade", "tank-instance", "weapon", 3);
    const armor = createRunGearItem("companion-armor", "tank-instance", "armor", 1);
    const trinket = createRunGearItem("companion-trinket", "tank-instance", "trinket", 4);
    const equipment = { player: { weapon: playerWeapon } };

    expect(compareRunGear(equipment, playerUpgrade)).toEqual({
      currentItem: playerWeapon,
      effectLabel: "Healing power",
      currentValue: 12,
      replacementValue: 24,
      delta: 12,
    });
    expect(compareRunGear(equipment, companionUpgrade)).toMatchObject({
      currentItem: undefined,
      effectLabel: "Damage",
      currentValue: 0,
      replacementValue: 16,
      delta: 16,
    });
    expect(compareRunGear(equipment, armor).effectLabel).toBe("Max health");
    expect(compareRunGear(equipment, trinket).effectLabel).toBe("Haste");
  });

  it("supports shop buy, sell, rest, revive, and leave", () => {
    let state = finishDrafts(501);
    const generated = generateRunShop(state.random, state, 1);
    const downId = state.roster[0].instanceId;
    const injuredId = state.roster[1].instanceId;
    const sellable = createRunGearItem("sellable", "player", "armor", 0);
    state = {
      ...state,
      phase: "shop",
      random: generated.random,
      shop: generated.shop,
      currency: 2_000,
      bag: [sellable],
      roster: state.roster.map((member) => member.instanceId === downId
        ? { ...member, hp: 0 }
        : member.instanceId === injuredId
          ? { ...member, hp: 1 }
          : member),
    };
    const offer = state.shop!.offers[0];
    state = dispatch(state, { type: "shop-buy", offerId: offer.id });
    expect(state.shop!.offers[0].sold).toBe(true);
    expect(equippedRunGear(state.equipment, offer.item.ownerId, offer.item.slotId)).toEqual(offer.item);
    const afterBuyCurrency = state.currency;
    state = dispatch(state, { type: "shop-sell", itemId: sellable.id });
    expect(state.currency).toBe(afterBuyCurrency + sellable.sellPrice);
    state = dispatch(state, { type: "shop-rest" });
    expect(state.roster.find((member) => member.instanceId === injuredId)?.hp)
      .toBe(state.roster.find((member) => member.instanceId === injuredId)?.stats.maxHp);
    expect(Object.values(assignRosterToCombatSlots(state.roster)).find((member) => member?.instanceId === injuredId)?.hp)
      .toBe(state.roster.find((member) => member.instanceId === injuredId)?.stats.maxHp);
    state = dispatch(state, { type: "shop-revive", memberId: downId });
    expect(state.roster.find((member) => member.instanceId === downId)!.hp).toBeGreaterThan(0);
    expect(Object.values(assignRosterToCombatSlots(state.roster)).find((member) => member?.instanceId === downId)?.hp)
      .toBe(Math.ceil(state.roster.find((member) => member.instanceId === downId)!.stats.maxHp * 0.5));
    state = dispatch(state, { type: "shop-leave" });
    expect(state.phase).not.toBe("shop");
    expect(state.shop).toBeNull();
  });

  it("persists bounded party health and ends immediately on a boss loss", () => {
    let state = finishDrafts(808);
    state = dispatch(state, { type: "challenge-start" });
    state = dispatch(state, { type: "challenge-complete" });
    state = dispatch(state, { type: "boss-start" });
    const member = state.roster[0];
    state = dispatch(state, {
      type: "boss-lost",
      vitals: [{ instanceId: member.instanceId, hp: -20 }],
    });
    expect(state.phase).toBe("defeat");
    expect(state.roster[0].hp).toBe(0);
    expect(dispatch(state, { type: "reward-open" })).toBe(state);
  });

  it("defines monotonic rarity order and modest archetype upgrades", () => {
    expect(PARTY_RARITY_ORDER).toEqual(["white", "green", "blue", "purple", "gold"]);
    const state = finishDrafts(15);
    const white = state.roster.find((member) => member.rarity === "white") as PartyRosterMember | undefined;
    if (!white) return;
    const choice: RewardChoice = {
      id: "test-rarity",
      kind: "member-rarity",
      memberId: white.instanceId,
      nextRarity: "green",
      label: "upgrade",
    };
    const upgraded = applyRewardChoice(state, choice).roster.find((member) => member.instanceId === white.instanceId)!;
    expect(upgraded.stats.maxHp).toBeGreaterThan(white.stats.maxHp);
    expect(upgraded.stats.singleTarget).toBeGreaterThan(white.stats.singleTarget);
    expect(upgraded.traitIds.length).toBeGreaterThan(white.traitIds.length);

    const defeatedState = {
      ...state,
      roster: state.roster.map((member) => member.instanceId === white.instanceId ? { ...member, hp: 0 } : member),
    };
    const defeatedUpgrade = applyRewardChoice(defeatedState, choice).roster
      .find((member) => member.instanceId === white.instanceId)!;
    expect(defeatedUpgrade.hp).toBe(0);
  });
});
