#include "panels/PropertiesPanel.h"
#include "MainFrame.h"
#include "ResourceLoader.h"
#include "Prefs.h"
#include "io/CatalogLoader.h"
#include "io/SceneryCatalog.h"
#include "canvas/CanvasState.h"
#include "ShopifyVariants.h"
#include "Platform.h"
#include "model/HelixVariant.h"
#include "model/PlacedScenery.h"
#include <set>
#include <wx/textdlg.h>
#include <vector>
#include <wx/statline.h>
#include <wx/listctrl.h>
#include <wx/utils.h>
#include <wx/filename.h>
#include <wx/stdpaths.h>
#include <wx/hyperlink.h>
#include <map>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <cmath>
#include <sstream>
#include <iomanip>

static constexpr double kMaxGradeDeg = 6.0;

enum {
    ID_LEVEL_CHOICE  = 4101,
    ID_LABEL_FIELD   = 4107,
    ID_LABEL_OK      = 4108,
    ID_COLOR_CHOICE  = 4109,
    ID_COLOR_OK      = 4110,
};

wxBEGIN_EVENT_TABLE(PropertiesPanel, wxPanel)
    EVT_CHOICE(ID_LEVEL_CHOICE,     PropertiesPanel::OnLevelChanged)
    EVT_BUTTON(ID_LABEL_OK,         PropertiesPanel::OnLabelOK)
    EVT_TEXT_ENTER(ID_LABEL_FIELD,  PropertiesPanel::OnLabelOK)
wxEND_EVENT_TABLE()

PropertiesPanel::PropertiesPanel(wxWindow* parent, MainFrame* frame)
    : wxPanel(parent, wxID_ANY), m_frame(frame)
{
    m_sizer = new wxBoxSizer(wxVERTICAL);

    // ── Text Label section ────────────────────────────────────────────────────
    {
        wxStaticBoxSizer* lblSizer = new wxStaticBoxSizer(wxVERTICAL, this, "Add Label");
        wxBoxSizer* row = new wxBoxSizer(wxHORIZONTAL);
        m_txtLabel = new wxTextCtrl(lblSizer->GetStaticBox(), ID_LABEL_FIELD, "",
                                    wxDefaultPosition, wxDefaultSize, wxTE_PROCESS_ENTER);
        m_btnLabel = new wxButton(lblSizer->GetStaticBox(), ID_LABEL_OK, "OK",
                                   wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT);
        row->Add(m_txtLabel, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
        row->Add(m_btnLabel, 0, wxALIGN_CENTER_VERTICAL);
        lblSizer->Add(row, 0, wxEXPAND | wxALL, 4);
        m_sizer->Add(lblSizer, 0, wxEXPAND | wxALL, 4);
    }
    m_sizer->Add(new wxStaticLine(this), 0, wxEXPAND | wxLEFT | wxRIGHT, 6);

    m_lblName = new wxStaticText(this, wxID_ANY, "No selection",
                                  wxDefaultPosition, wxDefaultSize,
                                  wxST_ELLIPSIZE_END);
    wxFont f = m_lblName->GetFont();
    f.SetWeight(wxFONTWEIGHT_BOLD);
    m_lblName->SetFont(f);
    m_sizer->Add(m_lblName, 0, wxEXPAND | wxALL, 6);

    m_lblInfo = new wxStaticText(this, wxID_ANY, "");
    m_sizer->Add(m_lblInfo, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 6);

    // Level row
    m_sizer->Add(new wxStaticLine(this), 0, wxEXPAND | wxLEFT | wxRIGHT, 6);
    wxBoxSizer* lvlRow = new wxBoxSizer(wxHORIZONTAL);
    lvlRow->Add(new wxStaticText(this, wxID_ANY, "Level:"),
                0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
    m_levelChoice = new wxChoice(this, ID_LEVEL_CHOICE);
    m_levelChoice->Enable(false);
    lvlRow->Add(m_levelChoice, 1, wxALIGN_CENTER_VERTICAL);
    m_sizer->Add(lvlRow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 6);

    // ── Color section ─────────────────────────────────────────────────────────
    {
        wxStaticBoxSizer* colSizer = new wxStaticBoxSizer(wxHORIZONTAL, this, "Color");
        static const char* kColorNames[] = {
            "Default","Red","Orange","Yellow","Green","Blue","Purple","Pink","White"
        };
        wxArrayString choices;
        for (auto* n : kColorNames) choices.Add(n);
        m_colorChoice = new wxChoice(colSizer->GetStaticBox(), ID_COLOR_CHOICE,
                                     wxDefaultPosition, wxDefaultSize, choices);
        m_colorChoice->SetSelection(0);
        wxButton* btnCol = new wxButton(colSizer->GetStaticBox(), ID_COLOR_OK, "Apply",
                                        wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT);
        colSizer->Add(m_colorChoice, 1, wxALIGN_CENTER_VERTICAL | wxALL, 4);
        colSizer->Add(btnCol,        0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
        m_sizer->Add(colSizer, 0, wxEXPAND | wxALL, 4);
        btnCol->Bind(wxEVT_BUTTON, &PropertiesPanel::OnColorApply, this);
    }

    // ── Elevation info (read-only) ───────────────────────────────────────
    m_sizer->Add(new wxStaticLine(this), 0, wxEXPAND | wxLEFT | wxRIGHT, 6);
    {
        wxBoxSizer* hRow = new wxBoxSizer(wxHORIZONTAL);
        m_lblHeight = new wxStaticText(this, wxID_ANY, "Height:");
        m_lblHeightVal = new wxStaticText(this, wxID_ANY, "-");
        hRow->Add(m_lblHeight,    0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
        hRow->Add(m_lblHeightVal, 0, wxALIGN_CENTER_VERTICAL);
        m_sizer->Add(hRow, 0, wxLEFT | wxRIGHT | wxTOP, 6);

        wxBoxSizer* gRow = new wxBoxSizer(wxHORIZONTAL);
        m_lblGrade    = new wxStaticText(this, wxID_ANY, "Grade:");
        m_lblGradeVal = new wxStaticText(this, wxID_ANY, "-");
        gRow->Add(m_lblGrade,    0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
        gRow->Add(m_lblGradeVal, 0, wxALIGN_CENTER_VERTICAL);
        m_sizer->Add(gRow, 0, wxLEFT | wxRIGHT | wxBOTTOM, 6);

        m_lblWarn = new wxStaticText(this, wxID_ANY, "");
        m_lblWarn->SetForegroundColour(wxColour(200, 40, 40));
        m_sizer->Add(m_lblWarn, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 6);
    }

    // Piece tally
    m_sizer->Add(new wxStaticLine(this), 0, wxEXPAND | wxLEFT | wxRIGHT, 6);
    wxStaticText* tallyHeader = new wxStaticText(this, wxID_ANY, "Piece Count");
    wxFont fh = tallyHeader->GetFont();
    fh.SetWeight(wxFONTWEIGHT_BOLD);
    tallyHeader->SetFont(fh);
    m_sizer->Add(tallyHeader, 0, wxLEFT | wxRIGHT | wxTOP, 6);

    m_tallyList = new wxListCtrl(this, wxID_ANY, wxDefaultPosition, wxSize(-1, 180),
                                  wxLC_REPORT | wxLC_NO_HEADER | wxBORDER_NONE);
    m_tallyList->InsertColumn(0, "Piece",  wxLIST_FORMAT_LEFT, 110);
    m_tallyList->InsertColumn(1, "Count",  wxLIST_FORMAT_RIGHT, 40);
    m_sizer->Add(m_tallyList, 1, wxEXPAND | wxALL, 4);

    // Buy Track button row with help tooltip
    m_sizer->Add(new wxStaticLine(this), 0, wxEXPAND | wxLEFT | wxRIGHT, 6);
    wxBoxSizer* buyRow = new wxBoxSizer(wxHORIZONTAL);

    wxButton* buyBtn = new wxButton(this, wxID_ANY, "  Buy Track  ");
    // Green-on-Windows, native-on-Mac. See Platform.h: Cocoa ignores
    // SetBackgroundColour, so forcing the colours there left white text on a
    // grey button.
    TD::StyleAsPrimary(buyBtn);
    buyBtn->SetMinSize(wxSize(-1, 36));
    buyRow->Add(buyBtn, 1, wxEXPAND | wxRIGHT, 4);

    wxButton* helpBtn = new wxButton(this, wxID_ANY, "?",
        wxDefaultPosition, wxSize(30, 36));
    helpBtn->SetToolTip("About Buy Track");
    buyRow->Add(helpBtn, 0);

    m_sizer->Add(buyRow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 6);

    buyBtn->Bind(wxEVT_BUTTON, &PropertiesPanel::OnBuyTrack, this);
    helpBtn->Bind(wxEVT_BUTTON, &PropertiesPanel::OnBuyTrackHelp, this);

    SetSizer(m_sizer);
    Layout();
}

void PropertiesPanel::OnBuyTrackHelp(wxCommandEvent&) {
    wxDialog dlg(this, wxID_ANY, "Buy Track - How it works",
                 wxDefaultPosition, wxSize(480, 840),
                 wxDEFAULT_DIALOG_STYLE | wxSTAY_ON_TOP);
    dlg.Centre(wxBOTH);
    wxBoxSizer* sz = new wxBoxSizer(wxVERTICAL);

    // Cashier image — loaded from embedded resource (playful mode only)
    if (Prefs::Get().IsPlayful()) {
        wxInitAllImageHandlers();
        wxImage img = LoadImageResource(RES_CASHIER);
        if (img.IsOk()) {
            img = img.Scale(450, 510, wxIMAGE_QUALITY_HIGH);
            wxStaticBitmap* bmp = new wxStaticBitmap(&dlg, wxID_ANY, wxBitmap(img));
            sz->Add(bmp, 0, wxALIGN_CENTRE | wxALL, 6);
        }
    }

    // Text
    wxStaticText* txt = new wxStaticText(&dlg, wxID_ANY,
        "Buy all required track and accessories securely and\n"
        "conveniently online from Euro Model Trains.\n\n"
        "Click Buy Track and you will be taken to the Euro\n"
        "Model Trains website with all required track waiting\n"
        "in the shopping cart for you to review and check out.",
        wxDefaultPosition, wxDefaultSize, wxALIGN_CENTRE_HORIZONTAL);
    wxFont tf = txt->GetFont();
    tf.SetPointSize(tf.GetPointSize() + 1);
    txt->SetFont(tf);
    sz->Add(txt, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 12);

    wxHyperlinkCtrl* lnk = new wxHyperlinkCtrl(&dlg, wxID_ANY,
        "www.euromodeltrains.com", "https://www.euromodeltrains.com");
    sz->Add(lnk, 0, wxALIGN_CENTRE | wxBOTTOM, 8);

    wxButton* ok = new wxButton(&dlg, wxID_OK, "Got it!");
    ok->SetBackgroundColour(wxColour(20, 110, 20));
    ok->SetForegroundColour(*wxWHITE);
    sz->Add(ok, 0, wxALIGN_CENTRE | wxBOTTOM, 10);

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

void PropertiesPanel::OnBuyTrack(wxCommandEvent&) {
    if (!m_frame) return;
    auto& state   = m_frame->State();
    auto& catalog = MC::GetCatalog();
    auto& variants = GetShopifyVariants();

    // Count pieces by catalog number. Skip pieces flagged as
    // freeWithTurntable -- the 6 spoke extensions that ship bundled
    // with a turntable aren't separately billable.
    std::map<std::string, int> counts;
    for (const auto& p : state.pieces) {
        if (p.freeWithTurntable) continue;
        const MC::TrackPiece* def = catalog.Find(p.catalogNumber);
        if (def) counts[p.catalogNumber]++;
    }
    // Include any scenery items whose id is a Märklin catalog number
    // (i.e. items that exist in the Shopify variants map). Items with
    // descriptive ids like "warehouse" aren't sold individually and so
    // won't appear in the cart.
    for (const auto& s : state.scenery) {
        if (GetShopifyVariants().count(s.itemId))
            counts[s.itemId]++;
    }

    // 74871 sold in packs of 3 -- ceil count to pack quantity.
    if (counts.count("74871")) {
        int n = counts["74871"];
        counts["74871"] = (n + 2) / 3;
    }

    // Build cart URL: /cart/VARIANT_ID:QTY,VARIANT_ID:QTY,...
    std::string cartItems;
    std::vector<std::string> notFound;
    for (const auto& kv : counts) {
        auto it = variants.find(kv.first);
        if (it == variants.end()) {
            notFound.push_back(kv.first);
            continue;
        }
        if (!cartItems.empty()) cartItems += ",";
        cartItems += it->second + ":" + std::to_string(kv.second);
    }

    // Add recommended items using hardcoded switch numbers
    static const std::unordered_map<std::string, int> kMotorsCTrack = {
        {"24611",1},{"24612",1},{"24630",2},{"24671",1},{"24672",1},
        {"24711",1},{"24712",1},{"24771",1},{"24772",1},{"24720",1},
        {"62611",1},{"62612",1},{"62630",2},{"62671",1},{"62672",1},
    };
    static const std::unordered_map<std::string, int> kMotorsKTrack = {
        {"2261",1},{"2263",1},{"2264",1},{"2265",1},{"2266",1},{"2267",1},
        {"2268",1},{"2269",1},{"2271",1},{"2272",1},{"2276",1},{"2277",1},
    };
    int nMotorC = 0, nMotorK = 0, nDecoder74462 = 0, nDecoder74467 = 0;
    for (const auto& kv : counts) {
        auto mc = kMotorsCTrack.find(kv.first);
        if (mc != kMotorsCTrack.end()) nMotorC += mc->second * kv.second;
        auto mk = kMotorsKTrack.find(kv.first);
        if (mk != kMotorsKTrack.end()) nMotorK += mk->second * kv.second;
        if (kv.first=="24611"||kv.first=="24612"||kv.first=="24630"||
            kv.first=="24624"||kv.first=="24671"||kv.first=="24672"||
            kv.first=="24711"||kv.first=="24712"||kv.first=="24771"||kv.first=="24772"||
            kv.first=="62611"||kv.first=="62612"||kv.first=="62630"||
            kv.first=="62624"||kv.first=="62671"||kv.first=="62672")
            nDecoder74462 += kv.second;
        if (kv.first == "24720") nDecoder74467 += kv.second;
    }
    int nMotors = nMotorC;
    if (nMotors > 0) {
        auto it = variants.find("74492");
        if (it != variants.end()) {
            if (!cartItems.empty()) cartItems += ",";
            cartItems += it->second + ":" + std::to_string(nMotors);
        }
    }
    if (nMotorK > 0) {
        auto it = variants.find("75491");
        if (it != variants.end()) {
            if (!cartItems.empty()) cartItems += ",";
            cartItems += it->second + ":" + std::to_string(nMotorK);
        }
    }
    auto addDecoder = [&](const std::string& num, int qty) {
        if (qty <= 0) return;
        auto it = variants.find(num);
        if (it != variants.end()) {
            if (!cartItems.empty()) cartItems += ",";
            cartItems += it->second + ":" + std::to_string(qty);
        }
    };
    addDecoder("74462", nDecoder74462);
    addDecoder("74467", nDecoder74467);

    if (cartItems.empty()) {
        wxMessageBox("No purchasable track pieces found in your layout.",
                     "Buy Track", wxOK | wxICON_INFORMATION);
        return;
    }

    // Warn about pieces not in store
    if (!notFound.empty()) {
        wxString msg = "The following pieces are not available in the store and will be skipped:\n";
        for (auto& s : notFound) msg += "  " + wxString::FromUTF8(s) + "\n";
        wxMessageBox(msg, "Some pieces not available", wxOK | wxICON_WARNING);
    }

    wxString url = "https://euromodeltrains.com/cart/" + wxString::FromUTF8(cartItems);
    wxLaunchDefaultBrowser(url);
}

void PropertiesPanel::RefreshTally() {
    if (!m_tallyList || !m_frame) return;
    auto& state   = m_frame->State();
    auto& catalog = MC::GetCatalog();
    auto& variants = GetShopifyVariants();

    // Count pieces by catalog number. Skip pieces flagged as
    // freeWithTurntable -- those are the 6 spoke extensions that ship
    // bundled with each 74861 turntable and are not separately billable.
    std::map<std::string, int> counts;
    for (const auto& p : state.pieces) {
        if (p.freeWithTurntable) continue;
        counts[p.catalogNumber]++;
        if (p.catalogNumber == "74636") counts["24360"]++;
        if (p.catalogNumber == "74620") counts["24188"]++;
        if (p.catalogNumber == "74623") counts["24230"]++;
        if (p.catalogNumber == "74613") counts["24130"]++;
        if (p.catalogNumber == "7263")  counts["2200"] += 2;
        if (p.catalogNumber == "7262")  counts["2200"]++;
        if (p.catalogNumber == "7569")  counts["2221"]++;
        if (p.catalogNumber == "7267")  counts["2221"]++;
        if (p.catalogNumber == "62636") { counts["62188"]++; counts["62172"]++; }
        if (p.catalogNumber == "62620") counts["62188"]++;
        if (p.catalogNumber == "62623") counts["62230"]++;
        if (p.catalogNumber == "62613") counts["62130"]++;
        if (p.catalogNumber == "74618") { counts["24188"]++; counts["24172"]++; }
        if (p.catalogNumber == "8975")  counts["8505"]++;
        if (p.catalogNumber == "89759") counts["8500"]++;
        if (p.catalogNumber == "8976")  counts["8500"]++;
        if (p.catalogNumber == "8977")  counts["8510"]++;
    }

    // 74871 is sold in packs of 3. Convert the per-piece count to pack
    // count (rounded up), so a layout with 2 extras still buys one pack.
    if (counts.count("74871")) {
        int n = counts["74871"];
        counts["74871"] = (n + 2) / 3;   // ceil(n / 3)
    }

    // Include scenery items whose id is a Märklin catalog number that
    // exists in the Shopify variants map. Items with descriptive ids
    // ("warehouse", "shed_2road", etc.) aren't sold individually and so
    // don't appear here.
    for (const auto& s : state.scenery) {
        if (variants.count(s.itemId)) counts[s.itemId]++;
    }

    // Helix interior curves are not in state.pieces (rendered on the fly),
    // but they exist physically -- add them to the tally. Counts come from
    // each helix's variant (e.g., Proses 2.5 = 30 of each; Proses 3.5 = 42).
    for (const auto& h : state.helices) {
        const MC::HelixVariant* v = MC::FindHelixVariant(h.variantId);
        if (!v) v = MC::FindHelixVariant(MC::DefaultHelixVariantId());
        if (!v) continue;
        const int pieces_per_radius = (int)(v->turns * 12);
        counts[v->innerCurve] += pieces_per_radius;
        counts[v->outerCurve] += pieces_per_radius;
    }

    // Always rebuild — skip cache check
    m_tallyCounts = counts;

    m_tallyList->Freeze();
    m_tallyList->DeleteAllItems();
    long row = 0;

    // Track tally
    for (const auto& kv : counts) {
        const MC::TrackPiece* def = catalog.Find(kv.first);
        std::string name = def ? def->displayName : kv.first;
        // If it's not a track piece, try the scenery catalog
        if (!def) {
            const MC::SceneryItem* sdef = MC::GetSceneryCatalog().Find(kv.first);
            if (sdef) name = sdef->displayName;
        }
        m_tallyList->InsertItem(row, wxString::FromUTF8(name));
        m_tallyList->SetItem(row, 1, wxString::Format("%d", kv.second));
        row++;
    }

    // Recommendations section — hardcoded switch catalog numbers
    // C-Track & TRIX: motor 74491, decoder 74462/74467
    // K-Track: motor 75491 only
    static const std::unordered_map<std::string, int> kMotorsCTrack = {
        {"24611",1},{"24612",1},{"24630",2},{"24671",1},{"24672",1},
        {"24711",1},{"24712",1},{"24771",1},{"24772",1},{"24720",1},
        {"62611",1},{"62612",1},{"62630",2},{"62671",1},{"62672",1},
    };
    static const std::unordered_map<std::string, int> kMotorsKTrack = {
        {"2261",1},{"2263",1},{"2264",1},{"2265",1},{"2266",1},{"2267",1},
        {"2268",1},{"2269",1},{"2271",1},{"2272",1},{"2276",1},{"2277",1},
    };
    static const std::unordered_map<std::string, std::string> kDecoderNeeded = {
        {"24611","74462"},{"24612","74462"},{"24630","74462"},
        {"24671","74462"},{"24672","74462"},{"24711","74462"},{"24712","74462"},
        {"24771","74462"},{"24772","74462"},{"24624","74462"},{"24720","74467"},
        {"62611","74462"},{"62612","74462"},{"62630","74462"},
        {"62671","74462"},{"62672","74462"},{"62624","74462"},
    };

    int nMotorC = 0, nMotorK = 0, nDecoder74462 = 0, nDecoder74467 = 0;
    for (const auto& kv : counts) {
        auto mc = kMotorsCTrack.find(kv.first);
        if (mc != kMotorsCTrack.end()) nMotorC += mc->second * kv.second;
        auto mk = kMotorsKTrack.find(kv.first);
        if (mk != kMotorsKTrack.end()) nMotorK += mk->second * kv.second;
        auto dit = kDecoderNeeded.find(kv.first);
        if (dit != kDecoderNeeded.end()) {
            if (dit->second == "74462") nDecoder74462 += kv.second;
            else if (dit->second == "74467") nDecoder74467 += kv.second;
        }
    }

    int totalSwitches = nMotorC + nMotorK + nDecoder74462 + nDecoder74467;
    if (totalSwitches > 0) {
        m_tallyList->InsertItem(row, "We Recommend");
        wxFont boldFont = m_tallyList->GetFont();
        boldFont.SetWeight(wxFONTWEIGHT_BOLD);
        m_tallyList->SetItemFont(row, boldFont);
        m_tallyList->SetItemTextColour(row, wxColour(60, 60, 160));
        row++;

        if (nMotorC > 0) {
            m_tallyList->InsertItem(row, "74491 Turnout Motor");
            m_tallyList->SetItem(row, 1, wxString::Format("%d", nMotorC));
            row++;
        }
        if (nMotorK > 0) {
            m_tallyList->InsertItem(row, "75491 K-Track Motor");
            m_tallyList->SetItem(row, 1, wxString::Format("%d", nMotorK));
            row++;
        }
        if (nDecoder74462 > 0) {
            m_tallyList->InsertItem(row, "74462 Decoder");
            m_tallyList->SetItem(row, 1, wxString::Format("%d", nDecoder74462));
            row++;
        }
        if (nDecoder74467 > 0) {
            m_tallyList->InsertItem(row, "74467 Decoder (24720)");
            m_tallyList->SetItem(row, 1, wxString::Format("%d", nDecoder74467));
            row++;
        }
    }

    m_tallyList->Thaw();
}

// Composite view for a selection that's an entire straight chain. Renders
// the piece-count combo ("24188 + 24172 + 24188") and the total length in
// the user's active units, in place of the single-piece view.
void PropertiesPanel::ShowStraightChain(
    const std::vector<MC::PlacedPiece*>& pieces)
{
    m_selectedId = -1;
    m_selectedSceneryId = -1;
    m_selectedHelixId = -1;
    m_selectedShapeId = -1;

    // Order pieces along the chain by following joints starting at an
    // end (a piece with at most one selected neighbour). This produces a
    // natural left-to-right summary instead of selection order.
    std::set<int> selIds;
    for (auto* p : pieces) selIds.insert(p->id);
    auto neighbourCount = [&](const MC::PlacedPiece* p) {
        int n = 0;
        for (const auto& j : p->joints)
            if (j.pieceId >= 0 && selIds.count(j.pieceId)) ++n;
        return n;
    };
    const MC::PlacedPiece* startP = pieces[0];
    for (auto* p : pieces) if (neighbourCount(p) == 1) { startP = p; break; }

    std::vector<const MC::PlacedPiece*> ordered;
    std::set<int> visited;
    const MC::PlacedPiece* cur = startP;
    const MC::PlacedPiece* prev = nullptr;
    while (cur && !visited.count(cur->id)) {
        ordered.push_back(cur);
        visited.insert(cur->id);
        const MC::PlacedPiece* next = nullptr;
        for (const auto& j : cur->joints) {
            if (j.pieceId < 0) continue;
            if (!selIds.count(j.pieceId)) continue;
            if (prev && j.pieceId == prev->id) continue;
            next = m_frame->State().FindById(j.pieceId);
            if (next) break;
        }
        prev = cur;
        cur  = next;
    }
    // If chain ordering came up short (selection wasn't a simple path),
    // fall back to selection order.
    if (ordered.size() != pieces.size()) {
        ordered.clear();
        for (auto* p : pieces) ordered.push_back(p);
    }

    // Build catalog-number combo string and total length.
    std::ostringstream combo;
    double totalLen = 0.0;
    for (size_t i = 0; i < ordered.size(); ++i) {
        if (i) combo << " + ";
        combo << ordered[i]->catalogNumber;
        const MC::TrackPiece* def = MC::GetCatalog().Find(ordered[i]->catalogNumber);
        if (def) totalLen += def->lengthMm;
    }

    m_lblName->SetLabel(wxString::FromUTF8(combo.str()));

    auto& state = m_frame->State();
    std::ostringstream info;
    info << ordered.size() << " pieces  |  ";
    if      (state.units == 1) info << std::fixed << std::setprecision(1) << (totalLen/10.0) << " cm";
    else if (state.units == 2) info << std::fixed << std::setprecision(2) << (totalLen/25.4) << " in";
    else                       info << std::fixed << std::setprecision(1) << totalLen        << " mm";
    m_lblInfo->SetLabel(wxString::FromUTF8(info.str()));

    // Hide per-piece edit fields — this is a summary view.
    if (m_lblHeight) m_lblHeight->SetLabel("");
    // The other fields (height value, grade, color etc.) keep whatever
    // they last had; the user can still click an individual piece to edit.

    // Populate the level dropdown so the user can reassign all selected
    // pieces to a new level in one operation. If every selected piece
    // shares the same level we preselect it; otherwise we add a synthetic
    // "(mixed)" entry at the front and preselect that.
    if (m_levelChoice) {
        m_levelChoice->Clear();
        int firstLvl = ordered.empty() ? -1 : ordered.front()->levelId;
        bool mixed = false;
        for (auto* p : ordered) {
            if (p->levelId != firstLvl) { mixed = true; break; }
        }
        int selIdx = 0;
        int rowOffset = 0;
        if (mixed) {
            m_levelChoice->Append("(mixed)", reinterpret_cast<void*>((intptr_t)-1));
            rowOffset = 1;
        }
        int i = 0;
        for (const auto& l : state.levels) {
            m_levelChoice->Append(wxString::FromUTF8(l.name),
                                  reinterpret_cast<void*>((intptr_t)l.id));
            if (!mixed && l.id == firstLvl) selIdx = i + rowOffset;
            ++i;
        }
        m_levelChoice->SetSelection(selIdx);
        m_levelChoice->Enable(true);
    }
}

void PropertiesPanel::RefreshProperties() {
    if (!m_frame) return;
    auto& state = m_frame->State();
    auto selected = state.Selected();

    if (selected.empty()) {
        // No track pieces selected. If exactly one scenery item is selected,
        // show its properties (including its level). If a free-form polygon
        // shape is selected, show its properties. Otherwise empty.
        auto sceneSel = state.SelectedScenery();
        if (sceneSel.size() == 1) { ShowSceneryProperties(sceneSel[0]->id); return; }
        int selShapeId = -1;
        int selShapeCount = 0;
        for (const auto& s : state.shapes) if (s.selected) { ++selShapeCount; selShapeId = s.id; }
        if (selShapeCount == 1) { ShowShapeProperties(selShapeId); return; }
        ShowEmpty();
        return;
    }

    // If the selection is exactly one complete helix (all selected pieces
    // share the same non-empty helixId, no scenery), show the helix itself
    // rather than its 24188 stub pieces.
    {
        bool scenerySelected = false;
        for (const auto& s : state.scenery) if (s.selected) { scenerySelected = true; break; }
        int hxId = -1; bool allHelix = !scenerySelected;
        for (auto* s : selected) {
            if (s->helixId == -1) { allHelix = false; break; }
            if (hxId == -1) hxId = s->helixId;
            else if (hxId != s->helixId) { allHelix = false; break; }
        }
        if (allHelix && hxId != -1) {
            const MC::Helix* h = state.HelixById(hxId);
            if (h) { ShowHelixProperties(*h); return; }
        }
    }

    // If the selection is a single connected, collinear straight chain
    // (e.g. 24188 + 24172 + 24188), show the combo and total length in
    // place of the single-piece view. This is purely informational; the
    // pieces stay individually editable via per-piece selection.
    if (selected.size() >= 2) {
        bool allStraight = true;
        for (auto* p : selected) {
            const MC::TrackPiece* def = MC::GetCatalog().Find(p->catalogNumber);
            if (!def || def->type != MC::PieceType::Straight) {
                allStraight = false; break;
            }
            if (p->helixId != -1) { allStraight = false; break; } // skip helix stubs
        }
        if (allStraight) {
            // Collinearity: every piece's heading must equal the first's
            // mod 180 deg (so reversed-orientation straights still count).
            double h0 = selected[0]->transform.heading;
            bool collinear = true;
            for (auto* p : selected) {
                double d = std::fmod(std::abs(p->transform.heading - h0), 180.0);
                if (d > 1.0 && d < 179.0) { collinear = false; break; }
            }
            // Chain connectivity: every piece must be reachable from the
            // first via joint links, using only the selected set.
            bool chained = true;
            if (collinear && selected.size() >= 2) {
                std::set<int> selIds;
                for (auto* p : selected) selIds.insert(p->id);
                std::set<int> reached;
                std::vector<int> stack;
                stack.push_back(selected[0]->id);
                reached.insert(selected[0]->id);
                while (!stack.empty()) {
                    int id = stack.back(); stack.pop_back();
                    const MC::PlacedPiece* p = state.FindById(id);
                    if (!p) continue;
                    for (const auto& j : p->joints) {
                        if (j.pieceId < 0) continue;
                        if (!selIds.count(j.pieceId)) continue;
                        if (reached.insert(j.pieceId).second)
                            stack.push_back(j.pieceId);
                    }
                }
                if ((int)reached.size() != (int)selected.size())
                    chained = false;
            }
            if (collinear && chained) {
                ShowStraightChain(selected);
                return;
            }
        }
    }

    // Use most recently placed selected piece (highest id)
    const MC::PlacedPiece* p = selected[0];
    for (auto* s : selected)
        if (s->id > p->id) p = s;
    const MC::TrackPiece* def = MC::GetCatalog().Find(p->catalogNumber);
    if (def) ShowPieceProperties(*p, *def);

    // If multiple track pieces are selected and they straddle multiple
    // levels, ShowPieceProperties preselected the highest-id piece's
    // level on the dropdown -- which would mislead the user into
    // thinking the whole selection sits on that level. Replace the
    // preselection with a synthetic "(mixed)" entry so the dropdown
    // reflects the heterogeneous state. Changing it then commits the
    // chosen level to every selected piece (see OnLevelChanged).
    if (selected.size() >= 2 && m_levelChoice) {
        int firstLvl = selected.front()->levelId;
        bool mixed = false;
        for (auto* s : selected) {
            if (s->levelId != firstLvl) { mixed = true; break; }
        }
        if (mixed) {
            m_levelChoice->Insert("(mixed)", 0,
                                  reinterpret_cast<void*>((intptr_t)-1));
            m_levelChoice->SetSelection(0);
        }
    }
}

// Populate the right panel for a single selected free-form polygon shape.
// The level dropdown is active so the user can change the shape's level
// from here. Color/height/label are edited via a double-click on canvas
// (handled in CanvasPanel::OnLeftDblClick).
void PropertiesPanel::ShowShapeProperties(int shapeId) {
    auto& state = m_frame->State();
    MC::CanvasShape* sp = nullptr;
    for (auto& s : state.shapes) if (s.id == shapeId) { sp = &s; break; }
    if (!sp) { ShowEmpty(); return; }

    m_selectedId        = -1;
    m_selectedSceneryId = -1;
    m_selectedHelixId   = -1;
    m_selectedShapeId   = shapeId;

    m_lblName->SetLabel(sp->label.empty()
        ? wxString("(unnamed shape)")
        : wxString::FromUTF8(sp->label));

    // Compute area-bounding extent to give a sense of size in the info line.
    if (!sp->vertices.empty()) {
        double xmin = sp->vertices[0].x, xmax = xmin;
        double ymin = sp->vertices[0].y, ymax = ymin;
        for (const auto& v : sp->vertices) {
            xmin = std::min(xmin, v.x); xmax = std::max(xmax, v.x);
            ymin = std::min(ymin, v.y); ymax = std::max(ymax, v.y);
        }
        std::ostringstream info;
        info << "Polygon  |  " << sp->vertices.size() << " verts  |  "
             << std::fixed << std::setprecision(0)
             << (xmax - xmin) << " x " << (ymax - ymin) << " mm"
             << "  |  height " << sp->heightMm << " mm";
        m_lblInfo->SetLabel(wxString::FromUTF8(info.str()));
    } else {
        m_lblInfo->SetLabel("Polygon  |  (no vertices)");
    }

    if (m_lblHeight) m_lblHeight->SetLabel("Height:");
    if (m_lblHeightVal) m_lblHeightVal->SetLabel("-");
    m_lblGradeVal->SetLabel("-");
    m_lblWarn->SetLabel("");

    if (m_levelChoice) {
        m_levelChoice->Clear();
        state.EnsureDefaultLevel();
        int selIdx = 0;
        for (int i = 0; i < (int)state.levels.size(); ++i) {
            auto& l = state.levels[i];
            m_levelChoice->Append(wxString::FromUTF8(l.name),
                                  reinterpret_cast<void*>((intptr_t)l.id));
            if (l.id == sp->levelId) selIdx = i;
        }
        m_levelChoice->SetSelection(selIdx);
        m_levelChoice->Enable(true);
    }
}

void PropertiesPanel::ShowSceneryProperties(int sceneryId) {
    auto& state = m_frame->State();
    MC::PlacedScenery* s = state.FindSceneryById(sceneryId);
    if (!s) { ShowEmpty(); return; }
    const MC::SceneryItem* def = MC::GetSceneryCatalog().Find(s->itemId);

    m_selectedId = -1;                 // not a track piece
    m_selectedSceneryId = sceneryId;   // remember for OnLevelChanged
    m_selectedHelixId = -1;
    m_selectedShapeId = -1;

    wxString name = def ? wxString::FromUTF8(def->displayName)
                        : wxString::FromUTF8(s->itemId);
    m_lblName->SetLabel(name);

    std::ostringstream info;
    info << "Scenery";
    if (def && def->widthMm > 0)
        info << "  |  " << std::fixed << std::setprecision(0)
             << def->widthMm * s->scaleX << " x "
             << def->heightMm * s->scaleY << " mm";
    m_lblInfo->SetLabel(wxString::FromUTF8(info.str()));

    // Scenery has no elevation/grade controls.
    if (m_lblHeight) m_lblHeight->SetLabel("Height:");
    if (m_lblHeightVal) m_lblHeightVal->SetLabel("-");
    m_lblGradeVal->SetLabel("-");
    m_lblWarn->SetLabel("");

    // Level selector — this is the point of the feature.
    if (m_levelChoice) {
        m_levelChoice->Clear();
        state.EnsureDefaultLevel();
        int selIdx = 0;
        for (int i = 0; i < (int)state.levels.size(); ++i) {
            auto& l = state.levels[i];
            m_levelChoice->Append(wxString::FromUTF8(l.name),
                                  reinterpret_cast<void*>((intptr_t)l.id));
            if (l.id == s->levelId) selIdx = i;
        }
        m_levelChoice->SetSelection(selIdx);
        m_levelChoice->Enable(true);
    }

    Layout();
}


void PropertiesPanel::ShowHelixProperties(const MC::Helix& h) {
    m_selectedId = -1;   // not a single piece; disable per-piece edits
    m_selectedSceneryId = -1;
    m_selectedHelixId = h.id;

    const MC::HelixVariant* v = MC::FindHelixVariant(h.variantId);
    if (!v) v = MC::FindHelixVariant(MC::DefaultHelixVariantId());

    auto& state = m_frame->State();

    wxString name = v ? wxString::FromUTF8(v->displayName) : "Helix";
    m_lblName->SetLabel(name);

    std::ostringstream info;
    if (v) {
        double riseDisp; wxString u;
        if      (state.units == 1) { riseDisp = v->totalRise / 10.0; u = "cm"; }
        else if (state.units == 2) { riseDisp = v->totalRise / 25.4; u = "in"; }
        else                       { riseDisp = v->totalRise;        u = "mm"; }
        info << std::fixed << std::setprecision(1)
             << v->turns << " turns  |  rise "
             << riseDisp << " " << u
             << "  |  R1 " << v->radiusR1 << " / R2 " << v->radiusR2 << " mm";
    }
    m_lblInfo->SetLabel(wxString::FromUTF8(info.str()));

    // Height shows the bottom-deck elevation of the helix (z at bottom stubs).
    double baseZ = 0.0;
    if (const MC::PlacedPiece* bs = state.FindById(h.bottomStubR1)) baseZ = bs->z_mm;
    double zDisplay; wxString zUnit;
    if      (state.units == 1) { zDisplay = baseZ / 10.0;  zUnit = "cm"; }
    else if (state.units == 2) { zDisplay = baseZ / 25.4;  zUnit = "in"; }
    else                       { zDisplay = baseZ;          zUnit = "mm"; }
    if (m_lblHeight) m_lblHeight->SetLabel(wxString::Format("Base height (%s):", zUnit));
    if (m_lblHeightVal) {
        std::ostringstream zs;
        zs << std::fixed << std::setprecision(state.units == 2 ? 3 : 1) << zDisplay;
        m_lblHeightVal->SetLabel(wxString::FromUTF8(zs.str()));
    }

    m_lblGradeVal->SetLabel("-");
    m_lblWarn->SetLabel("");

    // Level choice still applies — a helix lives on a level like any piece.
    if (m_levelChoice) {
        m_levelChoice->Clear();
        state.EnsureDefaultLevel();
        int selIdx = 0;
        int helixLevel = 1;
        if (const MC::PlacedPiece* bs = state.FindById(h.bottomStubR1))
            helixLevel = bs->levelId;
        for (int i = 0; i < (int)state.levels.size(); ++i) {
            auto& l = state.levels[i];
            m_levelChoice->Append(wxString::FromUTF8(l.name),
                                  reinterpret_cast<void*>((intptr_t)l.id));
            if (l.id == helixLevel) selIdx = i;
        }
        m_levelChoice->SetSelection(selIdx);
        m_levelChoice->Enable(true);
    }

    Layout();
}

void PropertiesPanel::ShowEmpty() {
    m_selectedId = -1;
    m_selectedSceneryId = -1;
    m_selectedHelixId = -1;
    m_selectedShapeId = -1;
    m_lblName->SetLabel("No selection");
    m_lblInfo->SetLabel("");
    if (m_lblHeightVal) m_lblHeightVal->SetLabel("-");
    m_lblGradeVal->SetLabel("-");
    m_lblWarn->SetLabel("");
    if (m_levelChoice) m_levelChoice->Enable(false);
    Layout();
}

void PropertiesPanel::ShowPieceProperties(const MC::PlacedPiece& piece,
                                           const MC::TrackPiece& def)
{
    m_selectedId = piece.id;
    m_selectedSceneryId = -1;
    m_selectedHelixId = -1;
    m_selectedShapeId = -1;

    m_lblName->SetLabel(wxString::FromUTF8(def.displayName));

    std::ostringstream info;
    info << def.seriesName() << "  |  ";
    if (def.lengthMm > 0)
        info << std::fixed << std::setprecision(1) << def.lengthMm << " mm";
    m_lblInfo->SetLabel(wxString::FromUTF8(info.str()));

    // Height (read-only)
    auto& state = m_frame->State();
    double displayZ = piece.z_mm;

    double zDisplay;
    wxString zUnit;
    if      (state.units == 1) { zDisplay = displayZ / 10.0;  zUnit = "cm"; }
    else if (state.units == 2) { zDisplay = displayZ / 25.4;  zUnit = "in"; }
    else                       { zDisplay = displayZ;          zUnit = "mm"; }
    if (m_lblHeight)
        m_lblHeight->SetLabel(wxString::Format("Height (%s):", zUnit));

    if (m_lblHeightVal) {
        std::ostringstream zs;
        zs << std::fixed << std::setprecision(state.units == 2 ? 3 : 1) << zDisplay;
        m_lblHeightVal->SetLabel(wxString::FromUTF8(zs.str()));
    }

    // Grade (read-only)
    double gradeDeg = 0.0;
    double gradePct = 0.0;
    bool   hasGrade = false;
    wxString gradeStr = "-";
    wxString warnStr;

    if (std::abs(piece.gradeDeg) > 0.001) {
        gradeDeg = piece.gradeDeg;
        gradePct = std::tan(std::abs(gradeDeg) * M_PI / 180.0) * 100.0;
        hasGrade = true;
        std::ostringstream gs;
        gs << std::fixed << std::setprecision(2);
        gs << (gradeDeg >= 0 ? "+" : "-") << std::abs(gradeDeg) << " deg ("
           << std::setprecision(1) << gradePct << "%)";
        gradeStr = wxString::FromUTF8(gs.str());
        if (std::abs(gradeDeg) > kMaxGradeDeg)
            warnStr = wxString::Format(
                "WARNING: Grade %.1f deg exceeds %.0f deg max!",
                std::abs(gradeDeg), kMaxGradeDeg);
    }

    m_lblGradeVal->SetLabel(gradeStr);
    m_lblWarn->SetLabel(warnStr);

    if (hasGrade && std::abs(gradeDeg) > kMaxGradeDeg)
        m_lblGradeVal->SetForegroundColour(wxColour(200, 40, 40));
    else
        m_lblGradeVal->SetForegroundColour(
            wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT));

    // Level choice
    m_levelChoice->Clear();
    state.EnsureDefaultLevel();
    int selIdx = 0;
    for (int i = 0; i < (int)state.levels.size(); ++i) {
        auto& l = state.levels[i];
        m_levelChoice->Append(wxString::FromUTF8(l.name),
                              reinterpret_cast<void*>((intptr_t)l.id));
        if (l.id == piece.levelId) selIdx = i;
    }
    m_levelChoice->SetSelection(selIdx);
    m_levelChoice->Enable(true);

    Layout();
}

void PropertiesPanel::OnColorApply(wxCommandEvent&) {
    static const struct { wxColour col; } kColors[] = {
        { wxColour(  0,   0,   0,   0) },  // Default (no color)
        { wxColour(220,  60,  60)      },  // Red
        { wxColour(230, 140,  40)      },  // Orange
        { wxColour(220, 200,  50)      },  // Yellow
        { wxColour( 60, 170,  60)      },  // Green
        { wxColour( 60, 100, 200)      },  // Blue
        { wxColour(140,  60, 180)      },  // Purple
        { wxColour(210,  90, 150)      },  // Pink
        { wxColour(240, 240, 240)      },  // White
    };
    if (!m_colorChoice) return;
    int sel = m_colorChoice->GetSelection();
    if (sel < 0) return;
    wxColour col = kColors[sel].col;

    auto& state = m_frame->State();
    auto selected = state.Selected();
    if (selected.empty()) return;

    state.PushUndo();
    for (auto* p : selected) {
        if (col.Alpha() == 0) {
            p->colorR = p->colorG = p->colorB = 0;
            p->hasColor = false;
        } else {
            p->colorR = col.Red();
            p->colorG = col.Green();
            p->colorB = col.Blue();
            p->hasColor = true;
        }
    }
    m_frame->MarkDirty();
    state.ClearSelection();
    m_frame->OnSelectionChanged();
    m_frame->RefreshCanvas();
}

void PropertiesPanel::FocusLabelField(MC::Vec2 worldPos) {
    m_labelPos    = worldPos;
    m_labelPosSet = true;
    if (m_txtLabel) {
        m_txtLabel->SetFocus();
        m_txtLabel->SelectAll();
    }
}

void PropertiesPanel::OnLabelOK(wxCommandEvent&) {
    if (!m_txtLabel) return;
    wxString text = m_txtLabel->GetValue().Trim();
    if (text.IsEmpty()) return;

    auto& state = m_frame->State();
    state.PushUndo();
    MC::CanvasLabel lbl;
    lbl.id   = state.nextId++;
    lbl.text = text.ToStdString();
    // If the user clicked a spot on the canvas to set a position, use it.
    // Otherwise drop the label at world origin so the user can drag it
    // wherever they like; this lifts the old "you must click first"
    // restriction.
    lbl.pos  = m_labelPosSet ? m_labelPos : MC::Vec2{0, 0};
    state.labels.push_back(lbl);

    m_txtLabel->Clear();
    m_labelPosSet = false;
    m_frame->MarkDirty();
    m_frame->RefreshCanvas();
}

void PropertiesPanel::OnLevelChanged(wxCommandEvent&) {
    if (!m_frame || !m_levelChoice) return;
    int sel = m_levelChoice->GetSelection();
    if (sel == wxNOT_FOUND) return;
    int newLevelId = (int)(intptr_t)m_levelChoice->GetClientData(sel);
    // -1 is the sentinel for the "(mixed)" placeholder row inserted when
    // a multi-piece selection straddles levels. Selecting it is a no-op.
    if (newLevelId < 0) return;
    auto& state = m_frame->State();
    const MC::Level* lvl = state.FindLevel(newLevelId);
    double lvlHeight = lvl ? lvl->heightMm : 0.0;

    // Scenery item selected → reassign its level. Scenery inherits the
    // level's physical height (matters in 3D).
    if (m_selectedSceneryId >= 0) {
        MC::PlacedScenery* s = state.FindSceneryById(m_selectedSceneryId);
        if (!s) return;
        state.PushUndo();
        s->levelId = newLevelId;
        state.activeLevelId = newLevelId;
        m_frame->MarkDirty();
        m_frame->RefreshCanvas();
        return;
    }

    // Free-form polygon shape selected → reassign its level.
    if (m_selectedShapeId >= 0) {
        MC::CanvasShape* sp = nullptr;
        for (auto& s : state.shapes) if (s.id == m_selectedShapeId) { sp = &s; break; }
        if (!sp) return;
        state.PushUndo();
        sp->levelId = newLevelId;
        state.activeLevelId = newLevelId;
        m_frame->MarkDirty();
        m_frame->RefreshCanvas();
        return;
    }

    // Helix selected → reassign all 4 stubs to the new level. Bottom stubs
    // sit at the level's height; top stubs sit at level height + variant
    // total rise (the helix's own intrinsic climb). External track chained
    // off the top stubs is propagated to the new level too.
    if (m_selectedHelixId >= 0) {
        const MC::Helix* hp = state.HelixById(m_selectedHelixId);
        if (!hp) return;
        const MC::HelixVariant* v = MC::FindHelixVariant(hp->variantId);
        if (!v) v = MC::FindHelixVariant(MC::DefaultHelixVariantId());
        double topZ = lvlHeight + (v ? v->totalRise : 0.0);
        state.PushUndo();
        int bR1 = hp->bottomStubR1, bR2 = hp->bottomStubR2;
        int tR1 = hp->topStubR1,    tR2 = hp->topStubR2;
        for (auto& p : state.pieces) {
            if (p.id == bR1 || p.id == bR2) {
                p.levelId = newLevelId; p.z_mm = lvlHeight;
            } else if (p.id == tR1 || p.id == tR2) {
                p.levelId = newLevelId; p.z_mm = topZ;
            }
        }
        // Propagate to external chains hanging off the top stubs (deck track
        // above the helix should follow with it).
        std::vector<int> stack;
        std::unordered_set<int> seen;
        auto seedFrom = [&](int stubId) {
            MC::PlacedPiece* s = state.FindById(stubId);
            if (!s) return;
            for (const auto& j : s->joints) {
                if (j.pieceId < 0) continue;
                MC::PlacedPiece* nb = state.FindById(j.pieceId);
                if (!nb || nb->helixId != -1) continue;
                if (seen.insert(j.pieceId).second) stack.push_back(j.pieceId);
            }
        };
        seedFrom(tR1); seedFrom(tR2);
        while (!stack.empty()) {
            int id = stack.back(); stack.pop_back();
            MC::PlacedPiece* p = state.FindById(id);
            if (!p) continue;
            p->levelId = newLevelId;
            if (std::abs(p->gradeDeg) < 0.001) p->z_mm = topZ;
            for (const auto& j : p->joints) {
                if (j.pieceId < 0 || seen.count(j.pieceId)) continue;
                MC::PlacedPiece* nb = state.FindById(j.pieceId);
                if (!nb || nb->helixId != -1) continue;
                seen.insert(j.pieceId);
                stack.push_back(j.pieceId);
            }
        }
        state.activeLevelId = newLevelId;
        m_frame->MarkDirty();
        m_frame->RefreshCanvas();
        return;
    }

    // Track pieces selected -> reassign EVERY selected piece's level.
    // Connected pieces outside the selection are left alone; future
    // pieces auto-extended off any of these inherit via the normal
    // anchor-inheritance path.
    auto selPieces = state.Selected();
    if (selPieces.empty()) return;

    bool anyChanged = false;
    state.PushUndo();
    for (auto* p : selPieces) {
        if (!p) continue;
        if (p->helixId != -1) continue;     // don't reassign helix stubs
        p->levelId = newLevelId;
        // Flat (non-graded) pieces snap to the new level's height.
        // Graded pieces keep their computed z (they're a ramp).
        if (std::abs(p->gradeDeg) < 0.001)
            p->z_mm = lvlHeight;
        anyChanged = true;
    }
    if (!anyChanged) {
        // Nothing eligible -- pop the undo we just pushed to keep history
        // clean.
        if (!state.undoStack.empty()) state.undoStack.pop_back();
        return;
    }

    state.activeLevelId = newLevelId;
    m_frame->MarkDirty();
    m_frame->RefreshCanvas();
}
