#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 <QRectF>

#include "BeltSystem.h"
#include "Building.h"
#include "BuildingBuffers.h"
#include "BuildingSystem.h"
#include "ProductionSystem.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(ProductionSystem& production, const GameConfig& cfg,
                     FactoryState& state_bs, BeltSystem& belts, int n, Tick& tick)
{
    for (int i = 0; i < n; ++i)
    {
        ConstructionSystem(cfg).tick(state_bs, belts, tick);
        DeconstructionSystem(cfg).tick(state_bs, tick);
        production.tickBeltPull(state_bs, belts);
        production.tickProduction(state_bs, tick);
        production.tickOutputBelts(state_bs, belts);
        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;
    std::mt19937   rng{0};
    BuildingSystem   bs;
    ProductionSystem production;

    // Blocks credited back since the run began. The state is seeded with the configured
    // starting stock (FactoryState.h), so a refund reads as a delta rather than a total.
    int getRefundedBlocks() const
    {
        return state.buildingBlocksStock - cfg.world.startingBuildingBlocks;
    }

    // Everything counts as unlocked unless a test says otherwise, which is what the two
    // unlock-driven rules turn on: output-group eligibility (REQ-LOCK-OUTPUT-POOL) and
    // automatic recipe selection (REQ-BLD-AUTO-RECIPE).
    static std::function<bool(const std::string&)> unlockedUnless(
        std::function<bool(const std::string&)> given)
    {
        return given ? std::move(given)
                     : std::function<bool(const std::string&)>(
                           [](const std::string&) { return true; });
    }

    // 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,
        std::function<bool(const std::string&)> isItemUnlocked = nullptr,
        std::function<bool(const std::string&)> isRecipeUnlocked = nullptr)
        : belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
        , bs(cfg)
        , production(cfg,
             [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
             unlockedUnless(std::move(isItemUnlocked)),
             unlockedUnless(std::move(isRecipeUnlocked)),
             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)));
}

TEST_CASE("buildingsInBox covers a body cell the box only reaches into", "[building]")
{
    PlacementFixture f;

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

    // Body at (0,0),(1,0),(0,1). The box is unsnapped and lies wholly within cell
    // (1,0) without filling it, which is enough: a building is covered when the box
    // overlaps any of its body cells (REQ-UI-MULTI-SELECT, Coverage).
    const std::vector<BuildingId> grazed =
        buildingsInBox(f.state, QRectF(1.6, 0.4, 0.2, 0.2));
    REQUIRE(grazed.size() == 1);
    REQUIRE(grazed.front() == id);

    // (1,1) is the output-port tile, not a body cell, so a box inside it covers
    // nothing even though it is surrounded by the miner's cells.
    REQUIRE(buildingsInBox(f.state, QRectF(1.2, 1.2, 0.5, 0.5)).empty());
}

TEST_CASE("buildingsInBox stops at the edge of a tile-aligned box", "[building]")
{
    PlacementFixture f;

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

    // Body at (0,0),(1,0),(0,1). A click resolves as the unit square of the tile it
    // hit (GameWorldView::getBoxWorldRect), so the box edges land exactly on tile
    // boundaries -- the case where an over-inclusive coverage rule reaches into the
    // neighbouring tiles (REQ-UI-MULTI-SELECT).
    const std::vector<BuildingId> onBody =
        buildingsInBox(f.state, QRectF(1.0, 0.0, 1.0, 1.0));
    REQUIRE(onBody.size() == 1);
    REQUIRE(onBody.front() == id);

    // (2,0) is empty and merely touches the body cell (1,0) along their shared edge;
    // clicking it must select nothing. Same for (1,1), which touches (1,0) and (0,1)
    // along an edge each and (0,0) at a corner.
    REQUIRE(buildingsInBox(f.state, QRectF(2.0, 0.0, 1.0, 1.0)).empty());
    REQUIRE(buildingsInBox(f.state, QRectF(1.0, 1.0, 1.0, 1.0)).empty());
}

// -- 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.production, f.cfg, f.state, f.belts, 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).
    f.bs.deconstruct(f.state, f.belts, id, 0);

    REQUIRE(f.getRefundedBlocks() == 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.production, f.cfg, f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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).
    f.bs.deconstruct(f.state, f.belts, id, tick);
    REQUIRE(isQueuedForDeconstruction(f.state, id));
    REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));
    REQUIRE(f.getRefundedBlocks() == 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.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(0.1)) + 1, tick);
    REQUIRE(findBuilding(f.state, id) == nullptr);
    REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0)));
    REQUIRE(f.getRefundedBlocks() == 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, f.belts, a, tick);
    f.bs.deconstruct(f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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.getRefundedBlocks() == 15 * f.cfg.world.refundPercentage / 100);

    // The second drains next.
    runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(0.1)) + 2, tick);
    REQUIRE(findBuilding(f.state, b) == nullptr);
    REQUIRE(f.getRefundedBlocks() == 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, f.belts, 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, f.belts, id);
    REQUIRE_FALSE(isQueuedForDeconstruction(f.state, id));
    REQUIRE(findBuilding(f.state, id) != nullptr);
    REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));
    REQUIRE(f.getRefundedBlocks() == 0);

    // It is never removed even after more than a deconstruction interval passes.
    runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(0.1)) + 5, tick);
    REQUIRE(findBuilding(f.state, id) != nullptr);
    REQUIRE(f.getRefundedBlocks() == 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, f.belts, 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, f.belts, 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, f.belts, id, tick);
    REQUIRE_FALSE(f.belts.getSplitterInfo(QPoint(0, 0)).has_value());

    f.bs.cancelDeconstruction(f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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.production, f.cfg, f.state, f.belts,
             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 and cycle 2 starts in that same tick
    // (completesAt=360). Cycle 2 completes at tick 360: deposit item -> 2 items held,
    // which fills the buffer (capacity 2), so cycle 3 cannot start.
    // Need to process through tick 360: 361 ticks total.
    runTicks(f.production, f.cfg, f.state, f.belts,
             static_cast<int>(secondsToTicks(10.0))
             + 2 * static_cast<int>(secondsToTicks(1.0)) + 1,
             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());
}

TEST_CASE("BuildingSystem: the next cycle starts on the tick the last one completed",
          "[building]")
{
    // A cycle takes exactly its recipe duration, so a building whose output keeps
    // draining produces at the configured rate (REQ-MAT-CYCLE). An idle tick between
    // cycles would cost a one-second recipe about 3% of its throughput.
    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");

    const Tick cycleTicks = secondsToTicks(1.0);  // mine_iron_ore duration

    Tick tick = 0;
    // Construction completes at tick 300 and cycle 1 starts in that same tick.
    runTicks(f.production, f.cfg, f.state, f.belts,
             static_cast<int>(secondsToTicks(10.0)) + 1, tick);
    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    REQUIRE(b->production.has_value());
    const Tick firstCompletesAt = b->production->completesAt;

    // Process up to and including that completion tick: the next cycle is already
    // running, due exactly one duration later rather than one duration plus a tick.
    runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(cycleTicks), tick);
    b = findBuilding(f.state, id);
    REQUIRE(b->getOutputItemCount() == 1);
    REQUIRE(b->production.has_value());
    REQUIRE(b->production->completesAt == firstCompletesAt + cycleTicks);

    // Nothing hauls the ore away here, so the buffer (capacity 2) would stall the third
    // cycle. Drain it and confirm the cadence holds across the next boundary too.
    f.bs.forEachBuilding(f.state, [](Building& building) {
        building.outputBuffer.items.clear();
        for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
    });
    runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(cycleTicks), tick);
    b = findBuilding(f.state, id);
    REQUIRE(b->production.has_value());
    REQUIRE(b->production->completesAt == firstCompletesAt + 2 * cycleTicks);
}

// ---------------------------------------------------------------------------
// 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.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
    REQUIRE(getProductionBuildingCount(f.state) == 1);

    runTicks(f.production, f.cfg, f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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();
    // A smelter starts with no recipe and picks one from the first material offered to
    // it (REQ-BLD-AUTO-RECIPE), which is what lets it accept the ore below.

    // Complete construction (15s → tick 450+1 = 451 ticks).
    Tick tick = 0;
    runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
    REQUIRE(findBuilding(f.state, sid)->recipeId.empty());

    // 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.production.tickBeltPull(f.state, f.belts);

    const Building* b = findBuilding(f.state, sid);
    REQUIRE(b != nullptr);
    // The ore selected the recipe that consumes it, and its buffers were sized for that
    // recipe alone -- copper ore is not one of its inputs any more.
    REQUIRE(b->recipeId == "iron_ingot");
    REQUIRE(b->inputBuffer.caps.count(ItemType{"iron_ore"}) == 1);
    REQUIRE(b->inputBuffer.caps.count(ItemType{"copper_ore"}) == 0);
    REQUIRE(b->outputBuffer.caps.count(ItemType{"iron_ingot"}) == 1);
    REQUIRE(b->outputBuffer.caps.count(ItemType{"copper_ingot"}) == 0);
    // 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:
// 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.production, f.cfg, f.state, f.belts, 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.production.tickBeltPull(f.state, f.belts);  // 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.production.tickBeltPull(f.state, f.belts);
    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.production, f.cfg, f.state, f.belts, 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.production.tickBeltPull(f.state, f.belts);
    }

    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.production, f.cfg, f.state, f.belts, 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.production.tickBeltPull(f.state, f.belts);
    }

    // iron_ingot recipe cycle is 2s; run to completion.
    runTicks(f.production, f.cfg, f.state, f.belts, 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);
}

// A belt carrying mixed ore is the realistic case for REQ-BLD-AUTO-RECIPE: the first ore
// to arrive settles the recipe, and everything else on that belt is refused rather than
// smelted alongside it.
TEST_CASE("BuildingSystem: mixed ore on one belt leaves the smelter on the first ore's recipe",
          "[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.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);

    // Feed 1 iron_ore, then 2 copper_ore, 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.production.tickBeltPull(f.state, f.belts);
    }

    runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(2.5)) + 2, tick);

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

    // The iron ore came first, so the smelter smelts iron and nothing else. The copper
    // was never taken in, so no copper ingot was made.
    REQUIRE(b->recipeId == "iron_ingot");
    for (const Item& item : outputSideItems(*b))
    {
        REQUIRE(item.type.id != "copper_ingot");
    }
    REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0);

    // The lone iron ore still waits for a second unit: the recipe needs two.
    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);
    REQUIRE_FALSE(b->production.has_value());
}

// ---------------------------------------------------------------------------
// 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.production, f.cfg, f.state, f.belts,
             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.production, f.cfg, f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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(ItemType{"iron_ore"})
            == miner->outputBuffer.caps.at(ItemType{"iron_ore"}));
}

// ---------------------------------------------------------------------------
// 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.production, f.cfg, f.state, f.belts,
             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 -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
// ---------------------------------------------------------------------------

TEST_CASE("ConfigLoader: the outputs shorthand and one output_group load alike",
          "[config]")
{
    // `outputs = [...]` is exactly one group holding those items (REQ-MAT-OUTPUT-GROUP),
    // so a recipe written either way behaves identically.
    PlacementFixture f;

    const RecipeDef* shorthand =
        f.cfg.recipes.findRecipeDef("iron_ingot", BuildingType::Smelter);
    REQUIRE(shorthand != nullptr);
    REQUIRE(shorthand->outputGroups.size() == 1);
    REQUIRE_FALSE(shorthand->outputGroups.front().probability.has_value());

    // Sizing and the cycle gate read it as one group like any other.
    Building smelter; smelter.type = BuildingType::Smelter;
    smelter.recipeId = shorthand->id;
    initBuffers(smelter, *shorthand);
    REQUIRE(smelter.outputBuffer.caps.at(ItemType{"iron_ingot"})
            == 2 * shorthand->outputGroups.front().items.front().amount);
    REQUIRE(recipeOutputsFit(smelter, *shorthand));
}

TEST_CASE("BuildingSystem: a single-group recipe consumes no randomness", "[building]")
{
    // Nothing is picked where there is one group, so no draw is made (REQ-MAT-OUTPUT-GROUP).
    // Drawing here would consume entropy for every ordinary recipe and shift every later
    // random outcome, which is what the two fixtures below would expose: they differ only
    // in how far their generators have been advanced.
    PlacementFixture quiet;
    PlacementFixture advanced;
    for (int i = 0; i < 50; ++i) { (void)advanced.rng(); }

    Tick tickA = 0;
    Tick tickB = 0;
    const BuildingId a =
        quiet.bs.place(quiet.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    const BuildingId b =
        advanced.bs.place(advanced.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
    quiet.bs.setRecipe(quiet.state, a, "mine_iron_ore");
    advanced.bs.setRecipe(advanced.state, b, "mine_iron_ore");

    const int ticks = static_cast<int>(secondsToTicks(10.0)) + 40;
    runTicks(quiet.production, quiet.cfg, quiet.state, quiet.belts, ticks, tickA);
    runTicks(advanced.production, advanced.cfg, advanced.state, advanced.belts, ticks, tickB);

    const Building* minerA = findBuilding(quiet.state, a);
    const Building* minerB = findBuilding(advanced.state, b);
    REQUIRE(minerA != nullptr);
    REQUIRE(minerB != nullptr);
    REQUIRE(minerA->getOutputItemCount() > 0);
    REQUIRE(minerA->getOutputItemCount() == minerB->getOutputItemCount());
    REQUIRE(minerA->production.has_value() == minerB->production.has_value());
}

TEST_CASE("BuildingSystem: a group's items are sized and gated together", "[building]")
{
    // A group yields all of its items at once (REQ-MAT-OUTPUT-GROUP), so each is buffered
    // at twice its own amount and the cycle needs room for all of them at once. No config
    // recipe has a multi-item group yet, so one is built here.
    PlacementFixture f;

    RecipeDef recipe;
    recipe.id              = "multi_item_group";
    recipe.building        = BuildingType::Assembler;
    recipe.durationSeconds = 1.0;
    recipe.inputs.push_back(RecipeIngredient{"iron_ore", 1});
    RecipeOutputGroup group;
    group.items.push_back(RecipeOutput{"iron_ingot", 2});
    group.items.push_back(RecipeOutput{"silicon", 1});
    recipe.outputGroups.push_back(group);

    Building assembler; assembler.type = BuildingType::Assembler;
    initBuffers(assembler, recipe);
    REQUIRE(assembler.outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
    REQUIRE(assembler.outputBuffer.caps.at(ItemType{"silicon"})    == 2);

    // Both fit while both have room.
    REQUIRE(recipeOutputsFit(assembler, recipe));

    // One item of the group short of room blocks the whole cycle, even though the other
    // still has plenty: the group cannot be produced in halves.
    assembler.outputBuffer.items.push_back(makeItem("silicon"));
    assembler.outputBuffer.items.push_back(makeItem("silicon"));
    REQUIRE(outputBufferHasRoom(assembler, ItemType{"iron_ingot"}, 2));
    REQUIRE_FALSE(outputBufferHasRoom(assembler, ItemType{"silicon"}, 1));
    REQUIRE_FALSE(recipeOutputsFit(assembler, recipe));
}

TEST_CASE("BuildingSystem: a single-group recipe produces what nothing yet demands",
          "[building][unlock]")
{
    // The other half of the single-group exemption (REQ-LOCK-OUTPUT-POOL), the half that
    // is about eligibility rather than entropy. Implicit unlocking is derived from demand
    // (REQ-LOCK-IMPLICIT), so an ordinary recipe's output can be perfectly producible
    // while nothing calls for it yet. Testing eligibility here would not gate a drop, it
    // would stop the building producing at all -- which is why the display filters more
    // strictly than the pool does, rather than the pool being brought into line with it.
    PlacementFixture f(std::nullopt, [](const std::string&) { return false; });

    Tick tick = 0;
    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");
    runTicks(f.production, f.cfg, f.state, f.belts,
             static_cast<int>(secondsToTicks(10.0)) + 40, tick);

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

TEST_CASE("BuildingSystem: a group with a locked item is never picked", "[building]")
{
    // A group's items come together, so a group holding any locked item is dropped whole
    // (REQ-LOCK-OUTPUT-POOL). Here only circuit_board is unlocked, so every cycle must
    // yield that group however the weights are stacked -- iron_ingot's group carries the
    // largest weight of the three and would dominate were the filter not applied.
    PlacementFixture f(std::nullopt,
                       [](const std::string& id) { return id == "circuit_board"; });

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

    // Run many cycles, refilling the scrap and draining the output each time so the plant
    // never stalls. A broken filter would show iron_ingot within a few rounds.
    int produced = 0;
    for (int round = 0; round < 20; ++round)
    {
        f.bs.forEachBuilding(f.state, [](Building& building) {
            if (building.type != BuildingType::ReprocessingPlant) { return; }
            building.inputBuffer.counts[ItemType{"scrap"}] =
                building.inputBuffer.caps.at(ItemType{"scrap"});
            building.outputBuffer.items.clear();
            for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
        });
        runTicks(f.production, f.cfg, f.state, f.belts,
                 static_cast<int>(secondsToTicks(3.0)) + 1, tick);

        for (const Item& item : outputSideItems(*findBuilding(f.state, id)))
        {
            CHECK(item.type.id == "circuit_board");
            ++produced;
        }
    }
    REQUIRE(produced > 0);
}

TEST_CASE("a group is judged whole against the unlock state", "[building][unlock]")
{
    // The rule the pool applies, on its own (REQ-LOCK-OUTPUT-POOL): a group counts as
    // unlocked only when every item it yields is, because its items are produced
    // together and one of them being locked is enough to rule the group out.
    RecipeOutputGroup group;
    group.items.push_back(RecipeOutput{"voidsteel", 1});
    group.items.push_back(RecipeOutput{"iron_ingot", 1});

    const std::function<bool(const std::string&)> allUnlocked =
        [](const std::string&) { return true; };
    const std::function<bool(const std::string&)> voidsteelLocked =
        [](const std::string& itemId) { return itemId != "voidsteel"; };

    CHECK(isOutputGroupUnlocked(group, allUnlocked));
    CHECK_FALSE(isOutputGroupUnlocked(group, voidsteelLocked));

    // An empty group yields nothing to be locked out by, so nothing disqualifies it.
    CHECK(isOutputGroupUnlocked(RecipeOutputGroup{}, voidsteelLocked));
}

TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
          "[building]")
{
    PlacementFixture f;

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

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

    // A plant holds no buffers until it has a recipe (REQ-BLD-AUTO-RECIPE); selecting
    // one sizes them, exactly as the first scrap offered to it would.
    REQUIRE(findBuilding(f.state, id)->outputBuffer.caps.empty());
    f.bs.setRecipe(f.state, id, "reprocessing_cycle");

    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%). One roll yields one of them, so each buffer holds twice
    // that outcome's own amount (REQ-MAT-OUTPUT-BUFFER).
    REQUIRE(b->outputBuffer.caps.size() == 3);
    REQUIRE(b->outputBuffer.caps.at(ItemType{"iron_ingot"})    == 4);
    REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) == 2);
    REQUIRE(b->outputBuffer.caps.at(ItemType{"advanced_alloy"}) == 2);
}

TEST_CASE("BuildingSystem: one full output buffer stops the plant even when the others have room",
          "[building]")
{
    // The gate that replaced the old one-item cap: a cycle may only start when *every*
    // outcome would fit, because the roll is committed once it starts (REQ-MAT-CYCLE).
    // Were the plant to roll first and skip a result that does not fit, a player could
    // stall one output belt to filter the distribution towards the other items.
    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.production, f.cfg, f.state, f.belts,
             static_cast<int>(secondsToTicks(25.0)) + 1, tick);

    // Feed a full cycle's scrap (5) so only the output side can hold it back.
    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.production.tickBeltPull(f.state, f.belts);
    }

    // Fill the iron_ingot buffer to its cap and leave the other two empty.
    f.bs.forEachBuilding(f.state, [](Building& building) {
        if (building.type != BuildingType::ReprocessingPlant) { return; }
        const int cap = building.outputBuffer.caps.at(ItemType{"iron_ingot"});
        for (int i = 0; i < cap; ++i)
        {
            building.outputBuffer.items.push_back(makeItem("iron_ingot"));
        }
    });

    runTicks(f.production, f.cfg, f.state, f.belts, 5, tick);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    // circuit_board and advanced_alloy have room, but iron_ingot does not, so no cycle
    // starts at all and the scrap is still waiting.
    REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) > 0);
    REQUIRE(outputBufferHasRoom(*b, ItemType{"circuit_board"}, 1));
    REQUIRE_FALSE(outputBufferHasRoom(*b, ItemType{"iron_ingot"}, 1));
    REQUIRE_FALSE(b->production.has_value());
    REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
    REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Blocked);
}

TEST_CASE("BuildingSystem: reprocessing plant runs a second cycle while holding the first output",
          "[building]")
{
    // Its buffers hold twice each outcome's amount (REQ-MAT-OUTPUT-BUFFER), so a held
    // result no longer stops the next cycle. The old one-item cap made this impossible:
    // whatever the first roll was, the plant stalled until that item left the 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.production, f.cfg, f.state, f.belts,
             static_cast<int>(secondsToTicks(25.0)) + 1, tick);

    // Two cycles' worth of scrap (5 each), which is exactly the input cap.
    f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
    for (int i = 0; i < 10; ++i)
    {
        f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
        f.belts.tick();
        f.production.tickBeltPull(f.state, f.belts);
    }
    REQUIRE(findBuilding(f.state, id)->pendingInputCount(ItemType{"scrap"}) == 10);

    // No belt carries the output away, so the first cycle's result is still held.
    // reprocessing_cycle runs 3s; run through the completion tick.
    runTicks(f.production, f.cfg, f.state, f.belts,
             static_cast<int>(secondsToTicks(3.0)) + 1, tick);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    REQUIRE(b->getOutputItemCount() > 0);
    // Whichever outcome was rolled, every outcome still fits, so the second cycle is
    // already running rather than the plant sitting blocked.
    REQUIRE(b->production.has_value());
    REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Producing);
}

// ---------------------------------------------------------------------------
// Automatic recipe selection (REQ-BLD-AUTO-RECIPE)
// ---------------------------------------------------------------------------

// Places a smelter and runs it to completion, leaving it with no recipe.
static BuildingId buildSmelter(PlacementFixture& f, QPoint anchor, Tick& tick)
{
    const BuildingId id =
        f.bs.place(f.state, BuildingType::Smelter, anchor, Rotation::East, 0).value();
    runTicks(f.production, f.cfg, f.state, f.belts,
             static_cast<int>(secondsToTicks(15.0)) + 1, tick);
    return id;
}

TEST_CASE("BuildingSystem: an unset auto-recipe building is unconfigured", "[building]")
{
    // It holds no recipe until one is offered to it, so it reads grey like any other
    // unconfigured building (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
    PlacementFixture f;
    Tick tick = 0;
    const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b != nullptr);
    REQUIRE(b->recipeId.empty());
    REQUIRE(b->inputBuffer.caps.empty());
    REQUIRE(b->outputBuffer.caps.empty());
    REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Unconfigured);
}

TEST_CASE("BuildingSystem: a set recipe is never replaced by a later material",
          "[building]")
{
    // Once set the recipe is the player's to change: a material belonging to another of
    // its recipes is simply not an accepted input (REQ-BLD-AUTO-RECIPE).
    PlacementFixture f(kFastBeltSpeed_tps);
    Tick tick = 0;
    const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);

    // Iron ore first, which selects the iron recipe.
    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
    f.belts.tick();
    f.production.tickBeltPull(f.state, f.belts);
    REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot");

    // Copper ore next: refused, and the recipe stands.
    f.belts.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
    f.belts.tick();
    f.production.tickBeltPull(f.state, f.belts);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b->recipeId == "iron_ingot");
    REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0);
    // The copper is still sitting on the belt, refused rather than swallowed.
    REQUIRE(f.belts.peekItem(westPort(QPoint(2, 0))).has_value());
}

TEST_CASE("BuildingSystem: a locked recipe is not auto-selected", "[building][unlock]")
{
    // Auto-selection may only reach for a recipe the player could have selected
    // themselves (REQ-BLD-AUTO-RECIPE, REQ-LOCK-UI-RECIPE) -- a building must not drift
    // into running one that can yield them nothing. Here the iron recipe is locked, so
    // the ore that would normally select it selects nothing.
    PlacementFixture f(kFastBeltSpeed_tps, nullptr,
                       [](const std::string& id) { return id != "iron_ingot"; });
    Tick tick = 0;
    const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);

    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
    f.belts.tick();
    f.production.tickBeltPull(f.state, f.belts);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b->recipeId.empty());
    // Unconfigured means no input buffer, so the ore is refused rather than swallowed
    // into a building that has no use for it: it is still on the belt.
    REQUIRE(b->inputBuffer.caps.empty());
    REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 0);
    REQUIRE(f.belts.peekItem(westPort(QPoint(2, 0))).has_value());
}

TEST_CASE("BuildingSystem: an unlocked recipe is still auto-selected", "[building][unlock]")
{
    // The counterpart to the case above: with the iron recipe locked, copper ore still
    // selects the copper recipe, so the gate refuses one recipe rather than disabling
    // automatic selection (REQ-BLD-AUTO-RECIPE).
    PlacementFixture f(kFastBeltSpeed_tps, nullptr,
                       [](const std::string& id) { return id != "iron_ingot"; });
    Tick tick = 0;
    const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);

    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    f.belts.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
    f.belts.tick();
    f.production.tickBeltPull(f.state, f.belts);

    REQUIRE(findBuilding(f.state, id)->recipeId == "copper_ingot");
}

TEST_CASE("BuildingSystem: a manually selected recipe is not overridden", "[building]")
{
    // The player's selection is a recipe like any other, so auto-selection stays out of
    // the way and the smelter refuses ore it does not smelt (REQ-BLD-AUTO-RECIPE).
    PlacementFixture f(kFastBeltSpeed_tps);
    Tick tick = 0;
    const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);

    f.bs.setRecipe(f.state, id, "copper_ingot");
    REQUIRE(findBuilding(f.state, id)->recipeId == "copper_ingot");

    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
    f.belts.tick();
    f.production.tickBeltPull(f.state, f.belts);

    const Building* b = findBuilding(f.state, id);
    REQUIRE(b->recipeId == "copper_ingot");
    REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 0);
}

TEST_CASE("BuildingSystem: selecting a different recipe frees a stuck auto-recipe building",
          "[building]")
{
    // A smelter left holding part of a cycle nothing feeds any more is freed by
    // selecting another recipe, which clears the buffers -- that is why no separate
    // clear action exists (REQ-BLD-AUTO-RECIPE, REQ-MAT-INPUT-BUFFER).
    PlacementFixture f(kFastBeltSpeed_tps);
    Tick tick = 0;
    const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);

    // One iron ore, where the recipe needs two: it can never run.
    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
    f.belts.tick();
    f.production.tickBeltPull(f.state, f.belts);
    runTicks(f.production, f.cfg, f.state, f.belts, 30, tick);

    const Building* stuck = findBuilding(f.state, id);
    REQUIRE(stuck->recipeId == "iron_ingot");
    REQUIRE(stuck->pendingInputCount(ItemType{"iron_ore"}) == 1);
    REQUIRE_FALSE(stuck->production.has_value());

    f.bs.setRecipe(f.state, id, "copper_ingot");
    const Building* freed = findBuilding(f.state, id);
    REQUIRE(freed->recipeId == "copper_ingot");
    REQUIRE(freed->pendingInputCount(ItemType{"iron_ore"}) == 0);
    REQUIRE(freed->inputBuffer.caps.count(ItemType{"copper_ore"}) == 1);
}

TEST_CASE("BuildingSystem: selecting (Auto) returns the building to automatic selection",
          "[building]")
{
    // The dialog's clearing option unsets the recipe rather than leaving the building
    // idle for good: the next material offered selects one again (REQ-BLD-AUTO-RECIPE).
    PlacementFixture f(kFastBeltSpeed_tps);
    Tick tick = 0;
    const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);

    f.bs.setRecipe(f.state, id, "copper_ingot");
    f.bs.setRecipe(f.state, id, std::string());  // the "(Auto)" option
    REQUIRE(findBuilding(f.state, id)->recipeId.empty());

    f.belts.placeBelt(QPoint(2, 0), Rotation::West);
    f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
    f.belts.tick();
    f.production.tickBeltPull(f.state, f.belts);

    REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot");
}

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();
    // The plant selects its recipe from the first scrap offered to it
    // (REQ-BLD-AUTO-RECIPE), which is what the belt feeding below does.

    // Complete construction (25s).
    Tick tick = 0;
    runTicks(f.production, f.cfg, f.state, f.belts, 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.production.tickBeltPull(f.state, f.belts);
    }

    // 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.production, f.cfg, f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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 carry a recipe like any other, even though they can
    // also select it themselves (REQ-BLD-AUTO-RECIPE).
    CHECK(isConfigurableBuildingType(BuildingType::Smelter));
    CHECK(isConfigurableBuildingType(BuildingType::ReprocessingPlant));

    // The rest have no settings whatsoever.
    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);

    // Judged on where it sits, with no cursor to hit-test: the overlap is what decides.
    CHECK(resolveInConstellation(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
          == BlueprintGhostAction::Invalid);

    // With the cursor on the existing smelter the single-building gesture answers first
    // and hands it the settings, since a smelter carries a recipe (REQ-BLD-AUTO-RECIPE,
    // REQ-UI-BLUEPRINT-TRANSFER) -- the same as for a miner.
    CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
          == BlueprintGhostAction::Transfer);
}

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 carries a recipe too now (REQ-BLD-AUTO-RECIPE), so a blueprint of one
    // has something to hand over just as the miner does.
    const BlueprintGhostResolved smelter =
        resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East);
    REQUIRE(smelter.action == BlueprintGhostAction::Transfer);
    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, f.belts, 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, f.belts, 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.production, f.cfg, f.state, f.belts, 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, f.belts, 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.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);

    f.bs.rotateInPlace(f.state, f.belts, 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.production, f.cfg, f.state, f.belts, 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, f.belts, 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.production, f.cfg, f.state, f.belts, 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 an output buffer with no
        // room for the next cycle's output.
        miner.production = std::nullopt;
        miner.outputBuffer.caps[ItemType{"iron_ore"}] = 2;
        miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
        REQUIRE(statusOf(miner) == ProductionStatus::Blocked);       // -> yellow

        // One item handed off: the next cycle fits again, so the idle tick between two
        // cycles reads as producing rather than blinking yellow (REQ-UI-STATUS-LIGHT).
        miner.outputBuffer.items.pop_back();
        REQUIRE(statusOf(miner) == ProductionStatus::Producing);     // -> green

        // An emerging item has not left the building, so it fills the freed room and
        // blocks the cycle again (REQ-MAT-OUTPUT-EMERGE).
        miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } });
        REQUIRE(miner.getOutputItemCount(ItemType{"iron_ore"}) == 2);
        REQUIRE(statusOf(miner) == ProductionStatus::Blocked);       // -> yellow

        // Another item's backlog is measured against its own buffer, so it changes
        // nothing here (REQ-MAT-OUTPUT-BUFFER).
        miner.outputBuffer.caps[ItemType{"copper_ore"}] = 2;
        miner.outputBuffer.items.push_back(makeItem("copper_ore"));
        REQUIRE(statusOf(miner) == ProductionStatus::Blocked);
        miner.outputBuffer.items.clear();
        REQUIRE(statusOf(miner) == ProductionStatus::Producing);
    }

    SECTION("Assembler: starved, the transient between cycles, then blocked")
    {
        Building assembler; assembler.type = BuildingType::Assembler;
        assembler.recipeId = assemblerRecipe->id;
        // Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
        initBuffers(assembler, *assemblerRecipe);

        const std::string outputItemId =
            assemblerRecipe->outputGroups.front().items.front().item;
        int cycleOutput = 0;
        for (const RecipeOutput& out : assemblerRecipe->outputGroups.front().items)
        {
            cycleOutput += out.amount;
        }
        REQUIRE(cycleOutput > 0);

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

        // Inputs present and the output fits: nothing blocks a cycle, so the building
        // is merely between cycles -> green, not yellow (REQ-UI-STATUS-LIGHT).
        for (const RecipeIngredient& ing : assemblerRecipe->inputs)
        {
            assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
        }
        REQUIRE(statusOf(assembler) == ProductionStatus::Producing);

        // That item's own buffer filled to within less than one cycle's output of its
        // capacity: no cycle can start -> yellow.
        for (int i = 0; i < cycleOutput + 1; ++i)
        {
            assembler.outputBuffer.items.push_back(makeItem(outputItemId));
        }
        REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);

        // Inputs missing AND output blocked -> red wins over yellow.
        assembler.inputBuffer.counts.clear();
        REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
    }

    SECTION("A multi-item cycle blocks before the output buffer is full")
    {
        // Free space smaller than one cycle's output stops the cycle even though the
        // buffer still has room, so yellow is not the same as "full" (REQ-MAT-CYCLE).
        const RecipeDef* multiOutputRecipe = nullptr;
        for (const RecipeDef& r : f.cfg.recipes.recipes)
        {
            if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; }
            int total = 0;
            for (const RecipeOutput& out : r.outputGroups.front().items)
            {
                total += out.amount;
            }
            if (total >= 2) { multiOutputRecipe = &r; break; }
        }
        REQUIRE(multiOutputRecipe != nullptr);

        int cycleOutput = 0;
        for (const RecipeOutput& out : multiOutputRecipe->outputGroups.front().items)
        {
            cycleOutput += out.amount;
        }
        const std::string outputItemId =
            multiOutputRecipe->outputGroups.front().items.front().item;

        Building assembler; assembler.type = BuildingType::Assembler;
        assembler.recipeId = multiOutputRecipe->id;
        initBuffers(assembler, *multiOutputRecipe);
        for (const RecipeIngredient& ing : multiOutputRecipe->inputs)
        {
            assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
        }

        // One item short of a full cycle's worth of free space.
        for (int i = 0; i < cycleOutput + 1; ++i)
        {
            assembler.outputBuffer.items.push_back(makeItem(outputItemId));
        }
        REQUIRE(assembler.getOutputItemCount(ItemType{outputItemId})
                < assembler.outputBuffer.caps.at(ItemType{outputItemId}));
        REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);

        // Exactly one cycle's worth of free space: the cycle fits again.
        assembler.outputBuffer.items.pop_back();
        REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
    }

    SECTION("Reprocessing Plant: blocked once any possible roll has no room")
    {
        // The plant rolls one of its outputs per cycle (REQ-BLD-REPROCESSING) and the
        // roll is committed at cycle start, so every outcome has to fit before it may
        // begin: one full buffer blocks it whatever room the others have (REQ-MAT-CYCLE).
        const RecipeDef* reprocessingRecipe = nullptr;
        for (const RecipeDef& r : f.cfg.recipes.recipes)
        {
            if (r.building == BuildingType::ReprocessingPlant && !r.inputs.empty())
            {
                reprocessingRecipe = &r;
                break;
            }
        }
        REQUIRE(reprocessingRecipe != nullptr);
        REQUIRE(reprocessingRecipe->outputGroups.size() >= 2);

        Building plant; plant.type = BuildingType::ReprocessingPlant;
        plant.recipeId = reprocessingRecipe->id;
        initBuffers(plant, *reprocessingRecipe);
        for (const RecipeIngredient& ing : reprocessingRecipe->inputs)
        {
            plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
        }

        // Every buffer empty: whatever the roll turns out to be, it fits -> green.
        REQUIRE(statusOf(plant) == ProductionStatus::Producing);

        // Fill one outcome's buffer and leave the rest untouched -> yellow, even though
        // the other outcomes still have room.
        const std::string firstItemId =
            reprocessingRecipe->outputGroups.front().items.front().item;
        const std::string lastItemId =
            reprocessingRecipe->outputGroups.back().items.front().item;
        for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
        {
            plant.outputBuffer.items.push_back(makeItem(firstItemId));
        }
        REQUIRE(outputBufferHasRoom(plant, ItemType{lastItemId}, 1));
        REQUIRE_FALSE(outputBufferHasRoom(plant, ItemType{firstItemId}, 1));
        REQUIRE(statusOf(plant) == ProductionStatus::Blocked);

        // Without the scrap it is starved regardless of the buffers.
        plant.inputBuffer.counts.clear();
        REQUIRE(statusOf(plant) == ProductionStatus::Starved);
    }

    SECTION("Smelter: grey until it has a recipe, then judged like any other building")
    {
        // It holds no recipe until one is offered to it, so grey now applies to it too
        // (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
        Building smelter; smelter.type = BuildingType::Smelter;
        REQUIRE(statusOf(smelter) == ProductionStatus::Unconfigured);

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

        smelter.recipeId = smelterRecipe->id;
        initBuffers(smelter, *smelterRecipe);
        REQUIRE(statusOf(smelter) == ProductionStatus::Starved);  // recipe, but no ore

        for (const RecipeIngredient& ing : smelterRecipe->inputs)
        {
            smelter.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
        }
        REQUIRE(statusOf(smelter) == ProductionStatus::Producing);
    }

    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.caps[ItemType{"scrap"}] = 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(ProductionSystem& production, const GameConfig& cfg,
                           FactoryState& state, BeltSystem& belts, BuildingId id,
                           Tick& tick)
    {
        for (int i = 0; i < 20000 && findBuilding(state, id) == nullptr; ++i)
        {
            runTicks(production, cfg, state, belts, 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.production, f.cfg, f.state, f.belts, 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);
    }
}
