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

#include <map>
#include <random>
#include <set>
#include <string>
#include <utility>
#include <vector>

#include <QPoint>

#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "ConstructionSystem.h"
#include "DeconstructionSystem.h"
#include "FactoryState.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "Item.h"
#include "ItemType.h"
#include "Port.h"
#include "Rotation.h"
#include "Tick.h"
#include "TestConfig.h"

// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------

static Item makeItem(const std::string& id)
{
    Item item;
    item.type.id = id;
    return item;
}

static Port eastPort(QPoint tile)
{
    Port p;
    p.tile      = tile;
    p.direction = Rotation::East;
    return p;
}

static Port westPort(QPoint tile)
{
    Port p;
    p.tile      = tile;
    p.direction = Rotation::West;
    return p;
}

// Run N full sim ticks: construction, belt-pull, production, belt-push, belt tick.
static void runTicks(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state_bs,
                     BeltSystem& belts, int& stock, int n, Tick& tick)
{
    for (int i = 0; i < n; ++i)
    {
        ConstructionSystem(cfg).tick(state_bs, belts, tick);
        DeconstructionSystem(cfg, [&stock](int n) { stock += n; }).tick(state_bs, tick);
        bs.tickBeltPull(state_bs);
        bs.tickProduction(state_bs, tick);
        bs.tickOutputBelts(state_bs);
        belts.tick();
        ++tick;
    }
}

// All items currently on a building's output side: buffered plus still-emerging on
// the virtual output belts (REQ-MAT-OUTPUT-EMERGE). A produced item leaves the
// output buffer the moment it starts emerging, so tests count both.
static std::vector<Item> outputSideItems(const Building& b)
{
    std::vector<Item> items = b.outputBuffer.items;
    for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
    {
        for (const BeltItemSlot& slot : lane)
        {
            items.push_back(slot.item);
        }
    }
    return items;
}

// Owns a BuildingSystem and its dependencies for placement-bounds tests.
// Belt speed for the tests that need an item to cross a tile in a single tick, so
// it is available to peek or take on the next one.
constexpr double kFastBeltSpeed_tps = static_cast<double>(kTickRateHz);

struct PlacementFixture
{
    GameConfig     cfg = loadTestConfig();
    FactoryState   state = makeFactoryState(cfg);
    BeltSystem     belts;
    int            stock = 0;
    std::mt19937   rng{0};
    BuildingId     nextBuildingId = 1;
    BuildingSystem bs;

    // Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
    // needs items to arrive immediately.
    explicit PlacementFixture(std::optional<double> beltSpeed_tps = std::nullopt)
        : belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
        , bs(cfg, belts,
             [this]() { return nextBuildingId++; },
             [this](int n) { stock += n; },
             [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
             [](const std::string&) -> bool { return true; },
             rng)
    {
    }
};

// ---------------------------------------------------------------------------
// Placement
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    REQUIRE(id != kInvalidBuildingId);

    // Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1).
    REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));
    REQUIRE(isTileOccupied(f.state, QPoint(1, 0)));
    REQUIRE(isTileOccupied(f.state, QPoint(0, 1)));
    // (1,1) is the output-port tile, NOT a body cell.
    REQUIRE_FALSE(isTileOccupied(f.state, QPoint(1, 1)));
}

// -- World-bounds rejection (REQ-BLD-PLACE-VALID) ---------------------------

TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[building]")
{
    PlacementFixture f;

    // Miner mask ["AA","A>"] East → body at (0,0),(1,0),(0,1); at y=-1 the top
    // row sits above the world.
    const std::optional<BuildingId> id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, -1), Rotation::East, 0);
    REQUIRE_FALSE(id.has_value());
    REQUIRE(getAllSites(f.state).empty());
    REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0)));
}

TEST_CASE("BuildingSystem: place rejects a building below the world (y >= height)", "[building]")
{
    PlacementFixture f;
    const int heightTiles = f.cfg.world.heightTiles;

    // Anchored on the last in-bounds row, the miner's lower body row reaches
    // y == heightTiles, which is outside the world.
    const std::optional<BuildingId> id = f.bs.place(f.state, BuildingType::Miner,
                                     QPoint(0, heightTiles - 1), Rotation::East, 0);
    REQUIRE_FALSE(id.has_value());
    REQUIRE(getAllSites(f.state).empty());
}

TEST_CASE("BuildingSystem: place rejects a building left of the asteroid edge", "[building]")
{
    PlacementFixture f;
    const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles;

    const std::optional<BuildingId> id = f.bs.place(f.state, BuildingType::Miner,
                                     QPoint(leftEdgeX - 1, 0), Rotation::East, 0);
    REQUIRE_FALSE(id.has_value());
    REQUIRE(getAllSites(f.state).empty());
}

TEST_CASE("BuildingSystem: place accepts a building flush against the world's left edge",
          "[building]")
{
    PlacementFixture f;
    const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles;

    // Miner body min relative x is 0, so its leftmost cell sits exactly on the edge.
    const BuildingId id = f.bs.place(f.state, BuildingType::Miner,
                                     QPoint(leftEdgeX, 0), Rotation::East, 0).value();
    REQUIRE(id != kInvalidBuildingId);
    REQUIRE(isTileOccupied(f.state, QPoint(leftEdgeX, 0)));
}

TEST_CASE("BuildingSystem: place imposes no right-side bound (space extends rightward)",
          "[building]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Miner,
                                     QPoint(1000, 0), Rotation::East, 0).value();
    REQUIRE(id != kInvalidBuildingId);
}

TEST_CASE("BuildingSystem: isPlacementValid enforces terrain and world bounds", "[building]")
{
    PlacementFixture f;
    const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles;

    // Miner is all-asteroid (A): valid only fully on the asteroid (x < 0).
    REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(-3, 0), Rotation::East));
    REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, 0), Rotation::East));   // A cells in space
    REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, -1), Rotation::East));  // above world
    REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX, 0), Rotation::East));
    REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX - 1, 0), Rotation::East));            // past left edge

    // Shipyard mask ["AAAS>","AAAS "] straddles the boundary: A cells on the
    // asteroid, the S (dock) cell in space. At anchor (-3,0) the A cells land at
    // x=-3..-1 and the dock at x=0.
    REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-3, 0), Rotation::East));
    REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(0, 0), Rotation::East));   // A cells in space
    REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-4, 0), Rotation::East));  // dock on asteroid
}

TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after construction",
          "[building]")
{
    PlacementFixture f;

    f.bs.place(f.state, BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);

    // Belt is queued — not yet in BeltSystem.
    REQUIRE_FALSE(f.belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));

    // Complete construction (1 s).
    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);

    REQUIRE(f.belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
    REQUIRE(getAllBuildings(f.state).size() == 1);
    REQUIRE(getAllBuildings(f.state)[0].type == BuildingType::Belt);
    REQUIRE(getAllBuildings(f.state)[0].anchor == QPoint(5, 5));
}

TEST_CASE("BuildingSystem: placed building enters construction queue", "[building]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();

    REQUIRE(getAllSites(f.state).size() == 1);
    REQUIRE(getAllBuildings(f.state).empty());
    REQUIRE(findSite(f.state, id) != nullptr);
}

TEST_CASE("BuildingSystem: deconstructing a construction site removes it instantly with full refund",
          "[building]")
{
    PlacementFixture f;
    const BuildingId id =
        f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();

    // Still queued for construction (not yet built): instant removal, full cost
    // refunded immediately, never entering the deconstruction queue (REQ-BLD-DECONSTRUCT).
    const int refund = f.bs.deconstruct(f.state, id, 0);

    REQUIRE(refund == 15);  // Miner cost = 15
    REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0)));
    REQUIRE(getAllSites(f.state).empty());
}

// ---------------------------------------------------------------------------
// Construction queue
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: first queued building starts construction immediately",
          "[building]")
{
    PlacementFixture f;

    f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
    REQUIRE(getAllSites(f.state).front().completesAt > 0);
}

TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[building]")
{
    PlacementFixture f;

    f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
    f.bs.place(f.state, BuildingType::Miner, QPoint(5, 5), Rotation::East, 0);

    REQUIRE(getAllSites(f.state).size() == 2);
    REQUIRE(getAllSites(f.state)[0].completesAt > 0);
    REQUIRE(getAllSites(f.state)[1].completesAt == 0);
}

TEST_CASE("BuildingSystem: construction completes after configured duration", "[building]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();

    // Miner construction_time_seconds = 10.  completesAt = secondsToTicks(10) = 300.
    // We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);

    REQUIRE(getAllSites(f.state).empty());
    REQUIRE(findBuilding(f.state, id) != nullptr);
}

// ---------------------------------------------------------------------------
// Deconstruction queue (REQ-BLD-DECON-QUEUE)
// ---------------------------------------------------------------------------

// Runs ticks until the building with the given id is operational, or fails.
static void runUntilBuilt(PlacementFixture& f, BuildingId id, Tick& tick)
{
    for (int i = 0; i < 100000 && findBuilding(f.state, id) == nullptr; ++i)
    {
        runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
    }
    REQUIRE(findBuilding(f.state, id) != nullptr);
}

TEST_CASE("BuildingSystem: deconstructing a built building queues it; refund credited on completion",
          "[building][decon]")
{
    PlacementFixture f;
    const BuildingId id =
        f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();

    Tick tick = 0;
    runUntilBuilt(f, id, tick);

    // Deconstructing a built building returns nothing immediately and queues it,
    // stopping it operating while its tiles stay occupied (REQ-BLD-DECON-QUEUE).
    const int refund = f.bs.deconstruct(f.state, id, tick);
    REQUIRE(refund == 0);
    REQUIRE(isQueuedForDeconstruction(f.state, id));
    REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));
    REQUIRE(f.stock == 0);

    // After the deconstruction time (0.1s = 3 ticks) it is removed and the partial
    // refund (15 * 75 / 100 = 11) is credited exactly once.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(0.1)) + 1, tick);
    REQUIRE(findBuilding(f.state, id) == nullptr);
    REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0)));
    REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100);
}

TEST_CASE("BuildingSystem: deconstruction queue removes one building at a time", "[building][decon]")
{
    PlacementFixture f;
    const BuildingId a = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    const BuildingId b = f.bs.place(f.state, BuildingType::Miner, QPoint(5, 5), Rotation::East, 0).value();

    Tick tick = 0;
    runUntilBuilt(f, a, tick);
    runUntilBuilt(f, b, tick);

    // Queue both in one tick; 'a' is at the front of the deconstruction queue.
    f.bs.deconstruct(f.state, a, tick);
    f.bs.deconstruct(f.state, b, tick);
    REQUIRE(isQueuedForDeconstruction(f.state, a));
    REQUIRE(isQueuedForDeconstruction(f.state, b));

    // After one deconstruction interval only the front building is gone; the
    // second is still queued and its refund not yet credited.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(0.1)) + 1, tick);
    REQUIRE(findBuilding(f.state, a) == nullptr);
    REQUIRE(findBuilding(f.state, b) != nullptr);
    REQUIRE(isQueuedForDeconstruction(f.state, b));
    REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100);

    // The second drains next.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(0.1)) + 2, tick);
    REQUIRE(findBuilding(f.state, b) == nullptr);
    REQUIRE(f.stock == 2 * (15 * f.cfg.world.refundPercentage / 100));
}

TEST_CASE("BuildingSystem: cancelling deconstruction resumes the building with no refund",
          "[building][decon]")
{
    PlacementFixture f;
    const BuildingId id =
        f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();

    Tick tick = 0;
    runUntilBuilt(f, id, tick);

    f.bs.deconstruct(f.state, id, tick);
    REQUIRE(isQueuedForDeconstruction(f.state, id));

    // Un-queue before it drains: it operates again, no refund, tiles still occupied.
    f.bs.cancelDeconstruction(f.state, id);
    REQUIRE_FALSE(isQueuedForDeconstruction(f.state, id));
    REQUIRE(findBuilding(f.state, id) != nullptr);
    REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));
    REQUIRE(f.stock == 0);

    // It is never removed even after more than a deconstruction interval passes.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(0.1)) + 5, tick);
    REQUIRE(findBuilding(f.state, id) != nullptr);
    REQUIRE(f.stock == 0);
}

TEST_CASE("BuildingSystem: queued belt stops transporting; cancel restores it", "[building][decon]")
{
    PlacementFixture f;
    const BuildingId id =
        f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();

    Tick tick = 0;
    runUntilBuilt(f, id, tick);
    REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));

    // Queuing a belt unregisters its tile from the belt subsystem, so it no longer
    // accepts or transports items, though the tile stays occupied (REQ-BLD-DECON-QUEUE).
    f.bs.deconstruct(f.state, id, tick);
    REQUIRE_FALSE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));
    REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));

    // Un-queuing re-registers the belt tile so it transports again.
    f.bs.cancelDeconstruction(f.state, id);
    REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));
}

TEST_CASE("BuildingSystem: splitter filters survive a queue/un-queue round-trip", "[building][decon]")
{
    PlacementFixture f;
    const BuildingId id =
        f.bs.place(f.state, BuildingType::Splitter, QPoint(0, 0), Rotation::East, 0).value();

    Tick tick = 0;
    runUntilBuilt(f, id, tick);

    f.belts.setSplitterFilters(QPoint(0, 0), {ItemType{"iron_ore"}}, {});

    // Queue: the belt subsystem tile (and its filters) are unregistered, but the
    // filters are captured so an un-queue can restore them.
    f.bs.deconstruct(f.state, id, tick);
    REQUIRE_FALSE(f.belts.getSplitterInfo(QPoint(0, 0)).has_value());

    f.bs.cancelDeconstruction(f.state, id);
    const std::optional<BeltSystem::SplitterInfo> info = f.belts.getSplitterInfo(QPoint(0, 0));
    REQUIRE(info.has_value());
    REQUIRE(info->filterA.size() == 1);
    REQUIRE(info->filterA[0].id == "iron_ore");
    REQUIRE(info->filterB.empty());
}

TEST_CASE("BuildingSystem: second building starts after first completes", "[building]")
{
    PlacementFixture f;

    f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
    const BuildingId id2 = f.bs.place(f.state, BuildingType::Miner, QPoint(5, 5), Rotation::East, 0).value();

    // Process through tick 300 to complete first miner's construction.
    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);

    REQUIRE(getAllSites(f.state).size() == 1);
    REQUIRE(getAllSites(f.state).front().id == id2);
    REQUIRE(getAllSites(f.state).front().completesAt > 0);
}

// ---------------------------------------------------------------------------
// Miner production cycle
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[building]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    f.bs.setRecipe(f.state, id, "mine_iron_ore");

    Tick tick = 0;
    // Construction completes on tick 300; production cycle starts tick 300,
    // completes on tick 330.  Process through tick 330: 331 ticks total.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
             static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
             tick);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    // No belt at the output port, so the produced item emerges and stays on the
    // building's virtual output belt (REQ-MAT-OUTPUT-EMERGE).
    const std::vector<Item> out = outputSideItems(*b);
    REQUIRE(out.size() == 1);
    REQUIRE(out.front().type.id == "iron_ore");
}

TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    f.bs.setRecipe(f.state, id, "mine_iron_ore");

    Tick tick = 0;
    // Construction (10s) then cycle 1 starts at tick 300 (completesAt=330).
    // Cycle 1 completes at tick 330: deposit item, continue (no same-tick restart).
    // Cycle 2 starts at tick 331 (completesAt=361).
    // Cycle 2 completes at tick 361: deposit item → buffer=2, cycle 3 stalls.
    // Need to process through tick 361: 362 ticks total.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
             static_cast<int>(secondsToTicks(10.0))
             + 2 * static_cast<int>(secondsToTicks(1.0)) + 2,
             tick);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    // Both produced items are held on the output side (buffer + emerging lane),
    // which is what the capacity rule counts (REQ-MAT-OUTPUT-EMERGE).
    REQUIRE(b->getOutputItemCount() == 2);
    REQUIRE_FALSE(b->production.has_value());
}

// ---------------------------------------------------------------------------
// REQ-UI-DEBUG-OVERLAY production counts
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", "[building]")
{
    PlacementFixture f;

    const BuildingId minerId   = f.bs.place(f.state, BuildingType::Miner,   QPoint(0, 0),  Rotation::East, 0).value();
    const BuildingId smelterId = f.bs.place(f.state, BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0).value();
    (void)smelterId;

    Tick tick = 0;
    // Both still under construction.
    REQUIRE(getProductionBuildingCount(f.state) == 0);

    // The queue builds one at a time: miner (10s) completes at tick 300, then
    // the smelter (15s) starts and completes at tick 300 + 450 = 750.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
    REQUIRE(getProductionBuildingCount(f.state) == 1);

    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)), tick);
    REQUIRE(getProductionBuildingCount(f.state) == 2);

    // Neither is producing yet: the miner has no recipe selected, and the
    // smelter (auto-recipe, REQ-BLD-SMELTER) has no input feeding it.
    REQUIRE(getActiveProductionBuildingCount(f.state) == 0);

    f.bs.setRecipe(f.state, minerId, "mine_iron_ore");
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
    REQUIRE(getActiveProductionBuildingCount(f.state) == 1);
}

TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state",
          "[building]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    f.bs.setRecipe(f.state, id, "mine_iron_ore");

    Tick tick = 0;
    // Not yet operational while under construction.
    REQUIRE(getActiveProductionBuildingCount(f.state) == 0);

    // Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330).
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
    REQUIRE(getActiveProductionBuildingCount(f.state) == 1);

    // Run cycles 1 and 2 to completion (1s each); cycle 3 stalls once the
    // output buffer (capacity 2) is full (REQ-MAT-OUTPUT-BUFFER).
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 2 * static_cast<int>(secondsToTicks(1.0)) + 1, tick);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    REQUIRE(b->getOutputItemCount() == 2);
    REQUIRE_FALSE(b->production.has_value());
    REQUIRE(getActiveProductionBuildingCount(f.state) == 0);
}

// ---------------------------------------------------------------------------
// Belt pull → input buffer
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing belt",
          "[building]")
{
    // Fast belt so items are immediately available for peek/take.
    PlacementFixture f(kFastBeltSpeed_tps);

    // Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
    // Output port (2,1) East.  Input port example: (2,0) West.
    const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
    // Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
    // ore/scrap that is an input to a smelter recipe.

    // Complete construction (15s → tick 450+1 = 451 ticks).
    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);

    // Place west-flowing belt at (2,0): belt flows West, delivers to smelter.
    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
    f.belts.tick();

    f.bs.tickBeltPull(f.state);

    const Building* b = findBuilding(f.state, sid);
    REQUIRE(b != nullptr);
    // The item was accepted; it may still be travelling inward on the input belt,
    // so count buffered + in-transit (REQ-MAT-INPUT-INTAKE).
    REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1);
}

// An accepted input item travels inward on its input belt before it becomes usable
// f.stock: it is reserved (counts against the cap) on entry and only enters the
// buffer on reaching the tile centre (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer",
          "[building]")
{
    PlacementFixture f(kFastBeltSpeed_tps);

    const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);

    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
    f.belts.tick();
    f.bs.tickBeltPull(f.state);  // accepts the item onto the input belt at progress 0.0

    const Building* b = findBuilding(f.state, sid);
    REQUIRE(b != nullptr);
    // Reserved but not yet consumable: nothing in the buffer, but it counts against
    // the cap via pendingInputCount.
    const std::map<ItemType, int>::const_iterator it0 =
        b->inputBuffer.counts.find(ItemType{"iron_ore"});
    REQUIRE((it0 == b->inputBuffer.counts.end() || it0->second == 0));
    REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);

    // One more pull tick advances the input belt to the centre; the item arrives.
    f.bs.tickBeltPull(f.state);
    REQUIRE(b->inputBuffer.counts.at(ItemType{"iron_ore"}) == 1);
    REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
}

// The acceptance test counts in-transit items, so buffered + reserved never exceeds
// the per-material cap; excess items stay on the belt (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap",
          "[building]")
{
    PlacementFixture f(kFastBeltSpeed_tps);

    const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
                                  QPoint(0, 0), Rotation::East, 0).value();
    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);

    // Feed scrap via an input belt without ever running production (only pull), so
    // the buffer fills and stays full. Try to over-fill it well past the cap.
    f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
    for (int i = 0; i < 20; ++i)
    {
        f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
        f.belts.tick();
        f.bs.tickBeltPull(f.state);
    }

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    const int cap = b->inputBuffer.caps.at(ItemType{"scrap"});
    REQUIRE(cap > 0);
    // buffered + in-transit is capped; the plant never over-pulls.
    REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == cap);
    // Excess scrap is left stuck on the feeding belt rather than silently dropped.
    REQUIRE(f.belts.peekItem(eastPort(QPoint(-1, 0))).has_value());
}

// A smelter auto-selects the matching recipe for whatever it is fed, with no
// player recipe selection (REQ-BLD-SMELTER).
TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
          "[building]")
{
    PlacementFixture f(kFastBeltSpeed_tps);

    const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();

    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);

    // Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a
    // west-flowing belt at input port (2,0).
    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    for (int i = 0; i < 2; ++i)
    {
        f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
        f.belts.tick();
        f.bs.tickBeltPull(f.state);
    }

    // iron_ingot recipe cycle is 2s; run to completion.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(2.0)) + 2, tick);

    const Building* b = findBuilding(f.state, sid);
    REQUIRE(b != nullptr);
    bool hasIronIngot = false;
    for (const Item& item : outputSideItems(*b))
    {
        if (item.type.id == "iron_ingot") { hasIronIngot = true; }
    }
    REQUIRE(hasIronIngot);
}

// With mixed inputs, the smelter runs whichever recipe is currently satisfiable
// and leaves an incomplete batch of another input waiting (see the union-of-
// inputs caps in initAutoBuffers).
TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits",
          "[building]")
{
    PlacementFixture f(kFastBeltSpeed_tps);

    const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();

    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);

    // Feed 1 iron_ore (iron_ingot needs 2 — incomplete) then 2 copper_ore
    // (copper_ingot needs 2 — satisfiable) via the west-flowing input belt.
    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
    for (const char* id : fed)
    {
        f.belts.tryPutItem(QPoint(2, 0), makeItem(id));
        f.belts.tick();
        f.bs.tickBeltPull(f.state);
    }

    // copper_ingot cycle is 2.5s; run to completion.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(2.5)) + 2, tick);

    const Building* b = findBuilding(f.state, sid);
    REQUIRE(b != nullptr);

    // Copper was smelted; the lone iron_ore still waits for a second unit.
    bool hasCopperIngot = false;
    for (const Item& item : outputSideItems(*b))
    {
        if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
    }
    REQUIRE(hasCopperIngot);

    const std::map<ItemType, int>::const_iterator ironIt =
        b->inputBuffer.counts.find(ItemType{"iron_ore"});
    REQUIRE(ironIt != b->inputBuffer.counts.end());
    REQUIRE(ironIt->second == 1);
}

// ---------------------------------------------------------------------------
// Belt push → belt tile
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[building]")
{
    PlacementFixture f(kFastBeltSpeed_tps);

    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    f.bs.setRecipe(f.state, id, "mine_iron_ore");

    // Belt at the miner's output port tile (1,1) flowing East.
    f.belts.placeBelt(QPoint(1, 1), Rotation::East);

    Tick tick = 0;
    // Construction (10s) + 1 production cycle (1s) + 1 extra tick.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
             static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
             tick);

    // Item should have been pushed onto the belt this tick or a subsequent one.
    // Run one more tick to ensure tickBeltPush fires after the deposit tick.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);

    const std::optional<Item> item = f.belts.tryTakeItem(eastPort(QPoint(1, 1)));
    REQUIRE(item.has_value());
    REQUIRE(item->type.id == "iron_ore");
}

// Two directly adjacent buildings whose ports meet transfer items with no belt in
// between: a miner's iron_ore output feeds straight into a smelter, which smelts it
// (REQ-MAT-DIRECT-COUPLE).
TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port",
          "[building]")
{
    PlacementFixture f;

    // Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East.
    const BuildingId minerId = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    f.bs.setRecipe(f.state, minerId, "mine_iron_ore");
    // Smelter anchored at (1,1): body (1,1),(2,1),(1,2),(2,2). Its body cell (1,1) is
    // the miner's output-port tile, and its west input edge there faces East, so the
    // two ports meet — no belt placed anywhere.
    const BuildingId smelterId = f.bs.place(f.state, BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0).value();

    Tick tick = 0;
    // Smelter build (15s) + margin for coupling and a smelt cycle.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(30.0)), tick);

    const Building* smelter = findBuilding(f.state, smelterId);
    REQUIRE(smelter != nullptr);
    // iron_ore reached the smelter over the direct coupling and was smelted.
    bool hasIronIngot = false;
    for (const Item& produced : outputSideItems(*smelter))
    {
        if (produced.type.id == "iron_ingot") { hasIronIngot = true; }
    }
    REQUIRE(hasIronIngot);
}

// A producer coupled to a building that cannot accept its item delivers nothing; the
// item stays stuck at the producer's output port (REQ-MAT-DIRECT-COUPLE acceptance).
TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck",
          "[building]")
{
    PlacementFixture f;

    // Producing miner at (0,0), output port (1,1) East.
    const BuildingId minerId = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    f.bs.setRecipe(f.state, minerId, "mine_iron_ore");
    // A second, idle miner anchored at (1,1) occupies the output-port tile but takes
    // no inputs, so it cannot accept the iron_ore.
    const BuildingId sinkId = f.bs.place(f.state, BuildingType::Miner, QPoint(1, 1), Rotation::East, 0).value();

    Tick tick = 0;
    // Both miners build sequentially (10s each), then the producer runs and jams.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)), tick);

    const Building* miner = findBuilding(f.state, minerId);
    const Building* sink  = findBuilding(f.state, sinkId);
    REQUIRE(miner != nullptr);
    REQUIRE(sink != nullptr);
    // Nothing was delivered, and the producer's output side has backed up to its cap.
    REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
    REQUIRE(miner->getOutputItemCount() == miner->outputBuffer.capacity);
}

// ---------------------------------------------------------------------------
// setRecipe clears buffers
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production",
          "[building]")
{
    PlacementFixture f(kFastBeltSpeed_tps);

    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    f.bs.setRecipe(f.state, id, "mine_iron_ore");

    Tick tick = 0;
    // Run until first item is in output buffer.
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
             static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
             tick);

    {
        const Building* b = findBuilding(f.state, id);
        REQUIRE(b != nullptr);
        REQUIRE(b->getOutputItemCount() > 0);
    }

    f.bs.setRecipe(f.state, id, "mine_copper_ore");

    const Building* b = findBuilding(f.state, id);
    // Clearing the output buffer on a recipe change also discards emerging items
    // (REQ-MAT-OUTPUT-EMERGE).
    REQUIRE(b->getOutputItemCount() == 0);
    REQUIRE_FALSE(b->production.has_value());
}

// ---------------------------------------------------------------------------
// Reprocessing plant — output buffer capacity (REQ-MAT-OUTPUT-BUFFER-REPROCESSING)
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll",
          "[building]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
                                  QPoint(0, 0), Rotation::East, 0).value();
    // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
    // single reprocessing recipe is applied automatically on completion.

    // Complete construction (25s).
    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    // reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%),
    // 1 advanced_alloy (10%).  Max per roll = 2.  Capacity = 2 (1× max).
    REQUIRE(b->outputBuffer.capacity == 2);
}

TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls",
          "[building]")
{
    // Seed chosen so first roll produces 2-item output (iron_ingot), filling buffer.
    PlacementFixture f(kFastBeltSpeed_tps);

    const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
                                  QPoint(0, 0), Rotation::East, 0).value();
    // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
    // single reprocessing recipe is applied automatically on completion.

    // Complete construction (25s).
    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);

    // Feed 5 scrap into the building via a belt at an input port.
    // Reprocessing plant body (East rotation) = 3×3 at (0,0).
    // Valid input port: tile (-1,0) flowing East.
    f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
    for (int i = 0; i < 5; ++i)
    {
        f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
        f.belts.tick();
        f.bs.tickBeltPull(f.state);
    }

    // Verify all five scrap were accepted; some may still be travelling inward on
    // the input belt (REQ-MAT-INPUT-INTAKE), so count buffered + in-transit.
    {
        const Building* b = findBuilding(f.state, id);
        REQUIRE(b != nullptr);
        REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
    }

    // Run production cycle (3s = 90 ticks + 1 for the completion tick).
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(3.0)) + 1, tick);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    // Cycle should have completed and output deposited.
    REQUIRE_FALSE(b->outputBuffer.items.empty());
    // No new production: inputs were consumed and not replenished.
    REQUIRE_FALSE(b->production.has_value());
}

// ---------------------------------------------------------------------------
// findRotateInPlaceTarget
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is empty",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    REQUIRE_FALSE(
        findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Belt, QPoint(0, 0), Rotation::East).has_value());
}

TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a queued belt (same type, different rotation)",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();

    const std::optional<BuildingId> result =
        findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Belt, QPoint(0, 0), Rotation::North);
    REQUIRE(result.has_value());
    REQUIRE(*result == id);
}

TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a completed operational belt",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();

    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
    REQUIRE(getAllSites(f.state).empty());

    const std::optional<BuildingId> result =
        findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Belt, QPoint(0, 0), Rotation::South);
    REQUIRE(result.has_value());
    REQUIRE(*result == id);
}

TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building type differs",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);

    // Querying with Splitter at the same tile — type mismatch → nullopt.
    REQUIRE_FALSE(
        findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value());
}

TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    // Even with a coincident same-type tunnel under the ghost, rotate-in-place is
    // never offered for tunnels (REQ-BLD-ROTATE-IN-PLACE exception).
    f.bs.place(f.state, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0);
    f.bs.place(f.state, BuildingType::TunnelExit,  QPoint(-2, 0), Rotation::East, 0);

    REQUIRE_FALSE(
        findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value());
    REQUIRE_FALSE(
        findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value());
}

TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    // Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
    f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);

    // Ghost anchored at (1,0) would cover (1,0),(2,0),(1,1),(2,1):
    // only (1,0) and (1,1) are occupied — not a full coincidence.
    REQUIRE_FALSE(
        findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Smelter, QPoint(1, 0), Rotation::East).has_value());
}

TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    // Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
    // same four body tiles, so findRotateInPlaceTarget must still return the id.
    const BuildingId id = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();

    const std::optional<BuildingId> result =
        findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Smelter, QPoint(0, 0), Rotation::North);
    REQUIRE(result.has_value());
    REQUIRE(*result == id);
}

// ---------------------------------------------------------------------------
// resolveBlueprintGhost
// ---------------------------------------------------------------------------

// What a blueprint ghost does where it meets an existing building
// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). Blueprint placement never
// rotates anything, so a coinciding building must either take the blueprint's settings
// or already match it exactly.

namespace
{
// A single-building blueprint, whose cursor sits on the ghost's own anchor unless a test
// says otherwise. That gesture hit-tests the cursor for its transfer target.
BlueprintGhostResolved resolveOne(const PlacementFixture& f, BuildingType type,
                                  QPoint anchor, Rotation rotation)
{
    return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, anchor);
}

BlueprintGhostResolved resolveOneHovering(const PlacementFixture& f, BuildingType type,
                                          QPoint anchor, Rotation rotation, QPoint cursorTile)
{
    return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, cursorTile);
}

// One ghost of a constellation: no cursor hit-test, judged purely on where it sits.
BlueprintGhostResolved resolveInConstellation(const PlacementFixture& f, BuildingType type,
                                              QPoint anchor, Rotation rotation)
{
    return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, std::nullopt);
}
}  // namespace

TEST_CASE("isConfigurableBuildingType: only types with player-facing settings",
          "[blueprint]")
{
    // The gate on whether a single-building blueprint transfers anything at all.
    CHECK(isConfigurableBuildingType(BuildingType::Miner));
    CHECK(isConfigurableBuildingType(BuildingType::Assembler));
    CHECK(isConfigurableBuildingType(BuildingType::Shipyard));
    CHECK(isConfigurableBuildingType(BuildingType::Splitter));

    // Smelter and Reprocessing Plant run implicit recipes (REQ-BLD-SMELTER,
    // REQ-BLD-REPROCESSING) and the rest have no settings whatsoever.
    CHECK_FALSE(isConfigurableBuildingType(BuildingType::Smelter));
    CHECK_FALSE(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
    CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay));
    CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt));
    CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry));
    CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelExit));
    CHECK_FALSE(isConfigurableBuildingType(BuildingType::Hq));
}

TEST_CASE("resolveBlueprintGhost: free valid cells place a new building", "[blueprint]")
{
    PlacementFixture f;

    // Anchored on the asteroid (x < 0): a miner is all-asteroid cells. BuildingSystem's
    // place() skips the terrain rules, but resolveBlueprintGhost applies them.
    const BlueprintGhostResolved resolved =
        resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
    CHECK(resolved.action == BlueprintGhostAction::PlaceNew);
    CHECK_FALSE(resolved.targetId.has_value());
}

TEST_CASE("resolveBlueprintGhost: terrain-invalid positions are invalid", "[blueprint]")
{
    PlacementFixture f;

    // A miner is all-asteroid (A) cells, so it cannot sit out in space (x >= 0).
    CHECK(resolveOne(f, BuildingType::Miner, QPoint(5, 0), Rotation::East).action
          == BlueprintGhostAction::Invalid);
}

TEST_CASE("resolveBlueprintGhost: overlapping a different building type is invalid",
          "[blueprint]")
{
    PlacementFixture f;

    f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0);

    CHECK(resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::East).action
          == BlueprintGhostAction::Invalid);
}

TEST_CASE("resolveBlueprintGhost: a partial overlap of the same type is invalid",
          "[blueprint]")
{
    PlacementFixture f;

    // Smelter at (-3,0) covers (-3,0),(-2,0),(-3,1),(-2,1); a ghost at (-2,0) covers only
    // two of those, so it coincides with nothing and is an ordinary occupied overlap.
    // Both footprints stay on the asteroid, so terrain is not what fails here.
    f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);

    CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
          == BlueprintGhostAction::Invalid);
}

TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings",
          "[blueprint]")
{
    PlacementFixture f;

    const BuildingId id =
        f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();

    const BlueprintGhostResolved resolved =
        resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
    REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
    REQUIRE(resolved.targetId.has_value());
    CHECK(*resolved.targetId == id);
}

TEST_CASE("resolveBlueprintGhost: a transfer ignores the target's rotation", "[blueprint]")
{
    // A transfer never rotates anything, so which way the target faces cannot matter
    // (REQ-UI-BLUEPRINT-TRANSFER). The ghost snaps to the target's facing rather than
    // keeping the blueprint's.
    PlacementFixture f;

    const BuildingId id =
        f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0).value();

    const BlueprintGhostResolved resolved =
        resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North);
    REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
    REQUIRE(resolved.targetId.has_value());
    CHECK(*resolved.targetId == id);
    CHECK(resolved.ghostRotation == Rotation::East);
}

TEST_CASE("resolveBlueprintGhost: a single-building blueprint transfers from anywhere on the target",
          "[blueprint]")
{
    // The point of hit-testing the cursor instead of comparing footprints. Coincidence
    // needs the ghost's anchor to land on the target's own anchor, so with a 2x2 body
    // three of its four tiles missed and read as an ordinary overlap. Hovering any body
    // tile now targets it, and the ghost snaps onto the building
    // (REQ-UI-BLUEPRINT-TRANSFER).
    PlacementFixture f;

    // Assembler body covers (-3,0),(-2,0),(-3,1),(-2,1); its anchor is (-3,0).
    const BuildingId id =
        f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value();

    const QPoint offAnchorTile(-2, 1);
    const BlueprintGhostResolved resolved =
        resolveOneHovering(f, BuildingType::Assembler, offAnchorTile, Rotation::East,
                           offAnchorTile);
    REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
    CHECK(*resolved.targetId == id);
    CHECK(resolved.ghostAnchor == QPoint(-3, 0));

    // The same misaligned ghost inside a constellation still just overlaps invalidly:
    // a layout is placed where the blueprint puts it, and nothing snaps.
    CHECK(resolveInConstellation(f, BuildingType::Assembler, offAnchorTile, Rotation::East).action
          == BlueprintGhostAction::Invalid);
}

TEST_CASE("resolveBlueprintGhost: hovering a different building type does not transfer",
          "[blueprint]")
{
    PlacementFixture f;

    f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);

    // A miner blueprint over a smelter: the cursor hit-test only matches its own type.
    CHECK(resolveOne(f, BuildingType::Miner, QPoint(-3, 0), Rotation::East).action
          == BlueprintGhostAction::Invalid);
}

TEST_CASE("resolveBlueprintGhost: a construction site is a transfer target too",
          "[blueprint]")
{
    PlacementFixture f;

    // Not ticked to completion, so it is still queued (REQ-BLD-SITE-CONFIG).
    const BuildingId id =
        f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value();
    REQUIRE_FALSE(getAllSites(f.state).empty());

    const BlueprintGhostResolved resolved =
        resolveOne(f, BuildingType::Assembler, QPoint(-3, 0), Rotation::East);
    REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
    CHECK(*resolved.targetId == id);
}

TEST_CASE("resolveBlueprintGhost: a single building with no settings overlaps instead",
          "[blueprint]")
{
    // A belt carries nothing to transfer, so the same footprint is a compatible overlap
    // when the facings match -- and invalid when they do not, since nothing here may
    // re-orient it (REQ-UI-BLUEPRINT-OVERLAP).
    PlacementFixture f;

    const BuildingId id =
        f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0).value();

    const BlueprintGhostResolved matching =
        resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::East);
    REQUIRE(matching.action == BlueprintGhostAction::CompatibleOverlap);
    CHECK(*matching.targetId == id);

    CHECK(resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::North).action
          == BlueprintGhostAction::Invalid);
}

TEST_CASE("resolveBlueprintGhost: a constellation transfers onto a matching building",
          "[blueprint]")
{
    // Blueprint size does not gate the transfer itself: a configurable building already
    // standing where the blueprint wants it, facing the same way, takes its settings
    // whatever else the blueprint holds (REQ-UI-BLUEPRINT-TRANSFER).
    PlacementFixture f;

    const BuildingId id =
        f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();

    const BlueprintGhostResolved resolved =
        resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
    REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
    CHECK(*resolved.targetId == id);
}

TEST_CASE("resolveBlueprintGhost: a constellation still requires a matching rotation",
          "[blueprint]")
{
    // Only the single-building gesture is forgiving about facing. A constellation's
    // ghosts stay where the blueprint puts them, and one that cannot be re-oriented to
    // match blocks the whole placement (REQ-UI-BLUEPRINT-OVERLAP).
    PlacementFixture f;

    f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0);

    CHECK(resolveInConstellation(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North).action
          == BlueprintGhostAction::Invalid);
}

TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain overlaps",
          "[blueprint]")
{
    // One drop can reconfigure some of the buildings already there while leaving others
    // alone: the split is by whether the type has settings at all, not by blueprint size
    // (REQ-UI-BLUEPRINT-OVERLAP).
    PlacementFixture f;

    const BuildingId minerId =
        f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
    const BuildingId smelterId =
        f.bs.place(f.state, BuildingType::Smelter, QPoint(-5, 0), Rotation::East, 0).value();

    const BlueprintGhostResolved miner =
        resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
    REQUIRE(miner.action == BlueprintGhostAction::Transfer);
    CHECK(*miner.targetId == minerId);

    // A smelter runs an implicit recipe (REQ-BLD-SMELTER), so there is nothing to hand
    // over and it is simply left as it is.
    const BlueprintGhostResolved smelter =
        resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East);
    REQUIRE(smelter.action == BlueprintGhostAction::CompatibleOverlap);
    CHECK(*smelter.targetId == smelterId);
}

TEST_CASE("resolveBlueprintGhost: an identical tunnel is a compatible overlap", "[blueprint]")
{
    // findRotateInPlaceTarget refuses tunnels because re-orienting one is unsupported.
    // Nothing is re-oriented here, so that reason does not apply and the tunnel the
    // blueprint wants -- already there, same facing -- is simply left alone.
    PlacementFixture f;

    const BuildingId id =
        f.bs.place(f.state, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0).value();
    f.bs.place(f.state, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0);

    REQUIRE_FALSE(
        findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East)
            .has_value());

    const BlueprintGhostResolved resolved =
        resolveInConstellation(f, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East);
    REQUIRE(resolved.action == BlueprintGhostAction::CompatibleOverlap);
    CHECK(*resolved.targetId == id);
}

// ---------------------------------------------------------------------------
// rotateInPlace
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a construction site",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
    REQUIRE(findSite(f.state, id)->rotation == Rotation::East);

    f.bs.rotateInPlace(f.state, id, Rotation::North);

    REQUIRE(findSite(f.state, id)->rotation == Rotation::North);
}

TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of a queued site",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
    const Tick completesAt = findSite(f.state, id)->completesAt;
    REQUIRE(completesAt > 0);

    f.bs.rotateInPlace(f.state, id, Rotation::South);

    REQUIRE(findSite(f.state, id)->completesAt == completesAt);
}

TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direction on an operational building",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();

    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
    REQUIRE(findBuilding(f.state, id) != nullptr);

    const Building& before = *findBuilding(f.state, id);
    REQUIRE(before.outputPorts[0].direction == Rotation::East);

    f.bs.rotateInPlace(f.state, id, Rotation::North);

    const Building& after = *findBuilding(f.state, id);
    REQUIRE(after.rotation == Rotation::North);
    REQUIRE(after.outputPorts[0].direction == Rotation::North);
}

TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSystem so it still accepts items",
          "[building][rotate-in-place]")
{
    PlacementFixture f;

    const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();

    Tick tick = 0;
    runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);

    f.bs.rotateInPlace(f.state, id, Rotation::North);

    // Belt tile must still be registered after rotation — items can be placed on it.
    REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
}

TEST_CASE("BuildingSystem: rotateInPlace preserves the output filters of a splitter "
          "(REQ-BLD-SPLITTER)", "[building][rotate-in-place]")
{
    PlacementFixture f;

    const QPoint tile(5, 5);
    const BuildingId id = f.bs.place(f.state, BuildingType::Splitter, tile, Rotation::East, 0).value();

    // Run until construction completes, so the splitter is registered with BeltSystem.
    Tick tick = 0;
    while (getAllBuildings(f.state).empty() && tick < 100000)
    {
        runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
    }
    REQUIRE(getAllBuildings(f.state).size() == 1);

    const std::vector<ItemType> filterA{ ItemType{"iron_ore"} };
    const std::vector<ItemType> filterB{ ItemType{"copper_ore"} };
    f.belts.setSplitterFilters(tile, filterA, filterB);

    f.bs.rotateInPlace(f.state, id, Rotation::North);

    // The tile is re-registered with BeltSystem carrying the filters it had before
    // the rotation — rotating must not reset a configured splitter to "accept all".
    const std::optional<BeltSystem::SplitterInfo> info = f.belts.getSplitterInfo(tile);
    REQUIRE(info.has_value());
    REQUIRE(info->filterA == filterA);
    REQUIRE(info->filterB == filterB);
}

TEST_CASE("BuildingSystem: splitter filters configured on a construction site carry over "
          "to the built splitter (REQ-BLD-SITE-CONFIG)", "[building]")
{
    PlacementFixture f;

    const QPoint tile(5, 5);
    const BuildingId id = f.bs.place(f.state, BuildingType::Splitter, tile, Rotation::East, 0).value();
    REQUIRE(id != kInvalidBuildingId);
    REQUIRE(findSite(f.state, id) != nullptr);

    // Configure an output filter on the still-queued splitter site.
    const std::vector<ItemType> filterA{ ItemType{"iron_ore"} };
    const std::vector<ItemType> filterB{};
    f.bs.setSiteSplitterFilters(f.state, id, filterA, filterB);

    // The site reports its two output directions and the stored filters before
    // it is built; it is not yet registered with BeltSystem.
    const std::optional<BeltSystem::SplitterInfo> siteInfo = getSiteSplitterInfo(f.state, f.cfg, id);
    REQUIRE(siteInfo.has_value());
    REQUIRE(siteInfo->filterA == filterA);
    REQUIRE(siteInfo->filterB.empty());
    REQUIRE_FALSE(f.belts.getSplitterInfo(tile).has_value());

    // Run until construction completes.
    Tick tick = 0;
    while (getAllBuildings(f.state).empty() && tick < 100000)
    {
        runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
    }
    REQUIRE(getAllBuildings(f.state).size() == 1);
    REQUIRE(getAllBuildings(f.state)[0].type == BuildingType::Splitter);

    // The built splitter is registered with BeltSystem carrying the filters.
    const std::optional<BeltSystem::SplitterInfo> builtInfo = f.belts.getSplitterInfo(tile);
    REQUIRE(builtInfo.has_value());
    REQUIRE(builtInfo->filterA == filterA);
    REQUIRE(builtInfo->filterB.empty());
}

// ---------------------------------------------------------------------------
// Production status classifier (REQ-UI-STATUS-LIGHT)
// ---------------------------------------------------------------------------

TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[building]")
{
    PlacementFixture f;

    // Pick representative config ids so the test survives content edits.
    std::string minerRecipeId;
    for (const RecipeDef& r : f.cfg.recipes.recipes)
    {
        if (r.building == BuildingType::Miner) { minerRecipeId = r.id; break; }
    }
    REQUIRE_FALSE(minerRecipeId.empty());

    const RecipeDef* assemblerRecipe = nullptr;
    for (const RecipeDef& r : f.cfg.recipes.recipes)
    {
        if (r.building == BuildingType::Assembler && !r.inputs.empty())
        {
            assemblerRecipe = &r;
            break;
        }
    }
    REQUIRE(assemblerRecipe != nullptr);

    std::string shipId;
    for (const ShipDef& s : f.cfg.ships.ships)
    {
        if (!s.schematic.materials.empty()) { shipId = s.id; break; }
    }
    REQUIRE_FALSE(shipId.empty());

    const auto statusOf = [&f](const Building& b) { return getProductionStatus(f.cfg, b); };

    SECTION("non-production buildings show no status light")
    {
        Building belt; belt.type = BuildingType::Belt;
        Building hq;   hq.type   = BuildingType::Hq;
        REQUIRE_FALSE(statusOf(belt).has_value());
        REQUIRE_FALSE(statusOf(hq).has_value());
    }

    SECTION("Miner: unconfigured, producing, output-blocked")
    {
        Building miner; miner.type = BuildingType::Miner;
        REQUIRE(statusOf(miner) == ProductionStatus::Unconfigured);  // no recipe -> grey

        miner.recipeId   = minerRecipeId;
        miner.production = Production{};
        REQUIRE(statusOf(miner) == ProductionStatus::Producing);     // active cycle -> green

        // A miner has no inputs, so its only idle reason is a full output buffer.
        miner.production = std::nullopt;
        miner.outputBuffer.capacity = 2;
        miner.outputBuffer.items    = { makeItem("iron_ore"), makeItem("iron_ore") };
        REQUIRE(statusOf(miner) == ProductionStatus::Blocked);       // -> yellow
    }

    SECTION("Assembler: starved vs blocked, input-missing takes precedence")
    {
        Building assembler; assembler.type = BuildingType::Assembler;
        assembler.recipeId = assemblerRecipe->id;

        // Idle with inputs missing -> red.
        REQUIRE(statusOf(assembler) == ProductionStatus::Starved);

        // Inputs present but idle -> the only remaining reason is a full output
        // buffer -> yellow.
        for (const RecipeIngredient& ing : assemblerRecipe->inputs)
        {
            assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
        }
        REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);

        // Inputs missing AND output full -> red wins over yellow.
        assembler.inputBuffer.counts.clear();
        assembler.outputBuffer.capacity = 2;
        assembler.outputBuffer.items    = { makeItem("x"), makeItem("x") };
        REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
    }

    SECTION("Smelter (auto-recipe) is never grey")
    {
        Building smelter; smelter.type = BuildingType::Smelter;
        // No player-selectable recipe and empty inputs -> red, not grey.
        REQUIRE(statusOf(smelter) == ProductionStatus::Starved);
    }

    SECTION("Shipyard: unconfigured, then starved without materials, then producing")
    {
        Building yard; yard.type = BuildingType::Shipyard;
        REQUIRE(statusOf(yard) == ProductionStatus::Unconfigured);   // no schematic -> grey

        yard.recipeId = shipId;
        REQUIRE(statusOf(yard) == ProductionStatus::Starved);        // no materials -> red

        yard.production = Production{};
        REQUIRE(statusOf(yard) == ProductionStatus::Producing);      // active cycle -> green
    }

    SECTION("Salvage Bay: red when empty, green when holding scrap")
    {
        Building bay; bay.type = BuildingType::SalvageBay;
        bay.outputBuffer.capacity = 20;
        REQUIRE(statusOf(bay) == ProductionStatus::Starved);         // empty -> red

        bay.outputBuffer.items = { makeItem("scrap") };
        REQUIRE(statusOf(bay) == ProductionStatus::Producing);       // holding scrap -> green
    }
}

// ---------------------------------------------------------------------------
// getInputPorts (REQ-BLD-BELT-DRAG snapping, REQ-MAT-INPUT-PORTS)
// ---------------------------------------------------------------------------

namespace
{
    QPoint directionDelta(Rotation direction)
    {
        switch (direction)
        {
            case Rotation::North: return QPoint(0, -1);
            case Rotation::East:  return QPoint(1, 0);
            case Rotation::South: return QPoint(0, 1);
            case Rotation::West:  return QPoint(-1, 0);
        }
        return QPoint(0, 0);
    }

    bool hasInputPort(const std::vector<Port>& ports, QPoint tile, Rotation direction)
    {
        for (const Port& port : ports)
        {
            if (port.tile == tile && port.direction == direction) { return true; }
        }
        return false;
    }

    // Advances the sim until the given site becomes an operational building, or a
    // safety cap is reached.
    void buildToCompletion(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state,
                           BeltSystem& belts, int& stock, BuildingId id, Tick& tick)
    {
        for (int i = 0; i < 20000 && findBuilding(state, id) == nullptr; ++i)
        {
            runTicks(bs, cfg, state, belts, stock, 1, tick);
        }
    }
}

TEST_CASE("BuildingSystem: getInputPorts on a miner site lists every input edge", "[building]")
{
    PlacementFixture f;
    // Miner mask ["AA","A>"] East → body (0,0),(1,0),(0,1); output tile (1,1) East.
    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();

    const std::vector<Port> ports = getInputPorts(f.state, f.cfg, id);

    // Every perimeter edge except the output-port edge at (1,1), each pointing in.
    REQUIRE(ports.size() == 6);
    REQUIRE(hasInputPort(ports, QPoint(-1, 0), Rotation::East));
    REQUIRE(hasInputPort(ports, QPoint(0, -1), Rotation::South));
    REQUIRE(hasInputPort(ports, QPoint(2, 0), Rotation::West));
    REQUIRE(hasInputPort(ports, QPoint(1, -1), Rotation::South));
    REQUIRE(hasInputPort(ports, QPoint(-1, 1), Rotation::East));
    REQUIRE(hasInputPort(ports, QPoint(0, 2), Rotation::North));

    // The output-port tile is never an input port.
    REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::North));
    REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::West));
}

TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built building",
          "[building]")
{
    PlacementFixture f;
    Tick tick = 0;
    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();

    const std::vector<Port> sitePorts = getInputPorts(f.state, f.cfg, id);
    buildToCompletion(f.bs, f.cfg, f.state, f.belts, f.stock, id, tick);
    REQUIRE(findBuilding(f.state, id) != nullptr);
    const std::vector<Port> builtPorts = getInputPorts(f.state, f.cfg, id);

    // The operational path (stored inputPorts) agrees with the site path (mask-derived).
    REQUIRE(builtPorts.size() == sitePorts.size());
    for (const Port& port : sitePorts)
    {
        REQUIRE(hasInputPort(builtPorts, port.tile, port.direction));
    }
}

TEST_CASE("BuildingSystem: getInputPorts invariants hold for a rotated site", "[building]")
{
    PlacementFixture f;
    const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::South, 0).value();
    const ConstructionSite* site = findSite(f.state, id);
    REQUIRE(site != nullptr);

    std::set<std::pair<int, int>> bodySet;
    for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); }

    const std::vector<Port> ports = getInputPorts(f.state, f.cfg, id);
    REQUIRE_FALSE(ports.empty());
    for (const Port& port : ports)
    {
        // Each port tile is outside the footprint...
        REQUIRE(bodySet.count({port.tile.x(), port.tile.y()}) == 0);
        // ...and its direction points into an adjacent body cell.
        const QPoint into = port.tile + directionDelta(port.direction);
        REQUIRE(bodySet.count({into.x(), into.y()}) == 1);
    }
}
