// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3

#include "market_tab.h"
#include "../../app.h"
#include "../../config/version.h"
#include "../../data/wallet_state.h"
#include "../../config/settings.h"
#include "../../data/portfolio.h"
#include "../../data/market_series.h"
#include "../../data/exchange_info.h"
#include "../../util/i18n.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../material/overlay_scroll.h"
#include "../material/colors.h"
#include "../material/typography.h"
#include "../material/project_icons.h"
#include "../notifications.h"
#include "../effects/imgui_acrylic.h"
#include "../effects/scroll_fade_shader.h"
#include "../effects/low_spec.h"
#include "../../util/text_format.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../../util/platform.h"
#include "../layout.h"
#include "imgui.h"

#include <algorithm>
#include <cmath>
#include <ctime>
#include <unordered_set>

namespace dragonx {
namespace ui {

using namespace material;

// ---- Market tab persistent state ----
struct MarketViewState {
    std::vector<double> history;   // mirror of WalletState market.price_history
    int  exchangeIdx = 0;          // selected exchange / pair
    int  pairIdx = 0;
    bool stateLoaded = false;
    int  chartInterval = 4;        // main chart range: 0=Live 1=1H 2=1D 3=1W 4=1M (default 1M)
    int  chartStyle = 1;           // 0 = line, 1 = candlestick (only applies when per-exchange OHLC exists)
};
static MarketViewState s_mkt;

// The effective exchange registry: the live CoinGecko list when available, else the
// compiled-in fallback.
static const std::vector<data::ExchangeInfo>& EffectiveRegistry(const MarketInfo& market)
{
    return market.exchanges.empty() ? data::getExchangeRegistry() : market.exchanges;
}

// Helper: load selected exchange/pair from settings
static void LoadMarketState(config::Settings* settings, const std::vector<data::ExchangeInfo>& registry)
{
    if (s_mkt.stateLoaded || !settings) return;
    s_mkt.stateLoaded = true;

    std::string savedExchange = settings->getSelectedExchange();
    std::string savedPair = settings->getSelectedPair();

    for (int ei = 0; ei < (int)registry.size(); ei++) {
        if (registry[ei].name == savedExchange) {
            s_mkt.exchangeIdx = ei;
            for (int pi = 0; pi < (int)registry[ei].pairs.size(); pi++) {
                if (registry[ei].pairs[pi].displayName == savedPair) {
                    s_mkt.pairIdx = pi;
                    break;
                }
            }
            break;
        }
    }

    // Restore the chart range + style (validated).
    int iv = settings->getChartInterval();
    if (iv >= 0 && iv <= 4) s_mkt.chartInterval = iv;
    int st = settings->getChartStyle();
    if (st == 0 || st == 1) s_mkt.chartStyle = st;
}

// Helper: format compact currency
static std::string FormatCompactUSD(double val)
{
    char buf[64];
    if (val >= 1e9)        snprintf(buf, sizeof(buf), "$%.2fB", val / 1e9);
    else if (val >= 1e6)   snprintf(buf, sizeof(buf), "$%.2fM", val / 1e6);
    else if (val >= 1e3)   snprintf(buf, sizeof(buf), "$%.2fK", val / 1e3);
    else                   snprintf(buf, sizeof(buf), "$%.2f", val);
    return std::string(buf);
}

// Helper: format price to sensible precision
static std::string FormatPrice(double price)
{
    char buf[64];
    if (price >= 0.01)         snprintf(buf, sizeof(buf), "$%.4f", price);
    else if (price >= 0.0001)  snprintf(buf, sizeof(buf), "$%.6f", price);
    else                       snprintf(buf, sizeof(buf), "$%.8f", price);
    return std::string(buf);
}

// ---- Portfolio editor (modal) working state ----
struct PfEditState {
    bool open = false;             // modal visible (queried by PortfolioEditorActive())
    int  sel = -1;                 // selected group index in the master list; -1 = none
    int  section = 0;              // right-pane segmented control: 0=Appearance 1=Price 2=Addresses
    // (The blur backdrop + capture-once are owned by material::BeginOverlayDialog now.)
    // Working copy of the group being edited.
    char label[64] = {0};
    std::vector<std::string> addrs;
    std::string icon;              // selected wallet-icon name ("" = none)
    unsigned int color = 0;        // selected accent (packed IM_COL32; 0 = default)
    int  outlineOpacity = 25;      // accent-outline opacity (percent)
    char search[64] = {0};         // address-list filter text
    char iconSearch[64] = {0};     // icon-picker filter text
    int  typeFilter = 0;           // 0 = all, 1 = shielded, 2 = transparent
    bool fundedOnly = false;
    effects::ScrollFadeShader iconFade;  // edge-fade shaders for the two internal scroll regions
    effects::ScrollFadeShader addrFade;
    float customRgb[3] = {0.31f, 0.62f, 1.0f};  // working RGB for the custom color picker
    // Price-data config (working copy while editing a group).
    int    priceBasis = 0;
    double manualPrice = 0.0;
    char   manualCcy[12] = "USD";
    bool   showDrgx = true;
    bool   showValue = true;
    bool   show24h = false;
    bool   showSparkline = false;
    int    sparkInterval = 4;      // 0=min 1=hour 2=day 3=week 4=month (default month)
};
static PfEditState s_pfEdit;

// Two-stage delete arm/confirm for the master-list per-row delete icon (mirrors the
// contacts tab's s_confirm_delete_idx): first click on a row's trash arms it (icon turns
// red); a second click on the SAME row within the loop confirms the erase. Hovering/clicking
// a different row, or clicking off the icons, disarms. -1 = nothing armed.
static int s_pfConfirmDelIdx = -1;

// The pure price-series math lives in data/market_series.h; the selected chart range is
// s_mkt.chartInterval (0=Live 1=1H 2=1D 3=1W 4=1M, session-scoped, default 1M).


// A group's value trend tracks the DRGX price, so a group sparkline plots the price history
// (normalized) across a rect. Colored by overall direction. Only meaningful for market bases.
// Normalize a numeric series into evenly-spaced screen points within [mn, mx], y scaled to the
// series min..max. Returns false (leaving `out` untouched) when there are fewer than 2 points.
static bool pfProjectSeries(const std::vector<double>& hist, ImVec2 mn, ImVec2 mx,
                            std::vector<ImVec2>& out)
{
    int n = (int)hist.size();
    if (n < 2) return false;
    double lo = *std::min_element(hist.begin(), hist.end());
    double hi = *std::max_element(hist.begin(), hist.end());
    if (hi <= lo) hi = lo + 1e-9;
    float w = mx.x - mn.x, h = mx.y - mn.y;
    out.resize(n);
    for (int i = 0; i < n; i++) {
        float t = (float)i / (float)(n - 1);
        out[i] = ImVec2(mn.x + t * w, mx.y - (float)((hist[i] - lo) / (hi - lo)) * h);
    }
    return true;
}

static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
                            const std::vector<double>& hist, ImU32 col, float dp)
{
    std::vector<ImVec2> pts;
    if (!pfProjectSeries(hist, mn, mx, pts)) return;
    dl->AddPolyline(pts.data(), (int)pts.size(), col, ImDrawFlags_None, 1.2f * dp);
}

// Sparkline with a soft fill under the line — for the card's dedicated bottom strip.
static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
                                  const std::vector<double>& hist, ImU32 lineCol, float dp)
{
    std::vector<ImVec2> pts;
    if (!pfProjectSeries(hist, mn, mx, pts)) return;
    int n = (int)pts.size();
    for (int i = 0; i < n; i++) dl->PathLineTo(pts[i]);
    dl->PathLineTo(ImVec2(pts[n - 1].x, mx.y));
    dl->PathLineTo(ImVec2(pts[0].x, mx.y));
    dl->PathFillConcave(WithAlpha(lineCol, 28));
    dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f * dp);
}


// Build the right-hand amount strings for a group card / preview from its price-data config.
struct PfDisplay {
    std::string primary;    // name-row, top-right (DRGX amount or the value)
    std::string secondary;  // below primary (the value, when DRGX is also shown)
    std::string change;     // "+2.34%" (live-market bases only) or empty
    ImU32 changeCol = 0;
};
static PfDisplay pfBuildDisplay(const config::Settings::PortfolioEntry& e, double bal,
                                const MarketInfo& market)
{
    PfDisplay d;
    char b[80];
    std::string valueStr; bool hasValue = false;
    if (e.priceBasis == 0 && market.price_usd > 0) {
        snprintf(b, sizeof(b), "$%.2f", bal * market.price_usd); valueStr = b; hasValue = true;
    } else if (e.priceBasis == 1 && market.price_btc > 0) {
        snprintf(b, sizeof(b), "%.8f BTC", bal * market.price_btc); valueStr = b; hasValue = true;
    } else if (e.priceBasis == 3 && e.manualPrice > 0.0) {
        snprintf(b, sizeof(b), "%.2f %s", bal * e.manualPrice, e.manualCurrency.c_str());
        valueStr = b; hasValue = true;
    }
    std::string drgxStr;
    if (e.showDrgx) { snprintf(b, sizeof(b), "%.4f %s", bal, DRAGONX_TICKER); drgxStr = b; }
    if (e.showDrgx) {
        d.primary = drgxStr;
        if (e.showValue && hasValue) d.secondary = valueStr;
    } else if (e.showValue && hasValue) {
        d.primary = valueStr;
    }
    if (e.show24h && (e.priceBasis == 0 || e.priceBasis == 1) && market.change_24h != 0.0) {
        snprintf(b, sizeof(b), "%+.2f%%", market.change_24h);
        d.change = b;
        d.changeCol = market.change_24h >= 0 ? material::Success() : material::Error();
    }
    return d;
}

// Preset accent palette for portfolio groups (0 slot = "default / no color").
static const ImU32 kPfPalette[] = {
    IM_COL32(0x4F, 0x9D, 0xFF, 0xFF),  // blue
    IM_COL32(0x3D, 0xDC, 0x84, 0xFF),  // green
    IM_COL32(0xFF, 0xB0, 0x3D, 0xFF),  // amber
    IM_COL32(0xFF, 0x6B, 0x6B, 0xFF),  // red
    IM_COL32(0xB0, 0x7B, 0xFF, 0xFF),  // purple
    IM_COL32(0x4F, 0xD6, 0xD6, 0xFF),  // teal
    IM_COL32(0xFF, 0x8F, 0xC7, 0xFF),  // pink
    IM_COL32(0xC7, 0xCE, 0xD6, 0xFF),  // slate
    IM_COL32(0x6C, 0x7B, 0xFF, 0xFF),  // indigo
    IM_COL32(0x74, 0xC0, 0xFC, 0xFF),  // sky
    IM_COL32(0x3D, 0xD6, 0xF5, 0xFF),  // cyan
    IM_COL32(0x63, 0xE6, 0xBE, 0xFF),  // mint
    IM_COL32(0x2F, 0xB3, 0x80, 0xFF),  // emerald
    IM_COL32(0xA8, 0xE0, 0x5F, 0xFF),  // lime
    IM_COL32(0xB5, 0xB8, 0x4D, 0xFF),  // olive
    IM_COL32(0xF5, 0xC5, 0x42, 0xFF),  // gold
    IM_COL32(0xFF, 0x9F, 0x45, 0xFF),  // orange
    IM_COL32(0xFF, 0x8A, 0x65, 0xFF),  // coral
    IM_COL32(0xE6, 0x49, 0x80, 0xFF),  // crimson
    IM_COL32(0xFF, 0x8B, 0x94, 0xFF),  // rose
    IM_COL32(0xE5, 0x99, 0xF7, 0xFF),  // magenta
    IM_COL32(0x97, 0x75, 0xFA, 0xFF),  // violet
    IM_COL32(0xB1, 0x97, 0xFC, 0xFF),  // lavender
    IM_COL32(0xB0, 0x89, 0x68, 0xFF),  // brown
    IM_COL32(0x8F, 0xA3, 0xB8, 0xFF),  // steel
};

// Populate the working buffers for a new (index < 0) or existing entry.
static void PortfolioBeginEdit(App* app, int index)
{
    const auto& entries = app->settings()->getPortfolioEntries();
    s_pfEdit.sel = index;
    if (index >= 0 && index < (int)entries.size()) {
        const auto& src = entries[index];
        snprintf(s_pfEdit.label, sizeof(s_pfEdit.label), "%s", src.label.c_str());
        s_pfEdit.addrs  = src.addresses;
        s_pfEdit.icon   = src.icon;
        s_pfEdit.color  = src.color;
        s_pfEdit.outlineOpacity = src.outlineOpacity;
        s_pfEdit.priceBasis  = src.priceBasis;
        s_pfEdit.manualPrice = src.manualPrice;
        snprintf(s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy), "%s", src.manualCurrency.c_str());
        s_pfEdit.showDrgx  = src.showDrgx;
        s_pfEdit.showValue = src.showValue;
        s_pfEdit.show24h   = src.show24h;
        s_pfEdit.showSparkline = src.showSparkline;
        s_pfEdit.sparkInterval = src.sparklineInterval;
    } else {
        s_pfEdit.label[0] = '\0';
        s_pfEdit.addrs.clear();
        s_pfEdit.icon.clear();
        s_pfEdit.color = 0;
        s_pfEdit.outlineOpacity = 25;
        s_pfEdit.priceBasis = 0;
        s_pfEdit.manualPrice = 0.0;
        snprintf(s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy), "USD");
        s_pfEdit.showDrgx = true;
        s_pfEdit.showValue = true;
        s_pfEdit.show24h = false;
        s_pfEdit.showSparkline = false;
        s_pfEdit.sparkInterval = 4;   // default month (a real curve from the daily series)
    }
    s_pfEdit.search[0] = '\0';
    s_pfEdit.typeFilter = 0;
    s_pfEdit.fundedOnly = false;
}

// ---- Portfolio-editor persistence helpers (operate on the s_pfEdit working state) ----

// Build a PortfolioEntry from the working set, preserving off-form fields (grid geometry) from base.
// Normalized views of the two free-text buffers, applied identically by pfBuildWorking (what we store)
// and pfWorkingMatches (the dirty check) so the two can never disagree: a trailing space in the label
// or an emptied currency field must not leave the group perpetually "dirty" after a save.
static std::string pfEditLabel()  // label with leading/trailing whitespace trimmed
{
    std::string s(s_pfEdit.label);
    size_t i = 0, j = s.size();
    while (i < j && (unsigned char)s[i] <= ' ') i++;
    while (j > i && (unsigned char)s[j - 1] <= ' ') j--;
    return s.substr(i, j - i);
}
static std::string pfEditCurrency()  // currency, defaulting an empty buffer to USD
{
    return s_pfEdit.manualCcy[0] != '\0' ? std::string(s_pfEdit.manualCcy) : std::string("USD");
}

static config::Settings::PortfolioEntry pfBuildWorking(const config::Settings::PortfolioEntry& base)
{
    config::Settings::PortfolioEntry e = base;
    e.label = pfEditLabel(); e.addresses = s_pfEdit.addrs; e.icon = s_pfEdit.icon;
    e.color = s_pfEdit.color; e.outlineOpacity = s_pfEdit.outlineOpacity;
    e.priceBasis = s_pfEdit.priceBasis; e.manualPrice = s_pfEdit.manualPrice;
    e.manualCurrency = pfEditCurrency();
    e.showDrgx = s_pfEdit.showDrgx; e.showValue = s_pfEdit.showValue; e.show24h = s_pfEdit.show24h;
    e.showSparkline = s_pfEdit.showSparkline; e.sparklineInterval = s_pfEdit.sparkInterval;
    return e;
}

// True when the working set matches a stored entry (i.e. there are no uncommitted edits).
static bool pfWorkingMatches(const config::Settings::PortfolioEntry& e)
{
    return e.label == pfEditLabel() && e.addresses == s_pfEdit.addrs && e.icon == s_pfEdit.icon
        && e.color == s_pfEdit.color && e.outlineOpacity == s_pfEdit.outlineOpacity
        && e.priceBasis == s_pfEdit.priceBasis && e.manualPrice == s_pfEdit.manualPrice
        && e.manualCurrency == pfEditCurrency()
        && e.showDrgx == s_pfEdit.showDrgx && e.showValue == s_pfEdit.showValue && e.show24h == s_pfEdit.show24h
        && e.showSparkline == s_pfEdit.showSparkline && e.sparklineInterval == s_pfEdit.sparkInterval;
}

// A group is only worth persisting when it has a name, at least one address (else it's always $0), and
// — on the Manual price basis — a positive price (0 would render as "unavailable"). Gates both the Save
// button and the commit path (Close/switch commit too, so validating only the button wouldn't be enough).
static bool pfWorkingValid()
{
    return !pfEditLabel().empty()   // a whitespace-only label is treated as empty (invisible group)
        && !s_pfEdit.addrs.empty()
        && !(s_pfEdit.priceBasis == 3 && s_pfEdit.manualPrice <= 0.0);
}

static void pfPersist(config::Settings* settings, const std::vector<config::Settings::PortfolioEntry>& entries)
{
    settings->setPortfolioEntries(entries);
    settings->save();
}

// Persist the working group when it is named and actually changed; unnamed drafts are dropped.
// New groups adopt the active wallet's scope (per-wallet visibility). An existing group with no scope
// — a placeholder added before the wallet identity resolved, or a pre-scoping legacy group — is also
// claimed for the active wallet on edit, so it can't linger in the global (empty-scope) bucket and
// leak into every wallet's list. No-op when the identity is unavailable (stays unscoped until edited
// with a live identity).
static void pfCommitIfNeeded(App* app)
{
    config::Settings* settings = app->settings();
    if (!pfWorkingValid()) return;   // drop invalid drafts (unnamed / no addresses / manual price <= 0)
    auto entries = settings->getPortfolioEntries();
    bool existing = (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)entries.size());
    config::Settings::PortfolioEntry base;
    if (existing) { base = entries[s_pfEdit.sel]; if (pfWorkingMatches(base)) return; }
    auto e = pfBuildWorking(base);
    if (e.scope.empty()) {
        const std::string h = app->activeWalletIdentityHash();
        if (!h.empty()) e.scope = h;
    }
    if (existing) entries[s_pfEdit.sel] = e;
    else { entries.push_back(e); s_pfEdit.sel = (int)entries.size() - 1; }
    pfPersist(settings, entries);
}

// A group is visible to the active wallet when it carries the wallet's scope, is unscoped (legacy —
// shown everywhere), or the wallet identity isn't resolved yet. The editor mirrors the Market summary's
// scope filter so it only lists/edits the current wallet's groups.
static bool pfIndexVisible(App* app, int i)
{
    const auto& es = app->settings()->getPortfolioEntries();
    if (i < 0 || i >= (int)es.size()) return false;
    const std::string h = app->activeWalletIdentityHash();
    return es[i].scope.empty() || (!h.empty() && es[i].scope == h);
}
static int pfFirstVisibleIndex(App* app)
{
    const auto& es = app->settings()->getPortfolioEntries();
    const std::string h = app->activeWalletIdentityHash();
    for (int i = 0; i < (int)es.size(); i++)
        if (es[i].scope.empty() || (!h.empty() && es[i].scope == h)) return i;
    return -1;
}

// ---- Detail-pane section renderers (drawn inside ##pfDetailBody; operate on s_pfEdit) ----

// Appearance: accent-color picker (+ custom), outline-opacity slider, icon grid.
static void pfDrawAppearanceSection()
{
    float dp = Layout::dpiScale();
    // Color picker: preset swatches in ONE row (overflow hidden) + a custom "+" cell that is
    // always visible at the right and opens a picker.
    {
        float sw = 22.0f * dp, gap = 5.0f * dp;
        ImGui::TextUnformatted(TR("portfolio_color"));
        ImDrawList* cdl = ImGui::GetWindowDrawList();
        int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0]));
        float availW = ImGui::GetContentRegionAvail().x;
        ImVec2 rowStart = ImGui::GetCursorScreenPos();
        // Reserved "+" slot at the right; inset by a few px so its selection outline
        // (drawn at radius sw*0.5) stays inside the modal instead of clipping at the edge.
        float plusX = rowStart.x + std::max(sw, availW - sw - 4.0f * dp);
        bool isCustom = (s_pfEdit.color != 0u);
        for (int c = 0; c < nColors; c++) if (s_pfEdit.color == kPfPalette[c]) isCustom = false;
        bool openCustom = false;
        // Preset swatches (0 = default/no color, 1..N = palette); stop before the "+" slot.
        float x = rowStart.x;
        for (int idx = 0; idx <= nColors; idx++) {
            if (x + sw > plusX - gap) break;   // would reach the "+" -> hide the remaining colors
            ImVec2 mn(x, rowStart.y);
            ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
            bool isDefault = (idx == 0);
            ImU32 col32 = isDefault ? 0u : kPfPalette[idx - 1];
            bool sel = isDefault ? (s_pfEdit.color == 0u) : (s_pfEdit.color == col32);
            bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
            if (isDefault) cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
            else           cdl->AddCircleFilled(cc, sw * 0.40f, col32);
            if (sel)      cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
            else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
            ImGui::PushID(2000 + idx);
            ImGui::SetCursorScreenPos(mn);
            ImGui::InvisibleButton("##pfcol", ImVec2(sw, sw));
            if (ImGui::IsItemClicked()) s_pfEdit.color = isDefault ? 0u : col32;
            ImGui::PopID();
            x += sw + gap;
        }
        // Custom "+" cell — always visible at the reserved right slot.
        {
            ImVec2 mn(plusX, rowStart.y);
            ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
            bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
            if (isCustom) {
                cdl->AddCircleFilled(cc, sw * 0.40f, (ImU32)s_pfEdit.color);
            } else {
                cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
                float pr = sw * 0.18f;
                cdl->AddLine(ImVec2(cc.x - pr, cc.y), ImVec2(cc.x + pr, cc.y), OnSurfaceMedium(), 1.5f * dp);
                cdl->AddLine(ImVec2(cc.x, cc.y - pr), ImVec2(cc.x, cc.y + pr), OnSurfaceMedium(), 1.5f * dp);
            }
            if (isCustom)  cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
            else if (hov)  cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
            ImGui::PushID(2999);
            ImGui::SetCursorScreenPos(mn);
            ImGui::InvisibleButton("##pfcustomcol", ImVec2(sw, sw));
            if (ImGui::IsItemClicked()) {
                ImU32 base = s_pfEdit.color ? (ImU32)s_pfEdit.color : IM_COL32(0x4F, 0x9D, 0xFF, 0xFF);
                s_pfEdit.customRgb[0] = ((base >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
                s_pfEdit.customRgb[1] = ((base >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
                s_pfEdit.customRgb[2] = ((base >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
                openCustom = true;
            }
            if (hov) material::Tooltip("%s", TR("portfolio_custom_color"));
            ImGui::PopID();
        }
        ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + sw));
        ImGui::Dummy(ImVec2(availW, 0));
        if (openCustom) ImGui::OpenPopup("##pfCustomColor");
        if (ImGui::BeginPopup("##pfCustomColor")) {
            ImGui::ColorPicker3("##pfpick", s_pfEdit.customRgb,
                ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoAlpha | ImGuiColorEditFlags_NoLabel);
            s_pfEdit.color = IM_COL32((int)(s_pfEdit.customRgb[0] * 255.0f + 0.5f),
                                  (int)(s_pfEdit.customRgb[1] * 255.0f + 0.5f),
                                  (int)(s_pfEdit.customRgb[2] * 255.0f + 0.5f), 255);
            ImGui::EndPopup();
        }
    }
    // Accent-outline opacity (only meaningful when an accent color is set).
    ImGui::BeginDisabled(s_pfEdit.color == 0u);
    ImGui::TextUnformatted(TR("portfolio_outline_opacity"));
    ImGui::SetNextItemWidth(-1);
    ImGui::SliderInt("##pfOutlineOpacity", &s_pfEdit.outlineOpacity, 0, 100, "%d%%");
    if (s_pfEdit.outlineOpacity < 0) s_pfEdit.outlineOpacity = 0;
    if (s_pfEdit.outlineOpacity > 100) s_pfEdit.outlineOpacity = 100;
    ImGui::EndDisabled();
    ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
    // Icon picker: search on the heading line + a 12-per-row grid that scales to width,
    // smooth-scrolled with the Settings-tab edge fade.
    {
        float cellGap = 6.0f * dp;
        ImFont* gIconFont = Type().iconXL();
        ImGui::TextUnformatted(TR("portfolio_icon"));
        ImGui::SameLine();
        {
            float searchW = std::min(ImGui::GetContentRegionAvail().x, 200.0f * dp);
            float off = ImGui::GetContentRegionAvail().x - searchW;   // right-align the field
            if (off > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + off);
            ImGui::SetNextItemWidth(searchW);
            ImGui::InputTextWithHint("##pfIconSearch", TR("portfolio_search_icons"),
                                     s_pfEdit.iconSearch, sizeof(s_pfEdit.iconSearch));
        }
        // Filtered icon list (-1 = "None", shown only when not searching).
        std::string isearch = s_pfEdit.iconSearch;
        std::vector<int> iconIdx;
        if (isearch.empty()) iconIdx.push_back(-1);
        int nIcons = material::project_icons::walletIconCount();
        for (int i = 0; i < nIcons; i++) {
            if (!isearch.empty() &&
                !util::containsIgnoreCase(material::project_icons::walletIconName(i), isearch))
                continue;
            iconIdx.push_back(i);
        }
        ImDrawList* gdl = material::BeginFadeScrollChild("##pfIconGrid", s_pfEdit.iconFade,
                                             ImVec2(Layout::spacingSm(), Layout::spacingSm()), dp);
        const int cols = 12;
        float availW = ImGui::GetContentRegionAvail().x;
        float cell = std::max(14.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
        float gIconSz = cell * 0.5f;
        float gridW = cell * cols + cellGap * (cols - 1);
        float indent = std::max(0.0f, (availW - gridW) * 0.5f);
        int gcol = 0;
        for (size_t n = 0; n < iconIdx.size(); n++) {
            int idx = iconIdx[n];
            if (gcol == 0) { if (indent > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent); }
            else           ImGui::SameLine(0, cellGap);
            ImVec2 mn = ImGui::GetCursorScreenPos();
            ImVec2 mx(mn.x + cell, mn.y + cell);
            ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
            bool hov = ImGui::IsMouseHoveringRect(mn, mx);
            bool isNone = (idx < 0);
            const char* nm2 = isNone ? "" : material::project_icons::walletIconName(idx);
            bool sel = isNone ? s_pfEdit.icon.empty() : (s_pfEdit.icon == nm2);
            if (sel) {
                gdl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp);
                gdl->AddRect(mn, mx, WithAlpha(Primary(), 120), 6.0f * dp, 0, 1.5f * dp);
            } else if (hov) {
                gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp);
            }
            ImU32 icol = sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium());
            if (isNone) {
                float r = cell * 0.26f;
                gdl->AddCircle(cc, r, icol, 0, 1.5f * dp);
                gdl->AddLine(ImVec2(cc.x - r * 0.7f, cc.y + r * 0.7f),
                             ImVec2(cc.x + r * 0.7f, cc.y - r * 0.7f), icol, 1.5f * dp);
            } else {
                material::project_icons::drawByName(gdl, nm2, cc, icol, gIconFont, gIconSz);
            }
            ImGui::PushID(idx + 2);
            ImGui::InvisibleButton("##pfic", ImVec2(cell, cell));
            if (ImGui::IsItemClicked()) s_pfEdit.icon = isNone ? std::string() : std::string(nm2);
            if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm2);
            ImGui::PopID();
            gcol = (gcol + 1) % cols;
        }
        material::EndFadeScrollChild(s_pfEdit.iconFade, gdl);
    }
}

// Price: basis radios, optional manual price/currency, shown-field toggles, sparkline interval.
static void pfDrawPriceSection()
{
    // Price basis — radio group (all options visible; no dropdown).
    const char* basisItems[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"),
                                  TR("portfolio_price_drgx"), TR("portfolio_price_manual") };
    for (int b = 0; b < 4; b++) { if (b) ImGui::SameLine(); ImGui::RadioButton(basisItems[b], &s_pfEdit.priceBasis, b); }
    if (s_pfEdit.priceBasis == 3) {  // Manual: price-per-DRGX + currency label
        float half = (ImGui::GetContentRegionAvail().x - Layout::spacingSm()) * 0.62f;
        ImGui::SetNextItemWidth(half);
        ImGui::InputDouble("##pfManPrice", &s_pfEdit.manualPrice, 0.0, 0.0, "%.6f");
        // Reject negatives and non-finite input (InputDouble parses "inf"/"nan"; neither is < 0).
        if (!std::isfinite(s_pfEdit.manualPrice) || s_pfEdit.manualPrice < 0.0) s_pfEdit.manualPrice = 0.0;
        ImGui::SameLine();
        ImGui::SetNextItemWidth(-1);
        ImGui::InputTextWithHint("##pfManCcy", TR("portfolio_currency"), s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy));
    }
    ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
    // Shown fields.
    ImGui::TextUnformatted(TR("portfolio_show"));
    ImGui::Checkbox(DRAGONX_TICKER "##pfShowDrgx", &s_pfEdit.showDrgx);
    ImGui::SameLine();
    ImGui::BeginDisabled(s_pfEdit.priceBasis == 2);  // DRGX-only -> no value field
    ImGui::Checkbox(TR("portfolio_show_value"), &s_pfEdit.showValue);
    ImGui::EndDisabled();
    ImGui::SameLine();
    // 24h + sparkline toggles share one line (both live-market only).
    ImGui::BeginDisabled(!(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1));
    ImGui::Checkbox(TR("portfolio_show_24h"), &s_pfEdit.show24h);
    ImGui::SameLine();
    ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pfEdit.showSparkline);
    ImGui::EndDisabled();
    ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
    // Sparkline interval — radio group (enabled only when the sparkline is shown).
    ImGui::BeginDisabled(!s_pfEdit.showSparkline || !(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1));
    const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"),
                               TR("portfolio_spark_day"), TR("portfolio_spark_week"),
                               TR("portfolio_spark_month") };
    for (int s = 0; s < 5; s++) { if (s) ImGui::SameLine(); ImGui::RadioButton(spItems[s], &s_pfEdit.sparkInterval, s); }
    ImGui::EndDisabled();
}

// Addresses: selection count, search, type-filter/select row, and the pickable address list.
static void pfDrawAddressSection(App* app)
{
    float dp = Layout::dpiScale();
    const auto& state = app->getWalletState();
    ImFont* body2f = Type().body2();
    ImFont* capF   = Type().caption();
    ImFont* iconFont = Type().iconSmall();
    float iconFsz  = iconFont->LegacySize;
    {
        char selc[48];
        snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pfEdit.addrs.size());
        Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc);
    }
    ImGui::SetNextItemWidth(-1);
    ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pfEdit.search, sizeof(s_pfEdit.search));
    ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
    // Filter + selection on one row: type-filter segmented (left) + Select all / Clear /
    // Funded (right). Select-all is deferred so it applies to the freshly-filtered set below.
    bool selAllClicked = false;
    {
        const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") };
        const char* selLbl = TR("portfolio_select_shown");
        const char* clrLbl = TR("portfolio_clear_sel");
        ImGuiStyle& st = ImGui::GetStyle();
        ImVec2 pillPad(Layout::spacingLg(), Layout::spacingSm());  // roomier text padding
        float defFrameH = ImGui::GetFrameHeight();
        float fundedW = defFrameH + st.ItemInnerSpacing.x + ImGui::CalcTextSize(TR("portfolio_funded")).x;
        float pillH = ImGui::GetTextLineHeight() + pillPad.y * 2.0f;
        float selW = ImGui::CalcTextSize(selLbl).x + pillPad.x * 2.0f;
        float clrW = ImGui::CalcTextSize(clrLbl).x + pillPad.x * 2.0f;
        float groupW = selW + clrW + fundedW + st.ItemSpacing.x * 2.0f;
        float segH = 26.0f * dp;
        float rowW = ImGui::GetContentRegionAvail().x;
        float segW = std::min(360.0f * dp, std::max(160.0f * dp, rowW - groupW - Layout::spacingMd()));
        float rowH = std::max(segH, pillH);
        ImVec2 rowStart = ImGui::GetCursorScreenPos();
        // Segmented type filter (left), vertically centered in the row.
        float segY = rowStart.y + (rowH - segH) * 0.5f;
        int tclk = material::SegmentedControl(ImGui::GetWindowDrawList(), ImVec2(rowStart.x, segY),
                                      segW, segH, flabels, 3, s_pfEdit.typeFilter, capF, "##pftf", dp);
        if (tclk >= 0) s_pfEdit.typeFilter = tclk;
        // Select all / Clear (rounded pills) + Funded toggle (right), vertically centered.
        float grpX = rowStart.x + std::max(0.0f, rowW - groupW);
        ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f * dp);
        ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, pillPad);
        ImGui::SetCursorScreenPos(ImVec2(grpX, rowStart.y + (rowH - pillH) * 0.5f));
        if (material::TactileButton(selLbl)) selAllClicked = true;
        ImGui::SameLine();
        if (material::TactileButton(clrLbl)) s_pfEdit.addrs.clear();
        ImGui::PopStyleVar(2);
        ImGui::SameLine();
        ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x,
                                         rowStart.y + (rowH - defFrameH) * 0.5f));  // center vs. pills
        ImGui::Checkbox(TR("portfolio_funded"), &s_pfEdit.fundedOnly);
        ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + rowH));
        ImGui::Dummy(ImVec2(rowW, 0));
    }
    ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
    std::string search = s_pfEdit.search;
    std::vector<const AddressInfo*> filtered;
    for (const auto& a : state.addresses) {
        if (s_pfEdit.typeFilter == 1 && a.type != "shielded") continue;
        if (s_pfEdit.typeFilter == 2 && a.type != "transparent") continue;
        if (s_pfEdit.fundedOnly && a.balance <= 1e-9) continue;
        if (!search.empty()) {
            std::string hay = a.address + " " + app->getAddressLabel(a.address);
            if (!util::containsIgnoreCase(hay, search)) continue;
        }
        filtered.push_back(&a);
    }
    // Build the selected-address set once — the sort comparator and the per-row membership test below
    // both consulted it via a linear PortfolioEntryContains scan (O(n) each) every frame.
    std::unordered_set<std::string> selSet(s_pfEdit.addrs.begin(), s_pfEdit.addrs.end());
    std::sort(filtered.begin(), filtered.end(), [&](const AddressInfo* x, const AddressInfo* y) {
        bool sx = selSet.count(x->address) != 0;
        bool sy = selSet.count(y->address) != 0;
        if (sx != sy) return sx;
        return x->balance > y->balance;
    });
    if (selAllClicked)
        for (const AddressInfo* a : filtered)
            if (data::PortfolioEntryAdd(s_pfEdit.addrs, a->address)) selSet.insert(a->address);
    ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
    ImDrawList* ldl = material::BeginFadeScrollChild("##pfAddrList", s_pfEdit.addrFade,
                                         ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp);
    float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm());
    float lw = ImGui::GetContentRegionAvail().x;
    if (filtered.empty())
        Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
    for (const AddressInfo* ap : filtered) {
        const AddressInfo& a = *ap;
        bool inSet = selSet.count(a.address) != 0;
        ImVec2 rmn = ImGui::GetCursorScreenPos();
        ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
        bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
        if (inSet)      ldl->AddRectFilled(rmn, rmx, WithAlpha(Primary(), 22), 4.0f * dp);
        else if (rhov)  ldl->AddRectFilled(rmn, rmx, IM_COL32(255, 255, 255, 14), 4.0f * dp);
        float rcy = rmn.y + rowH * 0.5f;
        float rx = rmn.x + Layout::spacingSm();
        float cb = 15.0f * dp;
        ImVec2 cbMin(rx, rcy - cb * 0.5f), cbMax(rx + cb, rcy + cb * 0.5f);
        if (inSet) {
            ldl->AddRectFilled(cbMin, cbMax, Primary(), 3.0f * dp);
            ldl->AddLine(ImVec2(cbMin.x + cb * 0.22f, rcy),
                         ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f), IM_COL32(255, 255, 255, 255), 1.6f * dp);
            ldl->AddLine(ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f),
                         ImVec2(cbMin.x + cb * 0.80f, cbMin.y + cb * 0.22f), IM_COL32(255, 255, 255, 255), 1.6f * dp);
        } else {
            ldl->AddRect(cbMin, cbMax, WithAlpha(OnSurface(), 120), 3.0f * dp, 0, 1.2f * dp);
        }
        rx += cb + Layout::spacingSm();
        bool isZ = (a.type == "shielded");
        const char* chip = isZ ? "Z" : "T";
        ImU32 chipCol = isZ ? Success() : Warning();
        ImVec2 chipPos(rx, rcy - capF->LegacySize * 0.5f - 2.0f * dp);
        float chipW = material::DrawPill(ldl, chipPos, chip, capF, chipCol,
                                         WithAlpha(chipCol, 40), 0, ImVec2(4.0f * dp, 2.0f * dp), 4.0f * dp).x;
        rx += chipW + Layout::spacingSm();
        std::string aicon = app->getAddressIcon(a.address);
        if (!aicon.empty()) {
            material::project_icons::drawByName(ldl, aicon, ImVec2(rx + iconFsz * 0.5f, rcy), OnSurfaceMedium(), iconFont, iconFsz);
            rx += iconFsz + Layout::spacingSm();
        }
        char balbuf[48];
        snprintf(balbuf, sizeof(balbuf), "%.4f", a.balance);
        float balW = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, balbuf).x;
        float textRight = rmx.x - Layout::spacingSm() - balW - Layout::spacingSm();
        std::string alabel = app->getAddressLabel(a.address);
        std::string primary = alabel.empty() ? util::truncateMiddle(a.address, 22) : alabel;
        float availTextW = textRight - rx;
        while (primary.size() > 1 &&
               body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, primary.c_str()).x > availTextW)
            primary.pop_back();
        ldl->AddText(body2f, body2f->LegacySize, ImVec2(rx, rcy - body2f->LegacySize * 0.5f),
                     inSet ? OnSurface() : OnSurfaceMedium(), primary.c_str());
        ldl->AddText(capF, capF->LegacySize, ImVec2(rmx.x - Layout::spacingSm() - balW, rcy - capF->LegacySize * 0.5f),
                     a.balance > 1e-9 ? OnSurface() : OnSurfaceDisabled(), balbuf);
        ImGui::PushID(a.address.c_str());
        ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH));
        if (ImGui::IsItemClicked()) {
            if (inSet) { data::PortfolioEntryRemove(s_pfEdit.addrs, a.address); selSet.erase(a.address); }
            else       { data::PortfolioEntryAdd(s_pfEdit.addrs, a.address);    selSet.insert(a.address); }
        }
        ImGui::PopID();
    }
    material::EndFadeScrollChild(s_pfEdit.addrFade, ldl);
}

// The "Manage portfolio" modal: list/add/edit/delete entries, each a label tied to a group
// of wallet addresses. Persists to Settings on every mutation (like the address book).
static void RenderPortfolioEditor(App* app)
{
    if (!s_pfEdit.open) return;

    config::Settings* settings = app->settings();
    const auto& state = app->getWalletState();
    float dp = Layout::dpiScale();
    ImFont* body2f = Type().body2();
    ImFont* capF   = Type().caption();
    ImFont* sub1f  = Type().subtitle1();
    ImFont* iconFont = Type().iconSmall();

    // Persistence helpers are file-static (pfBuildWorking / pfWorkingMatches / pfCommitIfNeeded).

    // ---------------- Full-window blur overlay (shared framework) ----------------
    // Backdrop + capture-once, floating card, plain heading, outside-click dismiss and effect
    // suppression are owned by material::BeginOverlayDialog (BlurFloat). Every dismiss gesture
    // (Close button, Esc, outside-click, switching/adding/deleting groups) auto-saves the current
    // group via pfCommitIfNeeded; LatchBlurOverlayActive (App::render) does the acrylic re-capture.
    material::OverlayDialogSpec ov;
    ov.title      = TR("portfolio_manage_title");
    ov.p_open     = &s_pfEdit.open;
    ov.style      = material::OverlayStyle::BlurFloat;
    ov.cardWidth  = 1120.0f;
    ov.cardHeight = 760.0f;
    ov.idSuffix   = "pf";
    if (!material::BeginOverlayDialog(ov)) return;
    // Outside-click dismiss is handled inside BeginOverlayDialog, which clears s_pfEdit.open mid-frame
    // (the card still draws one final frame). Auto-save now, before that frame: the placeholder GC in
    // mktDrawPortfolio already ran this frame while open was still true, so entry indices haven't
    // shifted under us and pfCommitIfNeeded targets the right slot.
    if (!s_pfEdit.open) pfCommitIfNeeded(app);
    // A first Escape should dismiss only an open popup (e.g. the custom-color picker); don't also tear
    // down the whole editor while a popup is capturing the key. Otherwise Escape auto-saves and closes.
    if (ImGui::IsKeyPressed(ImGuiKey_Escape) &&
        !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) {
        pfCommitIfNeeded(app);
        s_pfEdit.open = false;
    }

    {  // Clamp/validate the selection. The in-editor delete already reloads the working state, but if the
       // list shrank or the selection landed on a group outside this wallet's scope via any other path,
       // reset to the first visible group and reload so a later commit can't overlay stale fields.
        const auto& entriesRO = settings->getPortfolioEntries();
        if (s_pfEdit.sel >= (int)entriesRO.size() ||
            (s_pfEdit.sel >= 0 && !pfIndexVisible(app, s_pfEdit.sel))) {
            s_pfEdit.sel = pfFirstVisibleIndex(app);
            PortfolioBeginEdit(app, s_pfEdit.sel);
        }
    }

    float footerH = 56.0f * dp;  // room for the 40px buttons + separator so they aren't clipped
    float bodyH = std::max(120.0f, ImGui::GetContentRegionAvail().y - footerH - Layout::spacingSm());
    float contentW = ImGui::GetContentRegionAvail().x;
    float gap = Layout::spacingLg();
    float masterW = std::min(std::max(contentW * 0.28f, 240.0f * dp), 320.0f * dp);
    float detailW = contentW - masterW - gap;

    // Does the selected group have uncommitted edits? (Only the selected group can differ from the
    // stored copy — the working buffer only ever holds the selected group; switching reloads it and
    // discards any uncommitted edits.) Drives the master-list "unsaved" dot and Revert/Save state.
    bool selDirty = false;
    {
        const auto& es = settings->getPortfolioEntries();
        if (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)es.size()) selDirty = !pfWorkingMatches(es[s_pfEdit.sel]);
    }

    // ================= MASTER: scrollable group list + a pinned Add button below it =================
    float addH = 34.0f * dp;
    ImGui::BeginGroup();
    {
        const auto& entries = settings->getPortfolioEntries();
        ImGui::BeginChild("##pfMasterList", ImVec2(masterW, std::max(48.0f, bodyH - addH - Layout::spacingSm())), false);
        {
            ImDrawList* mdl = ImGui::GetWindowDrawList();
            // Only list groups scoped to the active wallet (or legacy/unscoped) — same filter the
            // Market summary uses. `i` stays the storage index (selection/delete operate on storage).
            const std::string activeHash = app->activeWalletIdentityHash();
            std::vector<int> vis;
            for (int i = 0; i < (int)entries.size(); i++)
                // Global groups always; this wallet's groups only when the identity is known — never
                // another wallet's scoped groups during the switch/first-load window.
                if (entries[i].scope.empty() || (!activeHash.empty() && entries[i].scope == activeHash))
                    vis.push_back(i);
            if (vis.empty())
                Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
            float rowH = 46.0f * dp;
            float delSlot = 36.0f * dp;   // reserved trailing space for the delete icon + margin
            int clickedSel = -999, delRow = -1;  // deferred: don't mutate `entries` mid-loop
            bool armedThisFrame = false;         // an arm-click also fires the row Selectable; don't let it disarm
            for (int vi = 0; vi < (int)vis.size(); vi++) {
                int i = vis[vi];
                ImGui::PushID(i);
                const auto& en = entries[i];
                bool selRow = (s_pfEdit.sel == i);
                ImVec2 rmn = ImGui::GetCursorScreenPos();
                if (ImGui::Selectable("##pfgrp", selRow, ImGuiSelectableFlags_SpanAvailWidth, ImVec2(0, rowH))) {
                    if (i != s_pfEdit.sel) clickedSel = i;
                }
                ImVec2 rmx(rmn.x + ImGui::GetItemRectSize().x, rmn.y + rowH);
                bool rowHov = ImGui::IsItemHovered();
                ImU32 accent = en.color ? (ImU32)en.color : WithAlpha(OnSurface(), 60);
                mdl->AddRectFilled(ImVec2(rmn.x, rmn.y + 4.0f * dp),
                                   ImVec2(rmn.x + 3.0f * dp, rmx.y - 4.0f * dp), accent, 1.5f * dp);
                float ix = rmn.x + Layout::spacingSm() + 4.0f * dp;
                if (!en.icon.empty()) {
                    material::project_icons::drawByName(mdl, en.icon,
                        ImVec2(ix + sub1f->LegacySize * 0.5f, rmn.y + rowH * 0.5f),
                        en.color ? (ImU32)en.color : OnSurfaceMedium(), iconFont, sub1f->LegacySize);
                    ix += sub1f->LegacySize + Layout::spacingSm();
                }
                // Unsaved-changes dot: shown on the selected row while its edits aren't committed.
                if (selRow && selDirty) {
                    float dr = 3.5f * dp;
                    mdl->AddCircleFilled(ImVec2(ix + dr, rmn.y + rowH * 0.28f + body2f->LegacySize * 0.5f),
                                         dr, Warning());
                    ix += dr * 2.0f + Layout::spacingXs();
                }
                float txtRight = rmx.x - delSlot;  // reserve the trailing delete-icon slot
                std::string nm = en.label.empty() ? std::string(TR("portfolio_new_entry")) : en.label;
                while (nm.size() > 1 && body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, nm.c_str()).x > (txtRight - ix))
                    nm.pop_back();
                mdl->AddText(body2f, body2f->LegacySize, ImVec2(ix, rmn.y + rowH * 0.28f),
                             en.label.empty() ? OnSurfaceDisabled() : OnSurface(), nm.c_str());
                double bal = data::SumPortfolioBalance(en.addresses, state.addresses);
                char vl[80]; snprintf(vl, sizeof(vl), "%.4f %s \xC2\xB7 %d", bal, DRAGONX_TICKER, (int)en.addresses.size());
                mdl->AddText(capF, capF->LegacySize, ImVec2(ix, rmn.y + rowH * 0.56f), OnSurfaceMedium(), vl);
                // Per-row delete icon (larger, inset from the edge). Hit-tested manually so it is
                // NOT an overlapping ImGui item over the row Selectable (which asserted on hover).
                if (rowHov || selRow) {
                    bool armed = (s_pfConfirmDelIdx == i);   // this row is arm-confirmed
                    ImFont* delFont = Type().iconMed();
                    float ds = delFont->LegacySize;
                    ImVec2 dc(rmx.x - Layout::spacingMd() - ds * 0.5f, rmn.y + rowH * 0.5f);
                    ImVec2 dmn(dc.x - ds * 0.65f, dc.y - ds * 0.65f), dmx(dc.x + ds * 0.65f, dc.y + ds * 0.65f);
                    bool dhov = ImGui::IsMouseHoveringRect(dmn, dmx);
                    if (dhov) {
                        ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                        // Hovering a *different* row's trash disarms the previously armed one.
                        if (!armed && s_pfConfirmDelIdx >= 0) s_pfConfirmDelIdx = -1;
                        if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
                            if (armed) delRow = i;               // 2nd click on the armed row erases
                            else { s_pfConfirmDelIdx = i;        // 1st click arms this row...
                                   armedThisFrame = true; }      // ...and must survive the row-select this same click triggers
                        }
                    }
                    // A filled trash glyph while armed (vs outline) reinforces the recolor.
                    const char* delGlyph = armed ? ICON_MD_DELETE : ICON_MD_DELETE_OUTLINE;
                    ImU32 delCol = armed ? ReadableError() : (dhov ? Error() : OnSurfaceMedium());
                    ImVec2 isz = delFont->CalcTextSizeA(ds, FLT_MAX, 0, delGlyph);
                    mdl->AddText(delFont, ds, ImVec2(dc.x - isz.x * 0.5f, dc.y - isz.y * 0.5f),
                                 delCol, delGlyph);
                }
                ImGui::PopID();
            }
            if (delRow >= 0) {
                // Auto-save edits to a *different* selected group before mutating the list; only the
                // deleted row's own working state is dropped. (Re-read entries after the commit.)
                if (delRow != s_pfEdit.sel) pfCommitIfNeeded(app);
                auto es = settings->getPortfolioEntries();
                if (delRow < (int)es.size()) {
                    es.erase(es.begin() + delRow);
                    pfPersist(settings, es);
                    if (es.empty()) s_pfEdit.sel = -1;
                    else if (delRow < s_pfEdit.sel) s_pfEdit.sel -= 1;
                    else if (delRow == s_pfEdit.sel) s_pfEdit.sel = std::min(delRow, (int)es.size() - 1);
                    PortfolioBeginEdit(app, s_pfEdit.sel);
                }
                s_pfConfirmDelIdx = -1;   // erase done — storage indices shifted, drop the arm
            } else if (clickedSel != -999 && !armedThisFrame) {
                pfCommitIfNeeded(app);                // auto-save the current group before switching
                PortfolioBeginEdit(app, clickedSel);
                s_pfConfirmDelIdx = -1;   // switching rows disarms any pending delete
            }
            // A left-click that hit no row/action (empty list space) disarms a pending delete.
            if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
                !ImGui::IsAnyItemHovered())
                s_pfConfirmDelIdx = -1;
        }
        ImGui::EndChild();
        ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
        // Add immediately creates a persisted "Untitled" group scoped to this wallet — so it's disabled
        // while the wallet identity is unknown (switch / first-load window), otherwise a new group would
        // get an empty scope and show under every wallet.
        const bool pfIdentityKnown = !app->activeWalletIdentityHash().empty();
        ImGui::BeginDisabled(!pfIdentityKnown);
        if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) {
            // Auto-save the current group, then start a fresh one selected for editing.
            pfCommitIfNeeded(app);
            auto es = settings->getPortfolioEntries();
            config::Settings::PortfolioEntry ne;
            ne.label = TR("portfolio_untitled");
            ne.scope = app->activeWalletIdentityHash();   // new group defaults to this wallet
            es.push_back(ne);
            pfPersist(settings, es);
            s_pfEdit.sel = (int)es.size() - 1;
            PortfolioBeginEdit(app, s_pfEdit.sel);
        }
        ImGui::EndDisabled();
        if (!pfIdentityKnown && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
            material::Tooltip("%s", TR("portfolio_wallet_loading"));
    }
    ImGui::EndGroup();

    ImGui::SameLine(0, gap);

    // ================= DETAIL: options for the selected group =================
    ImGui::BeginChild("##pfDetail", ImVec2(detailW, bodyH), false);
    if (s_pfEdit.sel == -1) {
        const char* msg = TR("portfolio_detail_empty");
        ImVec2 av = ImGui::GetContentRegionAvail();
        ImVec2 ts = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, msg);
        ImGui::SetCursorPos(ImVec2(std::max(0.0f, (av.x - ts.x) * 0.5f), av.y * 0.42f));
        Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), msg);
    } else {
        // Body fills the detail column minus a bottom row reserved for the group-scoped
        // Revert/Save buttons (kept inside this container, below the modal-level Close in the footer).
        float pfBtnRowH = addH + Layout::spacingMd();
        ImGui::BeginChild("##pfDetailBody",
                          ImVec2(0, std::max(80.0f * dp, ImGui::GetContentRegionAvail().y - pfBtnRowH)),
                          false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
        // ---- Header: live preview + label (always visible above the accordion) ----
        {
            ImDrawList* pdl = ImGui::GetWindowDrawList();
            float ppad = Layout::spacingMd();
            float pw = ImGui::GetContentRegionAvail().x;
            float ph = ppad * 2.0f + sub1f->LegacySize + Layout::spacingXs() + capF->LegacySize;
            ImVec2 pMin = ImGui::GetCursorScreenPos();
            ImVec2 pMax(pMin.x + pw, pMin.y + ph);
            ImU32 accent = s_pfEdit.color ? (ImU32)s_pfEdit.color : 0;
            GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 22; g.borderAlpha = 40;
            DrawGlassPanel(pdl, pMin, pMax, g);
            if (accent) {
                int oa = (int)(std::max(0, std::min(100, s_pfEdit.outlineOpacity)) * 2.55f + 0.5f);
                pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f * dp, 0, 2.0f * dp);
            }
            if (s_pfEdit.showSparkline && (s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1)) {
                std::vector<double> h = data::sparklineSeries(state.market, s_pfEdit.sparkInterval);
                if (h.size() >= 2) {
                    ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80);
                    ImVec2 spMin(pMin.x + ppad, pMin.y + ph * 0.46f);
                    ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f);
                    pfDrawSparkline(pdl, spMin, spMax, h, spCol, dp);
                }
            }
            float rowY = pMin.y + ppad, nameX = pMin.x + ppad;
            if (!s_pfEdit.icon.empty()) {
                material::project_icons::drawByName(pdl, s_pfEdit.icon,
                    ImVec2(nameX + sub1f->LegacySize * 0.5f, rowY + sub1f->LegacySize * 0.5f),
                    accent ? accent : OnSurface(), iconFont, sub1f->LegacySize);
                nameX += sub1f->LegacySize + Layout::spacingSm();
            }
            const char* nm = s_pfEdit.label[0] ? s_pfEdit.label : TR("portfolio_group_name");
            pdl->AddText(sub1f, sub1f->LegacySize, ImVec2(nameX, rowY),
                         s_pfEdit.label[0] ? OnSurface() : OnSurfaceDisabled(), nm);
            double pbal = data::SumPortfolioBalance(s_pfEdit.addrs, state.addresses);
            config::Settings::PortfolioEntry tmp;
            tmp.priceBasis = s_pfEdit.priceBasis;
            tmp.manualPrice = s_pfEdit.manualPrice;
            tmp.manualCurrency = s_pfEdit.manualCcy;
            tmp.showDrgx = s_pfEdit.showDrgx;
            tmp.showValue = s_pfEdit.showValue;
            tmp.show24h = s_pfEdit.show24h;
            PfDisplay pd = pfBuildDisplay(tmp, pbal, state.market);
            float primW = pd.primary.empty() ? 0.0f
                : body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, pd.primary.c_str()).x;
            if (!pd.primary.empty())
                pdl->AddText(body2f, body2f->LegacySize,
                             ImVec2(pMax.x - ppad - primW, rowY + (sub1f->LegacySize - body2f->LegacySize) * 0.5f),
                             OnSurface(), pd.primary.c_str());
            {
                float belowY = rowY + sub1f->LegacySize + Layout::spacingXs();
                float cursorR = pMax.x - ppad;
                if (!pd.change.empty()) {
                    float cw = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.change.c_str()).x;
                    pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - cw, belowY), pd.changeCol, pd.change.c_str());
                    cursorR -= cw + Layout::spacingSm();
                }
                if (!pd.secondary.empty()) {
                    float sw2 = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.secondary.c_str()).x;
                    pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - sw2, belowY), OnSurfaceDisabled(), pd.secondary.c_str());
                }
            }
            ImGui::Dummy(ImVec2(pw, ph));
        }
        ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
        ImGui::SetNextItemWidth(-1);
        ImGui::InputTextWithHint("##pfLabel", TR("portfolio_group_name"), s_pfEdit.label, sizeof(s_pfEdit.label));
        ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

        // Segmented control switches the detail section (no collapsible dropdowns) — one at a time.
        {
            const char* secs[3] = { TR("portfolio_appearance"), TR("portfolio_price"),
                                    TR("portfolio_addresses_hdr") };
            float segH = 30.0f * dp;
            float segW = ImGui::GetContentRegionAvail().x;
            ImVec2 sMin = ImGui::GetCursorScreenPos();
            int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), sMin, segW, segH,
                                         secs, 3, s_pfEdit.section, body2f, "##pfseg", dp);
            if (clk >= 0) s_pfEdit.section = clk;
            ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH));
            ImGui::Dummy(ImVec2(segW, 0));
        }
        ImGui::Dummy(ImVec2(0, Layout::spacingMd()));

        // ---- Detail sections (one shown at a time via the segmented control above) ----
        if (s_pfEdit.section == 0)      pfDrawAppearanceSection();
        else if (s_pfEdit.section == 1) pfDrawPriceSection();
        else if (s_pfEdit.section == 2) pfDrawAddressSection(app);

        ImGui::EndChild();  // ##pfDetailBody
        // Group-scoped actions (Add-entry height → lower hierarchy than the modal Close in the footer).
        {
            float bw = 104.0f * dp, sp = Layout::spacingSm();
            float grp = bw * 2 + sp;
            material::RightAlignX(grp);
            ImGui::SetCursorPosY(ImGui::GetCursorPosY() + Layout::spacingSm());
            ImGui::BeginDisabled(!selDirty);
            if (material::TactileButton(TR("portfolio_revert"), ImVec2(bw, addH)))
                PortfolioBeginEdit(app, s_pfEdit.sel);   // reload the working set from the stored entry
            ImGui::SameLine(0, sp);
            ImGui::BeginDisabled(!pfWorkingValid());
            if (material::TactileButton(TR("portfolio_save"), ImVec2(bw, addH))) pfCommitIfNeeded(app);
            ImGui::EndDisabled();
            ImGui::EndDisabled();
            // Explain why Save is disabled — but only for a validation gap, not the "nothing changed"
            // (!selDirty) case, which is self-evident. Priority mirrors pfWorkingValid's checks.
            if (!pfWorkingValid() && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
                if (pfEditLabel().empty())       material::Tooltip("%s", TR("portfolio_save_need_name"));
                else if (s_pfEdit.addrs.empty()) material::Tooltip("%s", TR("portfolio_save_need_address"));
                else                             material::Tooltip("%s", TR("portfolio_save_need_price"));
            }
        }
    }
    ImGui::EndChild();  // ##pfDetail

    // ================= FOOTER: close (modal-level action, larger → higher hierarchy) =================
    {
        float bh = 40.0f * dp, bw = 120.0f * dp;
        material::RightAlignX(bw);
        if (material::TactileButton(TR("portfolio_close"), ImVec2(bw, bh))) { pfCommitIfNeeded(app); s_pfEdit.open = false; }
    }

    material::EndOverlayDialog();
}

bool PortfolioEditorActive() { return s_pfEdit.open; }


// ---- Portfolio ROW layouts (replace the drag/resize grid) --------------------------------------
// Draw one group as a full-width row card in [rowMin,rowMax]; style 0=compact, 1=detailed,
// 2=value-hero. No left accent strip — identity comes from the tinted icon + a subtle accent
// border. Fixed right-aligned columns keep numbers aligned across rows; on-but-absent fields show a
// muted em-dash so a row never looks broken. Pure drawing into dl.
// TABLE-style (portfolio_style 0) right-edge column anchors — shared by pfDrawRow's row body and the
// column-header strip so the header labels sit exactly over the values they name.
// TABLE-style (style 0) column widths (logical px) — shared by pfTableCols (which turns them into
// right-edge anchors) and pfDrawRow (which feeds valW/drgxW to fit() truncation), so truncated text
// always lines up with the column edges. Change here and both stay in sync.
static constexpr float kPfChgColW  = 62.0f;
static constexpr float kPfValColW  = 108.0f;
static constexpr float kPfDrgxColW = 118.0f;

struct PfTableCols { float labelR, drgxR, valR, chgR; };
static PfTableCols pfTableCols(float right, float dp)
{
    const float colGap = Layout::spacingMd();
    PfTableCols c;
    c.chgR   = right;
    c.valR   = c.chgR  - kPfChgColW  * dp - colGap;
    c.drgxR  = c.valR  - kPfValColW  * dp - colGap;
    c.labelR = c.drgxR - kPfDrgxColW * dp - colGap;
    return c;
}

// Portfolio row height + inter-row gap by style — one source shared by the row drawing
// (mktDrawPortfolio) and the scroll-region height budget (RenderMarketTab) so they can't drift and clip.
// Row heights: Table 48dp (a dedicated trend column needs vertical room), Cards 68dp (two-zone),
// Spotlight 92dp (a dominant bottom chart band under the hero value). Uniform per style.
static float pfRowHeight(int style, float dp) { return (style == 0 ? 48.0f : style == 1 ? 68.0f : 92.0f) * dp; }
// Fraction of the Table label+trend zone given to the label; the rest is the centre TREND column.
// Shared by the row body and the header strip so the sparkline and its "TREND" label line up.
static constexpr float kPfTableTrendFrac = 0.38f;
static float pfRowGapFor(int style, float dp) { return style == 0 ? 2.0f * dp : Layout::spacingSm(); }

static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
                      const config::Settings::PortfolioEntry& e, const WalletState& state,
                      const MarketInfo& market, int style, float dp,
                      ImFont* sub1, ImFont* capFont, bool hov, bool tableReserveSpark = false)
{
    const double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
    ImU32 accent = e.color ? (ImU32)e.color : 0;
    const bool zeroBal = (bal <= 0.0);   // empty groups render dimmed (content only, not the container)

    // ---- Per-style CONTAINER: Table (0) = borderless ledger row (hairline + hover wash); Cards (1) =
    //      glass card with a faint identity tint; Spotlight (2) = a heavier glass tile. No accent line —
    //      the group's colour already reads from its icon and the sparkline, so it adds nothing here. ----
    const float round = 10.0f * dp;
    if (style == 0) {
        if (hov) dl->AddRectFilled(rowMin, rowMax, WithAlpha(OnSurface(), 14), 0.0f);
        dl->AddLine(ImVec2(rowMin.x, rowMax.y - 0.5f), ImVec2(rowMax.x, rowMax.y - 0.5f),
                    WithAlpha(OnSurface(), 24), 1.0f);
    } else if (style == 1) {
        GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 42 : 30; g.borderAlpha = 55;
        DrawGlassPanel(dl, rowMin, rowMax, g);
        if (accent) dl->AddRectFilled(rowMin, rowMax, WithAlpha(accent, hov ? 16 : 10), round);  // faint identity wash
        if (hov)    dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 80), round, 0, 1.2f);
    } else {
        GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 56 : 46; g.borderAlpha = 60;
        DrawGlassPanel(dl, rowMin, rowMax, g);
        if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), round, 0, 1.2f);
    }

    auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
    auto fit = [&](std::string s, ImFont* f, float maxW){ bool t=false; while (s.size()>1 && tw(f,s)>maxW){ s.pop_back(); t=true; } if (t) s += "\xE2\x80\xA6"; return s; };
    // Right-aligned text: right edge at xr, vertically centred on yc.
    auto rtext = [&](ImFont* f, float xr, float yc, ImU32 col, const std::string& s){
        dl->AddText(f, f->LegacySize, ImVec2(xr - tw(f, s), yc - f->LegacySize * 0.5f), col, s.c_str());
    };
    const std::string kDash = "\xE2\x80\x94";   // em-dash for on-but-absent fields

    // ---- Field values + presence ----
    char vb[80]; std::string valStr; bool hasValue=false;
    if (e.priceBasis==0 && market.price_usd>0){ snprintf(vb,sizeof(vb),"$%.2f",bal*market.price_usd); valStr=vb; hasValue=true; }
    else if (e.priceBasis==1 && market.price_btc>0){ snprintf(vb,sizeof(vb),"%.8f BTC",bal*market.price_btc); valStr=vb; hasValue=true; }
    else if (e.priceBasis==3 && e.manualPrice>0.0){ snprintf(vb,sizeof(vb),"%.2f %s",bal*e.manualPrice,e.manualCurrency.c_str()); valStr=vb; hasValue=true; }
    char db[48]; snprintf(db,sizeof(db),"%.4f %s",bal,DRAGONX_TICKER);
    std::string drgxStr = std::string(db);
    bool wantValue  = e.showValue;
    bool wantChange = e.show24h && (e.priceBasis==0 || e.priceBasis==1);
    bool hasChange  = wantChange && market.change_24h != 0.0;
    std::string chgStr; if (hasChange){ char cb[32]; snprintf(cb,sizeof(cb),"%+.2f%%",market.change_24h); chgStr=cb; }
    ImU32 chgCol = market.change_24h>=0 ? Success() : Error();
    bool wantSpark = e.showSparkline && (e.priceBasis==0 || e.priceBasis==1);
    std::vector<double> spark;
    if (wantSpark) spark = data::sparklineSeries(market, e.sparklineInterval);
    bool hasSpark = wantSpark && spark.size() >= 2;
    ImU32 sparkCol = (!spark.empty() && spark.back()>=spark.front()) ? Success() : Error();

    const float padX = Layout::spacingMd(), padY = Layout::spacingSm(), colGap = Layout::spacingMd();
    float left  = rowMin.x + padX;
    float right  = rowMax.x - padX;
    float midY  = (rowMin.y + rowMax.y) * 0.5f;

    // Empty groups: dim the BODY content (text/pill/bar/sparkline) to ~half alpha so a $0 group reads as
    // muted, while the container (card/tick) stays full-strength. Done by scaling the alpha of the
    // vertices this body emits — one place instead of tinting every draw call.
    const int zdVtx = zeroBal ? dl->VtxBuffer.Size : -1;

    if (style == 0) {
        // ---- TABLE: icon + label (left); a dedicated centre TREND column (aligned + header-labelled);
        //      right-aligned numeric columns DRGX | value | 24h pill. The trend column is present for the
        //      whole list when any group opts in (tableReserveSpark); each row fills it when it has one.
        const PfTableCols cols = pfTableCols(right, dp);
        const float chgR = cols.chgR, valR = cols.valR, drgxR = cols.drgxR, labelR = cols.labelR;
        const float valW = kPfValColW*dp, drgxW = kPfDrgxColW*dp;   // fit() widths (shared with pfTableCols)
        float iconSz = capFont->LegacySize, x = left;
        if (!e.icon.empty()) {
            material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, midY), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
            x += iconSz + Layout::spacingSm();
        }
        if (e.showDrgx) rtext(capFont, drgxR, midY, OnSurfaceMedium(), fit(drgxStr, capFont, drgxW));
        if (wantValue)  rtext(sub1, valR, midY, hasValue?OnSurface():OnSurfaceDisabled(), hasValue?fit(valStr,sub1,valW):kDash);
        if (wantChange) {
            const std::string s = hasChange ? chgStr : kDash;
            const float pw = tw(capFont, s) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
            const ImVec2 pmn(chgR - pw, midY - ph*0.5f);
            if (hasChange) dl->AddRectFilled(pmn, ImVec2(chgR, midY + ph*0.5f), WithAlpha(chgCol, 32), ph*0.5f);
            dl->AddText(capFont, capFont->LegacySize, ImVec2(pmn.x + 6.0f*dp, midY - capFont->LegacySize*0.5f),
                        hasChange?chgCol:OnSurfaceDisabled(), s.c_str());
        }
        // Dedicated centre TREND column: when the list has any sparkline (tableReserveSpark), cap the
        // label to a fixed boundary so the column + its header label align across every row; each row
        // fills it only when that group opts in. Rows without any list-wide trend keep the full label.
        // Trend-column origin from the fixed LEFT (icon-independent) so it lines up with the "TREND"
        // header and across rows whether or not a group has an icon (x may be shifted past the icon).
        const float zoneW   = std::max(24.0f*dp, labelR - left);
        const float trendX0 = left + zoneW * kPfTableTrendFrac;
        const float lblMaxW = tableReserveSpark ? std::max(24.0f*dp, trendX0 - colGap - x) : std::max(24.0f*dp, labelR - x);
        dl->AddText(sub1, sub1->LegacySize, ImVec2(x, midY - sub1->LegacySize*0.5f), OnSurface(), fit(e.label,sub1,lblMaxW).c_str());
        if (hasSpark && tableReserveSpark && labelR - trendX0 > 40.0f * dp)
            pfDrawSparklineFilled(dl, ImVec2(trendX0, rowMin.y + padY), ImVec2(labelR, rowMax.y - padY), spark, sparkCol, dp);
    } else if (style == 1) {
        // ---- CARDS: a balanced snapshot — a LEFT-aligned info stack (icon + label / value+delta chip /
        //      DRGX) grouped on the left, with a full-height sparkline filling the rest of the width.
        const float zoneTop = rowMin.y + padY;
        const float zoneBot = rowMax.y - padY;
        // Icon centred in the info zone; the label/value/DRGX stack sits to its right.
        const float iconSz = capFont->LegacySize;
        float textX = left;
        if (!e.icon.empty()) {
            material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(left + iconSz*0.5f, (zoneTop + zoneBot)*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
            textX = left + iconSz + Layout::spacingSm();
        }
        // Info zone driven by the ACTUAL content width so a short group hands the extra room to the chart;
        // bounded [28%, 52%] so the info never cramps and the chart always keeps a decent band.
        float infoR = right, cSparkL = right;
        if (hasSpark) {
            const std::string vTxt0 = wantValue ? (hasValue?valStr:kDash) : std::string();
            const float chipW    = (wantChange && hasChange) ? (Layout::spacingSm() + tw(capFont, chgStr) + 12.0f*dp) : 0.0f;
            const float valLineW = wantValue ? (tw(sub1, vTxt0) + chipW) : 0.0f;
            const float contentW = std::max(std::max(tw(capFont, e.label), valLineW), e.showDrgx ? tw(capFont, drgxStr) : 0.0f);
            const float span     = right - left;
            const float infoW    = std::min(std::max((textX - left) + contentW + colGap * 2.0f, span * 0.28f), span * 0.52f);
            cSparkL = left + infoW;
            infoR   = cSparkL - colGap;
        }
        const float gap    = Layout::spacingXs();
        const float stackH = capFont->LegacySize + gap + sub1->LegacySize + gap + capFont->LegacySize;
        float cy = zoneTop + std::max(0.0f, (zoneBot - zoneTop - stackH) * 0.5f);
        // label (muted)
        dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, cy), OnSurfaceMedium(), fit(e.label, capFont, std::max(24.0f*dp, infoR - textX)).c_str());
        cy += capFont->LegacySize + gap;
        // value (leads via a soft shadow) + delta chip trailing it
        if (wantValue) {
            const std::string vTxt = hasValue ? valStr : kDash;
            DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(textX, cy), hasValue?OnSurface():OnSurfaceDisabled(), vTxt.c_str());
            if (wantChange && hasChange) {
                const float pw = tw(capFont, chgStr) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
                const float chipX = textX + tw(sub1, vTxt) + Layout::spacingSm();
                const float chipY = cy + (sub1->LegacySize - ph)*0.5f;
                if (chipX + pw <= infoR) {
                    dl->AddRectFilled(ImVec2(chipX, chipY), ImVec2(chipX+pw, chipY+ph), WithAlpha(chgCol, 34), ph*0.5f);
                    dl->AddText(capFont, capFont->LegacySize, ImVec2(chipX+6.0f*dp, chipY + (ph-capFont->LegacySize)*0.5f), chgCol, chgStr.c_str());
                }
            }
        }
        cy += sub1->LegacySize + gap;
        // DRGX (muted)
        if (e.showDrgx) dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, cy), OnSurfaceMedium(), fit(drgxStr, capFont, std::max(24.0f*dp, infoR - textX)).c_str());
        if (hasSpark) pfDrawSparklineFilled(dl, ImVec2(cSparkL, zoneTop), ImVec2(right, zoneBot), spark, sparkCol, dp);
    } else {
        // ---- SPOTLIGHT: "big number over a chart" hero tile — a top zone (muted name + DRGX, then a
        //      LARGE value coloured by 24h with a delta chip) over a dominant full-width sparkline band
        //      filling the bottom. The value spans the full width now (no competing right strip).
        const float heroSz = sub1->LegacySize * 1.5f;   // ImGui 1.92 rebakes at this size (crisp, not upscaled)
        const float topY = rowMin.y + padY;
        float iconSz = capFont->LegacySize, x = left;
        if (!e.icon.empty()) {
            material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+capFont->LegacySize*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
            x += iconSz + Layout::spacingSm();
        }
        // name (top-left, muted) + DRGX (top-right, muted)
        const float drgxW2 = e.showDrgx ? tw(capFont, drgxStr) : 0.0f;
        if (e.showDrgx) rtext(capFont, right, topY + capFont->LegacySize*0.5f, OnSurfaceMedium(), drgxStr);
        const float nameMaxW = std::max(24.0f*dp, (right - (e.showDrgx ? drgxW2 + colGap : 0.0f)) - x);
        dl->AddText(capFont, capFont->LegacySize, ImVec2(x, topY), OnSurfaceMedium(), fit(e.label,capFont,nameMaxW).c_str());
        // hero value — coloured by 24h direction (neutral when no live change)
        const float valY = topY + capFont->LegacySize + Layout::spacingXs();
        const std::string hero = hasValue ? valStr : (e.showDrgx ? drgxStr : kDash);
        const ImU32 heroCol = !hasValue ? OnSurfaceDisabled() : (hasChange ? chgCol : OnSurface());
        DrawTextShadow(dl, sub1, heroSz, ImVec2(left, valY), heroCol, hero.c_str());
        const float heroW = sub1->CalcTextSizeA(heroSz, FLT_MAX, 0, hero.c_str()).x;
        // delta chip trailing the hero value
        if (wantChange && hasChange) {
            const float pw = tw(capFont, chgStr) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
            const float chipX = left + heroW + Layout::spacingSm();
            const float chipY = valY + (heroSz - ph)*0.5f;
            if (chipX + pw <= right) {
                dl->AddRectFilled(ImVec2(chipX, chipY), ImVec2(chipX+pw, chipY+ph), WithAlpha(chgCol, 34), ph*0.5f);
                dl->AddText(capFont, capFont->LegacySize, ImVec2(chipX+6.0f*dp, chipY + (ph-capFont->LegacySize)*0.5f), chgCol, chgStr.c_str());
            }
        }
        // full-width sparkline band filling the bottom of the tile, below the hero value
        if (hasSpark) {
            const float bandTop = valY + heroSz + Layout::spacingXs();
            if (rowMax.y - padY - bandTop > 12.0f * dp)
                pfDrawSparklineFilled(dl, ImVec2(left, bandTop), ImVec2(right, rowMax.y - padY), spark, sparkCol, dp);
        }
    }

    if (zdVtx >= 0) {   // fade the just-emitted body vertices for an empty group
        for (int vi = zdVtx; vi < dl->VtxBuffer.Size; ++vi) {
            ImDrawVert& v = dl->VtxBuffer[vi];
            v.col = material::ScaleAlpha(v.col, 0.5f);
        }
    }
}

// Pair selector: every trading pair across all exchanges as flat wrapping chips; clicking one
// switches the active exchange/pair (persisted) and refreshes market data. Followed by attribution.
static void mktDrawPairSelector(App* app, const std::vector<data::ExchangeInfo>& registry, float availWidth)
{
    auto& S = schema::UI();
    const auto& market = app->getWalletState().market;
    ImDrawList* dl = ImGui::GetWindowDrawList();
    ImFont* capFont = Type().caption();
    ImFont* ovFont  = Type().overline();
    float gap = Layout::cardGap();
    char buf[128];

    ImGui::Dummy(ImVec2(0, S.drawElement("tabs.market", "exchange-top-gap").size));
    {
        float dp = Layout::dpiScale();
        float chipH = S.drawElement("tabs.market", "pair-chip-height").height * dp;
        float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius * dp;
        float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size * dp;
        float innerGap = Layout::spacingSm();
        float sidePad = Layout::spacingMd();

        ImVec2 origin = ImGui::GetCursorScreenPos();
        float left = origin.x;
        float right = origin.x + availWidth;
        float cx = left;
        float cy = origin.y;
        int btnCounter = 0;

        for (int ex = 0; ex < (int)registry.size(); ex++) {
            for (int pr = 0; pr < (int)registry[ex].pairs.size(); pr++) {
                const std::string& pairName = registry[ex].pairs[pr].displayName;
                const std::string& exName   = registry[ex].name;

                float pairW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pairName.c_str()).x;
                float exW   = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0, exName.c_str()).x;
                float cw    = sidePad * 2.0f + pairW + innerGap + exW;

                // Wrap to the next row when the pill would overflow (never on an empty row).
                if (cx + cw > right && cx > left) {
                    cx = left;
                    cy += chipH + chipSpacing;
                }

                ImVec2 cMin(cx, cy);
                ImVec2 cMax(cx + cw, cy + chipH);
                bool selected = (ex == s_mkt.exchangeIdx && pr == s_mkt.pairIdx);
                bool hov = material::IsRectHovered(cMin, cMax);
                ImU32 chipBg = selected ? WithAlpha(Primary(), 200)
                                        : (hov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 20));
                ImU32 chipBorder = selected ? Primary() : WithAlpha(OnSurface(), 40);
                ImU32 pairCol = selected ? IM_COL32(255, 255, 255, 255) : OnSurface();
                ImU32 exCol   = selected ? WithAlpha(IM_COL32(255, 255, 255, 255), 190) : OnSurfaceDisabled();

                dl->AddRectFilled(cMin, cMax, chipBg, chipR);
                dl->AddRect(cMin, cMax, chipBorder, chipR, 0, 1.0f);

                float tx = cx + sidePad;
                dl->AddText(capFont, capFont->LegacySize,
                            ImVec2(tx, cy + (chipH - capFont->LegacySize) * 0.5f), pairCol, pairName.c_str());
                dl->AddText(ovFont, ovFont->LegacySize,
                            ImVec2(tx + pairW + innerGap, cy + (chipH - ovFont->LegacySize) * 0.5f),
                            exCol, exName.c_str());

                ImGui::SetCursorScreenPos(cMin);
                snprintf(buf, sizeof(buf), "##PairBtn%d", btnCounter++);
                if (ImGui::InvisibleButton(buf, ImVec2(cw, chipH))) {
                    if (ex != s_mkt.exchangeIdx || pr != s_mkt.pairIdx) {
                        s_mkt.exchangeIdx = ex;
                        s_mkt.pairIdx = pr;
                        app->settings()->setSelectedExchange(exName);
                        app->settings()->setSelectedPair(pairName);
                        app->settings()->save();
                        app->refreshMarketData();
                    }
                }
                if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);

                cx += cw + chipSpacing;
            }
        }

        // Advance the cursor past the wrapped rows.
        ImGui::SetCursorScreenPos(ImVec2(left, cy + chipH));
        ImGui::Dummy(ImVec2(availWidth, 0));

        // Attribution (moved here from the removed exchange-selector row).
        ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
        Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
        if (!market.last_updated.empty()) {
            ImGui::SameLine(0, 12);
            snprintf(buf, sizeof(buf), TR("market_updated"), market.last_updated.c_str());
            Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
        }
        ImGui::Dummy(ImVec2(0, gap));
    }
}

// Shared per-frame context for the three coupled RenderMarketTab sections (hero/chart/portfolio):
// the precomputed geometry + chart series. Cheap locals (dl, fonts, dp, state, market, schema) are
// recomputed inside each helper.
struct MktCtx {
    App* app;
    const data::ExchangeInfo* currentExchange;
    float chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH;
    const std::vector<std::time_t>* chartTimes;
    const std::vector<data::Candle>* chartCandles;   // per-exchange OHLC (empty -> line chart)
    std::time_t nowSec;
    bool chartUp;
    double periodChangePct;
    std::string periodSuffix;
};

// Combined hero card: draws the ONE glass panel spanning the price header AND the chart below it,
// then the price / ticker / period-change badge / trade button (or a loading/error state).
static void mktDrawPriceHero(const MktCtx& cx)
{
    App* app = cx.app;
    const auto& market = app->getWalletState().market;
    const auto& currentExchange = *cx.currentExchange;
    ImDrawList* dl = ImGui::GetWindowDrawList();
    float availWidth = ImGui::GetContentRegionAvail().x;
    float pad = Layout::cardInnerPadding();
    ImFont* capFont = Type().caption();
    ImFont* sub1 = Type().subtitle1();
    ImFont* body2 = Type().body2();
    GlassPanelSpec glassSpec; glassSpec.rounding = Layout::glassRounding();
    float chartH = cx.chartH, heroHeaderH = cx.heroHeaderH;
    bool chartUp = cx.chartUp; double periodChangePct = cx.periodChangePct;
    const std::string& periodSuffix = cx.periodSuffix;
    char buf[128];

    float dp = Layout::dpiScale();
    ImVec2 cardMin = ImGui::GetCursorScreenPos();
    float cardH = heroHeaderH;
    ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
    // Combined hero + chart: draw ONE glass panel spanning the price/stats header AND the
    // chart below it (chartH precomputed so the chart shrinks to fit the portfolio).
    float mergedChartH = chartH;
    ImVec2 mergedMax(cardMin.x + availWidth, cardMax.y + mergedChartH);
    DrawGlassPanel(dl, cardMin, mergedMax, glassSpec);

    float cx0 = cardMin.x + Layout::spacingLg();
    float cy = cardMin.y + Layout::spacingLg();

    // Prefer the SELECTED exchange's own current price (it differs per venue — Ourbit vs NonKYC can be
    // ~7% apart); fall back to the CoinGecko cross-exchange aggregate when the venue price is unknown.
    double heroPrice = market.price_usd;
    if (!currentExchange.pairs.empty() && s_mkt.pairIdx < (int)currentExchange.pairs.size()
        && currentExchange.pairs[s_mkt.pairIdx].lastUsd > 0.0)
        heroPrice = currentExchange.pairs[s_mkt.pairIdx].lastUsd;

    if (heroPrice > 0) {
        // ---- HERO PRICE (large, prominent) ----
        ImFont* h3 = Type().h3();
        std::string priceStr = FormatPrice(heroPrice);
        ImU32 priceCol = Success();
        DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx0, cy), priceCol, priceStr.c_str());

        // Ticker label after price
        float priceW = h3->CalcTextSizeA(h3->LegacySize, FLT_MAX, 0, priceStr.c_str()).x;
        dl->AddText(body2, body2->LegacySize,
                    ImVec2(cx0 + priceW + Layout::spacingSm(), cy + (h3->LegacySize - body2->LegacySize)),
                    OnSurfaceMedium(), DRAGONX_TICKER);

        // Change badge — over the displayed chart period (or 24h for Live) — right of the ticker.
        float tickerW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, DRAGONX_TICKER).x;
        float badgeX = cx0 + priceW + Layout::spacingSm() + tickerW + Layout::spacingMd();
        ImU32 chgCol = chartUp ? Success() : Error();
        snprintf(buf, sizeof(buf), "%s%.2f%% %s", chartUp ? "+" : "", periodChangePct, periodSuffix.c_str());
        ImVec2 chgSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
        float badgePadH = Layout::spacingSm();
        float badgePadV = Layout::spacingXs();
        ImVec2 bMin(badgeX, cy + (h3->LegacySize - chgSz.y - badgePadV * 2) * 0.5f);
        ImVec2 bMax(badgeX + chgSz.x + badgePadH * 2, bMin.y + chgSz.y + badgePadV * 2);
        ImU32 badgeBg = chartUp ? WithAlpha(Success(), 30) : WithAlpha(Error(), 30);
        dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp);
        dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf);

        // (Divider is drawn in the chart block, below the interval buttons.)

        // ---- TRADE BUTTON (top-right of card) ----
        if (!currentExchange.pairs.empty()) {
            const char* pairName = currentExchange.pairs[s_mkt.pairIdx].displayName.c_str();
            ImFont* iconFont = Type().iconSmall();
            ImVec2 textSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, pairName);
            ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, ICON_MD_OPEN_IN_NEW);
            float iconGap = Layout::spacingSm();
            float tradePadH = Layout::spacingMd();
            float tradePadV = Layout::spacingSm();
            float tradeBtnW = textSz.x + iconGap + iconSz.x + tradePadH * 2;
            float tradeBtnH = std::max(textSz.y, iconSz.y) + tradePadV * 2;
            float tradeBtnX = cardMax.x - pad - tradeBtnW;
            float tradeBtnY = cardMin.y + Layout::spacingSm();

            // Chart line/candle segmented control, immediately left of the trade button — only when the
            // selected range has per-exchange candles (Live / aggregate is line-only, nothing to toggle).
            if (cx.chartCandles && cx.chartCandles->size() >= 2) {
                const char* styleIcons[2] = { ICON_MD_SHOW_CHART, ICON_MD_CANDLESTICK_CHART };
                const float csW = 2.0f * tradeBtnH;
                const ImVec2 csOrigin(tradeBtnX - csW - Layout::spacingSm(), tradeBtnY);
                const ImVec2 csSaved = ImGui::GetCursorScreenPos();
                const int csCur = s_mkt.chartStyle;
                const int csClk = SegmentedControl(dl, csOrigin, csW, tradeBtnH, styleIcons, 2, csCur,
                                                   Type().iconSmall(), "##chartStyleSeg", dp);
                if (csClk >= 0 && csClk != csCur) {
                    s_mkt.chartStyle = csClk;
                    if (app->settings()) { app->settings()->setChartStyle(csClk); app->settings()->save(); }
                }
                ImGui::SetCursorScreenPos(csSaved);
            }

            ImVec2 tMin(tradeBtnX, tradeBtnY), tMax(tradeBtnX + tradeBtnW, tradeBtnY + tradeBtnH);
            bool tradeHov = material::IsRectHovered(tMin, tMax);

            // Glass pill background
            GlassPanelSpec tradeBtnGlass;
            tradeBtnGlass.rounding = tradeBtnH * 0.5f;
            tradeBtnGlass.fillAlpha = tradeHov ? 35 : 20;
            DrawGlassPanel(dl, tMin, tMax, tradeBtnGlass);
            if (tradeHov)
                dl->AddRectFilled(tMin, tMax, WithAlpha(Primary(), 20), tradeBtnH * 0.5f);

            // Text (pair name with body2, icon with icon font)
            ImU32 tradeCol = tradeHov ? OnSurface() : OnSurfaceMedium();
            float contentY = tradeBtnY + tradePadV;
            float curX = tradeBtnX + tradePadH;
            dl->AddText(body2, body2->LegacySize,
                        ImVec2(curX, contentY), tradeCol, pairName);
            curX += textSz.x + iconGap;
            dl->AddText(iconFont, iconFont->LegacySize,
                        ImVec2(curX, contentY + (textSz.y - iconSz.y) * 0.5f), tradeCol, ICON_MD_OPEN_IN_NEW);

            // Click
            ImVec2 savedCur = ImGui::GetCursorScreenPos();
            ImGui::SetCursorScreenPos(tMin);
            ImGui::InvisibleButton("##TradeOnExchange", ImVec2(tradeBtnW, tradeBtnH));
            if (ImGui::IsItemClicked()) {
                util::Platform::openUrl(currentExchange.pairs[s_mkt.pairIdx].tradeUrl);
            }
            if (ImGui::IsItemHovered()) {
                ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                material::Tooltip(TR("market_trade_on"), currentExchange.name.c_str());
            }
            ImGui::SetCursorScreenPos(savedCur);
        }
    } else {
        const char* status = market.price_loading ? TR("market_price_loading") : TR("market_price_unavailable");
        DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx0, cy + 10 * dp), OnSurfaceDisabled(), status);
        if (!market.price_loading && !market.price_error.empty()) {
            std::string errorText = market.price_error;
            float maxErrorW = cardMax.x - cx0 - Layout::spacingLg();
            while (errorText.size() > 4 &&
                   capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, errorText.c_str()).x > maxErrorW) {
                errorText.pop_back();
            }
            if (errorText.size() < market.price_error.size()) errorText += "...";
            dl->AddText(capFont, capFont->LegacySize,
                        ImVec2(cx0, cy + 10 * dp + sub1->LegacySize + Layout::spacingXs()),
                        Warning(), errorText.c_str());
        }
    }

    // No inter-card gap — the chart is drawn immediately below, inside the same panel.
    ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
    ImGui::Dummy(ImVec2(availWidth, 0));
}

// Price chart, drawn inside the hero's shared glass panel: interval strip + 24h stats + refresh,
// then the plotted curve (grid, area fill, hi/lo + time labels, hover crosshair/tooltip) or an
// empty state. Reads the precomputed series (s_mkt.history / cx.chartTimes).
static void mktDrawPriceChart(const MktCtx& cx)
{
    App* app = cx.app;
    const auto& market = app->getWalletState().market;
    auto& S = schema::UI();
    ImDrawList* dl = ImGui::GetWindowDrawList();
    float availWidth = ImGui::GetContentRegionAvail().x;
    float pad = Layout::cardInnerPadding();
    float hs = Layout::hScale(availWidth);
    float mktDp = Layout::dpiScale();
    ImFont* capFont = Type().caption();
    ImFont* sub1 = Type().subtitle1();
    float chartH = cx.chartH;
    const auto& chartTimes = *cx.chartTimes;
    std::time_t nowSec = cx.nowSec;
    bool chartUp = cx.chartUp;
    char buf[128];

    // The chart series (s_mkt.history) + timestamps (chartTimes) were computed in the
    // precompute above. Historical intervals come from CoinGecko market_chart; "Live" is the
    // in-session buffer. Until two points exist, the empty-state below is shown.
    // The chart shares the combined hero+chart glass panel drawn in PRICE SUMMARY above.
    ImVec2 chartMin = ImGui::GetCursorScreenPos();
    ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH);

    float chartPad = pad;
    float pillH    = capFont->LegacySize + Layout::spacingXs() * 2.0f;
    // Buttons sit near the top of the chart area, with a gap below them before the plot.
    float stripTop = chartMin.y + Layout::spacingXs();

    // ---- Top strip: interval buttons (left) + 24H volume / market cap + refresh (right) ----
    {
        float rowTop = stripTop;
        float textY  = rowTop + (pillH - capFont->LegacySize) * 0.5f;

        // Interval buttons (left). Live = in-session buffer; the rest are historical.
        const struct { const char* lbl; int iv; } kIvs[] = {
            { TR("market_iv_live"), 0 }, { TR("market_iv_1h"), 1 }, { TR("market_iv_1d"), 2 },
            { TR("market_iv_1w"), 3 }, { TR("market_iv_1m"), 4 }
        };
        float bx = chartMin.x + chartPad;
        for (int b = 0; b < 5; b++) {
            float tw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, kIvs[b].lbl).x;
            float bw = tw + Layout::spacingSm() * 2.0f;
            ImVec2 bmn(bx, rowTop), bmx(bx + bw, rowTop + pillH);
            bool sel = (s_mkt.chartInterval == kIvs[b].iv);
            bool bhov = material::IsRectHovered(bmn, bmx);
            ImU32 bg = sel ? WithAlpha(Primary(), 200)
                           : (bhov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 18));
            dl->AddRectFilled(bmn, bmx, bg, 4.0f * mktDp);
            dl->AddText(capFont, capFont->LegacySize, ImVec2(bx + Layout::spacingSm(), textY),
                        sel ? IM_COL32(255, 255, 255, 255) : OnSurface(), kIvs[b].lbl);
            ImGui::SetCursorScreenPos(bmn);
            ImGui::PushID(9100 + b);
            if (ImGui::InvisibleButton("##civ", ImVec2(bw, pillH))) {
                s_mkt.chartInterval = kIvs[b].iv;
                if (app->settings()) { app->settings()->setChartInterval(kIvs[b].iv); app->settings()->save(); }
            }
            if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
            ImGui::PopID();
            bx += bw + Layout::spacingXs();
        }

        // (Line/candle chart-style toggle now lives top-right of the price hero, left of the trade button.)

        // Refresh button (far right).
        float rEdge = chartMax.x - chartPad;
        ImFont* iconSmall = material::Typography::instance().iconSmall();
        ImVec2 rbMin(rEdge - pillH, rowTop), rbMax(rEdge, rowTop + pillH);
        bool refreshHov = material::IsRectHovered(rbMin, rbMax);
        if (refreshHov) { dl->AddRectFilled(rbMin, rbMax, IM_COL32(255, 255, 255, 20), 4.0f * mktDp);
                          ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
        ImVec2 icSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH);
        dl->AddText(iconSmall, iconSmall->LegacySize,
                    ImVec2(rbMin.x + (pillH - icSz.x) * 0.5f, rbMin.y + (pillH - icSz.y) * 0.5f),
                    refreshHov ? OnSurface() : OnSurfaceMedium(), ICON_MD_REFRESH);
        ImGui::SetCursorScreenPos(rbMin);
        if (ImGui::InvisibleButton("##RefreshMarket", ImVec2(pillH, pillH))) {
            app->refreshMarketData();
        }
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("market_refresh_price"));

        // 24H volume + market cap, right-aligned to the left of the refresh button. Skipped
        // when there's no room (narrow window) so they never overlap the interval buttons.
        float sxr = rbMin.x - Layout::spacingMd();
        auto drawStat = [&](const char* label, const std::string& val) {
            char sb[64]; snprintf(sb, sizeof(sb), "%s %s", label, val.c_str());
            float w = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, sb).x;
            if (sxr - w < bx + Layout::spacingMd()) return;
            sxr -= w;
            dl->AddText(capFont, capFont->LegacySize, ImVec2(sxr, textY), OnSurfaceMedium(), sb);
            sxr -= Layout::spacingMd();
        };
        if (market.price_usd > 0) {
            // Market cap is coin-wide (stays aggregate); 24h volume is per-venue — show the SELECTED
            // exchange's own volume when we have it, else the CoinGecko aggregate.
            drawStat(TR("market_cap_short"), FormatCompactUSD(market.market_cap));
            double vol = market.volume_24h;
            const auto& exch = *cx.currentExchange;
            if (!exch.pairs.empty() && s_mkt.pairIdx < (int)exch.pairs.size()
                && exch.pairs[s_mkt.pairIdx].volumeUsd > 0.0)
                vol = exch.pairs[s_mkt.pairIdx].volumeUsd;
            drawStat(TR("market_vol_short"), FormatCompactUSD(vol));
        }
    }

    float labelPadLeft = std::max(S.drawElement("tabs.market", "chart-y-axis-min-padding").size,
                                  S.drawElement("tabs.market", "chart-y-axis-padding").size * hs);
    // Extra bottom room so the time labels aren't crowded against the card's bottom edge.
    float labelPadBottom = Layout::spacingXl() + Layout::spacingSm();
    float plotLeft   = chartMin.x + labelPadLeft;
    float plotRight  = chartMax.x - chartPad;
    float plotTop    = stripTop + pillH + Layout::spacingMd();   // gap below the buttons before the plot
    float plotBottom = chartMax.y - labelPadBottom;
    float plotW      = plotRight - plotLeft;
    float plotH      = plotBottom - plotTop;

    const auto& candles = *cx.chartCandles;
    // Candlesticks when per-exchange OHLC exists AND the user hasn't switched to the line view.
    const bool hasCandles = candles.size() >= 2 && s_mkt.chartStyle == 1;
    if (hasCandles || s_mkt.history.size() >= 2) {
        // Compute Y range with padding — candles span low..high, the line spans close..close.
        double yMin, yMax;
        if (hasCandles) {
            yMin = candles[0].low; yMax = candles[0].high;
            for (const auto& c : candles) { if (c.low < yMin) yMin = c.low; if (c.high > yMax) yMax = c.high; }
        } else {
            yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end());
            yMax = *std::max_element(s_mkt.history.begin(), s_mkt.history.end());
        }
        if (yMax <= yMin) { yMax = yMin + 1e-8; }
        double yRange = yMax - yMin;
        double yPadding = yRange * 0.12;
        yMin -= yPadding;
        yMax += yPadding;

        // Horizontal grid lines (4 lines)
        for (int g = 0; g <= 4; g++) {
            float gy = plotTop + plotH * (float)g / 4.0f;
            dl->AddLine(ImVec2(plotLeft, gy), ImVec2(plotRight, gy),
                        IM_COL32(255, 255, 255, 12), 1.0f);
            double labelVal = yMax - (yMax - yMin) * (double)g / 4.0;
            // Adaptive precision (via the shared price formatter) — cleaner than a fixed 6 decimals.
            snprintf(buf, sizeof(buf), "%s", FormatPrice(labelVal).c_str());
            ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
            // Keep the axis label inside the card even on narrow windows (min-padding may be
            // smaller than the label width) so it never spills onto the tab background.
            float lblX = std::max(chartMin.x + 3.0f * mktDp, plotLeft - labelSz.x - 6.0f * mktDp);
            dl->AddText(capFont, capFont->LegacySize,
                        ImVec2(lblX, gy - labelSz.y * 0.5f),
                        OnSurfaceDisabled(), buf);
        }

        // Build points
        size_t n = s_mkt.history.size();
        std::vector<ImVec2> points(n);

        // Colored by the DISPLAYED period's direction (chartUp), not just 24h.
        ImU32 dirCol  = chartUp ? Success() : Error();
        ImU32 lineCol = WithAlpha(dirCol, 220);
        ImU32 dotCol  = dirCol;

        auto yOf = [&](double v){ return plotBottom - (float)((v - yMin) / (yMax - yMin)) * plotH; };
        if (hasCandles) {
            // Candlesticks: a thin wick (low..high) + a body (open..close), green up / red down.
            const int   cn    = (int)candles.size();
            const float slotW = plotW / (float)cn;
            const float bodyW = std::max(1.0f, slotW * 0.62f);
            for (int i = 0; i < cn; i++) {
                const auto& c  = candles[i];
                const float xc = plotLeft + ((float)i + 0.5f) * slotW;
                const ImU32 col = (c.close >= c.open) ? Success() : Error();
                dl->AddLine(ImVec2(xc, yOf(c.high)), ImVec2(xc, yOf(c.low)), WithAlpha(col, 210),
                            std::max(1.0f, mktDp));
                float bT = yOf(std::max(c.open, c.close));   // higher price -> smaller y -> body top
                float bB = yOf(std::min(c.open, c.close));
                if (bB - bT < 1.5f * mktDp) bB = bT + 1.5f * mktDp;   // doji -> keep a visible sliver
                dl->AddRectFilled(ImVec2(xc - bodyW * 0.5f, bT), ImVec2(xc + bodyW * 0.5f, bB),
                                  WithAlpha(col, 230), 1.0f);
            }
            // Hover: highlight the candle under the cursor + an OHLC readout (date + open/high/low/close).
            ImVec2 mp = ImGui::GetIO().MousePos;
            if (mp.x >= plotLeft && mp.x <= plotRight && mp.y >= plotTop && mp.y <= plotBottom) {
                int hi = (int)((mp.x - plotLeft) / slotW);
                if (hi < 0) hi = 0;
                if (hi >= cn) hi = cn - 1;
                const auto& hc = candles[hi];
                const float hx = plotLeft + ((float)hi + 0.5f) * slotW;
                dl->AddRectFilled(ImVec2(hx - slotW * 0.5f, plotTop), ImVec2(hx + slotW * 0.5f, plotBottom),
                                  IM_COL32(255, 255, 255, 12));
                dl->AddLine(ImVec2(hx, plotTop), ImVec2(hx, plotBottom), IM_COL32(255, 255, 255, 45), 1.0f);
                char when[40] = "";
                std::time_t tt = hc.time;
                if (std::tm* tmv = std::localtime(&tt))
                    std::strftime(when, sizeof(when), s_mkt.chartInterval <= 2 ? "%b %d %H:%M" : "%b %d, %Y", tmv);
                char l2[80], l3[80];
                snprintf(l2, sizeof(l2), "O %s   H %s", FormatPrice(hc.open).c_str(), FormatPrice(hc.high).c_str());
                snprintf(l3, sizeof(l3), "L %s   C %s", FormatPrice(hc.low).c_str(),  FormatPrice(hc.close).c_str());
                const float lh  = capFont->LegacySize;
                const float pad = Layout::spacingSm();
                const float gap = 3.0f * mktDp;
                float tw = std::max(capFont->CalcTextSizeA(lh, FLT_MAX, 0, when).x,
                           std::max(capFont->CalcTextSizeA(lh, FLT_MAX, 0, l2).x,
                                    capFont->CalcTextSizeA(lh, FLT_MAX, 0, l3).x));
                float th = lh * 3 + gap * 2 + pad * 2;
                float tipX = hx + 10.0f * mktDp;
                if (tipX + tw + pad * 2 > plotRight) tipX = hx - tw - pad * 2 - 10.0f * mktDp;
                tipX = std::max(plotLeft, tipX);
                float tipY = plotTop + 4.0f * mktDp;
                ImVec2 tMin(tipX, tipY), tMax(tipX + tw + pad * 2, tipY + th);
                dl->AddRectFilled(tMin, tMax, IM_COL32(20, 20, 30, 235), 4.0f * mktDp);
                dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 0, 1.0f);
                ImU32 cCol = (hc.close >= hc.open) ? Success() : Error();
                dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad),                     OnSurface(), when);
                dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + lh + gap),          cCol, l2);
                dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + 2 * lh + 2 * gap),  cCol, l3);
            }
        } else {
            for (size_t i = 0; i < n; i++) {
                float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
                points[i] = ImVec2(plotLeft + t * plotW, yOf(s_mkt.history[i]));
            }
            // Flat translucent area fill under the curve (matches the portfolio group sparklines).
            for (size_t i = 0; i < n; i++) dl->PathLineTo(points[i]);
            dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
            dl->PathLineTo(ImVec2(points[0].x, plotBottom));
            dl->PathFillConcave(WithAlpha(dirCol, 28));
            // Line (no per-point dots — a clean curve).
            dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
        }

        // High/low price labels at the displayed range's extremes (no dot markers). Line only — the
        // candlestick wicks already show each period's high/low.
        if (!hasCandles && n >= 3) {
            size_t hiIdx = (size_t)(std::max_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
            size_t loIdx = (size_t)(std::min_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
            auto markExtreme = [&](size_t idx, bool high) {
                ImVec2 p = points[idx];
                std::string lbl = FormatPrice(s_mkt.history[idx]);
                ImVec2 ls = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, lbl.c_str());
                float ly = high ? p.y - ls.y - 5.0f * mktDp : p.y + 5.0f * mktDp;
                float lx = std::min(std::max(plotLeft, p.x - ls.x * 0.5f), plotRight - ls.x);
                dl->AddText(capFont, capFont->LegacySize, ImVec2(lx, ly), OnSurfaceMedium(), lbl.c_str());
            };
            markExtreme(hiIdx, true);
            markExtreme(loIdx, false);
        }

        // X-axis time labels. The series is timestamped, so label each tick with the real
        // "<time> ago" (rightmost = "now") derived from the sample's timestamp.
        {
            int nPts = (int)n;
            int ticks = std::min(4, nPts);
            for (int tk = 0; tk < ticks; tk++) {
                float t = (ticks > 1) ? (float)tk / (float)(ticks - 1) : 1.0f;
                float xpos = plotLeft + t * plotW;
                int idx = (int)(t * (nPts - 1) + 0.5f);
                float secAgo = (idx < (int)chartTimes.size()) ? (float)(nowSec - chartTimes[idx])
                                                              : (float)(nPts - 1 - idx) * 60.0f;
                char tlbl[32];
                if      (secAgo < 45.0f)      snprintf(tlbl, sizeof(tlbl), "%s", TR("market_now"));
                else if (secAgo < 5400.0f)    snprintf(tlbl, sizeof(tlbl), "~%.0fm", secAgo / 60.0f);
                else if (secAgo < 172800.0f)  snprintf(tlbl, sizeof(tlbl), "~%.0fh", secAgo / 3600.0f);
                else if (secAgo < 1209600.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fd", secAgo / 86400.0f);
                else if (secAgo < 7776000.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fw", secAgo / 604800.0f);
                else                          snprintf(tlbl, sizeof(tlbl), "~%.0fmo", secAgo / 2592000.0f);
                ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, tlbl);
                float lx = (tk == 0)            ? plotLeft
                         : (tk == ticks - 1)    ? plotRight - lblSz.x
                                                : xpos - lblSz.x * 0.5f;
                dl->AddText(capFont, capFont->LegacySize,
                            ImVec2(lx, plotBottom + 4 * mktDp), OnSurfaceDisabled(), tlbl);
            }
        }

        // Hover crosshair + tooltip (line only — it indexes the close-series `points`).
        ImVec2 mousePos = ImGui::GetIO().MousePos;
        if (!hasCandles && mousePos.x >= plotLeft && mousePos.x <= plotRight &&
            mousePos.y >= plotTop && mousePos.y <= plotBottom + labelPadBottom)
        {
            float mx = mousePos.x - plotLeft;
            float closest_t = mx / plotW;
            int idx = (int)(closest_t * (n - 1) + 0.5f);
            if (idx < 0) idx = 0;
            if (idx >= (int)n) idx = (int)n - 1;

            float px = points[idx].x;
            float py = points[idx].y;

            dl->AddLine(ImVec2(px, plotTop), ImVec2(px, plotBottom),
                        IM_COL32(255, 255, 255, 40), 1.0f);
            dl->AddLine(ImVec2(plotLeft, py), ImVec2(plotRight, py),
                        IM_COL32(255, 255, 255, 40), 1.0f);

            float hoverDotR = std::max(S.drawElement("tabs.market", "chart-hover-dot-min-radius").size, S.drawElement("tabs.market", "chart-hover-dot-radius").size * hs);
            float hoverRingR = std::max(S.drawElement("tabs.market", "chart-hover-ring-min-radius").size, S.drawElement("tabs.market", "chart-hover-ring-radius").size * hs);
            dl->AddCircleFilled(ImVec2(px, py), hoverDotR, dotCol);
            dl->AddCircle(ImVec2(px, py), hoverRingR, IM_COL32(255, 255, 255, 80), 0, 1.5f);

            // Tooltip: real date/time (from the series timestamps) + price at the hovered point.
            // Sub-day intervals (Live/1H) show the time; day+ intervals show the date.
            char whenBuf[32] = "";
            if (idx < (int)chartTimes.size()) {
                std::time_t tt = chartTimes[idx];
                std::tm* tmv = std::localtime(&tt);
                if (tmv) std::strftime(whenBuf, sizeof(whenBuf),
                                       s_mkt.chartInterval <= 1 ? "%b %d %H:%M" : "%b %d, %Y", tmv);
            }
            if (whenBuf[0])
                snprintf(buf, sizeof(buf), "%s \xC2\xB7 %s", whenBuf, FormatPrice(s_mkt.history[idx]).c_str());
            else
                snprintf(buf, sizeof(buf), "%s", FormatPrice(s_mkt.history[idx]).c_str());
            ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
            float tipPad = Layout::spacingSm() + Layout::spacingXs();
            float tipX = px + 10.0f * mktDp;
            float tipY = py - tipSz.y - tipPad * 2 - 4.0f * mktDp;
            if (tipX + tipSz.x + tipPad * 2 > plotRight)
                tipX = px - tipSz.x - tipPad * 2 - 10.0f * mktDp;
            if (tipY < plotTop) tipY = py + 10.0f * mktDp;

            ImVec2 tipMin(tipX, tipY);
            ImVec2 tipMax(tipX + tipSz.x + tipPad * 2, tipY + tipSz.y + tipPad * 2);
            dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f * mktDp);
            dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 0, 1.0f);
            dl->AddText(capFont, capFont->LegacySize,
                        ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
        }
    } else {
        // Empty state, centered in the plot area (the interval strip + refresh are already drawn).
        const float dp  = Layout::dpiScale();
        const float ecx = chartMin.x + availWidth * 0.5f;
        const float ecy = (plotTop + plotBottom) * 0.5f;
        if (app->isMarketChartLoading()) {
            // A fetch is in flight — e.g. just switched exchange pairs. Show a spinner + animated label
            // instead of "no history", since the data is on its way.
            const float r = 10.0f * dp;
            const float t = (float)ImGui::GetTime() * 4.5f;   // rotate ~0.7 rev/s
            dl->PathArcTo(ImVec2(ecx, ecy - 8.0f * dp), r, t, t + IM_PI * 1.5f, 24);
            dl->PathStroke(OnSurfaceMedium(), false, 2.5f * dp);
            char lb[80];
            snprintf(lb, sizeof(lb), "%s%s", TR("market_chart_loading"), LoadingDots());
            ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, lb);
            dl->AddText(capFont, capFont->LegacySize,
                        ImVec2(ecx - ts.x * 0.5f, ecy + 10.0f * dp), OnSurfaceMedium(), lb);
        } else {
            const char* msg = TR("market_no_history");
            ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
            dl->AddText(sub1, sub1->LegacySize,
                        ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, ecy - ts.y * 0.5f),
                        OnSurfaceDisabled(), msg);
        }
    }

    ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH));
    ImGui::Dummy(ImVec2(availWidth, 0));
}

// Portfolio: floating balance summary (fiat/DRGX/BTC + shielded ratio bar) and the draggable /
// resizable custom-group card grid (click a card to edit; drag to move; corner-drag to resize).
static void mktDrawPortfolio(const MktCtx& cx)
{
    App* app = cx.app;
    const auto& state = app->getWalletState();
    const auto& market = state.market;
    ImDrawList* dl = ImGui::GetWindowDrawList();
    float availWidth = ImGui::GetContentRegionAvail().x;
    float gap = Layout::cardGap();
    float mktDp = Layout::dpiScale();
    ImFont* capFont = Type().caption();
    ImFont* sub1 = Type().subtitle1();
    ImFont* body2 = Type().body2();
    float ratioBarH = cx.ratioBarH, pfSummaryH = cx.pfSummaryH, portfolioH = cx.portfolioH;
    char buf[128];

    // Garbage-collect abandoned placeholders. "Add entry" persists an empty group up front so it shows
    // in the editor list; if the user closes the editor without ever adding an address, that phantom
    // "$0 · 0" group would linger. A group with no addresses can't be Saved (the editor forbids it), so
    // it is always junk — prune it, but only while the editor is closed so we never delete one the user
    // is actively filling in.
    if (!s_pfEdit.open) {
        auto es = app->settings()->getPortfolioEntries();
        size_t before = es.size();
        es.erase(std::remove_if(es.begin(), es.end(),
                 [](const config::Settings::PortfolioEntry& e) { return e.addresses.empty(); }),
                 es.end());
        if (es.size() != before) pfPersist(app->settings(), es);
    }

    Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
    // Portfolio-style segmented control (Table / Cards / Spotlight) + "Manage…" button, right-aligned on
    // the header row (style picker left of Manage). Manage opens the portfolio editor.
    {
        const char* ml = TR("portfolio_manage");
        float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
        const char* styleItems[3] = { TR("portfolio_style_compact"), TR("portfolio_style_detailed"),
                                      TR("portfolio_style_featured") };
        const float segH = ImGui::GetFrameHeight();
        const float segW = 210.0f * mktDp;
        const float sGap = Layout::spacingSm();
        ImGui::SameLine();
        RightAlignX(segW + sGap + mBtnW);   // right-align the [style picker | Manage] group
        const ImVec2 segOrigin = ImGui::GetCursorScreenPos();
        const int curStyle = app->settings() ? app->settings()->getPortfolioStyle() : 0;
        const int segClk = SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segW, segH,
                                            styleItems, 3, curStyle, Type().caption(), "##pfStyleSeg", mktDp);
        if (segClk >= 0 && segClk != curStyle && app->settings()) {
            app->settings()->setPortfolioStyle(segClk);
            app->settings()->save();
        }
        ImGui::SetCursorScreenPos(segOrigin);
        ImGui::Dummy(ImVec2(segW, segH));   // reserve + restore layout flow
        ImGui::SameLine(0.0f, sGap);
        if (material::TactileButton(ml, ImVec2(mBtnW, 0))) {
            // Open the editor on the first group visible to this wallet (or the empty state if none) —
            // never a raw index 0 that might belong to a different wallet.
            PortfolioBeginEdit(app, pfFirstVisibleIndex(app));
            s_pfEdit.open = true;
        }
    }
    ImGui::Dummy(ImVec2(0, Layout::spacingXs()));

    double total_balance = state.totalBalance;
    double private_balance = state.privateBalance;
    double transparent_balance = state.transparentBalance;

    // No parent card background — the summary floats directly under the "MY DRGX" header.
    ImVec2 cardMin = ImGui::GetCursorScreenPos();
    float rightEdge = cardMin.x + availWidth;
    float cx0 = cardMin.x;
    float cy = cardMin.y;

    // ---- SUMMARY: fiat value (hero) + 24h change, BTC right-aligned ----
    if (market.price_usd > 0) {
        double portfolio_usd = total_balance * market.price_usd;
        if (portfolio_usd >= 1.0) snprintf(buf, sizeof(buf), "$%.2f USD", portfolio_usd);
        else                      snprintf(buf, sizeof(buf), "$%.6f USD", portfolio_usd);
        DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx0, cy), Success(), buf);
        float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;

        if (market.change_24h != 0.0) {
            char chg[48];
            snprintf(chg, sizeof(chg), "%+.2f%% %s", market.change_24h, TR("market_24h_change"));
            ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
            dl->AddText(capFont, capFont->LegacySize,
                        ImVec2(cx0 + usdW + Layout::spacingMd(),
                               cy + (sub1->LegacySize - capFont->LegacySize)),
                        chgCol, chg);
        }

        // Only show the BTC equivalent when a BTC price is actually available — otherwise it would read a
        // misleading "≈ 0.00000000 BTC" (price_usd can be present while price_btc is still 0).
        if (market.price_btc > 0) {
            double portfolio_btc = total_balance * market.price_btc;
            snprintf(buf, sizeof(buf), "\xE2\x89\x88 %.8f BTC", portfolio_btc);   // "≈ <n> BTC"
            float btcW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
            dl->AddText(capFont, capFont->LegacySize,
                        ImVec2(rightEdge - btcW, cy + (sub1->LegacySize - capFont->LegacySize)),
                        OnSurfaceMedium(), buf);
        }
    } else {
        dl->AddText(sub1, sub1->LegacySize, ImVec2(cx0, cy), OnSurfaceDisabled(), TR("market_no_price"));
    }
    cy += sub1->LegacySize + Layout::spacingSm();

    // DRGX balance + Z/T breakdown (right-aligned).
    snprintf(buf, sizeof(buf), "%.8f %s", total_balance, DRAGONX_TICKER);
    dl->AddText(body2, body2->LegacySize, ImVec2(cx0, cy), OnSurface(), buf);
    snprintf(buf, sizeof(buf), "Z %.4f  \xC2\xB7  T %.4f", private_balance, transparent_balance);
    float brkW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
    dl->AddText(capFont, capFont->LegacySize,
                ImVec2(rightEdge - brkW, cy + 2.0f * mktDp), OnSurfaceDisabled(), buf);
    cy += body2->LegacySize + Layout::spacingSm();

    // Full-width shielded/transparent ratio bar + % label.
    if (total_balance > 0) {
        float barW = availWidth;
        float shieldedRatio = (float)(private_balance / total_balance);
        if (shieldedRatio > 1.0f) shieldedRatio = 1.0f;
        if (shieldedRatio < 0.0f) shieldedRatio = 0.0f;
        float shieldedW = barW * shieldedRatio;
        float transpW   = barW - shieldedW;
        ImVec2 barStart(cx0, cy);

        // Theme-aware: a dark track (not an invisible white one) on light skins, and slightly muted
        // fills on dark skins where full-saturation Success/Warning read as a neon bar.
        const bool barLight = IsLightTheme();
        const int  fillA    = barLight ? 205 : 165;
        dl->AddRectFilled(barStart, ImVec2(barStart.x + barW, barStart.y + ratioBarH),
                          barLight ? IM_COL32(0, 0, 0, 20) : IM_COL32(255, 255, 255, 10), 3.0f * mktDp);
        if (shieldedW > 0.5f)
            dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + ratioBarH),
                              WithAlpha(Success(), fillA),
                              transpW > 0.5f ? ImDrawFlags_RoundCornersLeft : ImDrawFlags_RoundCornersAll, 3.0f * mktDp);
        if (transpW > 0.5f)
            dl->AddRectFilled(ImVec2(barStart.x + shieldedW, barStart.y),
                              ImVec2(barStart.x + barW, barStart.y + ratioBarH),
                              WithAlpha(Warning(), fillA),
                              shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f * mktDp);

        // market_pct_shielded is "%.0f%% Shielded" — pass a double (an int to %f is UB).
        snprintf(buf, sizeof(buf), TR("market_pct_shielded"), static_cast<double>(shieldedRatio) * 100.0);
        dl->AddText(capFont, capFont->LegacySize,
                    ImVec2(cx0, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
    }

    // ---- CUSTOM GROUPS — full-width rows in the selected style (single-line / two-line / value-hero),
    //      inside an internal scroll region so the portfolio stays within its height budget. Left/Right
    //      arrows switch styles (handled in RenderMarketTab). Click a row to edit it. ----
    {
        const auto& allEntries = app->settings()->getPortfolioEntries();
        const std::string activeHash = app->activeWalletIdentityHash();
        // Visible = this wallet's groups + legacy (empty scope). Keep storage indices for edit targets.
        std::vector<int> vis;
        for (int i = 0; i < (int)allEntries.size(); i++) {
            const auto& e = allEntries[i];
            // Global groups always; this wallet's only when the identity is known (never another
            // wallet's scoped groups during the switch/first-load window).
            if (e.scope.empty() || (!activeHash.empty() && e.scope == activeHash)) vis.push_back(i);
        }

        int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
        // The Table's dedicated TREND column (+ its header label) is present for the whole list when any
        // visible group opts into a sparkline, so the column aligns and doesn't flicker per row.
        bool anySpark = false;
        for (int i : vis) {
            const auto& e = allEntries[i];
            if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) { anySpark = true; break; }
        }
        float rowH = pfRowHeight(style, mktDp);
        // Table rows abut like a ledger (thin gap); Cards/Spotlight breathe.
        float rowGap = pfRowGapFor(style, mktDp);
        float rowsH = std::max(rowH, portfolioH - pfSummaryH);

        // Anchor the bottom of the portfolio: the group list otherwise ends at its content height,
        // leaving un-anchored dead space between the last row and the content-area bottom. Grow the
        // rows region to fill the remaining vertical space (down to the scroll area's bottom, less a
        // small margin) and wrap it in a contained glass panel — so it reads as one framed table with
        // a real bottom edge (like Explorer's block-list card) instead of trailing off into nothing.
        // The empty state then centres in this full-height panel rather than in a one-row band.
        ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH));
        float floorH = ImGui::GetContentRegionAvail().y - gap;   // to the scroll bottom, keep a margin
        float panelH = std::max(rowsH, floorH);
        {
            ImVec2 pMin(cardMin.x, cardMin.y + pfSummaryH);
            ImVec2 pMax(rightEdge, pMin.y + panelH);
            GlassPanelSpec pg;
            pg.rounding = Layout::glassRounding();
            pg.fillAlpha = 12; pg.borderAlpha = 24;   // faint container: frames without competing with the per-row cards
            DrawGlassPanel(dl, pMin, pMax, pg);
        }
        // Flush-left child (no window padding) so rows align with the summary; a scrollbar appears
        // only when the visible groups overflow the bounded height. NoBackground so the contained
        // glass panel drawn above is the sole surface — otherwise the opaque ChildBg (non-acrylic
        // mode) would paint over the panel fill + border.
        ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
        ImGui::BeginChild("##pfRows", ImVec2(availWidth, panelH), false, ImGuiWindowFlags_NoBackground);
        ImGui::PopStyleVar();
        // Zero item-spacing INSIDE the child: each row emits an InvisibleButton + a gap Dummy, and
        // ImGui's default ItemSpacing.y between those items would inflate the content past rowsH and
        // clip the last row (and widen the gaps). The inter-row gap is controlled by the Dummy below.
        ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
        ImDrawList* rdl = ImGui::GetWindowDrawList();
        float rowW = ImGui::GetContentRegionAvail().x;   // narrows automatically if a scrollbar shows
        if (vis.empty()) {
            material::DrawEmptyState(ICON_MD_PIE_CHART, TR("portfolio_no_entries"));
        } else {
            // TABLE column-header strip (style 0): a thin static header labelling the numeric columns —
            // a signature the card styles don't have. Uses the shared pfTableCols so it aligns with rows.
            if (style == 0) {
                const float headerH = capFont->LegacySize + 8.0f * mktDp;
                const ImVec2 hp = ImGui::GetCursorScreenPos();
                const float hLeft  = hp.x + Layout::spacingMd();
                const float hRight = hp.x + rowW - Layout::spacingMd();
                const PfTableCols hc = pfTableCols(hRight, mktDp);
                ImFont* ovF = Type().overline();
                const float ty = hp.y + (headerH - ovF->LegacySize) * 0.5f;
                const ImU32 hcol = OnSurfaceDisabled();
                auto hdr = [&](float xr, const char* s){
                    const float w = ovF->CalcTextSizeA(ovF->LegacySize, FLT_MAX, 0, s).x;
                    rdl->AddText(ovF, ovF->LegacySize, ImVec2(xr - w, ty), hcol, s);
                };
                rdl->AddText(ovF, ovF->LegacySize, ImVec2(hLeft, ty), hcol, TR("market_col_name"));
                if (anySpark)   // TREND column header, left-aligned at the same x the row sparklines start
                    rdl->AddText(ovF, ovF->LegacySize, ImVec2(hLeft + (hc.labelR - hLeft) * kPfTableTrendFrac, ty), hcol, TR("market_col_trend"));
                hdr(hc.drgxR, DRAGONX_TICKER);
                hdr(hc.valR,  TR("market_col_value"));
                hdr(hc.chgR,  TR("market_24h"));
                rdl->AddLine(ImVec2(hLeft, hp.y + headerH - 1.0f), ImVec2(hRight, hp.y + headerH - 1.0f),
                             WithAlpha(OnSurface(), 20), 1.0f);
                ImGui::Dummy(ImVec2(rowW, headerH));
            }
            for (int vi = 0; vi < (int)vis.size(); vi++) {
                int i = vis[vi];
                ImVec2 rMin = ImGui::GetCursorScreenPos();
                ImVec2 rMax(rMin.x + rowW, rMin.y + rowH);
                ImGui::PushID(4000 + i);
                bool clicked = ImGui::InvisibleButton("##pfRowBtn", ImVec2(rowW, rowH));
                bool hov = ImGui::IsItemHovered();
                if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                ImGui::PopID();
                pfDrawRow(rdl, rMin, rMax, allEntries[i], state, market, style, mktDp, sub1, capFont, hov, anySpark);
                if (clicked) { PortfolioBeginEdit(app, i); s_pfEdit.open = true; }
                if (vi + 1 < (int)vis.size()) ImGui::Dummy(ImVec2(rowW, rowGap));
            }
        }
        ImGui::PopStyleVar();   // ItemSpacing
        ImGui::EndChild();
    }

    ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + portfolioH));
    ImGui::Dummy(ImVec2(availWidth, 0));
    ImGui::Dummy(ImVec2(0, gap));
}

void RenderMarketTab(App* app)
{
    auto& S = schema::UI();
    auto summaryPanel   = S.table("tabs.market", "summary-panel");
    auto btcPriceLbl    = S.label("tabs.market", "btc-price-label");
    auto change24hLbl   = S.label("tabs.market", "change-24h-label");
    auto volumeLbl      = S.label("tabs.market", "volume-label");
    auto volumeValLbl   = S.label("tabs.market", "volume-value-label");
    auto mktCapLbl      = S.label("tabs.market", "market-cap-label");
    auto mktCapValLbl   = S.label("tabs.market", "market-cap-value-label");
    auto chartElem      = S.drawElement("tabs.market", "chart");
    auto portfolioValLbl = S.label("tabs.market", "portfolio-value-label");
    auto portfolioBtcLbl = S.label("tabs.market", "portfolio-btc-label");
    const auto& state = app->getWalletState();
    const auto& market = state.market;

    // Kick a once-per-session live exchange-list fetch (self-guarded), then source the
    // registry from it (falling back to the compiled-in list until it arrives).
    app->refreshExchanges();
    // Also kick the historical price-chart fetch (self-throttled) so the chart's hour/day/week/
    // month intervals populate promptly when the user opens the Market tab.
    app->refreshMarketChart();
    // ...and the SELECTED pair's per-exchange OHLC candles (self-throttled, in-flight-guarded). Without
    // this, the candle series only loaded on a pair-chip / refresh click, so the candlestick toggle was
    // missing (and "switch to candlesticks" silently did nothing) when the tab was just opened.
    app->refreshExchangeChart();
    const auto& registry = EffectiveRegistry(market);

    // Load persisted exchange/pair on first frame
    LoadMarketState(app->settings(), registry);

    if (s_mkt.exchangeIdx >= (int)registry.size()) s_mkt.exchangeIdx = 0;
    const auto& currentExchange = registry[s_mkt.exchangeIdx];
    if (s_mkt.pairIdx >= (int)currentExchange.pairs.size()) s_mkt.pairIdx = 0;

    // Left/Right arrows: cycle portfolio row styles (compact / detailed / featured), mirroring the
    // Overview tab's layout switch. Skip while typing, when Ctrl is held (theme cycle), or while the
    // portfolio editor / market-settings modal is open.
    if (app->settings() && !s_pfEdit.open &&
        !ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
        bool prev = ImGui::IsKeyPressed(ImGuiKey_LeftArrow);
        bool next = ImGui::IsKeyPressed(ImGuiKey_RightArrow);
        if (prev || next) {
            int st = app->settings()->getPortfolioStyle();
            st = next ? (st + 1) % 3 : (st + 2) % 3;
            app->settings()->setPortfolioStyle(st);
            app->settings()->save();   // persist the choice (matches the gear-modal control)
            const char* names[3] = { TR("portfolio_style_compact"), TR("portfolio_style_detailed"),
                                     TR("portfolio_style_featured") };
            Notifications::instance().info(std::string(TR("portfolio_style_label")) + ": " + names[st]);
        }
    }

    // (Panel theme-effects are suppressed frame-wide in App::render() while the modal is open, so
    // their foreground borders don't bleed over the overlay — see PortfolioEditorActive().)
    ImVec2 marketAvail = ImGui::GetContentRegionAvail();
    // Scrollable (the portfolio grid can extend past the window) but with no visible scrollbar —
    // scrolling still works via the mouse wheel.
    ImGui::BeginChild("##MarketScroll", marketAvail, false,
        ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);

    // Responsive: scale factors per frame (the section helpers recompute their own draw list /
    // fonts / paddings; only the values the precompute below needs are kept here).
    float availWidth = ImGui::GetContentRegionAvail().x;
    float vs = Layout::vScale(marketAvail.y);
    ImFont* capFont = Type().caption();
    ImFont* sub1    = Type().subtitle1();
    ImFont* body2   = Type().body2();

    // ================================================================
    // Section geometry.
    // ================================================================
    float mktDp = Layout::dpiScale();

    // -- Compact price chart: a modest responsive height, no longer stretched to fill the tab. --
    // ~50% taller than before (floor / desired / viewport-cap all scaled) — a roomier price chart.
    float chartH = std::max(165.0f * vs, std::min(chartElem.height * 1.5f * vs, marketAvail.y * 0.33f));

    // -- Hero header: size to the ACTUAL content (price row + separator gap + stats row) so the
    //    chart starts right after "24H VOLUME"/"Market Cap" instead of below a reserved empty band. --
    ImFont* mktH3 = Type().h3();
    // Header holds only the price row; 24H volume + market cap and the divider moved onto the
    // chart's top strip (with the interval buttons) so they no longer create an empty band.
    float heroHeaderH = Layout::spacingLg()                        // top pad
                      + mktH3->LegacySize                          // price row
                      + Layout::spacingXs();                       // small gap before the chart strip

    // -- Portfolio geometry. No parent card background: the summary floats under the header
    //    and the custom-group cards float below it (each with its own glass background). --
    const auto& pfEntriesGeo = app->settings()->getPortfolioEntries();
    float ratioBarH = std::max(S.drawElement("tabs.market", "ratio-bar-min-height").size,
                               S.drawElement("tabs.market", "ratio-bar-height").size * vs);
    float pfSummaryH = sub1->LegacySize  + Layout::spacingSm()                 // fiat hero row
                     + body2->LegacySize + Layout::spacingSm()                 // DRGX balance row
                     + ratioBarH + Layout::spacingXs() + capFont->LegacySize   // ratio bar + % label
                     + Layout::spacingMd();                                    // gap before the group cards
    // Content height = full-width rows in the selected style, confined to a bounded area (up to
    // pfMaxVisibleRows) with an internal scroll when the visible groups overflow. Only groups
    // visible in the active wallet (own scope or legacy/global) count toward the height.
    const std::string pfActiveHash = app->activeWalletIdentityHash();
    int pfVisN = 0;
    for (const auto& e : pfEntriesGeo)
        if (e.scope.empty() || (!pfActiveHash.empty() && e.scope == pfActiveHash)) pfVisN++;
    int   pfStyle    = app->settings()->getPortfolioStyle();
    float pfRowH     = pfRowHeight(pfStyle, mktDp);
    float pfRowGap   = pfRowGapFor(pfStyle, mktDp);   // Table rows abut
    float pfHeaderH  = (pfStyle == 0) ? (capFont->LegacySize + 8.0f * mktDp) : 0.0f;   // Table column strip
    const int pfMaxVisibleRows = 4;                        // rows shown before the list scrolls
    // Height for up to pfMaxVisibleRows rows. ItemSpacing is zeroed inside the row child, so the
    // content is exactly rows + inter-row gaps; one extra gap of bottom breathing room keeps the
    // last visible row off the clip edge. More groups than this scroll internally.
    int   pfVisRows  = std::min(pfVisN, pfMaxVisibleRows);
    float pfGroupsH  = (pfVisRows > 0)
                     ? (pfHeaderH + pfVisRows * pfRowH + (pfVisRows - 1) * pfRowGap + pfRowGap)
                     : 0.0f;
    float portfolioH = pfSummaryH + pfGroupsH;

    // Chart series for the selected interval (timestamped), shared by the hero change badge and
    // the chart block below. Computed once here so the badge can report the displayed period's change.
    std::time_t nowSec = std::time(nullptr);
    std::vector<std::time_t> chartTimes;
    s_mkt.history.clear();
    for (const auto& pr : data::chartSeries(market, s_mkt.chartInterval, nowSec)) {
        s_mkt.history.push_back(pr.second);
        chartTimes.push_back(pr.first);
    }
    // OHLC candles for the selected range when a per-exchange series is active (empty -> line chart).
    std::vector<data::Candle> ohlcCandles = data::chartCandles(market, s_mkt.chartInterval, nowSec);
    // Change over the displayed period (Live falls back to the market's 24h figure).
    double periodChangePct = market.change_24h;
    if (s_mkt.chartInterval != 0 && s_mkt.history.size() >= 2 && s_mkt.history.front() > 0.0)
        periodChangePct = (s_mkt.history.back() - s_mkt.history.front()) / s_mkt.history.front() * 100.0;
    bool chartUp = periodChangePct >= 0.0;
    // Suffix = the SELECTED range (not the raw data span), so 1D reads "24h", 1W reads "7d", etc.
    std::string periodSuffix = "24h";
    switch (s_mkt.chartInterval) {
        case 1: periodSuffix = "1h";  break;   // 1H
        case 2: periodSuffix = "24h"; break;   // 1D
        case 3: periodSuffix = "7d";  break;   // 1W
        case 4: periodSuffix = "30d"; break;   // 1M
        default: periodSuffix = "24h"; break;  // Live (uses the market's 24h change)
    }

    // ================================================================
    // PRICE SUMMARY — Combined hero card with price, stats, and exchange
    // ================================================================
    MktCtx mktc{
        app, &currentExchange,
        chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH,
        &chartTimes, &ohlcCandles, nowSec, chartUp, periodChangePct, periodSuffix
    };
    mktDrawPriceHero(mktc);

    // ================================================================
    // PRICE CHART — drawn inside the combined hero card's glass panel (above)
    // ================================================================
    mktDrawPriceChart(mktc);

    // ================================================================
    // PAIR SELECTOR — every trading pair across all exchanges shown as
    // round buttons (flat; no exchange dropdown). Wraps to multiple rows.
    // ================================================================
    mktDrawPairSelector(app, registry, availWidth);

    // ================================================================
    // PORTFOLIO — floating summary (no card background) + custom-group cards
    // ================================================================
    mktDrawPortfolio(mktc);

    ImGui::EndChild(); // ##MarketScroll

    // Portfolio editor modal (rendered outside the scroll child so it overlays the page). It captures
    // + blurs the live content drawn above, so it must render after the tab body.
    RenderPortfolioEditor(app);
}

} // namespace ui
} // namespace dragonx
