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

#include "contacts_tab.h"
#include "../../app.h"
#include "../../config/settings.h"
#include "../../data/address_book.h"
#include "../../util/i18n.h"
#include "../../util/text_format.h"
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "../material/project_icons.h"
#include "../../util/texture_loader.h"
#include "../../util/platform.h"
#include "image_picker.h"
#include "../layout.h"
#include "imgui.h"
#include <filesystem>
#include <unordered_map>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>

namespace dragonx {
namespace ui {

// Tab state (formerly AddressBookDialog's class statics). s_selected_index is a
// STORAGE index into book.entries() — decoupled from the visible row order so
// search + sort can't corrupt edit/delete/copy targets.
static int  s_selected_index = -1;
static bool s_show_add_dialog = false;
static bool s_show_edit_dialog = false;
static bool s_show_contacts_settings = false;   // Contacts customization modal (gear button)
static bool s_focus_edit_field = false;  // focus the first field the frame the add/edit dialog opens
static int  s_confirm_delete_idx = -1;   // armed storage index; a 2nd Delete confirms
static int  s_confirm_avatar_del_idx = -1;  // armed avatar-library index; a 2nd badge click confirms the (irreversible) file delete
static char s_edit_label[128] = "";
static char s_edit_address[512] = "";
static char s_edit_notes[512] = "";
static bool s_edit_global = false;   // add/edit dialog: "visible in every wallet" toggle
static char s_search[128] = "";
// Avatar being edited: "" = default Z/T badge, "icon:<name>", or "img:<abs path>".
static std::string s_edit_avatar;
static int  s_edit_avatar_mode = 0;      // 0 = badge, 1 = icon, 2 = image (segmented picker)
static char s_edit_icon_search[64] = ""; // icon-grid filter in the edit dialog

static void copyEditField(char* dest, size_t destSize, const std::string& source) {
    if (destSize == 0) return;
    std::strncpy(dest, source.c_str(), destSize - 1);
    dest[destSize - 1] = '\0';
}

static std::string toLower(std::string s) {
    std::transform(s.begin(), s.end(), s.begin(),
                   [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
    return s;
}

static bool matchesSearch(const data::AddressBookEntry& e, const std::string& needleLower) {
    if (needleLower.empty()) return true;
    return toLower(e.label).find(needleLower) != std::string::npos
        || toLower(e.address).find(needleLower) != std::string::npos
        || toLower(e.notes).find(needleLower) != std::string::npos;
}

// Lazily-loaded, path-keyed cache of custom avatar image textures. Each entry is a frame SEQUENCE
// (1 frame for stills, N for animated GIF/WebP), box-downscaled by texture_loader to bound VRAM.
// Decoding is budgeted per frame so opening a large library/contact list doesn't stall the UI — callers
// show a placeholder and the image fills in over the next frames.
struct AvatarTex {
    std::vector<ImTextureID> frames;   // 1 = still, N = animated
    std::vector<float> delays;         // seconds per frame (parallel to frames)
    float totalDur = 0.0f;
    int w = 0, h = 0;
};
static std::unordered_map<std::string, AvatarTex> s_avatarTexCache;
static int  s_avatarLoadsThisFrame = 0;
static bool s_animateAvatars = true;            // mirrors the setting; refreshed each frame in RenderContactsTab
static int  s_contactsShape   = 0;              // avatar shape (mirrors getContactsAvatarShape: 0/1/2)
static float s_contactsScale  = 1.0f;           // card/list row scale (mirrors getContactsListScale)
static bool s_avatarAnimatedThisFrame = false;  // set when a live animated frame is drawn -> keep redrawing
static constexpr int kAvatarLoadsPerFrame = 2;
static constexpr int kAvatarMaxFrames = 300;    // cap frames per animated avatar (bounds VRAM/decode)

static const AvatarTex* getAvatarTexture(const std::string& path) {
    auto it = s_avatarTexCache.find(path);
    if (it != s_avatarTexCache.end()) return it->second.frames.empty() ? nullptr : &it->second;
    if (s_avatarLoadsThisFrame >= kAvatarLoadsPerFrame) return nullptr;   // defer to a later frame
    s_avatarLoadsThisFrame++;
    AvatarTex at;
    util::AnimFrames af;
    if (util::LoadAnimatedRGBA(path.c_str(), kAvatarMaxFrames, af) && !af.frames.empty()) {
        at.w = af.w; at.h = af.h;
        for (size_t i = 0; i < af.frames.size(); ++i) {
            ImTextureID t = 0;
            if (util::CreateRawTexture(af.frames[i].data(), af.w, af.h, false, &t)) {
                at.frames.push_back(t);
                at.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.0f);
            }
        }
        for (float d : at.delays) at.totalDur += d;
    }
    auto& slot = (s_avatarTexCache[path] = std::move(at));
    return slot.frames.empty() ? nullptr : &slot;
}

// The texture to draw for `t` right now: a still's single frame, or — when the animate-avatars setting
// is on AND the avatar is actually on-screen — the animated frame for the current ImGui clock time (and
// flag that an animation is live, so the render loop keeps producing frames instead of idling). Passing
// onScreen=false (an avatar scrolled out of a list/table viewport) shows frame 0 and does NOT flag, so a
// culled animated avatar can't pin the app awake.
static ImTextureID currentAvatarFrame(const AvatarTex* t, bool onScreen = true) {
    if (!t || t->frames.empty()) return 0;
    if (onScreen && t->frames.size() > 1 && s_animateAvatars && t->totalDur > 0.0f) {
        s_avatarAnimatedThisFrame = true;
        double phase = std::fmod(ImGui::GetTime(), (double)t->totalDur);
        double acc = 0.0;
        for (size_t i = 0; i < t->delays.size() && i < t->frames.size(); ++i) {
            acc += t->delays[i];
            if (phase < acc) return t->frames[i];
        }
    }
    return t->frames[0];
}

// Drop a cached avatar's textures (all frames) so the next getAvatarTexture reloads from disk.
static void invalidateAvatarTexture(const std::string& path) {
    auto it = s_avatarTexCache.find(path);
    if (it == s_avatarTexCache.end()) return;
    for (ImTextureID t : it->second.frames) if (t) util::DestroyTexture(t);
    s_avatarTexCache.erase(it);
}

// Draw a contact's avatar: a custom image, a Material icon in a tinted shape, or the Z/T type badge.
// `shape` picks the outline: 0 = circle (centered at `c`, radius `r`); 1 = rounded square (same box as
// the circle, all corners rounded); 2 = a full-row-height tab spanning [tabMin, tabMax] with rounded
// LEFT corners (radius tabRound) + a straight right edge. The inner glyph is sized to the shape, so it
// stays proportional at any list scale.
static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::AddressBookEntry& e,
                              bool shielded, ImU32 typeCol, bool light, float dp,
                              ImFont* letterFont, ImFont* iconFont, bool onScreen = true, int shape = 0,
                              ImVec2 tabMin = ImVec2(0, 0), ImVec2 tabMax = ImVec2(0, 0), float tabRound = 0.0f) {
    const bool circle = (shape == 0);
    // Resolve the shape rectangle, its rounding, the content center + radius, and the glyph size.
    ImVec2 cc = c; float cr = r;
    ImVec2 sMin(c.x - r, c.y - r), sMax(c.x + r, c.y + r);
    float sRound = r * 0.42f;
    ImDrawFlags sFlags = ImDrawFlags_RoundCornersAll;
    if (shape == 2) {
        sMin = tabMin; sMax = tabMax; sRound = tabRound; sFlags = ImDrawFlags_RoundCornersLeft;
        cc = ImVec2((tabMin.x + tabMax.x) * 0.5f, (tabMin.y + tabMax.y) * 0.5f);
        cr = (tabMax.y - tabMin.y) * 0.5f;
    }
    const float glyphSz = cr * 0.92f;   // ~46% of the shape diameter — matches the classic badge ratio
    auto fillShape   = [&](ImU32 col) {
        if (circle) dl->AddCircleFilled(cc, cr, col);
        else        dl->AddRectFilled(sMin, sMax, col, sRound, sFlags);
    };
    auto borderShape = [&](ImU32 col, float th) {
        if (circle) dl->AddCircle(cc, cr, col, 0, th);
        else        dl->AddRect(sMin, sMax, col, sRound, sFlags, th);
    };
    const std::string& av = e.avatar;
    if (av.rfind("img:", 0) == 0) {
        const AvatarTex* t = getAvatarTexture(av.substr(4));
        ImTextureID tex = currentAvatarFrame(t, onScreen);
        if (tex) {
            const float aspect = (sMax.x - sMin.x) / std::max(1.0f, sMax.y - sMin.y);  // crop to the shape's aspect
            float u0 = 0, v0 = 0, u1 = 1, v1 = 1;
            const float tw = static_cast<float>(t->w), th2 = static_cast<float>(t->h);
            if (tw / th2 > aspect) { float keep = aspect * th2 / tw; float m = (1 - keep) * 0.5f; u0 = m; u1 = 1 - m; }
            else                   { float keep = (tw / aspect) / th2; float m = (1 - keep) * 0.5f; v0 = m; v1 = 1 - m; }
            dl->AddImageRounded(tex, sMin, sMax, ImVec2(u0, v0), ImVec2(u1, v1), IM_COL32_WHITE,
                                circle ? cr : sRound, circle ? ImDrawFlags_RoundCornersAll : sFlags);
            borderShape(material::WithAlpha(material::OnSurface(), 45), 1.0f * dp);
            return;
        }
        // fall through to the type badge if the image failed to load
    } else if (av.rfind("icon:", 0) == 0) {
        const std::string iconName = av.substr(5);
        const bool known = (iconName == material::project_icons::kPickaxeName) ||
                           (material::project_icons::glyphForName(iconName) != nullptr);
        if (known) {
            fillShape(material::WithAlpha(typeCol, light ? 45 : 60));
            borderShape(material::WithAlpha(typeCol, 190), 1.4f * dp);
            material::project_icons::drawByName(dl, iconName, cc, typeCol, iconFont, glyphSz);
            return;
        }
        // fall through to the type badge if the icon name is unknown
    }
    // Default: Z/T type badge.
    fillShape(material::WithAlpha(typeCol, light ? 45 : 60));
    borderShape(material::WithAlpha(typeCol, 190), 1.4f * dp);
    const char* letter = shielded ? "Z" : "T";
    const ImVec2 ls = letterFont->CalcTextSizeA(glyphSz, FLT_MAX, 0, letter);
    dl->AddText(letterFont, glyphSz, ImVec2(cc.x - ls.x * 0.5f, cc.y - ls.y * 0.5f), typeCol, letter);
}

static bool isShieldedAddr(const std::string& a) {
    return !a.empty() && a[0] == 'z';
}

// Width-aware middle-ellipsis truncation (mirrors the add/edit dialog's local fitMiddle lambda, but
// file-scope so the Cards/List rows can share it too): keeps head + tail, shrinking symmetrically
// until the rendered width fits maxW. minFront/minBack are the schema-configured floor — below that
// the fixed-length util::truncateMiddle result is used instead, so very cramped rows still read as
// "front...back" rather than collapsing to a near-empty stub. Addresses are ASCII, so byte-wise
// trimming is safe.
static std::string truncateAddressToWidth(const std::string& s, ImFont* f, float size, float maxW,
                                          int minFront, int minBack) {
    const std::string floor = util::truncateMiddle(s, minFront, minBack);
    auto w = [&](const std::string& t){ return f->CalcTextSizeA(size, FLT_MAX, 0, t.c_str()).x; };
    if (w(s) <= maxW) return s;                 // fits in full — no truncation needed at all
    if (maxW <= 0.0f) return floor;              // no room to measure against — fall back to the floor
    const std::string ell = "\xE2\x80\xA6";
    size_t head = s.size() / 2, tail = s.size() - head;
    while (head + tail > static_cast<size_t>(minFront + minBack)) {
        std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail);
        if (w(cand) <= maxW) return cand;
        if (head >= tail) --head; else --tail;
    }
    return floor;   // couldn't fit even at the configured floor — use the fixed-length result
}

// Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per
// theme. File-scope so both the list rows and the edit-dialog preview share one source of truth.
static ImU32 contactTypeColor(bool shielded, bool light) {
    ImVec4 c = shielded ? (light ? ImVec4(0.10f,0.55f,0.38f,1.0f) : ImVec4(0.35f,0.80f,0.60f,1.0f))
                        : (light ? ImVec4(0.72f,0.48f,0.05f,1.0f) : ImVec4(0.95f,0.72f,0.30f,1.0f));
    return ImGui::ColorConvertFloat4ToU32(c);
}

// Copy a chosen avatar image into <config>/contact-avatars/, returning the destination absolute path
// (empty on failure). Named by the source stem + an 8-hex FNV hash of the full source path, so
// re-picking the same file is idempotent and two different files never clobber each other.
static std::string copyAvatarImage(const std::string& srcPath) {
    namespace fs = std::filesystem;
    std::error_code ec;
    fs::path src(srcPath);
    if (!fs::is_regular_file(src, ec)) return "";
    fs::path dir = fs::path(util::Platform::getConfigDir()) / "contact-avatars";
    fs::create_directories(dir, ec);
    uint64_t h = 1469598103934665603ull;                       // FNV-1a
    for (unsigned char c : srcPath) { h ^= c; h *= 1099511628211ull; }
    char suffix[9];
    snprintf(suffix, sizeof(suffix), "%08x", (unsigned)(h & 0xffffffffu));
    std::string stem = src.stem().string();
    if (stem.size() > 40) stem.resize(40);
    fs::path dst = dir / (stem + "-" + suffix + src.extension().string());
    fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
    return ec ? std::string() : dst.string();
}

// Avatar image LIBRARY: the images the user has added live in <config>/contact-avatars/ and persist as
// a reusable, portable set (they travel with the wallet data dir). The Image avatar tab is a grid over
// this library; images are only removed when the user explicitly deletes them.
static std::vector<std::string> s_avatarLibrary;   // absolute paths, sorted
static bool s_avatarLibraryDirty = true;

static bool isImageFileName(const std::string& name) {
    std::string lo = name;
    for (char& c : lo) c = static_cast<char>(std::tolower((unsigned char)c));
    for (const char* e : { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp",
                           ".tga", ".psd", ".pnm", ".ppm", ".pgm", ".pic" }) {
        const std::string ext(e);
        if (lo.size() > ext.size() && lo.compare(lo.size() - ext.size(), ext.size(), ext) == 0) return true;
    }
    return false;
}

static void rescanAvatarLibrary() {
    s_avatarLibrary.clear();
    s_avatarLibraryDirty = false;
    namespace fs = std::filesystem;
    std::error_code ec;
    fs::path dir = fs::path(util::Platform::getConfigDir()) / "contact-avatars";
    if (!fs::is_directory(dir, ec)) return;
    for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) {
        std::error_code fec;
        if (it->is_regular_file(fec) && isImageFileName(it->path().filename().string()))
            s_avatarLibrary.push_back(it->path().string());
    }
    std::sort(s_avatarLibrary.begin(), s_avatarLibrary.end());
}

// Explicitly delete a library image (user pressed its delete icon). Only touches files directly inside
// the managed dir; drops the cached texture, clears the current selection if it pointed here, and marks
// the library for rescan. Contacts still referencing a deleted image fall back to their Z/T badge.
static void deleteAvatarLibraryFile(const std::string& path) {
    namespace fs = std::filesystem;
    std::error_code ec;
    fs::path managed = fs::weakly_canonical(fs::path(util::Platform::getConfigDir()) / "contact-avatars", ec);
    fs::path target  = fs::weakly_canonical(fs::path(path), ec);
    if (managed.empty() || target.parent_path() != managed) return;   // safety: never escape the dir
    fs::remove(target, ec);
    invalidateAvatarTexture(path);
    if (s_edit_avatar == "img:" + path) s_edit_avatar.clear();
    s_avatarLibraryDirty = true;
}

// UI-loop accessor: true if an animated avatar frame was drawn since the last call (clear-on-read), so
// the main render loop keeps producing frames while an animation plays and idles once it stops / the
// contacts view is hidden. Declared in contacts_tab.h.
bool ConsumeContactsAvatarAnimation() {
    // Also consume the image picker's hover-preview animation flag (the picker renders within this tab).
    bool picker = ImagePicker::consumeAnimationActive();
    bool v = s_avatarAnimatedThisFrame || picker;
    s_avatarAnimatedThisFrame = false;
    return v;
}

void RenderContactsTab(App* app)
{
    s_avatarLoadsThisFrame = 0;   // reset the per-frame avatar-decode budget (see getAvatarTexture)
    s_animateAvatars = !app->settings() || app->settings()->getAnimateAvatars();
    s_contactsShape  = app->settings() ? app->settings()->getContactsAvatarShape() : 0;
    s_contactsScale  = app->settings() ? app->settings()->getContactsListScale() : 1.0f;
    auto& S = schema::UI();
    // Reuse the existing address-book schema/column config for the table + add/edit form.
    auto addrTable = S.table("dialogs.address-book", "address-table");
    auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label");
    auto addrBackLbl = S.label("dialogs.address-book", "address-back-label");
    auto actionBtn = S.button("dialogs.address-book", "action-button");

    auto& book = app->addressBook();

    // Stable per-wallet id that scopes which contacts show + tags newly-added ones. Unlike the old
    // address-hash identity it doesn't drift when the address set changes, and it's known even before
    // connect. Empty only when there's no active wallet file.
    const std::string activeHash = app->activeWalletScopeId();
    // One-time recovery: contacts saved under the old drifting address-hash scope were orphaned when the
    // wallet's address set changed. When there's a single known wallet (so attribution is unambiguous),
    // re-attach those legacy-scoped contacts to this wallet's stable id. Idempotent (converted contacts
    // become "w:"-scoped), so it does real work only once.
    if (!activeHash.empty() && app->walletIndex().entries().size() <= 1)
        app->addressBook().reattachLegacyScopes(activeHash);

    auto clearEditFields = []() {
        s_edit_label[0] = '\0';
        s_edit_address[0] = '\0';
        s_edit_notes[0] = '\0';
        s_edit_global = false;   // new contacts default to the current wallet
        s_edit_avatar.clear();
        s_edit_avatar_mode = 0;
        s_edit_icon_search[0] = '\0';
        s_avatarLibraryDirty = true;   // rescan the image library on open (files may have changed)
    };

    auto loadEditFields = [](const data::AddressBookEntry& entry) {
        copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
        copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
        copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
        s_edit_global = entry.isGlobal();
        s_edit_avatar = entry.avatar;
        s_edit_avatar_mode = (entry.avatar.rfind("icon:", 0) == 0) ? 1
                           : (entry.avatar.rfind("img:", 0) == 0)  ? 2 : 0;
        s_edit_icon_search[0] = '\0';
        s_avatarLibraryDirty = true;   // rescan the image library on open (files may have changed)
    };

    bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());

    // Shared delete/copy/edit actions (used by the toolbar, keyboard shortcuts, and per-row icons).
    // Each validates s_selected_index FRESHLY rather than the frame-top has_selection bool: a per-row
    // icon sets the selection then invokes the action in the SAME frame, so the cached bool is stale.
    auto selValid = [&]() { return s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size()); };
    auto doDelete = [&]() {
        if (!selValid()) return;
        if (s_confirm_delete_idx == s_selected_index) {
            // The contact's avatar image (if any) stays in the library for reuse — not deleted here.
            book.removeEntry(s_selected_index);
            s_selected_index = -1;
            s_confirm_delete_idx = -1;
            Notifications::instance().success(TR("address_book_deleted"));
        } else {
            // Require a second, deliberate confirm (no undo for a removed contact).
            s_confirm_delete_idx = s_selected_index;
        }
    };
    auto doCopy = [&]() {
        if (!selValid()) return;
        ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str());
        Notifications::instance().info(TR("address_copied"));
    };
    auto openEdit = [&]() {
        if (!selValid()) return;
        loadEditFields(book.entries()[s_selected_index]);
        s_show_edit_dialog = true;
        s_focus_edit_field = true;
    };

    // Add/edit form — a modal popup layered over the tab, with a live contact preview and an avatar
    // picker (default Z/T badge, a Material icon, or a custom image via the in-app image picker).
    auto renderEntryDialog = [&]() {
        // The image picker takes over the modal surface while it's up (the overlay framework doesn't
        // nest) — render it instead of the edit dialog, exactly as WalletsDialog does for FolderPicker.
        if (ImagePicker::isOpen()) { ImagePicker::render(); return; }

        bool isEdit = s_show_edit_dialog;
        bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog;
        if (!*open) return;

        const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
        const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
        const float dp = Layout::dpiScale();
        const bool light = material::IsLightTheme();
        ImFont* btnFont = S.resolveFont(actionBtn.font);

        material::OverlayDialogSpec ov;
        ov.title = title; ov.p_open = open;
        ov.style = material::OverlayStyle::BlurFloat;
        ov.cardWidth = 880.0f; ov.idSuffix = id;   // two-column body: form (left) + avatar picker (right)
        // Fixed, tall card — fill the vertical space so the icon grid shows many rows and the notes
        // field has room, rather than a squat auto-height box with dead space below.
        const float vpH = ImGui::GetMainViewport()->Size.y;
        ov.cardHeight = std::min(vpH * 0.84f, 820.0f * dp) / dp;   // logical; framework re-applies dp
        if (material::BeginOverlayDialog(ov)) {
            // End-truncate a string to fit maxW (whole UTF-8 code points), appending an ellipsis.
            auto fitText = [&](std::string s, ImFont* f, float maxW) {
                bool t = false;
                while (s.size() > 1 && f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x > maxW) {
                    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;
            };
            // Middle-truncate to fit maxW, keeping both ends — an address reads best head + tail
            // (addresses are ASCII base58/bech32, so byte-wise trimming is safe).
            auto fitMiddle = [&](std::string s, ImFont* f, float maxW) {
                auto w = [&](const std::string& t){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, t.c_str()).x; };
                if (w(s) <= maxW || s.size() <= 8) return s;
                const std::string ell = "\xE2\x80\xA6";
                size_t head = s.size() / 2, tail = s.size() - head;
                while (head + tail > 6) {
                    std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail);
                    if (w(cand) <= maxW) return cand;
                    if (head >= tail) --head; else --tail;
                }
                return s.substr(0, 3) + ell + s.substr(s.size() - 3);
            };

            // ---- Live preview: the contact card as it will appear in the list --------------------
            {
                ImDrawList* pdl = ImGui::GetWindowDrawList();
                ImFont* nameF = material::Type().subtitle1();
                ImFont* addrF = material::Type().caption();
                ImFont* letF  = material::Type().subtitle1();
                ImFont* icoF  = material::Type().iconMed();
                const float pad = Layout::spacingMd();
                const float avR = 26.0f * dp;
                const float blockH = nameF->LegacySize + Layout::spacingXs() + addrF->LegacySize;
                const float ph = pad * 2.0f + std::max(avR * 2.0f, blockH);
                const float pw = ImGui::GetContentRegionAvail().x;
                ImVec2 pMin = ImGui::GetCursorScreenPos();
                ImVec2 pMax(pMin.x + pw, pMin.y + ph);
                material::GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 22; g.borderAlpha = 40;
                material::DrawGlassPanel(pdl, pMin, pMax, g);

                data::AddressBookEntry pv;
                pv.label = s_edit_label; pv.address = s_edit_address; pv.avatar = s_edit_avatar;
                const bool sh = isShieldedAddr(s_edit_address);
                const ImU32 tu = contactTypeColor(sh, light);
                ImVec2 avC(pMin.x + pad + avR, pMin.y + ph * 0.5f);
                const float pTabW = ph;   // full preview-height square tab (shape 2)
                drawContactAvatar(pdl, avC, avR, pv, sh, tu, light, dp, letF, icoF, true,
                                  s_contactsShape, pMin, ImVec2(pMin.x + pTabW, pMax.y), 10.0f * dp);

                float tx = (s_contactsShape == 2) ? (pMin.x + pTabW + Layout::spacingMd())
                                                  : (avC.x + avR + Layout::spacingMd());
                float textRight = pMax.x - pad;
                float ty = pMin.y + (ph - blockH) * 0.5f;
                const bool hasName = s_edit_label[0] != '\0';
                std::string nm = fitText(hasName ? std::string(s_edit_label) : std::string(TR("contact_preview_name")),
                                         nameF, textRight - tx);
                pdl->AddText(nameF, nameF->LegacySize, ImVec2(tx, ty),
                             hasName ? material::OnSurface() : material::OnSurfaceDisabled(), nm.c_str());
                const bool hasAddr = s_edit_address[0] != '\0';
                std::string ad = hasAddr ? fitMiddle(std::string(s_edit_address), addrF, textRight - tx)
                                         : fitText(std::string(TR("contact_preview_addr")), addrF, textRight - tx);
                pdl->AddText(addrF, addrF->LegacySize, ImVec2(tx, ty + nameF->LegacySize + Layout::spacingXs()),
                             hasAddr ? material::OnSurfaceMedium() : material::OnSurfaceDisabled(), ad.c_str());
                ImGui::Dummy(ImVec2(pw, ph));
            }
            ImGui::Dummy(ImVec2(0, Layout::spacingMd()));

            // ================= Two-column body: form (left) | avatar picker (right) =================
            // Body flexes to fill the tall card, leaving just the footer button row — so the icon grid
            // and notes field grow into the vertical space instead of leaving it empty.
            const float footerReserve = 44.0f * dp + Layout::spacingMd();
            // Never floor above what's available — the footer (Save/Cancel) must stay inside the fixed,
            // non-scrolling card even on short windows. A tiny floor only guards a degenerate size.
            const float bodyH    = std::max(48.0f * dp, ImGui::GetContentRegionAvail().y - footerReserve);
            const float colGap   = Layout::spacingLg();
            const float bodyAvail = ImGui::GetContentRegionAvail().x;
            const float colW     = (bodyAvail - colGap) * 0.5f;

            // ---- LEFT: label / address / notes / global ----------------------------------------
            ImGui::BeginChild("##contactFormCol", ImVec2(colW, bodyH), false,
                              ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
            // Focus the first field the frame the dialog opens so it's keyboard-ready.
            if (s_focus_edit_field) {
                ImGui::SetKeyboardFocusHere();
                s_focus_edit_field = false;
            }
            material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel",
                                   s_edit_label, sizeof(s_edit_label), -1.0f);

            ImGui::Spacing();

            // Address (full width) with a centered Paste button beneath it.
            material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress",
                                   s_edit_address, sizeof(s_edit_address), -1.0f);
            {
                float pasteW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("paste")).x
                             + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
                ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
                                     std::max(0.0f, (ImGui::GetContentRegionAvail().x - pasteW) * 0.5f));
                if (material::TactileButton(TR("paste"), ImVec2(pasteW, 0), btnFont)) {
                    const char* clipboard = ImGui::GetClipboardText();
                    if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
                }
            }

            ImGui::Spacing();

            // Notes grows to fill the column above the Global checkbox, which is pinned to the bottom.
            // Reserve the checkbox frame + the item spacing before it + a clear bottom margin so its
            // descenders ("y"/"g") never clip against the child's edge.
            {
                float globalReserve = ImGui::GetFrameHeight() + Layout::spacingLg() * 2.0f;
                float labelLine = ImGui::GetTextLineHeightWithSpacing();
                float notesFill = std::max(60.0f * dp,
                                           ImGui::GetContentRegionAvail().y - globalReserve - labelLine);
                material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes",
                                                s_edit_notes, sizeof(s_edit_notes), ImVec2(-1.0f, notesFill));
            }

            ImGui::Spacing();
            ImGui::Checkbox(TR("contact_global"), &s_edit_global);
            if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_tt"));
            ImGui::EndChild();  // ##contactFormCol

            ImGui::SameLine(0, colGap);

            // ---- RIGHT: avatar picker (Badge / Icon / Image), fills the column height -----------
            ImGui::BeginChild("##contactAvatarCol", ImVec2(colW, bodyH), false,
                              ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
            material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("contact_avatar"));
            {
                // Custom icon + label segmented control (the shared helper is single-font; avatar modes
                // read clearer with a glyph). Track + active pill mirror material::SegmentedControl.
                const char* segLbl[3] = { TR("contact_avatar_badge"), TR("contact_avatar_icon"),
                                          TR("contact_avatar_image") };
                const char* segIco[3] = { ICON_MD_BADGE, ICON_MD_PALETTE, ICON_MD_IMAGE };
                ImFont* segIcoF = material::Type().iconSmall();
                float segH = 34.0f * dp;
                float segW = ImGui::GetContentRegionAvail().x;
                float cellW = segW / 3.0f;
                ImVec2 sMin = ImGui::GetCursorScreenPos();
                ImDrawList* sdl = ImGui::GetWindowDrawList();
                sdl->AddRectFilled(sMin, ImVec2(sMin.x + segW, sMin.y + segH),
                                   material::WithAlpha(material::OnSurface(), 20), segH * 0.5f);
                int clk = -1;
                for (int i = 0; i < 3; i++) {
                    ImVec2 cMin(sMin.x + i * cellW, sMin.y), cMax(cMin.x + cellW, sMin.y + segH);
                    bool active = (s_edit_avatar_mode == i);
                    bool hov = ImGui::IsMouseHoveringRect(cMin, cMax);
                    if (active)
                        sdl->AddRectFilled(ImVec2(cMin.x + 2.0f * dp, cMin.y + 2.0f * dp),
                                           ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
                                           material::WithAlpha(material::Primary(), 210), (segH - 4.0f * dp) * 0.5f);
                    ImU32 fg = active ? material::OnPrimary() : (hov ? material::OnSurface() : material::OnSurfaceMedium());
                    float igW = segIcoF->CalcTextSizeA(segIcoF->LegacySize, FLT_MAX, 0, segIco[i]).x;
                    float lbW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, segLbl[i]).x;
                    float gapI = 5.0f * dp;
                    float startX = cMin.x + (cellW - (igW + gapI + lbW)) * 0.5f;
                    sdl->AddText(segIcoF, segIcoF->LegacySize,
                                 ImVec2(startX, cMin.y + (segH - segIcoF->LegacySize) * 0.5f), fg, segIco[i]);
                    sdl->AddText(btnFont, btnFont->LegacySize,
                                 ImVec2(startX + igW + gapI, cMin.y + (segH - btnFont->LegacySize) * 0.5f), fg, segLbl[i]);
                    ImGui::PushID(i);
                    ImGui::SetCursorScreenPos(cMin);
                    if (ImGui::InvisibleButton("##avseg", ImVec2(cellW, segH))) clk = i;
                    if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                    ImGui::PopID();
                }
                if (clk >= 0 && clk != s_edit_avatar_mode) {
                    s_edit_avatar_mode = clk;
                    // Switching mode resets the value unless it already matches that mode's prefix.
                    if (clk == 0) s_edit_avatar.clear();
                    else if (clk == 1 && s_edit_avatar.rfind("icon:", 0) != 0) s_edit_avatar.clear();
                    else if (clk == 2 && s_edit_avatar.rfind("img:", 0)  != 0) s_edit_avatar.clear();
                }
                ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH));
                ImGui::Dummy(ImVec2(segW, 0));
            }
            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

            // The mode content fills the rest of the avatar column (fixed height → no jump on switch).
            if (s_edit_avatar_mode == 0) {
                // Badge: show the two example chips (Z shielded / T transparent) — the badge is picked
                // automatically from the address type — plus a short caption. Centered in the space below.
                ImDrawList* bdl = ImGui::GetWindowDrawList();
                ImFont* letF = material::Type().subtitle1();
                ImFont* capF = material::Type().caption();
                ImVec2 a = ImGui::GetContentRegionAvail();
                ImVec2 origin = ImGui::GetCursorScreenPos();
                const float chipR = 24.0f * dp;
                const float chipGap = 40.0f * dp;
                const float labelY = chipR * 2.0f + Layout::spacingXs() + capF->LegacySize;
                const char* msg = TR("contact_avatar_badge_hint");
                float wrapW = a.x - 8.0f * dp;
                ImVec2 ms = capF->CalcTextSizeA(capF->LegacySize, wrapW, 0, msg);
                const float blockH = labelY + Layout::spacingMd() + ms.y;
                float cy = origin.y + std::max(0.0f, (a.y - blockH) * 0.42f);
                float cx = origin.x + a.x * 0.5f;
                struct Chip { bool sh; const char* lbl; };
                Chip chips[2] = { { true, TR("contact_avatar_shielded") }, { false, TR("contact_avatar_transparent") } };
                for (int i = 0; i < 2; i++) {
                    float ccx = cx + (i == 0 ? -(chipR + chipGap * 0.5f) : (chipR + chipGap * 0.5f));
                    ImVec2 cc(ccx, cy + chipR);
                    ImU32 col = contactTypeColor(chips[i].sh, light);
                    bdl->AddCircleFilled(cc, chipR, material::WithAlpha(col, light ? 45 : 60));
                    bdl->AddCircle(cc, chipR, material::WithAlpha(col, 190), 0, 1.6f * dp);
                    const char* L = chips[i].sh ? "Z" : "T";
                    ImVec2 ls = letF->CalcTextSizeA(letF->LegacySize, FLT_MAX, 0, L);
                    bdl->AddText(letF, letF->LegacySize, ImVec2(cc.x - ls.x * 0.5f, cc.y - ls.y * 0.5f), col, L);
                    ImVec2 lb = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, chips[i].lbl);
                    bdl->AddText(capF, capF->LegacySize,
                                 ImVec2(cc.x - lb.x * 0.5f, cy + chipR * 2.0f + Layout::spacingXs()),
                                 material::OnSurfaceMedium(), chips[i].lbl);
                }
                // Caption below the chips (wrapped, centered).
                ImGui::SetCursorScreenPos(ImVec2(origin.x + std::max(0.0f, (a.x - std::min(ms.x, wrapW)) * 0.5f),
                                                 cy + labelY + Layout::spacingMd()));
                ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + std::min(ms.x, wrapW));
                material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), msg);
                ImGui::PopTextWrapPos();
            } else if (s_edit_avatar_mode == 1) {
                // Icon grid: search on top, then a scrollable name-filtered grid.
                ImGui::SetNextItemWidth(-1);
                ImGui::InputTextWithHint("##avIconSearch", TR("portfolio_search_icons"),
                                         s_edit_icon_search, sizeof(s_edit_icon_search));
                std::string needle = toLower(s_edit_icon_search);
                std::vector<int> vis;
                for (int i = 0; i < material::project_icons::walletIconCount(); ++i) {
                    const char* nm = material::project_icons::walletIconName(i);
                    if (needle.empty() || toLower(nm).find(needle) != std::string::npos) vis.push_back(i);
                }
                ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
                ImGui::BeginChild("##avIconGrid", ImVec2(-1, ImGui::GetContentRegionAvail().y), false);
                ImDrawList* gdl = ImGui::GetWindowDrawList();
                ImFont* gIcoF = material::Type().iconXL();
                if (vis.empty())
                    material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), TR("no_icons_found"));
                const float cellGap = 6.0f * dp;
                const float availW = ImGui::GetContentRegionAvail().x;
                const int cols = std::max(4, (int)((availW + cellGap) / (54.0f * dp + cellGap)));
                const float cell = std::max(28.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
                int col = 0;
                for (int vi = 0; vi < (int)vis.size(); ++vi) {
                    int i = vis[vi];
                    const char* nm = material::project_icons::walletIconName(i);
                    if (col != 0) 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 sel = (s_edit_avatar == std::string("icon:") + nm);
                    if (sel) {
                        gdl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 40), 6.0f * dp);
                        gdl->AddRect(mn, mx, material::WithAlpha(material::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 ? material::Primary() : (hov ? material::OnSurface() : material::OnSurfaceMedium());
                    material::project_icons::drawByName(gdl, nm, cc, icol, gIcoF, cell * 0.5f);
                    ImGui::PushID(i);
                    ImGui::InvisibleButton("##avic", ImVec2(cell, cell));
                    if (ImGui::IsItemClicked()) s_edit_avatar = std::string("icon:") + nm;
                    if (hov) material::Tooltip("%s", nm);
                    ImGui::PopID();
                    col = (col + 1) % cols;
                }
                ImGui::EndChild();
                ImGui::PopStyleColor();
            } else {
                // Image: a grid over the avatar image LIBRARY. The first cell adds a new image (opens the
                // picker + copies it into the library); each library image is selectable and has a delete
                // badge to remove it. Images persist in <config>/contact-avatars/ so they're reusable and
                // travel with the wallet data — deleting a contact never removes them.
                if (s_avatarLibraryDirty) rescanAvatarLibrary();
                ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
                ImGui::BeginChild("##avImgGrid", ImVec2(-1, ImGui::GetContentRegionAvail().y), false);
                ImDrawList* gdl = ImGui::GetWindowDrawList();
                const float cellGap = 6.0f * dp;
                const float availW = ImGui::GetContentRegionAvail().x;
                const int cols = std::max(3, (int)((availW + cellGap) / (74.0f * dp + cellGap)));
                const float cell = std::max(48.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
                const int total = 1 + (int)s_avatarLibrary.size();
                int pendingDelete = -1;
                int col = 0;
                for (int n = 0; n < total; ++n) {
                    if (col != 0) 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);
                    if (n == 0) {
                        // "Add image" cell — always first.
                        bool hov = ImGui::IsMouseHoveringRect(mn, mx);
                        gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 30 : 14), 6.0f * dp);
                        gdl->AddRect(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 110 : 55), 6.0f * dp, 0, 1.5f * dp);
                        ImFont* gf = material::Type().iconXL();
                        float gsz = cell * 0.4f;
                        ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_ADD_PHOTO_ALTERNATE);
                        gdl->AddText(gf, gsz, ImVec2(cc.x - gs.x * 0.5f, cc.y - gs.y * 0.5f),
                                     hov ? material::Primary() : material::OnSurfaceMedium(), ICON_MD_ADD_PHOTO_ALTERNATE);
                        ImGui::PushID(0);
                        if (ImGui::InvisibleButton("##avadd", ImVec2(cell, cell))) {
                            ImagePicker::open("", [](const std::string& src) {
                                // Verify the source decodes before committing (guards corrupt/unsupported files).
                                int iw = 0, ih = 0;
                                unsigned char* px = util::LoadRawPixelsFromFile(src.c_str(), &iw, &ih);
                                if (!px) { Notifications::instance().error(TR("contact_avatar_bad_image")); return; }
                                util::FreeRawPixels(px);
                                std::string dst = copyAvatarImage(src);
                                if (!dst.empty()) {
                                    invalidateAvatarTexture(dst);
                                    s_edit_avatar = "img:" + dst; s_edit_avatar_mode = 2;
                                    s_avatarLibraryDirty = true;   // surface the new image in the grid
                                } else Notifications::instance().error(TR("contact_avatar_copy_failed"));
                            });
                        }
                        if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", TR("contact_avatar_choose")); }
                        ImGui::PopID();
                    } else {
                        const std::string& path = s_avatarLibrary[n - 1];
                        const bool sel = (s_edit_avatar == "img:" + path);
                        bool hov = ImGui::IsMouseHoveringRect(mn, mx);
                        gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 18), 6.0f * dp);
                        const AvatarTex* t = ImGui::IsRectVisible(mn, mx) ? getAvatarTexture(path) : nullptr;
                        ImTextureID ttex = currentAvatarFrame(t);
                        if (ttex) {
                            float u0=0,v0=0,u1=1,v1=1;   // centre-crop to a square
                            if (t->w > t->h) { float m=(t->w-t->h)*0.5f/t->w; u0=m; u1=1-m; }
                            else if (t->h > t->w) { float m=(t->h-t->w)*0.5f/t->h; v0=m; v1=1-m; }
                            float ins = 2.0f * dp;
                            gdl->AddImageRounded(ttex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
                                                 ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp);
                        } else {
                            ImFont* gf = material::Type().iconLarge();
                            ImVec2 gs = gf->CalcTextSizeA(cell * 0.34f, FLT_MAX, 0, ICON_MD_IMAGE);
                            gdl->AddText(gf, cell * 0.34f, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f),
                                         material::OnSurfaceDisabled(), ICON_MD_IMAGE);
                        }
                        if (sel)      gdl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 230), 6.0f * dp, 0, 2.5f * dp);
                        else if (hov) gdl->AddRect(mn, mx, material::WithAlpha(material::OnSurface(), 100), 6.0f * dp, 0, 1.5f * dp);
                        // The delete badge is hit-tested MANUALLY (not an ImGui item) so it never moves the
                        // layout cursor — an InvisibleButton here would leave CursorPosPrevLine at the badge
                        // corner and jitter the rest of the row's cells via the next SameLine.
                        float dr = std::max(7.0f * dp, cell * 0.15f);
                        ImVec2 dcc(mx.x - dr - 3.0f * dp, mn.y + dr + 3.0f * dp);
                        bool dhov = ImGui::IsMouseHoveringRect(ImVec2(dcc.x-dr, dcc.y-dr), ImVec2(dcc.x+dr, dcc.y+dr));
                        const int libIdx = n - 1;
                        const bool delArmed = (s_confirm_avatar_del_idx == libIdx);
                        ImGui::PushID(n);
                        bool thumbClicked = ImGui::InvisibleButton("##avthumb", ImVec2(cell, cell));
                        bool thumbHov = ImGui::IsItemHovered();
                        ImGui::PopID();
                        if (thumbHov || sel || hov || delArmed) {   // draw the delete badge (persist while armed)
                            gdl->AddCircleFilled(dcc, dr, (dhov || delArmed) ? material::ReadableError() : IM_COL32(0, 0, 0, 175));
                            // While armed, ring the badge so the "click again to delete" state is unmistakable.
                            if (delArmed) gdl->AddCircle(dcc, dr + 1.5f * dp, material::ReadableError(), 0, 1.5f * dp);
                            ImFont* xf = material::Type().iconSmall();
                            float xsz = dr * 1.35f;
                            ImVec2 xs = xf->CalcTextSizeA(xsz, FLT_MAX, 0, ICON_MD_CLOSE);
                            gdl->AddText(xf, xsz, ImVec2(dcc.x - xs.x*0.5f, dcc.y - xs.y*0.5f), IM_COL32(255,255,255,235), ICON_MD_CLOSE);
                        }
                        if (dhov) {                     // badge takes priority over selecting the thumbnail
                            ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                            // Two-stage confirm: the first click arms this badge; a second click on the SAME
                            // badge deletes the image file (fs::remove is irreversible — no undo).
                            material::Tooltip("%s", delArmed ? TR("address_book_confirm_delete") : TR("delete"));
                            if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
                                if (delArmed) { pendingDelete = libIdx; s_confirm_avatar_del_idx = -1; }
                                else            s_confirm_avatar_del_idx = libIdx;   // arm; requires a 2nd deliberate click
                            }
                        } else {
                            if (thumbHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                            // Any click that lands off this badge (selecting the thumbnail or elsewhere)
                            // disarms it, so a stale armed badge can't be confirmed by an unrelated click.
                            if (thumbClicked) { s_edit_avatar = "img:" + path; s_confirm_avatar_del_idx = -1; }
                        }
                    }
                    col = (col + 1) % cols;
                }
                ImGui::EndChild();
                ImGui::PopStyleColor();
                // Deferred: mutate the library only after the grid loop is done.
                if (pendingDelete >= 0 && pendingDelete < (int)s_avatarLibrary.size())
                    deleteAvatarLibraryFile(s_avatarLibrary[pendingDelete]);
            }
            ImGui::EndChild();  // ##contactAvatarCol

            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
            bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
            float actionW = std::max(110.0f * dp,
                btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("save")).x
                + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
            float actionGap = Layout::spacingSm();
            float totalActionsW = actionW * 2.0f + actionGap;
            material::BeginOverlayDialogFooter(totalActionsW, /*drawSeparator=*/false);

            if (!canSubmit) ImGui::BeginDisabled();

            // Accent the primary action (Save/Add) so it reads above Cancel.
            ImGui::PushStyleColor(ImGuiCol_Button,        ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
            ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
            ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
            const char* primaryLabel = isEdit ? TR("save") : TR("add");
            bool doSubmit = material::TactileButton(primaryLabel, ImVec2(actionW, 0), btnFont);
            ImGui::PopStyleColor(3);
            if (doSubmit) {
                // Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is.
                auto trimAB = [](std::string s) {
                    while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
                    while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
                    return s;
                };
                data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
                entry.avatar = s_edit_avatar;
                // Scope: global if the user asked; otherwise this wallet — but ONLY when we actually
                // know which wallet is loaded. While the identity is unknown (the switch / first-load
                // window) don't silently make a wallet-scoped contact global: preserve an edited
                // contact's existing scope, and refuse a NEW wallet-scoped one (tick global or wait).
                bool blocked = false;
                if (s_edit_global) {
                    entry.scope = "global";
                } else if (!activeHash.empty()) {
                    entry.scope = activeHash;
                } else if (isEdit && s_selected_index >= 0 && s_selected_index < (int)book.size()) {
                    entry.scope = book.entries()[s_selected_index].scope;   // keep; don't demote to global
                    if (entry.scope.empty()) entry.scope = "global";
                } else {
                    Notifications::instance().error(TR("contact_wallet_loading"));
                    blocked = true;   // don't create a wallet-scoped contact with an unknown wallet
                }
                if (blocked) {
                    // leave the dialog open so the user can tick global or retry once loaded
                } else if (isEdit) {
                    if (app->addressBook().updateEntry(s_selected_index, entry)) {
                        Notifications::instance().success(TR("address_book_updated"));
                        s_show_edit_dialog = false;
                    } else {
                        Notifications::instance().error(TR("address_book_update_failed"));
                    }
                } else {
                    if (app->addressBook().addEntry(entry)) {
                        Notifications::instance().success(TR("address_book_added"));
                        s_show_add_dialog = false;
                    } else {
                        Notifications::instance().error(TR("address_book_exists"));
                    }
                }
            }

            if (!canSubmit) ImGui::EndDisabled();

            ImGui::SameLine(0, actionGap);
            if (material::TactileButton(TR("cancel"), ImVec2(actionW, 0), btnFont)) {
                *open = false;
            }

            material::EndOverlayDialog();
        }
    };

    // Inline tab content lives in a scroll child (mirrors peers_tab / explorer_tab).
    ImVec2 avail = ImGui::GetContentRegionAvail();
    ImGui::BeginChild("##ContactsScroll", avail, false,
                      ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar |
                      ImGuiWindowFlags_NoScrollWithMouse);   // inner ##contactList owns the wheel (smooth scroll)

    // Toolbar — icon + label actions; the primary "Add New" is accented so it stands out.
    const float dp = ui::Layout::dpiScale();
    ImFont* tbTxtF = S.resolveFont(actionBtn.font);
    ImFont* tbIcoF = material::Type().iconSmall();
    // Renders a tactile button with a leading Material icon (icon font) + label (text font). ImGui has no
    // native two-font button, so draw the styled rect via TactileButton (empty ##id label) then paint the
    // icon + label over it. `primary` accents the main action; the icon/label inherit the (disabled-aware)
    // text alpha so they gray out correctly inside BeginDisabled().
    auto toolbarBtn = [&](const char* id, const char* glyph, const char* label, bool primary) -> bool {
        ImGui::PushFont(tbIcoF); const float iw = ImGui::CalcTextSize(glyph).x; ImGui::PopFont();
        ImGui::PushFont(tbTxtF); const float tw = ImGui::CalcTextSize(label).x; ImGui::PopFont();
        const float gap = 6.0f * dp;
        const float w = ImGui::GetStyle().FramePadding.x * 2.0f + iw + gap + tw;
        const float h = ImGui::GetFrameHeight();
        if (primary) {
            ImGui::PushStyleColor(ImGuiCol_Button,        ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
            ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
            ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
        }
        const ImVec2 p = ImGui::GetCursorScreenPos();
        const bool clicked = material::TactileButton(id, ImVec2(w, h), tbTxtF);
        if (primary) ImGui::PopStyleColor(3);
        ImDrawList* dl = ImGui::GetWindowDrawList();
        const ImU32 col = ImGui::GetColorU32(ImGuiCol_Text);
        const float sx = p.x + (w - (iw + gap + tw)) * 0.5f;
        dl->AddText(tbIcoF, tbIcoF->LegacySize, ImVec2(sx, p.y + (h - tbIcoF->LegacySize) * 0.5f), col, glyph);
        dl->AddText(tbTxtF, tbTxtF->LegacySize, ImVec2(sx + iw + gap, p.y + (h - tbTxtF->LegacySize) * 0.5f), col, label);
        return clicked;
    };

    if (toolbarBtn("##ab_add", ICON_MD_PERSON_ADD, TR("address_book_add_new"), /*primary=*/true)) {
        s_show_add_dialog = true;
        s_focus_edit_field = true;
        clearEditFields();
    }

    ImGui::SameLine();

    if (!has_selection) ImGui::BeginDisabled();

    if (toolbarBtn("##ab_edit", ICON_MD_EDIT, TR("edit"), false))
        openEdit();

    ImGui::SameLine();

    // Delete — relabel to a visible confirm prompt while armed (not just a toast).
    bool armed = has_selection && s_confirm_delete_idx == s_selected_index;
    const char* delLabel = armed ? TR("address_book_confirm_delete") : TR("delete");
    if (toolbarBtn("##ab_del", ICON_MD_DELETE, delLabel, false))
        doDelete();

    ImGui::SameLine();

    if (toolbarBtn("##ab_copy", ICON_MD_CONTENT_COPY, TR("copy_address"), false))
        doCopy();

    if (!has_selection) ImGui::EndDisabled();

    // Address-list view toggle (Cards / List / Table) — right-aligned on the toolbar row; persisted.
    int viewMode = app->settings() ? app->settings()->getContactsViewMode() : 0;
    {
        const char* segIcons[3] = { ICON_MD_VIEW_AGENDA, ICON_MD_VIEW_LIST, ICON_MD_TABLE_ROWS };
        const float segH = ImGui::GetFrameHeight();
        const float segW = 44.0f * dp * 3.0f;
        const float segGap = 6.0f * dp;
        const float grpW = segW + segGap + segH;   // view toggle + gap + a square settings gear
        ImGui::SameLine();
        ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - grpW));
        ImVec2 segOrigin = ImGui::GetCursorScreenPos();
        int segClk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segW, segH,
                                                segIcons, 3, viewMode, material::Type().iconSmall(),
                                                "##contactsView", dp);
        if (segClk >= 0 && app->settings()) {
            app->settings()->setContactsViewMode(segClk);
            app->settings()->save();
            viewMode = segClk;
        }
        // Contacts-customization gear, immediately right of the view toggle.
        ImGui::SetCursorScreenPos(ImVec2(segOrigin.x + segW + segGap, segOrigin.y));
        material::IconButtonStyle gst;
        gst.tooltip    = TR("contacts_settings_tip");
        gst.hoverBg    = material::WithAlpha(material::OnSurface(), 30);
        gst.restBg     = material::WithAlpha(material::OnSurface(), 20);
        gst.bgRounding = 7.0f * dp;
        if (material::IconButton("##contactsSettings", ICON_MD_SETTINGS, material::Type().iconSmall(),
                                 ImVec2(segH, segH), gst))
            s_show_contacts_settings = true;
        ImGui::SetCursorScreenPos(segOrigin);
        ImGui::Dummy(ImVec2(grpW, segH));
    }

    // Search / filter (tight against the toolbar row above — no extra spacer)
    ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x, 700.0f * dp));
    ImGui::InputTextWithHint("##ContactSearch", TR("contacts_search_placeholder"),
                             s_search, sizeof(s_search));
    bool searchActive = ImGui::IsItemActive();
    std::string needle = toLower(s_search);

    ImGui::Spacing();
    ImGui::Separator();
    ImGui::Spacing();

    // Filtered, sorted view — storage indices only (keeps s_selected_index meaningful).
    std::vector<size_t> visibleRows;
    visibleRows.reserve(book.size());
    for (size_t i = 0; i < book.size(); ++i) {
        const auto& e = book.entries()[i];
        if (!matchesSearch(e, needle)) continue;
        // Scope filter: show global + this wallet's contacts. A legacy (non-"w:") scope belongs to a
        // wallet whose old address-hash identity has drifted and can't be reattributed here (multi-wallet
        // case where recovery didn't run) — fail OPEN so those contacts are never hidden. Stable "w:"
        // scopes match strictly. When no wallet is active, show global + legacy only.
        bool visible;
        if (e.isGlobal())                       visible = true;
        else if (e.scope.rfind("w:", 0) == 0)   visible = !activeHash.empty() && e.scope == activeHash;
        else                                    visible = true;   // legacy scope → fail open (recovery)
        if (!visible) continue;
        visibleRows.push_back(i);
    }

    // Address list — fill the tab, leaving room for the count footer.
    const float footerH = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y;
    float listH = ImGui::GetContentRegionAvail().y - footerH;
    if (listH < 120.0f * dp) listH = 120.0f * dp;

    const bool lightTheme = material::IsLightTheme();
    auto typeColor = [&](bool shielded) -> ImU32 { return contactTypeColor(shielded, lightTheme); };
    // Centered Material empty state (no contacts yet / no search match).
    auto emptyState = [&](const char* iconGlyph, const char* msgKey) {
        const char* msg = TR(msgKey);
        ImFont* icoF = material::Type().iconLarge();
        ImFont* txtF = material::Type().body2();
        const float availW = ImGui::GetContentRegionAvail().x;
        ImVec2 is = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, iconGlyph);
        ImVec2 ts = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, 0, msg);
        ImGui::SetCursorPos(ImVec2((availW - is.x) * 0.5f, ImGui::GetCursorPosY() + listH * 0.32f));
        ImGui::PushFont(icoF);
        ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceDisabled()), "%s", iconGlyph);
        ImGui::PopFont();
        ImGui::SetCursorPosX((availW - ts.x) * 0.5f);
        material::Type().textColored(material::TypeStyle::Body2, material::OnSurfaceMedium(), msg);
    };

    // Right-click a row (any view) -> select it and open the shared context menu (rendered after the
    // list). A left-click on empty list space clears the selection (handled in the Cards/List child).
    bool openContextMenu = false;

    if (viewMode == 2) {
        // ── TABLE mode (material-ized): no outer/grid borders, row backgrounds, interactive sort. ──
        // Frosted-glass panel behind the table so it blurs the backdrop like the card/list view (the
        // table's translucent row backgrounds let the blur show through). A BeginTable can't take
        // AlwaysUseWindowPadding, so inset the table itself inside the glass so its headers/cells
        // don't hug the container edges.
        ImDrawList* tpdl   = ImGui::GetWindowDrawList();
        const ImVec2 tpMin = ImGui::GetCursorScreenPos();
        const float  tpW   = ImGui::GetContentRegionAvail().x;
        const float  tIpad = 12.0f * dp, tVpad = 10.0f * dp;
        {
            material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
            material::DrawGlassPanel(tpdl, tpMin, ImVec2(tpMin.x + tpW, tpMin.y + listH), g);
        }
        ImGui::SetCursorScreenPos(ImVec2(tpMin.x + tIpad, tpMin.y + tVpad));
        if (ImGui::BeginTable("AddressBookTable", 3,
            ImGuiTableFlags_RowBg | ImGuiTableFlags_Sortable |
            ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY,
            ImVec2(tpW - 2.0f * tIpad, listH - 2.0f * tVpad)))
        {
            ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_DefaultSort, 1.5f);
            ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch, 2.6f);
            ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthStretch, 1.0f);
            ImGui::TableSetupScrollFreeze(0, 1);
            ImGui::TableHeadersRow();
            if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) {
                if (specs->SpecsCount > 0) {
                    const ImGuiTableColumnSortSpecs& sc = specs->Specs[0];
                    bool asc = sc.SortDirection != ImGuiSortDirection_Descending;
                    const auto& entries = book.entries();
                    std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) {
                        const std::string* ka; const std::string* kb;
                        switch (sc.ColumnIndex) {
                            case 1:  ka = &entries[a].address; kb = &entries[b].address; break;
                            case 2:  ka = &entries[a].notes;   kb = &entries[b].notes;   break;
                            default: ka = &entries[a].label;   kb = &entries[b].label;   break;
                        }
                        int cmp = toLower(*ka).compare(toLower(*kb));
                        return asc ? (cmp < 0) : (cmp > 0);
                    });
                }
            }
            for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
                size_t i = visibleRows[vi];
                const auto& entry = book.entries()[i];
                ImGui::TableNextRow();
                ImGui::PushID(static_cast<int>(i));
                const bool shielded = isShieldedAddr(entry.address);
                ImGui::TableNextColumn();
                bool is_selected = (s_selected_index == static_cast<int>(i));
                // Small avatar before the label. SpanAllColumns keeps the whole row clickable; the avatar
                // is drawn AFTER the Selectable so its selection/hover fill doesn't paint over it.
                const float tLineH = ImGui::GetTextLineHeight();
                const float tAvR = tLineH * 0.5f;
                const ImVec2 tAvP = ImGui::GetCursorScreenPos();
                ImGui::Dummy(ImVec2(tAvR * 2.0f, tLineH));
                ImGui::SameLine(0.0f, 6.0f * dp);
                if (ImGui::Selectable(entry.label.c_str(), is_selected,
                                      ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) {
                    if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1;
                    s_selected_index = static_cast<int>(i);
                    if (ImGui::IsMouseDoubleClicked(0)) openEdit();
                }
                if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
                    s_selected_index = static_cast<int>(i); s_confirm_delete_idx = -1; openContextMenu = true;
                }
                bool tAvOnScreen = ImGui::IsRectVisible(tAvP, ImVec2(tAvP.x + tAvR * 2.0f, tAvP.y + tLineH));
                // The table is a compact inline view: a full-height "tab" (shape 2) can't render inside a
                // table cell, so it falls back to the rounded square there (table scale is fixed too).
                const int tShape = (s_contactsShape == 2) ? 1 : s_contactsShape;
                drawContactAvatar(ImGui::GetWindowDrawList(), ImVec2(tAvP.x + tAvR, tAvP.y + tLineH * 0.5f),
                                  tAvR, entry, shielded, typeColor(shielded), lightTheme, dp,
                                  material::Type().caption(), material::Type().iconSmall(), tAvOnScreen, tShape);
                ImGui::TableNextColumn();
                ImGui::PushFont(material::Type().subtitle2());
                ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T");
                ImGui::PopFont();
                ImGui::SameLine(0.0f, 6.0f * dp);
                std::string addr_display = util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
                ImGui::TextUnformatted(addr_display.c_str());
                if (ImGui::IsItemHovered()) material::Tooltip("%s", entry.address.c_str());
                if (entry.isGlobal()) {
                    ImGui::SameLine(0.0f, 8.0f * dp);
                    ImGui::PushFont(material::Type().iconSmall());
                    ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), "%s", ICON_MD_PUBLIC);
                    ImGui::PopFont();
                    if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_badge_tt"));
                }
                ImGui::TableNextColumn();
                ImGui::TextUnformatted(entry.notes.c_str());
                ImGui::PopID();
            }
            ImGui::EndTable();
        }
        // Land the cursor at the glass-panel bottom (tpMin.y + listH) so the count footer lines up
        // with the Cards/List views. The table is inset by tVpad and its outer_size is listH-2*tVpad,
        // so it would otherwise end tVpad higher and pull the footer up.
        ImGui::SetCursorScreenPos(ImVec2(tpMin.x, tpMin.y + listH));
    } else {
        // ── CARDS (0) / LIST (1) mode — tactile Material items, no grid lines. ──
        const bool asCard = (viewMode == 0);
        // Cards/list have no interactive sort header — sort by label ascending.
        {
            const auto& entries = book.entries();
            std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) {
                return toLower(entries[a].label).compare(toLower(entries[b].label)) < 0;
            });
        }
        // Frosted-glass list container so the contacts area blurs the backdrop (peers_tab pattern:
        // glass behind a NoBackground list child) instead of showing the raw texture through the pane.
        {
            ImDrawList* pdl = ImGui::GetWindowDrawList();
            const ImVec2 pMin = ImGui::GetCursorScreenPos();
            const float  pW = ImGui::GetContentRegionAvail().x;
            material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
            material::DrawGlassPanel(pdl, pMin, ImVec2(pMin.x + pW, pMin.y + listH), g);
        }
        // AlwaysUseWindowPadding: a borderless child ignores WindowPadding without it, so the rows
        // would hug the frosted container's edges.
        ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12.0f * dp, 10.0f * dp));
        ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0,0,0,0));
        ImGui::BeginChild("##contactList", ImVec2(0, listH), ImGuiChildFlags_AlwaysUseWindowPadding,
                          ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
        material::ApplySmoothScroll();   // wheel-lerp scroll, matching the rest of the app
        if (visibleRows.empty()) {
            emptyState(ICON_MD_CONTACTS, book.empty() ? "address_book_empty" : "contacts_search_no_match");
        } else {
            ImGui::PushStyleColor(ImGuiCol_Header,        ImVec4(0,0,0,0));
            ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0,0,0,0));
            ImGui::PushStyleColor(ImGuiCol_HeaderActive,  ImVec4(0,0,0,0));
            ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, asCard ? 8.0f * dp : 0.0f));
            ImDrawList* dl = ImGui::GetWindowDrawList();
            ImFont* lblF = material::Type().subtitle2();
            ImFont* adrF = material::Type().caption();
            ImFont* icoF = material::Type().iconSmall();
            const float sc  = s_contactsScale;   // card/list scale (avatar + text + row height; not the table)
            const float pad = 12.0f * dp;
            const float avR = 15.0f * dp * sc;
            const float rowH = avR * 2.0f + (asCard ? 16.0f * dp : 12.0f * dp);
            const float round = 10.0f * dp;
            const float lblSz = lblF->LegacySize * sc;   // scaled label + address text
            const float adrSz = adrF->LegacySize * sc;
            bool rowDeleteRequested = false;   // per-row delete is deferred until after the loop
            for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
                size_t i = visibleRows[vi];
                const auto& entry = book.entries()[i];
                ImGui::PushID(static_cast<int>(i));
                const bool selected = (s_selected_index == static_cast<int>(i));
                const ImVec2 p = ImGui::GetCursorScreenPos();
                const float w = ImGui::GetContentRegionAvail().x;
                ImGui::SetNextItemAllowOverlap();   // let the per-row action buttons overlap the row
                if (ImGui::Selectable("##it", selected,
                                      ImGuiSelectableFlags_SpanAvailWidth | ImGuiSelectableFlags_AllowDoubleClick,
                                      ImVec2(0, rowH))) {
                    if (!selected) s_confirm_delete_idx = -1;
                    s_selected_index = static_cast<int>(i);
                    if (ImGui::IsMouseDoubleClicked(0)) openEdit();
                }
                const bool selHov = ImGui::IsItemHovered();
                if (selHov && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
                    s_selected_index = static_cast<int>(i); s_confirm_delete_idx = -1; openContextMenu = true;
                }
                const ImVec2 afterRow = ImGui::GetCursorScreenPos();   // restore this for the next row
                const ImVec2 mn = p, mx(p.x + w, p.y + rowH);
                const bool rowHovered = ImGui::IsMouseHoveringRect(mn, mx);   // whole-row hover (survives action-icon hover)
                if (rowHovered && !selected) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                if (asCard) {
                    ImU32 fill = selected    ? material::WithAlpha(material::Primary(), 42)
                               : rowHovered  ? material::WithAlpha(material::OnSurface(), 26)
                                             : material::WithAlpha(material::OnSurface(), 12);
                    dl->AddRectFilled(mn, mx, fill, round);
                    if (selected) dl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 150), round, 0, 1.6f * dp);
                } else {
                    if (selected)        dl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 34), 0);
                    else if (rowHovered) dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 16), 0);
                    dl->AddLine(ImVec2(mn.x + pad, mx.y - 0.5f), ImVec2(mx.x - pad, mx.y - 0.5f),
                                material::WithAlpha(material::OnSurface(), 24), 1.0f);
                }
                // Avatar: custom image (circular) / Material icon / default Z/T type badge.
                const bool shielded = isShieldedAddr(entry.address);
                const ImU32 tu = typeColor(shielded);
                const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f);
                // Only animate when the row is actually on-screen, so a scrolled-off animated avatar
                // doesn't hold the app awake (this loop has no clipper).
                const bool rowOnScreen = ImGui::IsRectVisible(mn, mx);
                // Shape 2 = a full-row-height left tab (a square the height of the row, rounded-left to
                // match the row corner); shapes 0/1 are inset badges. Text shifts to clear the tab.
                const float tabW = rowH;
                drawContactAvatar(dl, avC, avR, entry, shielded, tu, lightTheme, dp, lblF, icoF, rowOnScreen,
                                  s_contactsShape, mn, ImVec2(mn.x + tabW, mx.y), asCard ? round : 0.0f);
                // Label (line 1) + muted address (line 2) — reserve trailing room for the globe + actions.
                const float actHit = icoF->LegacySize + 8.0f * dp;   // per-action square hit area
                const float globeW = entry.isGlobal() ? (icoF->LegacySize + 8.0f * dp) : 0.0f;
                const float trailW = 3.0f * actHit + 6.0f * dp + globeW;
                const float cy = mn.y + rowH * 0.5f;
                const float tx = (s_contactsShape == 2) ? (mn.x + tabW + pad)
                                                        : (mn.x + pad + avR * 2.0f + pad);
                const float textMaxX = mx.x - pad - trailW;
                const float blockH = lblSz + adrSz + 3.0f * dp;
                const float ty = cy - blockH * 0.5f;
                dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true);
                dl->AddText(lblF, lblSz, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str());
                dl->PopClipRect();
                // Un-collapse to the full address on hover (clipped to the text column so it never
                // runs under the trailing actions); otherwise middle-truncated to FIT the actual text
                // column width (tx..textMaxX) rather than a fixed char count — wide rows show more of
                // the address instead of leaving a dead gap before the action-icon cluster.
                std::string addr = rowHovered
                    ? entry.address
                    : truncateAddressToWidth(entry.address, adrF, adrSz, textMaxX - tx,
                                             addrFrontLbl.truncate, addrBackLbl.truncate);
                dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true);
                dl->AddText(adrF, adrSz, ImVec2(tx, ty + lblSz + 3.0f * dp),
                            material::OnSurfaceMedium(), addr.c_str());
                dl->PopClipRect();
                // Trailing: the globe badge stays pinned far-right (global contacts); per-row copy/edit/
                // delete actions appear to its LEFT on hover/selection.
                float rightX = mx.x - pad;
                if (entry.isGlobal()) {
                    const ImVec2 gs = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, ICON_MD_PUBLIC);
                    dl->AddText(icoF, icoF->LegacySize, ImVec2(rightX - gs.x, cy - gs.y * 0.5f),
                                material::OnSurfaceMedium(), ICON_MD_PUBLIC);
                    rightX -= gs.x + 8.0f * dp;
                }
                if (rowHovered || selected) {
                    const char* actGlyph[3] = { ICON_MD_CONTENT_COPY, ICON_MD_EDIT, ICON_MD_DELETE };
                    const char* actTip[3]   = { TR("copy_address"), TR("edit"), TR("delete") };
                    float ax = rightX - actHit;   // rightmost action (delete), just left of the globe
                    for (int a = 2; a >= 0; --a) {
                        ImGui::SetCursorScreenPos(ImVec2(ax, cy - actHit * 0.5f));
                        ImGui::PushID(a + 100);
                        const bool aClk = ImGui::InvisibleButton("##act", ImVec2(actHit, actHit));
                        const bool aHov = ImGui::IsItemHovered();
                        if (aHov) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", actTip[a]); }
                        if (aClk) {
                            s_selected_index = static_cast<int>(i);
                            if (a == 0)      doCopy();
                            else if (a == 1) openEdit();
                            // Delete mutates book.entries() (removeEntry -> erase) — deferring it past the
                            // row loop avoids iterating a vector we shrink mid-loop (OOB on later rows).
                            else             rowDeleteRequested = true;
                        }
                        const bool armedDel = (a == 2 && s_confirm_delete_idx == static_cast<int>(i));
                        const ImU32 acol = armedDel ? material::ReadableError()
                                         : aHov     ? material::OnSurface()
                                                    : material::OnSurfaceMedium();
                        const ImVec2 gsz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, actGlyph[a]);
                        dl->AddText(icoF, icoF->LegacySize,
                                    ImVec2(ax + (actHit - gsz.x) * 0.5f, cy - gsz.y * 0.5f),
                                    acol, actGlyph[a]);
                        ImGui::PopID();
                        ax -= actHit + 2.0f * dp;
                    }
                }
                ImGui::SetCursorScreenPos(afterRow);   // undo the action buttons' cursor moves
                ImGui::PopID();
            }
            // Run the deferred per-row delete now that the loop over book.entries() has finished
            // (doDelete's two-stage confirm arms on the first click and removes on the second).
            if (rowDeleteRequested) doDelete();
            // The per-row SetCursorScreenPos leaves the cursor at the last row's bottom with no item
            // submitted there; commit it so ImGui sizes the scroll child (avoids the extend-boundary warning).
            ImGui::Dummy(ImVec2(0.0f, 0.0f));
            ImGui::PopStyleVar();
            ImGui::PopStyleColor(3);
        }
        // A left-click on empty list space (no row/action hovered) clears the selection.
        if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !ImGui::IsAnyItemHovered()) {
            s_selected_index = -1;
            s_confirm_delete_idx = -1;
        }
        ImGui::EndChild();
        ImGui::PopStyleColor();
        ImGui::PopStyleVar();   // ##contactList inner WindowPadding
    }

    // Shared right-click context menu (opened by either view's row right-click; acts on the selection).
    if (openContextMenu) ImGui::OpenPopup("##contactCtx");
    if (ImGui::BeginPopup("##contactCtx")) {
        if (ImGui::MenuItem(TR("copy_address"))) doCopy();
        if (ImGui::MenuItem(TR("edit")))         openEdit();
        ImGui::Separator();
        if (ImGui::MenuItem(TR("delete")))       doDelete();
        ImGui::EndPopup();
    }

    // Status line — singular form for exactly one contact ("1 address saved", not "1 addresses saved").
    ImGui::TextDisabled(TR(book.size() == 1 ? "address_book_count_one" : "address_book_count"), book.size());

    // Keyboard shortcuts — only when the tab owns focus (no field being edited, no modal up).
    if (!searchActive && !ImGui::IsAnyItemActive() &&
        !s_show_add_dialog && !s_show_edit_dialog && !visibleRows.empty()) {
        // Current selection's position within the visible view.
        int curPos = -1;
        for (size_t k = 0; k < visibleRows.size(); ++k)
            if (static_cast<int>(visibleRows[k]) == s_selected_index) { curPos = static_cast<int>(k); break; }

        if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
            curPos = (curPos < 0) ? 0 : std::min(curPos + 1, static_cast<int>(visibleRows.size()) - 1);
            s_selected_index = static_cast<int>(visibleRows[curPos]);
            s_confirm_delete_idx = -1;
        } else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
            curPos = (curPos < 0) ? 0 : std::max(curPos - 1, 0);
            s_selected_index = static_cast<int>(visibleRows[curPos]);
            s_confirm_delete_idx = -1;
        } else if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) {
            openEdit();
        } else if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) {
            doDelete();
        } else if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C, false)) {
            doCopy();
        }
    }

    ImGui::EndChild();

    renderEntryDialog();

    // ---- Contacts customization modal (opened by the toolbar gear) — house BlurFloat overlay ----
    if (s_show_contacts_settings && app->settings()) {
        auto* st = app->settings();
        material::OverlayDialogSpec ov;
        ov.title = TR("contacts_settings_title");
        ov.p_open = &s_show_contacts_settings;
        ov.style = material::OverlayStyle::BlurFloat;
        ov.cardWidth = 460.0f; ov.idSuffix = "contactsettings";
        if (material::BeginOverlayDialog(ov)) {
            const float ctrlW = 210.0f * dp;
            auto rowStart = [&](const char* label) {
                ImGui::AlignTextToFramePadding();
                ImGui::TextUnformatted(label);
                ImGui::SameLine();
                ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - ctrlW));
                ImGui::SetNextItemWidth(ctrlW);
            };
            // Draw one sample contact card (avatar + label + muted address) at a given shape/scale, using
            // the SAME drawContactAvatar + row geometry the real list uses — so the preview matches reality.
            auto drawSampleRow = [&](ImDrawList* pdl, ImVec2 mn, float w,
                                     const data::AddressBookEntry& e, int shape, float sc) {
                ImFont* lblF = material::Type().subtitle2();
                ImFont* adrF = material::Type().caption();
                ImFont* icoF = material::Type().iconSmall();
                const float pad   = 12.0f * dp;
                const float avR   = 15.0f * dp * sc;
                const float rowH  = avR * 2.0f + 16.0f * dp;
                const float round = 10.0f * dp;
                const float lblSz = lblF->LegacySize * sc;
                const float adrSz = adrF->LegacySize * sc;
                const ImVec2 mx(mn.x + w, mn.y + rowH);
                pdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 18), round);
                const bool shielded = isShieldedAddr(e.address);
                const ImU32 tu = contactTypeColor(shielded, lightTheme);
                const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f);
                const float tabW = rowH;   // shape 2 = full-row-height square tab on the left
                drawContactAvatar(pdl, avC, avR, e, shielded, tu, lightTheme, dp, lblF, icoF, true,
                                  shape, mn, ImVec2(mn.x + tabW, mx.y), round);
                const float tx = (shape == 2) ? (mn.x + tabW + pad) : (mn.x + pad + avR * 2.0f + pad);
                const float blockH = lblSz + adrSz + 3.0f * dp;
                const float ty = mn.y + rowH * 0.5f - blockH * 0.5f;
                const std::string addr = util::truncateMiddle(e.address, addrFrontLbl.truncate, addrBackLbl.truncate);
                pdl->PushClipRect(ImVec2(tx, mn.y), ImVec2(mx.x - pad, mx.y), true);
                pdl->AddText(lblF, lblSz, ImVec2(tx, ty), material::OnSurface(), e.label.c_str());
                pdl->AddText(adrF, adrSz, ImVec2(tx, ty + lblSz + 3.0f * dp), material::OnSurfaceMedium(), addr.c_str());
                pdl->PopClipRect();
            };
            // ---- Live preview: two sample cards at the current shape + scale (updates as controls move). ----
            {
                const float sc    = st->getContactsListScale();
                const int   shape = st->getContactsAvatarShape();
                data::AddressBookEntry sA; sA.label = "Ava Reyes"; sA.address = "zs1preview0shielded0contact0demo0address0q9k";
                data::AddressBookEntry sB; sB.label = "Node Ops";  sB.address = "t1PreviewTransparentContactDemoAddr8xQ";
                ImDrawList* pdl  = ImGui::GetWindowDrawList();
                const float padIn = 10.0f * dp, gap = 8.0f * dp;
                const float w     = ImGui::GetContentRegionAvail().x;
                const float avR   = 15.0f * dp * sc;
                const float rowH  = avR * 2.0f + 16.0f * dp;
                const float panelH = 2.0f * rowH + gap + 2.0f * padIn;
                const ImVec2 pOrigin = ImGui::GetCursorScreenPos();
                material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
                material::DrawGlassPanel(pdl, pOrigin, ImVec2(pOrigin.x + w, pOrigin.y + panelH), g);
                const ImVec2 rmn(pOrigin.x + padIn, pOrigin.y + padIn);
                const float rowW = w - 2.0f * padIn;
                drawSampleRow(pdl, rmn, rowW, sA, shape, sc);
                drawSampleRow(pdl, ImVec2(rmn.x, rmn.y + rowH + gap), rowW, sB, shape, sc);
                ImGui::Dummy(ImVec2(w, panelH));
                ImGui::Dummy(ImVec2(0.0f, 10.0f * dp));
            }
            // Avatar shape — circle, rounded square, or a full-row-height left tab (segmented control,
            // matching the chat settings modal's segmented style; the live preview above reflects it).
            {
                rowStart(TR("contacts_avatar_shape"));
                const char* items[] = { TR("contacts_shape_circle"), TR("contacts_shape_square"),
                                        TR("contacts_shape_tab") };
                const float segH = ImGui::GetFrameHeight();
                const ImVec2 segOrigin = ImGui::GetCursorScreenPos();
                const int cur = st->getContactsAvatarShape();
                const int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, ctrlW, segH,
                                                           items, 3, cur, material::Type().caption(),
                                                           "##contactsShape", dp);
                ImGui::SetCursorScreenPos(segOrigin);
                ImGui::Dummy(ImVec2(ctrlW, segH));   // restore + reserve the control's rect for layout flow
                if (clk >= 0 && clk != cur) { st->setContactsAvatarShape(clk); st->save(); }
            }
            // Card/list row scale (does not affect the table view).
            {
                ImGui::Dummy(ImVec2(0.0f, 4.0f * dp));
                rowStart(TR("contacts_list_scale"));
                float v = st->getContactsListScale();
                if (ImGui::SliderFloat("##contactsScale", &v, 0.8f, 1.5f, "%.2fx", ImGuiSliderFlags_AlwaysClamp)) {
                    st->setContactsListScale(v); st->save();
                }
            }
            ImGui::Dummy(ImVec2(0.0f, 8.0f * dp));
            {
                // Done: sized to its text (+ a little shoulder room) and centered, not full width.
                const char* doneLbl = TR("chat_settings_done");
                ImGui::PushFont(material::Type().button());
                const float bw = ImGui::CalcTextSize(doneLbl).x + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp;
                ImGui::PopFont();
                ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - bw) * 0.5f));
                ImGui::PushStyleColor(ImGuiCol_Button,        ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
                ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
                ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
                if (material::TactileButton(doneLbl, ImVec2(bw, 0)))
                    s_show_contacts_settings = false;
                ImGui::PopStyleColor(3);
            }
            material::EndOverlayDialog();
        }
    }
}

void ContactsSweepOpenEditDialog(int avatarMode)
{
    s_selected_index = 0;   // seedSweepContacts seeds entry 0 as a valid target
    copyEditField(s_edit_label, sizeof(s_edit_label), "drgx pool payout address");
    copyEditField(s_edit_address, sizeof(s_edit_address),
                  "zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000");
    copyEditField(s_edit_notes, sizeof(s_edit_notes), "mining pool payouts");
    s_edit_global = true;
    s_edit_avatar_mode = (avatarMode < 0 || avatarMode > 2) ? 0 : avatarMode;
    s_edit_avatar = (s_edit_avatar_mode == 1) ? "icon:account_balance" : "";
    s_edit_icon_search[0] = '\0';
    s_show_add_dialog = false;
    s_show_edit_dialog = true;
    s_focus_edit_field = false;
}

void ContactsSweepCloseDialog()
{
    s_show_edit_dialog = false;
    s_show_add_dialog = false;
    ImagePicker::close();
}

} // namespace ui
} // namespace dragonx
