#include "GameWorldView.h"
#include "PlacementRules.h"
#include "FactoryQueries.h"
#include "BlueprintLibrary.h"

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

#include <QColor>
#include <QCoreApplication>
#include <QCursor>
#include <QDir>
#include <QFile>
#include <QFocusEvent>
#include <QFont>
#include <QKeyEvent>
#include <QLinearGradient>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPixmap>
#include <QRadialGradient>
#include <QFontMetrics>
#include <QStringList>
#include <QTimer>

#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 "EventManager.h"
#include "FactionComponent.h"
#include "GameOverEvent.h"
#include "HealthComponent.h"
#include "ItemIconCache.h"
#include "PositionComponent.h"
#include "DebrisSystem.h"
#include "SelectionAnchorChangedEvent.h"
#include "SelectionBounds.h"
#include "SelectionChangedEvent.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 "GameResetEvent.h"
#include "BossWaveUpdatedEvent.h"
#include "BuilderModeExitedEvent.h"
#include "BlueprintModeExitedEvent.h"
#include "BuildingBlocksChangedEvent.h"
#include "UnlockedBuildingsChangedEvent.h"
#include "ExpansionCostChangedEvent.h"
#include "ViewScrolledEvent.h"
#include "GameSpeedChangedEvent.h"
#include "SchematicChoicesAvailableEvent.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h"

namespace
{

// The action a left-button gesture triggers, with the Ctrl variant falling back to the
// plain one wherever nothing is bound to it. That fallback is what keeps Ctrl+click
// placing a building in builder mode and Ctrl+drag deconstructing an area: the modifier
// only means something where an action claims it (REQ-UI-CONTROLS-CONTENT).
ControlAction resolveLeftGesture(bool controlHeld, bool isDrag,
                                 const ControlContext& context)
{
    if (controlHeld)
    {
        const ControlAction action = resolveMouseAction(
            isDrag ? MouseBinding::CtrlLeftDrag : MouseBinding::CtrlLeftClick, context);
        if (action != ControlAction::None) { return action; }
    }
    return resolveMouseAction(isDrag ? MouseBinding::LeftDrag : MouseBinding::LeftClick,
                              context);
}

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

}  // 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_commandManager(*sim)
    , m_gameSpeedMultiplier(1.0)
    , m_prevNonZeroSpeed(1.0)
    , m_camera(config->world.scroll, config->world.regions)
    , m_debugDraw(false)
    , m_rng(std::random_device{}())
    , m_boxSelecting(false)
    , m_boxDragMoved(false)
    , m_gameOverShown(false)
    , m_schematicChoiceShown(false)
{
    setFocusPolicy(Qt::StrongFocus);
    setMouseTracking(true);

    m_renderer = std::make_unique<WorldRenderer>(*sim, *visuals, itemIcons, 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();

    // Apply held scroll
    {
        const bool viewMoved =
            m_camera.advance(m_panDirection, elapsed, getScrollBounds());

        // Two things no mouse move reports: the world position under a stationary
        // cursor changing as the view scrolls, and the cursor crossing onto a panel
        // or out of the window, which ends the hover (REQ-BLD-GHOST).
        if (viewMoved || isHoverLive() != m_hoverLive)
        {
            refreshHover();
        }

        // The world-to-widget transform has changed, which no other event reports. What
        // stands in the world rather than on the screen re-places itself on this
        // (REQ-UI-EXPAND-BUTTON).
        if (viewMoved)
        {
            EventManager::getInstance()->sendEventImmediately(
                std::make_shared<ViewScrolledEvent>());
        }
    }

    // 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 bar 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);

    m_renderer->render(painter, getCoordinates(), makeRenderFrame());

    // Screen-anchored chrome over the finished world.
    drawScreenSpace(painter);
    if (m_debugDraw) { drawDebugOverlay(painter); }
    drawPauseBorder(painter);
    drawDeconstructBorder(painter);
    drawReplayOverlay(painter);
}

WorldRenderFrame GameWorldView::makeRenderFrame() const
{
    // A box only reaches the renderer once the gesture reads as a drag: below the
    // threshold there is nothing to draw and nothing the box marks that hovering does
    // not mark already (REQ-UI-MULTI-SELECT).
    std::optional<QRectF> boxWorldRect;
    if (m_boxSelecting && m_boxDragMoved) { boxWorldRect = getBoxWorldRect(); }

    return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams, boxWorldRect,
                            m_debugDraw};
}

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

WorldCoordinates GameWorldView::getCoordinates() const
{
    return WorldCoordinates::scrolling(size(), m_config->world.heightTiles,
                                       m_camera.getViewCenterXTiles());
}

float GameWorldView::getAsteroidLeftEdge() const
{
    float leftX = -static_cast<float>(m_sim->getCurrentAsteroidWidth_tiles());
    for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
    {
        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;
}

ScrollBounds GameWorldView::getScrollBounds() const
{
    // The camera clamps its view center to these, 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).
    return ScrollBounds{getAsteroidLeftEdge(), getEnemyStationRightEdge()};
}

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

bool GameWorldView::canPlaceBuildingHere(BuildingType type, QPoint anchor,
                                         Rotation rot) const
{
    return canPlaceBuilding(m_sim->getFactoryState(), m_sim->getConfig(),
                            type, anchor, rot);
}

std::optional<BuildingId> GameWorldView::buildingAtTile(QPoint tile) const
{
    for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
    {
        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 : getAllSites(m_sim->getFactoryState()))
	{
		for (const QPoint& cell : s.bodyCells)
		{
			if (cell == tile)
			{
				return s.id;
			}
		}
	}
	return std::nullopt;
}

void GameWorldView::pruneDespawnedDebris()
{
    const std::vector<entt::entity>& selected = m_selection.getSelectedDebris();
    if (selected.empty()) { return; }

    // Keeps the debris that still exist, in the order the simulation reports them.
    std::vector<entt::entity> live;
    for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin()))
    {
        if (std::find(selected.begin(), selected.end(), info.entity) != selected.end())
        {
            live.push_back(info.entity);
        }
    }

    if (live.size() != selected.size())
    {
        m_selection.setSelectedDebris(std::move(live));
    }
}

void GameWorldView::pruneDespawnedActors()
{
    const std::vector<entt::entity>& selected = m_selection.getSelectedActors();
    if (selected.empty()) { return; }

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

    m_selection.setSelectedActors(std::move(live));
}

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_buildMode.getBlueprint();

    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 (resolveBlueprintGhostHere(bb, center).action == BlueprintGhostAction::Invalid)
        {
            return;
        }
    }

    // Only genuinely new buildings are charged for: a compatible overlap places nothing
    // (REQ-UI-BLUEPRINT-OVERLAP) and a transfer changes settings only
    // (REQ-UI-BLUEPRINT-TRANSFER). Locked building types are excluded from the total
    // as well (REQ-LOCK-UI-BLUEPRINT).
    int totalCost = 0;
    for (const BlueprintBuilding& bb : bp.buildings)
    {
        if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
        if (resolveBlueprintGhostHere(bb, center).action != BlueprintGhostAction::PlaceNew)
        {
            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 BlueprintGhostResolved resolved = resolveBlueprintGhostHere(bb, center);

        // The building the blueprint wants is already there, facing the same way: leave
        // it exactly as it is (REQ-UI-BLUEPRINT-OVERLAP).
        if (resolved.action == BlueprintGhostAction::CompatibleOverlap) { continue; }

        if (resolved.action == BlueprintGhostAction::Transfer)
        {
            transferConfigTo(*resolved.targetId, bb);
            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;

        command->recipeId   = unlockedRecipeId(bb);
        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);
    }
}

BlueprintGhostResolved GameWorldView::resolveBlueprintGhostHere(const BlueprintBuilding& building,
                                                               QPoint center) const
{
    // A single-building blueprint hit-tests the cursor for its transfer target; a
    // constellation does not (REQ-UI-BLUEPRINT-TRANSFER). `center` is the cursor tile.
    // The size is read from the blueprint as stored, before locked types are dropped, so
    // the gesture behaves the same however much the player has unlocked.
    const std::optional<QPoint> hoverTile =
        m_buildMode.getBlueprint().buildings.size() == 1 ? std::make_optional(center)
                                                         : std::nullopt;
    return resolveBlueprintGhost(m_sim->getFactoryState(), m_sim->getConfig(),
                                 building.type, center + building.offset, building.rotation,
                                 hoverTile);
}

std::string GameWorldView::unlockedRecipeId(const BlueprintBuilding& building) const
{
    // A stored recipe or schematic is applied only while it is unlocked; a locked one
    // yields no id at all rather than a stale one (REQ-LOCK-UI-BLUEPRINT). Shared by
    // placement and configuration transfer so the two cannot gate differently.
    if (building.recipeId.empty()) { return std::string(); }

    if (building.type == BuildingType::Shipyard)
    {
        return m_sim->isSchematicUnlocked(building.recipeId) ? building.recipeId
                                                            : std::string();
    }

    return m_sim->isRecipeUnlocked(building.recipeId) ? building.recipeId : std::string();
}

void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& source)
{
    // Hands the blueprint's settings to a building that is already there, changing
    // nothing else -- no construction site, no cost, no rotation
    // (REQ-UI-BLUEPRINT-TRANSFER). Every field is sent unconditionally, so a field the
    // blueprint has nothing stored for clears the target's rather than leaving it: the
    // target ends up identical to the source. Setting a value the building already holds
    // is a no-op in the simulation (REQ-MAT-INPUT-BUFFER), which is what keeps clicking
    // an already-matching building free of buffer and production-progress loss.
    if (source.type == BuildingType::Splitter)
    {
        // Operational splitters are configured by tile, sites by BuildingId (mirrors
        // SelectionPanel::onSplitterFilterChanged). Locked item types are dropped
        // per REQ-LOCK-UI-BLUEPRINT.
        const std::vector<ItemType> filterA = filterUnlockedItems(source.splitterFilterA, *m_sim);
        const std::vector<ItemType> filterB = filterUnlockedItems(source.splitterFilterB, *m_sim);
        if (const Building* building = findBuilding(m_sim->getFactoryState(), id))
        {
            std::shared_ptr<SetSplitterFiltersCommand> command =
                std::make_shared<SetSplitterFiltersCommand>();
            command->tile    = building->anchor;
            command->filterA = filterA;
            command->filterB = filterB;
            enqueueCommand(command);
        }
        else
        {
            std::shared_ptr<SetSiteSplitterFiltersCommand> command =
                std::make_shared<SetSiteSplitterFiltersCommand>();
            command->id      = id;
            command->filterA = filterA;
            command->filterB = filterB;
            enqueueCommand(command);
        }
        return;
    }

    std::shared_ptr<SetRecipeCommand> recipeCommand = std::make_shared<SetRecipeCommand>();
    recipeCommand->id       = id;
    recipeCommand->recipeId = unlockedRecipeId(source);
    enqueueCommand(recipeCommand);

    // After the schematic, never before: a genuine schematic change resets the layout,
    // and both commands drain in order at the next tick boundary.
    if (source.type == BuildingType::Shipyard)
    {
        std::shared_ptr<SetShipLayoutCommand> layoutCommand =
            std::make_shared<SetShipLayoutCommand>();
        layoutCommand->id     = id;
        layoutCommand->layout = source.shipLayout.value_or(ShipLayoutConfig{});
        enqueueCommand(layoutCommand);
    }
}

void GameWorldView::updateTunnelGhost()
{
    // The connection preview and entry/exit switch only apply at a valid placement
    // (REQ-BLD-TUNNEL-MODE); at an invalid position, and where the cursor points at
    // no tile at all, the ghost stays a plain entry.
    const std::optional<QPoint>& ghostTile = m_buildMode.getGhostTile();
    if (!ghostTile.has_value() || !m_buildMode.isGhostValid())
    {
        m_buildMode.setTunnelGhost(BuildingType::TunnelEntry, std::nullopt);
        return;
    }

    const TunnelTileMap tunnels = collectTunnelTiles(m_sim->getFactoryState());
    const TunnelLookup  lookup  = makeTunnelLookup(tunnels);

    const TunnelCompletion completion =
        resolveTunnelCompletion(lookup, *ghostTile,
                                m_buildMode.getGhostRotation(),
                                m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos);
    m_buildMode.setTunnelGhost(completion.resolvedType, completion.partnerTile);
}

void GameWorldView::placeAtTile(QPoint tile)
{
    if (!m_buildMode.isBuilderMode())
	{
		return;
	}
    const BuildingType type     = m_buildMode.getEffectiveBuilderType();
    const Rotation     rotation = m_buildMode.getGhostRotation();

    if (!canPlaceBuildingHere(type, tile, rotation))
	{
		return;
	}

    const std::optional<BuildingId> rotateTarget =
        findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, tile, rotation);
    if (rotateTarget.has_value())
    {
        std::shared_ptr<RotateInPlaceCommand> command =
            std::make_shared<RotateInPlaceCommand>();
        command->id          = *rotateTarget;
        command->newRotation = rotation;
        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 (!isTileOccupied(m_sim->getFactoryState(), tile) && canAfford(type))
        {
            enqueuePlaceBuilding(type, tile, rotation);
        }
    }
    else
    {
        enqueuePlaceBuilding(type, tile, rotation);
    }
}

// ---------------------------------------------------------------------------
// 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 = findBuilding(m_sim->getFactoryState(), *targetId))
        {
            targetType = building->type;
        }
    }
    else if (std::optional<BuildingId> siteId = siteAtTile(cursorTile); siteId.has_value())
    {
        targetId = siteId;
        if (const ConstructionSite* site = findSite(m_sim->getFactoryState(), *siteId))
        {
            targetType = site->type;
        }
    }

    if (targetId.has_value() && targetType.has_value()
        && *targetType != BuildingType::Belt)
    {
        const std::vector<Port> inputPorts = getInputPorts(m_sim->getFactoryState(), m_sim->getConfig(), *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;
        }
    }

    std::vector<BeltPathTile> path = computeBeltDragPath(
        m_buildMode.getBeltDragAnchor(), endTile, m_buildMode.getGhostRotation());
    if (forcedEndRotation.has_value() && !path.empty())
    {
        // The end tile points into the target, overriding its incoming-step
        // orientation (REQ-BLD-BELT-DRAG "Snapping to a building").
        path.back().rotation = *forcedEndRotation;
    }
    m_buildMode.setBeltDragPath(std::move(path));
}

std::vector<BeltDragResolved> GameWorldView::resolveBeltDragPath() const
{
    return ::resolveBeltDragPath(m_buildMode.getBeltDragPath(),
                                 m_sim->getFactoryState(), m_sim->getConfig(),
                                 m_sim->getBuildingBlocksStock());
}

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_buildMode.getBeltDragPath()[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::drawDebugOverlay(QPainter& painter)
{
    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::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_buildMode.isDeconstructMode()) { 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;
    }

    // Keys are turned into actions and published by the input mapper
    // (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so
    // nothing is handled here directly.
    if (m_inputMapper.handleKeyPress(event, getControlContext())) { return; }

    QOpenGLWidget::keyPressEvent(event);
}

void GameWorldView::keyReleaseEvent(QKeyEvent* event)
{
    if (event->isAutoRepeat())
    {
        QOpenGLWidget::keyReleaseEvent(event);
        return;
    }
    if (m_inputMapper.handleKeyRelease(event, getControlContext())) { return; }
    QOpenGLWidget::keyReleaseEvent(event);
}

void GameWorldView::focusOutEvent(QFocusEvent* event)
{
    // Without this, holding A while a modal opens (the escape menu, a schematic
    // choice, game over) leaves the pan key held: the key-up goes to the dialog,
    // never here, and the view pans on its own once the dialog closes. Panning runs
    // on wall-clock time, so pausing does not mask it either.
    m_inputMapper.releaseAll();
    QOpenGLWidget::focusOutEvent(event);
}

void GameWorldView::setBlueprintLibrary(const BlueprintLibrary* library)
{
    m_blueprintLibrary = library;
}

ControlContext GameWorldView::getControlContext() const
{
    ControlContext context;
    context.mode                   = m_buildMode.getMode();
    context.draggingBelt           = m_buildMode.isDraggingBelt();
    context.hoveredGhostIsTransfer = m_buildMode.isHoveredGhostTransfer();
    // The type a click would actually place, so the panel agrees with the ghost when
    // tunnel mode resolves to an exit (REQ-BLD-TUNNEL-MODE).
    if (m_buildMode.isBuilderMode())
    {
        context.builderType = m_buildMode.getEffectiveBuilderType();
    }

    // Buildings win over field objects, so the two are never both non-empty
    // (REQ-UI-SELECTION-CATEGORIES).
    const std::vector<BuildingId>&   buildings = m_selection.getSelectedBuildings();
    const std::vector<entt::entity>& actors    = m_selection.getSelectedActors();
    const std::vector<entt::entity>& debris    = m_selection.getSelectedDebris();
    if (!buildings.empty())
    {
        context.selection      = ControlSelection::Buildings;
        context.selectionCount = static_cast<int>(buildings.size());
    }
    else if (!actors.empty() || !debris.empty())
    {
        context.selection      = ControlSelection::FieldObjects;
        context.selectionCount = static_cast<int>(actors.size() + debris.size());
    }

    if (m_blueprintLibrary)
    {
        context.placeableBuildingSelected = m_blueprintLibrary->getCanCaptureSelection();
        context.temporaryBlueprintExists  = m_blueprintLibrary->getHasTemporaryBlueprint();
    }
    return context;
}

QString GameWorldView::getActiveBlueprintName() const
{
    if (!m_buildMode.isBlueprintMode()) { return QString(); }
    return m_buildMode.getBlueprint().name;
}

void GameWorldView::mousePressEvent(QMouseEvent* event)
{
    const WorldCoordinates coordinates = getCoordinates();
    const ControlContext   context     = getControlContext();

    if (event->button() != Qt::LeftButton)
    {
        if (event->button() == Qt::RightButton)
        {
            switch (resolveMouseAction(MouseBinding::RightClick, context))
            {
            case ControlAction::CancelBeltLine:
                // Drop the in-progress path without placing anything; belt builder
                // mode stays active (REQ-BLD-BELT-DRAG).
                m_buildMode.cancelBeltDrag();
                break;
            case ControlAction::ExitMode:
                m_buildMode.exitCurrentMode();
                break;
            default:
                break;
            }
        }
        return;
    }

    const QPoint tile = coordinates.widgetToTile(event->pos());
    const bool   controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;

    // A press begins the click gesture; whether it turns out to be a drag is settled on
    // release, where the drag binding is resolved instead.
    switch (resolveLeftGesture(controlHeld, /*isDrag*/ false, context))
    {
    case ControlAction::Place:
    case ControlAction::ApplySettings:
        if (m_buildMode.isBlueprintMode())
        {
            placeBlueprintAtTile(tile);
        }
        else if (m_buildMode.getBuilderType() == BuildingType::Belt)
        {
            // Belts place by dragging, so the press only anchors the path and shows its
            // ghost; nothing is placed until release, and a plain click is the one-tile
            // case (REQ-BLD-BELT-DRAG).
            m_buildMode.beginBeltDrag(tile);
            m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
            recomputeBeltDragPath(tile);
        }
        else
        {
            placeAtTile(tile);
        }
        break;

    case ControlAction::ToggleDeconstruct:
        // 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_boxStartWorld   = coordinates.widgetToWorld(event->pos());
        m_boxCurrentWorld = m_boxStartWorld;
        m_boxDragMoved    = false;
        break;

    case ControlAction::Select:
    case ControlAction::AddToSelection:
        // Only a click that hit nothing starts a box drag. Starting one on a hit
        // would re-resolve the same object as a 1x1 box on release and undo the
        // click: a Ctrl+click would toggle the building off, then straight back on.
        if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), controlHeld))
        {
            // selectAtPoint has already cleared the selection unless Ctrl is
            // preserving it for an additive drag.
            m_boxSelecting    = true;
            m_boxStartWorld   = coordinates.widgetToWorld(event->pos());
            m_boxCurrentWorld = m_boxStartWorld;
            m_boxDragMoved    = false;
        }
        break;

    default:
        break;
    }
}

bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive)
{
    // Point hit-test precedence: buildings win over actors, which win over debris
    // (REQ-UI-SELECTION-CATEGORIES). What each hit then does to the existing
    // selection is the controller's business, not this method's.
    const SelectionMode mode = additive ? SelectionMode::Toggle : SelectionMode::Replace;

    std::optional<BuildingId> buildingHit = buildingAtTile(tile);
    if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }
    if (buildingHit.has_value())
    {
        publishSelectionAnchor(mode, {*buildingHit}, {}, {});
        m_selection.selectBuildings({*buildingHit}, mode);
        return true;
    }

    const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
    if (actorHit != entt::null)
    {
        publishSelectionAnchor(mode, {}, {actorHit}, {});
        m_selection.selectFieldObjects({actorHit}, {}, mode);
        return true;
    }

    const entt::entity debrisHit = debrisAtWorldPos(m_sim->getAdmin(), worldPos);
    if (debrisHit != entt::null)
    {
        publishSelectionAnchor(mode, {}, {}, {debrisHit});
        m_selection.selectFieldObjects({}, {debrisHit}, mode);
        return true;
    }

    // Empty space: a plain click clears the whole selection, Ctrl preserves it.
    if (!additive) { m_selection.clearAll(); }
    return false;
}

void GameWorldView::selectInBox(bool additive)
{
    // Same precedence as a point click, over everything the box covers
    // (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT). The only difference is the
    // mode: a Ctrl box adds and never deselects, where a Ctrl click toggles.
    const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace;

    const QRectF worldBox = getBoxWorldRect();

    const std::vector<BuildingId> boxIds =
        buildingsInBox(m_sim->getFactoryState(), worldBox);
    if (!boxIds.empty())
    {
        publishSelectionAnchor(mode, boxIds, {}, {});
        m_selection.selectBuildings(boxIds, mode);
        return;
    }

    const std::vector<entt::entity> boxActors = actorsInBox(m_sim->getAdmin(), worldBox);
    const std::vector<entt::entity> boxDebris = debrisInBox(m_sim->getAdmin(), worldBox);
    if (!boxActors.empty() || !boxDebris.empty())
    {
        publishSelectionAnchor(mode, {}, boxActors, boxDebris);
        m_selection.selectFieldObjects(boxActors, boxDebris, mode);
        return;
    }

    // Empty box: a plain (non-additive) drag clears the current selection.
    if (!additive) { m_selection.clearAll(); }
}

void GameWorldView::clearSelectionForBuildMode()
{
    m_selection.clearAll();
}

bool GameWorldView::isHoverLive() const
{
    // underMouse() is false while the cursor sits on one of the floating panels,
    // which are siblings of this widget rather than children, and while it is
    // outside the window. A drag holding the button is the exception: it tracks the
    // cursor wherever it goes until the button comes back up (REQ-UI-MULTI-SELECT,
    // REQ-BLD-BELT-DRAG).
    return underMouse() || m_boxSelecting || m_buildMode.isDraggingBelt();
}

void GameWorldView::refreshHover()
{
    if (isHoverLive())
    {
        updateHoverAt(mapFromGlobal(QCursor::pos()));
    }
    else
    {
        m_buildMode.clearHover();
        m_hoverLive = false;
    }
}

void GameWorldView::updateHoverAt(QPoint cursorWidgetPos)
{
    // Reached either from a mouse move, which only this widget receives, or from a
    // hover refresh that has already established the cursor is on the world.
    m_hoverLive = true;

    const WorldCoordinates coordinates = getCoordinates();
    const QPoint tile = coordinates.widgetToTile(cursorWidgetPos);
    m_cursorWorldPos  = coordinates.widgetToWorld(cursorWidgetPos);

    if (m_buildMode.isBuilderMode())
    {
        m_buildMode.setGhostTile(tile);
        m_buildMode.setGhostValidity(
            canPlaceBuildingHere(m_buildMode.getBuilderType(), tile,
                             m_buildMode.getGhostRotation()));

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

        if (m_buildMode.isDraggingBelt())
        {
            // Belt drag: update the previewed path; placement happens on release
            // (REQ-BLD-BELT-DRAG).
            recomputeBeltDragPath(tile);
        }
    }
    else if (m_buildMode.isBlueprintMode())
    {
        m_buildMode.setBlueprintGhostTile(tile);

        // Resolved here, once, through the same classifier the click and the ghost's
        // colour use, and stored on the mode: the controls panel says "Apply settings"
        // exactly when clicking would transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a
        // single-building blueprint hit-tests the cursor, so only it can be a hovered
        // transfer target.
        const std::vector<BlueprintBuilding>& buildings =
            m_buildMode.getBlueprint().buildings;
        bool transfer = false;
        if (buildings.size() == 1)
        {
            transfer = resolveBlueprintGhostHere(buildings.front(), tile).action
                       == BlueprintGhostAction::Transfer;
        }
        m_buildMode.setHoveredGhostTransfer(transfer);
    }
    else if (m_buildMode.isDeconstructMode())
    {
        m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile));
        if (m_boxSelecting) { updateBoxDrag(cursorWidgetPos); }
    }
    else if (m_boxSelecting)
    {
        updateBoxDrag(cursorWidgetPos);
    }
}

void GameWorldView::updateBoxDrag(QPoint cursorWidgetPos)
{
    const WorldCoordinates coordinates = getCoordinates();
    m_boxCurrentWorld = coordinates.widgetToWorld(cursorWidgetPos);

    // Measured against where the anchor sits on screen right now, not against where
    // the button went down: a view that scrolls under a held button moves the anchor
    // away from a motionless cursor, and that is a drag as much as moving the mouse
    // is (REQ-UI-MULTI-SELECT).
    const QPointF anchorWidgetPos = coordinates.worldToWidget(m_boxStartWorld);
    const qreal   travel_px = std::abs(cursorWidgetPos.x() - anchorWidgetPos.x())
                            + std::abs(cursorWidgetPos.y() - anchorWidgetPos.y());
    if (travel_px >= kBoxDragThresholdPixels) { m_boxDragMoved = true; }
}

QRectF GameWorldView::getBoxWorldRect() const
{
    if (!m_boxDragMoved)
    {
        // Still a click: the rectangle it spans has no area and would cover nothing,
        // so the box is the whole tile the button went down on instead — what the
        // click points at (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-CLICK).
        return QRectF(std::floor(m_boxStartWorld.x()), std::floor(m_boxStartWorld.y()),
                      1.0, 1.0);
    }
    return QRectF(QPointF(m_boxStartWorld.x(), m_boxStartWorld.y()),
                  QPointF(m_boxCurrentWorld.x(), m_boxCurrentWorld.y())).normalized();
}

void GameWorldView::publishSelectionAnchor(SelectionMode mode,
                                           const std::vector<BuildingId>& buildings,
                                           const std::vector<entt::entity>& actors,
                                           const std::vector<entt::entity>& debris)
{
    // An additive gesture onto something already selected is growing that selection, not
    // starting one, and the panel stays where it was put (REQ-UI-SELECTION-PANEL).
    const bool startsSelection =
        (mode == SelectionMode::Replace) || (m_selection.getSelectedBuildings().empty()
                                             && m_selection.getSelectedActors().empty()
                                             && m_selection.getSelectedDebris().empty());
    if (!startsSelection)
    {
        return;
    }

    // Screen space, frozen here: the panel is placed against where the selection is at
    // this moment and stays there, however far the view scrolls or the objects move
    // afterwards.
    const QRect anchorRect = getSelectionWidgetRect(*m_sim, getCoordinates(),
                                                    buildings, actors, debris);
    if (anchorRect.isNull())
    {
        return;
    }
    // The gap the panel keeps from that rectangle is half a tile (REQ-UI-SELECTION-PANEL),
    // and this is where the tile size is known. It is sampled in the same moment as the
    // rectangle and travels with it, so both describe the view as it stood when the
    // selection started.
    const int selectionGapPx = qRound(getCoordinates().getTilePx() / 2.0f);
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<SelectionAnchorChangedEvent>(anchorRect, selectionGapPx));
}

void GameWorldView::mouseMoveEvent(QMouseEvent* event)
{
    updateHoverAt(event->pos());
}

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

    if (m_buildMode.isDraggingBelt())
    {
        // Apply the previewed belt path now that the button is released
        // (REQ-BLD-BELT-DRAG).
        applyBeltDragPath();
        m_buildMode.cancelBeltDrag();
    }

    if (m_boxSelecting)
    {
        m_boxSelecting = false;

        const std::vector<BuildingId> boxIds =
            buildingsInBox(m_sim->getFactoryState(), getBoxWorldRect());

        const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
        const ControlAction dragAction =
            resolveLeftGesture(controlHeld, /*isDrag*/ true, getControlContext());

        if (dragAction == ControlAction::DeconstructArea)
        {
            const FactoryState& factory = m_sim->getFactoryState();

            // 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 = findBuilding(factory, id))
                {
                    if (b->type == BuildingType::Hq) { continue; }
                    operational.push_back(id);
                }
                else if (findSite(factory, 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 (!isQueuedForDeconstruction(factory, 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 (!isQueuedForDeconstruction(factory, id))
                {
                    std::shared_ptr<DeconstructCommand> command =
                        std::make_shared<DeconstructCommand>();
                    command->id = id;
                    enqueueCommand(command);
                }
            }

            m_buildMode.setDeconstructHoverBuildingId(std::nullopt);
            return;
        }

        // A Ctrl box adds and never deselects, where a plain one replaces
        // (REQ-UI-MULTI-SELECT).
        selectInBox(dragAction == ControlAction::AddAreaToSelection);
    }
}

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

void GameWorldView::rotateGhost(bool clockwise)
{
    if (m_buildMode.isBuilderMode())
    {
        m_buildMode.rotateGhost(clockwise);

        // The new facing is kept whatever the cursor is over; what it means for the
        // world is only re-resolved while the cursor points at a tile (REQ-BLD-GHOST).
        const std::optional<QPoint>& ghostTile = m_buildMode.getGhostTile();
        if (!ghostTile.has_value()) { return; }

        m_buildMode.setGhostValidity(
            canPlaceBuildingHere(m_buildMode.getBuilderType(), *ghostTile,
                             m_buildMode.getGhostRotation()));
        // A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE).
        if (m_buildMode.isTunnelMode()) { 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_buildMode.isDraggingBelt())
        {
            recomputeBeltDragPath(*ghostTile);
        }
    }
    else if (m_buildMode.isBlueprintMode())
    {
        for (BlueprintBuilding& bb : m_buildMode.getMutableBlueprint().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);
        }
    }
}

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()
{
    // Leaves whichever mode was active, announcing that one exit rather than all
    // three; a mode that was not active has nothing to announce.
    m_buildMode.exitCurrentMode();
    m_activeBeams.clear();
    m_schematicChoiceShown = false;
    m_selection.clearAll();
    m_boxSelecting     = false;
    m_camera.reset();
    // Drops any key still held across the restart, which also republishes the pan
    // direction so m_panDirection follows.
    m_inputMapper.releaseAll();
    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>{}));
    // The one place a restart actually lands (the menu, game over and win dialogs only
    // enqueue the command), so it is where presentation state belonging to the finished
    // run is dropped -- e.g. the temporary blueprint (REQ-UI-BLUEPRINT-TEMP).
    EventManager::getInstance()->sendEventImmediately(std::make_shared<GameResetEvent>());
	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)
{
    clearSelectionForBuildMode();
    m_buildMode.enterBuilderMode(event->type);
    // A mode entered by hotkey usually leaves the cursor exactly where it was, and no
    // mouse move follows to place the ghost; entered from a build button it leaves the
    // cursor on the bar, where there is nothing to hover (REQ-BLD-GHOST).
    refreshHover();
}

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

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

void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
{
    // The blueprint arrives already built from the selection this drops
    // (REQ-UI-BLUEPRINT-TEMP, REQ-UI-SELECTION-EXCLUSIVE).
    clearSelectionForBuildMode();
    m_buildMode.enterBlueprintMode(event->blueprint);
    refreshHover();
}

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

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

void GameWorldView::handleEvent(std::shared_ptr<const PanDirectionChangedEvent> event)
{
    m_panDirection = event->direction;
}

void GameWorldView::handleEvent(std::shared_ptr<const PauseToggleRequestedEvent> /*event*/)
{
    if (m_gameSpeedMultiplier > 0.0)
    {
        m_prevNonZeroSpeed = m_gameSpeedMultiplier;
        setGameSpeed(0.0);
    }
    else
    {
        setGameSpeed(m_prevNonZeroSpeed);
    }
}

void GameWorldView::handleEvent(std::shared_ptr<const SpeedStepRequestedEvent> event)
{
    stepSpeed(event->delta);
}

void GameWorldView::handleEvent(std::shared_ptr<const GhostRotationRequestedEvent> event)
{
    rotateGhost(event->clockwise);
}

void GameWorldView::handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> /*event*/)
{
    // One key backs out of whichever mode is active, and enters deconstruct mode when
    // none is (REQ-UI-HOTKEYS). The selection case of that key never reaches here: it
    // resolves to its own event, so there is nothing left to clear by the time the
    // fallthrough enters deconstruct mode (REQ-UI-SELECTION-EXCLUSIVE).
    if (m_buildMode.getMode() == BuildMode::None) { m_buildMode.toggleDeconstructMode(); }
    else                                          { m_buildMode.exitCurrentMode(); }
}

void GameWorldView::handleEvent(std::shared_ptr<const SelectionClearRequestedEvent> /*event*/)
{
    m_selection.clearAll();
}

void GameWorldView::handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> /*event*/)
{
    m_debugDraw = !m_debugDraw;
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<DebugDrawToggledEvent>(m_debugDraw));
}

void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
{
    // Other widgets (MainWindow, SelectionPanel) 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;
}
