#pragma once

// BaseCardListPanel<TCard, TSortColumn>
//
// Header-only template that owns ALL the non-game-specific machinery for the
// `wxListCtrl`-backed card table:
//
//   - hidden zero-width spacer column (MSW comctl32 image-list gutter
//     workaround; see `ui_wx/AGENTS.md` for the rationale)
//   - app-owned themed header row (clickable to sort, edge-drag to resize,
//     divider double-click to autosize) - native `wxListCtrl` header is
//     unreliable in Windows dark mode
//   - custom-drawn flag-icon sub-items via `IconListCtrl` so row icons sit
//     pixel-perfect centered under the themed-header icons regardless of
//     column width (native `LVS_REPORT` sub-item images left-anchor with an
//     inset and would never align with our centered header icons)
//   - rebuild guard so DESELECTED/SELECTED storms during rebuild collapse
//     into a single bubbled `EVT_CARD_SELECTED` event
//   - case-insensitive substring filter via `setFilter(...)` and per-column
//     toggle-direction sort via the header click
//
// Game-specific behavior is exposed as virtual hooks the derived class fills
// in (template method pattern):
//
//   declareTextColumns()   -> spec list (label, width, format) for the leading
//                             "value-key" columns and the trailing Note column
//   declareIconColumns()   -> spec list (svg, width, sortColumn) for icon-only
//                             flag columns (foil/signed/altered, holo, ...)
//   renderTextCell(card, idx) -> cell string for text column `idx`
//   isIconColumnSet(card, idx) -> whether the n-th icon column shows for this card
//   sortColumnForListIdx(col)  -> map physical wxListCtrl column to sort key
//   sortBy(col, asc)           -> in-place stable sort of `cards_`
//   matchesFilter(card, f)     -> case-insensitive row matcher
//
// New games extend this template — see `MagicCardListPanel` and
// `PokemonCardListPanel` for the canonical patterns.

#include "ccm/ui/IconListCtrl.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/ui/Theme.hpp"

#include <wx/bitmap.h>
#include <wx/clipbrd.h>
#include <wx/colour.h>
#include <wx/cursor.h>
#include <wx/dataobj.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/listctrl.h>
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/textctrl.h>
#include <wx/statbmp.h>
#include <wx/stattext.h>
#include <wx/utils.h>

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>

namespace ccm::ui {

// Single shared selection-changed event. The base panel raises this on the
// parent every time the active card changes (after a rebuild settles, after a
// user click, etc.). Defined once in `BaseEvents.cpp` so the wxEvent table is
// not duplicated per template instantiation.
wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);

// Raised on `wxEVT_LIST_ITEM_ACTIVATED` (double-click / Enter on a row).
// `IGameView` implementations bind this to open Edit for `selected()`.
wxDECLARE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);

// Raised when the list wants a short status-bar note (e.g. clipboard copy).
// `event.GetString()` is the message; MainFrame shows it in the bottom strip.
wxDECLARE_EVENT(EVT_UI_STATUS, wxCommandEvent);

template <typename TCard, typename TSortColumn>
class BaseCardListPanel : public wxPanel {
public:
    using card_type = TCard;
    using sort_column_type = TSortColumn;

    // Replace the displayed rows. When preferSelectId is set, selects that
    // card exclusively if present (used after Add). Otherwise preserves the
    // previously selected card ids when still present; the first-row CallAfter
    // path in rebuildRows() runs only when there was no prior selection
    // (startup).
    void setCards(std::vector<TCard> cards,
                  std::optional<std::uint32_t> preferSelectId = std::nullopt) {
        std::optional<std::vector<std::uint32_t>> keepIds;
        if (preferSelectId) {
            keepIds = std::vector<std::uint32_t>{*preferSelectId};
        } else {
            auto ids = selectedIds();
            if (!ids.empty()) keepIds = std::move(ids);
        }
        cards_ = std::move(cards);
        // Drop sort state when the underlying data is replaced - the indicator
        // shown in the header should match the order actually rendered, and
        // wxListCtrl keeps the indicator across DeleteAllItems().
        nextDirByCol_.clear();
        list_->RemoveSortIndicator();
        rebuildRows(keepIds);
        if (!autoSizedOnce_ && !cards_.empty()) {
            autoSizeAllColumns();
            autoSizedOnce_ = true;
        }
    }

    // Update the filter string and rebuild the visible rows in place. The
    // panel preserves previously-selected cards across the rebuild when they
    // still match the new filter; otherwise the first remaining row is
    // selected, or none if the filter excluded everything. A single
    // EVT_CARD_SELECTED is emitted afterwards so the parent re-syncs.
    void setFilter(std::string_view filter) {
        if (filter_ == filter) return;
        filter_.assign(filter);
        auto ids = selectedIds();
        std::optional<std::vector<std::uint32_t>> keepIds;
        if (!ids.empty()) keepIds = std::move(ids);
        suppressListFocus_ = true;
        rebuildRows(keepIds);
        suppressListFocus_ = false;
    }

    void applyTheme(const ThemePalette& palette) {
        list_->SetBackgroundColour(palette.inputBg);
        list_->SetForegroundColour(palette.inputText);
        SetBackgroundColour(palette.panelBg);
        SetForegroundColour(palette.text);
        rebuildIconBitmaps(palette.inputText, wxColour(255, 255, 255));
        refreshHeaderTheme(palette);
        auto ids = selectedIds();
        std::optional<std::vector<std::uint32_t>> keepIds;
        if (!ids.empty()) keepIds = std::move(ids);
        rebuildRows(keepIds);
        Refresh();
    }

    [[nodiscard]] const std::vector<TCard>& cards() const noexcept { return cards_; }
    [[nodiscard]] const std::string&        filter() const noexcept { return filter_; }
    // First selected card (detail panel / single-edit primary).
    [[nodiscard]] std::optional<TCard>      selected() const {
        const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
        if (const TCard* c = cardForRow(sel)) return *c;
        return std::nullopt;
    }
    [[nodiscard]] std::size_t selectedCount() const {
        if (list_ == nullptr) return 0;
        std::size_t n = 0;
        long row = -1;
        while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
            ++n;
        }
        return n;
    }
    [[nodiscard]] std::vector<TCard> selectedCards() const {
        std::vector<TCard> out;
        if (list_ == nullptr) return out;
        long row = -1;
        while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
            if (const TCard* c = cardForRow(row)) out.push_back(*c);
        }
        return out;
    }
    [[nodiscard]] std::vector<std::uint32_t> selectedIds() const {
        std::vector<std::uint32_t> out;
        if (list_ == nullptr) return out;
        long row = -1;
        while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
            if (const TCard* c = cardForRow(row)) out.push_back(c->id);
        }
        return out;
    }

    // Ensure the first selected row is actively focused so Windows uses the
    // active highlight color (blue in light mode), keeping selected-row icons
    // legible. Does not clear a multi-selection.
    void activateSelection() {
        if (list_ == nullptr || list_->GetItemCount() <= 0) return;
        long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
        if (row < 0) row = 0;
        list_->SetItemState(row,
                            wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
                            wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
        list_->EnsureVisible(row);
        if (dynamic_cast<wxTextCtrl*>(wxWindow::FindFocus()) == nullptr)
            list_->SetFocus();
    }

    // Move the selection by `delta` rows (+1 / -1). Used when Up/Down are
    // pressed while focus is on the filter box. Collapses any multi-selection
    // to a single row. Clamps to the visible range; leaves list HWND focus
    // alone so the caret can stay in the filter.
    void nudgeSelection(int delta) {
        if (list_ == nullptr || list_->GetItemCount() <= 0 || delta == 0) return;
        long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
        if (row < 0) row = 0;
        const long count = list_->GetItemCount();
        long next = row + delta;
        if (next < 0) next = 0;
        if (next >= count) next = count - 1;
        suppressListFocus_ = true;
        // Clear every selected row so filter nudge is always single-select.
        long sel = -1;
        while ((sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
            list_->SetItemState(sel, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
        }
        list_->SetItemState(next,
                            wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
                            wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
        list_->EnsureVisible(next);
        suppressListFocus_ = false;
    }

protected:
    // Column descriptor types -------------------------------------------------

    struct TextColumnSpec {
        std::string        label;
        int                width;
        wxListColumnFormat format;  // wxLIST_FORMAT_LEFT / RIGHT / CENTER
        std::optional<TSortColumn> sortColumn;  // none = not sortable
    };

    struct IconColumnSpec {
        const char* svg;
        int         width;
        std::optional<TSortColumn> sortColumn;
    };

    // Subclass hooks ----------------------------------------------------------

    // Subclass declares its leading text columns (Name, Set, ...). Order
    // matches the on-screen left-to-right ordering. The trailing "Note" column
    // is also returned here as the last entry — it is added AFTER the icon
    // columns by the base.
    [[nodiscard]] virtual std::vector<TextColumnSpec> declareTextColumns() const = 0;

    // Subclass declares the icon flag columns (Foil/Signed/Altered, etc.).
    // These render between the leading text columns and the trailing Note.
    [[nodiscard]] virtual std::vector<IconColumnSpec> declareIconColumns() const = 0;

    [[nodiscard]] virtual std::string renderTextCell(const TCard& card, std::size_t idx) const = 0;
    [[nodiscard]] virtual bool        isIconColumnSet(const TCard& card, std::size_t idx) const = 0;

    virtual void sortBy(TSortColumn column, bool ascending) = 0;
    [[nodiscard]] virtual bool matchesFilter(const TCard& card, std::string_view filter) const = 0;

    // Construction ------------------------------------------------------------

    explicit BaseCardListPanel(wxWindow* parent) : wxPanel(parent, wxID_ANY) {}

    // Subclass calls this once from its constructor body (after virtual hooks
    // are reachable) to wire up columns + the header row + custom-draw hooks.
    void buildLayout() {
        // Multi-select: native Ctrl (toggle) and Shift (range) without
        // wxLC_SINGLE_SEL. Set-completion tables keep single-select separately.
        list_ = new IconListCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize,
                                 wxLC_REPORT | wxLC_NO_HEADER);

        textCols_ = declareTextColumns();
        iconCols_ = declareIconColumns();

        // Note must be the *last* text column. We render it after the icons.
        // Layout: [hidden spacer] [textCols_-1 leading text cols] [icon cols] [last text col].
        if (textCols_.empty()) {
            // No text columns at all is unsupported; the trailing note column
            // is required by the panel layout.
            textCols_.push_back({"Note", 220, wxLIST_FORMAT_LEFT, std::nullopt});
        }

        buildHeaderRow();

        // Column 0 is a hidden spacer kept for historical reasons (it used
        // to swallow MSW's mandatory item-icon gutter when we had an image
        // list). It is harmless now that row icons go through NM_CUSTOMDRAW
        // and is preserved so existing column-index math stays correct.
        list_->AppendColumn("", wxLIST_FORMAT_LEFT, 0);

        // Leading text columns (everything except the last).
        for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
            list_->AppendColumn(textCols_[i].label, textCols_[i].format, textCols_[i].width);
        }
        // Icon columns. Format is irrelevant here — we paint the icon
        // ourselves, exactly centered, in `IconListCtrl::MSWOnNotify`.
        for (const auto& ic : iconCols_) {
            list_->AppendColumn("", wxLIST_FORMAT_CENTER, ic.width);
        }
        rebuildIconBitmaps(wxColour(20, 20, 20), wxColour(255, 255, 255));
        // Trailing Note column.
        const auto& last = textCols_.back();
        list_->AppendColumn(last.label, last.format, last.width);

        // Wire NM_CUSTOMDRAW callbacks so row icons render centered in their
        // sub-item rect. The predicate maps a (row, iconIdx) back through the
        // filtered card vector so we ask the same `isIconColumnSet(...)` hook
        // the rest of the panel uses. The bitmap cache was already pushed
        // into `list_` by `rebuildIconBitmaps(...)` above.
        list_->setIconColumns(firstIconColIdx(), iconColCount());
        list_->setIconPredicate([this](long row, int iconIdx) {
            const TCard* c = cardForRow(row);
            if (c == nullptr) return false;
            if (iconIdx < 0 || static_cast<std::size_t>(iconIdx) >= iconCols_.size()) {
                return false;
            }
            return isIconColumnSet(*c, static_cast<std::size_t>(iconIdx));
        });

        auto* sizer = new wxBoxSizer(wxVERTICAL);
        sizer->Add(headerRow_, 0, wxEXPAND);
        sizer->Add(list_, 1, wxEXPAND);
        SetSizer(sizer);

        list_->Bind(wxEVT_LIST_ITEM_SELECTED,   &BaseCardListPanel::onSelectionChanged, this);
        list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this);
        list_->Bind(wxEVT_LIST_ITEM_ACTIVATED, &BaseCardListPanel::onListItemActivated, this);
        list_->Bind(wxEVT_KEY_DOWN, &BaseCardListPanel::onListKeyDown, this);
    }

    // Forwarded helpers ------------------------------------------------------

    // wxListCtrl column indices for derived helpers.
    [[nodiscard]] int firstTextColIdx() const noexcept { return 1; }
    [[nodiscard]] int firstIconColIdx() const noexcept {
        return 1 + static_cast<int>(textCols_.size()) - 1;
    }
    [[nodiscard]] int noteColIdx() const noexcept {
        return firstIconColIdx() + static_cast<int>(iconCols_.size());
    }
    [[nodiscard]] int textColCount() const noexcept {
        return static_cast<int>(textCols_.size());
    }
    [[nodiscard]] int iconColCount() const noexcept {
        return static_cast<int>(iconCols_.size());
    }

    [[nodiscard]] wxListCtrl* listCtrl() const noexcept { return list_; }

    // Mutable access to the underlying vector for the typed `sortBy` hook
    // (the sort runs in-place on the same vector the base owns, so we can't
    // hand the subclass a copy).
    [[nodiscard]] std::vector<TCard>& mutableCards() noexcept { return cards_; }

private:
    // ----- header row construction -------------------------------------------

    void buildHeaderRow() {
        headerRow_ = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE);
        auto* s = new wxBoxSizer(wxHORIZONTAL);
        headerCells_.clear();
        headerCellToCol_.clear();
        headerIcons_.clear();

        auto bindHeaderEvents = [this](wxWindow* hit, int col) {
            hit->Bind(wxEVT_LEFT_DOWN, [this, col](wxMouseEvent& ev) { onHeaderMouseDown(col, ev); });
            hit->Bind(wxEVT_MOTION,    [this, col](wxMouseEvent& ev) { onHeaderMouseMove(col, ev); });
            hit->Bind(wxEVT_LEFT_UP,   [this](wxMouseEvent& ev)      { onHeaderMouseUp(ev); });
            hit->Bind(wxEVT_LEFT_DCLICK,
                      [this, col](wxMouseEvent& ev) { onHeaderDoubleClick(col, ev); });
        };
        auto addText = [&](const wxString& label, int width, int col) {
            auto* p = new wxPanel(headerRow_, wxID_ANY, wxDefaultPosition, wxSize(width, -1), wxBORDER_NONE);
            auto* ps = new wxBoxSizer(wxHORIZONTAL);
            auto* t = new wxStaticText(p, wxID_ANY, label);
            ps->Add(t, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 4);
            p->SetSizer(ps);
            p->SetMinSize(wxSize(width, -1));
            bindHeaderEvents(p, col);
            bindHeaderEvents(t, col);
            s->Add(p, 0, wxEXPAND);
            headerCells_.push_back(p);
            headerCellToCol_[p] = col;
        };
        auto addIcon = [&](const char* svg, int width, int col) {
            auto* p = new wxPanel(headerRow_, wxID_ANY, wxDefaultPosition, wxSize(width, -1), wxBORDER_NONE);
            auto* ps = new wxBoxSizer(wxHORIZONTAL);
            auto bmp = svgIconBitmap(svg, kFlagIconSize, "#E6E6E6");
            auto* b = new wxStaticBitmap(p, wxID_ANY, bmp);
            ps->AddStretchSpacer(1);
            ps->Add(b, 0, wxALIGN_CENTER_VERTICAL);
            ps->AddStretchSpacer(1);
            p->SetSizer(ps);
            p->SetMinSize(wxSize(width, -1));
            bindHeaderEvents(p, col);
            bindHeaderEvents(b, col);
            s->Add(p, 0, wxEXPAND);
            headerCells_.push_back(p);
            headerCellToCol_[p] = col;
            headerIcons_.push_back({b, svg});
        };

        const int firstText = firstTextColIdx();
        // Leading text columns.
        for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
            addText(textCols_[i].label, textCols_[i].width, firstText + static_cast<int>(i));
        }
        const int firstIcon = firstIconColIdx();
        for (std::size_t i = 0; i < iconCols_.size(); ++i) {
            addIcon(iconCols_[i].svg, iconCols_[i].width, firstIcon + static_cast<int>(i));
        }
        const int noteCol = noteColIdx();
        addText(textCols_.back().label, textCols_.back().width, noteCol);

        headerRow_->SetSizer(s);

        // Header is mouse-only (sort / resize). Keep it out of the tab order so
        // Up/Down after a header click still drive the list, not wx focus travel.
        headerRow_->SetCanFocus(false);
        for (wxWindow* cell : headerCells_) {
            if (cell == nullptr) continue;
            cell->SetCanFocus(false);
            for (wxWindow* child : cell->GetChildren()) {
                if (child != nullptr) child->SetCanFocus(false);
            }
        }
    }

    // ----- header drag-resize / sort hit-test ---------------------------------

    [[nodiscard]] bool isResizeGripHit(int col, int x) const {
        const int firstText = firstTextColIdx();
        if (col < firstText || col > noteColIdx()) return false;
        const std::size_t idx = static_cast<std::size_t>(col - firstText);
        if (idx >= headerCells_.size() || headerCells_[idx] == nullptr) return false;
        const int w = headerCells_[idx]->GetSize().GetWidth();
        return x >= (w - kResizeGripPx);
    }

    void setColumnWidth(int col, int width) {
        // Icon columns get a tighter min so they don't grow when dragged.
        const int firstIcon = firstIconColIdx();
        const int lastIcon  = firstIcon + iconColCount() - 1;
        const int minWidth = (col >= firstIcon && col <= lastIcon) ? 24 : 40;
        const int nextWidth = std::max(minWidth, width);
        list_->SetColumnWidth(col, nextWidth);
        const std::size_t idx = static_cast<std::size_t>(col - firstTextColIdx());
        if (idx < headerCells_.size() && headerCells_[idx] != nullptr) {
            headerCells_[idx]->SetMinSize(wxSize(nextWidth, -1));
        }
        // Row icons are drawn from the live sub-item rect via NM_CUSTOMDRAW,
        // so column resizing automatically re-centers them on the next paint
        // — no image-list rebuild needed.
        headerRow_->Layout();
    }

    void autoSizeColumn(int col) {
        list_->SetColumnWidth(col, wxLIST_AUTOSIZE);
        const int contentWidth = list_->GetColumnWidth(col);
        list_->SetColumnWidth(col, wxLIST_AUTOSIZE_USEHEADER);
        const int headerWidth = list_->GetColumnWidth(col);
        setColumnWidth(col, std::max(contentWidth, headerWidth));
    }

    void autoSizeAllColumns() {
        for (int col = firstTextColIdx(); col <= noteColIdx(); ++col) {
            autoSizeColumn(col);
        }
    }

    void onHeaderMouseDown(int col, wxMouseEvent& ev) {
        wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
        wxWindow* cell = src;
        while (cell != nullptr && cell->GetParent() != headerRow_) {
            cell = cell->GetParent();
        }
        if (cell == nullptr) return;
        const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
        if (!isResizeGripHit(col, posInCell.x)) return;

        resizingCol_ = true;
        activeResizeCol_ = col;
        resizeStartScreenX_ = wxGetMousePosition().x;
        resizeStartWidth_ = list_->GetColumnWidth(col);
        cell->CaptureMouse();
    }

    void onHeaderMouseMove(int col, wxMouseEvent& ev) {
        wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
        wxWindow* cell = src;
        while (cell != nullptr && cell->GetParent() != headerRow_) {
            cell = cell->GetParent();
        }
        if (cell == nullptr) return;

        if (resizingCol_ && activeResizeCol_ == col && cell->HasCapture()) {
            const int delta = wxGetMousePosition().x - resizeStartScreenX_;
            setColumnWidth(col, resizeStartWidth_ + delta);
            return;
        }

        const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
        cell->SetCursor(isResizeGripHit(col, posInCell.x)
            ? wxCursor(wxCURSOR_SIZEWE)
            : wxCursor(wxCURSOR_ARROW));
    }

    void onHeaderMouseUp(wxMouseEvent& ev) {
        const bool wasResizing = resizingCol_;
        wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
        wxWindow* cell = src;
        while (cell != nullptr && cell->GetParent() != headerRow_) {
            cell = cell->GetParent();
        }
        if (cell != nullptr && cell->HasCapture()) {
            cell->ReleaseMouse();
        }
        resizingCol_ = false;
        if (suppressNextHeaderClick_) {
            suppressNextHeaderClick_ = false;
            activeResizeCol_ = -1;
            return;
        }
        if (!wasResizing && cell != nullptr) {
            auto it = headerCellToCol_.find(cell);
            if (it != headerCellToCol_.end()) {
                onHeaderClick(it->second);
            }
        }
        activeResizeCol_ = -1;
    }

    void onHeaderDoubleClick(int col, wxMouseEvent& ev) {
        wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
        wxWindow* cell = src;
        while (cell != nullptr && cell->GetParent() != headerRow_) {
            cell = cell->GetParent();
        }
        if (cell == nullptr) return;

        const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
        if (isResizeGripHit(col, posInCell.x)) {
            suppressNextHeaderClick_ = true;
            autoSizeColumn(col);
        }
    }

    // Map a physical wxListCtrl column to a sort column. Looks at the
    // declared TextColumnSpec/IconColumnSpec lists to find the optional
    // `sortColumn` for each column. Returns nullopt for non-sortable columns
    // (the spacer column 0 or any text/icon column without a sort key).
    [[nodiscard]] std::optional<TSortColumn> sortColumnForListIdx(int listColIdx) const {
        if (listColIdx <= 0) return std::nullopt;
        const int firstIcon = firstIconColIdx();
        const int noteCol   = noteColIdx();
        if (listColIdx < firstIcon) {
            const std::size_t i = static_cast<std::size_t>(listColIdx - firstTextColIdx());
            if (i < textCols_.size() - 1) return textCols_[i].sortColumn;
        } else if (listColIdx < noteCol) {
            const std::size_t i = static_cast<std::size_t>(listColIdx - firstIcon);
            if (i < iconCols_.size()) return iconCols_[i].sortColumn;
        } else if (listColIdx == noteCol) {
            return textCols_.back().sortColumn;
        }
        return std::nullopt;
    }

    void onHeaderClick(int col) {
        if (resizingCol_) return;
        const auto sortCol = sortColumnForListIdx(col);
        if (!sortCol) return;

        // Per-column toggle, faithful to TableTemplate.tsx::sortByField.
        auto it = nextDirByCol_.find(*sortCol);
        const bool ascending = (it == nextDirByCol_.end()) ? true : it->second;
        nextDirByCol_[*sortCol] = !ascending;

        auto ids = selectedIds();
        std::optional<std::vector<std::uint32_t>> keepIds;
        if (!ids.empty()) keepIds = std::move(ids);

        sortBy(*sortCol, ascending);
        rebuildRows(keepIds);
        if (list_ != nullptr) list_->SetFocus();
    }

    // ----- cached icon bitmaps for NM_CUSTOMDRAW -----------------------------

    // Pre-renders the per-icon-column bitmaps used by the custom-draw path in
    // `IconListCtrl`. Two color variants per column: the `normal` color for
    // unselected rows (paired with the panel's themed text color) and the
    // `selected` color drawn on the highlighted row. After rebuilding, the
    // bitmaps are pushed into `IconListCtrl` which converts them into a
    // single `HIMAGELIST` for `ImageList_Draw` from `NM_CUSTOMDRAW`. See
    // `ui_wx/AGENTS.md` convention 11 for why earlier `wxGraphicsContext::
    // DrawBitmap` and raw `AlphaBlend` paths were abandoned.
    void rebuildIconBitmaps(const wxColour& normal, const wxColour& selected) {
        iconBitmapsNormal_.clear();
        iconBitmapsSelected_.clear();
        iconBitmapsNormal_.reserve(iconCols_.size());
        iconBitmapsSelected_.reserve(iconCols_.size());
        const std::string normalHex   = normal.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
        const std::string selectedHex = selected.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
        for (const auto& ic : iconCols_) {
            iconBitmapsNormal_.push_back(
                svgIconBitmap(ic.svg, kFlagIconSize, normalHex.c_str()));
            iconBitmapsSelected_.push_back(
                svgIconBitmap(ic.svg, kFlagIconSize, selectedHex.c_str()));
        }
        if (list_ != nullptr) {
            list_->setIconBitmaps(iconBitmapsNormal_, iconBitmapsSelected_);
        }
    }

    void refreshHeaderTheme(const ThemePalette& palette) {
        headerRow_->SetBackgroundColour(palette.inputBg);
        headerRow_->SetForegroundColour(palette.inputText);
        headerRow_->SetOwnBackgroundColour(palette.inputBg);
        headerRow_->SetOwnForegroundColour(palette.inputText);
        for (wxWindow* cell : headerCells_) {
            if (cell == nullptr) continue;
            cell->SetBackgroundColour(palette.inputBg);
            cell->SetForegroundColour(palette.inputText);
            cell->SetOwnBackgroundColour(palette.inputBg);
            cell->SetOwnForegroundColour(palette.inputText);
            const wxWindowList& children = cell->GetChildren();
            for (wxWindowList::compatibility_iterator it = children.GetFirst(); it; it = it->GetNext()) {
                wxWindow* child = it->GetData();
                if (child == nullptr) continue;
                child->SetBackgroundColour(palette.inputBg);
                child->SetForegroundColour(palette.inputText);
                child->SetOwnBackgroundColour(palette.inputBg);
                child->SetOwnForegroundColour(palette.inputText);
            }
        }
        const std::string iconHex = palette.inputText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
        for (auto& it : headerIcons_) {
            if (it.first == nullptr || it.second == nullptr) continue;
            it.first->SetBitmap(svgIconBitmap(it.second, kFlagIconSize, iconHex.c_str()));
        }
        headerRow_->Refresh();
    }

    // ----- row rendering -----------------------------------------------------

    // nullopt keepIds → no prior selection (startup / empty): defer first-row
    // select. Otherwise restore every id that is still visible after filter.
    void rebuildRows(std::optional<std::vector<std::uint32_t>> keepIds = std::nullopt) {
        // Suppress wxListCtrl's natural DESELECTED (from DeleteAllItems) and
        // SELECTED (from the SetItemState below) events while we churn through
        // the rebuild. See `ui_wx/AGENTS.md` for the rate-limit rationale.
        inRebuild_ = true;
        list_->DeleteAllItems();

        filteredIndices_.clear();
        filteredIndices_.reserve(cards_.size());
        for (std::size_t i = 0; i < cards_.size(); ++i) {
            if (matchesFilter(cards_[i], filter_)) {
                filteredIndices_.push_back(i);
            }
        }

        std::unordered_set<std::uint32_t> keepSet;
        if (keepIds) {
            keepSet.insert(keepIds->begin(), keepIds->end());
        }

        long row = 0;
        long firstRestored = -1;
        const int firstText = firstTextColIdx();
        const int noteCol   = noteColIdx();
        std::vector<long> rowsToSelect;
        for (std::size_t srcIdx : filteredIndices_) {
            const auto& c = cards_[srcIdx];
            // Insert via the hidden column-0 spacer. We never set sub-item
            // images: row icons are drawn through `IconListCtrl` custom-draw
            // straight onto the device context, exactly centered in the cell.
            wxListItem spacerItem;
            spacerItem.SetId(row);
            spacerItem.SetText("");
            spacerItem.SetImage(-1);
            spacerItem.SetMask(wxLIST_MASK_TEXT | wxLIST_MASK_IMAGE);
            const long idx = list_->InsertItem(spacerItem);

            // Leading text columns.
            for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
                const std::string cell = renderTextCell(c, i);
                list_->SetItem(idx, firstText + static_cast<int>(i),
                               wxString::FromUTF8(cell.c_str()));
            }
            // Trailing Note text column. Icon columns intentionally have no
            // text and no image — the custom-draw paints them.
            const std::string note = renderTextCell(c, textCols_.size() - 1);
            list_->SetItem(idx, noteCol, wxString::FromUTF8(note.c_str()));

            if (!keepSet.empty() && keepSet.count(c.id) != 0) {
                rowsToSelect.push_back(idx);
                if (firstRestored < 0) firstRestored = idx;
            }
            ++row;
        }
        bool deferredInitialSelect = false;
        if (!filteredIndices_.empty() && !rowsToSelect.empty()) {
            for (long r : rowsToSelect) {
                const long flags = (r == firstRestored)
                                       ? (wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED)
                                       : wxLIST_STATE_SELECTED;
                list_->SetItemState(r, flags, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
            }
            list_->EnsureVisible(firstRestored);
        } else if (!filteredIndices_.empty() && !keepIds.has_value()) {
            // Defer the initial selection to the next event turn so first
            // paint stays responsive.
            deferredInitialSelect = true;
            CallAfter([this]() {
                if (list_ == nullptr || list_->GetItemCount() <= 0) return;
                const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
                if (sel >= 0) return;
                list_->SetItemState(0,
                                    wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
                                    wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
                list_->EnsureVisible(0);
            });
        }

        inRebuild_ = false;
        if (!deferredInitialSelect) {
            notifySelectionChanged();
        }
    }

    void notifySelectionChanged() {
        // Fires the event on the panel itself. The owning IGameView binds
        // directly to its typed list panel so the typed selection wiring stays
        // local (MainFrame only sees IGameView, never MagicCard / PokemonCard).
        wxCommandEvent ev(EVT_CARD_SELECTED, GetId());
        ev.SetEventObject(this);
        ProcessWindowEvent(ev);
    }

    [[nodiscard]] const TCard* cardForRow(long row) const noexcept {
        if (row < 0) return nullptr;
        const auto r = static_cast<std::size_t>(row);
        if (r >= filteredIndices_.size()) return nullptr;
        const std::size_t srcIdx = filteredIndices_[r];
        if (srcIdx >= cards_.size()) return nullptr;
        return &cards_[srcIdx];
    }

    void onSelectionChanged(wxListEvent& event) {
        // wxListCtrl invalidates the row when its selection state changes,
        // which re-fires NM_CUSTOMDRAW with the new `CDIS_SELECTED` flag.
        // The icon bitmap provider returns the selected-color variant, so
        // no per-row icon swap is required here.
        (void)event;
        if (inRebuild_) return;
        // Row click / native arrow keys: keep HWND focus on the list. Filter
        // nudge sets suppressListFocus_ so the caret stays in the text box.
        // Also skip the focus grab when any wxTextCtrl already has focus
        // (covers the deferred CallAfter select that fires after setFilter
        // has reset suppressListFocus_).
        if (!suppressListFocus_ && list_ != nullptr) {
            if (dynamic_cast<wxTextCtrl*>(wxWindow::FindFocus()) == nullptr)
                list_->SetFocus();
        }
        notifySelectionChanged();
    }

    void onListItemActivated(wxListEvent& event) {
        (void)event;
        if (inRebuild_) return;
        // Defer so ShowModal (Edit) does not run inside the list notify path.
        CallAfter([this]() {
            if (inRebuild_) return;
            wxCommandEvent ev(EVT_CARD_ACTIVATED, GetId());
            ev.SetEventObject(this);
            ProcessWindowEvent(ev);
        });
    }

    void onListKeyDown(wxKeyEvent& event) {
        const int key = event.GetKeyCode();
        const bool copyChord =
            (event.ControlDown() || event.CmdDown()) && (key == 'C' || key == 'c');
        if (!copyChord) {
            event.Skip();
            return;
        }
        copySelectedRowsToClipboard();
    }

    void copySelectedRowsToClipboard() {
        const auto cards = selectedCards();
        if (cards.empty() || textCols_.empty()) return;

        auto formatRow = [&](const TCard& card) {
            std::string line;
            auto appendCell = [&](std::string_view cell) {
                if (!line.empty()) line.push_back('\t');
                line.append(cell);
            };
            // Leading text columns (everything except trailing Note).
            for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
                appendCell(renderTextCell(card, i));
            }
            // Icon/flag columns — no list text; export as true/false.
            for (std::size_t i = 0; i < iconCols_.size(); ++i) {
                appendCell(isIconColumnSet(card, i) ? "true" : "false");
            }
            // Trailing Note.
            appendCell(renderTextCell(card, textCols_.size() - 1));
            return line;
        };

        std::string payload = formatRow(cards.front());
        for (std::size_t i = 1; i < cards.size(); ++i) {
            payload.push_back('\n');
            payload.append(formatRow(cards[i]));
        }

        wxClipboardLocker lock;
        if (!lock) return;
        if (!wxTheClipboard->SetData(
                new wxTextDataObject(wxString::FromUTF8(payload.c_str())))) {
            return;
        }
        if (cards.size() == 1) {
            emitUiStatus("Saved entry to clipboard");
        } else {
            emitUiStatus(wxString::Format("Saved %zu entries to clipboard", cards.size()));
        }
    }

    void emitUiStatus(const wxString& message) {
        wxCommandEvent ev(EVT_UI_STATUS, GetId());
        ev.SetEventObject(this);
        ev.SetString(message);
        // Same parent-hop as BaseSelectedCardPanel::emitPreviewStatus so the
        // command event can propagate up to MainFrame's status strip.
        if (auto* parent = GetParent()) {
            parent->GetEventHandler()->ProcessEvent(ev);
        } else {
            ProcessWindowEvent(ev);
        }
    }

    // ----- members ----------------------------------------------------------

    static constexpr int kFlagIconSize = 14;
    static constexpr int kResizeGripPx = 5;

    wxPanel*               headerRow_{nullptr};
    std::vector<wxWindow*> headerCells_;
    std::vector<std::pair<wxStaticBitmap*, const char*>> headerIcons_;
    std::unordered_map<wxWindow*, int> headerCellToCol_;
    IconListCtrl*          list_{nullptr};

    std::vector<TextColumnSpec> textCols_;
    std::vector<IconColumnSpec> iconCols_;

    bool                   resizingCol_{false};
    bool                   suppressNextHeaderClick_{false};
    bool                   autoSizedOnce_{false};
    int                    activeResizeCol_{-1};
    int                    resizeStartScreenX_{0};
    int                    resizeStartWidth_{0};

    std::vector<TCard>          cards_;
    std::vector<std::size_t>    filteredIndices_;
    std::string                 filter_;

    // Rebuild guard - see ui_wx/AGENTS.md for the burst-suppression rationale.
    bool inRebuild_{false};
    // When true, onSelectionChanged skips list_->SetFocus (filter Up/Down nudge).
    bool suppressListFocus_{false};

    std::map<TSortColumn, bool> nextDirByCol_;

    // Per-icon-column cached bitmaps consumed by `IconListCtrl`'s NM_CUSTOMDRAW
    // path. Index aligns with `iconCols_`.
    std::vector<wxBitmap> iconBitmapsNormal_;
    std::vector<wxBitmap> iconBitmapsSelected_;
};

}  // namespace ccm::ui
