// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Wallet-files list + switcher (multi-wallet P2). Lists wallet files in the datadir (and any
// user-added folders) with cached metadata (size from disk; balance / address count / last-opened
// from the wallet index, since those can't be read off a wallet.dat without loading it). "Open"
// switches the active wallet via App::switchToWallet (daemon restart on -wallet=<name>).

#pragma once

#include <algorithm>
#include <atomic>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_set>
#include <vector>

#include "imgui.h"

#include "../../app.h"
#include "../../config/settings.h"
#include "../../config/version.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/text_format.h"
#include "../../util/wallet_file_probe.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../notifications.h"
#include "../layout.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "folder_picker.h"

namespace dragonx {
namespace ui {

class WalletsDialog {
public:
    static void show(App* app) {
        s_open = true;
        s_app = app;
        s_needScan = true;
    }

    static void hide() { s_open = false; }

    static void render() {
        if (!s_open || !s_app) return;
        // The folder picker (opened by "Scan another folder") takes over the modal surface while
        // it's up — the overlay framework doesn't nest, so only one overlay renders at a time.
        if (FolderPicker::isOpen()) { FolderPicker::render(); return; }
        using namespace material;
        App* app = s_app;
        const float dp = Layout::dpiScale();
        ImGuiStyle& style = ImGui::GetStyle();

        if (s_needScan) { scan(); s_needScan = false; }

        // ---- Content-sized card: a stack of wallet cards, sized to the wallet count (up to
        //      kMaxVisibleRows, then the list scrolls). Computed from the KNOWN row count so the
        //      dialog stays fixed-height (no auto-height feedback that slid content off on a move). --
        const int   kMaxVisibleRows = 7;
        ImFont* nameFont = Type().subtitle1();
        ImFont* metaFont = Type().caption();
        // Size the list to the ACTUAL wallet count so a few wallets sit tight against the controls below
        // instead of stranding ~200px of empty rows before the create/scan prompts; cap at kMaxVisibleRows
        // (more than that scrolls within the cap). An empty list keeps two rows of height so the centered
        // empty-state hint (drawn inside the list child) still has room to show.
        const int   numWallets = (int)s_rows.size();
        const int   visRows  = numWallets <= 0 ? 2 : std::min(numWallets, kMaxVisibleRows);
        const float cardPadY = Layout::spacingMd();   // roomier cards (was spacingSm)
        const float walRowH  = cardPadY * 2.0f + nameFont->LegacySize + Layout::spacingSm() + metaFont->LegacySize;
        const float cardGap  = Layout::spacingMd();   // more breathing room between wallet cards
        // ItemSpacing is zeroed inside the list child, so the content is exactly cards + inter-card
        // gaps; a small bottom pad keeps the last card off the clip edge (no spurious scrollbar).
        const float listH    = visRows * walRowH + (float)std::max(0, visRows - 1) * cardGap + Layout::spacingSm();

        const float ctrlRow = ImGui::GetFrameHeightWithSpacing();
        const float capRow  = Type().caption()->LegacySize + style.ItemSpacing.y;
        const int   numScanFolders = app ? (int)app->walletIndex().extraFolders().size() : 0;
        const float foldersH = numScanFolders > 0
                             ? (capRow + (float)numScanFolders * ctrlRow + Layout::spacingXs())   // "Scanned folders:" + one row each
                             : 0.0f;
        const float belowH  = 4.0f * Layout::spacingSm()
                            + (capRow + ctrlRow)                                 // Create label + input row
                            + ctrlRow                                            // full-width "scan folder" button
                            + foldersH                                           // scanned-folders manager
                            + (style.ItemSpacing.y + 1.0f)                       // separator
                            + ctrlRow                                            // footer buttons
                            + 6.0f * style.ItemSpacing.y;                        // uncounted inter-item gaps
        const float headH   = Type().h6()->LegacySize + Layout::spacingXs()      // framework h6 title
                            + Type().caption()->LegacySize + Layout::spacingSm()   // intro + gap
                            + ctrlRow + Layout::spacingSm();                       // sort control row + gap
        const float padV    = 48.0f * dp;                                        // content-child padding (top+bottom) + margin
        // Size to content, but cap at a viewport fraction: with many wallets on a small / HiDPI
        // screen the content-sized card could exceed the window (the framework would clip a too-tall
        // card, footer and all). When capped, the wallet list flexes + scrolls and the controls
        // below it stay pinned. In the common case (few wallets) nothing changes.
        const float desiredH = headH + listH + belowH + padV;                    // physical
        const float vpH      = ImGui::GetMainViewport()->Size.y;
        const bool  capped   = desiredH > vpH * 0.86f;
        const float cardH    = std::min(desiredH, vpH * 0.86f) / dp;

        OverlayDialogSpec ov;
        ov.title      = TR("wallets_title");
        ov.p_open     = &s_open;
        ov.style      = OverlayStyle::BlurFloat;
        ov.cardWidth  = 860.0f;   // roomier — makes space for the per-row "open folder" button
        ov.cardHeight = cardH;
        ov.idSuffix   = "wallets";
        if (BeginOverlayDialog(ov)) {
            Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_intro"));
            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

            const std::string active = app->settings() ? app->settings()->getActiveWalletFile() : "wallet.dat";
            // When an external wallet is open in place, the active file is its per-target link name; that row
            // is the external wallet whose path hashes to it. Precompute per row so the sort stays cheap.
            const bool linkMode = isLinkName(active);
            auto rowIsActive = [&](const WalletRow& row) {
                return linkMode ? (!row.inDatadir && linkNameFor(row.canonPath) == active)
                                : (row.inDatadir && row.fileName == active);
            };
            std::vector<char> rowActive(s_rows.size(), 0);
            for (std::size_t j = 0; j < s_rows.size(); ++j) rowActive[j] = rowIsActive(s_rows[j]) ? 1 : 0;

            // ---- Frame-consistent snapshot of the async probe results (badges / counts / sort all read
            //      the same view; one lock instead of one-per-row). ----------------------------------
            std::vector<ProbeResult> probeSnap;
            if (s_probe) { std::lock_guard<std::mutex> lk(s_probe->mtx); probeSnap = s_probe->results; }
            auto probeAt = [&](std::size_t idx) -> ProbeResult {
                return idx < probeSnap.size() ? probeSnap[idx] : ProbeResult{};
            };

            // ---- Sort controls: a segmented key picker + an arrow that flips ascending/descending ----
            {
                ImGui::AlignTextToFramePadding();
                Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_sort_by"));
                ImGui::SameLine();
                ImFont* segFont = Type().caption();
                const char* segLabels[] = { TR("wallets_sort_created"), TR("wallets_sort_addresses"),
                                            TR("wallets_sort_txs"), TR("wallets_sort_size") };
                float maxLW = 0.0f;
                for (const char* l : segLabels)
                    maxLW = std::max(maxLW, segFont->CalcTextSizeA(segFont->LegacySize, FLT_MAX, 0, l).x);
                const float segH  = ImGui::GetFrameHeight();
                const float segTW = (maxLW + 20.0f * dp) * 4.0f;
                const ImVec2 segOrigin = ImGui::GetCursorScreenPos();
                const int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segTW, segH,
                                                           segLabels, 4, s_sortMode, segFont, "##walletSortSeg", dp);
                if (clk >= 0) s_sortMode = clk;
                // SegmentedControl is draw-list based and doesn't move the layout cursor, so place the
                // direction toggle explicitly just to its right on the same baseline (a real item advances
                // the cursor past this row for the list below). A circular IconButton (square size →
                // bgRounding = radius) with the arrow glyph centered.
                ImGui::SetCursorScreenPos(ImVec2(segOrigin.x + segTW + Layout::spacingSm(), segOrigin.y));
                IconButtonStyle dirStyle;
                dirStyle.restBg  = WithAlpha(OnSurface(), 24);
                dirStyle.hoverBg = WithAlpha(OnSurface(), 44);
                dirStyle.color   = OnSurfaceMedium();
                dirStyle.tooltip = TR(s_sortDesc ? "wallets_sort_desc" : "wallets_sort_asc");
                if (IconButton("##walletSortDir", s_sortDesc ? ICON_MD_ARROW_DOWNWARD : ICON_MD_ARROW_UPWARD,
                               Type().iconSmall(), ImVec2(segH, segH), dirStyle))
                    s_sortDesc = !s_sortDesc;
            }
            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

            // ---- Compute the display order for the active sort key (stable, so ties keep scan order).
            s_order.resize(s_rows.size());
            for (std::size_t k = 0; k < s_rows.size(); ++k) s_order[k] = k;
            {
                std::vector<long long> sortVal(s_rows.size(), 0);
                for (std::size_t k = 0; k < s_rows.size(); ++k) {
                    const WalletRow& r = s_rows[k];
                    const ProbeResult pr = probeAt(k);
                    switch (s_sortMode) {
                        case 1: { const auto* m = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
                                  sortVal[k] = (m && m->cachedAddressCount >= 0) ? (long long)m->cachedAddressCount
                                                                                 : pr.keyCount; break; }
                        case 2: sortVal[k] = pr.txCount; break;
                        case 3: sortVal[k] = r.sizeBytes; break;
                        default: sortVal[k] = pr.createdEpoch; break;   // 0 = date created
                    }
                }
                std::stable_sort(s_order.begin(), s_order.end(), [&](std::size_t a, std::size_t b) {
                    const bool aa = rowActive[a] != 0, ba = rowActive[b] != 0;
                    if (aa != ba) return aa;   // the active wallet is always pinned to the top
                    return s_sortDesc ? sortVal[a] > sortVal[b] : sortVal[a] < sortVal[b];
                });
            }

            // ---- Wallet cards (Material-style rows; no table) -------------------------------------
            const float listW = ImGui::GetContentRegionAvail().x;
            // Flex only when the card was viewport-capped: give the list the space left above the
            // create/scan/footer controls so it scrolls internally and those stay visible. Uncapped,
            // it equals the content height exactly (no spurious scrollbar — the common case).
            float listHFit = listH;
            if (capped) {
                // Fill the height left above the pinned controls, but clip on a CLEAN row boundary:
                // floor the available space to a whole number of card strides (walRowH + cardGap, the
                // per-card advance from the loop) so the list never ends mid-row. Keep the same bottom
                // pad as the uncapped listH so the last visible card stays off the clip edge.
                const float stride = walRowH + cardGap;
                const float avail  = ImGui::GetContentRegionAvail().y - belowH;
                // N rows occupy N*walRowH + (N-1)*cardGap = N*stride - cardGap; solve for the most whole
                // rows that fit (a non-negative cast truncates toward zero = floor here), clamped to at
                // least one so a tiny viewport still shows a row.
                int nRows = stride > 0.0f ? (int)((avail + cardGap) / stride) : 1;
                if (nRows < 1) nRows = 1;
                listHFit = (float)nRows * stride - cardGap + Layout::spacingSm();
            }
            ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
            // NoScrollWithMouse + ApplySmoothScroll gives the wheel the same eased scrolling as the
            // Settings page (ApplySmoothScroll handles the wheel itself, so let it own that input).
            ImGui::BeginChild("##walletList", ImVec2(listW, listHFit), false, ImGuiWindowFlags_NoScrollWithMouse);
            ImGui::PopStyleVar();
            ApplySmoothScroll();
            ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));   // inter-card gaps are explicit
            ImDrawList* dl = ImGui::GetWindowDrawList();
            const float rowW  = ImGui::GetContentRegionAvail().x;
            ImFont* icoFont   = Type().iconSmall();
            const char* dot   = "  \xC2\xB7  ";
            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){
                    // Pop a whole UTF-8 code point (never split a multibyte char — external .dat names
                    // and some locale metadata are non-ASCII).
                    while (s.size()>1 && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
                    if (s.size()>1) s.pop_back();
                    t=true;
                }
                if (t) s += "\xE2\x80\xA6";
                return s;
            };
            // The containing sub-directory as its last 1-2 components (e.g. "…/Backups/2021"), so several
            // same-named wallet.dat files surfaced by the recursive scan are distinguishable at a glance.
            auto shortSubdir = [](std::string d) {
                while (d.size() > 1 && (d.back() == '/' || d.back() == '\\')) d.pop_back();
                const auto p2 = d.find_last_of("/\\");
                if (p2 == std::string::npos) return d;                       // no separator: whole thing
                std::string leaf = d.substr(p2 + 1);
                const auto p1 = (p2 > 0) ? d.find_last_of("/\\", p2 - 1) : std::string::npos;
                std::string parent = (p1 == std::string::npos) ? d.substr(0, p2) : d.substr(p1 + 1, p2 - p1 - 1);
                std::string out = parent.empty() ? leaf : (parent + "/" + leaf);
                return (p1 != std::string::npos && p1 > 0) ? ("\xE2\x80\xA6/" + out) : out;   // "…/" if deeper
            };

            for (std::size_t k = 0; k < s_order.size(); ++k) {
                const std::size_t i = s_order[k];   // sorted display order → actual s_rows index
                const WalletRow& r = s_rows[i];
                // The wallet index is keyed by bare filename and is only ever written for the active
                // DATADIR wallet — so an external row (esp. a same-named one surfaced by the recursive
                // subdir scan) must NOT borrow a datadir wallet's cached balance/addresses. Only look up
                // metadata for datadir rows; external rows show size + "never opened" (accurate).
                const data::WalletIndexEntry* meta = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
                const bool isCurrent = rowActive[i] != 0;

                ImVec2 rMin = ImGui::GetCursorScreenPos();
                ImVec2 rMax(rMin.x + rowW, rMin.y + walRowH);
                bool hov = ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(rMin, rMax);

                // Card background + state
                GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = hov ? 40 : 26; g.borderAlpha = 45;
                DrawGlassPanel(dl, rMin, rMax, g);
                if (isCurrent) {
                    dl->AddRectFilled(rMin, rMax, WithAlpha(Success(), 16), 10.0f * dp);
                    dl->AddRect(rMin, rMax, WithAlpha(Success(), 130), 10.0f * dp, 0, 1.6f * dp);
                } else if (hov) {
                    dl->AddRect(rMin, rMax, WithAlpha(OnSurface(), 70), 10.0f * dp, 0, 1.0f);
                }

                const float padX  = Layout::spacingMd();
                const float iconSz = nameFont->LegacySize * 1.15f;
                const float midY  = (rMin.y + rMax.y) * 0.5f;
                float x = rMin.x + padX;
                dl->AddText(icoFont, iconSz, ImVec2(x, midY - iconSz * 0.5f),
                            isCurrent ? Success() : OnSurfaceMedium(), ICON_MD_ACCOUNT_BALANCE_WALLET);
                x += iconSz + Layout::spacingMd();

                // Action-button + folder-button slots on the right — reserve room so text truncates first.
                const float btnW = 96.0f * dp, btnH = ImGui::GetFrameHeight();
                const float btnX = rMax.x - padX - btnW;                       // Open button / Active chip zone
                const float folderW = btnH;                                    // circular "open folder" button
                const float folderX = btnX - folderW - Layout::spacingSm();
                const float textR = folderX - Layout::spacingMd();

                // Name (+ info icon for external files). Status badges render as a right-aligned vertical
                // stack of "label icon" rows (e.g. "Seed phrase 🌱" over "Encrypted 🔒") to the left of the
                // action button — reserve their width first so name/metadata truncate before them.
                const float topY = rMin.y + cardPadY;
                const float badgeSz = metaFont->LegacySize;
                // Positive detections are definitive; a MISSING marker is trustworthy only when the whole
                // file was scanned (probeComplete): seed iff hdSeed; "legacy" only when complete & no seed;
                // "encrypted" iff mkey found; and an "unknown" row when a truncated scan couldn't confirm
                // encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
                const ProbeResult pres = probeAt(i);   // from the frame-consistent snapshot above
                const bool bLock    = pres.probed && pres.encrypted;
                // Seed-phrase vs legacy. The offline probe reads the hdchain record's fMnemonicSeed flag
                // straight off disk (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy with no phrase,
                // 0 = couldn't tell). That is the SAME flag the daemon's IsMnemonicSeed()/z_exportmnemonic
                // consult, so a definitive read is authoritative and takes precedence. The runtime badge
                // (activeWalletSeedBadge, reset only on disconnect) is used ONLY when the offline probe
                // couldn't decide — it must NEVER override a definitive on-disk read, or a stale
                // HasMnemonic carried from a previously-active mnemonic wallet mislabels a legacy wallet as
                // a seed-phrase wallet. seed uses the same 1/2/0 encoding.
                int seed = 0;
                if (pres.probed && pres.mnemonic != 0)
                    seed = pres.mnemonic;                                          // definitive on-disk flag wins
                else if (rowActive[i] && app->activeWalletSeedBadge() != 0)
                    seed = app->activeWalletSeedBadge();                           // runtime fallback (active row only)
                else if (pres.probed && pres.complete && !pres.hdSeed)
                    seed = 2;                                                      // no HD records at all → no phrase
                const bool bSeed    = (seed == 1);
                const bool bLegacy  = (seed == 2);
                // seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD
                // wallet (we just couldn't read the phrase flag — e.g. the tier-1 byte-scan fallback), so
                // label it "HD wallet" rather than a bare "?"; only a scan that couldn't even establish
                // that (incomplete, no HD marker yet) stays "Unknown". Both yield to the Lock badge when
                // encrypted, so an encrypted-but-unclassified row shows just the lock.
                const bool bHd      = (seed == 0) && pres.probed && pres.hdSeed  && !pres.encrypted;
                const bool bUnknown = (seed == 0) && pres.probed && !pres.hdSeed && !pres.encrypted;
                struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
                Badge bl[4]; int nb = 0;   // seed/legacy/hd/unknown are mutually exclusive → at most one + lock
                if (bSeed)    bl[nb++] = { ICON_MD_ECO,          WithAlpha(Success(), 235),         TR("wallets_badge_seed_short"),      TR("wallets_badge_seed") };
                if (bLock)    bl[nb++] = { ICON_MD_LOCK,         WithAlpha(Warning(), 240),         TR("wallets_badge_encrypted_short"), TR("wallets_badge_encrypted") };
                if (bLegacy)  bl[nb++] = { ICON_MD_HISTORY,      WithAlpha(OnSurfaceMedium(), 220), TR("wallets_badge_legacy_short"),    TR("wallets_badge_legacy") };
                if (bHd)      bl[nb++] = { ICON_MD_ACCOUNT_TREE, WithAlpha(OnSurfaceMedium(), 210), TR("wallets_badge_hd_short"),        TR("wallets_badge_hd") };
                if (bUnknown) bl[nb++] = { ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 185), TR("wallets_badge_unknown_short"),   TR("wallets_badge_unknown") };
                const float bGap = Layout::spacingSm();
                float maxLabelW = 0.0f;
                for (int k = 0; k < nb; ++k) maxLabelW = std::max(maxLabelW, tw(metaFont, bl[k].label));
                const float stackW    = nb > 0 ? maxLabelW + bGap + badgeSz : 0.0f;
                const float stackLeft = nb > 0 ? textR - stackW - Layout::spacingMd() : textR;  // text stops here
                const float infoReserve = (!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f;

                std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, stackLeft - x - infoReserve));
                dl->AddText(nameFont, nameFont->LegacySize, ImVec2(x, topY), OnSurface(), nm.c_str());
                if (!r.inDatadir) {
                    ImVec2 ip(x + tw(nameFont, nm) + Layout::spacingSm(), topY + (nameFont->LegacySize - metaFont->LegacySize));
                    dl->AddText(icoFont, metaFont->LegacySize, ip, OnSurfaceMedium(), ICON_MD_INFO);
                    if (ImGui::IsMouseHoveringRect(ip, ImVec2(ip.x + metaFont->LegacySize, ip.y + metaFont->LegacySize)))
                        Tooltip("%s\n%s", TR("wallets_external_tt"), r.dir.c_str());
                }
                if (nb > 0) {
                    const float lineH  = metaFont->LegacySize;
                    const float vGap   = Layout::spacingXs();
                    const float totalH = nb * lineH + (nb - 1) * vGap;
                    const float sy     = midY - totalH * 0.5f;
                    for (int k = 0; k < nb; ++k) {
                        const float ey = sy + k * (lineH + vGap);
                        const float lw = tw(metaFont, bl[k].label);
                        dl->AddText(metaFont, lineH,   ImVec2(textR - badgeSz - bGap - lw, ey), bl[k].col, bl[k].label);
                        dl->AddText(icoFont,  badgeSz, ImVec2(textR - badgeSz,             ey), bl[k].col, bl[k].glyph);
                        if (ImGui::IsMouseHoveringRect(ImVec2(textR - badgeSz - bGap - lw, ey), ImVec2(textR, ey + lineH)))
                            Tooltip("%s", bl[k].tip);
                    }
                }

                // Metadata line (size · N addresses/keys · M txs · balance DRGX · last opened). The cached
                // wallet index gives the authoritative user-facing ADDRESS count; the btree walk gives an
                // exact tx count and a spendable-KEY count (labeled "keys", not "addresses", since it counts
                // change keys the daemon's address list omits — a different figure for the same wallet).
                std::string ms;
                if (!r.inDatadir) ms = shortSubdir(r.dir) + dot;   // which sub-directory this wallet is in
                ms += util::Platform::formatFileSize((uint64_t)r.sizeBytes);
                char b[80];
                if (meta && meta->cachedAddressCount >= 0) { snprintf(b, sizeof(b), "%s%lld %s", dot, (long long)meta->cachedAddressCount, TR("wallets_col_addresses")); ms += b; }
                else if (pres.hasCounts && pres.keyCount > 0) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.keyCount, TR("wallets_col_keys")); ms += b; }
                if (pres.hasCounts) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.txCount, TR("wallets_col_txs")); ms += b; }
                if (pres.hasCounts && pres.createdEpoch > 0) {   // wallet birthday, e.g. "created Aug 2025"
                    std::time_t ct = static_cast<std::time_t>(pres.createdEpoch);
                    char cd[24]; std::strftime(cd, sizeof(cd), "%b %Y", std::localtime(&ct));
                    snprintf(b, sizeof(b), "%s%s %s", dot, TR("wallets_created"), cd); ms += b;
                }
                if (meta && meta->cachedBalance >= 0.0)    { snprintf(b, sizeof(b), "%s%.4f %s", dot, meta->cachedBalance, DRAGONX_TICKER); ms += b; }
                ms += dot;
                ms += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never"));
                ms = fit(ms, metaFont, std::max(24.0f * dp, stackLeft - x));
                dl->AddText(metaFont, metaFont->LegacySize, ImVec2(x, topY + nameFont->LegacySize + Layout::spacingXs()),
                            OnSurfaceMedium(), ms.c_str());

                // Action: circular "open folder" button, then the Active chip (current) or an Open button.
                // External wallets Open IN PLACE (link, no copy); datadir wallets switch directly.
                ImGui::PushID(static_cast<int>(i));
                {
                    ImGui::SetCursorScreenPos(ImVec2(folderX, midY - folderW * 0.5f));
                    IconButtonStyle fs;
                    fs.restBg  = WithAlpha(OnSurface(), 20);
                    fs.hoverBg = WithAlpha(OnSurface(), 44);
                    fs.color   = OnSurfaceMedium();
                    fs.tooltip = TR("wallets_open_folder");
                    if (IconButton("##walletFolder", ICON_MD_FOLDER_OPEN, Type().iconSmall(), ImVec2(folderW, folderW), fs))
                        util::Platform::openFolder(r.dir);
                }
                if (isCurrent) {
                    const char* al = TR("wallets_active");
                    float aw = tw(metaFont, al), cw = aw + 16.0f * dp, ch = metaFont->LegacySize + 6.0f * dp;
                    ImVec2 cp(rMax.x - padX - cw, midY - ch * 0.5f);
                    dl->AddRectFilled(cp, ImVec2(cp.x + cw, cp.y + ch), WithAlpha(Success(), 34), 5.0f * dp);
                    dl->AddText(metaFont, metaFont->LegacySize, ImVec2(cp.x + 8.0f * dp, cp.y + 3.0f * dp), Success(), al);
                } else {
                    ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
                    if (TactileButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
                        if (r.inDatadir) { app->switchToWallet(r.fileName); s_open = false; }
                        else             { openInPlace(app, r); }
                    }
                    if (!r.inDatadir && ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_open_inplace_tt"));
                }
                ImGui::PopID();

                // Register the card's footprint with a Dummy (not an interactive item, so nothing
                // overlaps the action button's ID) and add an explicit gap between cards (ItemSpacing
                // is 0, so no double spacing; the last card has no trailing gap).
                ImGui::SetCursorScreenPos(rMin);
                ImGui::Dummy(ImVec2(rowW, walRowH));
                if (i + 1 < s_rows.size()) ImGui::Dummy(ImVec2(rowW, cardGap));
            }
            // Empty-state nudge: the list is now sized to the actual wallet count, so a populated list has
            // no room to spare and this draws nothing (the cards butt up against the controls below). It
            // fires only when the list is EMPTY (the reserved rows leave room): a subtle centered hint (a
            // folder glyph + one line) instead of a blank box; purely decorative — the actions live below.
            {
                const float remainY = ImGui::GetContentRegionAvail().y;
                if (remainY > walRowH * 1.6f) {
                    ImDrawList* wdl  = ImGui::GetWindowDrawList();
                    ImFont* hIco     = Type().iconLarge();
                    ImFont* hTxt     = Type().caption();
                    const char* icon = ICON_MD_CREATE_NEW_FOLDER;
                    const char* hint = TR("wallets_empty_hint");
                    const ImU32 col  = OnSurfaceDisabled();
                    const ImVec2 is  = hIco->CalcTextSizeA(hIco->LegacySize, FLT_MAX, 0, icon);
                    const ImVec2 ts  = hTxt->CalcTextSizeA(hTxt->LegacySize, FLT_MAX, 0, hint);
                    const float  gap = Layout::spacingXs();
                    const ImVec2 o   = ImGui::GetCursorScreenPos();
                    const float  cx  = o.x + ImGui::GetContentRegionAvail().x * 0.5f;
                    const float  top = o.y + (remainY - (is.y + gap + ts.y)) * 0.5f;
                    wdl->AddText(hIco, hIco->LegacySize, ImVec2(cx - is.x * 0.5f, top), col, icon);
                    wdl->AddText(hTxt, hTxt->LegacySize, ImVec2(cx - ts.x * 0.5f, top + is.y + gap), col, hint);
                }
            }
            ImGui::PopStyleVar();   // ItemSpacing
            ImGui::EndChild();

            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

            // ---- Create a new wallet (primary) — the input + button span the full list width -----
            const float createBtnW = 140.0f * dp;
            Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_new_label"));
            ImGui::SetNextItemWidth(listW - createBtnW - style.ItemSpacing.x);
            ImGui::InputTextWithHint("##newWalletName", TR("wallets_new_hint"), s_newName, sizeof(s_newName));
            ImGui::SameLine();
            if (TactileButton(TR("wallets_create"), ImVec2(createBtnW, 0))) {
                std::string name = normalizeWalletName(s_newName);
                std::error_code ec;
                if (name.empty() || isLinkName(name)) {   // reserve the in-place-link prefix
                    Notifications::instance().warning(TR("wallets_name_invalid"));
                } else if (std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + name, ec)) {
                    Notifications::instance().warning(TR("wallets_exists"));
                } else {
                    s_newName[0] = '\0';
                    Notifications::instance().info(TR("wallets_creating"));
                    app->switchToWallet(name);
                    s_open = false;
                }
            }

            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

            // ---- Scan another folder — always visible, full-width, opens the in-app folder picker -
            // Start in the DRAGONX data directory (where the datadir wallets live) so the user can
            // navigate up/out from a familiar anchor to find wallets in other folders.
            if (TactileButton(TR("wallets_scan_folder"), ImVec2(listW, 0))) {
                FolderPicker::open(util::Platform::getDragonXDataDir(), [app](const std::string& dir) {
                    std::error_code ec;
                    if (!dir.empty() && std::filesystem::is_directory(dir, ec)) {
                        if (app->walletIndex().addExtraFolder(dir)) app->walletIndex().save();
                        s_needScan = true;
                    } else {
                        Notifications::instance().warning(TR("wallets_folder_invalid"));
                    }
                });
            }

            // ---- Manage scanned folders: list each user-added folder with a control to stop scanning it.
            // (The datadir is always scanned and isn't listed here — only the folders the user added.) -----
            {
                const auto& scanFolders = app->walletIndex().extraFolders();
                if (!scanFolders.empty()) {
                    ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
                    Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_scanned_folders"));
                    ImFont* pf = Type().caption();
                    const float bh2 = ImGui::GetFrameHeight();
                    // Front-elide a long path so its identifying tail (the leaf folder) stays visible.
                    auto elideFront = [&](std::string s, float maxW) {
                        auto w = [&](const std::string& t){ return pf->CalcTextSizeA(pf->LegacySize, FLT_MAX, 0, t.c_str()).x; };
                        if (w(s) <= maxW) return s;
                        while (s.size() > 1 && w("\xE2\x80\xA6" + s) > maxW) {
                            s.erase(s.begin());
                            while (!s.empty() && (static_cast<unsigned char>(s.front()) & 0xC0) == 0x80) s.erase(s.begin());
                        }
                        return "\xE2\x80\xA6" + s;
                    };
                    std::string toRemove;
                    for (const auto& folder : scanFolders) {
                        ImGui::PushID(folder.c_str());
                        IconButtonStyle rm;
                        rm.color      = OnSurfaceMedium();
                        rm.hoverColor = Error();
                        rm.hoverBg    = StateHover();
                        rm.bgRounding = 4.0f * dp;
                        rm.tooltip    = TR("wallets_remove_folder");
                        if (IconButton("##rmScanFolder", ICON_MD_CLOSE, Type().iconSmall(), ImVec2(bh2, bh2), rm))
                            toRemove = folder;
                        ImGui::SameLine(0, Layout::spacingSm());
                        ImGui::AlignTextToFramePadding();
                        const float textW = listW - bh2 - 2.0f * Layout::spacingSm();
                        Type().textColored(TypeStyle::Caption, OnSurface(), elideFront(folder, textW).c_str());
                        if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", folder.c_str());
                        ImGui::PopID();
                    }
                    if (!toRemove.empty()) {
                        app->walletIndex().removeExtraFolder(toRemove);
                        app->walletIndex().save();
                        s_needScan = true;   // re-scan so wallets from the dropped folder disappear
                    }
                }
            }

            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
            // ---- Refresh: icon-only, de-emphasized next to the text actions -----------------------
            {
                float bh = ImGui::GetFrameHeight();
                IconButtonStyle rst;
                rst.color = OnSurfaceMedium();
                rst.hoverBg = StateHover();
                rst.bgRounding = 4.0f * dp;
                rst.tooltip = TR("refresh");
                if (IconButton("##walletsRefresh", ICON_MD_REFRESH, Type().iconSmall(), ImVec2(bh, bh), rst))
                    s_needScan = true;
            }
            ImGui::SameLine();
            if (TactileButton(TR("wallets_reveal"), ImVec2(150.0f * dp, 0)))
                util::Platform::openFolder(util::Platform::getDragonXDataDir());
            ImGui::SameLine();
            if (TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) s_open = false;

            EndOverlayDialog();
        }
    }

private:
    struct WalletRow {
        std::string fileName;
        std::string dir;
        std::string canonPath;   // symlink-resolved, absolute path — the STABLE identity of the physical
                                 // file (two paths reaching the same wallet share it), used for the
                                 // in-place link name + to de-dup rows. Computed once at scan time.
        bool inDatadir = false;
        long long sizeBytes = 0;
    };

    // Light facts read off each wallet.dat WITHOUT loading it (util/wallet_file_probe.h), computed on a
    // background thread so scan() never blocks the UI on a big file read. Results live in a shared,
    // index-aligned batch: the probe thread fills it, render() reads it under the mutex, and a re-scan
    // supersedes the old batch (cancel + swap) so a detached in-flight probe can't touch stale rows.
    struct ProbeResult {
        bool probed    = false;  // validated as a BDB wallet and scanned
        bool complete  = false;  // scan covered the whole file → a MISSING marker is trustworthy
        bool encrypted = false;  // has an mkey record
        bool hdSeed    = false;  // HD/seed wallet (else legacy)
        bool hasCounts = false;  // an exact btree walk produced the counts below
        int  keyCount  = 0;      // transparent + shielded spendable keys (≈ addresses, incl. change)
        int  txCount   = 0;      // wallet transaction records
        long long createdEpoch = 0;  // wallet birthday (earliest keymeta nCreateTime); 0 = unknown
        int  mnemonic  = 0;      // hdchain fMnemonicSeed flag: 0 unknown, 1 seed-phrase, 2 no phrase
    };
    struct ProbeBatch {
        std::mutex mtx;
        std::vector<ProbeResult> results;   // index-aligned with s_rows at the moment of scan()
        std::atomic<bool> cancel{false};
    };

    // Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the
    // datadir scan and can't collide with the daemon's internal .dat files. Returns "" if the
    // input has no usable characters.
    static std::string normalizeWalletName(const std::string& raw) {
        std::string s = raw;
        while (!s.empty() && static_cast<unsigned char>(s.front()) <= ' ') s.erase(s.begin());
        while (!s.empty() && static_cast<unsigned char>(s.back()) <= ' ') s.pop_back();
        auto slash = s.find_last_of("/\\");
        if (slash != std::string::npos) s = s.substr(slash + 1);
        if (s.size() > 4 && s.substr(s.size() - 4) == ".dat") s = s.substr(0, s.size() - 4);
        std::string clean;
        for (char c : s)
            if (std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_') clean += c;
        if (clean.empty()) return "";
        if (clean.rfind("wallet", 0) != 0) clean = "wallet-" + clean;
        return clean + ".dat";
    }

    // A reserved bare-filename PREFIX for the datadir links that let out-of-datadir wallets open in place.
    // Each external wallet gets its own STABLE link name derived from its path — so switching between two
    // of them is a real -wallet=<different name> switch (not a no-op on one shared name), and the wallet
    // index tracks each separately (correct per-wallet rescan + cached data). Hidden from the list.
    // Single source of truth lives in util/wallet_file_probe.h (shared with the offline enumeration helper).
    static constexpr const char* kLinkPrefix = util::kInPlaceLinkPrefix;
    static bool isLinkName(const std::string& n) { return util::isInPlaceLinkName(n); }

    // Stable per-target bare link name, e.g. "wallet-ip-1a2b3c4d.dat" (FNV-1a of the absolute path — a
    // deterministic, cross-platform, cross-run hash, unlike std::hash). Feed it a canonicalOf() path so the
    // SAME physical wallet always maps to the same name regardless of which path reached it.
    static std::string linkNameFor(const std::string& absPath) {
        std::uint64_t h = 1469598103934665603ULL;
        for (unsigned char c : absPath) { h ^= c; h *= 1099511628211ULL; }
        char buf[40];
        std::snprintf(buf, sizeof(buf), "%s%08x.dat", kLinkPrefix,
                      static_cast<unsigned>(h ^ (h >> 32)));
        return buf;
    }

    // Symlink-resolved, absolute path — the stable identity of a physical wallet file. Two different path
    // strings that reach the same file (e.g. a symlinked mount /mnt/usb vs the real /media/usb) collapse to
    // one value, so they share a single in-place link name and de-dup to one row. fs::canonical resolves
    // symlinks (the file exists at scan time); on failure fall back to a lexical-absolute path.
    static std::string canonicalOf(const std::filesystem::path& p) {
        namespace fs = std::filesystem;
        std::error_code ec;
        fs::path c = fs::canonical(p, ec);
        if (ec) c = fs::weakly_canonical(p, ec);
        return c.empty() ? p.string() : c.string();
    }

    // Open an out-of-datadir wallet IN PLACE. The daemon only loads a bare filename from its datadir, so
    // link the real file in under its per-target name and switch to that. On Linux/macOS prefer a SYMLINK
    // (no privileges, spans volumes so a wallet on a USB / other partition works, and it's visibly a
    // pointer — not a duplicate); on Windows prefer a HARD LINK (a symlink there needs admin / Developer
    // Mode), falling back to a symlink. Either way we NEVER copy (that would fork the wallet) and NEVER
    // delete a real file — only our own link.
    static void openInPlace(App* app, const WalletRow& r) {
        namespace fs = std::filesystem;
        std::error_code ec;
        const std::string datadir  = util::Platform::getDragonXDataDir();
        fs::create_directories(datadir, ec);
        const std::string src      = r.dir + "/" + r.fileName;
        // The row could be stale (file moved/deleted between the scan and this click). Don't link a ghost:
        // on Linux/macOS create_symlink to a missing target would happily succeed (a dangling link), we'd
        // switch to it, and the daemon guard would silently fall back to wallet.dat — "opened X, got the
        // default". Refresh the list instead so the vanished row drops out.
        if (!fs::exists(src, ec)) { scan(); return; }
        // canonPath (symlinks resolved) is the wallet's stable identity: hash it so the same file reached
        // via any path maps to ONE link name, and point the link straight at the resolved real file.
        const std::string target   = r.canonPath.empty() ? canonicalOf(src) : r.canonPath;
        const std::string linkName = linkNameFor(target);
        const std::string linkPath = datadir + "/" + linkName;

        // Reconcile whatever already holds our reserved name. We only ever reclaim a link of OURS — never
        // destroy an unknown file's unique data. is_symlink uses lstat, so it also catches a DANGLING prior
        // symlink (source moved) that fs::exists — which follows the link — would miss and that
        // create_symlink would then trip over ("file exists").
        if (fs::is_symlink(linkPath, ec)) {
            fs::remove(linkPath, ec);                                    // our prior symlink (maybe dangling) — replace it
        } else if (fs::exists(linkPath, ec)) {
            if (fs::equivalent(linkPath, src, ec)) {                     // already a valid hard link to THIS wallet
                app->switchToWallet(linkName); s_open = false; return;  //   → reuse as-is (same inode)
            }
            // A DIFFERENT file holds our reserved name. scan()/create() forbid real wallets at this prefix,
            // so it's a stale orphan (a hard link whose source moved, or a copy left by a datadir that was
            // migrated across filesystems) — NOT the current wallet; reusing it would load the wrong
            // balance. Reclaim the name only if the file is provably redundant (another hard link still
            // holds the data); otherwise refuse rather than risk destroying something unique.
            if (fs::hard_link_count(linkPath, ec) > 1) {
                fs::remove(linkPath, ec);
            } else {
                Notifications::instance().error(TR("wallets_open_failed"));
                return;
            }
        }
        std::error_code lec;
#ifdef _WIN32
        fs::create_hard_link(target, linkPath, lec);                           // 1) hard link — no privileges, same volume
        if (lec) { lec.clear(); fs::create_symlink(target, linkPath, lec); }   // 2) symlink — cross-volume (needs admin / Developer Mode)
#else
        fs::create_symlink(target, linkPath, lec);                             // 1) symlink — no privileges, spans volumes
        if (lec) { lec.clear(); fs::create_hard_link(target, linkPath, lec); } // 2) hard link — same-volume fallback
#endif
        if (lec) { Notifications::instance().error(TR("wallets_open_failed")); return; }  // no copy fallback (would fork the wallet)

        app->switchToWallet(linkName);   // distinct per wallet → the index tracks rescan/cache correctly
        s_open = false;
    }

    // Scan the datadir (wallet-prefixed *.dat only, to skip peers.dat / asmap.dat / fee_estimates.dat)
    // and each user-added folder (any *.dat). User-added folders are searched RECURSIVELY into
    // subdirectories; the datadir stays top-level only (never descend into blocks/ chainstate/ …).
    // Exception-safe: iterates with error_codes. Recursion is bounded (depth / hit / visit caps) and
    // never follows directory symlinks (std default), so it can't loop or stall the UI on a huge tree.
    static void scan() {
        // Supersede any in-flight probe batch: its detached thread notices cancel and stops. It only ever
        // touches its OWN batch (never s_rows), so rebuilding the list below is safe while it winds down.
        if (s_probe) s_probe->cancel.store(true);
        s_rows.clear();
        namespace fs = std::filesystem;
        // Guards for recursive external-folder scans (a user could point us at a large tree / home dir).
        constexpr int    kMaxDepth   = 8;      // don't descend deeper than this below the chosen folder
        constexpr size_t kMaxHits    = 200;    // cap total wallet files listed
        constexpr int    kMaxVisited = 40000;  // cap total entries walked, so scan() can't stall the UI

        // Node/daemon .dat files that are NOT wallets. The datadir scan already restricts to a "wallet"
        // prefix, but recursive external folders accept any *.dat — and a user can easily point the picker
        // at a folder that IS or CONTAINS a datadir, so filter these out everywhere or the list fills with
        // blk*/rev* block files, peers.dat, etc. presented as importable "wallets".
        auto isNodeArtifact = [](const std::string& n) {
            static const char* kExact[] = { "peers.dat", "banlist.dat", "fee_estimates.dat",
                                            "mempool.dat", "asmap.dat", "zindex.dat" };
            for (const char* e : kExact) if (n == e) return true;
            return n.rfind("blk", 0) == 0 || n.rfind("rev", 0) == 0;  // blk00000.dat / rev00000.dat
        };
        std::unordered_set<std::string> seenCanon;  // one row per physical wallet. The datadir is scanned
                                                     // first, so a datadir wallet wins over the same file
                                                     // reached via an external (possibly symlinked) path.
        auto consider = [&](const fs::path& p, bool inDatadir) {
            // String-only checks FIRST (no syscalls) — the vast majority of entries in a large tree are
            // neither .dat nor wallets, so reject them before paying for a stat().
            const std::string name = p.filename().string();
            if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return;
            if (inDatadir) { if (name.rfind("wallet", 0) != 0 || isLinkName(name)) return; }  // datadir: wallet-prefixed, hide in-place links
            else           { if (isNodeArtifact(name))          return; }  // external: reject node junk
            std::error_code fec;
            if (!fs::is_regular_file(p, fec)) return;
            WalletRow r;
            r.fileName  = name;
            r.dir       = p.parent_path().string();   // the file's ACTUAL parent (may be a subdir) — Open reads r.dir + "/" + r.fileName
            r.canonPath = canonicalOf(p);             // symlink-resolved identity (see below)
            if (!seenCanon.insert(r.canonPath).second) return;  // same physical wallet already listed via another path
            r.inDatadir = inDatadir;
            r.sizeBytes = static_cast<long long>(fs::file_size(p, fec));
            s_rows.push_back(std::move(r));   // encryption/seed flags are filled in asynchronously (see below)
        };
        // Directory names we never descend into during a recursive external scan — node data subtrees hold
        // no user wallets and would otherwise burn the visit budget (and flood the list) with block files.
        auto isNodeSubtree = [](const std::string& n) {
            return n == "blocks" || n == "chainstate" || n == "database" ||
                   n == "indexes" || n == "index";
        };
        auto addFrom = [&](const std::string& dir, bool inDatadir, bool recursive) {
            std::error_code ec;
            if (dir.empty() || !fs::is_directory(dir, ec)) return;
            if (!recursive) {
                fs::directory_iterator it(dir, ec), end;
                for (; !ec && it != end; it.increment(ec)) consider(it->path(), inDatadir);
                return;
            }
            // skip_permission_denied keeps a locked subdir from aborting the whole walk; the iterator
            // does NOT follow directory symlinks by default, so symlink cycles can't cause infinite loops.
            fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec), end;
            int visited = 0;
            for (; !ec && it != end; it.increment(ec)) {
                if (++visited > kMaxVisited) break;
                std::error_code dec;
                if (it->is_directory(dec)) {
                    // Prune: don't descend past the depth cap or into node data subtrees.
                    if (it.depth() >= kMaxDepth || isNodeSubtree(it->path().filename().string()))
                        it.disable_recursion_pending();
                    continue;  // directories aren't wallet files
                }
                consider(it->path(), inDatadir);
                if (s_rows.size() >= kMaxHits) break;
            }
        };
        addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true,  /*recursive=*/false);
        if (s_app)
            for (const auto& f : s_app->walletIndex().extraFolders())
                addFrom(f, /*inDatadir=*/false, /*recursive=*/true);

        // Probe the encryption/seed flags on a background thread so a large wallet.dat read never stalls
        // the UI. The thread is DETACHED and captures only its own heap batch + a copy of the target paths
        // (no static/s_rows access), so it's safe to outlive a re-scan or app shutdown. Results land in the
        // shared batch that render() reads under the mutex; badges fill in over the next few frames.
        auto batch = std::make_shared<ProbeBatch>();
        batch->results.resize(s_rows.size());
        s_probe = batch;
        std::vector<std::pair<std::string, std::size_t>> targets;
        targets.reserve(s_rows.size());
        for (std::size_t i = 0; i < s_rows.size(); ++i)
            targets.emplace_back(s_rows[i].dir + "/" + s_rows[i].fileName, i);

        auto runProbe = [](std::shared_ptr<ProbeBatch> batch,
                           std::vector<std::pair<std::string, std::size_t>> targets) {
            // Per-file 256 MB (BDB sorts long-keyed mkey/hdchain LATE, so a small cap risks a
            // false-negative); a shared budget bounds total I/O, charged by bytes actually read.
            std::size_t budget = 768u * 1024u * 1024u;
            constexpr std::size_t kPerFile = 256u * 1024u * 1024u;
            for (const auto& t : targets) {
                if (batch->cancel.load()) return;
                ProbeResult res;
                if (budget > 0) {
                    // Primary: an exact btree walk (encryption/seed flags + address/tx counts). If it
                    // can't fully parse (unknown BDB variant, or a >cap file), fall back to the cheap
                    // byte-scan for badges only (no counts).
                    const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile));
                    if (bt.parsed && bt.complete) {
                        res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch };
                        res.mnemonic = bt.mnemonicSeed;   // read straight off the hdchain record
                        budget -= std::min(budget, bt.bytesRead);
                    } else {
                        const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
                        res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
                        // A cap-truncated btree walk still yields DEFINITIVE positives (a found marker is
                        // authoritative even when the scan didn't finish), so carry what it read — notably
                        // the fMnemonicSeed flag. Otherwise a large wallet probed after the shared budget is
                        // spent loses its seed/legacy classification and the row falls back to the (possibly
                        // stale) runtime badge, mislabelling a legacy wallet as a seed-phrase wallet.
                        if (bt.mnemonicSeed != 0) res.mnemonic  = bt.mnemonicSeed;
                        if (bt.hdSeed)            res.hdSeed    = true;
                        if (bt.encrypted)         res.encrypted = true;
                        budget -= std::min(budget, std::max(bt.bytesRead, pr.bytesRead));
                    }
                }
                std::lock_guard<std::mutex> lk(batch->mtx);
                if (t.second < batch->results.size()) batch->results[t.second] = res;
            }
        };
        if (targets.empty()) {
            // nothing to probe
        } else if (s_app && s_app->isScreenshotSweeping()) {
            runProbe(batch, targets);   // offline sweep: probe synchronously so the captured frame is populated
        } else {
            std::thread(runProbe, batch, targets).detach();
        }
    }

    static inline bool s_open = false;
    static inline App* s_app = nullptr;
    static inline bool s_needScan = false;
    static inline char s_newName[128] = "";
    static inline std::vector<WalletRow> s_rows;
    static inline std::shared_ptr<ProbeBatch> s_probe;   // current async probe batch (index-aligned w/ s_rows)
    static inline std::vector<std::size_t> s_order;      // display order (indices into s_rows) for the active sort
    static inline int  s_sortMode = 0;                   // 0 created · 1 addresses · 2 txs · 3 size
    static inline bool s_sortDesc = true;                // newest / most / largest first
};

} // namespace ui
} // namespace dragonx
