#include "catch.hpp"

#include <map>
#include <optional>
#include <string>
#include <vector>

#include <QPoint>
#include <QRect>

#include "BeltSystem.h"
#include "Item.h"
#include "ItemType.h"
#include "Port.h"
#include "Rotation.h"
#include "Tick.h"

// Belt speed of 30 t/s means progress/tick = 30/30 = 1.0.
// One tick() call advances any item exactly one full tile.
// This makes test assertions on item positions simple and deterministic.
static constexpr double kFastBeltSpeed = static_cast<double>(kTickRateHz);

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;
}

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

TEST_CASE("BeltSystem: tryPutItem succeeds on registered belt", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);

    REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
}

TEST_CASE("BeltSystem: tryPutItem fails on unregistered tile", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    REQUIRE_FALSE(bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));
}

TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);
    bs.removeTile(tile);

    REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
}

// ---------------------------------------------------------------------------
// Capacity
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: four items fit in one tile", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);

    REQUIRE(bs.tryPutItem(tile, makeItem("a"), Rotation::East));
    REQUIRE(bs.tryPutItem(tile, makeItem("b"), Rotation::East));
    REQUIRE(bs.tryPutItem(tile, makeItem("c"), Rotation::East));
    REQUIRE(bs.tryPutItem(tile, makeItem("d"), Rotation::East));
}

TEST_CASE("BeltSystem: fifth tryPutItem on full tile returns false", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);

    bs.tryPutItem(tile, makeItem("a"), Rotation::East);
    bs.tryPutItem(tile, makeItem("b"), Rotation::East);
    bs.tryPutItem(tile, makeItem("c"), Rotation::East);
    bs.tryPutItem(tile, makeItem("d"), Rotation::East);

    REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("e"), Rotation::East));
}

// ---------------------------------------------------------------------------
// tryTakeItem
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: tryTakeItem returns placed item after reaching output edge", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);
    bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
    bs.tick();  // advance to output edge

    const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile));

    REQUIRE(taken.has_value());
    REQUIRE(taken->type.id == "iron_ore");
}

TEST_CASE("BeltSystem: tryTakeItem requires item to reach output edge before yielding", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);
    bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);

    // Item placed but not yet at output edge — must not be available.
    REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
    REQUIRE_FALSE(bs.peekItem(eastPort(tile)).has_value());

    // After one tick the item has reached progress 1.0 and is available.
    bs.tick();
    REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value());
}

TEST_CASE("BeltSystem: tryTakeItem with two items returns both after each reaches output edge", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);
    bs.tryPutItem(tile, makeItem("first"), Rotation::East);
    bs.tryPutItem(tile, makeItem("second"), Rotation::East);

    // Front item reaches output edge after one tick.
    bs.tick();
    const std::optional<Item> taken1 = bs.tryTakeItem(eastPort(tile));
    REQUIRE(taken1.has_value());

    // Back item (now promoted to front) needs another tick to reach output edge.
    bs.tick();
    const std::optional<Item> taken2 = bs.tryTakeItem(eastPort(tile));
    REQUIRE(taken2.has_value());

    REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
}

TEST_CASE("BeltSystem: tryTakeItem returns nullopt on empty tile", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    bs.placeBelt(QPoint(0, 0), Rotation::East);

    REQUIRE_FALSE(bs.tryTakeItem(eastPort(QPoint(0, 0))).has_value());
}

// ---------------------------------------------------------------------------
// tick() — item advancement
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: item transfers from tile A to tile B and becomes available after two ticks", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tileA(0, 0);
    const QPoint tileB(1, 0);
    bs.placeBelt(tileA, Rotation::East);
    bs.placeBelt(tileB, Rotation::East);

    bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
    bs.tick();  // item reaches output edge of A, moves to B at progress 0
    bs.tick();  // item reaches output edge of B

    REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value());
    const std::optional<Item> inB = bs.tryTakeItem(eastPort(tileB));
    REQUIRE(inB.has_value());
    REQUIRE(inB->type.id == "iron_ore");
}

TEST_CASE("BeltSystem: item stays at progress 1.0 when next tile is absent", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tileA(0, 0);
    bs.placeBelt(tileA, Rotation::East);

    bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
    bs.tick();

    // Item should still be on tileA (no registered tile to the east).
    REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value());
}

TEST_CASE("BeltSystem: item traverses 3-tile chain in 3 ticks (one per tile)", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tileA(0, 0);
    const QPoint tileB(1, 0);
    const QPoint tileC(2, 0);
    bs.placeBelt(tileA, Rotation::East);
    bs.placeBelt(tileB, Rotation::East);
    bs.placeBelt(tileC, Rotation::East);

    bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
    bs.tick();  // A output edge → moves to B at progress 0
    bs.tick();  // B output edge → moves to C at progress 0
    bs.tick();  // C output edge → available for pickup

    REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value());
    REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileB)).has_value());
    REQUIRE(bs.tryTakeItem(eastPort(tileC)).has_value());
}

TEST_CASE("BeltSystem: item stays blocked when next tile is full", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tileA(0, 0);
    const QPoint tileB(1, 0);
    bs.placeBelt(tileA, Rotation::East);
    bs.placeBelt(tileB, Rotation::East);

    // Fill tileB to capacity.
    bs.tryPutItem(tileB, makeItem("b1"), Rotation::East);
    bs.tryPutItem(tileB, makeItem("b2"), Rotation::East);
    bs.tryPutItem(tileB, makeItem("b3"), Rotation::East);
    bs.tryPutItem(tileB, makeItem("b4"), Rotation::East);

    // Place item in tileA — should be blocked.
    bs.tryPutItem(tileA, makeItem("a1"), Rotation::East);
    bs.tick();

    // Item in tileA must still be there.
    REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value());
}

TEST_CASE("BeltSystem: belt second slot is capped at progress 0.75", "[belt]")
{
    // Use progress/tick = 0.4 so the cap is observable: without it, slot[1] would
    // advance to 0.8 while slot[0] is stuck at 1.0. With the 0.75 cap it stays
    // at 0.75 and needs exactly 1 more tick after promotion.
    const double medBeltSpeed = 0.4 * static_cast<double>(kTickRateHz);
    BeltSystem bs(medBeltSpeed);

    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);

    // Advance front item to the output edge; it stays there (no next tile).
    bs.tryPutItem(tile, makeItem("front_item"), Rotation::East);
    bs.tick();  // slot[0]: 0.4
    bs.tick();  // slot[0]: 0.8
    bs.tick();  // slot[0]: 1.0 (capped, stuck)

    // Place second item; slot[0] is at 1.0.
    bs.tryPutItem(tile, makeItem("back_item"), Rotation::East);
    bs.tick();  // slot[1]: 0.4
    bs.tick();  // slot[1] would reach 0.8 — capped at 0.75

    // Remove front; slot[1] (now promoted to slot[0]) must be at 0.75.
    REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value());
    // At 0.4/tick, 0.75 → 1.0 (capped) after one tick — available.
    bs.tick();
    REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value());
}

// ---------------------------------------------------------------------------
// clearTiles
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: clearTiles removes all items from specified tiles", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);
    bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
    bs.tryPutItem(tile, makeItem("copper_ore"), Rotation::East);

    bs.clearTiles({tile});

    REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
}

// ---------------------------------------------------------------------------
// forEachVisualItem
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: forEachVisualItem visits items inside viewport", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(5, 5);
    bs.placeBelt(tile, Rotation::East);
    bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);

    int count = 0;
    bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });

    REQUIRE(count == 1);
}

TEST_CASE("BeltSystem: forEachVisualItem skips items outside viewport", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(50, 50);
    bs.placeBelt(tile, Rotation::East);
    bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);

    int count = 0;
    bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });

    REQUIRE(count == 0);
}

TEST_CASE("BeltSystem: forEachVisualItem reports correct ItemType", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);
    bs.tryPutItem(tile, makeItem("copper_ingot"), Rotation::East);

    std::vector<ItemType> seen;
    bs.forEachVisualItem(QRect(-1, -1, 10, 10), [&seen](VisualItem vi)
    {
        seen.push_back(vi.type);
    });

    REQUIRE(seen.size() == 1);
    REQUIRE(seen[0].id == "copper_ingot");
}

// ---------------------------------------------------------------------------
// countItems (REQ-UI-BELT-ITEMS)
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: countItems sums one item type over several tiles", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint first(0, 0);
    const QPoint second(1, 0);
    bs.placeBelt(first, Rotation::East);
    bs.placeBelt(second, Rotation::East);
    bs.tryPutItem(first, makeItem("iron_ore"), Rotation::East);
    bs.tryPutItem(second, makeItem("iron_ore"), Rotation::East);
    bs.tryPutItem(second, makeItem("copper_ore"), Rotation::East);

    const std::map<ItemType, int> counts = bs.countItems({first, second});

    REQUIRE(counts.size() == 2);
    REQUIRE(counts.at(ItemType{"iron_ore"}) == 2);
    REQUIRE(counts.at(ItemType{"copper_ore"}) == 1);
}

TEST_CASE("BeltSystem: countItems ignores tiles outside the given selection", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint selected(0, 0);
    const QPoint other(1, 0);
    bs.placeBelt(selected, Rotation::East);
    bs.placeBelt(other, Rotation::East);
    bs.tryPutItem(other, makeItem("iron_ore"), Rotation::East);

    REQUIRE(bs.countItems({selected}).empty());
}

TEST_CASE("BeltSystem: countItems counts a splitter's held items", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint feed(0, 0);
    const QPoint splitter(1, 0);
    bs.placeBelt(feed, Rotation::East);
    bs.placeSplitter(splitter, Rotation::North, Rotation::South);

    // One item routed onto an output slot, one still unassigned in the back.
    bs.tryPutItem(splitter, makeItem("iron_ore"), Rotation::East);
    bs.tick();
    bs.tryPutItem(splitter, makeItem("iron_ore"), Rotation::East);

    REQUIRE(bs.countItems({splitter}).at(ItemType{"iron_ore"}) == 2);
}

TEST_CASE("BeltSystem: countItems counts transit items on the exit, not the entry",
          "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    const QPoint entry(0, 0);
    const QPoint exit(5, 0);

    bs.placeTunnelEntry(entry, Rotation::East, 10);
    bs.placeTunnelExit(exit, Rotation::East);

    bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
    bs.tick();  // item enters the entry's front slot
    bs.tick();  // entry front -> transit

    // Invisible in the world and drawn nowhere, so the exit's count is the only place
    // the player can see it (REQ-BLD-TUNNEL-TRANSIT).
    REQUIRE(bs.countItems({entry}).empty());
    REQUIRE(bs.countItems({exit}).at(ItemType{"iron_ore"}) == 1);
}

TEST_CASE("BeltSystem: countItems on empty tiles reports nothing", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::East);

    REQUIRE(bs.countItems({tile}).empty());
    REQUIRE(bs.countItems({}).empty());
}

// ---------------------------------------------------------------------------
// Splitter — basic alternation (no filters)
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]")
{
    // Layout: tileIn -> splitter -> tileA (North output)
    //                           -> tileB (South output)
    // Pipeline per item: tileIn(1) -> back(2) -> front(3) -> output belt(4)
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);
    const QPoint tileA(1, -1);   // North of splitter
    const QPoint tileB(1,  1);   // South of splitter

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
    bs.placeBelt(tileA, Rotation::North);
    bs.placeBelt(tileB, Rotation::South);

    bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
    bs.tick();   // item1: tileIn -> splitter back (progress 0)

    bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
    bs.tick();   // item1 back -> 0.5 -> frontA; item2 advances but back is occupied
    bs.tick();   // item1 frontA -> 1.0 -> tileA; item2 enters splitter back
    bs.tick();   // item2 back -> 0.5 -> frontB; item1 at tileA output edge
    bs.tick();   // item2 frontB -> 1.0 -> tileB
    bs.tick();   // item2 at tileB output edge

    const bool inA = bs.tryTakeItem(Port{tileA, Rotation::North}).has_value();
    const bool inB = bs.tryTakeItem(Port{tileB, Rotation::South}).has_value();

    // One item in each output — alternation worked.
    REQUIRE(inA);
    REQUIRE(inB);
}

// ---------------------------------------------------------------------------
// Splitter — filter routing
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: splitter routes to preferred output when item matches both filters", "[belt]")
{
    // filterA = iron_ore, filterB = {} (accept all) → iron_ore matches both.
    // With nextOutputIsA=true initially, alternation sends the item to A.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);
    const QPoint tileA(1, -1);   // North output
    const QPoint tileB(1,  1);   // South output

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
    bs.placeBelt(tileA, Rotation::North);
    bs.placeBelt(tileB, Rotation::South);

    bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {});

    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick();   // tileIn -> splitter back
    bs.tick();   // back -> frontA (both match, alternation, preferred A)
    bs.tick();   // frontA -> tileA
    bs.tick();   // item at tileA output edge

    REQUIRE(bs.tryTakeItem(Port{tileA, Rotation::North}).has_value());
    REQUIRE_FALSE(bs.tryTakeItem(Port{tileB, Rotation::South}).has_value());
}

TEST_CASE("BeltSystem: splitter routes item to output A when only filter A matches", "[belt]")
{
    // filterA = {iron_ore}, filterB = {copper_ore}: iron_ore matches A exclusively → goes to A.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}});

    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick();   // tileIn -> splitter back
    bs.tick();   // back -> frontA (exclusive match to A)
    bs.tick();   // frontA reaches 1.0; no downstream belt, waits for building pickup

    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
    REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}

TEST_CASE("BeltSystem: splitter routes item to output B when only filter B matches", "[belt]")
{
    // filterA = {copper_ore}, filterB = {iron_ore}: iron_ore matches B exclusively → goes to B.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ore"}});

    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick();
    bs.tick();
    bs.tick();

    REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}

TEST_CASE("BeltSystem: splitter alternates A then B when item matches both explicit filters", "[belt]")
{
    // filterA = {iron_ore}, filterB = {iron_ore}: both match → strict alternation A, B, A.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});

    // Item 1 → preferred A (nextOutputIsA=true initially).
    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick(); bs.tick(); bs.tick();
    REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());

    // Item 2 → preferred B (nextOutputIsA toggled to false).
    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick(); bs.tick(); bs.tick();
    REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());

    // Item 3 → preferred A again (nextOutputIsA toggled back to true).
    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick(); bs.tick(); bs.tick();
    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
    REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}

TEST_CASE("BeltSystem: splitter routes unmatched item to the unfiltered output", "[belt]")
{
    // filterA = {copper_ore} (non-empty), filterB = {} (accept all).
    // iron_ore: matchesA=false, matchesB=true → goes to B.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {});

    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick(); bs.tick(); bs.tick();

    REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}

TEST_CASE("BeltSystem: splitter stalls when item matches neither filter", "[belt]")
{
    // filterA = {copper_ore}, filterB = {iron_ingot}: iron_ore matches neither → stall.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ingot"}});

    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick();   // tileIn -> splitter back
    bs.tick();   // back reaches 0.5; routing fires but stalls (no filter match)
    bs.tick();   // back stays at 0.5; stall persists

    REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
    REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}

TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blocked", "[belt]")
{
    // filterA = filterB = {iron_ore}: both match → alternation.
    // When preferred output is occupied, item goes to the other without toggling nextOutputIsA.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});

    // Item 1 → preferred A (nextOutputIsA=true → false after routing).
    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick(); bs.tick(); bs.tick();   // frontA = item1 at 1.0

    // Item 2 → preferred B (nextOutputIsA=false → true after routing). Take item2 to free frontB.
    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick(); bs.tick(); bs.tick();   // frontB = item2 at 1.0
    REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());

    // frontA still holds item1; nextOutputIsA=true (prefer A).
    // Item 3: both match, preferred A is occupied → fallback to B without toggling nextOutputIsA.
    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick(); bs.tick(); bs.tick();   // frontB = item3 at 1.0

    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());  // item1 still in A
    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());  // item3 in B via fallback

    // nextOutputIsA was not toggled by the fallback: next item should still prefer A.
    REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());  // free frontA
    REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());  // free frontB
    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick(); bs.tick(); bs.tick();
    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());  // item4 → A (preferA still true)
    REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}

TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75", "[belt]")
{
    // When the preferred output is blocked, the diverted item is dropped onto the
    // open output near its edge (progress 0.75) instead of at progress 0.0. This
    // closes the large gap that would otherwise appear between items leaving the
    // open side of a half-blocked splitter.
    //
    // Progress/tick = 0.25 so the 0.0-vs-0.75 entry position is observable: a
    // normally-routed item starts at 0.0, a fallback item starts at 0.75.
    const double quarterSpeed = 0.25 * static_cast<double>(kTickRateHz);
    BeltSystem bs(quarterSpeed);

    const QPoint tileSpl(1, 0);
    const QPoint tileB(1, 1);    // South output belt; North output has no belt (blocked).

    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
    bs.placeBelt(tileB, Rotation::South);

    // Reads a named item's progress along the South output via the rendering contract.
    // slotWorldPos maps a South-bound slot on tileSpl (y = 0) to worldPos.y == progress.
    // Matching by id avoids the blocked North item, which also renders at worldPos.y 0.
    auto southProgressOf = [&bs](const std::string& id) -> std::optional<double>
    {
        std::optional<double> progress;
        bs.forEachVisualItem(QRect(-5, -5, 20, 20), [&](VisualItem vi)
        {
            if (vi.type.id == id)
            {
                progress = vi.worldPos.y();
            }
        });
        return progress;
    };

    // Permanently block output A: route one item to frontA where it sticks at 1.0
    // (North has no downstream tile, so it can never move out).
    bs.tryPutItem(tileSpl, makeItem("blockA"), Rotation::East);
    bs.tick();  // back: 0.25
    bs.tick();  // back: 0.5 -> frontA at 0.75 (preferred A), nextOutputIsA = false
    bs.tick(); bs.tick();  // frontA: 0.75 -> 1.0 (stuck, no North downstream)

    // Cycle one item through B as the *preferred* output (also enters at 0.75) to
    // flip nextOutputIsA back to true and free frontB for the fallback case below.
    bs.tryPutItem(tileSpl, makeItem("toB_pref"), Rotation::East);
    bs.tick();  // back: 0.25
    bs.tick();  // back: 0.5 -> frontB at 0.75 (preferred B), nextOutputIsA = true
    REQUIRE(southProgressOf("toB_pref") == Approx(0.75));

    // One tick reaches the edge and hands off to tileB; the rest just clear frontB.
    bs.tick(); bs.tick();  // frontB: 0.75 -> 1.0 -> tileB, then empty

    // Next item prefers A again (nextOutputIsA == true), but A is still blocked,
    // so it falls back to B — and must enter near the edge at progress 0.75.
    bs.tryPutItem(tileSpl, makeItem("toB_fallback"), Rotation::East);
    bs.tick();  // back: 0.25
    bs.tick();  // back: 0.5 -> fallback routes to frontB at 0.75
    REQUIRE(southProgressOf("toB_fallback") == Approx(0.75));
}

TEST_CASE("BeltSystem: splitter with an exclusive filter enters its only output at progress 0.75", "[belt]")
{
    // An item that matches only one filter has a single eligible output. Like the
    // blocked-fallback case, it must enter near the edge (progress 0.75) so the
    // one-item-wide front does not throttle that output and open large gaps.
    const double quarterSpeed = 0.25 * static_cast<double>(kTickRateHz);
    BeltSystem bs(quarterSpeed);

    const QPoint tileSpl(1, 0);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
    bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}});

    // Inverts slotWorldPos to recover a named item's progress along the given output.
    auto progressOf = [&bs, tileSpl](const std::string& id, Rotation dir) -> std::optional<double>
    {
        std::optional<double> progress;
        bs.forEachVisualItem(QRect(-5, -5, 20, 20), [&](VisualItem vi)
        {
            if (vi.type.id != id)
            {
                return;
            }
            switch (dir)
            {
                case Rotation::North: progress = (tileSpl.y() + 1.0) - vi.worldPos.y(); break;
                case Rotation::South: progress = vi.worldPos.y() - tileSpl.y();         break;
                case Rotation::East:  progress = vi.worldPos.x() - tileSpl.x();         break;
                case Rotation::West:  progress = (tileSpl.x() + 1.0) - vi.worldPos.x(); break;
            }
        });
        return progress;
    };

    // iron_ore matches filterA only -> sole eligible output A.
    bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East);
    bs.tick();  // back: 0.25
    bs.tick();  // back: 0.5 -> routes to frontA at 0.75
    REQUIRE(progressOf("iron_ore", Rotation::North) == Approx(0.75));

    // copper_ore matches filterB only -> sole eligible output B.
    bs.tryPutItem(tileSpl, makeItem("copper_ore"), Rotation::East);
    bs.tick();  // back: 0.25
    bs.tick();  // back: 0.5 -> routes to frontB at 0.75
    REQUIRE(progressOf("copper_ore", Rotation::South) == Approx(0.75));
}

TEST_CASE("BeltSystem: splitter alternation enters the preferred output at progress 0.75", "[belt]")
{
    // With both outputs eligible and free, the preferred output uses the same
    // near-edge entry as the diverted paths, so an evenly-split splitter keeps
    // each side packed instead of throttling it to one in-flight item per tile.
    const double quarterSpeed = 0.25 * static_cast<double>(kTickRateHz);
    BeltSystem bs(quarterSpeed);

    const QPoint tileSpl(1, 0);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);  // no filters: both match

    auto progressOf = [&bs, tileSpl](const std::string& id, Rotation dir) -> std::optional<double>
    {
        std::optional<double> progress;
        bs.forEachVisualItem(QRect(-5, -5, 20, 20), [&](VisualItem vi)
        {
            if (vi.type.id != id)
            {
                return;
            }
            switch (dir)
            {
                case Rotation::North: progress = (tileSpl.y() + 1.0) - vi.worldPos.y(); break;
                case Rotation::South: progress = vi.worldPos.y() - tileSpl.y();         break;
                case Rotation::East:  progress = vi.worldPos.x() - tileSpl.x();         break;
                case Rotation::West:  progress = (tileSpl.x() + 1.0) - vi.worldPos.x(); break;
            }
        });
        return progress;
    };

    // First item: preferred A (nextOutputIsA starts true) -> frontA at 0.75.
    bs.tryPutItem(tileSpl, makeItem("first"), Rotation::East);
    bs.tick();  // back: 0.25
    bs.tick();  // back: 0.5 -> routes to preferred frontA at 0.75, nextOutputIsA = false
    REQUIRE(progressOf("first", Rotation::North) == Approx(0.75));

    // Second item: preference flipped, B is free -> frontB at 0.75.
    bs.tryPutItem(tileSpl, makeItem("second"), Rotation::East);
    bs.tick();  // back: 0.25 (first sticks at North 1.0, no downstream)
    bs.tick();  // back: 0.5 -> routes to preferred frontB at 0.75
    REQUIRE(progressOf("second", Rotation::South) == Approx(0.75));
}

// ---------------------------------------------------------------------------
// Splitter — direct building input (no output belts)
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: splitter back slot is capped at 0.5 and waits before routing", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick();   // item enters splitter back at progress 0; routing not yet triggered

    // Back has not yet reached 0.5 — front slots empty, nothing available.
    REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
    REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());

    bs.tick();   // back advances to 0.5, routes to frontA at progress 0
    bs.tick();   // frontA advances to 1.0, available for building pickup

    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
}

TEST_CASE("BeltSystem: splitter delivers item directly to building input via tryTakeItem", "[belt]")
{
    // Bug 1: splitter could not insert into a building input with no belt in between.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
    // No output belts — both outputs lead directly to building inputs.

    bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
    bs.tick();   // tileIn -> splitter back
    bs.tick();   // back -> frontA at progress 0
    bs.tick();   // frontA reaches 1.0; no downstream belt, item waits for building pickup

    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
    const std::optional<Item> taken = bs.tryTakeItem(Port{tileSpl, Rotation::North});
    REQUIRE(taken.has_value());
    REQUIRE(taken->type.id == "iron_ore");
}

TEST_CASE("BeltSystem: splitter accepts new items after building pulls from front slot", "[belt]")
{
    // Bug 2: when outputs had no belts, splitter never cleared its held state
    // so no new items could enter.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
    bs.tick();
    bs.tick();
    bs.tick();   // item1 now in frontA at 1.0

    // Building pulls item1 — clears frontA; nextOutputIsA toggled to false.
    REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());

    // Feed item2; preferred is now South.
    bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
    bs.tick();
    bs.tick();
    bs.tick();   // item2 now in frontB at 1.0

    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}

TEST_CASE("BeltSystem: tryPutItem succeeds directly on a splitter tile", "[belt]")
{
    // Regression: buildings outputting onto a splitter tile were silently dropped
    // because tryPutItem had no splitter case and returned false.
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileSpl(0, 0);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East));

    // Item should arrive at one of the output fronts after the splitter ticks through.
    bs.tick();   // back advances to 0.5, routes to frontA
    bs.tick();   // frontA reaches 1.0

    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
}

TEST_CASE("BeltSystem: splitter alternates between two unregistered outputs (building inputs)", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    const QPoint tileIn(0, 0);
    const QPoint tileSpl(1, 0);

    bs.placeBelt(tileIn, Rotation::East);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    // item1 → frontA (preferred, nextOutputIsA=true)
    bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
    bs.tick();
    bs.tick();
    bs.tick();
    REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());

    // item2 → frontB (preferred, nextOutputIsA now false)
    bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
    bs.tick();
    bs.tick();
    bs.tick();
    REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());

    // item3 → frontA again (nextOutputIsA toggled back to true)
    bs.tryPutItem(tileIn, makeItem("item3"), Rotation::East);
    bs.tick();
    bs.tick();
    bs.tick();
    REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
}

// ---------------------------------------------------------------------------
// Tunnel — pairing
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: tunnel pairing — basic pair within max distance", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    const QPoint entry(0, 0);
    const QPoint exit(3, 0);

    bs.placeTunnelEntry(entry, Rotation::East, 10);
    bs.placeTunnelExit(exit, Rotation::East);

    bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);

    // With kFastBeltSpeed, items cross one tile per tick.
    // entry tile: 1 tick to reach front progress 1.0
    // transit: 3 tiles distance → 3 ticks
    // exit tile: 1 tick to reach front progress 1.0
    // Total: 1 (entry) + 1 (entry→transit) + 3 (transit) + 1 (transit→exit) + 1 (exit advance) = ~5-7 ticks

    for (int i = 0; i < 20; ++i)
    {
        bs.tick();
    }

    REQUIRE(bs.peekItem(Port{exit, Rotation::East}).has_value());
    const std::optional<Item> taken = bs.tryTakeItem(Port{exit, Rotation::East});
    REQUIRE(taken.has_value());
    REQUIRE(taken->type.id == "iron_ore");
}

TEST_CASE("BeltSystem: tunnel pairing — wrong direction prevents pair", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
    bs.placeTunnelExit(QPoint(3, 0), Rotation::North);

    bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);

    for (int i = 0; i < 20; ++i)
    {
        bs.tick();
    }

    // Exit faces North, not East — no pair formed, item stuck in entry.
    REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::North}).has_value());
}

TEST_CASE("BeltSystem: tunnel pairing — beyond max distance prevents pair", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 2);
    bs.placeTunnelExit(QPoint(3, 0), Rotation::East);

    bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);

    for (int i = 0; i < 20; ++i)
    {
        bs.tick();
    }

    REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value());
}

TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing", "[belt]")
{
    // Entry1 at (0,0) East, Entry2 at (2,0) East, Exit at (4,0) East.
    // Entry2 is closer to Exit → Entry2 pairs with Exit; Entry1 is blocked by Entry2.
    BeltSystem bs(kFastBeltSpeed);

    bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
    bs.placeTunnelEntry(QPoint(2, 0), Rotation::East, 10);
    bs.placeTunnelExit(QPoint(4, 0), Rotation::East);

    // Put item on Entry2 — should reach exit.
    bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"), Rotation::East);
    for (int i = 0; i < 20; ++i)
    {
        bs.tick();
    }
    REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value());
    bs.tryTakeItem(Port{QPoint(4, 0), Rotation::East});

    // Put item on Entry1 — should NOT reach exit (Entry1 is unpaired).
    bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
    for (int i = 0; i < 20; ++i)
    {
        bs.tick();
    }
    REQUIRE_FALSE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value());
}

TEST_CASE("BeltSystem: tunnel pairing — cross-dir entry between is ignored", "[belt]")
{
    // Entry1 at (0,0) East, Entry2 at (2,0) North (different dir), Exit at (4,0) East.
    // Entry2 faces North → ignored → Entry1 pairs with Exit.
    BeltSystem bs(kFastBeltSpeed);

    bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
    bs.placeTunnelEntry(QPoint(2, 0), Rotation::North, 10);
    bs.placeTunnelExit(QPoint(4, 0), Rotation::East);

    bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
    for (int i = 0; i < 20; ++i)
    {
        bs.tick();
    }
    REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value());
}

// ---------------------------------------------------------------------------
// Tunnel — item transit
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: unpaired entry blocks items at front", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
    // No exit placed — entry is unpaired.

    bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
    for (int i = 0; i < 10; ++i)
    {
        bs.tick();
    }

    // Item should not vanish — it stays in the entry.
    // We can verify by placing an exit and seeing item eventually arrive.
    bs.placeTunnelExit(QPoint(3, 0), Rotation::East);
    for (int i = 0; i < 20; ++i)
    {
        bs.tick();
    }
    REQUIRE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value());
}

TEST_CASE("BeltSystem: deconstruct entry discards transit items", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    const QPoint entry(0, 0);
    const QPoint exit(5, 0);

    bs.placeTunnelEntry(entry, Rotation::East, 10);
    bs.placeTunnelExit(exit, Rotation::East);

    bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);

    // Advance just enough for item to enter transit but not reach exit.
    bs.tick();  // item enters entry front
    bs.tick();  // entry front → transit (progress 0)

    bs.removeTile(entry);

    // Even with many more ticks, nothing arrives at exit.
    for (int i = 0; i < 30; ++i)
    {
        bs.tick();
    }
    REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value());
}

// A clear removes exactly what countItems reports for the tile it acts on
// (REQ-UI-BELT-CLEAR, REQ-UI-BELT-ITEMS): the transit items belong to the exit, so only
// the exit's clear discards them.
TEST_CASE("BeltSystem: clearTiles on a tunnel exit discards transit items", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    const QPoint entry(0, 0);
    const QPoint exit(5, 0);

    bs.placeTunnelEntry(entry, Rotation::East, 10);
    bs.placeTunnelExit(exit, Rotation::East);

    bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
    bs.tick();
    bs.tick();

    bs.clearTiles({exit});

    for (int i = 0; i < 30; ++i)
    {
        bs.tick();
    }
    REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value());
}

TEST_CASE("BeltSystem: clearTiles on a tunnel entry leaves transit items travelling",
          "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    const QPoint entry(0, 0);
    const QPoint exit(5, 0);

    bs.placeTunnelEntry(entry, Rotation::East, 10);
    bs.placeTunnelExit(exit, Rotation::East);

    bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
    bs.tick();
    bs.tick();

    bs.clearTiles({entry});

    for (int i = 0; i < 30; ++i)
    {
        bs.tick();
    }
    REQUIRE(bs.peekItem(Port{exit, Rotation::East}).has_value());
}

TEST_CASE("BeltSystem: belt to entry to transit to exit to belt full chain", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    const QPoint beltIn(0, 0);
    const QPoint entry(1, 0);
    const QPoint exit(4, 0);
    const QPoint beltOut(5, 0);

    bs.placeBelt(beltIn, Rotation::East);
    bs.placeTunnelEntry(entry, Rotation::East, 10);
    bs.placeTunnelExit(exit, Rotation::East);
    bs.placeBelt(beltOut, Rotation::East);

    bs.tryPutItem(beltIn, makeItem("iron_ore"), Rotation::East);

    for (int i = 0; i < 30; ++i)
    {
        bs.tick();
    }

    // Item should have arrived on beltOut.
    REQUIRE(bs.peekItem(eastPort(beltOut)).has_value());
    const std::optional<Item> taken = bs.tryTakeItem(eastPort(beltOut));
    REQUIRE(taken.has_value());
    REQUIRE(taken->type.id == "iron_ore");
}

TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);

    const QPoint entry(0, 0);
    const QPoint exit(3, 0);

    bs.placeTunnelEntry(entry, Rotation::East, 10);
    bs.placeTunnelExit(exit, Rotation::East);

    bs.tryPutItem(entry, makeItem("item1"), Rotation::East);
    bs.tick();
    bs.tick();  // item1 enters transit

    bs.tryPutItem(entry, makeItem("item2"), Rotation::East);

    for (int i = 0; i < 30; ++i)
    {
        bs.tick();
    }

    // item1 should arrive first.
    const std::optional<Item> taken1 = bs.tryTakeItem(Port{exit, Rotation::East});
    REQUIRE(taken1.has_value());
    REQUIRE(taken1->type.id == "item1");

    for (int i = 0; i < 10; ++i)
    {
        bs.tick();
    }

    const std::optional<Item> taken2 = bs.tryTakeItem(Port{exit, Rotation::East});
    REQUIRE(taken2.has_value());
    REQUIRE(taken2->type.id == "item2");
}

// ---------------------------------------------------------------------------
// Output-edge rejection (REQ-MAT-ACCEPT-DIR)
// ---------------------------------------------------------------------------

TEST_CASE("BeltSystem: belt refuses an item arriving through its output edge", "[belt]")
{
    // Belt A flows East into belt B, but B flows West — so the hand-off would
    // enter B through its own (West) output edge and must be refused. Without the
    // guard the item would ping-pong between the two belts forever.
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tileA(0, 0);
    const QPoint tileB(1, 0);
    bs.placeBelt(tileA, Rotation::East);
    bs.placeBelt(tileB, Rotation::West);

    REQUIRE(bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East));
    for (int i = 0; i < 5; ++i)
    {
        bs.tick();
    }

    // B never accepts the item through its output edge...
    REQUIRE_FALSE(bs.tryTakeItem(Port{tileB, Rotation::West}).has_value());
    // ...and it stays blocked at A's output edge.
    REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value());
}

TEST_CASE("BeltSystem: tryPutItem refuses a deposit onto a belt facing the source", "[belt]")
{
    // A belt whose output edge faces back toward the depositing building must
    // refuse the item; feeding through a non-output edge still works.
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tile(0, 0);
    bs.placeBelt(tile, Rotation::West);

    // Item travelling East enters through the West (output) edge -> refused.
    REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
    // Item travelling West enters through the East (back) edge -> accepted.
    REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::West));
}

TEST_CASE("BeltSystem: splitter refuses an item arriving through an output edge", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tileSpl(1, 0);
    bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);

    // Entering through the North output edge (item travelling South) -> refused.
    REQUIRE_FALSE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::South));
    // Entering through the South output edge (item travelling North) -> refused.
    REQUIRE_FALSE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::North));
    // Entering through a non-output (West) edge (item travelling East) -> accepted.
    REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East));
}

TEST_CASE("BeltSystem: tunnel entry refuses an item arriving through its mouth edge", "[belt]")
{
    BeltSystem bs(kFastBeltSpeed);
    const QPoint entry(0, 0);
    bs.placeTunnelEntry(entry, Rotation::East, 10);

    // Item travelling West enters through the East mouth (output) edge -> refused.
    REQUIRE_FALSE(bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::West));
    // Item travelling East enters through the back (West) edge -> accepted.
    REQUIRE(bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East));
}

TEST_CASE("BeltSystem: tunnel exit refuses an item pushed into its output edge", "[belt]")
{
    // A splitter's East front sits next to a tunnel exit whose output faces West
    // (back toward the splitter). The front cannot hand off into the exit's
    // output edge, so the item stays on the splitter front.
    BeltSystem bs(kFastBeltSpeed);
    const QPoint tileSpl(1, 0);
    const QPoint exitTile(2, 0);
    bs.placeSplitter(tileSpl, Rotation::East, Rotation::South);
    bs.placeTunnelExit(exitTile, Rotation::West);

    // Feed through the West edge (item travelling East); first item routes to the
    // East front (nextOutputIsA starts true).
    REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East));
    for (int i = 0; i < 5; ++i)
    {
        bs.tick();
    }

    // The item is stuck on the splitter's East front; the exit never received it.
    REQUIRE(bs.peekItem(Port{tileSpl, Rotation::East}).has_value());
    REQUIRE_FALSE(bs.peekItem(Port{exitTile, Rotation::West}).has_value());
}
