#include "MainWindow.h"
#include "FactoryQueries.h"

#include <map>
#include <random>
#include <set>

#include <QApplication>
#include <QCloseEvent>
#include <QDialog>
#include <QDir>
#include <QFile>
#include <QMessageBox>
#include <QPoint>
#include <QResizeEvent>
#include <QVBoxLayout>
#include <QVector2D>

#include "BlueprintLibrary.h"
#include "BlueprintSelectionDialog.h"
#include "BuildButtonBar.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "ConfigLoader.h"
#include "EventManager.h"
#include "GameWorldView.h"
#include "RecipeSelectionDialog.h"
#include "SchematicChoiceDialog.h"
#include "HeaderBar.h"
#include "SelectionPanel.h"
#include "ControlsPanel.h"
#include "ExpandButton.h"
#include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h"
#include "BuildingIconCache.h"
#include "ItemIconCache.h"
#include "MessageDialog.h"
#include "ModalLayer.h"
#include "ModalPauseScope.h"
#include "NameInputDialog.h"
#include "Simulation.h"
#include "Tick.h"
#include "VisualsLoader.h"

MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
                       std::shared_ptr<ParsedReplay> replay, QWidget* parent)
    : QWidget(parent)
    , m_configDir(configDir)
    , m_visuals(VisualsLoader::load(configDir + "/visuals.toml"))
    , m_sim(sim)
    , m_replay(std::move(replay))
{
    setWindowTitle(tr("Dota Factory"));
    resize(1280, 768);

    // Item and building icons live alongside the config (siblings of the config dir),
    // read from disk at runtime the same way visuals.toml is (REQ-UI-ITEM-ICON,
    // REQ-UI-BUILD-ICON).
    const QString configDirPath = QString::fromStdString(m_configDir);
    m_itemIcons = std::make_unique<ItemIconCache>(
        QDir::cleanPath(configDirPath + "/../icons/items"), &m_visuals);
    m_buildingIcons = std::make_unique<BuildingIconCache>(
        QDir::cleanPath(configDirPath + "/../icons/buildings"));

    m_headerBar = new HeaderBar(getItemTooltipContext(), this);

    m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
                                        m_itemIcons.get(), m_replay.get(), this);

    // Floats over the game world at its bottom center, sized to its buttons
    // (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so
    // building it after the world view puts it above the world and its vignettes,
    // and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM).
    // Its geometry comes from layoutPanels().
    m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(),
                                          m_buildingIcons.get(), m_itemIcons.get(),
                                          this);

    // Stands in the world rather than on the screen, on the columns the next expansion
    // unlocks (REQ-UI-EXPAND-BUTTON). A sibling of the world view like the panels, which
    // is what keeps the builder-mode ghost off the tile beneath it and the click off the
    // world (REQ-BLD-GHOST): the view's hover follows underMouse(), false while the
    // cursor rests on a sibling.
    m_expandButton = new ExpandButton(getItemTooltipContext(), this);

    // The blueprints have no widget of their own: they are saved with Ctrl+C and picked
    // from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window
    // because only it can pause the game and raise the dim overlay. Built after the
    // world view because loading blueprints.toml may put a message box on screen.
    m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
    // Two facts about blueprints decide what the world view offers the player
    // (REQ-UI-CONTROLS-CONTENT); the library is built after the view, so it is handed
    // over here rather than passed to the constructor.
    m_gameWorldView->setBlueprintLibrary(m_blueprintLibrary.get());

    // Floats over the game world at its right edge rather than occupying a column of
    // its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like
    // the build button bar it is a sibling of the world view built after it, which is
    // what puts it above the world and its vignettes and below the dim overlay. It
    // brings its own chrome; its geometry comes from layoutPanels().
    m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), &m_visuals,
                                          m_itemIcons.get(), m_buildingIcons.get(),
                                          this);

    // Floats at the world view's opposite edge from the selection panel and reads the
    // world view for the player's current situation (REQ-UI-CONTROLS-PANEL). Built
    // after the view for the same stacking reason as the panels above it.
    m_controlsPanel = new ControlsPanel(m_gameWorldView, this);

    // Created last so it stacks above the other children; covers the whole window,
    // dims the game behind every modal, and is what those modals are drawn on
    // (REQ-UI-MODAL-DIM, REQ-UI-MODAL-CHROME).
    m_modalLayer = new ModalLayer(m_visuals.overlays.modalDim, this);

    m_gameWorldView->setFocus();

    connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) {
        // A modal holds the focus while it is open, whether it is one of ours on the
        // layer or a system message box (REQ-UI-MODAL-CHROME). A null widget -- the focus
        // going nowhere at all -- is caught too: the world view is what answers keys when
        // no modal is up, so leaving the window with no focus widget would leave the
        // hotkeys dead (REQ-UI-HOTKEYS).
        if (newWidget != m_gameWorldView && !QApplication::activeModalWidget()
            && !m_modalLayer->isActive())
        {
            m_gameWorldView->setFocus();
        }
    });

    // Load layout blueprints from disk. Missing file is silently ignored.
    const QString bpPath = QCoreApplication::applicationDirPath() + "/ship_layouts.toml";
    QFile bpFile(bpPath);
    if (bpFile.open(QIODevice::ReadOnly | QIODevice::Text))
    {
        try
        {
            m_layoutBlueprints = ShipLayoutBlueprintSerializer::deserialize(
                bpFile.readAll().toStdString());
        }
        catch (const std::exception& e)
        {
            QMessageBox::critical(this, tr("Load Error"),
                tr("Failed to load ship_layouts.toml:\n%1").arg(e.what()));
            m_layoutBlueprints.clear();
        }
    }

    registerForEvents();
}

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

void MainWindow::resizeEvent(QResizeEvent* event)
{
    QWidget::resizeEvent(event);
    layoutPanels();
}

void MainWindow::closeEvent(QCloseEvent* event)
{
    // A modal on the layer runs a nested event loop over widgets this window owns, so
    // the window must outlive it. A modal window used to block the close outright; this
    // does the same for a modal that is only a widget (REQ-UI-MODAL-CHROME).
    if (m_modalLayer->isActive())
    {
        event->ignore();
        return;
    }

    const QString path = QCoreApplication::applicationDirPath() + "/ship_layouts.toml";
    QFile file(path);
    if (file.open(QIODevice::WriteOnly | QIODevice::Text))
    {
        try
        {
            const std::string content =
                ShipLayoutBlueprintSerializer::serialize(m_layoutBlueprints);
            file.write(QByteArray::fromStdString(content));
        }
        catch (...) {}
    }
    QWidget::closeEvent(event);
}

void MainWindow::layoutPanels()
{
    const int totalW  = width();
    const int totalH  = height();
    const int headerH = m_headerBar->sizeHint().height();
    if (headerH <= 0) { return; }

    // Header bar and game world view span the full window width; the two floating
    // widgets below are the only things over the world (REQ-UI-HEADER,
    // REQ-UI-WORLD-SIZE).
    const QRect worldRect(0, headerH, totalW, totalH - headerH);
    m_headerBar->setGeometry(0, 0, totalW, headerH);
    m_gameWorldView->setGeometry(worldRect);
    m_modalLayer->setGeometry(0, 0, totalW, totalH);

    // The floating widgets are placed in one ordered pass, each into the space the
    // earlier ones have not taken (FloatingPanel.h). The order is the priority the
    // requirements state: the build button bar takes what it wants and never moves for
    // anyone (REQ-UI-BUILD-BAR), the controls panel steps around the bar
    // (REQ-UI-CONTROLS-PANEL), and the selection panel keeps clear of both
    // (REQ-UI-SELECTION-PANEL). A widget with nothing to show hides itself in placeIn()
    // and takes no space.
    //
    // Re-entry is refused rather than queued: setGeometry() on a widget in the pass can
    // reach code that asks for another pass, and the one already running is about to
    // produce the same answer.
    if (m_layingOut)
    {
        return;
    }
    m_layingOut = true;

    const std::vector<QWidget*> floatingWidgets = {
        m_buildButtonBar, m_controlsPanel, m_selectionPanel };
    const std::vector<FloatingPanel*> floatingPanels = {
        m_buildButtonBar, m_controlsPanel, m_selectionPanel };

    std::vector<QRect> occupiedRects;
    for (std::size_t i = 0; i < floatingPanels.size(); ++i)
    {
        floatingPanels[i]->placeIn(worldRect, occupiedRects);
        if (floatingWidgets[i]->isVisible())
        {
            occupiedRects.push_back(floatingWidgets[i]->geometry());
        }
    }

    // Last, and never added to occupiedRects: it marks a place in the world, so the
    // panels do not step around it and it does not step around them
    // (REQ-UI-EXPAND-BUTTON).
    placeExpandButton();

    m_layingOut = false;
}

void MainWindow::handleEvent(
    std::shared_ptr<const FloatingLayoutInvalidatedEvent> /*event*/)
{
    // One of the floating widgets changed size or visibility. What each of them may take
    // depends on the ones placed before it, so the answer is the whole pass rather than
    // that one widget re-placing itself.
    layoutPanels();
}

void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
{
    ModalPauseScope pause(*m_gameWorldView);

    SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes,
                                 getItemTooltipContext(), m_modalLayer);
    m_modalLayer->execute(dialog);

    // The command goes out unconditionally because the dialog cannot be dismissed: it
    // returns only once an option was clicked, so the index always names that option
    // (REQ-DEF-SCHEMATIC-DROP). It is also the only thing that resolves the drop -- the
    // poll that opened this dialog will not open it again while the choices stay pending
    // (GameWorldView::onFrame) -- so a path that skipped the command would strand it.
    std::shared_ptr<ApplySchematicChoiceCommand> command =
        std::make_shared<ApplySchematicChoiceCommand>();
    command->choiceIndex = dialog.getChosenIndex();
    EventManager::getInstance()->sendEventImmediately(
        std::make_shared<CommandRequestedEvent>(command));
}

void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/)
{
    ModalPauseScope pause(*m_gameWorldView);

    MessageDialog box(tr("Paused"), QString(), m_modalLayer);
    const int continueIndex = box.addButton(tr("Continue"));
    const int restartIndex  = box.addButton(tr("Restart"));
    const int quitIndex     = box.addButton(tr("Quit"));
    // Escape stands for Continue, as it did when this was a system box
    // (REQ-UI-GAME-MENU).
    box.setEscapeButtonIndex(continueIndex);
    m_modalLayer->execute(box);

    const std::optional<int> clicked = box.getClickedButtonIndex();
    if (clicked == restartIndex)
    {
        std::optional<GameConfig> newConfig = reloadConfig();
        if (!newConfig.has_value())
        {
            return;
        }
        // Restart is a command boundary; the view resets when the drain applies
        // it (see GameWorldView::onFrame). A fresh random seed starts a new run.
        // resetForNewGame() sets the speed for the new run, so the pre-restart
        // speed is deliberately not restored here.
        pause.release();
        std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
        command->config = std::make_shared<GameConfig>(std::move(*newConfig));
        command->seed   = std::random_device{}();
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<CommandRequestedEvent>(command));
    }
    else if (clicked == quitIndex)
    {
        pause.release();
        close();
    }
}

std::optional<GameConfig> MainWindow::reloadConfig()
{
    // Config is reloaded from disk on every restart (REQ-CFG-RELOAD); a malformed
    // file must not leave the window half-updated, so the visuals are only applied
    // once both files have parsed.
    try
    {
        GameConfig    newConfig  = ConfigLoader::loadFromDirectory(m_configDir);
        VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
        m_visuals = std::move(newVisuals);
        m_modalLayer->setDimColor(m_visuals.overlays.modalDim);
        // The composed item squares carry the colors they were painted with, so they
        // are dropped for the new ones to take effect (REQ-UI-ITEM-ICON).
        m_itemIcons->clearPixmapCache();
        return newConfig;
    }
    catch (const std::exception& e)
    {
        QMessageBox::critical(this, tr("Config Error"),
            tr("Failed to reload config:\n%1").arg(e.what()));
        return std::nullopt;
    }
}

void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
                                      const std::string& schematicId,
                                      const ShipLayoutConfig& currentLayout)
{
    ModalPauseScope pause(*m_gameWorldView);

    std::set<std::string> unlockedModuleIds;
    for (const ModuleDef& def : m_sim->getConfig().modules.modules)
    {
        if (m_sim->isModuleSchematicUnlocked(def.id))
        {
            unlockedModuleIds.insert(def.id);
        }
    }

    ShipLayoutDialog dialog(schematicId, currentLayout,
                            m_layoutBlueprints,
                            std::move(unlockedModuleIds),
                            m_gameWorldView->isDebugDrawEnabled(),
                            getItemTooltipContext(), m_modalLayer);
    // Opened from the panel's "Configure" button (REQ-MOD-UI-PREVIEW) or straight after
    // a schematic change (REQ-MOD-UI-AUTO-DIALOG), so it opens on the panel either way.
    if (m_modalLayer->execute(dialog, getSelectionPanelAnchor()) == QDialog::Accepted
        && dialog.getResult().has_value())
    {
        std::shared_ptr<SetShipLayoutCommand> command =
            std::make_shared<SetShipLayoutCommand>();
        command->id     = shipyardId;
        command->layout = *dialog.getResult();
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<CommandRequestedEvent>(command));
    }
}

QRect MainWindow::getSelectionPanelAnchor() const
{
    // The panel is up whenever one of these modals opens -- they are opened from its own
    // controls, and it is shown whenever anything is selected (REQ-UI-EMPTY-SELECTION).
    // Were it not, there would be no rectangle to center on and the layer's own centering
    // stands (REQ-UI-PANEL-MODAL).
    if (!m_selectionPanel->isVisible()) { return QRect(); }

    // The panel's live geometry, so a panel the player has dragged
    // (REQ-UI-SELECTION-PANEL-DRAG) carries the modal with it. Panel and layer are both
    // children of this window, so the panel's geometry needs no mapping.
    return m_selectionPanel->geometry();
}

void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
{
    // A construction site has no Building yet; fall back to its site record so
    // the shipyard layout can be configured before it is built (REQ-BLD-SITE-CONFIG).
    const Building* b = findBuilding(m_sim->getFactoryState(), event->shipyardId);
    const ConstructionSite* s =
        b ? nullptr : findSite(m_sim->getFactoryState(), event->shipyardId);
    if (!b && !s)
    {
        return;
    }

    const std::string& schematicId = b ? b->recipeId : s->recipeId;
    // Nothing to configure without a schematic and a grid to place modules on. The
    // Configure button that publishes this is already disabled then (REQ-MOD-UI-PREVIEW),
    // so this guards the event rather than the button: the dialog would otherwise open
    // over a grid of no cells.
    if (!m_sim->getConfig().ships.findLayoutShipDef(schematicId))
    {
        return;
    }

    const std::optional<ShipLayoutConfig>& layoutOpt =
        b ? b->shipLayout : s->shipLayout;

    ShipLayoutConfig currentLayout;
    if (layoutOpt.has_value())
    {
        currentLayout = *layoutOpt;
    }

    openShipLayoutDialog(event->shipyardId, schematicId, currentLayout);
}

void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event)
{
    ModalPauseScope pause(*m_gameWorldView);

    // A construction site has no Building yet; fall back to its site record so
    // the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG).
    const Building* b = findBuilding(m_sim->getFactoryState(), event->buildingId);
    const ConstructionSite* s =
        b ? nullptr : findSite(m_sim->getFactoryState(), event->buildingId);
    if (!b && !s)
    {
        return;
    }

    // Held across both the selection dialog and any auto-opened layout dialog so the
    // dim stays continuously visible through that sequence (REQ-UI-MODAL-DIM).
    ModalLayerHold dim(*m_modalLayer);

    const BuildingType type = b ? b->type : s->type;
    // Captured as a copy: a queued command may drain during the modal dialog's
    // event loop and reallocate the building vectors, so b/s must not be
    // dereferenced after dialog.exec() returns.
    const std::string oldSchematic = b ? b->recipeId : s->recipeId;
    const std::vector<RecipeSelectionOption> options =
        buildRecipeSelectionOptions(type, *m_sim, m_sim->getConfig());
    const QString title = (type == BuildingType::Shipyard)
        ? tr("Select Schematic")
        : tr("Select Recipe");

    bool autoOpenLayout = false;
    std::string chosenSchematic;
    RecipeSelectionDialog dialog(options, title, getItemTooltipContext(),
                                 m_modalLayer);
    if (m_modalLayer->execute(dialog, getSelectionPanelAnchor()) == QDialog::Accepted
        && dialog.getChosenId().has_value())
    {
        std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
        command->id       = event->buildingId;
        command->recipeId = *dialog.getChosenId();
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<CommandRequestedEvent>(command));

        // REQ-MOD-UI-AUTO-DIALOG: picking a new schematic for a shipyard opens the
        // layout configuration dialog immediately. Only on an actual change, and only
        // for an actual schematic: "(None)" clears the shipyard and carries the empty
        // id (REQ-UI-SELECT-OPTIONS), which differs from whatever was set but is not a
        // schematic to configure.
        if (type == BuildingType::Shipyard && *dialog.getChosenId() != oldSchematic
            && m_sim->getConfig().ships.findLayoutShipDef(*dialog.getChosenId()))
        {
            autoOpenLayout = true;
            chosenSchematic = *dialog.getChosenId();
        }
    }

    // The SetRecipeCommand above is queued (drains on a later frame) and clears
    // the shipyard's layout, so open the dialog with the chosen schematic and an
    // empty layout rather than reading the not-yet-updated building state. Speed
    // is restored first so the helper snapshots the real speed to restore.
    pause.restore();
    if (autoOpenLayout)
    {
        openShipLayoutDialog(event->buildingId, chosenSchematic, ShipLayoutConfig{});
    }
}

void MainWindow::handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> /*event*/)
{
    // Ctrl+C has effect only when something player-placeable is selected; otherwise no
    // dialog opens at all (REQ-UI-BLUEPRINT-CREATE).
    if (!m_blueprintLibrary->getCanCaptureSelection()) { return; }

    // Both scopes are held across the naming dialog and the selection dialog it hands
    // over to, so the dim never blinks off and the simulation is not resumed in between
    // (REQ-UI-MODAL-DIM).
    ModalPauseScope pause(*m_gameWorldView);
    ModalLayerHold  dim(*m_modalLayer);

    NameInputDialog nameDialog(tr("Create Blueprint"), tr("Blueprint name:"),
                               m_modalLayer);
    const int result = m_modalLayer->execute(nameDialog);
    // Cancel, Escape, or an empty name: no blueprint, and no selection dialog.
    if (result != QDialog::Accepted || nameDialog.getName().isEmpty()) { return; }

    m_blueprintLibrary->saveSelectionAs(nameDialog.getName());
    showBlueprintSelectionDialog();
}

void MainWindow::handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> /*event*/)
{
    ModalPauseScope pause(*m_gameWorldView);
    showBlueprintSelectionDialog();
}

void MainWindow::placeExpandButton()
{
    // Null while the button is still being built: refreshing its cost asks for a
    // placement pass, and the first refresh happens inside its own constructor.
    if (m_expandButton == nullptr)
    {
        return;
    }

    // The columns the next purchase unlocks: the ones immediately left of the buildable
    // edge (REQ-GW-ASTEROID-EXPAND, REQ-UI-LOCKED-ASTEROID). Their middle, and the
    // world's middle vertically, is where the button goes (REQ-UI-EXPAND-BUTTON).
    const int   asteroidWidth_tiles = m_sim->getCurrentAsteroidWidth_tiles();
    const float columns_tiles       = static_cast<float>(
        m_sim->getConfig().world.expansion.columnsPerExpansion_tiles);
    const QVector2D center_tiles(
        -static_cast<float>(asteroidWidth_tiles) - columns_tiles / 2.0f,
        static_cast<float>(m_sim->getConfig().world.heightTiles) / 2.0f);

    // The transform is the view's, so the point it yields is in the view's coordinates;
    // the button is a sibling of the view, so it is lifted into this window's.
    const QPointF centerInView_px =
        m_gameWorldView->getCoordinates().worldToWidget(center_tiles);
    const QPoint center_px = m_gameWorldView->geometry().topLeft()
                           + QPoint(static_cast<int>(centerInView_px.x()),
                                    static_cast<int>(centerInView_px.y()));

    // Neither clamped nor hidden: the button belongs to that ground and leaves the view
    // with it, which the window's own edge takes care of (REQ-UI-EXPAND-BUTTON).
    const QSize buttonSize = m_expandButton->sizeHint();
    m_expandButton->setGeometry(QRect(
        center_px - QPoint(buttonSize.width() / 2, buttonSize.height() / 2),
        buttonSize));
}

void MainWindow::handleEvent(std::shared_ptr<const ViewScrolledEvent> /*event*/)
{
    // The ground moved under the button; nothing else about the layout changed, so this
    // is the one widget to re-place (REQ-UI-EXPAND-BUTTON).
    placeExpandButton();
}

ItemTooltipContext MainWindow::getItemTooltipContext() const
{
    return ItemTooltipContext{ m_sim, &m_sim->getConfig(), m_itemIcons.get(),
                               m_buildingIcons.get() };
}

void MainWindow::showBlueprintSelectionDialog()
{
    BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), getItemTooltipContext(),
                                    m_modalLayer);
    if (m_modalLayer->execute(dialog) == QDialog::Accepted
        && dialog.getChosenIndex().has_value())
    {
        // Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD
        // describes: clicking a card closes the dialog and enters placement mode.
        m_blueprintLibrary->beginPlacement(*dialog.getChosenIndex());
    }
}

void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
{
    const Tick tick = m_sim->getCurrentTick();
    const int totalSeconds = static_cast<int>(ticksToSeconds(tick));
    const int minutes = totalSeconds / 60;
    const int seconds = totalSeconds % 60;

    MessageDialog box(tr("Game Over"),
                      tr("HQ destroyed!\nSurvival time: %1:%2")
                          .arg(minutes, 2, 10, QChar('0'))
                          .arg(seconds, 2, 10, QChar('0')),
                      m_modalLayer);
    const int restartIndex = box.addButton(tr("Restart"));
    const int quitIndex    = box.addButton(tr("Quit"));
    // Escape quits, which is where the system box's reject role sent it.
    box.setEscapeButtonIndex(quitIndex);
    m_modalLayer->execute(box);

    if (box.getClickedButtonIndex() == restartIndex)
    {
        std::optional<GameConfig> newConfig = reloadConfig();
        if (!newConfig.has_value())
        {
            return;
        }
        // Restart is a command boundary; the view resets when the drain applies it.
        std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
        command->config = std::make_shared<GameConfig>(std::move(*newConfig));
        command->seed   = std::random_device{}();
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<CommandRequestedEvent>(command));
    }
    else
    {
        close();
    }
}

void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
{
    const Tick tick = m_sim->getCurrentTick();
    const int totalSeconds = static_cast<int>(ticksToSeconds(tick));
    const int minutes = totalSeconds / 60;
    const int seconds = totalSeconds % 60;

    MessageDialog box(tr("Won!"),
                      tr("You collected all artifacts!\nSurvival time: %1:%2")
                          .arg(minutes, 2, 10, QChar('0'))
                          .arg(seconds, 2, 10, QChar('0')),
                      m_modalLayer);
    const int restartIndex = box.addButton(tr("Restart"));
    const int quitIndex    = box.addButton(tr("Quit"));
    // Escape quits, which is where the system box's reject role sent it.
    box.setEscapeButtonIndex(quitIndex);
    m_modalLayer->execute(box);

    if (box.getClickedButtonIndex() == restartIndex)
    {
        std::optional<GameConfig> newConfig = reloadConfig();
        if (!newConfig.has_value())
        {
            return;
        }
        // Restart is a command boundary; the view resets when the drain applies it.
        std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
        command->config = std::make_shared<GameConfig>(std::move(*newConfig));
        command->seed   = std::random_device{}();
        EventManager::getInstance()->sendEventImmediately(
            std::make_shared<CommandRequestedEvent>(command));
    }
    else
    {
        close();
    }
}
