#include "GameWorldView.h"

#include <algorithm>
#include <cctype>
#include <climits>
#include <cmath>
#include <functional>
#include <map>
#include <memory>
#include <string>

#include <QByteArray>
#include <QColor>
#include <QCoreApplication>
#include <QCursor>
#include <QDir>
#include <QFile>
#include <QFont>
#include <QKeyEvent>
#include <QLinearGradient>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPixmap>
#include <QPolygonF>
#include <QRadialGradient>
#include <QRegion>
#include <QRegularExpression>
#include <QStringList>
#include <QSvgRenderer>
#include <QTimer>

#include "AttackBehavior.h"
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "ReplayPlayer.h"
#include "ReplayReader.h"
#include "ReplayRecorder.h"
#include "DeconstructModeChangedEvent.h"
#include "EntityHitTest.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "FacingComponent.h"
#include "FactionComponent.h"
#include "GameOverEvent.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "ItemIconCache.h"
#include "PortGeometry.h"
#include "PositionComponent.h"
#include "RepairBehavior.h"
#include "SalvageScrapBehavior.h"
#include "DebrisSelectionChangedEvent.h"
#include "DebrisSystem.h"
#include "SelectionChangedEvent.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "DebrisComponent.h"
#include "SurfaceMask.h"
#include "Tick.h"
#include "TunnelCompletion.h"
#include "EscapeMenuRequestedEvent.h"
#include "TracePrintRequestedEvent.h"
#include "BuildHotkeyPressedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
#include "BossWaveUpdatedEvent.h"
#include "BuilderModeExitedEvent.h"
#include "BlueprintModeExitedEvent.h"
#include "BuildingBlocksChangedEvent.h"
#include "UnlockedBuildingsChangedEvent.h"
#include "ExpansionCostChangedEvent.h"
#include "GameSpeedChangedEvent.h"
#include "SchematicChoicesAvailableEvent.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h"

namespace
{

// --- World building icons (REQ-UI-WORLD-ICON) --------------------------------

// Building types that render with a world icon, and the icon file name for each.
// Belts, splitters, and tunnels are intentionally absent so their orientation
// stays readable; the two defence stations share one symbol (colored per side by
// the fill it is drawn over).
struct WorldIconEntry { BuildingType type; const char* file; };
const WorldIconEntry kWorldIconFiles[] = {
    { BuildingType::Miner,                "miner.svg"              },
    { BuildingType::Smelter,              "smelter.svg"            },
    { BuildingType::Assembler,            "assembler.svg"          },
    { BuildingType::ReprocessingPlant,    "reprocessing_plant.svg" },
    { BuildingType::Shipyard,             "shipyard.svg"           },
    { BuildingType::SalvageBay,           "salvage_bay.svg"        },
    { BuildingType::Hq,                   "hq.svg"                 },
    { BuildingType::PlayerDefenceStation, "station.svg"            },
    { BuildingType::EnemyDefenceStation,  "station.svg"            },
};

// On-screen size of every world icon, as a multiple of one tile (REQ-UI-WORLD-ICON):
// a little over one tile so all buildings' icons read at the same size regardless
// of footprint.
const qreal kWorldIconTileFactor = 1.25;

// Produces an icon SVG containing only the glyph (the chip background rect
// stripped), with the white glyph stroke recolored to inkHex.
QByteArray worldIconSvg(const QByteArray& svg, const QString& inkHex)
{
    QString s = QString::fromUtf8(svg);
    // Drop the full-canvas chip rect only; some glyphs use their own <rect>
    // elements (e.g. the miner's drill housing, the hq's base), so match the
    // 100x100 background rect specifically.
    static const QRegularExpression backgroundRect(
        QStringLiteral("<rect\\s+width=\"100\"\\s+height=\"100\"[^>]*>"));
    s.remove(backgroundRect);
    s.replace(QStringLiteral("#ffffff"), inkHex);
    return s.toUtf8();
}

// Perceived luminance test; picks a dark glyph on light fills so it stays legible.
bool isLightFill(const QColor& c)
{
    const double lum = (0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()) / 255.0;
    return lum > 0.6;
}

// Keep only the filter entries whose item type is currently unlocked
// (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all".
std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter,
                                          const Simulation& sim)
{
    std::vector<ItemType> result;
    for (const ItemType& item : filter)
    {
        if (sim.isItemUnlocked(item.id)) { result.push_back(item); }
    }
    return result;
}

Rotation rotateClockwise(Rotation r)
{
    switch (r)
    {
    case Rotation::North: return Rotation::East;
    case Rotation::East:  return Rotation::South;
    case Rotation::South: return Rotation::West;
    case Rotation::West:  return Rotation::North;
    }
    return Rotation::East;
}

Rotation rotateCounterClockwise(Rotation r)
{
    switch (r)
    {
    case Rotation::North: return Rotation::West;
    case Rotation::East:  return Rotation::North;
    case Rotation::South: return Rotation::East;
    case Rotation::West:  return Rotation::South;
    }
    return Rotation::East;
}

// Fill color for a building's status light per its production state
// (REQ-UI-STATUS-LIGHT).
QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
{
    switch (status)
    {
    case ProductionStatus::Unconfigured: return sl.grey;
    case ProductionStatus::Producing:    return sl.green;
    case ProductionStatus::Starved:      return sl.red;
    case ProductionStatus::Blocked:      return sl.yellow;
    }
    return sl.grey;
}

}  // namespace


GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
                             const VisualsConfig* visuals, const std::string& configDir,
                             ItemIconCache* itemIcons, const ParsedReplay* replay,
                             QWidget* parent)
    : QOpenGLWidget(parent)
    , m_sim(sim)
    , m_config(config)
    , m_visuals(visuals)
    , m_itemIcons(itemIcons)
    , m_commandManager(*sim)
    , m_gameSpeedMultiplier(1.0)
    , m_prevNonZeroSpeed(1.0)
    , m_scrollXTiles(0.0f)
    , m_ghostRotation(Rotation::East)
    , m_ghostValid(false)
    , m_dragging(false)
    , m_deconstructMode(false)
    , m_debugDraw(false)
    , m_rng(std::random_device{}())
    , m_boxSelecting(false)
    , m_scrollLeft(false)
    , m_scrollRight(false)
    , m_gameOverShown(false)
    , m_schematicChoiceShown(false)
{
    setFocusPolicy(Qt::StrongFocus);
    setMouseTracking(true);

    loadBuildingIcons(configDir);

    m_renderTimer = new QTimer(this);
    m_renderTimer->setInterval(16);
    connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame);
    m_renderTimer->start();
    m_frameTimer.start();

    registerForEvents();

    if (replay)
    {
        // View-only playback: ignore live input and drive ticks from the recorded
        // stream. No recorder (we are not creating a new run).
        m_commandManager.setReplayMode(true);
        m_replayPlayer = std::make_unique<ReplayPlayer>(*sim, replay->entries);
        m_replayPlayer->start();  // process tick-0 entries before the first tick
    }
    else
    {
        // Record every run to disk, into a "replays" folder next to the executable.
        // Uses the exe's real directory rather than traversing up from the config
        // dir: with a symlinked build layout, relative traversal would resolve into
        // the wrong tree. Attaching the recorder opens the first file and writes the
        // header for the initial run.
        const QString replayDir =
            QDir(QCoreApplication::applicationDirPath()).filePath("replays");
        m_commandManager.setRecorder(std::make_unique<ReplayRecorder>(
            configDir, replayDir.toStdString()));
    }
}

GameWorldView::~GameWorldView()
{
    unregisterForEvents();
}

void GameWorldView::initializeGL()
{
    // QPainter handles all rendering; no custom GL state needed.
}

void GameWorldView::onFrame()
{
    const qint64 elapsed = m_frameTimer.restart();

    if (m_replayPlayer)
    {
        // Playback: apply recorded commands at their ticks and verify checksums.
        // Manual speed/pause still works; playback only moves forward.
        const int ticks = m_tickDriver.advance(
            static_cast<double>(elapsed), m_gameSpeedMultiplier);
        for (int i = 0; i < ticks; ++i)
        {
            // Stop at the recorded stream's end, or at the run's terminal state:
            // the real game froze when it was won or lost, so the replay does too
            // (the recording may run a few ticks past that point).
            if (m_replayPlayer->isFinished()
                || m_sim->isWon() || m_sim->isGameOver()) { break; }
            m_sim->tick();
            m_replayPlayer->advanceTo(m_sim->getCurrentTick());
        }
    }
    else
    {
        // Drain queued player commands once per frame, before the tick batch. This
        // runs even at 0x so a paused player sees placed construction sites
        // immediately, while staying deterministic (see docs/replay_design.md).
        const bool commandsApplied = m_commandManager.hasPending();
        m_commandManager.drain();

        // A drained Reset reinitialized the simulation; reset the view to match.
        if (m_viewResetPending)
        {
            m_viewResetPending = false;
            resetForNewGame();
            // The reset command may have drained after a long-open modal (e.g. the
            // Game Over dialog), so `elapsed` above still holds the whole time that
            // dialog was open. Feeding it into the tick driver would fast-forward the
            // brand-new run by that duration. resetForNewGame() has rebased the time
            // source; skip this frame's tick advance so the stale delta is discarded.
            return;
        }

        // Notify presentation widgets that queued commands were applied, so a
        // paused player still sees the effect (e.g. a shipyard's layout preview
        // after picking a schematic) even though no tick advances. UI-only: this
        // does not touch the command queue or simulation, so replay recording
        // and determinism are unaffected.
        if (commandsApplied)
        {
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<PlayerCommandsAppliedEvent>());
        }

        const int ticks = m_tickDriver.advance(
            static_cast<double>(elapsed), m_gameSpeedMultiplier);
        for (int i = 0; i < ticks; ++i)
        {
            m_sim->tick();
            // Periodic checksum (every 30 ticks) for replay desync detection.
            m_commandManager.recordTickCheckpoint();
        }
    }

    // Emit fire events via EventManager
    {
        const std::vector<BeamFiredEvent> fires = m_sim->drainBeamFiredEvents();
        for (const BeamFiredEvent& fe : fires)
        {
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<BeamFiredEvent>(fe));
        }
    }

    // Expire old beams. Lifetime is measured in game ticks so beams stay
    // visible while the simulation is paused or slowed (REQ-SHP-FIRING-BEAM).
    {
        const Tick now = m_sim->getCurrentTick();
        std::vector<ActiveBeam> live;
        for (const ActiveBeam& b : m_activeBeams)
        {
            if (now - b.event.emittedAt < kBeamLifetimeTicks)
            {
                live.push_back(b);
            }
        }
        m_activeBeams = std::move(live);
    }

    // Drop selected debris that were collected or despawned this frame, so the
    // panel stops counting them and the selection empties out (REQ-UI-DEBRIS-CLICK-SELECT).
    pruneDespawnedDebris();
    pruneDespawnedActors();

    // Expire copy/paste flashes. Lifetime is wall-clock (the frame delta), so the
    // flash plays for a fixed real duration regardless of game speed, including
    // while the game is paused (REQ-BLD-COPY-CONFIG-FEEDBACK).
    if (!m_copyConfigFlashes.empty())
    {
        std::vector<CopyConfigFlash> live;
        for (CopyConfigFlash flash : m_copyConfigFlashes)
        {
            flash.remainingMs -= elapsed;
            if (flash.remainingMs > 0) { live.push_back(flash); }
        }
        m_copyConfigFlashes = std::move(live);
    }

    // Apply held scroll
    {
        // Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED).
        const float viewCenterX = m_scrollXTiles;
        const float delta = panSpeedTilesPerSecondAt(viewCenterX)
            * static_cast<float>(elapsed) / 1000.0f;
        const float scrollBefore = m_scrollXTiles;
        if (m_scrollLeft)  { m_scrollXTiles -= delta; }
        if (m_scrollRight) { m_scrollXTiles += delta; }
        clampScroll();

        // While the view scrolls, the tile under a stationary cursor changes,
        // so refresh the box-select rectangle even though no mouse move fires.
        if (m_boxSelecting && m_scrollXTiles != scrollBefore)
        {
            m_boxCurrentTile = widgetToTile(mapFromGlobal(QCursor::pos()));
        }
    }

    // Fire events for any state that changed since the last frame
    {
        const Tick newTick      = m_sim->getCurrentTick();
        const int  newBlocks    = m_sim->getBuildingBlocksStock();
        const int  newExpCost   = m_sim->getCurrentExpansionCost();
        const int  newBoss      = m_sim->getBossWaveCounter();
        const Tick newCountdown = m_sim->getBossCountdownTicks();

        if (newTick != m_lastTick)
        {
            m_lastTick = newTick;
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<TickAdvancedEvent>());
        }
        if (newBlocks != m_lastBlocks)
        {
            m_lastBlocks = newBlocks;
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<BuildingBlocksChangedEvent>());
        }
        if (newExpCost != m_lastExpansionCost)
        {
            m_lastExpansionCost = newExpCost;
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<ExpansionCostChangedEvent>());
        }
        if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown)
        {
            m_lastBossCounter   = newBoss;
            m_lastBossCountdown = newCountdown;
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<BossWaveUpdatedEvent>());
        }

        // Unlocked building set changes only after a drop is applied or on Restart
        // (REQ-LOCK-BUILDING); the build grid rebuilds its visible buttons.
        int newUnlockedBuildingCount = 0;
        for (const BuildingDef& def : m_sim->getConfig().buildings.buildings)
        {
            if (m_sim->isBuildingUnlocked(def.type)) { ++newUnlockedBuildingCount; }
        }
        if (newUnlockedBuildingCount != m_lastUnlockedBuildingCount)
        {
            m_lastUnlockedBuildingCount = newUnlockedBuildingCount;
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<UnlockedBuildingsChangedEvent>());
        }
    }

    // Sim-state polls are input sources for the live game; in replay these are
    // gated off (the recorded ApplySchematicChoice resolves the choice with no UI,
    // and game-over becomes the passive "replay ended" state below).
    if (!m_replayPlayer)
    {
        // Schematic choice available
        if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
        {
            m_schematicChoiceShown = true;
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
        }
        if (!m_sim->hasSchematicChoicesPending())
        {
            m_schematicChoiceShown = false;
        }

        // Win check
        if (m_sim->isWon() && !m_winShown)
        {
            m_winShown = true;
            m_gameSpeedMultiplier = 0.0;
            EventManager::getInstance()->sendEventImmediately(std::make_shared<WinEvent>());
        }

        // Game over check
        if (m_sim->isGameOver() && !m_gameOverShown)
        {
            m_gameOverShown = true;
            m_gameSpeedMultiplier = 0.0;
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<GameOverEvent>());
        }
    }

    // Artifact count is a passive reflection of sim state (not a live-input source
    // like the schematic/game-over polls above), so it updates during replay too —
    // the recorded ApplySchematicChoice drives m_sim->getArtifactCount() forward and
    // the header bar must track it. Fires on the first frame (count 0 vs the -1
    // sentinel), which also populates the win-count max (replacing the "0/?" label).
    const int currentArtifactCount = m_sim->getArtifactCount();
    if (currentArtifactCount != m_lastArtifactCount)
    {
        m_lastArtifactCount = currentArtifactCount;
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<ArtifactCountChangedEvent>());
    }

    update();
}

void GameWorldView::paintGL()
{
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing, false);

    drawTiles(painter);
    drawBuildings(painter);
    // Port items are drawn over the buildings but clipped to a thin margin at each
    // machine's edges (see drawPortItems), so items appear to emerge from / sink
    // into the port and stay visible while crossing directly between two touching
    // buildings (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE, REQ-MAT-DIRECT-COUPLE).
    drawPortItems(painter);
    drawCopyConfigFeedback(painter);
    drawStations(painter);
    drawBeltItems(painter);
    drawDebris(painter);
    if (m_debugDraw)
    {
        drawDebugSensorRanges(painter);
        drawDebugTargetLines(painter);
        drawDebugOverlay(painter);
    }
    drawShips(painter);
    drawBeams(painter);
    drawOverlays(painter);
    drawScreenSpace(painter);
    drawPauseBorder(painter);
    drawDeconstructBorder(painter);
    drawReplayOverlay(painter);
}

// ---------------------------------------------------------------------------
// Coordinate helpers
// ---------------------------------------------------------------------------

float GameWorldView::getTilePx() const
{
    if (m_config->world.heightTiles <= 0) { return 1.0f; }
    return static_cast<float>(height()) / static_cast<float>(m_config->world.heightTiles);
}

float GameWorldView::getViewportWidthTiles() const
{
    return static_cast<float>(width()) / getTilePx();
}

float GameWorldView::getViewLeftTiles() const
{
    return m_scrollXTiles - getViewportWidthTiles() / 2.0f;
}

QPointF GameWorldView::worldToWidget(QVector2D worldPos) const
{
    return QPointF(
        static_cast<qreal>((worldPos.x() - getViewLeftTiles()) * getTilePx()),
        static_cast<qreal>(worldPos.y() * getTilePx()));
}

QPointF GameWorldView::tileToWidget(QPoint tile) const
{
    return worldToWidget(QVector2D(static_cast<float>(tile.x()),
                                   static_cast<float>(tile.y())));
}

QPoint GameWorldView::widgetToTile(QPoint widgetPt) const
{
    const float wx = static_cast<float>(widgetPt.x()) / getTilePx() + getViewLeftTiles();
    const float wy = static_cast<float>(widgetPt.y()) / getTilePx();
    return QPoint(static_cast<int>(std::floor(wx)), static_cast<int>(std::floor(wy)));
}

QVector2D GameWorldView::widgetToWorld(QPoint widgetPt) const
{
    const float wx = static_cast<float>(widgetPt.x()) / getTilePx() + getViewLeftTiles();
    const float wy = static_cast<float>(widgetPt.y()) / getTilePx();
    return QVector2D(wx, wy);
}

QRectF GameWorldView::tileRect(QPoint tile) const
{
    const QPointF tl = tileToWidget(tile);
    return QRectF(tl.x(), tl.y(),
                  static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
}

QRect GameWorldView::getViewportRect() const
{
    const int left   = static_cast<int>(std::floor(getViewLeftTiles())) - 1;
    const int top    = 0;
    const int right  = static_cast<int>(std::ceil(getViewLeftTiles() + getViewportWidthTiles())) + 1;
    const int bottom = m_config->world.heightTiles;
    return QRect(left, top, right - left, bottom - top);
}

float GameWorldView::getAsteroidLeftEdge() const
{
    float leftX = -static_cast<float>(m_sim->getCurrentAsteroidWidth_tiles());
    for (const Building& b : m_sim->getBuildings().getAllBuildings())
    {
        for (const QPoint& cell : b.bodyCells)
        {
            if (static_cast<float>(cell.x()) < leftX)
            {
                leftX = static_cast<float>(cell.x());
            }
        }
    }
    return leftX;
}

float GameWorldView::getEnemyStationRightEdge() const
{
    float rightX = static_cast<float>(m_config->world.regions.playerBufferWidth_tiles
        + m_config->world.regions.contestZoneWidth_tiles);
    m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent>(
        [&rightX](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f)
        {
            if (!f.isEnemy) { return; }
            for (const QPoint& cell : sb.bodyCells)
            {
                const float cx = static_cast<float>(cell.x() + 1);
                if (cx > rightX) { rightX = cx; }
            }
        });
    return rightX;
}

namespace
{
    // Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
    // outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
    float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
    {
        if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
        const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
        return valueAt0 + (valueAt1 - valueAt0) * t;
    }
}

float GameWorldView::panSpeedTilesPerSecondAt(float viewCenterXTiles) const
{
    // Slow near the asteroid/player buffer, fast across the contest zone, with a
    // linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
    // contest zone spans from the player buffer's right edge to the enemy stations,
    // the latter tracked live so the ramp follows the front line as it is pushed.
    const float slow = static_cast<float>(m_config->world.scroll.panSpeedSlow_tps);
    const float fast = static_cast<float>(m_config->world.scroll.panSpeedFast_tps);
    const float half = static_cast<float>(m_config->world.scroll.panRampBandWidth_tiles) / 2.0f;
    const float leftEdge  = static_cast<float>(m_config->world.regions.playerBufferWidth_tiles);
    const float rightEdge = getEnemyStationRightEdge();

    // Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
    // boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
    // the middle, and — if the bands overlap in a narrow contest zone — a single peak
    // below the fast speed where the two ramps cross.
    const float leftRamp  = lerpClamped(slow, fast, leftEdge  - half, leftEdge  + half, viewCenterXTiles);
    const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
    return std::min(leftRamp, rightRamp);
}

void GameWorldView::clampScroll()
{
    // m_scrollXTiles is the view center, so the pan limits are the edges themselves:
    // the view can pan left until the buildable/asteroid edge is centered, and right
    // until the enemy stations are centered (revealing a little space beyond them).
    const float leftBound  = getAsteroidLeftEdge();
    const float rightBound = getEnemyStationRightEdge();
    m_scrollXTiles = std::max(leftBound, std::min(m_scrollXTiles, rightBound));
}

// ---------------------------------------------------------------------------
// Placement helpers
// ---------------------------------------------------------------------------

bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
                                     Rotation rot) const
{
    // Terrain and world-bounds validity are owned by the simulation
    // (REQ-BLD-PLACE-VALID); the presentation layer only adds the occupancy /
    // rotate-in-place check.
    if (!m_sim->getBuildings().isPlacementValid(type, anchor, rot))
    {
        return false;
    }

    const BuildingDef* def = m_config->buildings.findBuildingDef(type);
    if (!def) { return false; }
    const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot);

    bool anyOccupied = false;
    for (const QPoint& relCell : parsed.bodyCells)
    {
        if (m_sim->getBuildings().isTileOccupied(anchor + relCell))
        {
            anyOccupied = true;
            break;
        }
    }

    if (anyOccupied)
    {
        return m_sim->getBuildings().findRotateInPlaceTarget(type, anchor, rot).has_value();
    }
    return true;
}

std::optional<BuildingId> GameWorldView::buildingAtTile(QPoint tile) const
{
    for (const Building& b : m_sim->getBuildings().getAllBuildings())
    {
        for (const QPoint& cell : b.bodyCells)
        {
            if (cell == tile)
			{
				return b.id;
			}
        }
    }
    return std::nullopt;
}

std::optional<BuildingId> GameWorldView::siteAtTile(QPoint tile) const
{
	for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
	{
		for (const QPoint& cell : s.bodyCells)
		{
			if (cell == tile)
			{
				return s.id;
			}
		}
	}
	return std::nullopt;
}


std::vector<BuildingId> GameWorldView::buildingsInBox(QPoint cornerA, QPoint cornerB) const
{
    const int x0 = std::min(cornerA.x(), cornerB.x());
    const int y0 = std::min(cornerA.y(), cornerB.y());
    const int x1 = std::max(cornerA.x(), cornerB.x());
    const int y1 = std::max(cornerA.y(), cornerB.y());

    std::vector<BuildingId> ids;
    for (const Building& b : m_sim->getBuildings().getAllBuildings())
    {
        for (const QPoint& cell : b.bodyCells)
        {
            if (cell.x() >= x0 && cell.x() <= x1
             && cell.y() >= y0 && cell.y() <= y1)
            {
                ids.push_back(b.id);
                break;
            }
        }
    }
    for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
    {
        for (const QPoint& cell : s.bodyCells)
        {
            if (cell.x() >= x0 && cell.x() <= x1
             && cell.y() >= y0 && cell.y() <= y1)
            {
                ids.push_back(s.id);
                break;
            }
        }
    }
    return ids;
}

std::optional<QVector2D> GameWorldView::entityPosition(entt::entity entity) const
{
    if (!m_sim->getAdmin().isValid(entity) || !m_sim->getAdmin().hasAll<PositionComponent>(entity))
    {
        return std::nullopt;
    }
    return m_sim->getAdmin().get<PositionComponent>(entity).value;
}

void GameWorldView::clearDebrisSelection()
{
    if (m_selectedDebris.empty()) { return; }
    m_selectedDebris.clear();
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<DebrisSelectionChangedEvent>(m_selectedDebris));
}

void GameWorldView::pruneDespawnedDebris()
{
    if (m_selectedDebris.empty()) { return; }

    std::vector<entt::entity> live;
    for (const DebrisInfo& info : m_sim->getDebrisSystem().getAllDebrisInfo())
    {
        if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
            != m_selectedDebris.end())
        {
            live.push_back(info.entity);
        }
    }

    if (live.size() != m_selectedDebris.size())
    {
        m_selectedDebris = std::move(live);
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<DebrisSelectionChangedEvent>(m_selectedDebris));
    }
}

void GameWorldView::clearEntitySelection()
{
    if (m_selectedEntities.empty()) { return; }
    m_selectedEntities.clear();
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
}

void GameWorldView::pruneDespawnedActors()
{
    if (m_selectedEntities.empty()) { return; }

    EntityAdmin& admin = m_sim->getAdmin();
    std::vector<entt::entity> live;
    for (entt::entity e : m_selectedEntities)
    {
        if (admin.isValid(e) && admin.hasAll<HealthComponent>(e)
            && admin.get<HealthComponent>(e).hp > 0.0f)
        {
            live.push_back(e);
        }
    }

    if (live.size() != m_selectedEntities.size())
    {
        m_selectedEntities = std::move(live);
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
    }
}

bool GameWorldView::isEntitySelected(entt::entity entity) const
{
    return std::find(m_selectedEntities.begin(), m_selectedEntities.end(), entity)
        != m_selectedEntities.end();
}

void GameWorldView::stepSpeed(int delta)
{
    const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
    const int kCount = 5;
    int current = 2;
    for (int i = 0; i < kCount; ++i)
    {
        if (std::abs(kSpeeds[i] - m_gameSpeedMultiplier) < 0.001)
        {
            current = i;
            break;
        }
    }
    const int next = std::max(0, std::min(kCount - 1, current + delta));
    setGameSpeed(kSpeeds[next]);
}

void GameWorldView::placeBlueprintAtTile(QPoint center)
{
    const Blueprint& bp = *m_blueprintMode;

    for (const BlueprintBuilding& bb : bp.buildings)
    {
        // Locked building types are excluded from this placement entirely
        // (REQ-LOCK-BUILDING, REQ-LOCK-UI-BLUEPRINT): not validity-checked here.
        if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
        if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; }
    }

    // Cost only applies to buildings that are genuinely new (not rotate-in-place),
    // and excludes locked building types (REQ-LOCK-UI-BLUEPRINT).
    int totalCost = 0;
    for (const BlueprintBuilding& bb : bp.buildings)
    {
        if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
        if (m_sim->getBuildings().findRotateInPlaceTarget(
                bb.type, center + bb.offset, bb.rotation).has_value())
        {
            continue;
        }
        const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
        if (def) { totalCost += def->cost; }
    }
    if (m_sim->getBuildingBlocksStock() < totalCost) { return; }

    for (const BlueprintBuilding& bb : bp.buildings)
    {
        if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
        const QPoint anchor = center + bb.offset;
        const std::optional<BuildingId> rotateTarget =
            m_sim->getBuildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
        if (rotateTarget.has_value())
        {
            std::shared_ptr<RotateInPlaceCommand> rotateCommand =
                std::make_shared<RotateInPlaceCommand>();
            rotateCommand->id          = *rotateTarget;
            rotateCommand->newRotation = bb.rotation;
            enqueueCommand(rotateCommand);
            continue;
        }

        // Place-and-configure is one atomic command: commands apply at a deferred
        // tick boundary, so the caller never sees the new BuildingId. Unlock
        // gating stays here (UI-side pre-filter); only fields that should apply
        // are set on the command.
        std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
        command->type     = bb.type;
        command->anchor   = anchor;
        command->rotation = bb.rotation;

        if (!bb.recipeId.empty())
        {
            if (bb.type == BuildingType::Shipyard)
            {
                if (m_sim->isSchematicUnlocked(bb.recipeId))
                {
                    command->recipeId = bb.recipeId;
                }
            }
            else
            {
                const bool needsUnlockCheck = bb.type == BuildingType::Miner
                                           || bb.type == BuildingType::Assembler;
                if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
                {
                    command->recipeId = bb.recipeId;
                }
            }
        }

        command->shipLayout = bb.shipLayout;

        if (bb.type == BuildingType::Splitter
            && (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
        {
            // The splitter is still a construction site, so the filters carry
            // over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
            // item types are dropped per REQ-LOCK-UI-BLUEPRINT.
            command->hasSplitterFilters = true;
            command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
            command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
        }

        enqueueCommand(command);
    }
}

bool GameWorldView::inTunnelMode() const
{
    return m_builderType.has_value() && *m_builderType == BuildingType::TunnelEntry;
}

BuildingType GameWorldView::effectiveBuilderType() const
{
    return inTunnelMode() ? m_tunnelGhostType : *m_builderType;
}

TunnelTileMap GameWorldView::collectTunnelTiles() const
{
    // Index every tunnel entry/exit — built or still a construction site — by its
    // single-cell tile, so a just-placed tunnel (not yet constructed) is matchable
    // (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
    TunnelTileMap tunnels;
    for (const Building& b : m_sim->getBuildings().getAllBuildings())
    {
        if (b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit)
        {
            tunnels[b.anchor] = TunnelTileInfo{b.type, b.rotation};
        }
    }
    for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
    {
        if (s.type == BuildingType::TunnelEntry || s.type == BuildingType::TunnelExit)
        {
            tunnels[s.anchor] = TunnelTileInfo{s.type, s.rotation};
        }
    }
    return tunnels;
}

TunnelLookup GameWorldView::makeTunnelLookup(const TunnelTileMap& tunnels)
{
    return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
    {
        const TunnelTileMap::const_iterator it = tunnels.find(tile);
        if (it == tunnels.end()) { return std::nullopt; }
        return it->second;
    };
}

void GameWorldView::updateTunnelGhost()
{
    m_tunnelGhostType = BuildingType::TunnelEntry;
    m_tunnelPartnerTile.reset();

    // The connection preview and entry/exit switch only apply at a valid placement
    // (REQ-BLD-TUNNEL-MODE); at an invalid position the ghost stays a plain entry.
    if (!m_ghostValid)
    {
        return;
    }

    const TunnelTileMap tunnels = collectTunnelTiles();
    const TunnelLookup  lookup  = makeTunnelLookup(tunnels);

    const TunnelCompletion completion =
        resolveTunnelCompletion(lookup, m_ghostTile, m_ghostRotation,
                                m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos);
    m_tunnelGhostType   = completion.resolvedType;
    m_tunnelPartnerTile = completion.partnerTile;
}

void GameWorldView::placeAtTile(QPoint tile)
{
    if (!m_builderType.has_value())
	{
		return;
	}
    const BuildingType type = effectiveBuilderType();

    if (!isValidPlacement(type, tile, m_ghostRotation))
	{
		return;
	}

    const std::optional<BuildingId> rotateTarget =
        m_sim->getBuildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
    if (rotateTarget.has_value())
    {
        std::shared_ptr<RotateInPlaceCommand> command =
            std::make_shared<RotateInPlaceCommand>();
        command->id          = *rotateTarget;
        command->newRotation = m_ghostRotation;
        enqueueCommand(command);
        return;
    }

    // For the splitter and tunnels, pre-validate occupancy + affordability so the
    // optimistic UI update matches what the deferred command will do — isValidPlacement
    // (above) already covered terrain/bounds. In tunnel mode the resolved type (entry
    // or exit) is placed as-is (REQ-BLD-TUNNEL-MODE); there is no post-placement toggle.
    // Belts are placed via the drag path (applyBeltDragPath), not here.
    if (type == BuildingType::Splitter
             || type == BuildingType::TunnelEntry
             || type == BuildingType::TunnelExit)
    {
        if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type))
        {
            enqueuePlaceBuilding(type, tile, m_ghostRotation);
        }
    }
    else
    {
        enqueuePlaceBuilding(type, tile, m_ghostRotation);
    }
}

// ---------------------------------------------------------------------------
// Belt drag placement (REQ-BLD-BELT-DRAG)
// ---------------------------------------------------------------------------

void GameWorldView::recomputeBeltDragPath(QPoint cursorTile)
{
    QPoint                  endTile = cursorTile;
    std::optional<Rotation> forcedEndRotation;

    // If the cursor is over a non-belt building or construction site, snap the end
    // tile to the input-capable adjacent tile closest to the cursor, pointing into
    // the target (REQ-BLD-BELT-DRAG).
    std::optional<BuildingId>   targetId = buildingAtTile(cursorTile);
    std::optional<BuildingType> targetType;
    if (targetId.has_value())
    {
        if (const Building* building = m_sim->getBuildings().findBuilding(*targetId))
        {
            targetType = building->type;
        }
    }
    else if (std::optional<BuildingId> siteId = siteAtTile(cursorTile); siteId.has_value())
    {
        targetId = siteId;
        if (const ConstructionSite* site = m_sim->getBuildings().findSite(*siteId))
        {
            targetType = site->type;
        }
    }

    if (targetId.has_value() && targetType.has_value()
        && *targetType != BuildingType::Belt)
    {
        const std::vector<Port> inputPorts = m_sim->getBuildings().getInputPorts(*targetId);
        std::optional<Port> best;
        float               bestDistanceSq = 0.0f;
        for (const Port& port : inputPorts)
        {
            const QVector2D center(static_cast<float>(port.tile.x()) + 0.5f,
                                   static_cast<float>(port.tile.y()) + 0.5f);
            const float distanceSq = (center - m_cursorWorldPos).lengthSquared();
            if (!best.has_value() || distanceSq < bestDistanceSq)
            {
                best           = port;
                bestDistanceSq = distanceSq;
            }
        }
        if (best.has_value())
        {
            endTile           = best->tile;
            forcedEndRotation = best->direction;
        }
    }

    m_beltDragPath = computeBeltDragPath(m_beltDragAnchor, endTile, m_ghostRotation);
    if (forcedEndRotation.has_value() && !m_beltDragPath.empty())
    {
        // The end tile points into the target, overriding its incoming-step
        // orientation (REQ-BLD-BELT-DRAG "Snapping to a building").
        m_beltDragPath.back().rotation = *forcedEndRotation;
    }
}

std::vector<GameWorldView::BeltDragResolved> GameWorldView::resolveBeltDragPath() const
{
    std::vector<BeltDragResolved> resolved;
    resolved.reserve(m_beltDragPath.size());

    const BuildingDef* def = m_config->buildings.findBuildingDef(BuildingType::Belt);
    const int beltCost      = (def != nullptr) ? def->cost : 0;
    const int stock         = m_sim->getBuildingBlocksStock();
    int       spent         = 0;

    for (const BeltPathTile& entry : m_beltDragPath)
    {
        BeltDragResolved item;
        const std::optional<BuildingId> rotateTarget =
            m_sim->getBuildings().findRotateInPlaceTarget(
                BuildingType::Belt, entry.tile, entry.rotation);
        if (rotateTarget.has_value())
        {
            // A tile holding only a belt (or belt site) is re-oriented, no cost.
            item.action     = BeltTileAction::RotateInPlace;
            item.affordable = true;
            item.rotateId   = rotateTarget;
        }
        else if (isValidPlacement(BuildingType::Belt, entry.tile, entry.rotation))
        {
            // Empty, valid cell: a new belt, subject to cumulative affordability.
            item.action     = BeltTileAction::PlaceNew;
            item.affordable = (spent + beltCost <= stock);
            item.rotateId   = std::nullopt;
            if (item.affordable) { spent += beltCost; }
        }
        else
        {
            // Occupied by a non-belt building/site, or otherwise invalid terrain.
            item.action     = BeltTileAction::Invalid;
            item.affordable = false;
            item.rotateId   = std::nullopt;
        }
        resolved.push_back(item);
    }
    return resolved;
}

void GameWorldView::applyBeltDragPath()
{
    const std::vector<BeltDragResolved> resolved = resolveBeltDragPath();
    for (std::size_t index = 0; index < resolved.size(); ++index)
    {
        const BeltDragResolved& item = resolved[index];
        const BeltPathTile&     entry = m_beltDragPath[index];
        if (item.action == BeltTileAction::PlaceNew && item.affordable)
        {
            enqueuePlaceBuilding(BuildingType::Belt, entry.tile, entry.rotation);
        }
        else if (item.action == BeltTileAction::RotateInPlace)
        {
            std::shared_ptr<RotateInPlaceCommand> command =
                std::make_shared<RotateInPlaceCommand>();
            command->id          = *item.rotateId;
            command->newRotation = entry.rotation;
            enqueueCommand(command);
        }
    }
}

// ---------------------------------------------------------------------------
// Port glyph helper
// ---------------------------------------------------------------------------

void GameWorldView::drawPortGlyph(QPainter& painter, QPoint tile,
                                   Rotation direction, const QColor& color,
                                   bool centered)
{
    const float px = getTilePx();
    const QRectF tr = tileRect(tile);
    const QPointF center(tr.x() + static_cast<qreal>(px) * 0.5,
                         tr.y() + static_cast<qreal>(px) * 0.5);

    QPointF edgeOffset;
    const char* ch;
    switch (direction)
    {
    case Rotation::East:  edgeOffset = QPointF(px * 0.25f,  0);   ch = ">"; break;
    case Rotation::West:  edgeOffset = QPointF(-px * 0.25f, 0);   ch = "<"; break;
    case Rotation::North: edgeOffset = QPointF(0,  -px * 0.25f);  ch = "^"; break;
    case Rotation::South: edgeOffset = QPointF(0,   px * 0.25f);  ch = "v"; break;
    default:              return;
    }

    // Centered glyphs sit in the middle of the tile (used for a port's target
    // cell, REQ-UI-PORT-TARGET-GLYPH) and are drawn 150% larger to stand out;
    // otherwise offset toward the exit edge of the port's body tile
    // (REQ-UI-PORT-GLYPH).
    const QPointF offset = centered ? QPointF(0.0, 0.0) : edgeOffset;
    const qreal glyphScale = centered ? 1.5 : 1.0;

    const qreal half = static_cast<qreal>(px) * 0.3 * glyphScale;
    const QPointF pos = center + offset;
    const QRectF textRect(pos.x() - half, pos.y() - half, half * 2.0, half * 2.0);

    QFont f = painter.font();
    f.setPixelSize(std::max(6, static_cast<int>(px * 0.4f * glyphScale)));
    painter.setFont(f);
    painter.setPen(color);
    painter.drawText(textRect, Qt::AlignCenter, QString::fromLatin1(ch));
}

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------

void GameWorldView::drawTiles(QPainter& painter)
{
    const int leftTile   = static_cast<int>(std::floor(getViewLeftTiles())) - 1;
    const int rightTile  = leftTile + static_cast<int>(std::ceil(getViewportWidthTiles())) + 2;
    const int bottomTile = m_config->world.heightTiles;

    // Asteroid columns left of the buildable edge are not yet unlocked by
    // expansion; tint them so the player sees the reachable-but-locked area.
    const int buildableLeftX = -m_sim->getCurrentAsteroidWidth_tiles();

    painter.setPen(Qt::NoPen);
    for (int x = leftTile; x <= rightTile; ++x)
    {
        const QColor& fill = (x < 0)
            ? m_visuals->asteroid.fill
            : m_visuals->space.fill;
        const bool locked = (x < buildableLeftX);
        for (int y = 0; y < bottomTile; ++y)
        {
            const QRectF rect = tileRect(QPoint(x, y));
            painter.fillRect(rect, fill);
            if (locked)
            {
                painter.fillRect(rect, m_visuals->overlays.lockedAsteroid);
            }
        }
    }
}

void GameWorldView::loadBuildingIcons(const std::string& configDir)
{
    // Icons live beside the config dir, read at runtime like visuals.toml
    // (REQ-UI-WORLD-ICON), mirroring how MainWindow derives the same path.
    const QString iconDir = QDir::cleanPath(
        QString::fromStdString(configDir) + "/../icons/buildings");

    for (const WorldIconEntry& entry : kWorldIconFiles)
    {
        QFile file(iconDir + "/" + QString::fromLatin1(entry.file));
        if (!file.open(QIODevice::ReadOnly))
        {
            continue;  // A missing icon is not an error; the text glyph is used.
        }
        const QByteArray svg = file.readAll();
        BuildingIconRenderers renderers;
        renderers.white = std::make_unique<QSvgRenderer>(
            worldIconSvg(svg, QStringLiteral("#ffffff")));
        renderers.dark = std::make_unique<QSvgRenderer>(
            worldIconSvg(svg, QStringLiteral("#1c1c20")));
        m_buildingIcons[entry.type] = std::move(renderers);
    }
}

bool GameWorldView::drawBuildingIcon(QPainter& painter, BuildingType type,
                                     const QRectF& box, const QColor& fill) const
{
    const std::map<BuildingType, BuildingIconRenderers>::const_iterator it =
        m_buildingIcons.find(type);
    if (it == m_buildingIcons.end()) { return false; }

    // Every icon is the same fixed on-screen size, centered on the footprint,
    // regardless of footprint size (REQ-UI-WORLD-ICON).
    const qreal side = static_cast<qreal>(getTilePx()) * kWorldIconTileFactor;
    const QRectF target(box.center().x() - side / 2.0,
                        box.center().y() - side / 2.0, side, side);
    QSvgRenderer* renderer = isLightFill(fill) ? it->second.dark.get()
                                               : it->second.white.get();

    // Render the glyph as vector at the view scale so it stays crisp (a
    // pre-rasterized pixmap downscaled to tile size looked blurry).
    const bool wasAntialiasing = painter.testRenderHint(QPainter::Antialiasing);
    painter.setRenderHint(QPainter::Antialiasing, true);
    renderer->render(&painter, target);
    painter.setRenderHint(QPainter::Antialiasing, wasAntialiasing);
    return true;
}

void GameWorldView::drawBuildings(QPainter& painter)
{
    for (const Building& b : m_sim->getBuildings().getAllBuildings())
    {
        const std::map<BuildingType, BuildingVisuals>::const_iterator it =
            m_visuals->buildings.find(b.type);
        if (it == m_visuals->buildings.end()) { continue; }
        const BuildingVisuals& bv = it->second;

        painter.setPen(Qt::NoPen);
        for (const QPoint& cell : b.bodyCells)
        {
            painter.fillRect(tileRect(cell), bv.fill);
        }

        const QPointF tl = tileToWidget(b.anchor);
        const QRectF bboxRect(tl.x(), tl.y(),
            b.footprint.width()  * static_cast<qreal>(getTilePx()),
            b.footprint.height() * static_cast<qreal>(getTilePx()));

        painter.setPen(QPen(bv.outline, 1));
        painter.setBrush(Qt::NoBrush);
        painter.drawRect(bboxRect);

        // Icon glyph over the fill (REQ-UI-WORLD-ICON); falls back to the text
        // glyph for building types without a world icon (e.g. tunnels).
        if (!drawBuildingIcon(painter, b.type, bboxRect, bv.fill) && !bv.glyph.isEmpty())
        {
            painter.setPen(bv.outline);
            painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);
        }

        for (const Port& port : b.outputPorts)
        {
            drawPortGlyph(painter, outputBodyTile(port.tile, port.direction),
                          port.direction, bv.outline, /*centered*/ false);
        }

        // Status light: a small circle in the building's upper-right corner
        // (in default/East orientation), anchored to that footprint corner and
        // rotating with the building (REQ-UI-STATUS-LIGHT). All status-light
        // building footprints are rectangular, so a corner of the axis-aligned
        // bounding box is the true corner.
        if (const std::optional<ProductionStatus> status =
                m_sim->getBuildings().getProductionStatus(b))
        {
            const float px    = getTilePx();
            const float r     = px * 0.18f;
            const float inset = r + px * 0.12f;
            // Default orientation (East) puts the light at the top-right corner;
            // clockwise rotation carries it around the footprint.
            QPointF center(bboxRect.right() - inset, bboxRect.top() + inset);
            switch (b.rotation)
            {
            case Rotation::East:  break;
            case Rotation::South: center = QPointF(bboxRect.right() - inset,
                                                   bboxRect.bottom() - inset); break;
            case Rotation::West:  center = QPointF(bboxRect.left() + inset,
                                                   bboxRect.bottom() - inset); break;
            case Rotation::North: center = QPointF(bboxRect.left() + inset,
                                                   bboxRect.top() + inset); break;
            }
            painter.setBrush(statusLightFill(*status, m_visuals->statusLight));
            painter.setPen(QPen(m_visuals->statusLight.outline, 1));
            painter.drawEllipse(center, r, r);
        }
    }

    painter.setOpacity(0.5);
    for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
    {
        const std::map<BuildingType, BuildingVisuals>::const_iterator it =
            m_visuals->buildings.find(s.type);
        if (it == m_visuals->buildings.end()) { continue; }
        const BuildingVisuals& bv = it->second;

        for (const QPoint& cell : s.bodyCells)
        {
            painter.fillRect(tileRect(cell), bv.fill);
        }

        const QPointF tl      = tileToWidget(s.anchor);
        const QRectF  bboxRect(tl.x(), tl.y(),
            s.footprint.width()  * static_cast<qreal>(getTilePx()),
            s.footprint.height() * static_cast<qreal>(getTilePx()));
        painter.setPen(QPen(bv.outline, 1, Qt::DashLine));
        painter.setBrush(Qt::NoBrush);
        painter.drawRect(bboxRect);

        const BuildingDef* siteDef = m_config->buildings.findBuildingDef(s.type);
        if (siteDef)
        {
            // Glyph + progress percentage
            const Tick durationTicks = secondsToTicks(siteDef->constructionTimeSeconds);
            int pct = 0;
            if (s.completesAt > 0 && durationTicks > 0)
            {
                const Tick elapsed = m_sim->getCurrentTick()
                    - (s.completesAt - durationTicks);
                pct = static_cast<int>(
                    std::max(Tick(0), std::min(durationTicks, elapsed))
                    * 100 / durationTicks);
            }
            const QString pctText = QString::number(pct) + "%";

            painter.setPen(bv.outline);
            // Identity symbol with the progress percentage below it
            // (REQ-UI-CONSTRUCTION-PROGRESS): the world icon centered on the
            // footprint where the type has one, otherwise the text glyph.
            if (drawBuildingIcon(painter, s.type, bboxRect, bv.fill))
            {
                painter.drawText(bboxRect, Qt::AlignHCenter | Qt::AlignBottom, pctText);
            }
            else if (!bv.glyph.isEmpty())
            {
                const QRectF topHalf(bboxRect.x(), bboxRect.y(),
                                     bboxRect.width(), bboxRect.height() * 0.5);
                const QRectF botHalf(bboxRect.x(),
                                     bboxRect.y() + bboxRect.height() * 0.5,
                                     bboxRect.width(), bboxRect.height() * 0.5);
                painter.drawText(topHalf, Qt::AlignCenter, bv.glyph);
                painter.drawText(botHalf, Qt::AlignCenter, pctText);
            }
            else
            {
                painter.drawText(bboxRect, Qt::AlignCenter, pctText);
            }

            // Port glyphs
            const ParsedSurfaceMask siteMask =
                parseSurfaceMask(siteDef->surfaceMask, s.rotation);
            for (const Port& port : siteMask.outputPorts)
            {
                const QPoint absBody = s.anchor
                    + outputBodyTile(port.tile, port.direction);
                drawPortGlyph(painter, absBody, port.direction, bv.outline,
                              /*centered*/ false);
            }
        }
    }
    painter.setOpacity(1.0);

    // HP bar below the HQ footprint; the HQ's HP lives on its proxy entity. Drawn
    // after every building and construction site fill so a belt (or other tile)
    // placed directly below the HQ cannot overpaint the bar (REQ-UI-STATUS-LIGHT
    // neighbours case, same rationale as the selection highlights below).
    for (const Building& b : m_sim->getBuildings().getAllBuildings())
    {
        if (b.type != BuildingType::Hq) { continue; }
        const QPointF tl = tileToWidget(b.anchor);
        const QRectF bboxRect(tl.x(), tl.y(),
            b.footprint.width()  * static_cast<qreal>(getTilePx()),
            b.footprint.height() * static_cast<qreal>(getTilePx()));
        m_sim->getAdmin().forEach<HqProxyComponent, FactionComponent, HealthComponent>(
            [&](entt::entity /*e*/, const HqProxyComponent& /*hq*/,
                const FactionComponent& f, const HealthComponent& h)
            {
                if (h.maxHp > 0.0f)
                {
                    drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0,
                              bboxRect.width(), h.hp / h.maxHp, f.isEnemy);
                }
            });
    }

    // Selection highlights are drawn last, after every building and construction
    // site fill, so a selected building surrounded by neighbours keeps its outline:
    // the highlight sits 1px outside the footprint (into adjacent tiles), and drawing
    // it inline would let later-drawn neighbours overpaint it with their body fill.
    drawSelectionHighlights(painter);
}

std::optional<QRectF> GameWorldView::footprintWidgetRect(BuildingId id) const
{
    std::optional<QPoint> anchor;
    std::optional<QSize>  footprint;

    if (const Building* b = m_sim->getBuildings().findBuilding(id))
    {
        anchor    = b->anchor;
        footprint = b->footprint;
    }
    else if (const ConstructionSite* s = m_sim->getBuildings().findSite(id))
    {
        anchor    = s->anchor;
        footprint = s->footprint;
    }
    if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; }

    const QPointF tl = tileToWidget(*anchor);
    return QRectF(tl.x(), tl.y(),
        footprint->width()  * static_cast<qreal>(getTilePx()),
        footprint->height() * static_cast<qreal>(getTilePx()));
}

void GameWorldView::drawSelectionHighlights(QPainter& painter)
{
    painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
    painter.setBrush(Qt::NoBrush);

    for (BuildingId selId : m_selectedBuildingIds)
    {
        const std::optional<QRectF> rect = footprintWidgetRect(selId);
        if (!rect.has_value()) { continue; }
        // Outline sits 1px outside the footprint (into adjacent tiles).
        painter.drawRect(rect->adjusted(-1, -1, 1, 1));
    }

    // A ring around each selected piece of debris, sitting just outside the debris's
    // rendered circle (radius getTilePx()*0.2, matching drawDebris) (REQ-UI-DEBRIS-CLICK-SELECT).
    if (!m_selectedDebris.empty())
    {
        const qreal outlineRadius = static_cast<qreal>(getTilePx() * 0.2f) + 3.0;
        for (const DebrisInfo& debris : m_sim->getDebrisSystem().getAllDebrisInfo())
        {
            if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), debris.entity)
                == m_selectedDebris.end()) { continue; }
            painter.drawEllipse(worldToWidget(debris.position), outlineRadius, outlineRadius);
        }
    }
}

void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
{
    const QColor color = m_visuals->overlays.copyConfig;

    // Eligible-target tint: while a configuration is cached, every same-type
    // building and site (the source included) is a valid paste target and is
    // washed in the copy-settings color (REQ-BLD-COPY-CONFIG-FEEDBACK).
    if (m_copiedConfig.has_value())
    {
        painter.setPen(Qt::NoPen);
        painter.setBrush(color);
        const BuildingType type = m_copiedConfig->type;
        for (const Building& b : m_sim->getBuildings().getAllBuildings())
        {
            if (b.type != type) { continue; }
            const std::optional<QRectF> rect = footprintWidgetRect(b.id);
            if (rect.has_value()) { painter.drawRect(*rect); }
        }
        for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
        {
            if (s.type != type) { continue; }
            const std::optional<QRectF> rect = footprintWidgetRect(s.id);
            if (rect.has_value()) { painter.drawRect(*rect); }
        }
    }

    // Copy / paste flashes: a brief outline in the same color, drawn like the
    // selection outline (REQ-BLD-COPY-CONFIG-FEEDBACK).
    painter.setPen(QPen(color, 2));
    painter.setBrush(Qt::NoBrush);
    for (const CopyConfigFlash& flash : m_copyConfigFlashes)
    {
        const std::optional<QRectF> rect = footprintWidgetRect(flash.id);
        if (rect.has_value()) { painter.drawRect(rect->adjusted(-1, -1, 1, 1)); }
    }
}

void GameWorldView::drawPortItems(QPainter& painter)
{
    const float halfPx = getTilePx() * 0.5f * 0.5f;

    // Port items are drawn over the buildings (drawBuildings runs first) but clipped
    // to a thin margin at each machine's edges: the clip region is the whole view
    // minus every machine's interior (its footprint inset by kPortMarginTiles). So a
    // transiting item shows only near the port edge — appearing to emerge from / sink
    // into the machine (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE) and staying
    // visible in the ~2×margin band at a seam between two touching buildings
    // (REQ-MAT-DIRECT-COUPLE). Transport tiles are not machines and never occlude, so
    // items on belts stay fully visible.
    constexpr double kPortMarginTiles = 0.2;
    const double margin = kPortMarginTiles * static_cast<double>(getTilePx());

    QRegion clip(rect());
    for (const Building& b : m_sim->getBuildings().getAllBuildings())
    {
        if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter
            || b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit)
        {
            continue;
        }
        const std::set<QPoint, QPointCompare> cells(b.bodyCells.begin(), b.bodyCells.end());
        for (const QPoint& cell : b.bodyCells)
        {
            // Inset an edge only where the neighbouring cell is not part of the same
            // building, so interior cell seams stay filled (handles L-shaped footprints).
            const double l = cells.count(cell + QPoint(-1,  0)) ? 0.0 : margin;
            const double t = cells.count(cell + QPoint( 0, -1)) ? 0.0 : margin;
            const double r = cells.count(cell + QPoint( 1,  0)) ? 0.0 : margin;
            const double d = cells.count(cell + QPoint( 0,  1)) ? 0.0 : margin;
            clip = clip.subtracted(QRegion(tileRect(cell).adjusted(l, t, -r, -d).toRect()));
        }
    }

    // Shared with belt items (REQ-GW-TILE-SIZE): a half-tile item icon, or the
    // colored-square fallback (REQ-UI-ITEM-ICON), via the same draw path.
    const std::function<void(const ItemType&, QPointF)> drawItem =
        [&](const ItemType& type, QPointF worldPos)
    {
        const QPointF center = worldToWidget(
            QVector2D(static_cast<float>(worldPos.x()),
                      static_cast<float>(worldPos.y())));
        drawWorldItem(painter, type.id, center, halfPx);
    };

    painter.save();
    painter.setClipRegion(clip);
    m_sim->getBuildings().forEachEmergingItem(drawItem);
    m_sim->getBuildings().forEachIncomingItem(drawItem);
    painter.restore();
}

void GameWorldView::drawWorldItem(QPainter& painter, const std::string& itemId,
                                  QPointF center, float halfPx)
{
    const QRectF itemRect(center.x() - halfPx, center.y() - halfPx,
                          halfPx * 2, halfPx * 2);

    // Prefer the item's icon (REQ-UI-ITEM-ICON); it is rasterized once at the current
    // half-tile pixel size and cached, so this is a plain pixmap blit per frame.
    if (m_itemIcons && m_itemIcons->hasIcon(itemId))
    {
        int sizePx = qRound(static_cast<qreal>(halfPx * 2.0f));
        if (sizePx < 1) { sizePx = 1; }
        painter.drawPixmap(itemRect, m_itemIcons->getPixmap(itemId, sizePx),
                           QRectF(0, 0, sizePx, sizePx));
        return;
    }

    // Fallback: the colored square from visuals.toml (REQ-GW-TILE-SIZE).
    const std::map<std::string, ItemVisuals>::const_iterator it =
        m_visuals->items.find(itemId);
    if (it == m_visuals->items.end()) { return; }
    painter.fillRect(itemRect, it->second.fill);
    painter.setPen(QPen(it->second.outline, 1));
    painter.setBrush(Qt::NoBrush);
    painter.drawRect(itemRect);
}

void GameWorldView::drawBeltItems(QPainter& painter)
{
    const float halfPx = getTilePx() * 0.5f * 0.5f;
    const QRect vr     = getViewportRect();

    m_sim->getBelts().forEachVisualItem(vr, [&](const VisualItem& vi)
    {
        const QPointF center = worldToWidget(
            QVector2D(static_cast<float>(vi.worldPos.x()),
                      static_cast<float>(vi.worldPos.y())));
        drawWorldItem(painter, vi.type.id, center, halfPx);
    });
}

void GameWorldView::drawDebris(QPainter& painter)
{
    const float r = getTilePx() * 0.2f;
    for (const DebrisInfo& debris : m_sim->getDebrisSystem().getAllDebrisInfo())
    {
        const QPointF center = worldToWidget(debris.position);
        painter.setBrush(QColor(128, 110, 90));
        painter.setPen(QPen(QColor(50, 40, 30), 1));
        painter.drawEllipse(center,
                            static_cast<qreal>(r), static_cast<qreal>(r));
    }
}

void GameWorldView::drawStations(QPainter& painter)
{
    m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
        [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f,
            const HealthComponent& h)
        {
            const BuildingType visType = f.isEnemy
                ? BuildingType::EnemyDefenceStation
                : BuildingType::PlayerDefenceStation;
            const std::map<BuildingType, BuildingVisuals>::const_iterator it =
                m_visuals->buildings.find(visType);
            if (it == m_visuals->buildings.end()) { return; }
            const BuildingVisuals& bv = it->second;

            painter.setPen(Qt::NoPen);
            for (const QPoint& cell : sb.bodyCells)
            {
                painter.fillRect(tileRect(cell), bv.fill);
            }

            const QPointF tl = tileToWidget(QPoint(sb.anchor.x(), sb.anchor.y()));
            const QRectF bboxRect(tl.x(), tl.y(),
                sb.footprint.width()  * static_cast<qreal>(getTilePx()),
                sb.footprint.height() * static_cast<qreal>(getTilePx()));

            painter.setPen(QPen(bv.outline, 1));
            painter.setBrush(Qt::NoBrush);
            painter.drawRect(bboxRect);

            // Station icon over the fill (REQ-UI-WORLD-ICON); the same symbol is
            // colored blue/red by the player/enemy fill it is drawn over.
            drawBuildingIcon(painter, visType, bboxRect, bv.fill);

            if (isEntitySelected(e))
            {
                painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
                painter.setBrush(Qt::NoBrush);
                painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2));
            }

            // HP bar below footprint.
            if (h.maxHp > 0.0f)
            {
                drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0,
                          bboxRect.width(), h.hp / h.maxHp, f.isEnemy);
            }
        });
}

void GameWorldView::drawShips(QPainter& painter)
{
    m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
                           FactionComponent, HealthComponent>(
        [&](entt::entity e, const ShipIdentityComponent& si,
            const PositionComponent& pos, const FacingComponent& facing,
            const FactionComponent& fac, const HealthComponent& h)
        {
            const std::map<std::string, ShipVisuals>::const_iterator it =
                m_visuals->ships.find(si.schematicId);
            if (it == m_visuals->ships.end()) { return; }

            const QPointF center = worldToWidget(pos.value);
            const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
            const QVector2D perp(-dir.y(), dir.x());

            const float fwd  = getTilePx() * 0.45f;
            const float side = getTilePx() * 0.25f;

            QPolygonF tri;
            tri << QPointF(center.x() + static_cast<qreal>(dir.x()  * fwd),
                           center.y() + static_cast<qreal>(dir.y()  * fwd))
                << QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
                           center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
                << QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
                           center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side));

            painter.setPen(QPen(it->second.outline, 1));
            painter.setBrush(it->second.fill);
            painter.drawPolygon(tri);

            if (isEntitySelected(e))
            {
                painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
                painter.setBrush(Qt::NoBrush);
                const qreal r = static_cast<qreal>(fwd) + 2.0;
                painter.drawEllipse(center, r, r);
            }

            if (h.maxHp > 0.0f)
            {
                const qreal barW = static_cast<qreal>(fwd) * 2.0;
                const qreal barX = center.x() - static_cast<qreal>(fwd);
                const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
                drawHpBar(painter, barX, barY, barW, h.hp / h.maxHp, fac.isEnemy);
            }
        });
}

void GameWorldView::drawHpBar(QPainter& painter, qreal left, qreal top, qreal width,
                              float fraction, bool isEnemy)
{
    const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
    const float clamped = std::max(0.0f, fraction);
    painter.fillRect(QRectF(left, top, width, barH), QColor(60, 60, 60));
    painter.fillRect(QRectF(left, top, width * static_cast<qreal>(clamped), barH),
                     isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
}

void GameWorldView::drawDebugSensorRanges(QPainter& painter)
{
    painter.setBrush(Qt::NoBrush);
    m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
                           FactionComponent, SensorRangeComponent>(
        [&](entt::entity /*e*/, const ShipIdentityComponent& si,
            const PositionComponent& pos, const FacingComponent& /*facing*/,
            const FactionComponent& /*fac*/, const SensorRangeComponent& sensor)
        {
            const std::map<std::string, ShipVisuals>::const_iterator it =
                m_visuals->ships.find(si.schematicId);
            if (it == m_visuals->ships.end()) { return; }

            const QPointF center   = worldToWidget(pos.value);
            const qreal   radiusPx = static_cast<qreal>(sensor.value_tiles)
                                     * static_cast<qreal>(getTilePx());
            QColor circleColor = it->second.outline;
            circleColor.setAlpha(77);
            painter.setPen(QPen(circleColor, 1));
            painter.drawEllipse(center, radiusPx, radiusPx);
        });
}

void GameWorldView::drawDebugTargetLines(QPainter& painter)
{
    // Draw a thin translucent line from a ship to a target, colored by the ship's
    // own schematic fill. Shared by the attack, repair and salvage target lines.
    const std::function<void(const std::string&, const QVector2D&, const QVector2D&)>
        drawTargetLine = [&](const std::string& schematicId, const QVector2D& from,
                             const QVector2D& to)
    {
        const std::map<std::string, ShipVisuals>::const_iterator it =
            m_visuals->ships.find(schematicId);
        if (it == m_visuals->ships.end()) { return; }

        QColor lineColor = it->second.fill;
        lineColor.setAlpha(128);
        painter.setPen(QPen(lineColor, 1));
        painter.drawLine(worldToWidget(from), worldToWidget(to));
    };

    m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, AttackBehavior>(
        [&](entt::entity /*e*/, const ShipIdentityComponent& si,
            const PositionComponent& pos, const AttackBehavior& attack)
        {
            if (!attack.currentTarget.has_value()) { return; }

            const std::optional<QVector2D> targetPos =
                entityPosition(*attack.currentTarget);
            if (!targetPos.has_value()) { return; }

            drawTargetLine(si.schematicId, pos.value, *targetPos);
        });

    m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, RepairBehavior>(
        [&](entt::entity /*e*/, const ShipIdentityComponent& si,
            const PositionComponent& pos, const RepairBehavior& repair)
        {
            if (!repair.currentTarget.has_value()) { return; }

            const std::optional<QVector2D> targetPos =
                entityPosition(*repair.currentTarget);
            if (!targetPos.has_value()) { return; }

            drawTargetLine(si.schematicId, pos.value, *targetPos);
        });

    m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SalvageScrapBehavior>(
        [&](entt::entity /*e*/, const ShipIdentityComponent& si,
            const PositionComponent& pos, const SalvageScrapBehavior& salvage)
        {
            if (!salvage.debrisTarget.has_value()) { return; }

            drawTargetLine(si.schematicId, pos.value, *salvage.debrisTarget);
        });
}

void GameWorldView::drawDebugOverlay(QPainter& painter)
{
    painter.resetTransform();

    const QStringList lines = {
        tr("Accumulated Threat Level: %1")
            .arg(m_sim->getThreatLevel(), 0, 'f', 1),
        tr("Time until Wave: %1s")
            .arg(ticksToSeconds(m_sim->getNormalGapRemainingTicks()), 0, 'f', 1),
        tr("Threat Accumulation Rate: %1 threat/s")
            .arg(m_sim->getThreatAccumulationRate(), 0, 'f', 1),
        tr("Max Factory Production: %1 threat/s")
            .arg(m_sim->getMaxFactoryProductionThreatRate(), 0, 'f', 1),
        tr("Current Factory Production: %1 threat/s")
            .arg(m_sim->getCurrentFactoryProductionThreatRate(), 0, 'f', 1),
    };

    QFont font = painter.font();
    font.setPointSize(m_visuals->toast.fontSize);
    painter.setFont(font);

    const QFontMetrics fm = painter.fontMetrics();
    const int lineH   = fm.height();
    const int padding = 8;
    const int spacing = 4;

    int textW = 0;
    for (const QString& line : lines)
    {
        textW = std::max(textW, fm.horizontalAdvance(line));
    }
    const int bgW = textW + padding * 2;
    const int bgH = lineH * lines.size() + spacing * (lines.size() - 1) + padding * 2;

    const QRect bgRect(padding, padding, bgW, bgH);
    painter.fillRect(bgRect, QColor(0, 0, 0, 160));

    painter.setPen(Qt::white);
    int y = padding * 2;
    for (const QString& line : lines)
    {
        const QRect textRect(padding * 2, y, textW, lineH);
        painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, line);
        y += lineH + spacing;
    }
}

void GameWorldView::drawBeams(QPainter& painter)
{
    const QPainter::RenderHints savedHints = painter.renderHints();
    painter.setRenderHint(QPainter::Antialiasing, true);

    for (const ActiveBeam& beam : m_activeBeams)
    {
        const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
        const std::optional<QVector2D> targetPos  = entityPosition(beam.event.target);
        if (!shooterPos.has_value() || !targetPos.has_value()) { continue; }

        QColor color = m_visuals->beams.weaponColor;
        switch (beam.event.kind)
        {
            case BeamKind::Weapon:  color = m_visuals->beams.weaponColor;  break;
            case BeamKind::Repair:  color = m_visuals->beams.repairColor;  break;
            case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
        }

        const QPointF s = worldToWidget(*shooterPos);
        const QPointF t = worldToWidget(*targetPos + beam.targetOffset);

        // Unit direction/perpendicular of the beam in widget space. A degenerate
        // zero-length beam (shooter and target coincide) has no direction to
        // taper along, so skip it.
        const QVector2D delta(static_cast<float>(t.x() - s.x()),
                              static_cast<float>(t.y() - s.y()));
        const float lengthPx = delta.length();
        if (lengthPx < 0.001f) { continue; }
        const QVector2D dir  = delta / lengthPx;
        const QVector2D perp(-dir.y(), dir.x());

        // Directional taper: draw the beam as a quad that is wide at the shooter
        // and narrows to a faint tip at the target, so it reads as an arrow
        // pointing away from whoever fired it. Without this, a beam strung
        // between two nearby ships is symmetric and gives no cue which end is the
        // source (the readability problem this addresses).
        const float widthPx  = std::max(1.0f, static_cast<float>(m_visuals->beams.widthPx));
        const float baseHalf = widthPx * 1.f;
        const float tipHalf  = widthPx * 0.35f;

        QPolygonF quad;
        quad << QPointF(s.x() + static_cast<qreal>(perp.x() * baseHalf),
                        s.y() + static_cast<qreal>(perp.y() * baseHalf))
             << QPointF(s.x() - static_cast<qreal>(perp.x() * baseHalf),
                        s.y() - static_cast<qreal>(perp.y() * baseHalf))
             << QPointF(t.x() - static_cast<qreal>(perp.x() * tipHalf),
                        t.y() - static_cast<qreal>(perp.y() * tipHalf))
             << QPointF(t.x() + static_cast<qreal>(perp.x() * tipHalf),
                        t.y() + static_cast<qreal>(perp.y() * tipHalf));

        QColor bright = color;
        bright.setAlpha(255);
        QColor faint = color;
        faint.setAlpha(90);

        QLinearGradient bodyGrad(s, t);
        bodyGrad.setColorAt(0.0, bright);
        bodyGrad.setColorAt(1.0, faint);

        painter.setPen(Qt::NoPen);
        painter.setBrush(bodyGrad);
        painter.drawPolygon(quad);
    }

    painter.setBrush(Qt::NoBrush);
    painter.setRenderHints(savedHints);
}

void GameWorldView::drawSelectedTunnelConnections(QPainter& painter)
{
    if (m_selectedBuildingIds.empty()) { return; }

    const TunnelTileMap tunnels = collectTunnelTiles();
    if (tunnels.empty()) { return; }

    const TunnelLookup lookup = makeTunnelLookup(tunnels);

    // Collect the tiles to highlight in a set so a connection selected from both ends
    // (or overlapping runs) is filled exactly once — filling a semi-transparent green
    // twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
    std::set<QPoint, QPointCompare> highlightTiles;
    for (const BuildingId id : m_selectedBuildingIds)
    {
        std::optional<QPoint>   anchor;
        std::optional<BuildingType> type;
        Rotation                rotation = Rotation::East;
        if (const Building* b = m_sim->getBuildings().findBuilding(id))
        {
            anchor = b->anchor; type = b->type; rotation = b->rotation;
        }
        else if (const ConstructionSite* s = m_sim->getBuildings().findSite(id))
        {
            anchor = s->anchor; type = s->type; rotation = s->rotation;
        }
        if (!type.has_value()
            || (*type != BuildingType::TunnelEntry && *type != BuildingType::TunnelExit))
        {
            continue;
        }

        const std::optional<QPoint> partner = findTunnelPartner(
            lookup, *anchor, *type, rotation, m_config->world.tunnelMaxDistance_tiles);
        if (!partner.has_value()) { continue; }

        // Add every tile from the selected end to its partner inclusive (colinear run).
        const QPoint delta = *partner - *anchor;
        const QPoint stepDir((delta.x() > 0) - (delta.x() < 0),
                             (delta.y() > 0) - (delta.y() < 0));
        for (QPoint t = *anchor; ; t += stepDir)
        {
            highlightTiles.insert(t);
            if (t == *partner) { break; }
        }
    }

    const QColor green = m_visuals->overlays.tunnelPreview;
    for (const QPoint& tile : highlightTiles)
    {
        painter.fillRect(tileRect(tile), green);
    }
}

void GameWorldView::drawOverlays(QPainter& painter)
{
    // Green connection highlight for any selected tunnel end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
    drawSelectedTunnelConnections(painter);

    // Builder-mode ghost
    if (m_builderType.has_value())
    {
        if (*m_builderType == BuildingType::Belt && m_dragging)
        {
            // Belt drag: a ghost per path tile (REQ-BLD-BELT-DRAG). Rotate-in-place
            // and affordable new tiles use the belt colors; occupied/invalid tiles
            // use the invalid color; unaffordable tiles show no ghost at all.
            const std::vector<BeltDragResolved> resolved = resolveBeltDragPath();
            for (std::size_t index = 0; index < resolved.size(); ++index)
            {
                const BeltDragResolved& item = resolved[index];
                if (item.action == BeltTileAction::PlaceNew && !item.affordable)
                {
                    continue;
                }
                const BeltPathTile& entry = m_beltDragPath[index];
                drawBuildingGhost(painter, BuildingType::Belt, entry.tile,
                                  entry.rotation,
                                  /*valid*/ item.action != BeltTileAction::Invalid,
                                  /*showPortTargetGlyphs*/ true);
            }
        }
        else
        {
            // In tunnel mode the ghost shows the position-resolved type (entry or
            // exit) and, when it would complete an existing tunnel, the matched end
            // and the tiles between it and the ghost are tinted green
            // (REQ-BLD-TUNNEL-MODE).
            if (inTunnelMode() && m_ghostValid && m_tunnelPartnerTile.has_value())
            {
                const QColor green = m_visuals->overlays.tunnelPreview;
                painter.fillRect(tileRect(*m_tunnelPartnerTile), green);

                // Partner and ghost tile are colinear along the tunnel run; tint the
                // tiles strictly between them.
                const QPoint delta = *m_tunnelPartnerTile - m_ghostTile;
                const QPoint step((delta.x() > 0) - (delta.x() < 0),
                                  (delta.y() > 0) - (delta.y() < 0));
                for (QPoint t = m_ghostTile + step; t != *m_tunnelPartnerTile; t += step)
                {
                    painter.fillRect(tileRect(t), green);
                }
            }

            drawBuildingGhost(painter, effectiveBuilderType(), m_ghostTile,
                              m_ghostRotation, m_ghostValid,
                              /*showPortTargetGlyphs*/ true);
        }
    }

    // Blueprint placement ghost
    if (m_blueprintMode.has_value())
    {
        for (const BlueprintBuilding& bb : m_blueprintMode->buildings)
        {
            // Locked building types are omitted from the blueprint (REQ-LOCK-BUILDING,
            // REQ-LOCK-UI-BLUEPRINT), so they are not ghosted either.
            if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
            const QPoint anchor = m_blueprintGhostTile + bb.offset;
            const bool   valid  = isValidPlacement(bb.type, anchor, bb.rotation);
            drawBuildingGhost(painter, bb.type, anchor, bb.rotation, valid,
                              /*showPortTargetGlyphs*/ false);
        }
    }

    // Queued for deconstruction: tint every building currently in the
    // deconstruction queue, regardless of mode (REQ-BLD-DECON-QUEUE).
    for (const Building& b : m_sim->getBuildings().getAllBuildings())
    {
        if (!b.queuedForDeconstruction) { continue; }
        for (const QPoint& cell : b.bodyCells)
        {
            painter.fillRect(tileRect(cell), m_visuals->overlays.deconstructTint);
        }
    }

    // Deconstruct tint: while dragging a deconstruct box, tint every covered
    // building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one.
    if (m_deconstructMode && m_boxSelecting)
    {
        for (BuildingId id : buildingsInBox(m_boxStartTile, m_boxCurrentTile))
        {
            const Building* b = m_sim->getBuildings().findBuilding(id);
            if (b && b->type == BuildingType::Hq) { continue; }
            const std::vector<QPoint>* cells = nullptr;
            const ConstructionSite* s = nullptr;
            if (b) { cells = &b->bodyCells; }
            else if ((s = m_sim->getBuildings().findSite(id))) { cells = &s->bodyCells; }
            if (cells)
            {
                for (const QPoint& cell : *cells)
                {
                    painter.fillRect(tileRect(cell), m_visuals->overlays.deconstructTint);
                }
            }
        }
    }
    else if (m_deconstructMode && m_deconstructHoverBuildingId.has_value())
    {
        const Building* b = m_sim->getBuildings().findBuilding(*m_deconstructHoverBuildingId);
        if (b)
        {
            for (const QPoint& cell : b->bodyCells)
            {
                painter.fillRect(tileRect(cell), m_visuals->overlays.deconstructTint);
            }
        }
    }

    // Box-select rectangle
    if (m_boxSelecting)
    {
        const QPoint tl(std::min(m_boxStartTile.x(), m_boxCurrentTile.x()),
                        std::min(m_boxStartTile.y(), m_boxCurrentTile.y()));
        const QPoint br(std::max(m_boxStartTile.x(), m_boxCurrentTile.x()) + 1,
                        std::max(m_boxStartTile.y(), m_boxCurrentTile.y()) + 1);
        const QRectF selRect(tileToWidget(tl), tileToWidget(br));
        painter.setPen(QPen(m_visuals->overlays.selectionRect, 1));
        painter.setBrush(Qt::NoBrush);
        painter.drawRect(selRect);
    }
}

void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
                                      QPoint anchorTile, Rotation rotation,
                                      bool valid, bool showPortTargetGlyphs)
{
    const BuildingDef* def = m_config->buildings.findBuildingDef(type);
    if (!def) { return; }

    const std::map<BuildingType, BuildingVisuals>::const_iterator it =
        m_visuals->buildings.find(type);
    if (it == m_visuals->buildings.end()) { return; }
    const BuildingVisuals& bv = it->second;

    // Valid ghosts show the building type's own colors; invalid ghosts override
    // with the distinct invalid color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The
    // invalid color's RGB is taken at full opacity so it does not double-dim
    // against the setOpacity below (the configured color carries its own alpha).
    const QColor invalidColor(m_visuals->overlays.ghostInvalid.red(),
                              m_visuals->overlays.ghostInvalid.green(),
                              m_visuals->overlays.ghostInvalid.blue());
    const QColor fillColor = valid ? bv.fill : invalidColor;
    const QColor lineColor = valid ? bv.outline : invalidColor;

    const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
    if (parsed.bodyCells.empty()) { return; }

    painter.setOpacity(0.5);

    QPoint minCell = parsed.bodyCells.front();
    QPoint maxCell = parsed.bodyCells.front();
    for (const QPoint& cell : parsed.bodyCells)
    {
        painter.fillRect(tileRect(anchorTile + cell), fillColor);
        minCell.setX(std::min(minCell.x(), cell.x()));
        minCell.setY(std::min(minCell.y(), cell.y()));
        maxCell.setX(std::max(maxCell.x(), cell.x()));
        maxCell.setY(std::max(maxCell.y(), cell.y()));
    }

    const QPointF tl = tileToWidget(anchorTile + minCell);
    const QRectF  bboxRect(tl.x(), tl.y(),
        (maxCell.x() - minCell.x() + 1) * static_cast<qreal>(getTilePx()),
        (maxCell.y() - minCell.y() + 1) * static_cast<qreal>(getTilePx()));

    painter.setPen(QPen(lineColor, 1));
    painter.setBrush(Qt::NoBrush);
    painter.drawRect(bboxRect);

    // Icon glyph over the ghost fill (REQ-UI-WORLD-ICON); an invalid ghost's fill
    // is the red invalid color, which auto-contrasts to a white icon.
    if (!drawBuildingIcon(painter, type, bboxRect, fillColor) && !bv.glyph.isEmpty())
    {
        painter.setPen(lineColor);
        painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);
    }

    for (const Port& port : parsed.outputPorts)
    {
        drawPortGlyph(painter, anchorTile + outputBodyTile(port.tile, port.direction),
                      port.direction, lineColor, /*centered*/ false);
    }

    // REQ-UI-PORT-TARGET-GLYPH: while in builder mode, additionally mark each
    // output port's target cell (the cell just outside the footprint the port
    // pushes into) with a directional glyph, previewing where output will flow.
    // The Tunnel Entry is excluded — it receives items from any of its non-mouth
    // edges rather than emitting into a single adjacent cell. The Shipyard is
    // excluded too — it spawns a ship rather than emitting a belt item.
    if (showPortTargetGlyphs
        && type != BuildingType::TunnelEntry
        && type != BuildingType::Shipyard)
    {
        for (const Port& port : parsed.outputPorts)
        {
            drawPortGlyph(painter, anchorTile + port.tile, port.direction,
                          lineColor, /*centered*/ true);
        }
    }

    painter.setOpacity(1.0);
}

void GameWorldView::drawScreenSpace(QPainter& /*painter*/)
{
}

void GameWorldView::drawPauseBorder(QPainter& painter)
{
    if (m_gameSpeedMultiplier > 0.0) { return; }
    drawVignetteBorder(painter, QColor(0, 0, 0, 128));   // 50% black at the edge
}

void GameWorldView::drawDeconstructBorder(QPainter& painter)
{
    if (!m_deconstructMode) { return; }
    // Reuse the deconstruct overlay color (with its configured alpha) as the edge color.
    drawVignetteBorder(painter, m_visuals->overlays.deconstructTint);
}

void GameWorldView::drawVignetteBorder(QPainter& painter, const QColor& edgeColor)
{
    // Border thickness in pixels, capped so it never exceeds half the viewport on
    // very small windows (which would leave no un-tinted center).
    const qreal borderPx = std::min<qreal>(
        100.0, std::min(width(), height()) / 2.0);
    if (borderPx <= 0.0) { return; }

    const QColor& edge = edgeColor;         // full color (and alpha) at the viewport edge
    QColor inner = edgeColor;
    inner.setAlpha(0);                      // same color, fully transparent toward the center

    const qreal w = width();
    const qreal h = height();

    painter.save();

    // Each side is a linear gradient fading from `edge` at the viewport border to
    // `inner` toward the center. The four sides are clipped to trapezoids that meet
    // along the corner diagonals (a mitred picture frame), so the strips never
    // overlap and don't double-darken the corners. Because the border thickness is
    // the same on every side, both adjoining gradients evaluate equally along each
    // 45-degree diagonal and join seamlessly.
    const std::function<void(const QLinearGradient&, const QPolygonF&)> drawSide =
        [&](const QLinearGradient& gradient, const QPolygonF& trapezoid) {
        QPainterPath clip;
        clip.addPolygon(trapezoid);
        painter.save();
        painter.setClipPath(clip);
        painter.fillRect(QRectF(0, 0, w, h), gradient);
        painter.restore();
    };

    QLinearGradient left(0, 0, borderPx, 0);
    left.setColorAt(0.0, edge);
    left.setColorAt(1.0, inner);
    drawSide(left, QPolygonF({ QPointF(0, 0), QPointF(borderPx, borderPx),
                              QPointF(borderPx, h - borderPx), QPointF(0, h) }));

    QLinearGradient right(w, 0, w - borderPx, 0);
    right.setColorAt(0.0, edge);
    right.setColorAt(1.0, inner);
    drawSide(right, QPolygonF({ QPointF(w, 0), QPointF(w - borderPx, borderPx),
                               QPointF(w - borderPx, h - borderPx), QPointF(w, h) }));

    QLinearGradient top(0, 0, 0, borderPx);
    top.setColorAt(0.0, edge);
    top.setColorAt(1.0, inner);
    drawSide(top, QPolygonF({ QPointF(0, 0), QPointF(w, 0),
                             QPointF(w - borderPx, borderPx), QPointF(borderPx, borderPx) }));

    QLinearGradient bottom(0, h, 0, h - borderPx);
    bottom.setColorAt(0.0, edge);
    bottom.setColorAt(1.0, inner);
    drawSide(bottom, QPolygonF({ QPointF(0, h), QPointF(w, h),
                                QPointF(w - borderPx, h - borderPx), QPointF(borderPx, h - borderPx) }));

    painter.restore();
}

void GameWorldView::drawReplayOverlay(QPainter& painter)
{
    if (!m_replayPlayer) { return; }

    painter.save();

    QFont tag = painter.font();
    tag.setPixelSize(16);
    tag.setBold(true);
    painter.setFont(tag);
    painter.setPen(QColor(255, 220, 80));
    painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY"));

    // The replay is over once the recorded stream is exhausted or the run reached
    // its terminal state (win / defeat) — matching where playback freezes above.
    const bool ended = m_replayPlayer->isFinished()
        || m_sim->isWon() || m_sim->isGameOver();
    if (ended)
    {
        // Dim the world so the end message reads clearly over it.
        painter.fillRect(rect(), QColor(0, 0, 0, 140));

        const std::optional<Tick> desync = m_replayPlayer->getDesyncTick();
        QString message;
        if (desync.has_value())
        {
            message = tr("Desync at tick %1").arg(static_cast<qlonglong>(*desync));
            painter.setPen(QColor(255, 90, 90));
        }
        else if (m_sim->isWon())
        {
            message = tr("Replay ended — Victory");
            painter.setPen(QColor(120, 255, 120));
        }
        else if (m_sim->isGameOver())
        {
            message = tr("Replay ended — Defeat");
            painter.setPen(QColor(255, 255, 255));
        }
        else
        {
            message = tr("Replay ended");
            painter.setPen(QColor(255, 255, 255));
        }
        QFont big = painter.font();
        big.setPixelSize(28);
        painter.setFont(big);
        painter.drawText(rect(), Qt::AlignCenter, message);
    }

    painter.restore();
}

// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------

void GameWorldView::keyPressEvent(QKeyEvent* event)
{
    if (event->isAutoRepeat())
    {
        QOpenGLWidget::keyPressEvent(event);
        return;
    }

    // Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
    // physical digit independent of keyboard layout and Shift (with Shift held, key()
    // for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
    const quint32 virtualKey = event->nativeVirtualKey();
    if (virtualKey >= 0x31 && virtualKey <= 0x39)
    {
        const int  digit = static_cast<int>(virtualKey - 0x30);
        const bool shift = (event->modifiers() & Qt::ShiftModifier) != 0;
        std::optional<BuildingType> type;
        if (!shift)
        {
            switch (digit)
            {
            case 1: type = BuildingType::Belt;        break;
            case 2: type = BuildingType::Splitter;    break;
            case 3: type = BuildingType::TunnelEntry; break;  // unified tunnel mode (REQ-BLD-TUNNEL-MODE); 4 unused
            }
        }
        else
        {
            switch (digit)
            {
            case 1: type = BuildingType::Miner;             break;
            case 2: type = BuildingType::Smelter;           break;
            case 3: type = BuildingType::Assembler;         break;
            case 4: type = BuildingType::Shipyard;          break;
            case 5: type = BuildingType::SalvageBay;        break;
            case 6: type = BuildingType::ReprocessingPlant; break;
            }
        }
        if (type.has_value())
        {
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<BuildHotkeyPressedEvent>(*type));
            return;
        }
    }

    switch (event->key())
    {
    case Qt::Key_A:
        m_scrollLeft = true;
        break;
    case Qt::Key_D:
        m_scrollRight = true;
        break;
    case Qt::Key_Space:
        if (m_gameSpeedMultiplier > 0.0)
        {
            m_prevNonZeroSpeed = m_gameSpeedMultiplier;
            setGameSpeed(0.0);
        }
        else
        {
            setGameSpeed(m_prevNonZeroSpeed);
        }
        break;
    case Qt::Key_W:
        stepSpeed(+1);
        break;
    case Qt::Key_S:
        stepSpeed(-1);
        break;
    case Qt::Key_R:
        rotateGhost(event->modifiers() & Qt::ShiftModifier);
        break;
    case Qt::Key_Q:
        if (m_builderType.has_value())        { exitBuilderMode(); }
        else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
        else                                  { toggleDeconstructMode(); }
        break;
    case Qt::Key_T:
        // Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
        // The BlueprintPanel owns the selection and blueprint-capture logic; it decides
        // whether anything placeable is selected and drives placement mode from there.
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<TemporaryBlueprintRequestedEvent>());
        break;
    case Qt::Key_Escape:
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<EscapeMenuRequestedEvent>());
        break;
	case Qt::Key_F3:
		m_debugDraw = !m_debugDraw;
		EventManager::getInstance()->sendEventImmediately(
			std::make_shared<DebugDrawToggledEvent>(m_debugDraw));
		break;
	case Qt::Key_F4:
		EventManager::getInstance()->addEvent(std::make_shared<TracePrintRequestedEvent>());
		break;
    default:
        QOpenGLWidget::keyPressEvent(event);
        break;
    }
}

void GameWorldView::keyReleaseEvent(QKeyEvent* event)
{
    if (event->isAutoRepeat())
    {
        QOpenGLWidget::keyReleaseEvent(event);
        return;
    }
    if (event->key() == Qt::Key_A) { m_scrollLeft  = false; }
    if (event->key() == Qt::Key_D) { m_scrollRight = false; }
    // Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG).
    if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); }
    QOpenGLWidget::keyReleaseEvent(event);
}

void GameWorldView::mousePressEvent(QMouseEvent* event)
{
    if (event->button() != Qt::LeftButton)
    {
        if (event->button() == Qt::RightButton)
        {
            if (m_builderType.has_value())
            {
                if (m_dragging)
                {
                    // Cancel the in-progress belt drag without placing anything;
                    // stay in belt builder mode (REQ-BLD-BELT-DRAG).
                    m_dragging = false;
                    m_beltDragPath.clear();
                }
                else
                {
                    exitBuilderMode();
                }
            }
            else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
            else if (m_deconstructMode)              { toggleDeconstructMode(); }
            else if (event->modifiers() & Qt::ShiftModifier)
            {
                // Shift + right-click copies a building's settings, but only in the
                // default selection mode (REQ-BLD-COPY-CONFIG).
                const QPoint tile = widgetToTile(event->pos());
                std::optional<BuildingId> id = buildingAtTile(tile);
                if (!id.has_value()) { id = siteAtTile(tile); }
                if (id.has_value()) { copyConfigFrom(*id); }
            }
        }
        return;
    }

    const QPoint tile = widgetToTile(event->pos());

    if (m_builderType.has_value())
    {
        const BuildingType type = *m_builderType;
        if (type == BuildingType::Belt)
        {
            // Deferred placement: start the drag and show the path ghost; nothing
            // is placed until release (REQ-BLD-BELT-DRAG).
            m_dragging       = true;
            m_beltDragAnchor = tile;
            m_cursorWorldPos = widgetToWorld(event->pos());
            recomputeBeltDragPath(tile);
        }
        else
        {
            placeAtTile(tile);
        }
    }
    else if (m_blueprintMode.has_value())
    {
        placeBlueprintAtTile(tile);
    }
    else if (m_deconstructMode)
    {
        // Start a deconstruct box drag; a plain click resolves as a 1x1 box on
        // release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX).
        m_boxSelecting   = true;
        m_boxStartTile   = tile;
        m_boxCurrentTile = tile;
    }
    else
    {
        // Shift + left-click applies the copied settings to a same-type building
        // (REQ-BLD-COPY-CONFIG). Consumes the click so it does not change the
        // selection. Only active in the default selection mode.
        if ((event->modifiers() & Qt::ShiftModifier) && m_copiedConfig.has_value())
        {
            std::optional<BuildingId> id = buildingAtTile(tile);
            if (!id.has_value()) { id = siteAtTile(tile); }
            if (id.has_value())
            {
                pasteConfigTo(*id);
                return;
            }
        }

        const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
        const QVector2D worldPos = widgetToWorld(event->pos());

        // Point hit-test precedence: buildings win over actors, which win over debris
        // (REQ-UI-SELECTION-CATEGORIES).
        std::optional<BuildingId> buildingHit = buildingAtTile(tile);
        if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }

        if (buildingHit.has_value())
        {
            const BuildingId id = *buildingHit;
            // A building selection is exclusive: it clears any field selection —
            // actors and debris — because buildings win (REQ-UI-SELECTION-CATEGORIES).
            clearEntitySelection();
            clearDebrisSelection();
            if (ctrl)
            {
                bool found = false;
                std::vector<BuildingId> newSel;
                for (BuildingId sel : m_selectedBuildingIds)
                {
                    if (sel == id) { found = true; }
                    else           { newSel.push_back(sel); }
                }
                if (!found) { newSel.push_back(id); }
                m_selectedBuildingIds = newSel;
            }
            else
            {
                m_selectedBuildingIds = { id };
            }
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
            return;
        }

        // Selecting a field object (actor or debris) clears any building selection but
        // lets actors and debris coexist (REQ-UI-SELECTION-CATEGORIES).
        const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
        if (actorHit != entt::null)
        {
            if (!m_selectedBuildingIds.empty())
            {
                m_selectedBuildingIds.clear();
                EventManager::getInstance()->sendEventImmediately(
                    std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
            }
            if (ctrl)
            {
                // Toggle this actor within the field selection, leaving debris intact
                // (REQ-UI-ENTITY-CLICK-SELECT).
                bool found = false;
                std::vector<entt::entity> newSel;
                for (entt::entity sel : m_selectedEntities)
                {
                    if (sel == actorHit) { found = true; }
                    else                 { newSel.push_back(sel); }
                }
                if (!found) { newSel.push_back(actorHit); }
                m_selectedEntities = newSel;
            }
            else
            {
                // A plain click makes this actor the sole selection.
                m_selectedEntities = { actorHit };
                clearDebrisSelection();
            }
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
            return;
        }

        if (const entt::entity debrisHit =
                debrisAtWorldPos(m_sim->getAdmin(), worldPos); debrisHit != entt::null)
        {
            if (!m_selectedBuildingIds.empty())
            {
                m_selectedBuildingIds.clear();
                EventManager::getInstance()->sendEventImmediately(
                    std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
            }
            if (ctrl)
            {
                // Toggle this debris within the field selection, leaving actors intact
                // (REQ-UI-DEBRIS-MULTI-SELECT).
                bool found = false;
                std::vector<entt::entity> newSel;
                for (entt::entity sel : m_selectedDebris)
                {
                    if (sel == debrisHit) { found = true; }
                    else                 { newSel.push_back(sel); }
                }
                if (!found) { newSel.push_back(debrisHit); }
                m_selectedDebris = newSel;
            }
            else
            {
                // A plain click makes this debris the sole selection.
                m_selectedDebris = { debrisHit };
                clearEntitySelection();
            }
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<DebrisSelectionChangedEvent>(m_selectedDebris));
            return;
        }

        // Empty space: a plain click clears the whole selection and starts a box drag;
        // Ctrl preserves the current selection for additive box-select.
        if (!ctrl)
        {
            if (!m_selectedBuildingIds.empty())
            {
                m_selectedBuildingIds.clear();
                EventManager::getInstance()->sendEventImmediately(
                    std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
            }
            clearEntitySelection();
            clearDebrisSelection();
        }
        m_boxSelecting    = true;
        m_boxStartTile    = tile;
        m_boxCurrentTile  = tile;
    }
}

void GameWorldView::mouseMoveEvent(QMouseEvent* event)
{
    const QPoint tile = widgetToTile(event->pos());
    m_cursorWorldPos  = widgetToWorld(event->pos());

    if (m_builderType.has_value())
    {
        m_ghostTile  = tile;
        m_ghostValid = isValidPlacement(*m_builderType, tile, m_ghostRotation);

        if (inTunnelMode())
        {
            // Resolve entry vs exit and the completion partner for the new hover
            // position and sub-tile cursor (REQ-BLD-TUNNEL-MODE).
            updateTunnelGhost();
        }

        if (m_dragging)
        {
            // Belt drag: update the previewed path; placement happens on release
            // (REQ-BLD-BELT-DRAG).
            recomputeBeltDragPath(tile);
        }
    }
    else if (m_blueprintMode.has_value())
    {
        m_blueprintGhostTile = tile;
    }
    else if (m_deconstructMode)
    {
        m_deconstructHoverBuildingId = buildingAtTile(tile);
        if (m_boxSelecting) { m_boxCurrentTile = tile; }
    }
    else if (m_boxSelecting)
    {
        m_boxCurrentTile = tile;
    }
}

void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
{
    if (event->button() != Qt::LeftButton) { return; }

    if (m_dragging)
    {
        // Apply the previewed belt path now that the button is released
        // (REQ-BLD-BELT-DRAG).
        applyBeltDragPath();
        m_dragging = false;
        m_beltDragPath.clear();
    }

    if (m_boxSelecting)
    {
        m_boxSelecting = false;

        const std::vector<BuildingId> boxIds =
            buildingsInBox(m_boxStartTile, m_boxCurrentTile);

        if (m_deconstructMode)
        {
            const BuildingSystem& buildings = m_sim->getBuildings();

            // Split covered ids into construction sites (removed instantly) and
            // operational deconstructible buildings (the HQ is protected; player
            // defence stations are not Buildings and never appear in the box).
            std::vector<BuildingId> sites;
            std::vector<BuildingId> operational;
            for (BuildingId id : boxIds)
            {
                if (const Building* b = buildings.findBuilding(id))
                {
                    if (b->type == BuildingType::Hq) { continue; }
                    operational.push_back(id);
                }
                else if (buildings.findSite(id))
                {
                    sites.push_back(id);
                }
            }

            // Sites: instant demolition with the full refund (REQ-BLD-DECONSTRUCT).
            for (BuildingId id : sites)
            {
                std::shared_ptr<DeconstructCommand> command =
                    std::make_shared<DeconstructCommand>();
                command->id = id;
                enqueueCommand(command);
            }

            // Operational buildings: if every covered one is already queued,
            // un-queue them all; otherwise queue each one not yet queued. A plain
            // click is the one-element case, giving the queue/un-queue toggle
            // (REQ-BLD-DECONSTRUCT-BOX, REQ-BLD-DECONSTRUCT-CLICK).
            bool allQueued = !operational.empty();
            for (BuildingId id : operational)
            {
                if (!buildings.isQueuedForDeconstruction(id)) { allQueued = false; break; }
            }
            for (BuildingId id : operational)
            {
                if (allQueued)
                {
                    std::shared_ptr<CancelDeconstructionCommand> command =
                        std::make_shared<CancelDeconstructionCommand>();
                    command->id = id;
                    enqueueCommand(command);
                }
                else if (!buildings.isQueuedForDeconstruction(id))
                {
                    std::shared_ptr<DeconstructCommand> command =
                        std::make_shared<DeconstructCommand>();
                    command->id = id;
                    enqueueCommand(command);
                }
            }

            m_deconstructHoverBuildingId = std::nullopt;
            return;
        }

        const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;

        if (!boxIds.empty())
        {
            // A box covering any building selects buildings; field objects (actors and
            // debris) in the box are ignored — buildings win (REQ-UI-MULTI-SELECT).
            clearEntitySelection();
            clearDebrisSelection();
            if (!ctrl)
            {
                m_selectedBuildingIds = boxIds;
            }
            else
            {
                for (BuildingId id : boxIds)
                {
                    bool found = false;
                    for (BuildingId sel : m_selectedBuildingIds)
                    {
                        if (sel == id) { found = true; break; }
                    }
                    if (!found) { m_selectedBuildingIds.push_back(id); }
                }
            }
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
            return;
        }

        // No buildings in the box: select the field objects it covers — ships, defence
        // stations, and debris together (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
        const std::vector<entt::entity> boxActors =
            actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
        const std::vector<entt::entity> boxDebris =
            debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
        if (!boxActors.empty() || !boxDebris.empty())
        {
            if (!m_selectedBuildingIds.empty())
            {
                m_selectedBuildingIds.clear();
                EventManager::getInstance()->sendEventImmediately(
                    std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
            }
            if (!ctrl)
            {
                m_selectedEntities = boxActors;
                m_selectedDebris    = boxDebris;
            }
            else
            {
                for (entt::entity e : boxActors)
                {
                    bool found = false;
                    for (entt::entity sel : m_selectedEntities)
                    {
                        if (sel == e) { found = true; break; }
                    }
                    if (!found) { m_selectedEntities.push_back(e); }
                }
                for (entt::entity e : boxDebris)
                {
                    bool found = false;
                    for (entt::entity sel : m_selectedDebris)
                    {
                        if (sel == e) { found = true; break; }
                    }
                    if (!found) { m_selectedDebris.push_back(e); }
                }
            }
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<DebrisSelectionChangedEvent>(m_selectedDebris));
            return;
        }

        // Empty box: a plain (non-additive) drag clears the current selection.
        if (!ctrl)
        {
            m_selectedBuildingIds.clear();
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
            clearEntitySelection();
            clearDebrisSelection();
        }
    }
}

// ---------------------------------------------------------------------------
// Methods (formerly slots)
// ---------------------------------------------------------------------------

void GameWorldView::toggleDeconstructMode()
{
    if (m_deconstructMode)
    {
        m_deconstructMode    = false;
        m_deconstructHoverBuildingId = std::nullopt;
    }
    else
    {
        if (m_builderType.has_value())        { exitBuilderMode(); }
        if (m_blueprintMode.has_value())      { exitBlueprintMode(); }
        m_deconstructMode = true;
    }
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<DeconstructModeChangedEvent>(m_deconstructMode));
}

void GameWorldView::rotateGhost(bool clockwise)
{
    if (m_builderType.has_value())
    {
        m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation)
                                    : rotateCounterClockwise(m_ghostRotation);
        m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation);
        // A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE).
        if (inTunnelMode()) { updateTunnelGhost(); }
        // Rotating during a belt drag re-picks the path's primary axis immediately,
        // without waiting for the next mouse move (REQ-BLD-BELT-DRAG).
        if (m_dragging) { recomputeBeltDragPath(m_ghostTile); }
    }
    else if (m_blueprintMode.has_value())
    {
        for (BlueprintBuilding& bb : m_blueprintMode->buildings)
        {
            const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
            if (!def) { continue; }
            const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
            int minX = INT_MAX, minY = INT_MAX;
            for (const QPoint& cell : mask.bodyCells)
            {
                const QPoint abs = bb.offset + cell;
                if (clockwise)
                {
                    minX = std::min(minX, -abs.y());
                    minY = std::min(minY,  abs.x());
                }
                else
                {
                    minX = std::min(minX,  abs.y());
                    minY = std::min(minY, -abs.x());
                }
            }
            bb.offset   = QPoint(minX, minY);
            bb.rotation = clockwise ? rotateClockwise(bb.rotation)
                                    : rotateCounterClockwise(bb.rotation);
        }
    }
}

void GameWorldView::copyConfigFrom(BuildingId id)
{
    const std::optional<BuildingConfig> config = readBuildingConfig(*m_sim, id);
    if (!config.has_value()) { return; }

    // Only cache when there is something to copy: a selected recipe / schematic
    // (Miner, Assembler, Shipyard) or any Splitter (empty filters = accept-all).
    // Building types with no settings (Smelter, Reprocessing Plant, Salvage Bay,
    // belts / tunnels, HQ) leave any existing cache untouched (REQ-BLD-COPY-CONFIG).
    if (!config->recipeId.has_value() && !config->isSplitter) { return; }

    m_copiedConfig = config;
    m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });
}

void GameWorldView::pasteConfigTo(BuildingId id)
{
    if (!m_copiedConfig.has_value()) { return; }

    const std::optional<BuildingConfig> target = readBuildingConfig(*m_sim, id);
    if (!target.has_value() || target->type != m_copiedConfig->type) { return; }

    // The paste applies below; flash the target to confirm (REQ-BLD-COPY-CONFIG-FEEDBACK).
    m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });

    const BuildingConfig& source = *m_copiedConfig;

    // The cached settings were valid on a same-type source building, so they are
    // valid and available on the target: unlock state is global, so a selected
    // recipe / schematic (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC) and splitter
    // filter item types (REQ-LOCK-UI-SPLITTER) remain unlocked. Applying reuses the
    // same configuration commands as the selected building panel, inheriting their
    // buffer-clearing and mid-cycle-cancel semantics (REQ-MAT-INPUT-BUFFER,
    // REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
    if (source.isSplitter)
    {
        // Operational splitters are configured by tile; sites by BuildingId
        // (mirrors SelectedBuildingPanel::onSplitterFilterChanged).
        if (const Building* building = m_sim->getBuildings().findBuilding(id))
        {
            std::shared_ptr<SetSplitterFiltersCommand> command =
                std::make_shared<SetSplitterFiltersCommand>();
            command->tile    = building->anchor;
            command->filterA = source.splitterFilterA;
            command->filterB = source.splitterFilterB;
            enqueueCommand(command);
        }
        else
        {
            std::shared_ptr<SetSiteSplitterFiltersCommand> command =
                std::make_shared<SetSiteSplitterFiltersCommand>();
            command->id      = id;
            command->filterA = source.splitterFilterA;
            command->filterB = source.splitterFilterB;
            enqueueCommand(command);
        }
        return;
    }

    if (source.recipeId.has_value())
    {
        std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
        command->id       = id;
        command->recipeId = *source.recipeId;
        enqueueCommand(command);
    }

    // For a shipyard the schematic (above) must be applied before its module
    // layout, so both commands drain in order at the next tick boundary.
    if (source.type == BuildingType::Shipyard && source.shipLayout.has_value())
    {
        std::shared_ptr<SetShipLayoutCommand> command =
            std::make_shared<SetShipLayoutCommand>();
        command->id     = id;
        command->layout = *source.shipLayout;
        enqueueCommand(command);
    }
}

void GameWorldView::enterBuilderMode(BuildingType type)
{
    m_builderType     = type;
    m_ghostRotation   = Rotation::East;
    m_ghostValid      = false;
    m_tunnelGhostType = BuildingType::TunnelEntry;
    m_tunnelPartnerTile.reset();
    m_deconstructMode  = false;
    m_blueprintMode.reset();
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<DeconstructModeChangedEvent>(false));
}

void GameWorldView::enterBlueprintMode(Blueprint blueprint)
{
    if (m_builderType.has_value()) { exitBuilderMode(); }
    m_deconstructMode = false;
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<DeconstructModeChangedEvent>(false));
    m_blueprintGhostTile = m_ghostTile;
    m_blueprintMode      = std::move(blueprint);
}

void GameWorldView::exitBlueprintMode()
{
    m_blueprintMode.reset();
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<BlueprintModeExitedEvent>());
}

void GameWorldView::exitBuilderMode()
{
    m_builderType.reset();
    m_beltDragPath.clear();
    m_dragging = false;
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<BuilderModeExitedEvent>());
}

double GameWorldView::getGameSpeed() const
{
    return m_gameSpeedMultiplier;
}

bool GameWorldView::isDebugDrawEnabled() const
{
    return m_debugDraw;
}

void GameWorldView::resetFrameTimer()
{
    m_frameTimer.restart();
}

void GameWorldView::setGameSpeed(double multiplier)
{
    m_gameSpeedMultiplier = multiplier;
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<GameSpeedChangedEvent>(m_gameSpeedMultiplier));
}

void GameWorldView::resetForNewGame()
{
    exitBuilderMode();
    exitBlueprintMode();
    m_activeBeams.clear();
    m_schematicChoiceShown = false;
    m_ghostRotation    = Rotation::East;
    m_ghostValid       = false;
    m_deconstructMode     = false;
    m_deconstructHoverBuildingId  = std::nullopt;
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<DeconstructModeChangedEvent>(false));
    m_selectedBuildingIds.clear();
    clearEntitySelection();
    clearDebrisSelection();
    m_copiedConfig     = std::nullopt;
    m_copyConfigFlashes.clear();
    m_boxSelecting     = false;
    m_scrollXTiles     = 0.0f;
    m_scrollLeft       = false;
    m_scrollRight      = false;
    m_gameOverShown     = false;
    m_winShown          = false;
    m_prevNonZeroSpeed  = 1.0;
    m_lastTick          = Tick(-1);
    m_lastBlocks        = -1;
    m_lastExpansionCost = -1;
    m_lastBossCounter   = -1;
    m_lastBossCountdown = Tick(-1);
    m_lastArtifactCount = -1;
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<SelectionChangedEvent>(std::vector<BuildingId>{}));
	setGameSpeed(1.0);
    // Rebase the wall-clock time source so a fresh run starts from a clean time
    // base. Without this, wall time accumulated while a modal (Game Over, Win, or
    // the escape menu) was open would be converted into ticks on the new run,
    // fast-forwarding it by the time the player spent in the dialog.
    m_frameTimer.restart();
    m_tickDriver.reset();
    update();
}

// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------

void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
{
    // Endpoint offset is a fraction of the target's visual size (REQ-SHP-FIRING-BEAM):
    // half a ship's rendered radius, half a station's shorter footprint side, or
    // half a piece of debris's rendered radius (debris is drawn at getTilePx()*0.2).
    float maxRadius = 0.125f;
    if (m_sim->getAdmin().isValid(event->target)
        && m_sim->getAdmin().hasAll<StationBodyComponent>(event->target))
    {
        const StationBodyComponent& sb = m_sim->getAdmin().get<StationBodyComponent>(event->target);
        const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
        maxRadius = shorter / 2.0f;
    }
    else if (m_sim->getAdmin().isValid(event->target)
        && m_sim->getAdmin().hasAll<DebrisComponent>(event->target))
    {
        maxRadius = 0.1f;
    }

    std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
    std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
    const float angle  = angleDist(m_rng);
    const float radius = radiusDist(m_rng);

    ActiveBeam beam;
    beam.event         = *event;
    beam.targetOffset  = QVector2D(radius * std::cos(angle),
                                   radius * std::sin(angle));
    m_activeBeams.push_back(beam);
}

void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event)
{
    enterBuilderMode(event->type);
}

void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
{
    exitBuilderMode();
}

void GameWorldView::handleEvent(std::shared_ptr<const DeconstructModeToggleRequestedEvent> /*event*/)
{
    toggleDeconstructMode();
}

void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
{
    enterBlueprintMode(event->blueprint);
}

void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)
{
    exitBlueprintMode();
}

void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event)
{
    setGameSpeed(event->multiplier);
}

void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
{
    // Other widgets (MainWindow, SelectedBuildingPanel) request commands via this
    // event; GameWorldView owns the CommandManager and enqueues them.
    if (event->command && event->command->kind == CommandKind::Reset)
    {
        m_viewResetPending = true;
    }
    enqueueCommand(event->command);
}

void GameWorldView::enqueueCommand(std::shared_ptr<const Command> command)
{
    m_commandManager.enqueue(std::move(command));
}

void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
    std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
    command->type     = type;
    command->anchor   = anchor;
    command->rotation = rotation;
    enqueueCommand(command);
}

bool GameWorldView::canAfford(BuildingType type) const
{
    const BuildingDef* def = m_config->buildings.findBuildingDef(type);
    if (!def) { return false; }
    return m_sim->getBuildingBlocksStock() >= def->cost;
}
