#include <algorithm>
#include <functional>
#include <set>
#include <string>
#include <vector>

#include "catch.hpp"

#include "ConfigLoader.h"
#include "DisplayName.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "RecipesConfig.h"
#include "SchematicChoiceOption.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "StationBodyComponent.h"
#include "TestConfig.h"
#include "UnlockState.h"

// Zeros the HP of both enemy defence stations and advances one tick so that
// tickDeathsAndLoot fires, triggering the push and schematic choices.
static void killEnemyStations(Simulation& sim)
{
    sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
        [](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health)
        {
            if (faction.isEnemy)
            {
                health.hp = 0.0f;
            }
        });
    sim.tick();
}

// Kills enemy stations and applies the first schematic choice (index 0).
static void killEnemyStationsAndApply(Simulation& sim)
{
    killEnemyStations(sim);
    if (sim.hasSchematicChoicesPending())
    {
        SimulationTestAccess::applySchematicChoice(sim, 0);
    }
}

// Destroys station sets until recipeId is unlocked or maxDestructions is reached.
// Applies schematic choice 0 after each destruction. Returns true if unlocked.
static bool awaitRecipeUnlock(Simulation& sim, const std::string& recipeId,
                              int maxDestructions = 150)
{
    for (int i = 0; i < maxDestructions; ++i)
    {
        if (sim.isRecipeUnlocked(recipeId)) { return true; }
        killEnemyStationsAndApply(sim);
    }
    return sim.isRecipeUnlocked(recipeId);
}

// ---------------------------------------------------------------------------
// ConfigLoader: unlocked_at_start and unlock-group gating on assembler recipes
// ---------------------------------------------------------------------------

TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe_schematic]")
{
    const GameConfig cfg = loadTestConfig();
    const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
        [](const RecipeDef& r) { return r.id == "premium_circuit"; });
    REQUIRE(it != cfg.recipes.recipes.end());
    CHECK(it->unlockedAtStart);
}

TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[recipe_schematic]")
{
    const GameConfig cfg = loadTestConfig();
    const bool granted = std::any_of(cfg.unlocks.groups.begin(), cfg.unlocks.groups.end(),
        [](const UnlockGroupDef& g)
        {
            return std::find(g.recipes.begin(), g.recipes.end(), "quick_circuit") != g.recipes.end();
        });
    CHECK(granted);
}

TEST_CASE("RecipeSchematic: an untagged assembler recipe has unlocked_at_start = false", "[recipe_schematic]")
{
    const GameConfig cfg = loadTestConfig();
    const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
        [](const RecipeDef& r) { return r.id == "circuit_board"; });
    REQUIRE(it != cfg.recipes.recipes.end());
    CHECK_FALSE(it->unlockedAtStart);
}

// ---------------------------------------------------------------------------
// Initial explicit lock state (REQ-LOCK-EXPLICIT)
// ---------------------------------------------------------------------------

TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = -1 is unlocked at game start",
          "[recipe_schematic]")
{
    const Simulation sim(loadTestConfig());
    REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
}

TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 0 is locked at game start",
          "[recipe_schematic]")
{
    const Simulation sim(loadTestConfig());
    REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
}

TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 1 is locked at game start",
          "[recipe_schematic]")
{
    const Simulation sim(loadTestConfig());
    REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
}

// ---------------------------------------------------------------------------
// Implicit unlock graph (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------

TEST_CASE("RecipeSchematic: -1 recipe seeds its output item into the implicit unlock set",
          "[recipe_schematic]")
{
    // premium_circuit is not needed by any ship or module schematic, so it can
    // only reach the implicit set via the -1 recipe seed in Phase 1.
    const Simulation sim(loadTestConfig());
    REQUIRE(sim.isItemUnlocked("premium_circuit"));
}

TEST_CASE("RecipeSchematic: -1 recipe's inputs are in the implicit unlock set",
          "[recipe_schematic]")
{
    // premium_circuit takes circuit_board as input; that item was already
    // implicitly unlocked by ship schematics, so it must remain unlocked.
    const Simulation sim(loadTestConfig());
    REQUIRE(sim.isItemUnlocked("circuit_board"));
}

TEST_CASE("RecipeSchematic: locked recipe's unique input is not in the implicit unlock set",
          "[recipe_schematic]")
{
    // exotic_alloy has unlock_at_station_level = 0 (locked at start) and takes
    // exotic_ore as input. exotic_ore is only reachable through this locked
    // recipe, so it must not appear in the implicit set.
    const Simulation sim(loadTestConfig());
    REQUIRE_FALSE(sim.isItemUnlocked("exotic_ore"));
}

TEST_CASE("RecipeSchematic: locked recipe's output item is not in the implicit unlock set",
          "[recipe_schematic]")
{
    // exotic_alloy is produced only by the locked recipe of the same name and
    // is not needed by any schematic, so neither the item nor the recipe should
    // be unlocked at game start.
    const Simulation sim(loadTestConfig());
    REQUIRE_FALSE(sim.isItemUnlocked("exotic_alloy"));
    REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy"));
}

TEST_CASE("RecipeSchematic: normal implicit unlock is unaffected for untagged assembler recipes",
          "[recipe_schematic]")
{
    // circuit_board carries no unlock_at_station_level and is needed by ships
    // that start unlocked, so it must still be implicitly unlocked.
    const Simulation sim(loadTestConfig());
    REQUIRE(sim.isRecipeUnlocked("circuit_board"));
}

// ---------------------------------------------------------------------------
// Recipe unlocking, one rule for every building (REQ-LOCK-IMPLICIT step 4)
// ---------------------------------------------------------------------------

TEST_CASE("RecipeSchematic: a smelter recipe is unlocked once its output is wanted",
          "[recipe_schematic]")
{
    // Smelter recipes are judged like any other now: iron_ingot's output feeds the
    // circuit board every unlocked ship needs, so the recipe is one the player can run.
    const Simulation sim(loadTestConfig());
    REQUIRE(sim.isRecipeUnlocked("iron_ingot"));
    REQUIRE(sim.isRecipeUnlocked("copper_ingot"));
}

TEST_CASE("RecipeSchematic: a reprocessing recipe is unlocked by one yieldable group",
          "[recipe_schematic]")
{
    // reprocessing_cycle can yield iron_ingot, circuit_board or advanced_alloy. The
    // first two are wanted from the start, so the recipe is runnable even though
    // advanced_alloy is not -- one group it can yield is enough (REQ-LOCK-OUTPUT-POOL).
    const Simulation sim(loadTestConfig());
    REQUIRE(sim.isRecipeUnlocked("reprocessing_cycle"));

    // Being obtainable from the plant is not what unlocks an item, so the group the
    // plant cannot usefully yield stays locked (REQ-BLD-REPROCESSING).
    REQUIRE_FALSE(sim.isItemUnlocked("advanced_alloy"));
}

TEST_CASE("RecipeSchematic: explicit gating outlives a wanted output", "[recipe_schematic]")
{
    // The two halves of the rule are independent. quick_circuit produces circuit_board,
    // which is unlocked from the start, so it passes the yield test outright -- and must
    // still be locked, because its unlock group has not been awarded (REQ-LOCK-EXPLICIT).
    // Were recipe unlocking decided by outputs alone, blueprints and dialogs would hand
    // the player every drop-only recipe whose output happens to be wanted anyway.
    const Simulation sim(loadTestConfig());
    REQUIRE(sim.isItemUnlocked("circuit_board"));
    REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
}

// ---------------------------------------------------------------------------
// Drop pool and station destruction (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------

TEST_CASE("RecipeSchematic: eligible recipe schematic is eventually awarded on station destruction",
          "[recipe_schematic]")
{
    // quick_circuit has unlock_at_station_level = 0 and produces circuit_board
    // (already implicitly unlocked), so it is eligible from the first station
    // destruction. With up to 150 trials it must be awarded at least once.
    Simulation sim(loadTestConfig());
    REQUIRE(awaitRecipeUnlock(sim, "quick_circuit"));
}

TEST_CASE("RecipeSchematic: an implicitly-gated recipe with no unlock group is never awarded",
          "[recipe_schematic]")
{
    // exotic_alloy is in no unlock group and its output/inputs are unreachable
    // via the item graph, so it can never be dropped or implicitly unlocked.
    Simulation sim(loadTestConfig());
    for (int i = 0; i < 50; ++i)
    {
        killEnemyStationsAndApply(sim);
    }
    REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy"));
}

TEST_CASE("RecipeSchematic: recipe with level > destroyed station level is not awarded",
          "[recipe_schematic]")
{
    // advanced_circuit has unlock_at_station_level = 1. Destroying a single
    // level-0 station set must not award it regardless of the RNG outcome.
    Simulation sim(loadTestConfig());
    killEnemyStationsAndApply(sim);
    REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
}

TEST_CASE("RecipeSchematic: recipe with higher level is awarded once eligible station level is reached",
          "[recipe_schematic]")
{
    // After enough destructions to pass station level 1, advanced_circuit must
    // eventually be awarded.
    Simulation sim(loadTestConfig());
    REQUIRE(awaitRecipeUnlock(sim, "advanced_circuit", 300));
}

TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not awarded again",
          "[recipe_schematic]")
{
    Simulation sim(loadTestConfig());
    awaitRecipeUnlock(sim, "quick_circuit");
    REQUIRE(sim.isRecipeUnlocked("quick_circuit"));

    // Destroy 30 more station sets; the recipe is no longer in the pool.
    for (int i = 0; i < 30; ++i)
    {
        killEnemyStationsAndApply(sim);
    }

    REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
}

TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
          "[recipe_schematic]")
{
    Simulation sim(loadTestConfig());

    bool foundRecipeChoice = false;
    for (int i = 0; i < 150 && !foundRecipeChoice; ++i)
    {
        killEnemyStations(sim);
        if (sim.hasSchematicChoicesPending())
        {
            for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
            {
                for (const GrantedSchematic& grant : opt.grantedItems)
                {
                    if (grant.type == SchematicType::Recipe) { foundRecipeChoice = true; break; }
                }
                if (foundRecipeChoice) { break; }
            }
            SimulationTestAccess::applySchematicChoice(sim, 0);
        }
    }
    CHECK(foundRecipeChoice);
}

// ---------------------------------------------------------------------------
// reset() restores initial lock state (REQ-LOCK-EXPLICIT, REQ-CFG-RELOAD)
// ---------------------------------------------------------------------------

TEST_CASE("RecipeSchematic: reset re-locks a previously awarded recipe schematic",
          "[recipe_schematic]")
{
    Simulation sim(loadTestConfig());
    awaitRecipeUnlock(sim, "quick_circuit");
    REQUIRE(sim.isRecipeUnlocked("quick_circuit"));

    sim.reset();

    REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
}

TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items accessible",
          "[recipe_schematic]")
{
    Simulation sim(loadTestConfig());
    sim.reset();

    REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
    REQUIRE(sim.isItemUnlocked("premium_circuit"));
}

// ---------------------------------------------------------------------------
// Unlock dialog: what an award adds (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------

namespace
{

// A miniature config for the preview diff. One ship the player starts with and one they
// can be granted, an assembler recipe feeding each, and a reprocessing recipe with a
// group for either material -- run in a plant that is itself gated. Between them these
// express all three ways an award can add something: a recipe, an output group, and the
// building needed to run either.
//
// Hand-built rather than taken from the test config, which has no gated building and no
// recipe that gains a group, so neither case would be reachable through it.
GameConfig makePreviewConfig()
{
    GameConfig cfg;

    BuildingDef assembler{};
    assembler.id   = "assembler";
    assembler.type = BuildingType::Assembler;
    cfg.buildings.buildings.push_back(assembler);

    BuildingDef plant{};
    plant.id   = "reprocessing_plant";
    plant.type = BuildingType::ReprocessingPlant;
    cfg.buildings.buildings.push_back(plant);

    ShipDef starter{};
    starter.id = "starter";
    starter.schematic.materials.push_back(RecipeIngredient{"alpha", 1});
    cfg.ships.ships.push_back(starter);

    ShipDef late{};
    late.id = "late";
    late.schematic.materials.push_back(RecipeIngredient{"beta", 1});
    cfg.ships.ships.push_back(late);

    RecipeDef makeAlpha{};
    makeAlpha.id       = "make_alpha";
    makeAlpha.building = BuildingType::Assembler;
    makeAlpha.inputs.push_back(RecipeIngredient{"ore", 1});
    makeAlpha.outputGroups.push_back(
        RecipeOutputGroup{{RecipeOutput{"alpha", 1}}, std::nullopt});
    cfg.recipes.recipes.push_back(makeAlpha);

    RecipeDef makeBeta{};
    makeBeta.id       = "make_beta";
    makeBeta.building = BuildingType::Assembler;
    makeBeta.inputs.push_back(RecipeIngredient{"ore", 1});
    makeBeta.outputGroups.push_back(
        RecipeOutputGroup{{RecipeOutput{"beta", 1}}, std::nullopt});
    cfg.recipes.recipes.push_back(makeBeta);

    RecipeDef reprocess{};
    reprocess.id       = "reprocess";
    reprocess.building = BuildingType::ReprocessingPlant;
    reprocess.inputs.push_back(RecipeIngredient{"junk", 4});
    reprocess.outputGroups.push_back(
        RecipeOutputGroup{{RecipeOutput{"alpha", 1}}, 0.4});
    reprocess.outputGroups.push_back(
        RecipeOutputGroup{{RecipeOutput{"beta", 1}}, 0.4});
    // A group of two, which no shipped or fixture recipe has: the only shape in which
    // judging a group whole differs from judging its items one by one
    // (REQ-LOCK-OUTPUT-POOL).
    reprocess.outputGroups.push_back(
        RecipeOutputGroup{{RecipeOutput{"alpha", 1}, RecipeOutput{"beta", 1}}, 0.2});
    cfg.recipes.recipes.push_back(reprocess);

    UnlockGroupDef lateShipGroup{};
    lateShipGroup.id = "late_ship";
    lateShipGroup.ships.push_back("late");
    cfg.unlocks.groups.push_back(lateShipGroup);

    UnlockGroupDef plantGroup{};
    plantGroup.id = "plant";
    plantGroup.buildings.push_back("reprocessing_plant");
    cfg.unlocks.groups.push_back(plantGroup);

    return cfg;
}

std::vector<std::string> recipeIdsOf(const std::vector<PreviewedRecipe>& previewed)
{
    std::vector<std::string> ids;
    for (const PreviewedRecipe& entry : previewed)
    {
        ids.push_back(entry.recipeId);
    }
    return ids;
}

}  // namespace

TEST_CASE("RecipeSchematic: a recipe is no gain while its building is locked",
          "[recipe_schematic]")
{
    // reprocess is unlocked from the start -- one of its groups yields alpha, which the
    // starting ship calls for -- but the plant that runs it is not. Granting the late
    // ship makes beta wanted, which would add reprocess's second group, yet the plant is
    // still unbuildable, so the option offers the player nothing through it.
    const GameConfig cfg = makePreviewConfig();
    UnlockState state(cfg);
    state.initializeUnlockState();

    REQUIRE(state.isRecipeUnlocked("reprocess"));
    REQUIRE_FALSE(state.isBuildingUnlocked(BuildingType::ReprocessingPlant));

    const SchematicChoiceOption option = state.makeUnlockOption(cfg.unlocks.groups[0]);
    REQUIRE(recipeIdsOf(option.newlyUsableRecipes) == std::vector<std::string>{"make_beta"});
    REQUIRE(option.upgradedRecipes.empty());
}

TEST_CASE("RecipeSchematic: unlocking the building is itself the gain", "[recipe_schematic]")
{
    // The recipe was unlocked all along and never listed, because it was of no use. The
    // award that makes it usable is the one that must say so, however long ago the recipe
    // itself unlocked.
    const GameConfig cfg = makePreviewConfig();
    UnlockState state(cfg);
    state.initializeUnlockState();

    const SchematicChoiceOption option = state.makeUnlockOption(cfg.unlocks.groups[1]);
    REQUIRE(recipeIdsOf(option.newlyUsableRecipes) == std::vector<std::string>{"reprocess"});
    // Only the group it can actually yield: beta is still nobody's material.
    REQUIRE(option.newlyUsableRecipes[0].outputGroupIndices == std::vector<int>{0});
    REQUIRE(option.upgradedRecipes.empty());
}

TEST_CASE("RecipeSchematic: a recipe gaining an output group is an upgrade, not an unlock",
          "[recipe_schematic]")
{
    // With the plant built, reprocess yields alpha alone. Granting the late ship makes
    // beta wanted and so opens its second group: the same recipe, worth more. Listing it
    // as newly unlocked would be a lie the player would read as a bug.
    const GameConfig cfg = makePreviewConfig();
    UnlockState state(cfg);
    state.initializeUnlockState();
    state.awardUnlockGroup(state.makeUnlockOption(cfg.unlocks.groups[1]));

    const SchematicChoiceOption option = state.makeUnlockOption(cfg.unlocks.groups[0]);
    REQUIRE(recipeIdsOf(option.newlyUsableRecipes) == std::vector<std::string>{"make_beta"});
    REQUIRE(recipeIdsOf(option.upgradedRecipes) == std::vector<std::string>{"reprocess"});
    // Drawn whole, every group it can yield, the one it already had included: the caption
    // is what says the recipe is not new, so the line does not have to. Group 2 holds
    // both materials and comes with them, so it arrives only now that both are wanted.
    REQUIRE(option.upgradedRecipes[0].outputGroupIndices == std::vector<int>({0, 1, 2}));
}

TEST_CASE("RecipeSchematic: a group of two waits for the last of its items",
          "[recipe_schematic]")
{
    // The same award twice, differing only in whether beta is wanted yet. The plant's
    // third group yields alpha and beta together, so holding alpha unlocked is worth
    // nothing on its own: a group is judged whole, because its items are produced
    // together (REQ-LOCK-OUTPUT-POOL). This is the one shape in which that rule differs
    // from testing items one at a time, and no shipped or fixture recipe has it.
    const GameConfig cfg = makePreviewConfig();

    UnlockState betaLocked(cfg);
    betaLocked.initializeUnlockState();
    const SchematicChoiceOption withoutBeta =
        betaLocked.makeUnlockOption(cfg.unlocks.groups[1]);
    REQUIRE(recipeIdsOf(withoutBeta.newlyUsableRecipes)
            == std::vector<std::string>{"reprocess"});
    // The alpha group alone. Group 2 names alpha as well and is still left out.
    REQUIRE(withoutBeta.newlyUsableRecipes[0].outputGroupIndices == std::vector<int>{0});

    UnlockState betaWanted(cfg);
    betaWanted.initializeUnlockState();
    betaWanted.awardUnlockGroup(betaWanted.makeUnlockOption(cfg.unlocks.groups[0]));
    const SchematicChoiceOption withBeta =
        betaWanted.makeUnlockOption(cfg.unlocks.groups[1]);
    REQUIRE(recipeIdsOf(withBeta.newlyUsableRecipes)
            == std::vector<std::string>{"reprocess"});
    REQUIRE(withBeta.newlyUsableRecipes[0].outputGroupIndices
            == std::vector<int>({0, 1, 2}));
}

// ---------------------------------------------------------------------------
// Unlock dialog: preview shape and ordering (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------

TEST_CASE("RecipeSchematic: unlocking never takes anything away", "[recipe_schematic]")
{
    // Monotonicity, which the preview relies on rather than merely enjoys: an upgraded
    // recipe is spotted by its group count having grown, which only means "gained groups"
    // while nothing can ever be lost. Should unlocking ever become reversible, that
    // comparison turns from cheap into wrong, and this is what would say so.
    Simulation sim(loadTestConfig());
    const GameConfig cfg = loadTestConfig();

    std::set<std::string> allItemIds;
    for (const RecipeDef& def : cfg.recipes.recipes)
    {
        for (const std::string& item : getProducibleItems(def)) { allItemIds.insert(item); }
        for (const RecipeIngredient& ing : def.inputs) { allItemIds.insert(ing.item); }
    }

    std::set<std::string> unlockedItemsBefore;
    std::set<std::string> unlockedRecipesBefore;
    for (int i = 0; i < 60; ++i)
    {
        std::set<std::string> unlockedItems;
        for (const std::string& itemId : allItemIds)
        {
            if (sim.isItemUnlocked(itemId)) { unlockedItems.insert(itemId); }
        }
        std::set<std::string> unlockedRecipes;
        for (const RecipeDef& def : cfg.recipes.recipes)
        {
            if (sim.isRecipeUnlocked(def.id)) { unlockedRecipes.insert(def.id); }
        }

        for (const std::string& itemId : unlockedItemsBefore)
        {
            CHECK(unlockedItems.count(itemId) == 1);
        }
        for (const std::string& recipeId : unlockedRecipesBefore)
        {
            CHECK(unlockedRecipes.count(recipeId) == 1);
        }

        unlockedItemsBefore   = unlockedItems;
        unlockedRecipesBefore = unlockedRecipes;

        killEnemyStationsAndApply(sim);
    }
}

TEST_CASE("RecipeSchematic: an unlocked recipe always has a group it can yield",
          "[recipe_schematic]")
{
    // REQ-LOCK-IMPLICIT step 4 makes this true by construction, and the preview leans on
    // it: computeUsableRecipeGroups treats a usable recipe with no yieldable group as
    // impossible rather than as a case to render. Checked across a run of awards, since
    // it must hold in every state and not only at the start.
    Simulation sim(loadTestConfig());
    const GameConfig cfg = loadTestConfig();

    for (int i = 0; i < 40; ++i)
    {
        for (const RecipeDef& def : cfg.recipes.recipes)
        {
            if (!sim.isRecipeUnlocked(def.id)) { continue; }

            bool anyGroupYieldable = false;
            for (const RecipeOutputGroup& group : def.outputGroups)
            {
                if (isOutputGroupUnlocked(
                        group,
                        [&sim](const std::string& id) { return sim.isItemUnlocked(id); }))
                {
                    anyGroupYieldable = true;
                    break;
                }
            }
            CHECK(anyGroupYieldable);
        }

        killEnemyStationsAndApply(sim);
    }
}

TEST_CASE("RecipeSchematic: the preview lists are sorted, deduplicated, and drawable",
          "[recipe_schematic]")
{
    Simulation sim(loadTestConfig());
    const GameConfig cfg = loadTestConfig();

    const std::function<void(const std::vector<PreviewedRecipe>&)> checkList =
        [&cfg](const std::vector<PreviewedRecipe>& previewed)
        {
            // Strictly ascending display names imply sorted and deduplicated.
            for (std::size_t j = 1; j < previewed.size(); ++j)
            {
                CHECK(toDisplayName(previewed[j - 1].recipeId)
                      < toDisplayName(previewed[j].recipeId));
            }
            // Every entry must be drawable: a recipe the dialog can find, and at least
            // one group to put on the right of the arrow (REQ-DEF-SCHEMATIC-DROP).
            for (const PreviewedRecipe& entry : previewed)
            {
                const RecipeDef* def = cfg.recipes.findRecipeDef(entry.recipeId);
                REQUIRE(def != nullptr);
                CHECK_FALSE(entry.outputGroupIndices.empty());
                for (int index : entry.outputGroupIndices)
                {
                    CHECK(index >= 0);
                    CHECK(static_cast<std::size_t>(index) < def->outputGroups.size());
                }
            }
        };

    for (int i = 0; i < 100; ++i)
    {
        killEnemyStations(sim);
        if (!sim.hasSchematicChoicesPending()) { continue; }

        for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
        {
            checkList(opt.newlyUsableRecipes);
            checkList(opt.upgradedRecipes);

            // The lists are two answers to one question, so a recipe belongs to exactly
            // one of them: a recipe cannot be both new to the player and one they
            // already run (REQ-DEF-SCHEMATIC-DROP).
            for (const PreviewedRecipe& newly : opt.newlyUsableRecipes)
            {
                for (const PreviewedRecipe& upgraded : opt.upgradedRecipes)
                {
                    CHECK(newly.recipeId != upgraded.recipeId);
                }
            }
        }

        SimulationTestAccess::applySchematicChoice(sim, 0);
    }
}

TEST_CASE("RecipeSchematic: newlyUsableRecipes matches what the award really makes usable",
          "[recipe_schematic]")
{
    Simulation sim(loadTestConfig());
    const GameConfig cfg = loadTestConfig();

    // The oracle asks the two public conditions, not the group bookkeeping the preview is
    // built from: a recipe is of use when the player may run it and may place what runs
    // it (REQ-LOCK-UI-RECIPE, REQ-LOCK-BUILDING).
    const std::function<std::set<std::string>()> usableRecipeIds =
        [&sim, &cfg]()
        {
            std::set<std::string> ids;
            for (const RecipeDef& def : cfg.recipes.recipes)
            {
                if (sim.isRecipeUnlocked(def.id) && sim.isBuildingUnlocked(def.building))
                {
                    ids.insert(def.id);
                }
            }
            return ids;
        };

    for (int i = 0; i < 100; ++i)
    {
        killEnemyStations(sim);
        if (!sim.hasSchematicChoicesPending()) { continue; }

        const std::set<std::string> usableBefore = usableRecipeIds();
        const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];

        SimulationTestAccess::applySchematicChoice(sim, 0);

        std::vector<std::string> expected;
        for (const RecipeDef& def : cfg.recipes.recipes)
        {
            if (sim.isRecipeUnlocked(def.id) && sim.isBuildingUnlocked(def.building)
                && usableBefore.count(def.id) == 0)
            {
                expected.push_back(def.id);
            }
        }
        std::sort(expected.begin(), expected.end(),
                  [](const std::string& lhs, const std::string& rhs)
                  {
                      return toDisplayName(lhs) < toDisplayName(rhs);
                  });

        std::vector<std::string> previewed;
        for (const PreviewedRecipe& entry : choice.newlyUsableRecipes)
        {
            previewed.push_back(entry.recipeId);
        }
        REQUIRE(previewed == expected);
    }
}

// ---------------------------------------------------------------------------
// Owned ship/module schematics leave the drop pool (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------

TEST_CASE("SchematicDrop: an owned ship schematic is never offered again",
          "[recipe_schematic]")
{
    // repair_ship has unlock_at_station_level = 0 in the test config, so it is
    // locked at start and becomes eligible once a station set is destroyed.
    Simulation sim(loadTestConfig());
    REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));

    bool wasUnlocked = false;
    for (int i = 0; i < 200; ++i)
    {
        killEnemyStations(sim);
        if (!sim.hasSchematicChoicesPending()) { continue; }

        const std::vector<SchematicChoiceOption>& choices =
            sim.getPendingSchematicChoices();

        // Locate the option granting repair_ship, if present in this drop.
        int repairShipIndex = -1;
        for (int j = 0; j < static_cast<int>(choices.size()); ++j)
        {
            for (const GrantedSchematic& grant : choices[j].grantedItems)
            {
                if (grant.id == "repair_ship") { repairShipIndex = j; break; }
            }
        }

        // Once owned, it must never appear in the pool again.
        if (sim.isSchematicUnlocked("repair_ship"))
        {
            CHECK(repairShipIndex == -1);
        }

        // Prefer picking repair_ship the first time it shows up so we exercise
        // the transition from unowned to owned; otherwise just take choice 0.
        int pick = 0;
        if (repairShipIndex >= 0 && !sim.isSchematicUnlocked("repair_ship"))
        {
            pick = repairShipIndex;
            wasUnlocked = true;
        }
        SimulationTestAccess::applySchematicChoice(sim, pick);
    }

    // Sanity: we actually unlocked it at some point, so the exclusion CHECK above
    // was meaningfully exercised.
    CHECK(wasUnlocked);
    CHECK(sim.isSchematicUnlocked("repair_ship"));
}

