#include "catch.hpp"
#include "FactoryQueries.h"

#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "ItemType.h"
#include "ModuleOwnerComponent.h"
#include "ModulesConfig.h"
#include "Rotation.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipLayout.h"
#include "ShipStatsCalculator.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "Tick.h"
#include "WeaponComponent.h"
#include "TestConfig.h"

static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id)
{
    for (const ShipDef& def : cfg.ships.ships)
    {
        if (def.id == id)
        {
            return &def;
        }
    }
    return nullptr;
}

static entt::entity findFirstWeaponChild(EntityAdmin& admin, entt::entity ship)
{
    entt::entity result = entt::null;
    admin.forEach<WeaponComponent, ModuleOwnerComponent>(
        [&](entt::entity ce, const WeaponComponent&, const ModuleOwnerComponent& o)
        {
            if (o.owner == ship && result == entt::null) { result = ce; }
        });
    return result;
}

static const BuildingDef* findShipyardDef(const GameConfig& cfg)
{
    for (const BuildingDef& def : cfg.buildings.buildings)
    {
        if (def.type == BuildingType::Shipyard)
        {
            return &def;
        }
    }
    return nullptr;
}

static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{
    return SimulationTestAccess::buildings(sim).placeImmediate(SimulationTestAccess::state(sim), BuildingType::Shipyard,
        yardDef.surfaceMask,
        QPoint(0, 0),
        Rotation::East);
}

static void fillMaterials(Simulation& sim, BuildingId yardId,
                          const ShipDef& def,
                          const ShipLayoutConfig& layout)
{
    SimulationTestAccess::buildings(sim).forEachBuilding(SimulationTestAccess::state(sim), [&](Building& b) {
        if (b.id != yardId)
        {
            return;
        }
        for (const RecipeIngredient& ing : def.schematic.materials)
        {
            b.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
        }
        for (const PlacedModule& pm : layout.placedModules)
        {
            for (const ModuleDef& modDef : sim.getConfig().modules.modules)
            {
                if (modDef.id == pm.moduleId)
                {
                    for (const RecipeIngredient& ing : modDef.materials)
                    {
                        b.inputBuffer.counts[ItemType{ing.item}] += ing.amount;
                    }
                    break;
                }
            }
        }
    });
}

// ---------------------------------------------------------------------------
// Ship stat modifiers
// ---------------------------------------------------------------------------

TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);

    const float expectedHp = static_cast<float>(def->health.hp);

    const entt::entity e = sim.getShips().spawn("interceptor",
        QVector2D(5.0f, 5.0f), false, std::nullopt);

    REQUIRE(sim.getAdmin().isValid(e));
    CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(expectedHp));
}

TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);

    const float baseHp = static_cast<float>(def->health.hp);

    ShipLayoutConfig layout;
    PlacedModule pm;
    pm.moduleId = "armor_plate";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    layout.placedModules.push_back(pm);

    const entt::entity e = sim.getShips().spawn("interceptor",
        QVector2D(5.0f, 5.0f), false, layout);

    REQUIRE(sim.getAdmin().isValid(e));
    // armor_plate has multiplied_hp_formula = "1.5"
    // final = base * (1 + (1.5 - 1)) + 0 = base * 1.5
    CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(baseHp * 1.5f));
    CHECK(sim.getAdmin().get<HealthComponent>(e).hp == sim.getAdmin().get<HealthComponent>(e).maxHp);
}

TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);

    const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
    const float baseRange_tiles = static_cast<float>(def->sensor.sensorRange_m) / tileSize;

    ShipLayoutConfig layout;
    PlacedModule pm;
    pm.moduleId = "sensor_booster";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    layout.placedModules.push_back(pm);

    const entt::entity e = sim.getShips().spawn("interceptor",
        QVector2D(5.0f, 5.0f), false, layout);

    REQUIRE(sim.getAdmin().isValid(e));
    // sensor_booster has added_sensor_range_m_formula = "100" m → 100/10 = 10 tiles
    // final = baseRange_tiles * 1.0 + 10 = baseRange_tiles + 10
    CHECK(sim.getAdmin().get<SensorRangeComponent>(e).value_tiles == Approx(baseRange_tiles + 10.0f));
}

TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);

    const float baseHp = static_cast<float>(def->health.hp);

    ShipLayoutConfig layout;
    for (int i = 0; i < 2; ++i)
    {
        PlacedModule pm;
        pm.moduleId = "armor_plate";
        pm.position = QPoint(i * 2, 0);
        pm.rotation = Rotation::East;
        layout.placedModules.push_back(pm);
    }

    const entt::entity e = sim.getShips().spawn("interceptor",
        QVector2D(5.0f, 5.0f), false, layout);

    REQUIRE(sim.getAdmin().isValid(e));
    // Two armor_plates: each 1.5 multiplier
    // total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0
    // final = base * 2.0
    CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(baseHp * 2.0f));
}

// ---------------------------------------------------------------------------
// Shipyard module integration
// ---------------------------------------------------------------------------

TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
          "[modules][shipyard]")
{
    Simulation sim(loadTestConfig(), 42);
    const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
    REQUIRE(yardDef != nullptr);

    const BuildingId yardId = placeShipyard(sim, *yardDef);
    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");

    ShipLayoutConfig layout;
    PlacedModule pm;
    pm.moduleId = "armor_plate";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    layout.placedModules.push_back(pm);

    SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);

    const Building* b = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(b != nullptr);
    // armor_plate needs 2 iron_ingot; interceptor needs 3 iron_ingot + 1 circuit_board
    // Total iron_ingot = 5, buffer cap = 2 * 5 = 10
    CHECK(b->inputBuffer.caps.at(ItemType{"iron_ingot"}) == 10);
    CHECK(b->inputBuffer.caps.at(ItemType{"circuit_board"}) == 2);
}

TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
          "[modules][shipyard]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);
    const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
    REQUIRE(yardDef != nullptr);

    const BuildingId yardId = placeShipyard(sim, *yardDef);
    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");

    // Fill materials and tick to start production.
    ShipLayoutConfig emptyLayout;
    fillMaterials(sim, yardId, *def, emptyLayout);
    sim.tick();

    const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(b1 != nullptr);
    REQUIRE(b1->production.has_value());

    // Now set a layout — should cancel production.
    ShipLayoutConfig layout;
    PlacedModule pm;
    pm.moduleId = "sensor_booster";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    layout.placedModules.push_back(pm);

    SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);

    const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(b2 != nullptr);
    CHECK_FALSE(b2->production.has_value());
}

// Applying a configuration a building already has is a no-op (REQ-MAT-INPUT-BUFFER).
// setRecipe has always worked this way; setShipLayout did not, and wiped the shipyard
// on every re-apply. That matters now that a blueprint configuration transfer
// (REQ-UI-BLUEPRINT-TRANSFER) can be clicked repeatedly onto matching shipyards.

TEST_CASE("Shipyard: re-applying the same layout keeps production and buffers",
          "[modules][shipyard]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);
    const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
    REQUIRE(yardDef != nullptr);

    const BuildingId yardId = placeShipyard(sim, *yardDef);
    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");

    ShipLayoutConfig layout;
    PlacedModule pm;
    pm.moduleId = "armor_plate";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    layout.placedModules.push_back(pm);

    SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
    fillMaterials(sim, yardId, *def, layout);
    sim.tick();

    const Building* before = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(before != nullptr);
    REQUIRE(before->production.has_value());
    const Tick completesAt = before->production->completesAt;

    // A separately built but equal layout: equality is by value, not by identity.
    ShipLayoutConfig sameLayout;
    sameLayout.placedModules.push_back(pm);
    SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, sameLayout);

    const Building* after = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(after != nullptr);
    REQUIRE(after->production.has_value());
    CHECK(after->production->completesAt == completesAt);
    CHECK(after->inputBuffer.caps.at(ItemType{"iron_ingot"}) == 10);
}

TEST_CASE("Shipyard: an empty layout on an unconfigured shipyard changes nothing",
          "[modules][shipyard]")
{
    // An unset layout and an empty one are the same state everywhere that matters, so
    // clearing an already-unconfigured shipyard must not cancel its cycle. This is the
    // case a transfer from a layout-less source shipyard produces.
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);
    const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
    REQUIRE(yardDef != nullptr);

    const BuildingId yardId = placeShipyard(sim, *yardDef);
    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");

    ShipLayoutConfig emptyLayout;
    fillMaterials(sim, yardId, *def, emptyLayout);
    sim.tick();

    const Building* before = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(before != nullptr);
    REQUIRE(before->production.has_value());
    REQUIRE_FALSE(before->shipLayout.has_value());

    SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, emptyLayout);

    const Building* after = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(after != nullptr);
    CHECK(after->production.has_value());
    CHECK_FALSE(after->shipLayout.has_value());
}

TEST_CASE("Shipyard: the same modules in a different order count as a change",
          "[modules][shipyard]")
{
    // Layout equality is deliberately order-sensitive (see ShipLayout.h): the safe
    // answer when in doubt is "changed", which costs a buffer reset rather than
    // leaving a shipyard building the wrong ship.
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);
    const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
    REQUIRE(yardDef != nullptr);

    const BuildingId yardId = placeShipyard(sim, *yardDef);
    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");

    PlacedModule armor;
    armor.moduleId = "armor_plate";
    armor.position = QPoint(0, 0);
    armor.rotation = Rotation::East;
    PlacedModule sensor;
    sensor.moduleId = "sensor_booster";
    sensor.position = QPoint(1, 0);
    sensor.rotation = Rotation::East;

    ShipLayoutConfig layout;
    layout.placedModules.push_back(armor);
    layout.placedModules.push_back(sensor);
    SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
    fillMaterials(sim, yardId, *def, layout);
    sim.tick();
    REQUIRE(findBuilding(sim.getFactoryState(), yardId)->production.has_value());

    ShipLayoutConfig reordered;
    reordered.placedModules.push_back(sensor);
    reordered.placedModules.push_back(armor);
    SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, reordered);

    CHECK_FALSE(findBuilding(sim.getFactoryState(), yardId)->production.has_value());
}

TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
          "[modules][shipyard]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);
    // The schematic carries a weapon in its (wave-only) default loadout. This
    // test pins that a player shipyard with no configured layout does NOT hand
    // that weapon out for free: it builds an unarmed bare hull, matching the
    // base-hull materials it was charged.
    REQUIRE_FALSE(def->defaultModules.empty());

    const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
    REQUIRE(yardDef != nullptr);

    const BuildingId yardId = placeShipyard(sim, *yardDef);
    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
    // Deliberately no setShipLayout: recipe set, layout left unconfigured.

    // Charge only the base-hull materials (an empty layout adds none).
    fillMaterials(sim, yardId, *def, ShipLayoutConfig{});

    // Tick through one full production cycle so the ship spawns.
    const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
    for (Tick i = 0; i <= cycleTicks; ++i)
    {
        sim.tick();
    }

    // Locate the freshly built player ship.
    entt::entity built = entt::null;
    sim.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
        [&](entt::entity e, const ShipIdentityComponent& si, const FactionComponent& fac)
        {
            if (!fac.isEnemy && si.schematicId == "interceptor") { built = e; }
        });
    REQUIRE(sim.getAdmin().isValid(built));

    // Bare hull: the schematic's default weapon must NOT have been installed.
    const bool hasWeapon =
        findFirstWeaponChild(sim.getAdmin(), built) != entt::null;
    CHECK_FALSE(hasWeapon);
}

TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
{
    Simulation sim(loadTestConfig(), 42);
    const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
    REQUIRE(yardDef != nullptr);

    const BuildingId yardId = placeShipyard(sim, *yardDef);
    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");

    ShipLayoutConfig layout;
    PlacedModule pm;
    pm.moduleId = "armor_plate";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    layout.placedModules.push_back(pm);
    SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);

    const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(b1 != nullptr);
    REQUIRE(b1->shipLayout.has_value());

    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"destroyer");

    const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(b2 != nullptr);
    CHECK_FALSE(b2->shipLayout.has_value());
}

TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
          "[modules][shipyard]")
{
    Simulation sim(loadTestConfig(), 42);
    const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
    REQUIRE(yardDef != nullptr);

    const BuildingId yardId = placeShipyard(sim, *yardDef);
    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");

    ShipLayoutConfig layout;
    PlacedModule pm;
    pm.moduleId = "armor_plate";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    layout.placedModules.push_back(pm);
    SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);

    const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(b1 != nullptr);
    REQUIRE(b1->shipLayout.has_value());

    // Re-selecting the same recipe must be a no-op and preserve the layout.
    SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");

    const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
    REQUIRE(b2 != nullptr);
    REQUIRE(b2->shipLayout.has_value());
    REQUIRE(b2->shipLayout->placedModules.size() == 1);
    CHECK(b2->shipLayout->placedModules[0].moduleId == "armor_plate");
}

// ---------------------------------------------------------------------------
// Weapon modifier simulation tests
// ---------------------------------------------------------------------------

TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[modules]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);

    ShipLayoutConfig layout;
    for (const std::string& id : {"laser_cannon", "weapon_primer"})
    {
        PlacedModule pm;
        pm.moduleId = id;
        pm.position = QPoint(0, 0);
        pm.rotation = Rotation::East;
        layout.placedModules.push_back(pm);
    }

    const entt::entity ship = sim.getShips().spawn("interceptor",
        QVector2D(5.0f, 5.0f), false, layout);

    const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship);
    REQUIRE(sim.getAdmin().isValid(weapon));
    // base rate = 2.0 hz; weapon_primer multiplier = 1.2 → 2.4 hz
    CHECK(sim.getAdmin().get<WeaponComponent>(weapon).fireRateHz == Approx(2.4f));
}

TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation", "[modules]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);

    const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);

    ShipLayoutConfig layout;
    for (const std::string& id : {"laser_cannon", "weapon_stabilizer"})
    {
        PlacedModule pm;
        pm.moduleId = id;
        pm.position = QPoint(0, 0);
        pm.rotation = Rotation::East;
        layout.placedModules.push_back(pm);
    }

    const entt::entity ship = sim.getShips().spawn("interceptor",
        QVector2D(5.0f, 5.0f), false, layout);

    const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship);
    REQUIRE(sim.getAdmin().isValid(weapon));
    // base range = 50 m / tileSize = 5 tiles; weapon_stabilizer multiplier = 1.5 → 7.5 tiles
    CHECK(sim.getAdmin().get<WeaponComponent>(weapon).range_tiles == Approx(50.0f / tileSize * 1.5f));
}

TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s² to tiles/tick", "[modules]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);

    const float tileSize   = static_cast<float>(sim.getConfig().world.tileSize_m);
    const float tickRate   = static_cast<float>(kTickRateHz);
    const float base_mpss  = static_cast<float>(def->movement.mainAcceleration_mpss);

    ShipLayoutConfig layout;
    PlacedModule pm;
    pm.moduleId = "afterburner";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    layout.placedModules.push_back(pm);

    const entt::entity e = sim.getShips().spawn("interceptor",
        QVector2D(5.0f, 5.0f), false, layout);

    // added_main_acceleration_mpss = 60; same conversion as base: / tileSize / tickRate
    const float expected = (base_mpss + 60.0f) / tileSize / tickRate;
    CHECK(sim.getAdmin().get<DynamicBodyComponent>(e).mainAcceleration_tptt == Approx(expected));
}

TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/tick", "[modules]")
{
    Simulation sim(loadTestConfig(), 42);
    const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
    REQUIRE(def != nullptr);

    const float tileSize   = static_cast<float>(sim.getConfig().world.tileSize_m);
    const float tickRate   = static_cast<float>(kTickRateHz);
    const float base_mpss  = static_cast<float>(def->movement.maneuveringAcceleration_mpss);

    ShipLayoutConfig layout;
    PlacedModule pm;
    pm.moduleId = "maneuvering_thrusters";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    layout.placedModules.push_back(pm);

    const entt::entity e = sim.getShips().spawn("interceptor",
        QVector2D(5.0f, 5.0f), false, layout);

    // added_maneuvering_acceleration_mpss = 10; same conversion as base: / tileSize / tickRate
    const float expected = (base_mpss + 10.0f) / tileSize / tickRate;
    CHECK(sim.getAdmin().get<DynamicBodyComponent>(e).maneuveringAcceleration_tptt == Approx(expected));
}

// ---------------------------------------------------------------------------
// Weapon modifier stats view tests (calculateShipStats)
// ---------------------------------------------------------------------------

TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats view", "[modules]")
{
    const GameConfig cfg = loadTestConfig();
    const ShipDef* def = findSchematic(cfg, "interceptor");
    REQUIRE(def != nullptr);

    std::vector<PlacedModule> modules;
    for (const std::string& id : {"laser_cannon", "weapon_primer"})
    {
        PlacedModule pm;
        pm.moduleId = id;
        pm.position = QPoint(0, 0);
        pm.rotation = Rotation::East;
        modules.push_back(pm);
    }

    const ShipStats stats = calculateShipStats(cfg, "interceptor",
        modules);

    REQUIRE(stats.weapons.has_value());
    // base: damage = 2, rate = 2.0 hz; weapon_primer multiplies rate by 1.2 → DPS = 2 * 2.4 = 4.8
    CHECK(stats.weapons->combinedDps == Approx(4.8f));
}

TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stats view", "[modules]")
{
    const GameConfig cfg = loadTestConfig();
    const ShipDef* def = findSchematic(cfg, "interceptor");
    REQUIRE(def != nullptr);

    const float tileSize = static_cast<float>(cfg.world.tileSize_m);

    std::vector<PlacedModule> modules;
    for (const std::string& id : {"laser_cannon", "weapon_stabilizer"})
    {
        PlacedModule pm;
        pm.moduleId = id;
        pm.position = QPoint(0, 0);
        pm.rotation = Rotation::East;
        modules.push_back(pm);
    }

    const ShipStats stats = calculateShipStats(cfg, "interceptor",
        modules);

    REQUIRE(stats.weapons.has_value());
    // base range = 50 m / tileSize; weapon_stabilizer multiplier = 1.5
    CHECK(stats.weapons->maxRange_tiles == Approx(50.0f / tileSize * 1.5f));
}

TEST_CASE("calculateShipStats: afterburner additive main_acceleration is converted m/s² to tiles/s²", "[modules]")
{
    const GameConfig cfg = loadTestConfig();
    const ShipDef* def = findSchematic(cfg, "interceptor");
    REQUIRE(def != nullptr);

    const float tileSize  = static_cast<float>(cfg.world.tileSize_m);
    const float base_mpss = static_cast<float>(def->movement.mainAcceleration_mpss);

    std::vector<PlacedModule> modules;
    PlacedModule pm;
    pm.moduleId = "afterburner";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    modules.push_back(pm);

    const ShipStats stats = calculateShipStats(cfg, "interceptor",
        modules);

    // added_main_acceleration_mpss = 60; converted to tiles/s²: / tileSize
    const float expected = (base_mpss + 60.0f) / tileSize;
    CHECK(stats.mainAcceleration_tpss == Approx(expected));
}

TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/s²", "[modules]")
{
    const GameConfig cfg = loadTestConfig();
    const ShipDef* def = findSchematic(cfg, "interceptor");
    REQUIRE(def != nullptr);

    const float tileSize  = static_cast<float>(cfg.world.tileSize_m);
    const float base_mpss = static_cast<float>(def->movement.maneuveringAcceleration_mpss);

    std::vector<PlacedModule> modules;
    PlacedModule pm;
    pm.moduleId = "maneuvering_thrusters";
    pm.position = QPoint(0, 0);
    pm.rotation = Rotation::East;
    modules.push_back(pm);

    const ShipStats stats = calculateShipStats(cfg, "interceptor",
        modules);

    // added_maneuvering_acceleration_mpss = 10; converted to tiles/s²: / tileSize
    const float expected = (base_mpss + 10.0f) / tileSize;
    CHECK(stats.maneuveringAcceleration_tpss == Approx(expected));
}

// The precondition every entry to the layout configuration dialog checks
// (REQ-MOD-UI-DIALOG, REQ-MOD-UI-AUTO-DIALOG, REQ-MOD-UI-PREVIEW). The empty id is the
// one that used to slip through: "(None)" clears a shipyard and differs from whatever
// was set, which is not the same as naming a schematic to configure.
TEST_CASE("ShipsConfig: a layout is configurable only for a ship that has one",
          "[modules][config]")
{
    const GameConfig cfg = loadTestConfig();

    const ShipDef* interceptor = cfg.ships.findLayoutShipDef("interceptor");
    REQUIRE(interceptor != nullptr);
    CHECK(interceptor->id == "interceptor");
    CHECK_FALSE(interceptor->layout.empty());

    // The "(None)" option's id, and an id naming no ship at all.
    CHECK(cfg.ships.findLayoutShipDef("") == nullptr);
    CHECK(cfg.ships.findLayoutShipDef("no_such_ship") == nullptr);

    // A ship that exists but defines no grid has nothing to place modules on either,
    // where plain findShipDef still finds it.
    ShipsConfig gridless;
    ShipDef def;
    def.id = "hull_only";
    gridless.ships.push_back(def);
    CHECK(gridless.findShipDef("hull_only") != nullptr);
    CHECK(gridless.findLayoutShipDef("hull_only") == nullptr);
}
