#include "MainFrame.h"
#include "Version.h"
#include "ResourceLoader.h"
#include "Prefs.h"
#include "io/CatalogLoader.h"
#include "io/SceneryCatalog.h"
#include "model/HelixVariant.h"
#include "LayoutShapeDialog.h"
#include "UserPaths.h"
#include "RectangleDialog.h"
#include "ElevationWizard.h"
#include <unordered_set>
#include <unordered_map>
#include <queue>
#include <algorithm>
#include "LevelManagerDialog.h"
#include "io/LayoutXml.h"
#include "view3d/View3DFrame.h"
#include <wx/stdpaths.h>
#include "io/LayoutXml.h"
#include "view3d/View3DFrame.h"

#include <wx/filename.h>
#include <wx/stdpaths.h>
#include <wx/dir.h>
#include "panels/PalettePanel.h"
#include "panels/CanvasPanel.h"
#include "panels/PropertiesPanel.h"
#include "io/PlanXmlWriter.h"
#include "io/PlanXmlReader.h"
#include <wx/filedlg.h>
#include <wx/dirdlg.h>
#include <wx/hyperlink.h>
#include <wx/msgdlg.h>
#include <wx/splitter.h>
#include <wx/print.h>
#include <wx/printdlg.h>
#include "PrintOptionsDialog.h"
#include "LayoutPrintout.h"
#include <cmath>

static bool g_mfLogCleared = false;
static std::string MFLogPath() {
    static std::string cached;
    if (cached.empty()) {
        wxString dir = wxStandardPaths::Get().GetUserLocalDataDir();
        if (!wxFileName::DirExists(dir)) {
            wxFileName::Mkdir(dir, wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL);
        }
        if (!wxFileName::DirExists(dir)) {
            dir = wxStandardPaths::Get().GetTempDir();
        }
        wxString p = dir + wxFileName::GetPathSeparator() + "mainframe.log";
        cached = std::string(p.mb_str());
    }
    return cached;
}
static void MFLog(const char* msg) {
    std::string path = MFLogPath();
    if (!g_mfLogCleared) { DeleteFileA(path.c_str()); g_mfLogCleared = true; }
    HANDLE h = CreateFileA(path.c_str(),
        FILE_APPEND_DATA, FILE_SHARE_READ, nullptr,
        OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
    if (h != INVALID_HANDLE_VALUE) {
        DWORD w; WriteFile(h, msg, (DWORD)strlen(msg), &w, nullptr);
        WriteFile(h, "\r\n", 2, &w, nullptr);
        CloseHandle(h);
    }
}

// Mirror of CanvasPanel's helper -- kept in sync by hand.
// Returns true if the user OK'd the deletion (or no prompt was needed).
static bool ConfirmGradedDeletion(wxWindow* parent, const MC::CanvasState& state)
{
    bool anyGraded = false;
    for (const auto& p : state.pieces) {
        if (p.selected && std::abs(p.gradeDeg) > 0.001) { anyGraded = true; break; }
    }
    if (!anyGraded) return true;

    int reply = wxMessageBox(
        "Selection includes graded pieces. Delete?",
        "Delete",
        wxYES_NO | wxICON_QUESTION,
        parent);
    return reply == wxYES;
}

// Tunnel/helix track guard: refuses an operation if any selected piece is
// part of a Tunnel or a Helix, or any selected scenery is a tunnel portal.
static bool WarnIfTunneled(wxWindow* parent, const MC::CanvasState& state,
                            const char* verb)
{
    bool helixHit = false;
    bool tunnelHit = false;
    for (const auto& p : state.pieces) {
        if (p.selected) {
            if (p.helixId != -1)             { helixHit  = true; break; }
            if (state.IsPieceInTunnel(p.id)) tunnelHit = true;
        }
    }
    if (!helixHit && !tunnelHit) {
        for (const auto& s : state.scenery)
            if (s.selected && state.IsPortalScenery(s.id)) { tunnelHit = true; break; }
    }
    if (!helixHit && !tunnelHit) return true;
    wxString msg;
    if (helixHit) {
        msg = wxString::Format(
            "Selection includes track that is part of a helix. "
            "Helix track cannot be %sed individually.", verb);
    } else {
        msg = wxString::Format(
            "Selection includes tunnel track or portals. "
            "Remove the tunnel first, then %s.", verb);
    }
    wxMessageBox(msg, helixHit ? "Helix track is locked" : "Tunnel is locked",
                  wxOK | wxICON_INFORMATION, parent);
    return false;
}

wxBEGIN_EVENT_TABLE(MainFrame, wxFrame)
    EVT_MENU(ID_New,              MainFrame::OnNew)
    EVT_MENU(ID_Open,             MainFrame::OnOpen)
    EVT_MENU(ID_Import,           MainFrame::OnImport)
    EVT_MENU(ID_Export,           MainFrame::OnExport)
    EVT_MENU(ID_Save,             MainFrame::OnSave)
    EVT_MENU(ID_SaveAs,           MainFrame::OnSaveAs)
    EVT_MENU(ID_SaveBlock,        MainFrame::OnSaveBlock)
    EVT_MENU(ID_Close,            MainFrame::OnClose)
    EVT_MENU(ID_PageSetup,        MainFrame::OnPageSetup)
    EVT_MENU(ID_Print,            MainFrame::OnPrint)
    EVT_MENU(ID_PrintPreview,     MainFrame::OnPrintPreview)
    EVT_MENU(ID_View3D,           MainFrame::OnView3D)
    EVT_MENU(ID_Undo,             MainFrame::OnUndo)
    EVT_MENU(ID_Redo,             MainFrame::OnRedo)
    EVT_MENU(ID_Delete,           MainFrame::OnDelete)
    EVT_MENU(ID_SelectAll,        MainFrame::OnSelectAll)
    EVT_MENU(ID_ZoomIn,           MainFrame::OnZoomIn)
    EVT_MENU(ID_ZoomOut,          MainFrame::OnZoomOut)
    EVT_MENU(ID_ZoomFit,          MainFrame::OnZoomFit)
    EVT_MENU(ID_GridToggle,       MainFrame::OnGridToggle)
    EVT_MENU(ID_SnapToggle,       MainFrame::OnSnapToggle)
    EVT_MENU(ID_LabelToggle,      MainFrame::OnLabelToggle)
    EVT_MENU(ID_RailToggle,       MainFrame::OnRailToggle)
    EVT_MENU(ID_Tunnel,           MainFrame::OnTunnel)
    EVT_MENU(ID_Measure,          MainFrame::OnMeasureTool)
    EVT_MENU(ID_Polygon,          MainFrame::OnPolygonTool)
    EVT_MENU(ID_Rectangle,        MainFrame::OnRectangleTool)
    EVT_MENU(ID_Elevation,        MainFrame::OnElevation)
    EVT_MENU(ID_LayoutShape,      MainFrame::OnLayoutShape)
    EVT_MENU(ID_UnitMm,           MainFrame::OnUnitChange)
    EVT_MENU(ID_UnitCm,           MainFrame::OnUnitChange)
    EVT_MENU(ID_UnitInch,         MainFrame::OnUnitChange)
    EVT_MENU(ID_Levels,           MainFrame::OnLevels)
    EVT_MENU(ID_Validate,         MainFrame::OnValidate)
    EVT_MENU(ID_LayoutProperties, MainFrame::OnLayoutProperties)
    EVT_MENU(ID_Help,             MainFrame::OnHelp)
    EVT_MENU(ID_Options,          MainFrame::OnOptions)
    EVT_UPDATE_UI(wxID_ANY,       MainFrame::OnUpdateUI)
    EVT_CLOSE(                    MainFrame::OnClose)
    EVT_MENU(wxID_EXIT,           MainFrame::OnExit)
wxEND_EVENT_TABLE()

MainFrame::MainFrame()
    : wxFrame(nullptr, wxID_ANY, TD_APP_FULL_TITLE,
              wxDefaultPosition, wxSize(1400, 900))
{
    MFLog("A: wxFrame base constructed");
    BuildMenuBar();
    MFLog("B: MenuBar done");
    BuildToolBar();
    MFLog("C: ToolBar done");
    BuildPanels();
    MFLog("D: Panels done");
    BuildStatusBar();
    MFLog("E: StatusBar done");
    Centre();
    MFLog("F: MainFrame constructor complete");

    // Print subsystem state -- kept alive for the life of the frame so paper
    // size, orientation, and last-used options persist across jobs.
    m_printData     = new wxPrintData();
    m_printData->SetPaperId(wxPAPER_LETTER);
    m_printData->SetOrientation(wxLANDSCAPE);
    m_pageSetupData = new wxPageSetupDialogData(*m_printData);
    m_pageSetupData->SetMarginTopLeft(wxPoint(15, 15));
    m_pageSetupData->SetMarginBottomRight(wxPoint(15, 15));

    // Load preferences. If the user hasn't been asked yet, show the
    // one-time experience dialog after the frame is fully shown.
    Prefs::Get().Load();
    if (!Prefs::Get().WasAsked()) {
        CallAfter([this]() { ShowExperienceDialog(); });
    }
}

MainFrame::~MainFrame() {
    delete m_pageSetupData;
    delete m_printData;
}

void MainFrame::BuildMenuBar() {
    wxMenuBar* mb = new wxMenuBar();

    wxMenu* file = new wxMenu();
    file->Append(ID_New,    "&New Layout\tCtrl+N");
    file->Append(ID_Open,   "&Open Layout...\tCtrl+O");
    file->AppendSeparator();
    file->Append(ID_Save,   "&Save\tCtrl+S");
    file->Append(ID_SaveAs, "Save &As...\tCtrl+Shift+S");
    file->AppendSeparator();
    file->Append(ID_Import, "&Import Layout...");
    file->Append(ID_Export, "&Export Layout...");
    file->AppendSeparator();
    file->Append(ID_Close,  "&Close Layout\tCtrl+W");
    file->AppendSeparator();
    file->Append(ID_PageSetup,    "Page Set&up...");
    file->Append(ID_PrintPreview, "Print Pre&view...");
    file->Append(ID_Print,        "&Print...\tCtrl+P");
    file->AppendSeparator();
    file->Append(ID_SaveBlock, "Save as &Library Block...");
    file->AppendSeparator();
    file->Append(wxID_EXIT, "E&xit");
    mb->Append(file, "&File");

    wxMenu* edit = new wxMenu();
    edit->Append(ID_Undo,      "&Undo\tCtrl+Z");
    edit->Append(ID_Redo,      "&Redo\tCtrl+Y");
    edit->AppendSeparator();
    edit->Append(ID_Delete,    "&Delete\tDel");
    edit->Append(ID_SelectAll, "Select &All\tCtrl+A");
    mb->Append(edit, "&Edit");

    wxMenu* view = new wxMenu();
    view->Append(ID_ZoomIn,  "Zoom &In\t+");
    view->Append(ID_ZoomOut, "Zoom &Out\t-");
    view->Append(ID_ZoomFit, "Zoom to &Fit\tCtrl+0");
    view->AppendSeparator();
    view->AppendCheckItem(ID_GridToggle, "Show &Grid\tCtrl+G");
    view->AppendCheckItem(ID_SnapToggle, "&Snap\tCtrl+Shift+G");
    view->AppendCheckItem(ID_RailToggle, "Show &Rails");
    mb->Append(view, "&View");

    wxMenu* layout = new wxMenu();
    wxMenu* unitsMenu = new wxMenu();
    unitsMenu->AppendRadioItem(ID_UnitMm,   "Millimetres (mm)");
    unitsMenu->AppendRadioItem(ID_UnitCm,   "Centimetres (cm)");
    unitsMenu->AppendRadioItem(ID_UnitInch, "Inches (in)");
    unitsMenu->Check(ID_UnitMm, true);
    layout->AppendSubMenu(unitsMenu, "Units of Measure");
    layout->AppendSeparator();
    layout->Append(ID_LayoutShape, "Layout &Shape...");
    mb->Append(layout, "&Layout");

    wxMenu* helpMenu = new wxMenu();
    helpMenu->Append(ID_Help, "&Help\tF1");
    helpMenu->AppendSeparator();
    helpMenu->Append(ID_Options, "&Options...");
    mb->Append(helpMenu, "&Help");

    SetMenuBar(mb);
    mb->Check(ID_GridToggle, true);
    mb->Check(ID_SnapToggle, true);
    mb->Check(ID_RailToggle, true);
}

void MainFrame::BuildToolBar() {
    wxToolBar* tb = CreateToolBar(wxTB_HORIZONTAL | wxTB_TEXT | wxTB_NOICONS);
    tb->AddTool(ID_Undo,   "Undo",  wxNullBitmap);
    tb->AddTool(ID_Redo,   "Redo",  wxNullBitmap);
    tb->AddSeparator();
    tb->AddTool(ID_Levels, "Levels", wxNullBitmap, wxNullBitmap, wxITEM_NORMAL,
                "Manage layout levels (decks): height, color, and shape");
    tb->AddSeparator();
    tb->AddTool(ID_View3D, "3D View", wxNullBitmap);
    tb->AddSeparator();
    tb->AddTool(ID_ZoomFit,"Fit",   wxNullBitmap);
    tb->AddSeparator();
    tb->AddCheckTool(ID_LabelToggle, "Hide Labels", wxNullBitmap, wxNullBitmap,
                     "Toggle track labels and join dots");
    tb->AddSeparator();
    tb->AddTool(ID_Tunnel, "Tunnel", wxNullBitmap, wxNullBitmap, wxITEM_NORMAL,
                "Place tunnel portals on the selected chain (or remove if already in a tunnel)");
    tb->EnableTool(ID_Tunnel, false);
    tb->AddCheckTool(ID_Measure, "Measure", wxNullBitmap, wxNullBitmap,
                     "Measuring tape: click two points on the canvas to lay down a length annotation");
    tb->AddCheckTool(ID_Polygon, "Polygon", wxNullBitmap, wxNullBitmap,
                     "Draw a free-form polygon (access hatch, river, mountain, etc.)");
    tb->AddTool(ID_Rectangle, "Rectangle", wxNullBitmap, wxNullBitmap, wxITEM_NORMAL,
                "Rectangle",
                "Place a rectangle of an exact size -- easier than drawing one by hand");
    tb->AddTool(ID_Elevation, "Elevation", wxNullBitmap, wxNullBitmap, wxITEM_NORMAL,
                "Elevation",
                "Open the Elevation Wizard to set up track grades and inclines");
    tb->AddSeparator();
    tb->Realize();
}

void MainFrame::BuildPanels() {
    MFLog("BuildPanels: start");

    // Root panel with BoxSizer (same pattern as controller)
    wxPanel* root = new wxPanel(this);
    MFLog("BuildPanels: root panel created");

    wxBoxSizer* hbox = new wxBoxSizer(wxHORIZONTAL);

    // Outer splitter: palette (left) | canvas+properties (right). User can
    // drag the sash to resize the palette width.
    wxSplitterWindow* outerSplit = new wxSplitterWindow(root, wxID_ANY,
        wxDefaultPosition, wxDefaultSize,
        wxSP_LIVE_UPDATE | wxSP_3DSASH | wxNO_BORDER);
    outerSplit->SetSashGravity(0.0);   // keep palette width when frame resizes

    m_palette = new PalettePanel(outerSplit, this);
    m_palette->SetMinSize(wxSize(150, -1));
    MFLog("BuildPanels: palette created");

    // Inner splitter: canvas | properties
    wxSplitterWindow* splitter = new wxSplitterWindow(outerSplit, wxID_ANY,
        wxDefaultPosition, wxDefaultSize,
        wxSP_LIVE_UPDATE | wxSP_3DSASH | wxNO_BORDER);
    splitter->SetSashGravity(1.0);
    MFLog("BuildPanels: splitter created");

    m_canvas = new CanvasPanel(splitter, this);
    MFLog("BuildPanels: canvas created");

    m_properties = new PropertiesPanel(splitter, this);
    MFLog("BuildPanels: properties created");

    splitter->SplitVertically(m_canvas, m_properties, -280);
    splitter->SetMinimumPaneSize(200);
    MFLog("BuildPanels: split done");

    // Palette starts at 235 px wide, user-resizable
    outerSplit->SplitVertically(m_palette, splitter, 235);
    outerSplit->SetMinimumPaneSize(150);

    MFLog("BuildPanels: adding to sizer");
    hbox->Add(outerSplit, 1, wxEXPAND);
    MFLog("BuildPanels: outerSplit added");
    root->SetSizer(hbox);
    MFLog("BuildPanels: sizer set");
    root->Layout();
    MFLog("BuildPanels: layout done");

    // Populate palette now - window is constructed
    // Earlier versions kept layouts and library blocks beside the executable.
    // Move any that are still there into the user's folder before the palette
    // goes looking for them, so an upgrading user does not lose their blocks.
    TD::MigrateLegacyUserFiles();

    m_palette->Populate();
    m_palette->LoadLibrary();
    m_palette->LoadLibrary();
    UpdateTitleBar();
    MFLog("BuildPanels: palette populated");
}

void MainFrame::BuildStatusBar() {
    CreateStatusBar(3);
    SetStatusText("Ready", 0);
    SetStatusText("Pieces: 0", 1);
    SetStatusText("x: 0  y: 0 mm", 2);
}

void MainFrame::RefreshTally() {
    if (m_properties) m_properties->RefreshTally();
}

void MainFrame::MarkDirty() {
    m_dirty = true;
    UpdateTitleBar();
    if (m_properties) m_properties->RefreshTally();
}

void MainFrame::RefreshCanvas() {
    if (m_canvas) m_canvas->Refresh();
    if (m_view3d) m_view3d->RefreshLayout(m_state);
}

void MainFrame::UpdateTitleBar() {
    wxString title = TD_APP_FULL_TITLE;
    if (!m_layoutName.empty())
        title += " - " + m_layoutName;
    else if (!m_currentFile.empty())
        title += " - " + wxFileName(m_currentFile).GetName();
    if (m_dirty) title += " *";
    SetTitle(title);
}

void MainFrame::OnCanvasEmptyClick(wxPoint screenPt) {
    if (m_properties)
        m_properties->FocusLabelField(m_canvas->ScreenToWorld(screenPt));
}

void MainFrame::OnSelectionChanged() {
    if (m_properties) m_properties->RefreshProperties();
    if (m_properties) m_properties->RefreshTally();
}

bool MainFrame::ConfirmDiscard() {
    if (!m_dirty) return true;
    int r = wxMessageBox("You have unsaved changes. Save before continuing?",
        "Unsaved Changes", wxYES_NO | wxCANCEL | wxICON_QUESTION);
    if (r == wxCANCEL) return false;
    if (r == wxYES)    return SaveLayout(false);
    return true;
}

bool MainFrame::SaveLayout(bool forceDialog) {
    // Layouts live in the user's documents folder, not beside the exe -- see
    // UserPaths.h for why.
    wxString dir = TD::UserDataDir();

    // If saving in place (Save, not Save As), and we already have a path
    // from this session, just overwrite it.
    if (!forceDialog && !m_currentFile.empty()) {
        std::string err = TD::LayoutXml::Save(m_state, m_currentFile.ToStdString(),
                                               m_layoutName.ToStdString());
        if (!err.empty()) {
            wxMessageBox(wxString::FromUTF8(err), "Save Failed",
                         wxICON_ERROR | wxOK);
            return false;
        }
        m_dirty = false;
        UpdateTitleBar();
        return true;
    }

    // Otherwise, prompt for a layout name (no path picker). Default to the
    // current layout name if any. The file lives next to the exe with
    // extension ".tdl" (TrackDesigner Layout).
    wxTextEntryDialog dlg(this, "Layout name:", "Save Layout",
                          m_layoutName.empty() ? "My Layout" : m_layoutName);
    if (dlg.ShowModal() != wxID_OK) return false;
    wxString name = dlg.GetValue().Trim().Trim(false);
    if (name.empty()) name = "My Layout";

    wxString fullPath = dir + wxFILE_SEP_PATH + name + ".tdl";

    // Confirm overwrite if a file with that name already exists.
    if (wxFileName::FileExists(fullPath)) {
        wxString msg;
        msg.Printf("A layout named \"%s\" already exists.\n\nReplace it?", name);
        if (wxMessageBox(msg, "Confirm Replace",
                         wxYES_NO | wxICON_QUESTION | wxNO_DEFAULT, this) != wxYES)
            return false;
    }

    m_currentFile = fullPath;
    m_layoutName  = name;

    std::string err = TD::LayoutXml::Save(m_state, m_currentFile.ToStdString(),
                                           m_layoutName.ToStdString());
    if (!err.empty()) {
        wxMessageBox(wxString::FromUTF8(err), "Save Failed", wxICON_ERROR | wxOK);
        return false;
    }
    m_dirty = false;
    UpdateTitleBar();
    return true;
}

bool MainFrame::LoadLayout(const wxString& path) {
    std::string layoutName;
    std::string err = TD::LayoutXml::Load(m_state, path.ToStdString(), layoutName);
    if (!err.empty()) {
        wxMessageBox(wxString::FromUTF8(err), "Open Failed", wxICON_ERROR | wxOK);
        return false;
    }
    m_state.EnsureDefaultLevel();
    m_currentFile = path;
    m_layoutName  = wxString::FromUTF8(layoutName);
    m_dirty       = false;
    UpdateTitleBar();
    // Sync unit menu radio items
    GetMenuBar()->Check(ID_UnitMm,   m_state.units == 0);
    GetMenuBar()->Check(ID_UnitCm,   m_state.units == 1);
    GetMenuBar()->Check(ID_UnitInch, m_state.units == 2);
    m_canvas->Refresh();
    if (m_view3d) m_view3d->RefreshLayout(m_state);

    // Auto-zoom: Z layouts get scale=1.0, others get ZoomFit
    bool isZLayout = false;
    for (const auto& p : m_state.pieces) {
        const MC::TrackPiece* def = MC::GetCatalog().Find(p.catalogNumber);
        if (def && def->series == MC::TrackSeries::MarlinZ) { isZLayout = true; break; }
    }
    if (isZLayout)
        m_canvas->SetZoomForZ();
    else
        m_canvas->ZoomFit();
    if (m_properties) m_properties->RefreshTally();
    if (m_palette) m_palette->SelectSystemForLayout(m_state);
    if (!m_state.gradeRuns.empty()) {
        wxString msg = "[ELEV]";
        for (const auto& r : m_state.gradeRuns)
            msg += wxString::Format(" %.1f deg", r.gradeDeg);
        SetStatusText(msg + "  (right-click piece to stop)", 0);
    } else
        SetStatusText("Ready", 0);
    wxString pc; pc << "Pieces: " << (int)m_state.pieces.size();
    SetStatusText(pc, 1);
    return true;
}

bool MainFrame::SaveBlock() {
    if (!m_state.HasSelection()) {
        wxMessageBox("Select pieces first to save as library block.",
                     "Save Block", wxICON_INFORMATION | wxOK);
        return false;
    }
    wxTextEntryDialog nameDlg(this, "Block name:", "Save Library Block", "");
    if (nameDlg.ShowModal() != wxID_OK) return false;
    wxString blockName = nameDlg.GetValue();
    if (blockName.empty()) return false;

    // Save to system-specific library subfolder
    wxString libDir = m_palette->CurrentLibraryFolder();
    wxFileName::Mkdir(libDir, wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL);

    wxString filePath = libDir + wxFILE_SEP_PATH + blockName + ".tdl";
    std::string err = TD::LayoutXml::SaveBlock(m_state, filePath.ToStdString(),
                                                blockName.ToStdString());
    if (!err.empty()) {
        wxMessageBox(wxString::FromUTF8(err), "Save Block Failed", wxICON_ERROR | wxOK);
        return false;
    }
    // Reload library in palette
    if (m_palette) m_palette->LoadLibrary();
    wxMessageBox("Block saved: " + blockName, "Save Block", wxICON_INFORMATION | wxOK);
    return true;
}

void MainFrame::LoadLibraryBlocks() {
    if (m_palette) m_palette->LoadLibrary();
}

void MainFrame::OnNew(wxCommandEvent&) {
    if (!ConfirmDiscard()) return;
    m_state.Clear();
    m_state.baseboard.clear();
    m_state.levels.clear();
    m_state.EnsureDefaultLevel();
    m_state.activeLevelId = 1;
    m_state.StopAllGradeRuns();
    m_currentFile.clear();
    m_dirty = false;
    SetTitle(TD_APP_FULL_TITLE);
    m_canvas->ResetView();
    m_canvas->Refresh();
    // The right-panel tally and per-piece property fields hold data from
    // the previous layout; refresh them so they reflect the empty state.
    OnSelectionChanged();
    SetStatusText("Ready", 0);
    SetStatusText("Pieces: 0", 1);
}

void MainFrame::OnClose(wxCommandEvent&) {
    // Close the current layout: like New, but also clears the baseboard shape
    // so the user gets a truly blank canvas. New Layout deliberately keeps
    // the baseboard so the user can re-plan within the same physical space.
    if (!ConfirmDiscard()) return;
    m_state.Clear();
    m_state.baseboard.clear();
    m_state.levels.clear();
    m_state.EnsureDefaultLevel();
    m_state.activeLevelId = 1;
    m_state.StopAllGradeRuns();
    m_currentFile.clear();
    m_dirty = false;
    SetTitle(TD_APP_FULL_TITLE);
    m_canvas->ResetView();
    m_canvas->Refresh();
    // Refresh right-panel tally + properties so they show the empty layout.
    OnSelectionChanged();
    SetStatusText("Ready", 0);
    SetStatusText("Pieces: 0", 1);
}

void MainFrame::OnPageSetup(wxCommandEvent&) {
    *m_pageSetupData = *m_printData;
    wxPageSetupDialog dlg(this, m_pageSetupData);
    if (dlg.ShowModal() == wxID_OK) {
        *m_pageSetupData = dlg.GetPageSetupDialogData();
        *m_printData     = m_pageSetupData->GetPrintData();
    }
}

void MainFrame::OnPrintPreview(wxCommandEvent&) {
    // Preview uses the last-used print options (or defaults on first run).
    // The Print Options dialog is reserved for the actual Print command --
    // preview is for browsing, Print is the commitment.
    wxPrintDialogData printDlgData(*m_printData);
    wxPrintPreview* preview = new wxPrintPreview(
        new LayoutPrintout("Track Designer Layout", &m_state, m_printOptions),
        new LayoutPrintout("Track Designer Layout", &m_state, m_printOptions),
        &printDlgData);

    if (!preview->IsOk()) {
        delete preview;
        wxMessageBox("Could not create print preview. "
                     "Is a printer driver installed?",
                     "Print Preview", wxOK | wxICON_ERROR, this);
        return;
    }

    wxPreviewFrame* frame = new wxPreviewFrame(
        preview, this, "Print Preview -- Track Designer",
        wxDefaultPosition, wxSize(900, 700));
    frame->Centre();
    frame->Initialize();
    frame->Show(true);
}

void MainFrame::OnPrint(wxCommandEvent&) {
    PrintOptionsDialog optDlg(this, m_printOptions);
    if (optDlg.ShowModal() != wxID_OK) return;
    m_printOptions = optDlg.GetOptions();

    wxPrintDialogData printDlgData(*m_printData);
    wxPrinter printer(&printDlgData);
    LayoutPrintout printout("Track Designer Layout", &m_state, m_printOptions);

    if (!printer.Print(this, &printout, /*prompt=*/true)) {
        if (wxPrinter::GetLastError() == wxPRINTER_ERROR) {
            wxMessageBox("There was a problem printing. "
                         "Check that a printer is installed and connected.",
                         "Print", wxOK | wxICON_ERROR, this);
        }
    } else {
        *m_printData = printer.GetPrintDialogData().GetPrintData();
    }
}


void MainFrame::OnOpen(wxCommandEvent&) {
    if (!ConfirmDiscard()) return;

    // Find all layout files in exe directory. ".tdl" is the native
    // extension; ".xml" is accepted for backward compatibility with files
    // saved by older versions.
    wxString dir = TD::UserDataDir();
    wxArrayString names;
    wxDir::GetAllFiles(dir, &names, "*.tdl", wxDIR_FILES);
    wxDir::GetAllFiles(dir, &names, "*.xml", wxDIR_FILES);

    if (names.IsEmpty()) {
        wxMessageBox("No layouts found in\n" + dir, "Open Layout", wxOK | wxICON_INFORMATION, this);
        return;
    }

    // Build display list -- just the stem name, no path, no extension
    wxArrayString stems;
    for (const auto& f : names) {
        wxFileName fn(f);
        stems.Add(fn.GetName());
    }
    stems.Sort();

    // Simple picker dialog
    wxDialog dlg(this, wxID_ANY, "Open Layout",
                 wxDefaultPosition, wxSize(320, 420),
                 wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
    wxBoxSizer* sz = new wxBoxSizer(wxVERTICAL);
    wxListBox* lb = new wxListBox(&dlg, wxID_ANY, wxDefaultPosition, wxDefaultSize,
                                  stems, wxLB_SINGLE | wxLB_SORT);
    sz->Add(lb, 1, wxEXPAND | wxALL, 8);
    wxBoxSizer* btn = new wxBoxSizer(wxHORIZONTAL);
    wxButton* del = new wxButton(&dlg, wxID_ANY, "Delete...");
    btn->Add(del, 0, wxLEFT, 8);
    btn->AddStretchSpacer();
    wxButton* ok  = new wxButton(&dlg, wxID_OK,     "Open");
    wxButton* can = new wxButton(&dlg, wxID_CANCEL, "Cancel");
    btn->Add(ok,  0, wxRIGHT, 4);
    btn->Add(can, 0, wxRIGHT, 8);
    sz->Add(btn, 0, wxEXPAND | wxBOTTOM, 8);
    dlg.SetSizer(sz);
    dlg.Centre();

    // Double-click opens immediately
    lb->Bind(wxEVT_LISTBOX_DCLICK, [&](wxCommandEvent&) { dlg.EndModal(wxID_OK); });
    ok->Enable(false);
    del->Enable(false);
    lb->Bind(wxEVT_LISTBOX, [&](wxCommandEvent&) {
        bool any = (lb->GetSelection() != wxNOT_FOUND);
        ok->Enable(any);
        del->Enable(any);
    });

    // Track which paths we've deleted so they don't get reopened. Also a
    // local mutable copy of `names` so we can refresh from the listbox.
    auto findFullPath = [&](const wxString& stem) -> wxString {
        for (const auto& f : names)
            if (wxFileName(f).GetName() == stem) return f;
        return wxString();
    };

    del->Bind(wxEVT_BUTTON, [&](wxCommandEvent&) {
        int s = lb->GetSelection();
        if (s == wxNOT_FOUND) return;
        wxString stem = lb->GetString(s);
        wxString path = findFullPath(stem);
        if (path.IsEmpty()) return;
        wxString msg;
        msg.Printf("Delete layout \"%s\"?\n\nThis cannot be undone.", stem);
        if (wxMessageBox(msg, "Delete Layout",
                         wxYES_NO | wxICON_WARNING | wxNO_DEFAULT, &dlg) != wxYES)
            return;
        if (!wxRemoveFile(path)) {
            wxMessageBox("Could not delete:\n" + path,
                         "Delete Failed", wxOK | wxICON_ERROR, &dlg);
            return;
        }
        // Remove from the listbox and from `names`.
        lb->Delete(s);
        for (int i = (int)names.GetCount() - 1; i >= 0; --i)
            if (names[i] == path) names.RemoveAt(i);
        ok->Enable(false);
        del->Enable(false);
        // If this was the currently-loaded layout, clear that reference
        // so a subsequent Save doesn't try to write to a deleted file.
        if (m_currentFile == path) {
            m_currentFile.Clear();
        }
    });

    if (dlg.ShowModal() != wxID_OK) return;
    int sel = lb->GetSelection();
    if (sel == wxNOT_FOUND) return;

    // Find matching full path
    wxString chosen = lb->GetString(sel);
    for (const auto& f : names) {
        if (wxFileName(f).GetName() == chosen) {
            LoadLayout(f);
            return;
        }
    }
}

void MainFrame::OnSave(wxCommandEvent&)   { SaveLayout(false); }
void MainFrame::OnSaveAs(wxCommandEvent&) { SaveLayout(true);  }

void MainFrame::OnImport(wxCommandEvent&) {
    // Import a layout file from outside the app directory (e.g. one a
    // customer downloaded via email or the Shopify store). The user picks
    // a source directory (default = system Downloads), then a file from
    // it, and a name to import it under. The file is copied to the app's
    // layout folder with extension .tdl.

    wxString downloads = wxStandardPaths::Get().GetUserDir(wxStandardPaths::Dir_Downloads);
    if (downloads.IsEmpty() || !wxFileName::DirExists(downloads))
        downloads = wxGetHomeDir();

    wxDialog dlg(this, wxID_ANY, "Import Layout",
                 wxDefaultPosition, wxSize(440, 480),
                 wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
    wxBoxSizer* sz = new wxBoxSizer(wxVERTICAL);

    // Source directory row
    wxBoxSizer* dirRow = new wxBoxSizer(wxHORIZONTAL);
    dirRow->Add(new wxStaticText(&dlg, wxID_ANY, "Source folder:"),
                0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
    wxTextCtrl* txtDir = new wxTextCtrl(&dlg, wxID_ANY, downloads);
    dirRow->Add(txtDir, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
    wxButton* btnBrowse = new wxButton(&dlg, wxID_ANY, "Browse...",
                                       wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT);
    dirRow->Add(btnBrowse, 0, wxALIGN_CENTER_VERTICAL);
    sz->Add(dirRow, 0, wxEXPAND | wxALL, 8);

    // File list
    sz->Add(new wxStaticText(&dlg, wxID_ANY, "Layout files in this folder:"),
            0, wxLEFT | wxRIGHT, 8);
    wxListBox* lb = new wxListBox(&dlg, wxID_ANY, wxDefaultPosition, wxDefaultSize,
                                  0, nullptr, wxLB_SINGLE | wxLB_SORT);
    sz->Add(lb, 1, wxEXPAND | wxALL, 8);

    // Imported-as name
    wxBoxSizer* nameRow = new wxBoxSizer(wxHORIZONTAL);
    nameRow->Add(new wxStaticText(&dlg, wxID_ANY, "Import as:"),
                 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
    wxTextCtrl* txtName = new wxTextCtrl(&dlg, wxID_ANY, "");
    nameRow->Add(txtName, 1, wxALIGN_CENTER_VERTICAL);
    sz->Add(nameRow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8);

    // Buttons
    wxBoxSizer* btnRow = new wxBoxSizer(wxHORIZONTAL);
    btnRow->AddStretchSpacer();
    wxButton* ok  = new wxButton(&dlg, wxID_OK,     "Import");
    wxButton* can = new wxButton(&dlg, wxID_CANCEL, "Cancel");
    btnRow->Add(ok,  0, wxRIGHT, 4);
    btnRow->Add(can, 0, wxRIGHT, 8);
    sz->Add(btnRow, 0, wxEXPAND | wxBOTTOM, 8);
    dlg.SetSizer(sz);
    dlg.Centre();
    ok->Enable(false);

    // Helper: refresh file list for the current directory.
    wxArrayString currentFiles;  // full paths
    auto refreshList = [&]() {
        lb->Clear();
        currentFiles.Clear();
        wxString d = txtDir->GetValue().Trim().Trim(false);
        if (d.IsEmpty() || !wxFileName::DirExists(d)) return;
        wxArrayString found;
        wxDir::GetAllFiles(d, &found, "*.tdl", wxDIR_FILES);
        wxDir::GetAllFiles(d, &found, "*.xml", wxDIR_FILES);
        for (const auto& f : found) {
            currentFiles.Add(f);
            lb->Append(wxFileName(f).GetFullName());
        }
    };
    refreshList();

    btnBrowse->Bind(wxEVT_BUTTON, [&](wxCommandEvent&) {
        wxDirDialog dd(&dlg, "Choose folder containing layout file",
                       txtDir->GetValue());
        if (dd.ShowModal() == wxID_OK) {
            txtDir->SetValue(dd.GetPath());
            refreshList();
        }
    });
    txtDir->Bind(wxEVT_TEXT, [&](wxCommandEvent&) { refreshList(); });
    lb->Bind(wxEVT_LISTBOX, [&](wxCommandEvent&) {
        int s = lb->GetSelection();
        if (s != wxNOT_FOUND) {
            // Pre-fill "Import as" from the filename stem. We previously
            // tried peeking the internal <layout name="..."> via Load(),
            // but that produced wrong/stale suggestions on some files
            // (likely older .xml exports with a different internal
            // structure) -- simple and predictable beats clever here.
            wxFileName fn(currentFiles[s]);
            txtName->SetValue(fn.GetName());
        }
        ok->Enable(s != wxNOT_FOUND && !txtName->GetValue().IsEmpty());
    });
    txtName->Bind(wxEVT_TEXT, [&](wxCommandEvent&) {
        ok->Enable(lb->GetSelection() != wxNOT_FOUND && !txtName->GetValue().IsEmpty());
    });
    lb->Bind(wxEVT_LISTBOX_DCLICK, [&](wxCommandEvent&) {
        if (ok->IsEnabled()) dlg.EndModal(wxID_OK);
    });

    if (dlg.ShowModal() != wxID_OK) return;

    int sel = lb->GetSelection();
    if (sel == wxNOT_FOUND) return;
    wxString srcPath = currentFiles[sel];
    wxString name    = txtName->GetValue().Trim().Trim(false);
    if (name.IsEmpty()) return;

    // Compute destination path (exe folder, .tdl extension). Confirm
    // overwrite if a layout of that name already exists.
    wxString destPath = TD::UserDataDir() + wxFILE_SEP_PATH + name + ".tdl";
    if (wxFileName::FileExists(destPath)) {
        wxString msg;
        msg.Printf("A layout named \"%s\" already exists.\n\nReplace it?", name);
        if (wxMessageBox(msg, "Confirm Replace",
                         wxYES_NO | wxICON_QUESTION | wxNO_DEFAULT, this) != wxYES)
            return;
    }

    // Validate that the source file actually looks like a Track Designer
    // layout by trying to parse it. If it parses, copy it to the dest with
    // .tdl extension.
    {
        MC::CanvasState testState;
        std::string testName;
        std::string err = TD::LayoutXml::Load(testState, srcPath.ToStdString(), testName);
        if (!err.empty()) {
            wxMessageBox("This file does not appear to be a Track Designer "
                         "layout:\n\n" + wxString::FromUTF8(err),
                         "Import Failed", wxOK | wxICON_ERROR, this);
            return;
        }
    }

    bool sameFile = wxFileName(srcPath).GetFullPath() == wxFileName(destPath).GetFullPath();
    if (!sameFile) {
        // Source and destination differ -- copy normally.
        if (!wxCopyFile(srcPath, destPath, /*overwrite=*/true)) {
            wxMessageBox("Could not copy the file to:\n" + destPath,
                         "Import Failed", wxOK | wxICON_ERROR, this);
            return;
        }
    }
    // If srcPath and destPath are the same file (importing a layout that's
    // already in the app folder under the same name), skip the copy --
    // copying a file onto itself risks truncating it -- and just re-save
    // it in place with the corrected internal name below.

    // The file still has the original layout's internal name= attribute
    // baked into its XML. Load it and immediately re-save with the name
    // the user chose in this dialog, so the title bar and layout list
    // reflect "Import as" rather than the source file's original name.
    {
        MC::CanvasState importState;
        std::string discardedName;
        std::string err = TD::LayoutXml::Load(importState, destPath.ToStdString(), discardedName);
        if (err.empty()) {
            TD::LayoutXml::Save(importState, destPath.ToStdString(), name.ToStdString());
        }
    }

    // Offer to open the newly imported layout.
    if (wxMessageBox("Imported successfully.\n\nOpen \"" + name + "\" now?",
                     "Import", wxYES_NO | wxICON_QUESTION, this) == wxYES)
    {
        if (ConfirmDiscard()) LoadLayout(destPath);
    }
}

void MainFrame::OnExport(wxCommandEvent&) {
    // Export a layout from the app's library to a folder the user can
    // share from (email attachment, USB stick, etc.). Default destination
    // is the system Downloads folder. The export is always written as
    // ".tdl", regardless of whether the source was .tdl or older .xml.

    // Enumerate layouts in the app's library directory.
    wxString libDir = TD::UserDataDir();
    wxArrayString libFiles;
    wxDir::GetAllFiles(libDir, &libFiles, "*.tdl", wxDIR_FILES);
    wxDir::GetAllFiles(libDir, &libFiles, "*.xml", wxDIR_FILES);
    if (libFiles.IsEmpty()) {
        wxMessageBox("No layouts to export. Save a layout first.",
                     "Export Layout", wxOK | wxICON_INFORMATION, this);
        return;
    }

    wxString downloads = wxStandardPaths::Get().GetUserDir(wxStandardPaths::Dir_Downloads);
    if (downloads.IsEmpty() || !wxFileName::DirExists(downloads))
        downloads = wxGetHomeDir();

    wxDialog dlg(this, wxID_ANY, "Export Layout",
                 wxDefaultPosition, wxSize(440, 480),
                 wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
    wxBoxSizer* sz = new wxBoxSizer(wxVERTICAL);

    // Layout picker
    sz->Add(new wxStaticText(&dlg, wxID_ANY, "Choose a layout to export:"),
            0, wxLEFT | wxRIGHT | wxTOP, 8);
    wxArrayString stems;
    for (const auto& f : libFiles) stems.Add(wxFileName(f).GetName());
    wxListBox* lb = new wxListBox(&dlg, wxID_ANY, wxDefaultPosition, wxDefaultSize,
                                  stems, wxLB_SINGLE | wxLB_SORT);
    sz->Add(lb, 1, wxEXPAND | wxALL, 8);
    // Preselect the current layout if it exists.
    if (!m_layoutName.empty()) {
        int idx = lb->FindString(m_layoutName);
        if (idx != wxNOT_FOUND) lb->SetSelection(idx);
    }

    // Destination directory
    wxBoxSizer* dirRow = new wxBoxSizer(wxHORIZONTAL);
    dirRow->Add(new wxStaticText(&dlg, wxID_ANY, "Destination:"),
                0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
    wxTextCtrl* txtDir = new wxTextCtrl(&dlg, wxID_ANY, downloads);
    dirRow->Add(txtDir, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
    wxButton* btnBrowse = new wxButton(&dlg, wxID_ANY, "Browse...",
                                       wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT);
    dirRow->Add(btnBrowse, 0, wxALIGN_CENTER_VERTICAL);
    sz->Add(dirRow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8);

    // Export-as name (no extension; .tdl is appended automatically)
    wxBoxSizer* nameRow = new wxBoxSizer(wxHORIZONTAL);
    nameRow->Add(new wxStaticText(&dlg, wxID_ANY, "Save as (no extension):"),
                 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
    wxTextCtrl* txtName = new wxTextCtrl(&dlg, wxID_ANY, "");
    nameRow->Add(txtName, 1, wxALIGN_CENTER_VERTICAL);
    sz->Add(nameRow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8);

    // Buttons
    wxBoxSizer* btnRow = new wxBoxSizer(wxHORIZONTAL);
    btnRow->AddStretchSpacer();
    wxButton* ok  = new wxButton(&dlg, wxID_OK,     "Export");
    wxButton* can = new wxButton(&dlg, wxID_CANCEL, "Cancel");
    btnRow->Add(ok,  0, wxRIGHT, 4);
    btnRow->Add(can, 0, wxRIGHT, 8);
    sz->Add(btnRow, 0, wxEXPAND | wxBOTTOM, 8);
    dlg.SetSizer(sz);
    dlg.Centre();

    auto updateOk = [&]() {
        ok->Enable(lb->GetSelection() != wxNOT_FOUND &&
                   !txtName->GetValue().Trim().IsEmpty());
    };

    // Auto-fill the destination name from the selected source name.
    lb->Bind(wxEVT_LISTBOX, [&](wxCommandEvent&) {
        int s = lb->GetSelection();
        if (s != wxNOT_FOUND)
            txtName->SetValue(lb->GetString(s));
        updateOk();
    });
    txtName->Bind(wxEVT_TEXT, [&](wxCommandEvent&) { updateOk(); });
    btnBrowse->Bind(wxEVT_BUTTON, [&](wxCommandEvent&) {
        wxDirDialog dd(&dlg, "Choose destination folder",
                       txtDir->GetValue());
        if (dd.ShowModal() == wxID_OK) txtDir->SetValue(dd.GetPath());
    });
    lb->Bind(wxEVT_LISTBOX_DCLICK, [&](wxCommandEvent&) {
        if (ok->IsEnabled()) dlg.EndModal(wxID_OK);
    });

    // Trigger an initial selection event so name field auto-fills.
    if (lb->GetSelection() != wxNOT_FOUND) {
        txtName->SetValue(lb->GetString(lb->GetSelection()));
    }
    updateOk();

    if (dlg.ShowModal() != wxID_OK) return;

    int sel = lb->GetSelection();
    if (sel == wxNOT_FOUND) return;
    wxString chosenStem = lb->GetString(sel);
    // Find the matching full path in libFiles.
    wxString srcPath;
    for (const auto& f : libFiles)
        if (wxFileName(f).GetName() == chosenStem) { srcPath = f; break; }
    if (srcPath.IsEmpty()) return;

    wxString destDir  = txtDir->GetValue().Trim().Trim(false);
    wxString destName = txtName->GetValue().Trim().Trim(false);
    if (destDir.IsEmpty() || destName.IsEmpty()) return;
    if (!wxFileName::DirExists(destDir)) {
        wxMessageBox("Destination folder does not exist:\n" + destDir,
                     "Export Failed", wxOK | wxICON_ERROR, this);
        return;
    }
    // Strip any user-supplied extension; we always write .tdl.
    wxFileName destFn(destName);
    if (destFn.HasExt()) destFn.ClearExt();
    wxString destPath = destDir + wxFILE_SEP_PATH + destFn.GetFullName() + ".tdl";

    if (wxFileName::FileExists(destPath)) {
        wxString msg;
        msg.Printf("\"%s\" already exists in that folder.\n\nReplace it?",
                   destFn.GetFullName() + ".tdl");
        if (wxMessageBox(msg, "Confirm Replace",
                         wxYES_NO | wxICON_QUESTION | wxNO_DEFAULT, this) != wxYES)
            return;
    }

    if (!wxCopyFile(srcPath, destPath, /*overwrite=*/true)) {
        wxMessageBox("Could not copy the file to:\n" + destPath,
                     "Export Failed", wxOK | wxICON_ERROR, this);
        return;
    }

    wxMessageBox("Exported to:\n" + destPath, "Export",
                 wxOK | wxICON_INFORMATION, this);
}
void MainFrame::OnSaveBlock(wxCommandEvent&) { SaveBlock(); }

void MainFrame::OnUndo(wxCommandEvent&) {
    m_state.Undo(); m_canvas->Refresh(); MarkDirty();
}
void MainFrame::OnRedo(wxCommandEvent&) {
    m_state.Redo(); m_canvas->Refresh(); MarkDirty();
}
void MainFrame::OnDelete(wxCommandEvent&) {
    if (!WarnIfTunneled(this, m_state, "delete")) return;
    if (!ConfirmGradedDeletion(this, m_state)) return;
    m_state.DeleteSelected(); m_canvas->Refresh();
    OnSelectionChanged(); MarkDirty();
}
void MainFrame::OnSelectAll(wxCommandEvent&) {
    m_state.SelectAll(); m_canvas->Refresh(); OnSelectionChanged();
}
void MainFrame::OnZoomIn(wxCommandEvent&)  { m_canvas->ZoomBy(1.25); }
void MainFrame::OnZoomOut(wxCommandEvent&) { m_canvas->ZoomBy(0.80); }
void MainFrame::OnZoomFit(wxCommandEvent&) { m_canvas->ZoomFit();    }

void MainFrame::OnGridToggle(wxCommandEvent& e)  { m_canvas->SetShowGrid(e.IsChecked()); }
void MainFrame::OnSnapToggle(wxCommandEvent& e)  { m_state.snapEnabled = e.IsChecked(); }
void MainFrame::OnRailToggle(wxCommandEvent& e)  {
    m_state.showRails = e.IsChecked();
    m_canvas->Refresh();
}
void MainFrame::OnLabelToggle(wxCommandEvent& e) {
    m_state.showLabels = !e.IsChecked();
    wxToolBar* tb = GetToolBar();
    if (tb) {
        wxToolBarToolBase* tool = tb->FindById(ID_LabelToggle);
        if (tool) tool->SetLabel(m_state.showLabels ? "Hide Labels" : "Show Labels");
        tb->Realize();
    }
    m_canvas->Refresh();
}

void MainFrame::OnUnitChange(wxCommandEvent& e) {
    if      (e.GetId() == ID_UnitMm)   m_state.units = 0;
    else if (e.GetId() == ID_UnitCm)   m_state.units = 1;
    else if (e.GetId() == ID_UnitInch) m_state.units = 2;
    m_canvas->Refresh();
    if (m_properties) m_properties->RefreshProperties();
}

void MainFrame::OnLevels(wxCommandEvent&) {
    m_state.EnsureDefaultLevel();
    LevelManagerDialog dlg(this, m_state);
    dlg.ShowModal();
    m_canvas->Refresh();
    if (m_view3d) m_view3d->RefreshLayout(m_state);
    MarkDirty();
}

void MainFrame::OnLayoutShape(wxCommandEvent&) {
    LayoutShapeDialog dlg(this, m_state.baseboard, m_state.units, &m_state);
    if (dlg.ShowModal() == wxID_OK) {
        if (dlg.DrawAfterClose()) {
            // User picked "Free-form (draw on canvas)" — switch canvas
            // into BaseboardDraw mode. The existing baseboard stays put
            // until the user commits a new polygon (or cancels via Esc).
            m_state.toolMode = MC::ToolMode::BaseboardDraw;
            if (m_canvas) {
                m_canvas->SetCursor(wxCursor(wxCURSOR_CROSS));
                m_canvas->Refresh();
                m_canvas->SetFocus();
            }
            SetStatusText("Click vertices to outline the baseboard. "
                          "Click first vertex (or press Enter) to close. "
                          "Backspace removes last vertex; Escape cancels.");
            return;
        }
        m_state.baseboard = dlg.GetPolygon();
        MarkDirty();
        m_canvas->Refresh();
    }
}

void MainFrame::OnValidate(wxCommandEvent&) {
    wxMessageBox("Validation not yet implemented.", "Validate", wxICON_INFORMATION | wxOK);
}
void MainFrame::ShowExperienceDialog() {
    wxDialog dlg(this, wxID_ANY, "Choose Your Experience",
                 wxDefaultPosition, wxSize(420, 200),
                 wxDEFAULT_DIALOG_STYLE);
    dlg.Centre();
    wxBoxSizer* sz = new wxBoxSizer(wxVERTICAL);

    wxStaticText* hdr = new wxStaticText(&dlg, wxID_ANY,
        "How would you like Track Designer to greet you?",
        wxDefaultPosition, wxDefaultSize, wxALIGN_CENTRE_HORIZONTAL);
    wxFont hf = hdr->GetFont();
    hf.SetPointSize(hf.GetPointSize() + 1);
    hf.SetWeight(wxFONTWEIGHT_BOLD);
    hdr->SetFont(hf);
    hdr->Wrap(380);
    sz->Add(hdr, 0, wxALIGN_CENTER | wxALL, 16);

    wxBoxSizer* btnRow = new wxBoxSizer(wxHORIZONTAL);
    btnRow->AddStretchSpacer();

    wxButton* btnPlayful = new wxButton(&dlg, wxID_YES, "Keep it Playful");
    wxButton* btnBiz     = new wxButton(&dlg, wxID_NO,  "Down to Business");
    wxFont bf = btnPlayful->GetFont();
    bf.SetPointSize(bf.GetPointSize() + 1);
    btnPlayful->SetFont(bf);
    btnBiz->SetFont(bf);
    btnPlayful->SetMinSize(wxSize(160, 38));
    btnBiz->SetMinSize(wxSize(160, 38));

    btnRow->Add(btnPlayful, 0, wxALL, 8);
    btnRow->Add(btnBiz,     0, wxALL, 8);
    btnRow->AddStretchSpacer();
    sz->Add(btnRow, 0, wxEXPAND);

    dlg.SetSizerAndFit(sz);

    btnPlayful->Bind(wxEVT_BUTTON, [&](wxCommandEvent&){ dlg.EndModal(wxID_YES); });
    btnBiz->Bind(wxEVT_BUTTON,     [&](wxCommandEvent&){ dlg.EndModal(wxID_NO);  });

    int res = dlg.ShowModal();
    Prefs::Get().SetPlayful(res == wxID_YES);
    Prefs::Get().Save();
}

void MainFrame::OnOptions(wxCommandEvent&) {
    ShowExperienceDialog();
}

void MainFrame::OnHelp(wxCommandEvent&) {
    wxDialog dlg(this, wxID_ANY, TD_APP_NAME " v" TD_APP_VERSION " Help",
                 wxDefaultPosition, wxSize(925, 700),
                 wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
    dlg.Centre(wxBOTH);

    wxBoxSizer* main = new wxBoxSizer(wxHORIZONTAL);

    // Left: help image -- loaded from embedded resource (playful mode only)
    if (Prefs::Get().IsPlayful()) {
        wxImage img = LoadImageResource(RES_HELP);
        if (img.IsOk()) {
            img = img.Scale(368, 660, wxIMAGE_QUALITY_HIGH);
            wxStaticBitmap* bmp = new wxStaticBitmap(&dlg, wxID_ANY, wxBitmap(img));
            main->Add(bmp, 0, wxALL, 8);
        }
    }

    // Right: scrollable help text
    wxBoxSizer* right = new wxBoxSizer(wxVERTICAL);
    wxScrolledWindow* scroll = new wxScrolledWindow(&dlg, wxID_ANY,
        wxDefaultPosition, wxDefaultSize, wxVSCROLL | wxBORDER_NONE);
    scroll->SetBackgroundColour(dlg.GetBackgroundColour());
    scroll->SetScrollRate(0, 12);

    wxBoxSizer* scrollSizer = new wxBoxSizer(wxVERTICAL);
    wxFont hf(11, wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD);
    wxFont nf(10, wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL);

    auto H = [&](const wxString& s) {
        wxStaticText* t = new wxStaticText(scroll, wxID_ANY, s);
        t->SetFont(hf); scrollSizer->Add(t, 0, wxLEFT | wxTOP, 8);
    };
    auto P = [&](const wxString& s) {
        wxStaticText* t = new wxStaticText(scroll, wxID_ANY, s,
            wxDefaultPosition, wxDefaultSize, wxST_NO_AUTORESIZE);
        t->SetFont(nf); t->Wrap(490);
        scrollSizer->Add(t, 0, wxLEFT | wxRIGHT | wxBOTTOM, 8);
    };

    auto L = [&](const wxString& label, const wxString& url) {
        wxHyperlinkCtrl* lnk = new wxHyperlinkCtrl(scroll, wxID_ANY, label, url);
        lnk->SetFont(nf);
        scrollSizer->Add(lnk, 0, wxLEFT | wxRIGHT | wxBOTTOM, 8);
    };

    H("Welcome to " TD_APP_NAME " v" TD_APP_VERSION);
    P("Track Designer by");
    L("Euro Model Trains", "https://www.euromodeltrains.com");
    P("lets you design and plan your Marklin and TRIX "
      "model railway layouts. Supported systems: Marklin and TRIX HO C-Track, Marklin HO "
      "K-Track, Marklin M-Track and Marklin Z Track.");

    H("Getting Started");
    P("1. Select a track system from the palette dropdown on the left.\n"
      "2. Set your preferred units of measure via Layout > Units of Measure.\n"
      "3. Optionally, set your layout shape and size via Layout > Layout Shape.\n"
      "4. Drag a piece onto the canvas to place it.\n"
      "5. Drag subsequent pieces near existing ones - they snap together automatically.\n"
      "6. Double-click a piece on the canvas to clone it and extend the track.\n"
      "7. Double-click a piece in the palette while a canvas piece is selected to auto-connect it.\n"
      "8. Use File > Save to save your layout.");

    H("Canvas Navigation");
    P("Pan: middle-click and drag.\n"
      "Zoom: scroll the mouse wheel.\n"
      "Zoom to fit: Ctrl+0 or click Fit in the toolbar.\n"
      "Rubber band select: left-click and drag around track on canvas.");

    H("Track Selection");
    P("Left-click a piece to select it. Press Ctrl+A to select all. Press Escape to deselect.\n\n"
      "Selecting a chain: click the first piece, then Shift+click another piece to select "
      "every piece in the chain between them.\n\n"
      "Selecting several pieces (no chain expansion): click the first piece, then Ctrl+click "
      "subsequent pieces.\n\n"
      "Rubber band select: left-click on empty canvas, drag to surround pieces, release.");

    H("Autofill Gap");
    P("Autofill closes the space between two open track endpoints by figuring out which "
      "pieces fit. Useful for finishing loops and yards without trial and error.\n\n"
      "Autofill now works with curves as well.\n\n"
      "How to use it:\n"
      "1. Click the first piece on one side of the gap.\n"
      "2. Ctrl+click the second piece on the other side. Do NOT use Shift+click -- that "
      "selects the entire chain between the two pieces, and Autofill needs exactly two "
      "pieces selected.\n"
      "3. Right-click either selected piece. The context menu shows 'Autofill Gap...' "
      "only when exactly two pieces are selected.\n"
      "4. Pick a solution from the dialog. The pieces are added with their joints "
      "already connected.\n\n"
      "If no solution exists, Autofill will tell you so. Try different starting pieces, "
      "or adjust the spacing manually.");

    H("Library");
    P("The Library tab in the palette lets you borrow pre-built track sections. "
      "Double-click a library entry to place the entire section on the canvas at once. "
      "To save your own sections to the library: select the pieces you want, then use "
      "File > Save as Library Block. Your saved blocks will appear in the library for reuse.");

    H("Moving Pieces");
    P("Click and drag a selected piece to move it. Connected pieces move together "
      "when all their neighbours are also selected. Use Ctrl+A then drag to reposition "
      "the entire layout.");

    H("Dragging from the Palette");
    P("While a piece is being dragged from the palette onto the canvas, scroll the mouse "
      "wheel to rotate it in 30 degree steps (15 degree with Shift) before dropping. "
      "Press R during drag to rotate by 30 degrees from the keyboard. Press Escape to "
      "cancel the drag.");

    H("Rotating Pieces");
    P("With pieces selected, hold Ctrl and scroll the mouse wheel to "
      "rotate the selection in 15 degree steps (5 degree with Shift "
      "also held). The whole selection rotates as a rigid block around "
      "its centroid; each piece's heading spins in place and its "
      "position orbits the centroid. Plain mouse wheel without Ctrl "
      "always zooms.\n\n"
      "Alternatively, hold Alt and drag left/right to freely rotate. "
      "Pieces snap to 0, 90, 180 and 270 degrees (shown with a green "
      "flash). Hold Alt and right-click to rotate in 15 degree "
      "increments.");

    H("Elevation and Grades");
    P("Click the Elevation button in the toolbar to open the Elevation Wizard. "
      "It offers two ways to add elevation to your track:\n\n"
      "Set Grade: click Next, then click the piece where the grade should start. "
      "If the piece has two free connectors, the app uses the one closest to "
      "where you clicked. Choose UP or DOWN and a grade angle, then click "
      "Start Grade. Every new piece you snap on automatically continues the "
      "elevation. When you are done, right-click the last graded piece and "
      "choose End Grade from the context menu.\n\n"
      "Set Target Height: click Next, then click the starting piece. A yellow "
      "connector dot and direction arrow appear. Move the mouse to draw a "
      "dotted line along the track direction to your target location, then "
      "click. Enter the desired height in the dialog that follows. The app "
      "calculates the required grade, finds the right catalog pieces to fill "
      "the distance, and places them all at the correct elevation.\n\n"
      "Arch and truss bridges automatically end a grade chain and sit flat. "
      "Press Escape at any time to cancel the wizard.");

    H("3D View");
    P("Click '3D View' in the toolbar to open a 3D preview. "
      "Left-drag to orbit, right-drag to pan, scroll to zoom. "
      "Elevated track is shown at the correct height with grade tilts. "
      "Bridge supports are generated automatically based on piece elevation.");

    H("Layout Shape");
    P("Use Layout > Layout Shape to define the baseboard outline - the "
      "physical table the layout sits on. Choose from rectangle, "
      "L-shape, U-shape, T-shape, a custom polygon entered by typing "
      "vertex coordinates, or 'Free-form (draw on canvas)' to click "
      "vertices directly. The baseboard is drawn beneath the grid with "
      "its edge dimensions labelled, and is independent of per-level "
      "decks (see Levels).\n\n"
      "Free-form draw: pick the option and click OK to dismiss the "
      "dialog. Click vertices on the canvas; close by clicking near the "
      "first vertex or pressing Enter. Backspace removes the last "
      "vertex; Escape cancels.");

    H("Polygon and Rectangle Shapes");
    P("The Polygon button in the toolbar lets you draw free-form shapes "
      "anywhere on the canvas - useful for room outlines, mountains, rivers, "
      "access areas, or any custom region. Click vertices in sequence; close "
      "by clicking near the first vertex or pressing Enter. Backspace removes "
      "the last vertex; Escape cancels.\n\n"
      "The Rectangle button (next to Polygon) places a precise rectangle by "
      "entering exact dimensions. Choose your size, click OK, then click on "
      "the canvas to place it.\n\n"
      "Each completed polygon shows the length of every edge directly on the "
      "shape, formatted in your current units (mm, cm, or inches). These "
      "update automatically if you move a vertex.\n\n"
      "Right-click a polygon to Copy it; right-click empty canvas to Paste. "
      "The copy lands centered on where you right-clicked, ready to move.\n\n"
      "Single-click a placed polygon to select it; double-click to edit its "
      "name, fill color, opacity, height, and outline. Shapes with height "
      "extrude as solid blocks in 3D. When a polygon contains a tunnel, its "
      "3D rendering switches to wireframe so the tunnel remains visible.");

    H("Files (.tdl)");
    P("Track Designer saves layouts with the .tdl extension in "
      "C:\\Users\\...\\Documents\\TrackDesigner. "
      "Use File > Save to pick a layout name, File > Open lists all .tdl "
      "(and older .xml) layouts found there.\n\n"
      "File > Import Layout brings in a layout file from another folder "
      "(such as a download); it asks for the source folder and an "
      "import-as name, then copies the file into the app's library. "
      "File > Export Layout copies an existing layout out to a folder "
      "you choose (default Downloads), with a .tdl extension applied "
      "automatically. Useful for sharing layouts by email or USB.");

    H("Levels");
    P("Click the Levels button in the toolbar to open the Manage Levels dialog. "
      "Each level has a name, a height off the ground, optional track color, "
      "and a visibility checkbox. Double-click a row (or use the Properties button) "
      "to edit a level's properties.\n\n"
      "Track and scenery on a level inherit the level's height in 3D and its track "
      "color in 2D (a piece's own custom color, if set, still wins). Hiding a level "
      "hides its track and scenery on the canvas, and they become non-selectable "
      "until the level is shown again.\n\n"
      "When you drop a new piece on empty canvas it lands on the highest currently "
      "visible level; when you join to an existing piece, the new piece inherits "
      "that piece's level.");

    H("Measuring Tape");
    P("Use the Measure button in the toolbar (next to Tunnel) to lay down a "
      "length annotation on the canvas. Clicking it turns the cursor into a "
      "pencil; the first click on the canvas marks the start of the "
      "measurement and the second click commits it, leaving a line with "
      "arrows at both ends and a length label fused at the centre.\n\n"
      "Click anywhere on a measurement (line or label) to select it; the "
      "selection highlights blue. Drag a selected measurement to move it "
      "(label and line move together as one unit). Press Del with it "
      "selected to remove it.\n\n"
      "Escape during placement cancels. Multiple measurements may coexist "
      "on the canvas at once. The length is shown in the active units.");

    H("Adding Labels");
    P("Type your label text in the Add Label field in the right panel and "
      "click OK (or press Enter) to place it. By default the label is "
      "placed at the world origin; left-click on an empty area of the "
      "canvas first to drop it at that point instead. Once placed, labels "
      "can be moved by dragging and deleted with the Del key.");

    H("Piece Tally");
    P("The right panel automatically tallies all pieces in your layout. "
      "Bridge pieces also count their included deck track pieces. "
      "Click 'Buy Track' to open the");
    L("Euro Model Trains", "https://www.euromodeltrains.com");
    P("website in your browser where a shopping cart with your required track "
      "will be waiting for you to review and check out.");

    H("Coloring Track");
    P("Select the piece(s) you want to color, then use the Color dropdown in the right panel "
      "and click Apply. Colors help distinguish routes, levels, or sections of track. "
      "Selecting multiple pieces and applying a color colors them all at once.");

    H("Experience Settings");
    P("Help > Options lets you choose between two modes:\n\n"
      "Keep it Playful - the full Track Designer experience with images "
      "in the splash screen, help dialog, and Buy Track panel.\n\n"
      "Down to Business - a clean, image-free interface. The splash screen "
      "is skipped entirely and all decorative images are hidden. "
      "Your choice is saved to prefs.cfg next to the application and "
      "can be changed at any time via Help > Options.");

    H("Keyboard Shortcuts");
    P("Ctrl+Z / Ctrl+Y  - Undo / Redo\n"
      "Del              - Delete selected\n"
      "Ctrl+A           - Select all\n"
      "Ctrl+C / Ctrl+V  - Copy / Paste selection (track, scenery, polygons)\n"
      "Ctrl+S / O / N   - Save / Open / New\n"
      "Ctrl+0           - Zoom to fit\n"
      "+ / -            - Zoom in / out\n"
      "Middle-drag      - Pan canvas\n"
      "Left-drag        - Rubber band select (on empty area)\n"
      "Shift+click      - Range-select chain between two pieces\n"
      "Mouse wheel      - Zoom in / out\n"
      "Ctrl+wheel       - Rotate selection by 15 deg (Shift: 5 deg)\n"
      "Wheel (palette)  - Rotate piece being dragged (Shift: 15 deg)\n"
      "R (palette)      - Rotate piece being dragged by 30 deg\n"
      "Right-click      - Context menu (includes polygon Copy/Paste)\n"
      "Alt+drag         - Free rotate\n"
      "Alt+right-click  - Rotate 15 degrees\n"
      "Escape           - Deselect / cancel drag / cancel measurement\n"
      "F1               - This help screen");

    scroll->SetSizer(scrollSizer);
    scroll->FitInside();

    right->Add(scroll, 1, wxEXPAND | wxALL, 4);

    wxButton* ok = new wxButton(&dlg, wxID_OK, "Close");
    right->Add(ok, 0, wxALIGN_RIGHT | wxRIGHT | wxBOTTOM, 8);
    main->Add(right, 1, wxEXPAND);

    dlg.SetSizer(main);
    dlg.ShowModal();
}

void MainFrame::OnLayoutProperties(wxCommandEvent&) {
    wxMessageBox("Layout properties not yet implemented.", "Properties", wxICON_INFORMATION | wxOK);
}

void MainFrame::OnView3D(wxCommandEvent&) {
    if (m_view3d) {
        m_view3d->RefreshLayout(m_state);
        m_view3d->Raise();
        return;
    }
    m_view3d = new View3DFrame(this, m_state);
    m_view3d->Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent& e) {
        m_view3d = nullptr;
        e.Skip();
    });
}

void MainFrame::OnExit(wxCommandEvent&) {
    Close();
}

void MainFrame::OnClose(wxCloseEvent& e) {
    if (e.CanVeto() && !ConfirmDiscard()) { e.Veto(); return; }
    Destroy();
}

// Forward declaration of the app-wide logger (defined in App.cpp).
extern void TDLog(const wxString& msg);

// ── Tunnel placement / removal ────────────────────────────────────────────────
namespace {

// Helper return: outcome of analyzing the current selection.
struct TunnelAnalysis {
    bool enabled    = false;   // tool should be enabled
    bool removeMode = false;   // selection matches an existing tunnel -> remove
    int  tunnelId   = -1;      // when removeMode, the tunnel to remove
    int  endpointA  = -1;      // for create mode: chain endpoint pieces
    int  endpointB  = -1;
    int  openConnA  = -1;      // outward conn index on endpoint A
    int  openConnB  = -1;
    std::vector<int> chainPieceIds;  // for create mode: the contiguous chain
};

static TunnelAnalysis AnalyzeSelection(const MC::CanvasState& state) {
    TunnelAnalysis out;

    // Gather selected pieces
    std::vector<const MC::PlacedPiece*> selected;
    for (const auto& p : state.pieces)
        if (p.selected) selected.push_back(&p);
    if (selected.size() < 2) return out;

    std::unordered_set<int> selSet;
    for (auto* p : selected) selSet.insert(p->id);

    // Verify contiguity: BFS through joints, visiting only selected pieces.
    std::unordered_map<int, const MC::PlacedPiece*> byId;
    for (auto* p : selected) byId[p->id] = p;
    std::unordered_set<int> visited;
    std::queue<int> q;
    q.push(selected[0]->id);
    visited.insert(selected[0]->id);
    while (!q.empty()) {
        int cur = q.front(); q.pop();
        auto it = byId.find(cur);
        if (it == byId.end()) continue;
        for (const auto& j : it->second->joints) {
            if (j.pieceId == -1) continue;
            if (!selSet.count(j.pieceId)) continue;
            if (visited.insert(j.pieceId).second) q.push(j.pieceId);
        }
    }
    if (visited.size() != selSet.size()) return out;   // not contiguous

    // Match against existing tunnels: if the selection equals an existing
    // tunnel's covered pieces, we are in remove mode.
    for (const auto& t : state.tunnels) {
        if (t.coveredPieceIds.size() != selSet.size()) continue;
        bool match = true;
        for (int id : t.coveredPieceIds) if (!selSet.count(id)) { match = false; break; }
        if (match) {
            out.enabled    = true;
            out.removeMode = true;
            out.tunnelId   = t.id;
            return out;
        }
    }

    // Refuse if ANY selected piece is already in a tunnel (would create an
    // overlapping or nested tunnel, which we don't support).
    for (int id : selSet) {
        if (state.IsPieceInTunnel(id)) return out;
    }

    // Find endpoint pieces (those with <=1 selected neighbor) and their
    // outward connection (the side that does NOT join another selected piece).
    std::vector<const MC::PlacedPiece*> endpoints;
    for (auto* p : selected) {
        int selNbrs = 0;
        for (const auto& j : p->joints) {
            if (j.pieceId != -1 && selSet.count(j.pieceId)) ++selNbrs;
        }
        if (selNbrs <= 1) endpoints.push_back(p);
    }
    if (endpoints.size() != 2) return out;

    auto findOutwardConn = [&](const MC::PlacedPiece* p) -> int {
        for (size_t i = 0; i < p->joints.size(); ++i) {
            int nb = p->joints[i].pieceId;
            if (nb == -1) return (int)i;
            if (!selSet.count(nb)) return (int)i;
        }
        return -1;
    };
    int connA = findOutwardConn(endpoints[0]);
    int connB = findOutwardConn(endpoints[1]);
    if (connA < 0 || connB < 0) return out;

    out.enabled        = true;
    out.removeMode     = false;
    out.endpointA      = endpoints[0]->id;
    out.endpointB      = endpoints[1]->id;
    out.openConnA      = connA;
    out.openConnB      = connB;
    out.chainPieceIds.assign(selSet.begin(), selSet.end());
    std::sort(out.chainPieceIds.begin(), out.chainPieceIds.end());
    return out;
}

} // namespace

void MainFrame::OnMeasureTool(wxCommandEvent& e) {
    // Toggle in/out of measure mode. Enter → set the canvas cursor to a
    // pencil (sticky for the whole panel) and arm two-click placement.
    if (e.IsChecked()) {
        m_state.toolMode = MC::ToolMode::Measure;
        m_state.ClearSelection();
        if (m_canvas) m_canvas->SetCursor(wxCursor(wxCURSOR_CROSS));
        // Make sure Polygon button is unchecked if it was checked.
        wxToolBar* tb = GetToolBar();
        if (tb) tb->ToggleTool(ID_Polygon, false);
    } else {
        m_state.toolMode = MC::ToolMode::Select;
        if (m_canvas) m_canvas->SetCursor(wxNullCursor);
    }
    if (m_canvas) m_canvas->Refresh();
}

// Place a rectangle of an exact size. Drawing a precise footprint freehand is
// fiddly -- fine for a rough shape, hopeless for an exact 2400 x 1200 board --
// so this asks for the dimensions instead. The rectangle lands at the centre of
// the current view and can be dragged from there like any other polygon.
void MainFrame::OnRectangleTool(wxCommandEvent&) {
    if (!m_canvas) return;

    RectangleDialog dlg(this, m_state.units);
    if (dlg.ShowModal() != wxID_OK) return;

    const double w = dlg.WidthMm();
    const double h = dlg.HeightMm();
    if (w <= 0.0 || h <= 0.0) return;

    // Centre of the current view, in world coordinates.
    wxSize   cs = m_canvas->GetClientSize();
    MC::Vec2 c  = m_canvas->ScreenToWorld(wxPoint(cs.x / 2, cs.y / 2));

    m_state.PushUndo();

    MC::CanvasShape s;
    s.id = m_state.nextId++;
    s.vertices = {
        { c.x - w/2, c.y - h/2 },
        { c.x + w/2, c.y - h/2 },
        { c.x + w/2, c.y + h/2 },
        { c.x - w/2, c.y + h/2 },
    };

    // Drop it on the highest visible level, matching what the polygon tool does.
    int    bestId = m_state.activeLevelId;
    double bestH  = -1e9;
    for (const auto& l : m_state.levels) {
        if (!l.visible) continue;
        if (l.heightMm > bestH) { bestH = l.heightMm; bestId = l.id; }
    }
    s.levelId = bestId;

    m_state.shapes.push_back(s);

    MarkDirty();
    RefreshCanvas();
}

// ── Elevation Wizard ─────────────────────────────────────────────────────────
// Opens the Elevation Wizard dialog, then puts the canvas into the appropriate
// pick mode depending on which method the user chose.
void MainFrame::OnElevation(wxCommandEvent&) {
    if (!m_canvas) return;

    ElevationWizard dlg(this);
    if (dlg.ShowModal() != wxID_OK) return;

    auto method = dlg.ChosenMethod();
    if (method == ElevationWizard::kNone) return;

    // Cancel any other tool modes.
    m_state.toolMode = MC::ToolMode::Select;
    wxToolBar* tb = GetToolBar();
    if (tb) {
        tb->ToggleTool(ID_Measure, false);
        tb->ToggleTool(ID_Polygon, false);
    }

    m_canvas->BeginElevationPick(static_cast<int>(method));
}

void MainFrame::OnPolygonTool(wxCommandEvent& e) {
    // Toggle Polygon mode. Enter → cross-hair cursor; user click-drops
    // vertices, closes by clicking near the start vertex or pressing Enter,
    // cancels with Escape, backspace removes the last vertex.
    if (e.IsChecked()) {
        m_state.toolMode = MC::ToolMode::Polygon;
        m_state.ClearSelection();
        if (m_canvas) m_canvas->SetCursor(wxCursor(wxCURSOR_CROSS));
        wxToolBar* tb = GetToolBar();
        if (tb) tb->ToggleTool(ID_Measure, false);
    } else {
        m_state.toolMode = MC::ToolMode::Select;
        if (m_canvas) m_canvas->SetCursor(wxNullCursor);
    }
    if (m_canvas) m_canvas->Refresh();
}

void MainFrame::OnTunnel(wxCommandEvent&) {
    TunnelAnalysis a = AnalyzeSelection(m_state);
    if (!a.enabled) return;

    m_state.PushUndo();

    if (a.removeMode) {
        // Find the tunnel record, delete its portal scenery, then erase the record.
        const MC::Tunnel* t = nullptr;
        for (const auto& tu : m_state.tunnels) if (tu.id == a.tunnelId) { t = &tu; break; }
        if (!t) return;
        int pA = t->portalIdA, pB = t->portalIdB;
        m_state.scenery.erase(
            std::remove_if(m_state.scenery.begin(), m_state.scenery.end(),
                [&](const MC::PlacedScenery& s){
                    return s.id == pA || s.id == pB;
                }),
            m_state.scenery.end());
        m_state.tunnels.erase(
            std::remove_if(m_state.tunnels.begin(), m_state.tunnels.end(),
                [&](const MC::Tunnel& tt){ return tt.id == a.tunnelId; }),
            m_state.tunnels.end());
    } else {
        // Create mode: place two portals and record the Tunnel.
        auto& catalog = MC::GetCatalog();
        auto findPiece = [&](int id) -> const MC::PlacedPiece* {
            for (const auto& p : m_state.pieces) if (p.id == id) return &p;
            return nullptr;
        };
        const MC::PlacedPiece* pA = findPiece(a.endpointA);
        const MC::PlacedPiece* pB = findPiece(a.endpointB);
        if (!pA || !pB) return;
        const MC::TrackPiece* dA = catalog.Find(pA->catalogNumber);
        const MC::TrackPiece* dB = catalog.Find(pB->catalogNumber);
        if (!dA || !dB) return;

        MC::ConnectionPoint cpA = pA->worldConnection(*dA, a.openConnA);
        MC::ConnectionPoint cpB = pB->worldConnection(*dB, a.openConnB);

        auto makePortal = [&](const MC::ConnectionPoint& cp) {
            MC::PlacedScenery s;
            s.id = m_state.nextId++;
            s.itemId = "tunnel_portal_single";
            // Portal sits exactly at the open connection point (the outer edge
            // of the endpoint piece). Loaf and portal then share the same
            // endpoint coordinate trivially.
            s.transform.pos = cp.pos;
            s.transform.heading = cp.heading;
            s.scaleX = 1.0; s.scaleY = 1.0;
            return s;
        };
        MC::PlacedScenery portalA = makePortal(cpA);
        MC::PlacedScenery portalB = makePortal(cpB);
        int idA = portalA.id, idB = portalB.id;
        m_state.scenery.push_back(portalA);
        m_state.scenery.push_back(portalB);

        // Record the Tunnel
        MC::Tunnel t;
        t.id              = m_state.nextId++;
        t.portalIdA       = idA;
        t.portalIdB       = idB;
        t.anchorPieceA    = a.endpointA;
        t.anchorPieceB    = a.endpointB;
        t.coveredPieceIds = a.chainPieceIds;
        m_state.tunnels.push_back(std::move(t));
    }

    MarkDirty();
    if (m_canvas) m_canvas->Refresh();
}

// ── Helix placement ───────────────────────────────────────────────────────────
// Drops a pre-built two-track helix at the given world position. Creates one
// Helix record and four stub PlacedPieces (24188 straights) at the four
// endpoints. The 30+30 interior curves are rendered on the fly; they're NOT
// stored in state.pieces, so the user cannot select/move/delete them
// individually.
void MainFrame::PlaceHelix(const MC::Vec2& center, double heading,
                            const std::string& variantId) {
    // Resolve the variant: empty/unknown id falls back to the default,
    // which is the 2.5-turn Proses kit (matches pre-variant behavior).
    const std::string id = variantId.empty() ? MC::DefaultHelixVariantId() : variantId;
    const MC::HelixVariant* variant = MC::FindHelixVariant(id);
    if (!variant) variant = MC::FindHelixVariant(MC::DefaultHelixVariantId());
    if (!variant) return;

    auto& catalog = MC::GetCatalog();
    const MC::TrackPiece* stubDef = catalog.Find(variant->stubCatalog);
    if (!stubDef) return;

    m_state.PushUndo();

    const double R1        = variant->radiusR1;
    const double R2        = variant->radiusR2;
    const double totalRise = variant->totalRise;

    MC::Helix h;
    h.id        = m_state.nextId++;
    h.center    = center;
    h.heading   = heading;
    h.variantId = variant->id;

    auto rotateLocal = [&](const MC::Vec2& local) -> MC::Vec2 {
        double rad = heading * M_PI / 180.0;
        double cs = std::cos(rad), sn = std::sin(rad);
        return MC::Vec2{ center.x + cs*local.x - sn*local.y,
                          center.y + sn*local.x + cs*local.y };
    };

    auto makeStub = [&](double Ry, double zMm) -> MC::PlacedPiece {
        MC::PlacedPiece p;
        p.id            = m_state.nextId++;
        p.catalogNumber = variant->stubCatalog;
        p.transform.pos = rotateLocal(MC::Vec2{0, Ry});
        p.transform.heading = heading;
        p.helixId       = h.id;
        p.z_mm          = zMm;
        p.gradeDeg      = 0.0;
        p.levelId       = 1;
        p.joints.resize(2);
        return p;
    };

    // Bottom stubs (z=0) sit at the NORTH side of the helix; top stubs
    // (z=totalRise) sit at the SOUTH side. All four face East. With this
    // layout, viewed as the train climbs from bottom to top, the spiral
    // winds up-and-to-the-left. The half-turn fractional component in
    // `turns` is what makes the spiral end on the opposite side from
    // where it started.
    MC::PlacedPiece bR1 = makeStub(-R1, 0.0);
    MC::PlacedPiece bR2 = makeStub(-R2, 0.0);
    MC::PlacedPiece tR1 = makeStub(+R1, totalRise);
    MC::PlacedPiece tR2 = makeStub(+R2, totalRise);

    h.bottomStubR1 = bR1.id;
    h.bottomStubR2 = bR2.id;
    h.topStubR1    = tR1.id;
    h.topStubR2    = tR2.id;

    m_state.pieces.push_back(bR1);
    m_state.pieces.push_back(bR2);
    m_state.pieces.push_back(tR1);
    m_state.pieces.push_back(tR2);
    m_state.helices.push_back(std::move(h));

    MarkDirty();
    OnSelectionChanged();
    if (m_canvas) m_canvas->Refresh();
}

void MainFrame::OnUpdateUI(wxUpdateUIEvent& e) {
    switch (e.GetId()) {
        case ID_Undo:   e.Enable(m_state.CanUndo()); break;
        case ID_Redo:   e.Enable(m_state.CanRedo()); break;
        case ID_Delete: e.Enable(m_state.HasSelection()); break;
        case ID_Tunnel: {
            TunnelAnalysis a = AnalyzeSelection(m_state);
            e.Enable(a.enabled);
            // Reflect the mode in the toolbar label too.
            wxToolBar* tb = GetToolBar();
            if (tb) {
                wxToolBarToolBase* tool = tb->FindById(ID_Tunnel);
                if (tool) {
                    const char* want = a.removeMode ? "Remove Tunnel" : "Tunnel";
                    if (tool->GetLabel() != want) {
                        tool->SetLabel(want);
                        tb->Realize();
                    }
                }
            }
            break;
        }
        default: break;
    }
}
