// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// app.cpp — Main application: init, shutdown, ImGui render loop, NavPage
// dispatch, dialog rendering, and frame-level state management.

#include "app.h"
#include "config/version.h"
#include "rpc/rpc_client.h"
#include "rpc/rpc_worker.h"
#include "rpc/connection.h"
#include "config/settings.h"
#include "data/seed_migration_resume.h"
#include "wallet/lite_wallet_controller.h"
#include "wallet/lite_wallet_server_selection_adapter.h"
#include "wallet/lite_rollout_policy.h"
#include "wallet/lite_diagnostics.h"

#include <cstdlib>  // std::getenv for the lite kill-switch env var
#include <sodium.h>  // sodium_memzero for the lite unlock-passphrase buffer
#include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
#include "daemon/lifecycle_adapters.h"
#include "daemon/xmrig_manager.h"
#include "util/pool_registry.h"
#include "ui/windows/main_window.h"
#include "ui/windows/balance_tab.h"
#include "ui/windows/send_tab.h"
#include "ui/windows/receive_tab.h"
#include "ui/windows/transactions_tab.h"
#include "ui/windows/mining_tab.h"
#include "ui/windows/peers_tab.h"
#include "ui/windows/network_tab.h"
#include "ui/windows/console_command_executor.h"
#include "ui/windows/explorer_tab.h"
#include "ui/windows/seed_display.h"
#include "ui/windows/market_tab.h"
#include "ui/windows/settings_window.h"
#include "ui/windows/about_dialog.h"
#include "ui/windows/faq_dialog.h"
#include "embedded/IconsMaterialDesign.h"
#include "ui/windows/key_export_dialog.h"
#include "ui/windows/transaction_details_dialog.h"
#include "ui/windows/qr_popup_dialog.h"
#include "ui/windows/validate_address_dialog.h"
#include "ui/windows/contacts_tab.h"
#include "ui/windows/chat_tab.h"
#include "ui/windows/shield_dialog.h"
#include "ui/windows/request_payment_dialog.h"
#include "ui/windows/block_info_dialog.h"
#include "ui/windows/export_all_keys_dialog.h"
#include "ui/windows/export_transactions_dialog.h"
#include "ui/windows/address_label_dialog.h"
#include "ui/windows/address_transfer_dialog.h"
#include "ui/windows/bootstrap_download_dialog.h"
#include "ui/windows/xmrig_download_dialog.h"
#include "ui/windows/daemon_download_dialog.h"
#include "ui/windows/wallets_dialog.h"
#include "ui/windows/console_tab.h"
#include "ui/pages/settings_page.h"
#include "ui/theme.h"
#include "ui/sidebar.h"
#include "ui/effects/imgui_acrylic.h"
#include "ui/effects/theme_effects.h"
#include "ui/effects/low_spec.h"
#include "ui/material/color_theme.h"
#include "ui/material/type.h"
#include "ui/material/typography.h"
#include "ui/material/draw_helpers.h"
#include "ui/widgets/copy_field.h"
#include "ui/notifications.h"
#include "ui/node_status_banner.h"
#include "util/i18n.h"
#include "util/connect_stall.h"
#include "util/platform.h"
#include "util/text_format.h"
#include "util/payment_uri.h"
#include "util/seed_phrase.h"
#include "util/texture_loader.h"
#include "util/svg_texture.h"
#include "ui/material/colors.h"
#include "logo_dragonx_svg.h"
#include "util/bootstrap.h"
#include "util/secure_vault.h"
#include "resources/embedded_resources.h"
#include "ui/schema/ui_schema.h"
#include "ui/schema/skin_manager.h"
#include "util/perf_log.h"

// Embedded ui.toml (generated at build time) — fallback when file not on disk
#if __has_include("ui_toml_embedded.h")
#include "ui_toml_embedded.h"
#define HAS_EMBEDDED_UI_TOML 1
#else
#define HAS_EMBEDDED_UI_TOML 0
#endif

#include "imgui.h"
#include <nlohmann/json.hpp>
#include <curl/curl.h>

#include <cstdio>
#include <ctime>
#include <algorithm>
#include <random>
#include <map>
#include <set>
#include <unordered_set>
#include <fstream>
#include <filesystem>
#include <sstream>
#include <thread>
#include <chrono>
#include <exception>

#ifdef _WIN32
#include <windows.h>
#include <shellapi.h>
#include "util/logger.h"
#endif

namespace dragonx {

using json = nlohmann::json;

App::App()
{
    // Seed the auto-balance RNG once per run so weighted-random pool selection isn't
    // deterministic across launches.
    balance_rng_.seed(std::random_device{}());
    // Purge any plaintext key export left behind by a crashed/interrupted decrypt flow. (H-02)
    sweepStaleDecryptExports();
}
App::~App()
{
    wipeSecrets();
}

// Scrub every resident secret buffer. Idempotent + safe to call from the forced-exit path (main.cpp
// _Exit bypasses destructors), so key/seed material isn't left in freed heap on the real quit path. (L-05)
void App::wipeSecrets()
{
    if (!seed_migration_seed_.empty())
        sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
    if (!seed_backup_phrase_.empty())
        sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
    sodium_memzero(export_result_, sizeof(export_result_));      // exported WIF/z-key (SECRET)
    sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
}

// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
// never blocks on stop()'s SIGTERM->SIGKILL->join, while start/stop still execute in FIFO order. (M-03/…)
void App::postMiningControl(std::function<void()> job)
{
    {
        std::lock_guard<std::mutex> lk(mining_ctl_mutex_);
        if (mining_ctl_stop_) return;   // shutting down — don't enqueue new mining ops
        if (!mining_ctl_thread_.joinable()) {
            mining_ctl_thread_ = std::thread([this]() {
                for (;;) {
                    std::function<void()> j;
                    {
                        std::unique_lock<std::mutex> lk(mining_ctl_mutex_);
                        mining_ctl_cv_.wait(lk, [this]{ return mining_ctl_stop_ || !mining_ctl_queue_.empty(); });
                        if (mining_ctl_stop_) return;   // abandon any pending jobs on shutdown
                        j = std::move(mining_ctl_queue_.front());
                        mining_ctl_queue_.pop_front();
                    }
                    j();
                }
            });
        }
        mining_ctl_queue_.push_back(std::move(job));
    }
    mining_ctl_cv_.notify_one();
}

// Signal the mining-control thread to stop and join it. Called at shutdown BEFORE xmrig_manager_ is stopped
// or destroyed, so no control job runs concurrently with teardown. Idempotent.
void App::stopMiningControlThread()
{
    {
        std::lock_guard<std::mutex> lk(mining_ctl_mutex_);
        mining_ctl_stop_ = true;
    }
    mining_ctl_cv_.notify_all();
    if (mining_ctl_thread_.joinable()) mining_ctl_thread_.join();
}

namespace {
// How often auto-balance re-evaluates the pool while active. Long, because switching
// restarts the miner (drops in-flight shares + reconnect); the incumbent stickiness
// in chooseWeightedPool makes actual switches rarer still.
constexpr long long kRebalanceIntervalMs = 30LL * 60LL * 1000LL;  // 30 minutes

long long steadyNowMs()
{
    using namespace std::chrono;
    return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
} // namespace

void App::updatePoolAutoBalance()
{
    if (!settings_) return;
    if (!supportsPoolMining()) return;  // pool mining is available in both builds (solo is full-node only)
    if (settings_->getPoolSelectMode() != config::Settings::PoolSelectMode::AutoBalance) return;
    if (!settings_->getPoolMode()) return;  // only while POOL mode is selected
    if (ui::IsMiningBenchmarkActive()) return;  // don't auto-switch pools mid-benchmark — it restarts xmrig at the wrong thread count (L-12)

    const long long now = steadyNowMs();
    const bool intervalDue = (last_balance_eval_ms_ == 0) ||
                             (now - last_balance_eval_ms_ >= kRebalanceIntervalMs);

    // Kick a background refresh when requested or due (never while one is in flight).
    if ((balance_refresh_pending_ || intervalDue) && !pool_stats_service_.busy()) {
        balance_refresh_pending_ = false;
        last_balance_eval_ms_ = now;
        balance_snapshot_seen_ = false;
        pool_stats_service_.refresh(util::knownPools());
        return;  // results land on a later frame
    }

    // Apply the freshly-completed snapshot exactly once.
    if (!pool_stats_service_.busy()) {
        const auto snap = pool_stats_service_.snapshot();
        if (snap.ready && !balance_snapshot_seen_) {
            balance_snapshot_seen_ = true;
            applyPoolAutoBalance(snap);
        }
    }
}

void App::applyPoolAutoBalance(const util::PoolStatsService::Snapshot& snap)
{
    const auto& pools = util::knownPools();

    std::vector<util::PoolHashrate> samples;
    samples.reserve(pools.size());
    for (const auto& kp : pools) {
        const auto it = snap.byId.find(kp.id);
        samples.push_back(it != snap.byId.end()
                              ? it->second
                              : util::PoolHashrate{kp.id, 0.0, false});
    }

    const util::KnownPool* current = util::findKnownPoolByUrl(settings_->getPoolUrl());
    const std::string currentId = current ? current->id : std::string{};

    const std::string chosenId = util::chooseWeightedPool(samples, currentId, balance_rng_);
    if (chosenId.empty() || chosenId == currentId) return;  // keep the current pool

    const util::KnownPool* chosen = nullptr;
    for (const auto& kp : pools)
        if (kp.id == chosenId) { chosen = &kp; break; }
    if (!chosen) return;

    settings_->setPoolUrl(chosen->stratum);
    settings_->save();

    ui::Notifications::instance().info(
        std::string(TR("mining_auto_balanced_to")) + " " + chosen->label);

    // Restart so the new pool (and its per-pool algo) takes effect immediately.
    if (xmrig_manager_ && xmrig_manager_->isRunning()) {
        const int threads = xmrig_manager_->getRequestedThreads();
        stopPoolMining();
        startPoolMining(threads);
    }
}

std::string App::poolMiningInstalledVersion()
{
    daemon::XmrigManager::startVersionDetection();  // idempotent one-shot
    return daemon::XmrigManager::installedVersion();
}

// Console backend accessors — prefer the fast-lane RPC connection (so console commands
// don't queue behind the refresh batch), falling back to the main connection.
rpc::RPCClient* App::consoleRpc()
{
    return (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
}
rpc::RPCWorker* App::consoleWorker()
{
    return (fast_rpc_ && fast_rpc_->isConnected() && fast_worker_) ? fast_worker_.get() : worker_.get();
}
daemon::EmbeddedDaemon* App::consoleDaemon()
{
    return daemon_controller_ ? daemon_controller_->daemon() : nullptr;
}
daemon::XmrigManager* App::consoleXmrig()
{
    return xmrig_manager_.get();
}

bool App::sendStopCommandSafely(rpc::RPCClient& client, const char* context)
{
    const char* label = context ? context : "App";
    try {
        rpc::RPCClient::TraceScope trace("Daemon lifecycle / Stop command");
        client.call("stop");
        DEBUG_LOGF("[%s] Stop command sent\n", label);
        return true;
    } catch (const std::exception& e) {
        DEBUG_LOGF("[%s] Stop RPC failed: %s\n", label, e.what());
    } catch (...) {
        DEBUG_LOGF("[%s] Stop RPC failed with unknown exception\n", label);
    }
    return false;
}

class AppDaemonLifecycleRuntime final : public daemon::DaemonController::LifecycleRuntime {
public:
    explicit AppDaemonLifecycleRuntime(App& app) : app_(app) {}

    void stopDaemonWithPolicy() override { app_.stopEmbeddedDaemon(); }
    bool startDaemon() override { return app_.startEmbeddedDaemon(); }
    void resetOutputOffset() override { app_.daemon_output_offset_ = 0; }

    void requestRpcStopAndDisconnect(const char* context, const char* reason) override
    {
        if (app_.rpc_ && app_.rpc_->isConnected()) {
            app_.sendStopCommandSafely(*app_.rpc_, context);
            app_.rpc_->disconnect();
        }
        app_.onDisconnected(reason ? reason : "Daemon lifecycle");
    }

    int deleteBlockchainData() override
    {
        return daemon::BlockchainDataCleaner::removeBlockchainData(util::Platform::getDragonXDataDir());
    }

private:
    App& app_;
};

bool App::init()
{
    DEBUG_LOGF("Initializing ObsidianDragon...\n");

    // Extract embedded resources (Sapling params, asmap.dat) on first run
    if (resources::hasEmbeddedResources() && resources::needsParamsExtraction()) {
        DEBUG_LOGF("First run - extracting bundled resources...\n");
        resources::extractEmbeddedResources();
    }

    // Initialize settings
    settings_ = std::make_unique<config::Settings>();
    if (!settings_->load()) {
        DEBUG_LOGF("Warning: Could not load settings, using defaults\n");
    }
    // The initial font atlas is built (in main, before App renders) with monochrome emoji. If the saved
    // setting wants color, request a rebuild so the first preFrame switches to the FreeType color atlas.
    if (settings_->getChatEmojiColor()) font_rebuild_requested_ = true;
    // On upgrade (version mismatch), re-save to persist new defaults + current version
    if (settings_->needsUpgradeSave()) {
        DEBUG_LOGF("[INFO] Wallet upgraded — re-saving settings with new defaults\n");
        settings_->save();
        settings_->clearUpgradeSave();
    }

    // Lite builds with a linked SDXL backend own a lite wallet controller that drives
    // real create/open/restore through the bridge. Full-node and unlinked-lite builds
    // leave lite_wallet_ null (the UI falls back to validation-only).
    rebuildLiteWallet();

    // Apply verbose logging preference from saved settings
    util::Logger::instance().setVerbose(settings_->getVerboseLogging());

    // Restore console display preferences into the ConsoleTab statics at startup. These are the only
    // startup restore for these toggles — the Settings page / first-run wizard also sync scanline, but
    // only when visited, so without this an existing wallet would ignore a saved preference on launch.
    ui::ConsoleTab::s_scanline_enabled       = settings_->getScanlineEnabled();
    ui::ConsoleTab::s_line_accents_enabled   = settings_->getConsoleLineAccents();
    ui::ConsoleTab::s_line_text_color_enabled = settings_->getConsoleTextColor();
    ui::ConsoleTab::s_console_zoom           = settings_->getConsoleZoom();

    // Apply saved user font scale so fonts are correct on first reload
    {
        float fs = settings_->getFontScale();
        if (fs > 1.0f) {
            ui::Layout::setUserFontScale(fs);
            // Fonts were loaded at default scale in Init(); rebuild now.
            auto& typo = ui::material::Typography::instance();
            ImGuiIO& io = ImGui::GetIO();
            typo.reload(io, typo.getDpiScale());
            // Consume the flag so App::update() doesn't double-reload
            ui::Layout::consumeUserFontReload();
            DEBUG_LOGF("App: Applied saved font scale %.1fx\n", fs);
        }
    }

    // Ensure ObsidianDragon config directory and template files exist
    util::Platform::ensureObsidianDragonSetup();

    // W1-1 (startup): if the recorded active wallet file was moved/deleted between sessions, don't hand a
    // missing -wallet=<name> to the daemon — it would auto-create a fresh empty wallet under that name,
    // silently "opening" as a zero-balance wallet at launch. Fall back to the always-present default and
    // warn. (The default "wallet.dat" is legitimately absent on first run, so it is skipped.) Runs before
    // the vault init below so the vault is scoped to the wallet actually opened.
    if (settings_) {
        const std::string active = settings_->getActiveWalletFile();
        if (!active.empty() && active != "wallet.dat") {
            std::error_code walEc;
            const std::string walPath = util::Platform::getDragonXDataDir() + "/" + active;
            if (!std::filesystem::exists(walPath, walEc)) {
                DEBUG_LOGF("[App] active wallet '%s' not found at startup — falling back to wallet.dat\n",
                           active.c_str());
                settings_->setActiveWalletFile("wallet.dat");
                settings_->save();
                ui::Notifications::instance().warning(
                    std::string(TR("appx_last_used_wallet_not_found_prefix")) + active +
                    TR("appx_last_used_wallet_not_found_suffix"), 20.0f);
            }
        }
    }

    // Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never
    // offered for another (the default wallet keeps the legacy vault.dat).
    vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : "");

    // Theme is now applied via SkinManager below after UISchema loads.
    // The old SetThemeById() C++ fallback is no longer needed at startup
    // because SkinManager.setActiveSkin() loads colors from ui.toml directly.

    // Initialize unified UI schema (loads TOML, drives all layout values)
    {
        std::string schemaPath = util::Platform::getExecutableDirectory() + "/res/themes/ui.toml";
        bool loaded = false;

#if HAS_EMBEDDED_UI_TOML
        // If on-disk ui.toml exists but differs in size from the embedded
        // version, a newer wallet binary is running against stale theme
        // files.  Overwrite the on-disk copy so layout matches the binary.
        if (std::filesystem::exists(schemaPath)) {
            std::error_code ec;
            auto diskSize = std::filesystem::file_size(schemaPath, ec);
            if (!ec && diskSize != static_cast<std::uintmax_t>(embedded::ui_toml_size)) {
                DEBUG_LOGF("[INFO] ui.toml on disk (%ju bytes) differs from embedded (%zu bytes) — updating\n",
                       (uintmax_t)diskSize, embedded::ui_toml_size);
                std::ofstream ofs(schemaPath, std::ios::binary | std::ios::trunc);
                if (ofs.is_open()) {
                    ofs.write(reinterpret_cast<const char*>(embedded::ui_toml_data),
                              embedded::ui_toml_size);
                    ofs.close();
                }
            }
        }
#endif

        if (std::filesystem::exists(schemaPath)) {
            loaded = ui::schema::UISchema::instance().loadFromFile(schemaPath);
        }
        // Fallback: load from build-time embedded data when file not on disk
        // (e.g., single-file Windows distribution without res/ directory)
        if (!loaded) {
#if HAS_EMBEDDED_UI_TOML
            std::string embedded(reinterpret_cast<const char*>(embedded::ui_toml_data),
                                 embedded::ui_toml_size);
            ui::schema::UISchema::instance().loadFromString(embedded, "embedded");
#else
            DEBUG_LOGF("Warning: ui.toml not found at %s and no embedded fallback\n", schemaPath.c_str());
#endif
        }

        // Initialize SkinManager and activate saved skin
        auto& skinMgr = ui::schema::SkinManager::instance();
        skinMgr.refresh();

        // Register image reload callback for skin changes
        skinMgr.setImageReloadCallback([this](const std::string& bgPath, const std::string& logoPath) {
            reloadThemeImages(bgPath, logoPath);
        });

        std::string skinId = settings_->getSkinId();
        if (skinId.empty()) skinId = "dragonx";

        // Apply gradient background preference before activating the skin
        // so the initial image resolution uses the correct mode.
        if (settings_->getGradientBackground()) {
            skinMgr.setGradientMode(true);
        }

        skinMgr.setActiveSkin(skinId);
    }

    // Apply saved language
    std::string lang = settings_->getLanguage();
    if (!lang.empty()) {
        util::I18n::instance().loadLanguage(lang);
    }

    // Initialize RPC client
    rpc_ = std::make_unique<rpc::RPCClient>();

    // Initialize background RPC worker thread
    worker_ = std::make_unique<rpc::RPCWorker>();
    worker_->start();
    network_refresh_.markDue(services::NetworkRefreshService::Timer::Price);

    // Warm the Mining tab's data at startup (both async / off the UI thread) so pool
    // hashrates and the installed miner version are shown as soon as the tab is opened,
    // instead of only after the user clicks a mine / auto-balance button.
    if (supportsPoolMining()) {
        daemon::XmrigManager::startVersionDetection();   // idempotent one-shot `xmrig --version`
        pool_stats_service_.refresh(util::knownPools());  // snapshot lands on a later frame
    }

    // Forward error/warning notifications to the console tab. The channel is the semantic
    // key — the Errors filter keys off ConsoleChannel::Error, and the color/accent bar are
    // derived from the channel at draw time.
    ui::Notifications::instance().setConsoleCallback(
        [this](const std::string& msg, bool is_error) {
            console_tab_.addLine(msg, is_error ? ui::ConsoleChannel::Error
                                               : ui::ConsoleChannel::Warning);
        });

    // Forward all app-level log messages (DEBUG_LOGF, LOGF, etc.) to the
    // console tab so they are visible in the UI, not just in the log file.
    util::Logger::instance().setCallback(
        [this](const std::string& msg) {
            // Classify by content into a severity channel: errors, warnings, else the App
            // channel (the routine wallet log stream, controlled by the App messages filter).
            ui::ConsoleChannel channel = ui::ConsoleChannel::App;
            if (msg.find("[ERROR]") != std::string::npos ||
                msg.find("error") != std::string::npos ||
                msg.find("Error") != std::string::npos ||
                msg.find("failed") != std::string::npos ||
                msg.find("Failed") != std::string::npos) {
                channel = ui::ConsoleChannel::Error;
            } else if (msg.find("[WARN]") != std::string::npos ||
                       msg.find("warn") != std::string::npos) {
                channel = ui::ConsoleChannel::Warning;
            }
            // Strip trailing newline so console tab lines look clean. The App channel
            // supplies the accent bar; no "[app] " text prefix needed.
            std::string trimmed = msg;
            while (!trimmed.empty() && (trimmed.back() == '\n' || trimmed.back() == '\r'))
                trimmed.pop_back();
            if (!trimmed.empty())
                console_tab_.addLine(trimmed, channel);
        });

    // Check for first-run wizard — also re-run if blockchain data is missing
    // even when wizard was previously completed (e.g. data dir was deleted).
    // The wizard is full-node setup (daemon + blockchain); lite has neither, and isFirstRun()
    // is always true in lite (no `blocks` dir), so skip it — lite users create/open a wallet in
    // Settings, guided by the "No wallet open" prompt.
    if (isFirstRun() && !isLiteBuild()) {
        wizard_phase_ = WizardPhase::Appearance;
        DEBUG_LOGF("First run detected — starting wizard\n");
        // Don't start daemon yet — wait for wizard completion
    } else {
        // Normal startup — connect to daemon (tryConnect() is a no-op in lite builds)
        tryConnect();
    }

    // Populate the shared contact store from disk (per-variant addressbook.json).
    // Idempotent; a missing file is fine (returns true). Purely local — no daemon/RPC dependency.
    address_book_.load();

    // Load the wallet-files metadata index (wallets.json); populated after each wallet load. Local
    // only, missing file is fine.
    wallet_index_.load();

    // If this wallet build bundles a newer daemon than the one already installed, offer to update it
    // (once per version). Fixes the trap where a wallet update carrying a newer node silently kept
    // the old binary — e.g. an old daemon lacking z_exportmnemonic blocking migrate-to-seed.
    maybeOfferDaemonUpdate();

    DEBUG_LOGF("Initialization complete\n");
    return true;
}

// ============================================================================
// Pre-frame: font atlas rebuilds (must run BEFORE ImGui::NewFrame)
// ============================================================================
void App::preFrame()
{
    ImGuiIO& io = ImGui::GetIO();

    // Hot-reload unified UI schema
    {
        PERF_SCOPE("PreFrame.SchemaHotReload");
        ui::schema::UISchema::instance().pollForChanges();
        ui::schema::UISchema::instance().applyIfDirty();
    }

    // Refresh the per-frame layout cache (reads TOML values once)
    ui::Layout::beginFrame();

    // Refresh balance layout config only when schema changes
    {
        static uint32_t s_balanceLayoutGen = 0;
        uint32_t gen = ui::schema::UISchema::instance().generation();
        if (gen != s_balanceLayoutGen) {
            s_balanceLayoutGen = gen;
            ui::RefreshBalanceLayoutConfig();
        }
    }

    // If font sizes changed in the TOML, rebuild the font atlas
    if (ui::schema::UISchema::instance().consumeFontsChanged()) {
        auto& typo = ui::material::Typography::instance();
        typo.reload(io, typo.getDpiScale());
        DEBUG_LOGF("App: Font atlas rebuilt after hot-reload\n");
    }

    // If the user changed font scale in Settings, rebuild the font atlas
    if (ui::Layout::consumeUserFontReload()) {
        auto& typo = ui::material::Typography::instance();
        typo.reload(io, typo.getDpiScale());
        DEBUG_LOGF("App: Font atlas rebuilt after user font-scale change (%.1fx)\n",
                   ui::Layout::userFontScale());
    }

    // Keep Typography's emoji-style flag in sync with the setting so any reload (font-scale, DPI, or the
    // explicit request below) picks up the right emoji font. Inert unless this is a FreeType build.
    if (settings_) ui::material::Typography::instance().setColorEmoji(settings_->getChatEmojiColor());

    // App-wide clock format (24h/12h) — drives every user-facing timestamp via util::formatClock*.
    if (settings_) util::setClock12h(settings_->getTimeFormat() == 1);

    // Explicit rebuild request (e.g. the chat color-emoji toggle) — reload picks up the new emoji style.
    if (font_rebuild_requested_) {
        font_rebuild_requested_ = false;
        auto& typo = ui::material::Typography::instance();
        typo.reload(io, typo.getDpiScale());
        DEBUG_LOGF("App: Font atlas rebuilt on request (chat emoji style)\n");
    }
}

namespace {
// Resolve the lite-wallet rollout / kill-switch decision (see wallet/lite_rollout_policy.h).
// Local-only: reads the override + a stable per-install id from settings, an emergency env var,
// and a locally-cached manifest file (NO network fetch). Fail-open: a missing/invalid manifest
// leaves the wallet enabled.
wallet::LiteRolloutDecision resolveLiteRolloutDecision(config::Settings& settings)
{
    using namespace dragonx::wallet;
    LiteRolloutInputs inputs;
    inputs.appVersion = DRAGONX_VERSION;

    // Emergency kill-switch env var: any value other than empty/"0"/"false" disables.
    if (const char* env = std::getenv("DRAGONX_LITE_KILL_SWITCH")) {
        const std::string v = env;
        inputs.killSwitchEnv = !v.empty() && v != "0" && v != "false";
    }

    inputs.override = liteRolloutOverrideFromString(settings.getLiteRolloutOverride());

    // Stable per-install bucket source — generated once, persisted, never transmitted, no PII.
    std::string installId = settings.getLiteInstallId();
    if (installId.empty()) {
        if (sodium_init() >= 0) {
            unsigned char buf[16];
            randombytes_buf(buf, sizeof(buf));
            static const char kHex[] = "0123456789abcdef";
            installId.reserve(sizeof(buf) * 2);
            for (unsigned char c : buf) {
                installId.push_back(kHex[c >> 4]);
                installId.push_back(kHex[c & 0x0F]);
            }
        }
        settings.setLiteInstallId(installId);
        settings.save();
    }
    inputs.installBucket = liteRolloutBucketFromInstallId(installId);

    // Local manifest cache next to settings.json — no network fetch (a signed remote fetcher can
    // populate this later). Absent/unreadable -> fail-open.
    try {
        const std::filesystem::path cfgDir =
            std::filesystem::path(config::Settings::getDefaultPath()).parent_path();
        inputs.manifest = loadLiteRolloutManifestFromFile((cfgDir / "lite_rollout.json").string());
    } catch (...) {
        // leave manifest absent -> fail-open
    }

    const LiteRolloutDecision decision = evaluateLiteRollout(inputs);
    if (!decision.allowed) {
        DEBUG_LOGF("[lite-rollout] lite wallet gated OFF: %s — %s\n",
                   liteRolloutStatusName(decision.status), decision.message.c_str());
    }
    return decision;
}

// True when a z_sendmany failure means the wallet's shielded note data (witnesses/anchors) is
// out of sync with the chain — the symptom after a bootstrap/reindex without a full rescan. These
// are consensus/proof-build rejections, not user mistakes, and the only fix is a full rescan.
bool sendErrorNeedsRescan(const std::string& raw)
{
    auto has = [&](const char* s) { return raw.find(s) != std::string::npos; };
    return has("shielded-requirements-not-met") ||
           has("shielded requirements not met") ||
           has("missing sapling anchor") ||
           has("Invalid sapling spend proof") ||
           has("Invalid output proof") ||
           has("bad-txns-sapling-") ||
           has("anchor");
}
} // namespace

void App::rebuildLiteWallet(bool force)
{
    if (!supportsLiteBackend() || !settings_) return;
    // Don't tear down a live session unless forced: if a wallet is already open (and possibly
    // mid-sync), the new server selection is already persisted to settings and will take effect
    // the next time the controller is built (next launch, or before another wallet is opened).
    // Rebuilding would discard the open wallet and its in-flight, uninterruptible sync — which is
    // exactly what the Network tab's apply-immediately server switch wants (force=true). Replacing
    // lite_wallet_ destroys the old controller; its destructor detaches the uninterruptible sync
    // thread (which keeps the shared bridge alive) and only joins the short poll worker, so this
    // does not block. The app's auto-open loop then reopens the wallet against the new server.
    if (!force && lite_wallet_ && lite_wallet_->walletOpen()) return;

    const auto liteConn = wallet::liteConnectionSettingsFromAppSettings(*settings_);
    wallet::liteLog(std::string(force ? "Lite controller rebuilt" : "Lite controller built") +
                    " (preferred server: " + liteConn.stickyServerUrl + ")");
    lite_wallet_ = wallet::LiteWalletController::createLinked(
        walletCapabilities(), liteConn, resolveLiteRolloutDecision(*settings_));
    lite_wallet_->setPersistCallback([this]() { settings_->save(); });

    // The new controller starts closed. Re-arm the one-shot auto-open so the next update()
    // tick reopens the existing wallet against the (possibly changed) server — otherwise a
    // server switch from the Network tab, or a retry after a failed open, would rebuild the
    // controller but never reopen, leaving a permanent "disconnected" state.
    lite_autoopen_done_ = false;
    lite_open_error_.clear();

    // A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
    // identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and
    // reloads the right wallet's history) from the newly opened wallet's seed.
    resetChatSession();
}

void App::update()
{
    PERF_SCOPE("Update.Total");
    ImGuiIO& io = ImGui::GetIO();
    // Clamp the frame delta: after a long pause (window minimized, or the machine slept) NewFrame reports
    // a huge DeltaTime that would fire every refresh/animation timer at once. Every timer reads
    // io.DeltaTime, so one clamp here bounds them all (also caps the real-clock delta fed while minimized).
    if (io.DeltaTime > 0.25f) io.DeltaTime = 0.25f;

    // Full UI screenshot sweep: demo state is injected once and must stay frozen. Skip every live
    // op (refresh/connect/pumps) so a real daemon can't clobber it — on Windows a running node's
    // refresh set connected=false (CDB error) and blanked the Send/Receive/History tabs mid-sweep.
    // capture_mode_ is only set for the offline full sweep, so the live tab-only sweep is unaffected.
    if (capture_mode_) return;

    // If a wallet switch's daemon failed to start, revert to the previous wallet (main-thread-safe).
    processWalletSwitchRevert();

    // Track user interaction for auto-lock
    if (io.MouseDelta.x != 0 || io.MouseDelta.y != 0 ||
        io.MouseClicked[0] || io.MouseClicked[1] ||
        io.InputQueueCharacters.Size > 0 ||
        ImGui::IsAnyItemActive()) {
        last_interaction_ = std::chrono::steady_clock::now();
    }

    // Drain completed RPC results back onto the main thread
    if (worker_) {
        worker_->drainResults();
    }
    if (fast_worker_) {
        fast_worker_->drainResults();
    }

    // Apply any lite-wallet refresh the controller's background worker produced (main thread).
    if (lite_wallet_) {
        // Finalize any completed async open on the main thread (flips walletOpen / surfaces failure).
        lite_wallet_->pumpAsyncOpen();
        // Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
        lite_wallet_->pumpLifecycleResult();

        // Auto-open an existing wallet asynchronously with server failover (initialize_existing
        // needs no passphrase — it just loads the file and contacts the server). Running it off
        // the UI thread means an unreachable server never freezes startup, and trying the other
        // default servers means one dead server no longer strands the wallet. Retried on an
        // interval so a transient outage self-heals once a server comes back — and much sooner
        // (a few seconds) when the failure was a server merely warming up (-28), which clears fast.
        const double nowSecs = ImGui::GetTime();
        const double retryInterval = lite_wallet_->lastOpenWasWarmup() ? 4.0 : 20.0;
        if (!lite_wallet_->walletOpen() && !lite_wallet_->openInProgress() &&
            lite_wallet_->walletExists() &&
            (!lite_autoopen_done_ || nowSecs - lite_open_last_attempt_ > retryInterval)) {
            lite_autoopen_done_ = true;
            lite_open_last_attempt_ = nowSecs;
            lite_wallet_->beginOpenExisting();
        }

        // Lite has no RPC daemon (tryConnect() is a no-op in lite builds), so derive the app's
        // "online" state — which gates the wallet UI via isConnected() — from the lite wallet.
        // A wallet is open only after a successful backend init against the lite server, so this
        // is a non-blocking proxy for "lite backend operational".
        state_.connected = lite_wallet_->walletOpen();
        if (state_.connected) {
            lite_open_error_.clear();  // opened successfully — clear any prior error
        } else {
            // Surface a failed open (e.g. server unreachable) once per distinct reason, so the
            // disconnected state isn't silent.
            const std::string& err = lite_wallet_->lastOpenError();
            if (!err.empty() && err != lite_open_error_) {
                lite_open_error_ = err;
                ui::Notifications::instance().error(std::string(TR("appx_wallet_open_failed_prefix")) + err, 8.0f);
            }
        }
        // Suppress the status bar's full-node connection-detail line in lite ("" and "Connected"
        // are both hidden); the connected/no-wallet indicator + sync status convey lite state.
        connection_status_ = state_.connected ? "Connected" : "";

        wallet::LiteWalletAppRefreshModel liteModel;
        if (lite_wallet_->takeRefreshedModel(liteModel)) {
            wallet::applyLiteRefreshModelToWalletState(liteModel, state_);
            // HushChat (lite): harvest chat memos from the refreshed transactions and thread them
            // (no-op when the feature is off or no identity; the store dedups across refreshes).
            ingestLiteChatMemos(liteModel);
            // Chat note-buffer: recompute the verified/maturing self-note estimates from the fresh model.
            refreshChatNoteBudget(liteModel);
        }
        // Deliver a completed async send/shield result. Route by the in-flight op that owns the single
        // broadcast channel: a user Send-tab send goes to lite_send_callback_; a chat send / contact
        // request resolves its echo (real status — not the old optimistic "Sent"); a note-buffer split
        // just logs. Because the controller runs one broadcast at a time, inflight_op_ correlates the
        // one global result with no txid matching.
        wallet::LiteBroadcastResult broadcast;
        if (lite_wallet_->takeBroadcastResult(broadcast)) {
            // Mirror failures into the lite Console (copyable) in addition to the toast the send UI
            // shows — transient toasts are easy to miss and impossible to copy.
            if (!broadcast.ok)
                wallet::liteLog("Send/shield failed: " + broadcast.error);
            const LiteInflightOp finished = inflight_op_;
            inflight_op_ = LiteInflightOp{};                 // channel is free again
            switch (finished.kind) {
                case LiteOpKind::ChatSend:
                case LiteOpKind::ContactRequest:
                    onChatBroadcastResult(finished, broadcast.ok, broadcast.error);
                    break;
                case LiteOpKind::Split:
                    // Buffer split done; the next refresh re-counts the new (maturing) notes.
                    break;
                case LiteOpKind::UserSend:
                case LiteOpKind::None:
                default:
                    if (lite_send_callback_) {
                        lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
                        lite_send_callback_ = nullptr;
                    }
                    break;
            }
        }
        // Startup lock screen: once the first refresh reveals the (auto-opened) wallet is
        // encrypted, prompt to unlock if it's locked. Soft by design — balances stay viewable via
        // viewing keys while locked; only spending needs the passphrase, so the user may dismiss
        // and browse read-only. Fires once per session.
        if (!lite_startup_lock_checked_ && state_.encrypted) {
            lite_startup_lock_checked_ = true;
            if (state_.locked) requestLiteUnlock();
        }
    }
    async_tasks_.reapCompleted();

    // HushChat: once the wallet seed is reachable+unlocked, derive & set the chat identity so the
    // transaction-refresh harvest can decrypt incoming memos. Cheap early-outs keep it idle until
    // it can act; compiled away when the feature is off (constexpr gate inside).
    maybeProvisionChatIdentity();

    // Chat note-buffer coordinator (BOTH variants). Full-node only: refresh the per-note counts via a
    // rate-limited z_listunspent worker scan (lite gets its counts from the refresh model, above). Then
    // pump: drain queued chat sends against verified notes and build/refill the buffer during idle. Both
    // self-gate to no-ops unless chat is engaged; the pump serializes to one send outstanding at a time.
    refreshChatNoteBudgetNode();
    pumpChatNoteBuffer();

    // One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
    maybeRemindSeedBackup();

    // One-time warning if the active wallet loaded empty while a sibling wallet file holds funds
    // (a prior/unwitnessed salvage likely moved the coins into a wallet.<ts>.bak).
    maybeWarnEmptyWalletWithFundedSiblings();

    // One-time nudge if wallet.dat has bloated past the threshold (toast + clickable alert → consolidate).
    maybeWarnLargeWallet();

    // Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can
    // glow for a legacy, pre-seed-phrase wallet.
    probeWalletSeedStatus();

    // Pick up progress/result from a running seed-wallet migration (create/sweep/adopt).
    pumpSeedMigration();
    pumpWalletRestore();
    // While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
    if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) {
        seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime;
        if (seed_migration_poll_timer_ <= 0.0f) {
            seed_migration_poll_timer_ = 5.0f;
            pollSweepStatus();
        }
    }

    // Auto-lock check (only when connected + encrypted + unlocked)
    if (state_.connected && state_.isUnlocked()) {
        checkAutoLock();
    }

    // Mine-when-idle check (runs every frame, internally rate-limited by idle detection)
    checkIdleMining();

    // P8: Dedup rebuildAddressList — only rebuild once per frame
    if (address_list_dirty_) {
        address_list_dirty_ = false;
        state_.rebuildAddressList();
    }

    using RefreshTimer = services::NetworkRefreshService::Timer;
    network_refresh_.tick(io.DeltaTime);

    // Wipe a secret (seed/private key) from the clipboard once its auto-clear delay elapses.
    pumpSecretClipboardClear();

    // Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to
    // a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy).
    // effectivelySyncing() includes the post-sync settle window, so this also reverts to the normal
    // per-tab cadence once that window elapses.
    if (effectivelySyncing() != refresh_policy_syncing_) {
        applyRefreshPolicy(current_page_);
    }

    // Full-node RPC refreshes gate on ACTUAL RPC connectivity, not state_.connected. In lite
    // builds state_.connected is the lite-wallet "online" proxy (true when a wallet is open, to
    // enable the wallet UI), but there is no RPC daemon — so RPC polls (mining/balance/peers/txs)
    // must key off rpc_ being genuinely connected or they'd fire and fail ("Not connected").
    const bool rpcConnected = rpc_ && rpc_->isConnected();

    // Fast refresh (mining stats + daemon memory) every second
    // Skip when wallet is locked — no need to poll, and queued tasks
    // would delay the PIN unlock worker task.
    //
    // Also enter this block when the embedded daemon is running even if RPC currently reads
    // disconnected: during a long rescan the daemon holds cs_main, so getinfo times out and the
    // socket is marked down — but the rescan/witness PROGRESS we parse comes from the daemon's
    // stdout pipe (no RPC needed), and getrescaninfo still answers (it takes no lock). Without this,
    // the daemon-output parser stopped exactly when a rescan started, so witness progress never
    // surfaced. The RPC-dependent pollers inside stay individually gated (mining skips during
    // warmup/rescan; the getrescaninfo poll only runs once rescanning is flagged).
    if (network_refresh_.consumeDue(RefreshTimer::Fast)) {
        const bool daemonAlive = daemon_controller_ && daemon_controller_->isRunning();
        if ((rpcConnected || daemonAlive) && !state_.isLocked()) {
            // Skip the mining poll while the daemon is in warmup (e.g. during -rescan). Otherwise
            // getmininginfo is rejected with -28 ("Rescanning...") every second and floods the log.
            // The rescan progress poll below still runs — that's how we track the warmup/rescan.
            if (rpcConnected && !state_.warming_up && !runtime_rescan_active_) {
                refreshMiningInfo();
            }

            // Poll getrescaninfo for rescan progress (if rescan flag is set)
            // Use fast_rpc_ when available to avoid blocking on rpc_'s
            // curl_mutex (which may be held by a long-running import).
            // Suppressed during a runtime rescanblockchain: the daemon holds cs_main for the whole
            // scan, so every poll would block, and completion is signalled by the rescan RPC callback.
            if (state_.sync.rescanning && !runtime_rescan_active_ && fast_worker_ && !rescan_status_poll_in_progress_) {
                auto* rescanRpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
                rescan_status_poll_in_progress_ = true;
                fast_worker_->post([this, rescanRpc]() -> rpc::RPCWorker::MainCb {
                    try {
                        rpc::RPCClient::TraceScope trace("Startup / Rescan monitor");
                        auto info = rescanRpc->call("getrescaninfo");
                        bool rescanning = info.value("rescanning", false);
                        float progress = 0.0f;
                        if (info.contains("rescan_progress") && info["rescan_progress"].is_string()) {
                            try { progress = std::stof(info["rescan_progress"].get<std::string>()) * 100.0f; } catch (...) {}
                        } else if (info.contains("rescan_progress") && info["rescan_progress"].is_number()) {
                            progress = info["rescan_progress"].get<float>() * 100.0f;
                        }
                        return [this, rescanning, progress]() {
                            rescan_status_poll_in_progress_ = false;
                            if (rescanning) {
                                state_.sync.rescanning = true;
                                rescan_confirmed_active_ = true;
                                if (progress > 0.0f) {
                                    state_.sync.rescan_progress = progress / 100.0f;
                                }
                            } else if (state_.sync.rescanning && rescan_confirmed_active_) {
                                // Genuine completion: getrescaninfo answers cleanly AND we previously
                                // saw the rescan running. Without the confirmed-active gate, the first
                                // poll (which hits the still-running pre-restart daemon, rescanning=false)
                                // would fire a false "complete" the instant rescan was clicked.
                                if (user_initiated_rescan_) {
                                    ui::Notifications::instance().success(TR("appx_blockchain_rescan_complete"));
                                    user_initiated_rescan_ = false;   // surfaced once; not for background rebuilds
                                }
                                resetWitnessRescanProgress();
                                state_.sync.rescan_progress = 1.0f;
                            }
                            // else: rescanning=false but not yet confirmed → pre-restart daemon; keep waiting.
                        };
                    } catch (const std::exception& e) {
                        // During -rescan the daemon is in RPC warmup and rejects every call with the
                        // current phase as the message ("Loading block index..." → "Rescanning...").
                        // That's not a failure — it's proof the rescan is running. Surface it and mark
                        // the rescan confirmed-active so completion is recognised when warmup ends.
                        std::string phase = e.what();
                        return [this, phase]() {
                            rescan_status_poll_in_progress_ = false;
                            if (state_.sync.rescanning) {
                                rescan_confirmed_active_ = true;
                                state_.sync.rescan_status = phase;
                            }
                        };
                    } catch (...) {
                        return [this](){ rescan_status_poll_in_progress_ = false; };
                    }
                });
            }
        }

        // Surface an unexpected miner exit (crash / OOM-kill / external SIGKILL) once, and clear the stale
        // running flag so the UI and auto-balance don't keep believing it's still hashing. (L-03, L-10)
        if (xmrig_manager_ && state_.pool_mining.xmrig_running
            && xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
            state_.pool_mining.xmrig_running = false;
            state_.pool_mining.hashrate_10s = 0.0;
            state_.pool_mining.hashrate_60s = 0.0;
            state_.pool_mining.hashrate_15m = 0.0;
            pool_starting_.store(false, std::memory_order_relaxed);
            const std::string err = xmrig_manager_->getLastError();
            ui::Notifications::instance().error(err.empty() ? TR("appx_miner_stopped_unexpectedly")
                                                            : (std::string(TR("appx_miner_stopped_prefix")) + err));
        }

        // Poll xmrig stats every ~2 seconds (use a simple toggle)
        static bool xmrig_poll_tick = false;
        xmrig_poll_tick = !xmrig_poll_tick;
        if (xmrig_poll_tick && xmrig_manager_ && xmrig_manager_->isRunning()) {
            xmrig_manager_->pollStats();
            auto& ps = state_.pool_mining;
            const auto xs = xmrig_manager_->getStats();   // getStats() now returns a locked copy (M-03)
            ps.xmrig_running = true;
            ps.hashrate_10s  = xs.hashrate_10s;
            ps.hashrate_60s  = xs.hashrate_60s;
            ps.hashrate_15m  = xs.hashrate_15m;
            ps.accepted      = xs.accepted;
            ps.rejected      = xs.rejected;
            ps.uptime_sec    = xs.uptime_sec;
            ps.pool_diff     = xs.pool_diff;
            ps.pool_url      = xs.pool_url;
            ps.algo          = xs.algo;
            ps.version       = xs.version;
            ps.connected     = xs.connected;
            // Pool mining has a connect delay — announce once it's actually connected/hashing.
            if (pool_starting_.load(std::memory_order_relaxed) && (ps.connected || ps.hashrate_10s > 0.0)) {
                pool_starting_.store(false, std::memory_order_relaxed);
                ui::Notifications::instance().success(TR("appx_pool_miner_connected_and_hashing"));
            }
            // Get memory directly from OS (more reliable than API)
            double memMB = xmrig_manager_->getMemoryUsageMB();
            ps.memory_used   = static_cast<int64_t>(memMB * 1024.0 * 1024.0);
            ps.threads_active = xs.threads_active;
            ps.pool_hashrate  = xs.pool_hashrate;
            ps.log_lines     = xmrig_manager_->getRecentLines(30);

            // Record hashrate sample for the chart
            ps.hashrate_history.push_back(ps.hashrate_10s);
            if (ps.hashrate_history.size() > PoolMiningState::MAX_HISTORY) {
                ps.hashrate_history.erase(ps.hashrate_history.begin());
            }
        } else if (xmrig_manager_ && !xmrig_manager_->isRunning()) {
            state_.pool_mining.xmrig_running = false;
            pool_starting_.store(false, std::memory_order_relaxed);  // stopped / never came up
        }

        // Auto-balance: periodically re-pick the pool by hashrate (self-throttled).
        updatePoolAutoBalance();

        // Populate solo mining log lines from daemon output
        if (daemon_controller_ && daemon_controller_->isRunning()) {
            state_.mining.log_lines = daemon_controller_->recentLines(50);
        }

        // Check daemon output for rescan progress (offloaded to worker)
        if (daemon_controller_ && daemon_controller_->isRunning()) {
            std::string newOutput = daemon_controller_->outputSince(daemon_output_offset_);
            if (!newOutput.empty()) {
                // Parse inline on the main thread. It's cheap string scanning of a small incremental
                // buffer, and keeping it OFF the worker is essential: during a rescan the worker can
                // be blocked for minutes on a getrescaninfo call (it waits on cs_main while the daemon
                // holds it for a witness rebuild). On the worker, the parse queued behind that and the
                // rescan/witness progress froze. Inline, progress updates every frame regardless.
                const auto scan = services::NetworkRefreshService::parseDaemonRescanOutput(std::move(newOutput));

                    // Apply results directly — we are already on the main thread.
                    const std::string& status = scan.lastStatus;
                        if (scan.finished) {
                            if (state_.sync.rescanning && user_initiated_rescan_) {
                                ui::Notifications::instance().success(TR("appx_blockchain_rescan_complete"));
                                user_initiated_rescan_ = false;   // surfaced once; not for background rebuilds
                            }
                            // Witness rebuild finishes with the rescan it's part of.
                            resetWitnessRescanProgress();
                            state_.sync.rescan_progress = 1.0f;
                        } else if (scan.foundWitness) {
                            // Witness rebuild is the tail phase of a rescan — keep rescanning set so
                            // the broader gating holds, but surface the witness-specific progress.
                            state_.sync.rescanning = true;
                            rescan_confirmed_active_ = true;
                            state_.sync.building_witnesses = true;

                            // Which sub-phase is this batch? A "<n> remaining" token means the cache
                            // walk (phase 2); otherwise per-tx "Setting Initial" lines are the initial
                            // pass (phase 1). The two INTERLEAVE during a rescan (the daemon does both
                            // per block), so the phase is UPGRADE-ONLY: once the cache walk is seen it
                            // never drops back to the initial pass. Otherwise an interleaved initial
                            // line would flip the phase back and reset the bar every batch (thrash).
                            int phase = state_.sync.witness_phase;
                            if (scan.witnessRemaining >= 0 || scan.witnessRebuilt)  phase = 2;
                            else if (!scan.witnessTxids.empty() && phase < 2)  phase = 1;

                            // Entering a higher sub-phase → reset its progress + accumulators so the
                            // bar restarts from 0 (the two phases have different scales). Upgrade-only,
                            // so this happens at most twice (→1, →2), never repeatedly.
                            if (phase != state_.sync.witness_phase) {
                                state_.sync.witness_phase = phase;
                                state_.sync.witness_progress = 0.0f;
                                state_.sync.witness_remaining = 0;
                                witness_rebuild_total_blocks_ = 0;
                                witness_seen_txids_.clear();
                                witness_total_txs_ = 0;
                            }

                            if (phase == 2) {
                                state_.sync.witness_remaining = scan.witnessRemaining;
                                float p = -1.0f;
                                if (scan.witnessRebuilt) {
                                    // Parallel rebuild finished (it logs no progress) — snap to 100%.
                                    p = 1.0f;
                                    state_.sync.witness_remaining = 0;
                                } else if (scan.witnessReadTotal > 0) {
                                    // New daemon: exact done/total. Use the reported total as the
                                    // denominator directly (no peak anchoring) for a smooth 0..1 sweep.
                                    int done = scan.witnessReadTotal - (scan.witnessRemaining >= 0 ? scan.witnessRemaining : 0);
                                    p = static_cast<float>(done) / static_cast<float>(scan.witnessReadTotal);
                                } else if (scan.witnessRemaining >= 0) {
                                    // Older daemon: bare "<n> remaining" with no total — anchor the % to
                                    // the largest remaining seen (its first/largest value ≈ the full span).
                                    if (scan.witnessRemaining > witness_rebuild_total_blocks_)
                                        witness_rebuild_total_blocks_ = scan.witnessRemaining;
                                    if (witness_rebuild_total_blocks_ > 0)
                                        p = 1.0f - static_cast<float>(scan.witnessRemaining) /
                                                   static_cast<float>(witness_rebuild_total_blocks_);
                                }
                                if (p >= 0.0f) {
                                    if (p > 1.0f) p = 1.0f;
                                    if (p > state_.sync.witness_progress)  // monotonic within the phase
                                        state_.sync.witness_progress = p;
                                }
                            } else if (phase == 1) {
                                // Initial pass: count DISTINCT witnessed txs / total. The set only
                                // grows (dedups the daemon's double-prints), so it's monotonic; the
                                // raw "<i>" can't be used — it's an unordered-map slot that bounces.
                                if (scan.witnessTotalTxs > witness_total_txs_) witness_total_txs_ = scan.witnessTotalTxs;
                                for (const auto& txid : scan.witnessTxids) witness_seen_txids_.insert(txid);
                                if (witness_total_txs_ > 0 && !witness_seen_txids_.empty()) {
                                    float p = static_cast<float>(witness_seen_txids_.size()) /
                                              static_cast<float>(witness_total_txs_);
                                    if (p > 1.0f) p = 1.0f;
                                    if (p > state_.sync.witness_progress)  // monotonic within the phase
                                        state_.sync.witness_progress = p;
                                }
                            }
                            if (!status.empty()) {
                                state_.sync.rescan_status = status;
                            }
                        } else if (scan.foundRescan) {
                            state_.sync.rescanning = true;
                            // Reading "Still rescanning" straight from the daemon log is hard proof the
                            // rescan is genuinely running — confirm it so the getrescaninfo poll's
                            // completion check can fire even if it never caught a warmup error.
                            rescan_confirmed_active_ = true;
                            if (scan.rescanPct > 0.0f) {
                                state_.sync.rescan_progress = scan.rescanPct / 100.0f;
                            }
                            if (!status.empty()) {
                                state_.sync.rescan_status = status;
                            }
                        }
            }
        } else if (!daemon_controller_ || !daemon_controller_->isRunning()) {
            // Clear rescan state if daemon is not running (but preserve during restart)
            if (state_.sync.rescanning && state_.sync.rescan_progress >= 0.99f) {
                state_.sync.rescanning = false;
                state_.sync.rescan_status.clear();
            }
        }
    }

    // Poll pending z_sendmany operations for completion (full-node opid flow; lite has none).
    // Prefer the fast lane but fall back to the main worker (mirrors every other fast_worker_ user)
    // so a torn-down/not-yet-restarted fast lane can't silently strand the poll on "Waiting for
    // operation" — the symptom when fast_worker_ was stopped on reconnect and never came back.
    rpc::RPCWorker* opidWorker = (fast_worker_ && fast_worker_->isRunning())
                                     ? fast_worker_.get() : worker_.get();
    if (network_refresh_.isDue(RefreshTimer::Opid) && !pending_opids_.empty()
        && rpcConnected && opidWorker && !opid_poll_in_progress_) {
        network_refresh_.reset(RefreshTimer::Opid);
        auto opids = pending_opids_;  // copy for worker thread
        opid_poll_in_progress_ = true;
        opidWorker->post([this, opids]() -> rpc::RPCWorker::MainCb {
            auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
            if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
            json result;
            services::NetworkRefreshService::OperationStatusPollResult parsed;
            try {
                rpc::RPCClient::TraceScope trace("Send tab / Operation status");
                // No per-opid filter: this daemon rejects z_getoperationstatus(["opid"]) with
                // "JSON value is not an array as expected", which left completed sends stuck on
                // "Waiting for operation". The no-arg form returns ALL operations;
                // parseOperationStatusPoll() filters down to the opids we're tracking.
                result = rpc->call("z_getoperationstatus", json::array());
                // Parse INSIDE the guard: a malformed/type-anomalous element must never abort the
                // poll and leave opid_poll_in_progress_ stuck true for the whole connected session.
                parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
            } catch (...) {
                return [this](){ opid_poll_in_progress_ = false; };
            }
            return [this, parsed = std::move(parsed)]() mutable {
                opid_poll_in_progress_ = false;

                // Successes: hand the real txid to any waiting send UI callback.
                std::unordered_set<std::string> successfulOpids;
                for (const auto& [opid, txid] : parsed.successTxidsByOpid) {
                    successfulOpids.insert(opid);
                    markPendingSendTransactionSucceeded(opid, txid);
                    send_txids_.insert(txid);
                    invokeSendResultCallback(opid, true, txid);
                }

                // Failures: route to the originating send UI when there is one (it shows
                // its own error toast); otherwise surface a generic notification (this is
                // how shield/merge/auto-shield failures become visible).
                for (const auto& [opid, rawMsg] : parsed.failureByOpid) {
                    // Auto-work-around the daemon's note-selection fee-gap: re-issues the send with a
                    // self-output so it covers the fee. If a retry was issued, defer the outcome to it.
                    if (maybeRetrySendForFeeGap(opid, rawMsg)) {
                        continue;
                    }
                    std::string msg = sendErrorNeedsRescan(rawMsg)
                                          ? rawMsg + "\n\n" + TR("send_err_needs_rescan")
                                          : rawMsg;
                    if (!invokeSendResultCallback(opid, false, msg)) {
                        ui::Notifications::instance().error(msg);
                    }
                }

                std::vector<std::string> terminalOpids = std::move(parsed.doneOpids);
                terminalOpids.insert(terminalOpids.end(),
                                     parsed.staleOpids.begin(), parsed.staleOpids.end());
                for (const auto& id : terminalOpids) {
                    pending_opids_.erase(
                        std::remove(pending_opids_.begin(), pending_opids_.end(), id),
                        pending_opids_.end());
                }

                // Stale opids (no longer reported by the daemon): let any waiting send UI
                // know the outcome couldn't be confirmed rather than spinning forever.
                for (const auto& opid : parsed.staleOpids) {
                    invokeSendResultCallback(opid, false, TR("send_status_unconfirmed"));
                }

                if (parsed.anySuccess) {
                    std::vector<std::string> successOpids;
                    std::vector<std::string> failedOrStaleOpids;
                    for (const auto& opid : terminalOpids) {
                        if (successfulOpids.find(opid) != successfulOpids.end()) successOpids.push_back(opid);
                        else failedOrStaleOpids.push_back(opid);
                    }
                    removePendingSendTransactions(successOpids, false);
                    removePendingSendTransactions(failedOrStaleOpids, true);
                    // Transaction confirmed by daemon — force immediate data refresh
                    transactions_dirty_ = true;
                    addresses_dirty_ = true;
                    last_tx_block_height_ = -1;
                    network_refresh_.markWalletMutationRefresh();
                } else {
                    removePendingSendTransactions(terminalOpids, true);
                    maybeFinishTransactionSendProgress();
                }
            };
        });
    }

    // After a bootstrap the preserved wallet.dat has stale spent-state and must be reconciled
    // against the freshly-imported chain, or the first send tries to spend already-spent notes and
    // is rejected. The -rescan restart can't do it (the snapshot lacks pre-snapshot blocks), so we
    // detect the lowest available block height and run a runtime rescanblockchain from there. Wait
    // until the daemon is connected and out of warmup so the probe and rescan can actually run.
    if (post_bootstrap_rescan_pending_ && rpcConnected && state_.connected &&
        !state_.warming_up && !state_.isLocked() && !state_.sync.rescanning &&
        !runtime_rescan_active_ && !bootstrap_downloading_ &&
        state_.sync.blocks > 1) {  // wait until the tip is known so the probe has a real range
        post_bootstrap_rescan_pending_ = false;
        ui::Notifications::instance().info(TR("appx_bootstrap_complete_reconciling"));
        detectLowestAvailableBlockHeight([this](bool ok, int lowest, bool fullHistory) {
            if (ok && !fullHistory) {
                runtimeRescan(lowest);   // bootstrapped/pruned: rescan from the snapshot base
            } else {
                rescanBlockchain();      // full-history node (or probe failed): -rescan restart works
            }
        });
    }

    // Per-category refresh with tab-aware intervals
    // Skip when wallet is locked — same reason as above.
    if (rpcConnected && !state_.isLocked()) {
        const bool walletDataPage = currentPageNeedsWalletDataRefresh();
        if (!runtime_rescan_active_ && network_refresh_.consumeDue(RefreshTimer::Core)) {
            refreshCoreData();
        }
        // Skip balance/tx/address refresh during warmup — RPC calls fail with -28 — and during a
        // runtime rescan, when the daemon holds cs_main and these calls would block.
        if (!state_.warming_up && !runtime_rescan_active_) {
            if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
                if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
                    // Throttle the routine new-block full history rescan (z_listreceivedbyaddress —
                    // O(mapWallet), holds cs_main) by its measured cost, so a large wallet doesn't
                    // re-scan every block and starve connection near the tip. Bypass when there's an
                    // explicit need: a dirty set, an in-progress multi-cycle shielded scan (which must
                    // continue to completion), or an in-flight send — all need fresh data immediately.
                    const bool txExplicitNeed = transactions_dirty_ || shielded_history_scan_pending_ ||
                                                hasTransactionSendProgress() || !send_txids_.empty();
                    if (txExplicitNeed || txRefreshDue()) {
                        refreshTransactionData();
                    }
                } else if (walletDataPage && shouldRefreshRecentTransactions()) {
                    refreshRecentTransactionData();
                }
            }
            // 0-conf chat: a DEDICATED ~2.5s mempool scan of the chat address, independent of the page's
            // (slower, 10–15s) Transactions cadence — so incoming messages surface in a few seconds. The
            // normal harvest only re-scans on a new block. Self-gated (no-op without a chat identity /
            // connection / on lite); the in-flight guard keeps overlapping RPCs from stacking.
            chat_fast_scan_accum_ += ImGui::GetIO().DeltaTime;
            const float chatPoll = settings_ ? settings_->getChatPollRateSec() : 2.5f;  // user-configurable
            if (chat_fast_scan_accum_ >= chatPoll) {
                chat_fast_scan_accum_ = 0.0f;
                fastScanChatMemos();
            }
            if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
                // Explicit need (a changed address set, or an in-flight send tracking its change output)
                // must refresh now; the routine periodic poll on a wallet page is throttled by the last
                // address scan's measured cost (addressRefreshDue) — this is the z_listunspent hammering
                // that was starving cs_main while synced on a large wallet.
                if (addresses_dirty_ || hasTransactionSendProgress()) {
                    refreshAddressData();
                } else if (walletDataPage && addressRefreshDue()) {
                    refreshAddressData();
                }
            }
        }
        if (network_refresh_.consumeDue(RefreshTimer::Peers)) {
            refreshPeerInfo();
        }
    } else if (network_refresh_.consumeDue(RefreshTimer::Core)) {
        if (!connection_in_progress_ &&
            wizard_phase_ == WizardPhase::None &&
            !bootstrap_downloading_) {
            tryConnect();
        }
    }

    // Price refresh every 60 seconds
    if (network_refresh_.consumeDue(RefreshTimer::Price)) {
        if (settings_->getFetchPrices()) {
            refreshPrice();
            refreshMarketChart();  // self-throttled to ~30 min; keeps sparkline history fresh
        }
    }

    handleGlobalShortcuts();
}

void App::handleGlobalShortcuts()
{
    ImGuiIO& io = ImGui::GetIO();

    // Keyboard shortcut: Ctrl+, to open Settings page
    if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_Comma)) {
        setCurrentPage(ui::NavPage::Settings);
    }

    // Keyboard shortcut: Ctrl+Left/Right to cycle themes
    if (io.KeyCtrl && !io.WantTextInput) {
        bool prevTheme = ImGui::IsKeyPressed(ImGuiKey_LeftArrow);
        bool nextTheme = ImGui::IsKeyPressed(ImGuiKey_RightArrow);
        if (prevTheme || nextTheme) {
            auto& skinMgr = ui::schema::SkinManager::instance();
            const auto& skins = skinMgr.available();
            if (!skins.empty()) {
                int cur = 0;
                for (int i = 0; i < (int)skins.size(); i++) {
                    if (skins[i].id == skinMgr.activeSkinId()) { cur = i; break; }
                }
                if (prevTheme)
                    cur = (cur - 1 + (int)skins.size()) % (int)skins.size();
                else
                    cur = (cur + 1) % (int)skins.size();
                skinMgr.setActiveSkin(skins[cur].id);
                if (settings_) {
                    settings_->setSkinId(skins[cur].id);
                    settings_->save();
                }
                ui::Notifications::instance().info(std::string(TR("appx_theme_prefix")) + skins[cur].name);
            }
        }
    }

    // Keyboard shortcut: F5 to refresh
    if (ImGui::IsKeyPressed(ImGuiKey_F5)) {
        refreshNow();
    }

    // Keyboard shortcut: Ctrl+Shift+Down to toggle low-spec mode
    // (checked BEFORE Ctrl+Down so it doesn't also trigger theme effects)
    if (io.KeyCtrl && io.KeyShift && !io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
        bool newLow = !ui::effects::isLowSpecMode();
        ui::effects::setLowSpecMode(newLow);
        if (newLow) {
            // Disable all heavy effects at runtime (don't overwrite saved prefs)
            ui::effects::ImGuiAcrylic::ApplyBlurAmount(0.0f);
            ui::effects::ImGuiAcrylic::SetUIOpacity(1.0f);
            ui::effects::ThemeEffects::instance().setEnabled(false);
            ui::effects::ThemeEffects::instance().setReducedTransparency(true);
            if (settings_) {
                settings_->setLowSpecMode(true);
                settings_->setWindowOpacity(1.0f);
                settings_->save();
            }
        } else {
            // Restore effect settings from saved preferences
            if (settings_) {
                settings_->setLowSpecMode(false);
                settings_->save();
                ui::effects::ImGuiAcrylic::ApplyBlurAmount(settings_->getBlurMultiplier());
                ui::effects::ImGuiAcrylic::SetUIOpacity(settings_->getUIOpacity());
                ui::effects::ThemeEffects::instance().setEnabled(settings_->getThemeEffectsEnabled());
                ui::effects::ThemeEffects::instance().setReducedTransparency(!settings_->getThemeEffectsEnabled());
            }
        }
        ui::Notifications::instance().info(newLow ? TR("appx_low_spec_mode_enabled") : TR("appx_low_spec_mode_disabled"));
    }

    // Keyboard shortcut: Ctrl+Down to toggle theme effects (Shift excluded)
    else if (io.KeyCtrl && !io.KeyShift && !io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
        bool newState = !ui::effects::ThemeEffects::instance().isEnabled();
        ui::effects::ThemeEffects::instance().setEnabled(newState);
        ui::effects::ThemeEffects::instance().setReducedTransparency(!newState);
        if (settings_) {
            settings_->setThemeEffectsEnabled(newState);
            settings_->save();
        }
        ui::Notifications::instance().info(newState ? TR("appx_theme_effects_enabled") : TR("appx_theme_effects_disabled"));
    }

    // Keyboard shortcut: Ctrl+Up to toggle simple gradient background
    if (io.KeyCtrl && !io.KeyShift && !io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
        bool newGrad = !settings_->getGradientBackground();
        settings_->setGradientBackground(newGrad);
        ui::schema::SkinManager::instance().setGradientMode(newGrad);
        settings_->save();
        ui::Notifications::instance().info(newGrad ? TR("appx_simple_background_enabled") : TR("appx_simple_background_disabled"));
    }

    // Debug: Ctrl+Shift+W to re-show first-run wizard (set to false to disable)
    constexpr bool ENABLE_WIZARD_HOTKEY = false;
    if constexpr (ENABLE_WIZARD_HOTKEY) {
        if (io.KeyCtrl && io.KeyShift && ImGui::IsKeyPressed(ImGuiKey_W)) {
            wizard_phase_ = WizardPhase::Appearance;
            DEBUG_LOGF("[Debug] Wizard re-opened via Ctrl+Shift+W\n");
        }
    }
}

// Load (and recolor per theme) the DragonX header logo texture. Called at the top of render() — BEFORE
// the first-run-wizard / lock-screen paths — so every screen shows the mark. The SVG is recolored to the
// theme accent, so it re-rasterizes whenever the accent or the dark↔light variant changes.
void App::ensureLogoTexture()
{
    const bool wantDark = ui::material::IsDarkTheme();
    const ImU32 logoAccent = ui::material::Primary();
    if (logo_loaded_ && wantDark == logo_is_dark_variant_ && logoAccent == logo_accent_)
        return;   // already current
    logo_loaded_ = true;
    logo_is_dark_variant_ = wantDark;
    logo_accent_ = logoAccent;
    // The SVG's white highlight (detail) stays white on dark skins, but darkens to the theme's on-surface
    // colour on light skins so it doesn't wash out against a light card/background.
    const ImU32 detailCol = wantDark ? IM_COL32(255, 255, 255, 255) : ui::material::OnSurface();

    // ":drgx:" custom chat emoji — themed to the accent like the logo (re-rasterized on theme change).
    if (drgx_emoji_tex_) util::DestroyTexture(drgx_emoji_tex_);
    drgx_emoji_tex_ = 0; drgx_emoji_w_ = 0; drgx_emoji_h_ = 0;
    util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 96, logoAccent, detailCol,
                             &drgx_emoji_tex_, &drgx_emoji_w_, &drgx_emoji_h_);

    if (logo_tex_) util::DestroyTexture(logo_tex_);   // free the previous texture before replacing
    logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;

    // Coin/currency icon (the big DragonX mark on the balance card) — the SAME themed SVG recolored to
    // the accent, rasterized here so it changes with the skin too (done first, since the header block
    // below early-returns on success). Falls back to the PNG coin icon if rasterization fails.
    if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
    coin_logo_tex_ = 0; coin_logo_w_ = 0; coin_logo_h_ = 0;
    coin_logo_loaded_ = true;
    if (!util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 128, logoAccent, detailCol,
                                  &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
        auto coinElem = ui::schema::UI().drawElement("components.main-window", "coin-icon");
        auto cit = coinElem.extraColors.find("icon");
        std::string coinFile = (cit != coinElem.extraColors.end() && !cit->second.empty())
            ? cit->second : "logos/logo_dragonx_128.png";
        std::string coinPath = util::getExecutableDirectory() + "/res/img/" + coinFile;
        std::error_code coinEc;
        if (!(std::filesystem::exists(coinPath, coinEc) &&
              util::LoadTextureFromFile(coinPath.c_str(), &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_))) {
            std::string coinBasename = std::filesystem::path(coinFile).filename().string();
            const auto* coinRes = resources::getEmbeddedResource(coinBasename);
            if (coinRes && coinRes->data && coinRes->size > 0)
                util::LoadTextureFromMemory(coinRes->data, coinRes->size, &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_);
        }
    }

    // The header / top-left / About branding is the ObsidianDragon PRODUCT logo — NOT the DragonX coin
    // mark (that is coin_logo_tex_ / drgx_emoji_tex_ above). Resolve it below: active-skin override, else
    // the ui.toml header-icon, else the bundled ObsidianDragon dark/light PNG (disk, then embedded).

    // 1) theme-override logo from the active skin
    const auto* activeSkin = ui::schema::SkinManager::instance().findById(
        ui::schema::SkinManager::instance().activeSkinId());
    std::string logoPath;
    if (activeSkin && !activeSkin->logoPath.empty()) {
        logoPath = activeSkin->logoPath;
    } else {
        // 2) Read icon filename from ui.toml (dark/light variant)
        auto iconElem = ui::schema::UI().drawElement("components.main-window", "header-icon");
        const char* iconKey = wantDark ? "icon-dark" : "icon-light";
        auto it = iconElem.extraColors.find(iconKey);
        std::string iconFile;
        if (it != iconElem.extraColors.end() && !it->second.empty())
            iconFile = it->second;
        else
            iconFile = wantDark ? "logos/logo_ObsidianDragon_dark.png" : "logos/logo_ObsidianDragon_light.png";
        logoPath = util::getExecutableDirectory() + "/res/img/" + iconFile;
    }
    // Only attempt the disk read when the file is actually present (dev build / theme drop-in). The
    // portable single-file build has no res/img/ beside it, so skip straight to the embedded copy.
    std::error_code logoEc;
    if (std::filesystem::exists(logoPath, logoEc) &&
        util::LoadTextureFromFile(logoPath.c_str(), &logo_tex_, &logo_w_, &logo_h_)) {
        DEBUG_LOGF("Loaded header logo from %s (%dx%d)\n", logoPath.c_str(), logo_w_, logo_h_);
    } else {
        std::string embeddedName = std::filesystem::path(logoPath).filename().string();
        const auto* logoRes = resources::getEmbeddedResource(embeddedName);
        if (!logoRes || !logoRes->data || logoRes->size == 0)
            logoRes = resources::getEmbeddedResource(resources::RESOURCE_LOGO);
        if (logoRes && logoRes->data && logoRes->size > 0) {
            if (util::LoadTextureFromMemory(logoRes->data, logoRes->size, &logo_tex_, &logo_w_, &logo_h_))
                DEBUG_LOGF("Loaded header logo from embedded: %s (%dx%d)\n", embeddedName.c_str(), logo_w_, logo_h_);
            else
                DEBUG_LOGF("Note: Failed to decode embedded logo (text-only header)\n");
        } else {
            DEBUG_LOGF("Note: Header logo not found at %s (text-only header)\n", logoPath.c_str());
        }
    }
}

void App::render()
{
    // Advance the screenshot sweep FIRST — before the first-run-wizard early-return below — so the
    // sweep keeps progressing (and re-forces its target's state) even while a wizard step is shown.
    // (Pins current_page_ too, before the sidebar reads it further down.)
    updateScreenshotSweep();

    // DragonX logo — load/recolor before the wizard/lock early-returns so every screen shows it.
    ensureLogoTexture();

    // First-run wizard gate — blocks all normal UI
    if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) {
        renderFirstRunWizard();
        return;
    }

    // Handle wizard completion — start daemon and connect
    if (wizard_phase_ == WizardPhase::Done) {
        wizard_phase_ = WizardPhase::None;
        if (!state_.connected) {
            if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
                startEmbeddedDaemon();
            }
            tryConnect();
        }
        settings_->setWizardCompleted(true);
        settings_->save();
    }

    // Process deferred encryption from wizard (runs in background)
    processDeferredEncryption();

    // Surface the blockchain-delete completion on the main thread (the worker can't touch Notifications).
    {
        int deletedN = pending_delete_result_.exchange(-1, std::memory_order_relaxed);
        if (deletedN >= 0) {
            char deletedMsg[160];
            snprintf(deletedMsg, sizeof(deletedMsg), TR("appx_blockchain_data_deleted"), deletedN);
            ui::Notifications::instance().success(deletedMsg);
        }
    }


    // While any full-window BLUR overlay is open: (1) suppress panel theme-effects for the whole
    // frame so their foreground-draw-list borders (sidebar included) don't bleed over the overlay;
    // (2) mark a full-window blur overlay active so glass panels use their opaque fallback (they're
    // covered by the overlay backdrop, and this leaves the backdrop as the sole acrylic blur caller).
    // Driven by the shared framework's last-frame latch (modal-agnostic; timing-equivalent to the old
    // PortfolioEditorActive() signal). RAII-restored to the prior state on any return path.
    struct ModalRenderGuard {
        bool fxOn, wasEffects;
        explicit ModalRenderGuard(bool modalOpen) {
            wasEffects = ui::effects::ThemeEffects::instance().isEnabled();
            fxOn = modalOpen && wasEffects;
            if (fxOn) ui::effects::ThemeEffects::instance().setEnabled(false);
            ui::material::SetFullWindowBlurOverlayActive(modalOpen);
        }
        ~ModalRenderGuard() {
            if (fxOn) ui::effects::ThemeEffects::instance().setEnabled(true);
            ui::material::SetFullWindowBlurOverlayActive(false);
        }
    } modalGuard(ui::material::AnyBlurOverlayActiveLastFrame());

    // Main content area - use full window (no menu bar)
    ImGuiViewport* viewport = ImGui::GetMainViewport();
    ImGui::SetNextWindowPos(viewport->WorkPos);
    ImGui::SetNextWindowSize(viewport->WorkSize);
    
    ImGuiWindowFlags window_flags = ImGuiWindowFlags_NoTitleBar | 
                                    ImGuiWindowFlags_NoResize | 
                                    ImGuiWindowFlags_NoMove |
                                    ImGuiWindowFlags_NoCollapse |
                                    ImGuiWindowFlags_NoBringToFrontOnFocus |
                                    ImGuiWindowFlags_NoSavedSettings |
                                    ImGuiWindowFlags_NoScrollbar |
                                    ImGuiWindowFlags_NoScrollWithMouse;

    // When OS backdrop is active, use NoBackground so DWM Mica/Acrylic shows through
    if (ui::material::IsBackdropActive()) {
        window_flags |= ImGuiWindowFlags_NoBackground;
    }

    // Main window padding from ui.toml schema (DPI-scaled)
    const float hdp = ui::Layout::dpiScale();
    const auto& mwWin = ui::schema::UI().window("components.main-window");
    const float mainPadX   = (mwWin.padding[0] > 0.0f ? mwWin.padding[0] : 16.0f) * hdp;
    const float mainPadTop = (mwWin.padding[1] > 0.0f ? mwWin.padding[1] : 42.0f) * hdp;
    const float mainPadBot = ui::schema::UI().drawElement("components.main-window", "padding-bottom").size * hdp;
    ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(mainPadX, mainPadTop));
    ImGui::Begin("MainContent", nullptr, window_flags);
    ImGui::PopStyleVar(); // WindowPadding — applied to the window, safe to pop now

    // ---- Top-left branding: logo + "ObsidianDragon" title ----
    // Drawn via DrawList in the top padding area — zero layout impact.
    {
        ImDrawList* dl = ImGui::GetWindowDrawList();
        ImVec2 winPos = ImGui::GetWindowPos();
        const auto& S = ui::schema::UI();
        auto hdrElem = S.drawElement("components.main-window", "header-title");
        auto hdrLabel = S.label("components.main-window", "header-title");

        // Helper to read extraFloats with fallback (DPI-scaled)
        auto hdrF = [&](const char* key, float fb) -> float {
            auto it = hdrElem.extraFloats.find(key);
            return ((it != hdrElem.extraFloats.end()) ? it->second : fb) * hdp;
        };

        const float brandPadX = hdrF("pad-x", mainPadX / hdp);
        const float brandPadY = hdrF("pad-y", 8.0f);
        const float logoGap   = hdrF("logo-gap", 8.0f);
        const float brandOffY = hdrF("offset-y", 0.0f);
        const float hdrOpacity = (hdrElem.opacity >= 0.0f) ? hdrElem.opacity : 0.7f;

        // Logo
        float logoSize = mainPadTop - brandPadY * 2.0f; // fit within header
        if (logoSize < 16.0f * hdp) logoSize = 16.0f * hdp;
        float logoX = winPos.x + brandPadX;
        float logoY = winPos.y + brandPadY + brandOffY;
        if (logo_tex_ != 0) {
            float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
            float logoW = logoSize * aspect;
            dl->AddImage(logo_tex_,
                         ImVec2(logoX, logoY),
                         ImVec2(logoX + logoW, logoY + logoSize));
            logoX += logoW + logoGap;
        }

        // Title text — font and size from schema (DPI-scaled)
        ImFont* titleFont = S.resolveFont(hdrLabel.font);
        if (!titleFont) titleFont = ui::material::Type().subtitle1();
        // TOML size is in logical pixels; scale by DPI. Font's LegacySize
        // is already DPI-scaled from the atlas reload.
        float titleFontSize = hdrElem.size >= 0.0f
            ? hdrElem.size * hdp
            : titleFont->LegacySize;
        if (titleFont) {
            float textY = winPos.y + (mainPadTop - titleFontSize) * 0.5f + brandOffY;
            ImU32 textCol = ui::material::OnSurface();
            // Apply header text opacity
            int a = (int)((float)((textCol >> 24) & 0xFF) * hdrOpacity);
            textCol = (textCol & 0x00FFFFFF) | ((ImU32)a << 24);

            // Base brand word in a heavier (Ubuntu Medium) weight as the "semibold" —
            // the project ships no true 600 face. On the lite build the "Lite" suffix is
            // appended in the Regular title font.
            ImFont* baseFont = ui::material::Type().subtitle2();  // Ubuntu Medium
            if (!baseFont) baseFont = titleFont;
            const char* baseWord = "ObsidianDragon";
            dl->AddText(baseFont, titleFontSize, ImVec2(logoX, textY), textCol, baseWord);
#if DRAGONX_LITE_BUILD
            float baseW = baseFont->CalcTextSizeA(titleFontSize, FLT_MAX, 0.0f, baseWord).x;
            dl->AddText(titleFont, titleFontSize, ImVec2(logoX + baseW, textY), textCol, "Lite");
#endif
        }
    }

    // Sidebar + Content layout
    const float dp = ui::Layout::dpiScale();
    auto sbde = [dp](const char* key, float fb) {
        float v = ui::schema::UI().drawElement("components.sidebar", key).size;
        return (v >= 0 ? v : fb) * dp;
    };
    // Scale by dp to match the rendered status-bar child height (renderStatusBar), so the reserved
    // content strip stays in step with the bar at HiDPI instead of under-reserving.
    float statusBarH = ui::schema::UI().window("components.status-bar").height * dp;
    if (statusBarH <= 0.0f) statusBarH = 24.0f * dp;  // safety fallback
    // Content area padding from ui.toml schema
    const auto& caWin = ui::schema::UI().window("components.content-area");
    const float caMarginTop = ui::schema::UI().drawElement("components.content-area", "margin-top").size;
    const float caMarginBot = ui::schema::UI().drawElement("components.content-area", "margin-bottom").size;
    const float contentH = ImGui::GetContentRegionAvail().y - statusBarH - ImGui::GetStyle().ItemSpacing.y
                           - caMarginTop - caMarginBot;

    // Auto-collapse when viewport is narrow (skip if user manually toggled)
    float vpW = viewport->WorkSize.x;
    float collapseHysteresis = ui::schema::UI().drawElement("components.main-window", "collapse-hysteresis").sizeOr(60.0f) * dp;
    const float autoCollapseThreshold = sbde("auto-collapse-threshold", 800.0f);
    if (!sidebar_user_toggled_) {
        if (vpW < autoCollapseThreshold && !sidebar_collapsed_) {
            sidebar_collapsed_ = true;
        } else if (vpW >= autoCollapseThreshold + collapseHysteresis && sidebar_collapsed_) {
            sidebar_collapsed_ = false;
        }
    } else {
        // Reset manual override when viewport crosses the threshold significantly,
        // so auto-collapse resumes after the user resizes the window
        if ((!sidebar_collapsed_ && vpW >= autoCollapseThreshold + collapseHysteresis) ||
            (sidebar_collapsed_ && vpW < autoCollapseThreshold)) {
            sidebar_user_toggled_ = false;
        }
    }

    // Animate sidebar width
    float targetW = sidebar_collapsed_ ? sbde("collapsed-width", 64.0f) : sbde("width", 160.0f);
    // On DPI change, snap instantly instead of animating from stale value
    bool dpiChanged = (prev_dpi_scale_ > 0.0f && std::abs(dp - prev_dpi_scale_) > 0.01f);
    prev_dpi_scale_ = dp;
    if (sidebar_width_anim_ <= 0.0f || dpiChanged) sidebar_width_anim_ = targetW;
    {
        float diff = targetW - sidebar_width_anim_;
        float dt = ImGui::GetIO().DeltaTime;
        float t = dt * sbde("collapse-anim-speed", 10.0f);
        if (t > 1.0f) t = 1.0f;
        sidebar_width_anim_ += diff * t;
    }
    const float sidebarW = sidebar_width_anim_;

    // Build sidebar status for badges + footer
    ui::SidebarStatus sbStatus;
    sbStatus.peerCount   = static_cast<int>(state_.peers.size());
    sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running;
    sbStatus.chatUnreadCount = chatUnreadCount();   // unread badge on the Chat nav item (Q1)

    // (DragonX logo is loaded at the top of render() via ensureLogoTexture().)

    // (coin logo is loaded/themed at the top of render() via ensureLogoTexture().)

    if (logo_tex_ != 0) {
        sbStatus.logoTexID = logo_tex_;
        sbStatus.logoW = logo_w_;
        sbStatus.logoH = logo_h_;
    }
    if (gradient_tex_ != 0) {
        sbStatus.gradientTexID = gradient_tex_;
    }
    // Count unconfirmed transactions (pending in mempool, not conflicted/orphaned).
    // A transaction can appear as multiple legs sharing one txid (e.g. an autoshield send+receive),
    // and a leg parsed from z_viewtransaction may carry confirmations=0 even when the transaction is
    // long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is
    // confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf
    // legs of already-confirmed transactions and double-counts multi-leg ones.
    // Recompute only when the tx list actually changed (last_tx_update is bumped on every tx refresh;
    // size() catches same-second content changes). Otherwise this built two unordered_sets over the whole
    // wallet tx list every frame just to size a badge.
    if (state_.last_tx_update != sb_unconf_key_ts_ || state_.transactions.size() != sb_unconf_key_n_) {
        std::unordered_set<std::string> confirmedTxids;
        for (const auto& tx : state_.transactions) {
            if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid);
        }
        std::unordered_set<std::string> unconfirmedTxids;
        for (const auto& tx : state_.transactions) {
            if (tx.confirmations == 0 && confirmedTxids.find(tx.txid) == confirmedTxids.end()) {
                unconfirmedTxids.insert(tx.txid);
            }
        }
        sb_unconf_count_  = static_cast<int>(unconfirmedTxids.size());
        sb_unconf_key_ts_ = state_.last_tx_update;
        sb_unconf_key_n_  = state_.transactions.size();
    }
    sbStatus.unconfirmedTxCount = sb_unconf_count_;

    // Sidebar minimum height from ui.toml schema (DPI-scaled).
    const float sbMinHeight = sbde("min-height", 360.0f);

    // Save cursor Y before the sidebar so the content area (restored below) starts at the original row.
    float preSidebarCursorY = ImGui::GetCursorPosY();

    // Size the sidebar to span from its own top down to the status-bar top, so the nav panel centers
    // within the TRUE visible area (equal top/bottom gaps). Do NOT derive it from contentH (inset by the
    // content-area's edge-fade margins) and do NOT apply the legacy sidebar margin-top/-bottom (they are
    // asymmetric, -12 / +40, and pushed the panel upward). Window-local reference (matches how the status
    // bar is positioned — GetWindowPos/GetWindowSize, not GetMainViewport) so it is correct on every platform.
    float sbWindowBottom = ImGui::GetWindowPos().y + ImGui::GetWindowSize().y;
    float sbStatusTopY   = sbWindowBottom - statusBarH - mainPadBot;
    float sbChildTopY    = ImGui::GetCursorScreenPos().y;
    float sidebarH = std::max(sbMinHeight, sbStatusTopY - sbChildTopY);
    bool prevCollapsed = sidebar_collapsed_;
    {
        PERF_SCOPE("Render.Sidebar");
        ui::NavPage requestedPage = current_page_;
        ui::RenderSidebar(requestedPage, sidebarW, sidebarH, sbStatus, sidebar_collapsed_,
                          state_.isLocked());
        if (requestedPage != current_page_) {
            setCurrentPage(requestedPage);
        }
    }
    if (sbStatus.exitClicked) {
        requestQuit();
    }
    if (sidebar_collapsed_ != prevCollapsed) {
        sidebar_user_toggled_ = true;  // user clicked chevron or pressed [
    }

    ImGui::SameLine();
    // Restore cursor Y so content area is not shifted by sidebar margin
    ImGui::SetCursorPosY(preSidebarCursorY);

    // Page transition: detect change, ramp alpha
    if (current_page_ != prev_page_) {
        page_alpha_ = (ui::effects::isLowSpecMode() || (settings_ && settings_->getReduceMotion())) ? 1.0f : 0.0f;
        // Switching INTO the console → put the cursor in the command box (toggleable). Done here at the
        // transition (not in the console render) because prev_page_ is updated below; the console renders
        // later this same frame and consumes the one-shot request.
        if ((current_page_ == ui::NavPage::Console || current_page_ == ui::NavPage::LiteConsole)
            && settings_ && settings_->getConsoleAutoFocus())
            console_tab_.requestInputFocus();
        // Leaving the Mining tab → cancel a running thread benchmark so the miner isn't abandoned at a
        // benchmark step. (L-04)
        if (prev_page_ == ui::NavPage::Mining && current_page_ != ui::NavPage::Mining)
            ui::CancelMiningBenchmark(this);
        prev_page_  = current_page_;
    }
    if (page_alpha_ < 1.0f) {
        float dt = ImGui::GetIO().DeltaTime;
        float pageFadeSpeed = ui::schema::UI().drawElement("components.main-window", "page-fade-speed").sizeOr(8.0f);
        page_alpha_ += dt * pageFadeSpeed;
        if (page_alpha_ > 1.0f) page_alpha_ = 1.0f;
    }

    // Content area — fills remaining width, tabs handle their own scrolling
    ImGui::SetCursorPosY(ImGui::GetCursorPosY() + caMarginTop);
    ImGui::PushStyleVar(ImGuiStyleVar_Alpha,
        ImGui::GetStyle().Alpha * page_alpha_);
    ImGuiWindowFlags contentFlags = ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse;
    if (ui::material::IsBackdropActive())
        contentFlags |= ImGuiWindowFlags_NoBackground;
    float caPadX = caWin.padding[0] > 0.0f ? caWin.padding[0] : ImGui::GetStyle().WindowPadding.x;
    float caPadY = caWin.padding[1] > 0.0f ? caWin.padding[1] : ImGui::GetStyle().WindowPadding.y;
    ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(caPadX, caPadY));

    // Cap the content column to a readable max width and center it in wider windows. Every tab derives
    // its cards/forms/tables from this child's width, so an uncapped fill stretches them edge-to-edge on
    // wide/ultrawide displays. No-op below the cap (fills as before).
    float caAvailW = ImGui::GetContentRegionAvail().x;
    float caMaxW   = ui::Layout::kContentMaxWidth();
    float caChildW = 0.0f;   // 0 -> fill remaining width (default, and whenever the window is below the cap)
    if (caMaxW > 0.0f && caAvailW > caMaxW) {
        caChildW = caMaxW;
        ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (caAvailW - caMaxW) * 0.5f);
    }

    // Capture content area screen position for edge fade mask (after any centering offset)
    ImVec2 caScreenPos = ImGui::GetCursorScreenPos();

    ImGui::BeginChild("##ContentArea", ImVec2(caChildW, contentH), false, contentFlags);

    // Persistent node/RPC error banner — drawn first (before the edge-fade vertex capture below,
    // so it stays fully opaque) and above every page / overlay in the content column. It renders
    // nothing and consumes no space while the node is reachable.
    renderNodeStatusBanner();

    // Capture vertex start for edge fade mask
    ImDrawList* caDL = ImGui::GetWindowDrawList();
    int caVtxStart = caDL->VtxBuffer.Size;

    // Also capture ForegroundDrawList vertex start — DrawGlassPanel draws
    // theme effects (rainbow border, edge trace, shimmer, specular glare)
    // on the ForegroundDrawList, and those must also be edge-faded.
    ImDrawList* fgDL = ImGui::GetForegroundDrawList();
    int fgVtxStart = fgDL->VtxBuffer.Size;

    // ---------------------------------------------------------------
    // Loading overlay — show only on tabs that truly need wallet data.
    // Tabs that work without full daemon connection:
    //   Console, Peers, Settings, Market, Mining
    // Tabs that need balance/transaction data (show overlay):
    //   Overview, Send, Receive, History
    // ---------------------------------------------------------------
    bool pageNeedsWalletData = wallet::uiSurfaceNeedsWalletData(ui::NavPageSurface(current_page_));
    bool daemonReady = state_.connected && !state_.warming_up;

    // Don't show lock screen while pool mining — xmrig runs independently
    // of the wallet and locking would block the mining UI needlessly.
    bool poolMiningActive = xmrig_manager_ && xmrig_manager_->isRunning();
    if (state_.isLocked() && !poolMiningActive) {
        // Lock screen — covers tab content just like the loading overlay
        renderLockScreen();
    } else if (pageNeedsWalletData && state_.warming_up) {
        // Daemon is reachable but still initializing — show warmup overlay
        // only on wallet-data tabs. Other tabs render normally.
        lock_screen_was_visible_ = false;
        renderLoadingOverlay(contentH);
    } else if (pageNeedsWalletData && (!daemonReady || (state_.connected && !state_.encryption_state_known))) {
        // Track how long we've been waiting for encryption state
        if (state_.connected && !state_.encryption_state_known) {
            encryption_check_timer_ += ImGui::GetIO().DeltaTime;
            if (encryption_check_timer_ >= 15.0f) {
                DEBUG_LOGF("[App] Encryption state check timed out after 15s — assuming unencrypted\n");
                state_.encryption_state_known = true;
                state_.encrypted = false;
                encryption_check_timer_ = 0.0f;
            }
        } else {
            encryption_check_timer_ = 0.0f;
        }
        // Reset lock screen focus flag so it auto-focuses next time
        lock_screen_was_visible_ = false;
        // Show loading overlay instead of tab content
        renderLoadingOverlay(contentH);
    } else {
        lock_screen_was_visible_ = false;

    {
        PERF_SCOPE("Render.ActiveTab");
    switch (current_page_) {
        case ui::NavPage::Overview:
            ui::RenderBalanceTab(this);
            break;
        case ui::NavPage::Send:
            ui::RenderSendTab(this);
            break;
        case ui::NavPage::Receive:
            ui::RenderReceiveTab(this);
            break;
        case ui::NavPage::History:
            ui::RenderTransactionsTab(this);
            break;
        case ui::NavPage::Contacts:
            ui::RenderContactsTab(this);
            break;
        case ui::NavPage::Chat:
            ui::RenderChatTab(this);
            break;
        case ui::NavPage::Mining:
            ui::RenderMiningTab(this);
            break;
        case ui::NavPage::Peers:
            ui::RenderPeersTab(this);
            break;
        case ui::NavPage::LiteNetwork:
            ui::RenderLiteNetworkTab(this);
            break;
        case ui::NavPage::Explorer:
            ui::RenderExplorerTab(this);
            break;
        case ui::NavPage::Market:
            ui::RenderMarketTab(this);
            break;
        // One console UI for both variants; the executor supplies the backend (full-node
        // RPC vs lite). The two NavPages are mutually exclusive per build.
        case ui::NavPage::LiteConsole:
        case ui::NavPage::Console:
            if (!console_exec_) {
                if (isLiteBuild())
                    console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
                else
                    console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
            }
            console_tab_.render(*console_exec_);
            // Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
            // a value actually changed — this runs every frame the console is visible and save() is
            // disk I/O — and only here in the Console case where these statics can change.
            if (settings_) {
                if (ui::ConsoleTab::s_line_accents_enabled != settings_->getConsoleLineAccents() ||
                    ui::ConsoleTab::s_line_text_color_enabled != settings_->getConsoleTextColor() ||
                    ui::ConsoleTab::s_console_zoom != settings_->getConsoleZoom()) {
                    settings_->setConsoleLineAccents(ui::ConsoleTab::s_line_accents_enabled);
                    settings_->setConsoleTextColor(ui::ConsoleTab::s_line_text_color_enabled);
                    settings_->setConsoleZoom(ui::ConsoleTab::s_console_zoom);
                    settings_->save();
                }
            }
            break;
        case ui::NavPage::Settings:
            ui::RenderSettingsPage(this);
            break;
        default:
            break;
    }
    } // PERF_SCOPE

    } // end loading gate

    // Snapshot ForegroundDrawList vertex count BEFORE viewport-wide effects
    // so the edge fade only applies to per-panel theme effects, not embers/overlay.
    int fgVtxEnd = fgDL->VtxBuffer.Size;

    // Viewport-wide ambient theme effects (e.g. ember particles for fire themes)
    {
        PERF_SCOPE("Render.ThemeEffects");
        auto& fx = ui::effects::ThemeEffects::instance();
        ImDrawList* fgDl = ImGui::GetForegroundDrawList();

        if (fx.hasEmberRise()) {
            fx.drawViewportEmbers(fgDl);
        }

        // Sandstorm particles (wind-driven sand for desert themes)
        if (fx.hasSandstorm()) {
            fx.drawSandstorm(fgDl);
        }

        // Shader-like post-processing overlay (color wash + vignette)
        if (fx.hasViewportOverlay()) {
            fx.drawViewportOverlay(fgDl);
        }
    }

    // Apply edge fade mask to content area — soft transparency at top/bottom edges
    // so content doesn't appear sharply clipped at the content area boundary.
    {
        PERF_SCOPE("Render.EdgeFade");
        float caTopEdge = caScreenPos.y;
        float caBottomEdge = caScreenPos.y + contentH;
        // Cache the schema lookup — value only changes on theme reload
        static uint32_t s_fadeGen = 0;
        static float    s_fadeZone = 0.0f;
        uint32_t curGen = ui::schema::UI().generation();
        if (curGen != s_fadeGen) {
            s_fadeGen = curGen;
            s_fadeZone = ui::schema::UI().drawElement("components.content-area", "edge-fade-zone").size;
        }
        float fadeZone = s_fadeZone * ui::Layout::dpiScale();
        // fadeZone <= 0 disables the effect entirely
        if (fadeZone > 0.0f) {
            // Pre-compute safe zone — vertices with y fully inside don't need any work
            float safeTop = caTopEdge + fadeZone;
            float safeBot = caBottomEdge - fadeZone;

            // Lambda to fade a range of vertices in a given draw list
            auto fadeVerts = [&](ImDrawList* dl, int vtxStart, int vtxEnd) {
                for (int vi = vtxStart; vi < vtxEnd; vi++) {
                    ImDrawVert& v = dl->VtxBuffer[vi];
                    // Skip vertices in the safe zone (the common case)
                    if (v.pos.y >= safeTop && v.pos.y <= safeBot)
                        continue;

                    float alpha = 1.0f;

                    // Top fade
                    float dTop = v.pos.y - caTopEdge;
                    if (dTop < fadeZone)
                        alpha = std::min(alpha, std::max(0.0f, dTop / fadeZone));

                    // Bottom fade
                    float dBot = caBottomEdge - v.pos.y;
                    if (dBot < fadeZone)
                        alpha = std::min(alpha, std::max(0.0f, dBot / fadeZone));

                    if (alpha < 1.0f) {
                        int a = (v.col >> IM_COL32_A_SHIFT) & 0xFF;
                        a = static_cast<int>(a * alpha);
                        v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
                    }
                }
            };
            // Apply to foreground panel effects (rainbow borders, edge trace,
            // shimmer, specular glare) — but NOT viewport-wide effects (embers,
            // overlay) which were added after fgVtxEnd.
            fadeVerts(fgDL, fgVtxStart, fgVtxEnd);
        }
    }

    // Page-transition alpha: during page switches page_alpha_ ramps 0→1.
    // ImGuiStyleVar_Alpha only affects widget rendering inside the content
    // child window.  ForegroundDrawList effects (per-panel AND viewport-wide)
    // bypass it, so we manually scale their vertex alpha here.
    if (page_alpha_ < 1.0f) {
        int fgEnd = fgDL->VtxBuffer.Size;
        for (int vi = fgVtxStart; vi < fgEnd; vi++) {
            ImDrawVert& v = fgDL->VtxBuffer[vi];
            int a = (v.col >> IM_COL32_A_SHIFT) & 0xFF;
            a = static_cast<int>(a * page_alpha_);
            v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
        }
    }

    ImGui::EndChild();
    ImGui::PopStyleVar(); // WindowPadding (content area)
    ImGui::PopStyleVar(); // Alpha

    // Status bar — anchored to the bottom of the main window
    // Use mainPadX/mainPadBot (the actual MainContent window padding) because the
    // style var was already popped and GetStyle().WindowPadding is now wrong.
    {
        PERF_SCOPE("Render.StatusBar");
        float windowBottom = ImGui::GetWindowPos().y + ImGui::GetWindowSize().y;
        float statusY = windowBottom - statusBarH - mainPadBot;
        ImGui::SetCursorScreenPos(ImVec2(ImGui::GetWindowPos().x + mainPadX, statusY));
        renderStatusBar();
    }

    // ====================================================================
    // Modal dialogs — rendered INSIDE "MainContent" window scope so that
    // ImGui's modal dim-layer and input blocking covers the sidebar,
    // content area, status bar, and every other widget in this window.
    // ====================================================================
    PERF_BEGIN(_perfDialogs);
    if (show_settings_) {
        ui::RenderSettingsWindow(this, &show_settings_);
    }

    if (show_about_) {
        ui::RenderAboutDialog(this, &show_about_);
    }

    if (show_faq_) {
        ui::RenderFaqDialog(this, &show_faq_);
    }

    // Lite first-run welcome: prompt to create/restore when no wallet file exists yet.
    renderLiteFirstRunPrompt();
    // Lite send-time unlock prompt (shown when a spend is attempted on a locked wallet).
    renderLiteUnlockPrompt();

    if (show_import_key_) {
        renderImportKeyDialog();
    }
    
    if (show_export_key_) {
        renderExportKeyDialog();
    }
    
    if (show_backup_) {
        renderBackupDialog();
    }

    if (show_seed_backup_) {
        renderSeedBackupDialog();
    }

    if (show_seed_migration_) {
        renderSeedMigrationDialog();
    }

    if (show_daemon_update_prompt_ && !capture_mode_) {
        renderDaemonUpdatePrompt();
    }

    // Security overlay dialogs (encrypt, decrypt, PIN) are rendered AFTER ImGui::End()
    // to ensure they appear on top of all other content

    // Send confirm popup
    ui::RenderSendConfirmPopup(this);

    // Console command-reference popup (full-node RPC reference / lite backend verbs).
    console_tab_.renderCommandsPopupModal(console_exec_.get());

    // Key export dialog (triggered from balance tab context menu)
    ui::KeyExportDialog::render(this);
    
    // Transaction details dialog (triggered from transactions tab)
    ui::TransactionDetailsDialog::render(this);
    
    // QR code popup dialog (triggered from balance tab)
    ui::QRPopupDialog::render(this);
    
    // Validate address dialog (triggered from Edit menu)
    ui::ValidateAddressDialog::render(this);

    // Shield/merge dialog (triggered from Wallet menu)
    ui::ShieldDialog::render(this);
    
    // Request payment dialog (triggered from Wallet menu)
    ui::RequestPaymentDialog::render(this);
    
    // Block info dialog (triggered from View menu)
    ui::BlockInfoDialog::render(this);
    
    // Export all keys dialog (triggered from File menu)
    ui::ExportAllKeysDialog::render(this);
    
    // Export transactions to CSV dialog (triggered from File menu)
    ui::ExportTransactionsDialog::render(this);

    // Address label/icon editor
    ui::AddressLabelDialog::render();

    // Address-to-address transfer confirmation
    ui::AddressTransferDialog::render();

    // Bootstrap download from settings
    ui::BootstrapDownloadDialog::render();
    ui::XmrigDownloadDialog::render();
    ui::DaemonUpdateDialog::render();
    ui::WalletsDialog::render();

    // Windows Defender antivirus help dialog
    renderAntivirusHelpDialog();
    
    PERF_END("Render.Dialogs", _perfDialogs);

    ImGui::End();

    // Debug: ImGui demo window
    if (show_demo_window_) {
        ImGui::ShowDemoWindow(&show_demo_window_);
    }

    // Security overlay dialogs (must render LAST to be on top of everything)
    renderEncryptWalletDialog();
    renderDecryptWalletDialog();
    renderPinDialogs();
    renderSwitchStopDaemonDialog();
    renderDaemonStopConfirm();
    renderBlockDbReindexDialog();
    renderWalletRecoveredDialog();
    renderEmptyWalletWarningDialog();

    // Render notifications (toast messages)
    ui::Notifications::instance().render();

    // Latch whether any blur overlay was drawn this frame (for next frame's ModalRenderGuard) and, on
    // the overlay's close transition, invalidate the acrylic capture so glass panels re-blur cleanly.
    ui::material::LatchBlurOverlayActive();
}

void App::renderNodeStatusBanner()
{
    namespace m = ui::material;

    // Suppress during flows that legitimately have no connection, so the banner never contradicts
    // an overlay the app is already showing: the first-run wizard (no daemon started yet), a
    // wallet switch, an in-flight daemon restart, the screenshot sweep (forces demo state), and
    // shutdown. tryConnect() sets connection_in_progress_ before the first render on normal
    // startup, so the ordinary boot path is covered by the evaluator's own in-progress guard.
    if (capture_mode_ || isShuttingDown()) return;
    if (getWizardPhase() != WizardPhase::None) return;
    if (wallet_switch_phase_.load() != 0) return;
    if (daemon_restarting_.load()) return;

    ui::NodeBannerInputs in;
    in.lite                   = isLiteBuild();
    in.connected              = state_.connected;
    in.warming_up             = state_.warming_up;
    in.daemon_initializing    = state_.daemon_initializing;
    in.connection_in_progress = connection_in_progress_;
    in.using_embedded_daemon  = isUsingEmbeddedDaemon();
    in.has_daemon_controller  = (daemon_controller_ != nullptr);
    in.daemon_running         = isEmbeddedDaemonRunning();
    in.daemon_crash_count     = daemon_controller_ ? daemon_controller_->crashCount() : 0;
    in.connection_status      = connection_status_;
    in.daemon_last_error      = daemon_controller_ ? daemon_controller_->lastError() : std::string();
    in.lite_open_error        = lite_open_error_;
    in.tip_known              = state_.sync.tip_known;
    in.blocks                 = state_.sync.blocks;

    const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
    if (!banner.show) return;

    const auto& S       = ui::schema::UI();
    const float minH    = S.drawElement("banners.node-status", "min-height").size;
    const float baseH   = S.drawElement("banners.node-status", "height").size;

    // Fonts up-front so the banner height can never be shorter than the glyph row it vertically
    // centers — otherwise the title/icon draw above the child's top clip rect and slice off (seen at
    // HiDPI / large font scale, where the DPI-baked glyphs outgrow the schema height). Metrics scaled.
    ImFont* icoFont = m::Type().iconSmall();
    ImFont* txtFont = m::Type().body2();
    const float glyphH = std::max(icoFont ? icoFont->LegacySize : 0.0f,
                                  txtFont ? txtFont->LegacySize : 0.0f);
    // Both operands must be in scaled px: vScale() already folds in dpiScale(), so the raw min-height
    // floor needs the same dpiScale() or it under-clamps the banner at HiDPI.
    float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale());
    bannerH = std::max(bannerH, glyphH + ui::Layout::spacingSm() * 2.0f);  // never shorter than text

    const bool  isError = (banner.severity == ui::NodeBannerSeverity::Error);
    const ImU32 sevCol  = isError ? m::Error() : m::Warning();
    const ImU32 bgCol   = m::WithAlphaF(sevCol, isError ? 0.20f : 0.15f);

    // Translated headline for the reason; `detail` is the live status text (may be empty).
    const char* title;
    const char* icon;
    switch (banner.reason) {
        case ui::NodeBannerReason::DaemonCrashed:
            title = TR("node_banner_crashed_title");     icon = ICON_MD_ERROR;     break;
        case ui::NodeBannerReason::LiteOpenFailed:
            title = TR("node_banner_lite_open_failed");   icon = ICON_MD_ERROR;     break;
        case ui::NodeBannerReason::NoPeers:
            title = TR("node_banner_no_peers_title");     icon = ICON_MD_CLOUD_OFF; break;
        case ui::NodeBannerReason::FullNodeOffline:
        default:
            title = TR("node_banner_offline_title");      icon = ICON_MD_CLOUD_OFF; break;
    }

    const char* actionLabel = nullptr;
    if      (banner.action == ui::NodeBannerAction::Reconnect)   actionLabel = TR("node_banner_reconnect");
    else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");

    const float padX = ui::Layout::spacingLg();

    ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgCol));
    ImGui::BeginChild("##NodeStatusBanner",
                      ImVec2(ImGui::GetContentRegionAvail().x, bannerH), false,
                      ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
    const float winW = ImGui::GetWindowSize().x;

    // Icon — centered on its own metrics (clamped so it never draws above the child's top).
    ImGui::SetCursorPos(ImVec2(padX, std::max(0.0f, (bannerH - icoFont->LegacySize) * 0.5f)));
    ImGui::PushFont(icoFont);
    ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
    ImGui::TextUnformatted(icon);
    ImGui::PopStyleColor();
    ImGui::PopFont();

    const float txtCy = std::max(0.0f, (bannerH - txtFont->LegacySize) * 0.5f);

    // Right-aligned action button geometry (measured first so the detail text can be clipped to
    // never run underneath it).
    float btnW = 0.0f, btnH = 0.0f, actionReserve = 0.0f;
    if (actionLabel) {
        btnH = std::max(0.0f, bannerH - ui::Layout::spacingSm() * 2.0f);
        btnW = ImGui::CalcTextSize(actionLabel).x + ui::Layout::spacingLg() * 1.6f;
        actionReserve = btnW + padX + ui::Layout::spacingMd();
    }

    // Title.
    ImGui::SameLine(0.0f, ui::Layout::spacingSm());
    ImGui::SetCursorPosY(txtCy);
    ImGui::PushFont(txtFont);
    ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
    ImGui::TextUnformatted(title);
    ImGui::PopStyleColor();

    // Detail (dim) on the same row, clipped with an ellipsis so it can't push the button off-screen.
    if (!banner.detail.empty()) {
        ImGui::SameLine(0.0f, ui::Layout::spacingSm());
        ImGui::SetCursorPosY(txtCy);
        const float budget = winW - ImGui::GetCursorPosX() - actionReserve;
        if (budget > ImGui::CalcTextSize("W").x) {
            const std::string prefix = "\xC2\xB7  ";            // "·  "
            std::string detail = banner.detail;
            std::string shown  = prefix + detail;
            if (ImGui::CalcTextSize(shown.c_str()).x > budget) {
                const std::string ell = "\xE2\x80\xA6";         // "…"
                while (!detail.empty() &&
                       ImGui::CalcTextSize((prefix + detail + ell).c_str()).x > budget) {
                    detail.pop_back();
                    while (!detail.empty() &&
                           (static_cast<unsigned char>(detail.back()) & 0xC0) == 0x80)
                        detail.pop_back();  // drop the whole trailing UTF-8 code point
                }
                shown = prefix + detail + ell;
            }
            ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceMedium());
            ImGui::TextUnformatted(shown.c_str());
            ImGui::PopStyleColor();
        }
    }
    ImGui::PopFont();

    // Action button.
    if (actionLabel) {
        ImGui::SetCursorPos(ImVec2(winW - btnW - padX, std::max(0.0f, (bannerH - btnH) * 0.5f)));
        if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
            if      (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
            else if (banner.action == ui::NodeBannerAction::Reconnect)   tryConnect();
        }
    }

    ImGui::EndChild();
    ImGui::PopStyleColor();
}

void App::renderAlertHistoryPanel()
{
    namespace m = ui::material;
    const float dp = ui::Layout::dpiScale();
    auto& notes = ui::Notifications::instance();
    const auto& hist = notes.history();
    const float innerW = ImGui::GetContentRegionAvail().x;
    const float padX = 8.0f * dp;
    const float padY = 8.0f * dp;

    ImFont* icoF = m::Type().iconSmall();
    ImFont* txtF = m::Type().caption();

    ImGui::Dummy(ImVec2(0.0f, padY));  // breathing room above the content

    // Header: "Recent alerts" on the left, a Clear-all icon button on the right.
    ImGui::SetCursorPosX(padX);
    ImGui::PushFont(txtF);
    ImGui::TextDisabled("%s", TR("alerts_recent"));
    ImGui::PopFont();
    if (!hist.empty()) {
        const float clrW = icoF->LegacySize + 8.0f * dp;
        ImGui::SameLine();
        ImGui::SetCursorPosX(innerW - clrW);
        m::IconButtonStyle cst;
        cst.color      = m::OnSurfaceMedium();
        cst.hoverColor = m::OnSurface();
        cst.hoverBg    = m::StateHover();
        cst.bgRounding = 4.0f * dp;
        cst.tooltip    = TR("alerts_clear");
        if (m::IconButton("##ClearAlerts", ICON_MD_CLEAR_ALL, icoF,
                          ImVec2(clrW, icoF->LegacySize + 4.0f * dp), cst)) {
            notes.clearHistory();
            alerts_seen_total_ = notes.totalPushed();
        }
    }
    ImGui::Separator();

    if (hist.empty()) {
        ImGui::SetCursorPosX(padX);
        ImGui::PushFont(txtF);
        ImGui::TextDisabled("%s", TR("alerts_none"));
        ImGui::PopFont();
        ImGui::Dummy(ImVec2(0.0f, padY));  // breathing room below the content
        return;
    }

    // Scrollable list, newest first. Measure the TRUE content height so wrapped (multi-line) messages
    // and optional action links aren't clipped by an under-estimate; cap so a busy session scrolls
    // inside the panel instead of blowing past the popup's max height.
    const float msgWrapW = std::max(40.0f * dp, innerW - 2.0f * padX - icoF->LegacySize - 6.0f * dp);
    float contentH = 0.0f;
    for (const auto& a : hist) {
        const float msgH = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, msgWrapW, a.message.c_str()).y;
        contentH += std::max(msgH, static_cast<float>(icoF->LegacySize));      // icon + wrapped message
        contentH += txtF->LegacySize;                                          // relative-age line
        if (a.onClick && !a.actionHint.empty()) contentH += txtF->LegacySize;  // action-link line
        contentH += 8.0f * dp;                                                 // inter-entry spacing
    }
    const float listH = std::min(300.0f * dp, contentH);
    ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false);
    int idx = 0;
    for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) {
        const ui::AlertRecord& a = *it;
        ImU32 col; const char* icon;
        switch (a.type) {
            case ui::NotificationType::Success: col = m::Success(); icon = ICON_MD_CHECK_CIRCLE; break;
            case ui::NotificationType::Warning: col = m::Warning(); icon = ICON_MD_WARNING;      break;
            case ui::NotificationType::Error:   col = m::Error();   icon = ICON_MD_ERROR;        break;
            case ui::NotificationType::Info:
            default:                            col = m::Primary(); icon = ICON_MD_INFO;         break;
        }
        ImGui::PushID(idx);
        // Icon + message (message wraps in the remaining width).
        ImGui::SetCursorPosX(padX);
        ImGui::PushFont(icoF);
        ImGui::PushStyleColor(ImGuiCol_Text, col);
        ImGui::TextUnformatted(icon);
        ImGui::PopStyleColor();
        ImGui::PopFont();
        ImGui::SameLine(0.0f, 6.0f * dp);
        ImGui::PushFont(txtF);
        ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurface());
        ImGui::PushTextWrapPos(innerW - padX);
        ImGui::TextWrapped("%s", a.message.c_str());
        ImGui::PopTextWrapPos();
        ImGui::PopStyleColor();
        // Optional clickable action (accent link), directly under the message so it stays prominent.
        if (a.onClick && !a.actionHint.empty()) {
            ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
            ImGui::PushStyleColor(ImGuiCol_Text, m::Primary());
            ImGui::TextUnformatted(a.actionHint.c_str());
            ImGui::PopStyleColor();
            if (ImGui::IsItemHovered()) {
                const ImVec2 lmn = ImGui::GetItemRectMin(), lmx = ImGui::GetItemRectMax();
                ImGui::GetWindowDrawList()->AddLine(ImVec2(lmn.x, lmx.y), ImVec2(lmx.x, lmx.y), m::Primary());
                ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
            }
            if (ImGui::IsItemClicked()) { a.onClick(); ImGui::CloseCurrentPopup(); }
        }
        // Relative age, dim, indented under the message.
        ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
        ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled());
        ImGui::TextUnformatted(util::formatTimeAgoShort(a.epoch).c_str());
        ImGui::PopStyleColor();
        ImGui::PopFont();
        ImGui::PopID();
        ImGui::Spacing();
    }
    ImGui::EndChild();
    ImGui::Dummy(ImVec2(0.0f, padY));  // breathing room below the content
}

void App::renderStatusBar()
{
    // Status bar layout from unified UI schema
    const auto& S = ui::schema::UI();
    const auto& sbWin              = S.window("components.status-bar");
    // Schema values are logical px; the fonts/icons drawn inside are DPI-baked, so the box and its
    // gaps must be scaled by the same factor or they clip the text at HiDPI / font_scale > 1.
    const float dp                 = ui::Layout::dpiScale();
    const float sbHeight           = sbWin.height * dp;
    const float sbPadX             = sbWin.padding[0] * dp;
    const float sbPadY             = sbWin.padding[1] * dp;
    const float sbIconTextGap      = S.drawElement("components.status-bar", "icon-text-gap").size * dp;
    const float sbSectionGap       = S.drawElement("components.status-bar", "section-gap").size * dp;
    const float sbSeparatorGap     = S.drawElement("components.status-bar", "separator-gap").size * dp;

    ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoScrollbar |
                                   ImGuiWindowFlags_NoScrollWithMouse;
    if (ui::material::IsBackdropActive()) {
        childFlags |= ImGuiWindowFlags_NoBackground;
    }

    ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(sbPadX, sbPadY));
    ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0));
    ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0, 0, 0, 0));
    ImGui::BeginChild("##StatusBar", ImVec2(0, sbHeight), false, childFlags);

    // Use schema-configured font for status bar text (default: body2 = caption+1px)
    auto sbTextStyle = S.label("components.status-bar", "text-style");
    ImFont* sbFont = S.resolveFont(sbTextStyle.font);
    if (!sbFont) sbFont = ui::material::Type().body2();
    ImGui::PushFont(sbFont);

    // Apply text opacity from schema (default 60%)
    auto sbTextElem = S.drawElement("components.status-bar", "text-style");
    float sbTextOpacity = (sbTextElem.opacity >= 0.0f) ? sbTextElem.opacity : 0.6f;
    ImVec4 baseTextCol = ImGui::GetStyleColorVec4(ImGuiCol_Text);
    baseTextCol.w *= sbTextOpacity;
    ImGui::PushStyleColor(ImGuiCol_Text, baseTextCol);

    // Vertically center text within the status bar below the divider line.
    // sbPadY is already applied by WindowPadding, so the content region starts
    // at wPos.y + sbPadY.  We set CursorPosY relative to wPos.y (not content
    // start), so we must account for sbPadY ourselves to avoid placing text
    // above the padded region.
    {
        float fontH = ImGui::GetFont()->LegacySize;
        float topMargin = sbPadY;  // match the window padding = space below divider
        float availH = sbHeight - topMargin;
        float centerY = topMargin + (availH - fontH) * 0.5f;
        if (centerY > 0.0f)
            ImGui::SetCursorPosY(centerY);
    }
    {
        ImVec2 wPos = ImGui::GetWindowPos();
        float wWidth = ImGui::GetWindowWidth();
        ImGui::GetWindowDrawList()->AddLine(
            ImVec2(wPos.x, wPos.y),
            ImVec2(wPos.x + wWidth, wPos.y),
            ui::schema::UI().resolveColor("var(--status-divider)", IM_COL32(255, 255, 255, 20)), 1.0f);
    }

    // Connection status
    float dotOpacity = S.drawElement("components.status-bar", "connection-dot").opacity;
    if (dotOpacity < 0.0f) dotOpacity = 1.0f;
    if (state_.warming_up || state_.daemon_initializing) {
        // Both states mean "daemon reachable/launching but not serving yet" — show the same amber
        // status so tabs without the overlay (Peers, Console) still tell the user what's happening.
        ImGui::PushFont(ui::material::Type().iconSmall());
        ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.0f, dotOpacity), ICON_MD_CIRCLE);
        ImGui::PopFont();
        ImGui::SameLine(0, sbIconTextGap);
        // Show truncated warmup status (e.g. "Activating best chain... (Block 12345)")
        const char* warmupText = state_.warmup_status.empty()
            ? TR("sb_warming_up") : state_.warmup_status.c_str();
        ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", warmupText);
    } else if (state_.connected) {
        ImGui::PushFont(ui::material::Type().iconSmall());
        ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, dotOpacity), ICON_MD_CIRCLE);
        ImGui::PopFont();
        ImGui::SameLine(0, sbIconTextGap);
        ImGui::Text("%s", TR("connected"));
    } else {
        ImGui::PushFont(ui::material::Type().iconSmall());
        ImGui::TextColored(ImVec4(0.8f, 0.2f, 0.2f, dotOpacity), ICON_MD_CIRCLE);
        ImGui::PopFont();
        ImGui::SameLine(0, sbIconTextGap);
        // Lite has no daemon connection; "disconnected" here means no wallet is open.
        ImGui::Text("%s", TR(isLiteBuild() ? "lite_no_wallet_short" : "disconnected"));
    }

    // Block height
    ImGui::SameLine(0, sbSectionGap);
    ImGui::TextDisabled("|");
    ImGui::SameLine(0, sbSeparatorGap);
    ImGui::Text(TR("sb_block"), state_.sync.blocks);

    // Sync status or peer count
    ImGui::SameLine(0, sbSectionGap);
    ImGui::TextDisabled("|");
    ImGui::SameLine(0, sbSeparatorGap);
    if (state_.sync.building_witnesses) {
        // Witness rebuild is the tail phase of a rescan — show its own progress with priority.
        // Phase 1 (initial pass) and phase 2 (cache walk) have distinct labels so the progress
        // restart at the phase boundary reads as a new step rather than a glitch.
        const ImVec4 witCol(0.6f, 0.8f, 1.0f, 1.0f);
        const char* witLabel = (state_.sync.witness_phase == 2)
                                   ? TR("sb_witness_cache") : TR("sb_building_witnesses");
        if (state_.sync.witness_progress > 0.01f) {
            ImGui::TextColored(witCol, "%s %.0f%%", witLabel, state_.sync.witness_progress * 100.0f);
        } else {
            int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
            const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
            ImGui::TextColored(witCol, "%s%s", witLabel, dotStr);
        }
    } else if (state_.sync.rescanning) {
        // Show rescan progress (takes priority over sync)
        // Use animated dots if progress is unknown (0%)
        if (state_.sync.rescan_progress > 0.01f) {
            ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), TR("sb_rescanning_pct"),
                              state_.sync.rescan_progress * 100.0f);
        } else {
            // Animated "Rescanning..." with pulsing dots
            int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
            const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
            ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", TR("sb_rescanning"), dotStr);
        }
    } else if (state_.sync.syncing) {
        int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
        int blocksLeft = chainTip - state_.sync.blocks;
        if (blocksLeft < 0) blocksLeft = 0;

        // Calculate sync speed (blocks/sec) using a smoothed rolling average
        static int    s_prev_blocks = 0;
        static double s_prev_time   = 0.0;
        static double s_blocks_per_sec = 0.0;
        double now = ImGui::GetTime();
        if (s_prev_time == 0.0) {
            // First sample — seed baseline, don't compute rate yet
            s_prev_blocks = state_.sync.blocks;
            s_prev_time   = now;
        } else if (state_.sync.blocks > s_prev_blocks) {
            double dt = now - s_prev_time;
            if (dt > 0.5) {
                double raw = (state_.sync.blocks - s_prev_blocks) / dt;
                // Exponential smoothing (alpha ~0.3 per update)
                s_blocks_per_sec = s_blocks_per_sec > 0.0
                    ? s_blocks_per_sec * 0.7 + raw * 0.3
                    : raw;
                s_prev_blocks = state_.sync.blocks;
                s_prev_time   = now;
            }
        } else if (now - s_prev_time > 10.0) {
            // No new blocks for 10s — decay the displayed rate
            s_blocks_per_sec *= 0.5;
            s_prev_time = now;
        }

        if (s_blocks_per_sec > 0.1) {
            int eta_sec = (int)(blocksLeft / s_blocks_per_sec);
            char eta[32];
            if (eta_sec >= 3600)
                snprintf(eta, sizeof(eta), "%dh %dm", eta_sec / 3600, (eta_sec % 3600) / 60);
            else if (eta_sec >= 60)
                snprintf(eta, sizeof(eta), "%dm %ds", eta_sec / 60, eta_sec % 60);
            else
                snprintf(eta, sizeof(eta), "%ds", eta_sec);
            ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_eta"), 
                              state_.sync.verification_progress * 100.0, blocksLeft, s_blocks_per_sec, eta);
        } else {
            ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_basic"), 
                              state_.sync.verification_progress * 100.0, blocksLeft);
        }
    } else if (state_.connected && !isLiteBuild()) {
        // Lite has no P2P peers (the lite server isn't a peer set); skip the peer count.
        ImGui::Text(TR("sb_peers"), state_.peers.size());
    }

    // Network hashrate (if connected and have data)
    if (state_.connected && state_.mining.networkHashrate > 0) {
        ImGui::SameLine(0, sbSectionGap);
        ImGui::TextDisabled("|");
        ImGui::SameLine(0, sbSeparatorGap);
        if (state_.mining.networkHashrate >= 1e9) {
            ImGui::Text(TR("sb_net_ghs"), state_.mining.networkHashrate / 1e9);
        } else if (state_.mining.networkHashrate >= 1e6) {
            ImGui::Text(TR("sb_net_mhs"), state_.mining.networkHashrate / 1e6);
        } else if (state_.mining.networkHashrate >= 1e3) {
            ImGui::Text(TR("sb_net_khs"), state_.mining.networkHashrate / 1e3);
        } else {
            ImGui::Text(TR("sb_net_hs"), state_.mining.networkHashrate);
        }
    }

    // Mining indicator (if mining — solo or pool)
    const bool anyMining = state_.mining.generate || state_.pool_mining.xmrig_running;
    if (anyMining) {
        ImGui::SameLine(0, sbSectionGap);
        ImGui::TextDisabled("|");
        ImGui::SameLine(0, sbSeparatorGap);
        ImGui::PushFont(ui::material::Type().iconSmall());
        ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), ICON_MD_CONSTRUCTION);
        ImGui::PopFont();
        ImGui::SameLine(0, sbIconTextGap);
        double displayHashrate = state_.pool_mining.xmrig_running
            ? state_.pool_mining.hashrate_10s
            : state_.mining.localHashrate;
        ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), TR("sb_mining_hs"), 
                          displayHashrate);
    }

    // Transaction submission/operation progress
    if (hasTransactionSendProgress()) {
        ImGui::SameLine(0, sbSectionGap);
        ImGui::TextDisabled("|");
        ImGui::SameLine(0, sbSeparatorGap);
        ImGui::PushFont(ui::material::Type().iconSmall());
        float pulse = 0.6f + 0.4f * sinf((float)ImGui::GetTime() * 3.0f);
        ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_SEND);
        ImGui::PopFont();
        ImGui::SameLine(0, sbIconTextGap);
        int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
        const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
        std::string status = transactionSendProgressText();
        ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", status.c_str(), dotStr);
    }

    // Decrypt-import background task indicator
    if (wallet_security_workflow_.importActive()) {
        ImGui::SameLine(0, sbSectionGap);
        ImGui::TextDisabled("|");
        ImGui::SameLine(0, sbSeparatorGap);
        ImGui::PushFont(ui::material::Type().iconSmall());
        float pulse = 0.6f + 0.4f * sinf((float)ImGui::GetTime() * 3.0f);
        ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_LOCK_OPEN);
        ImGui::PopFont();
        ImGui::SameLine(0, sbIconTextGap);
        int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
        const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
        ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", TR("sb_importing_keys"), dotStr);
    }

    // Right side: version always at far right, connection status to its left.
    // Compute positions dynamically from actual text widths so they
    // never overlap and always stay within the window at any font scale.
    {
        // Where the left-side chain ended, so the right cluster never SameLine()s backward onto it.
        const float leftEndX = ImGui::GetCursorPosX();
        char versionBuf[32];
        snprintf(versionBuf, sizeof(versionBuf), "v%s", DRAGONX_VERSION);
        float versionW   = ImGui::CalcTextSize(versionBuf).x;
        float rightPad   = sbPadX;  // match the left window padding
        float versionX   = ImGui::GetWindowWidth() - versionW - rightPad;

        // Connection / daemon status sits to the left of the version string
        // with a small gap.
        float gap = sbSectionGap;
        float occupiedX = versionX;   // leftmost X used by the version + connection status so far
        if (!connection_status_.empty() && connection_status_ != "Connected") {
            // During a post-repair rescan the raw status is a scary "RPC request failed: Timeout" — show a
            // calm line instead (the rescan legitimately can't answer RPC yet).
            const std::string statusBase = post_recovery_rescan_ ? std::string(TR("sb_finishing_repair"))
                                                                 : connection_status_;
            // Middle-ellipsize to the space between the left chain and the version so a long status
            // (e.g. "Wallet needs recovery — see the prompt") can't push off-screen or overprint the
            // left chain; then clamp its start so it never crosses left of where the chain ended.
            float availW = versionX - leftEndX - gap * 2.0f;
            if (availW < 24.0f * dp) availW = 24.0f * dp;
            ImFont* stFont = ImGui::GetFont();
            std::string statusShown = ui::material::TruncateToWidth(
                statusBase, stFont, stFont->LegacySize, availW);
            float statusW = ImGui::CalcTextSize(statusShown.c_str()).x;
            float statusX = versionX - statusW - gap;
            if (statusX < leftEndX + gap) statusX = leftEndX + gap;
            ImGui::SameLine(statusX);
            if (wallet_auto_recovered_ && !post_recovery_rescan_) {
                // Actionable re-entry: a full-opacity accent chip that reopens the recovery dialog.
                // Dismiss is non-destructive, so this is the guaranteed way back to the prompt.
                ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
                ImGui::TextUnformatted(statusShown.c_str());
                ImGui::PopStyleColor();
                if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                if (ImGui::IsItemClicked() && recovery_phase_ != RecoveryPhase::Working) {
                    show_wallet_recovered_dialog_ = true;   // reopen the (non-destructively dismissed) prompt
                    recovery_phase_ = RecoveryPhase::Offer;
                }
            } else {
                ImGui::TextDisabled("%s", statusShown.c_str());
            }
            occupiedX = statusX;
        } else if (const std::string ds = getDaemonStatus();
                   !ds.empty() && ds.find("Error") != std::string::npos) {
            const char* errText = TR("sb_daemon_not_found");
            float statusW = ImGui::CalcTextSize(errText).x;
            float statusX = versionX - statusW - gap;
            ImGui::SameLine(statusX);
            ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "%s", errText);
            occupiedX = statusX;
        }

        // Chat note-buffer status — only while the Chat tab is active. Sits left of the version/connection
        // block. Surfaces the buffer filling/draining (and doubles as a diagnostic for send readiness).
        if (current_page_ == ui::NavPage::Chat) {
            const std::string cb = chatBufferStatusText();
            if (!cb.empty()) {
                float cbW = ImGui::CalcTextSize(cb.c_str()).x;
                float cbX = occupiedX - cbW - gap;
                ImGui::SameLine(cbX);
                ImGui::TextUnformatted(cb.c_str());
                occupiedX = cbX;
            }
        }

        // Alert-history bell — leftmost item of the right cluster. Opens a panel of recent alerts,
        // including ones whose toast already faded; an unread dot marks alerts that arrived since
        // the panel was last opened.
        {
            auto& notes = ui::Notifications::instance();
            ImFont* bellFont = ui::material::Type().iconSmall();
            const bool anyHist = notes.hasHistory();
            const char* bellGlyph = anyHist ? ICON_MD_NOTIFICATIONS : ICON_MD_NOTIFICATIONS_NONE;

            ImGui::PushFont(bellFont);
            const float glyphW = ImGui::CalcTextSize(bellGlyph).x;
            ImGui::PopFont();
            const float bellW = glyphW + 10.0f * dp;
            const float bellH = bellFont->LegacySize + 4.0f * dp;
            const float bellX = occupiedX - bellW - gap;

            ImGui::SameLine(bellX);
            ui::material::IconButtonStyle st;
            st.color      = ui::material::OnSurfaceMedium();
            st.hoverColor = ui::material::OnSurface();
            st.hoverBg    = ui::material::StateHover();
            st.bgRounding = 4.0f * dp;
            st.tooltip    = TR("alerts_history_tooltip");
            const bool clicked = ui::material::IconButton("##AlertBell", bellGlyph, bellFont,
                                                          ImVec2(bellW, bellH), st);
            const ImVec2 bellMin = ImGui::GetItemRectMin();
            const ImVec2 bellMax = ImGui::GetItemRectMax();

            // Unread dot: alerts pushed since the panel was last opened, coloured by the most
            // severe unseen alert. totalPushed() is monotonic, so this survives capping/clearing.
            const std::uint64_t unseen = notes.totalPushed() - alerts_seen_total_;
            if (unseen > 0 && anyHist) {
                const auto& h = notes.history();
                size_t scan = (unseen < h.size()) ? static_cast<size_t>(unseen) : h.size();
                bool anyErr = false, anyWarn = false;
                for (size_t i = 0; i < scan; ++i) {
                    auto t = h[h.size() - 1 - i].type;
                    if (t == ui::NotificationType::Error)   { anyErr = true; break; }
                    if (t == ui::NotificationType::Warning) anyWarn = true;
                }
                ImU32 dotCol = anyErr ? ui::material::Error()
                             : anyWarn ? ui::material::Warning()
                             : ui::material::Primary();
                const float r = 3.0f * dp;
                ImGui::GetWindowDrawList()->AddCircleFilled(
                    ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
            }

            if (clicked) {
                alerts_seen_total_ = notes.totalPushed();  // mark everything currently shown as seen
                ImGui::OpenPopup("##AlertHistoryPopup");
            }

            // The bell sits near the window's bottom-right, so anchor the popup's bottom-RIGHT
            // corner at the bell's right edge (pivot (1,1)) — it then grows LEFT over the canvas and
            // UP from the status bar. A left pivot would push a 320px panel off the right edge (and
            // an explicit SetNextWindowPos pivot skips ImGui's on-screen clamp, so it would overflow).
            ImGui::SetNextWindowPos(ImVec2(bellMax.x, bellMin.y - 4.0f * dp),
                                    ImGuiCond_Always, ImVec2(1.0f, 1.0f));
            const float panelW = 320.0f * dp;
            ImGui::SetNextWindowSizeConstraints(ImVec2(panelW, 0), ImVec2(panelW, 360.0f * dp));
            if (ImGui::BeginPopup("##AlertHistoryPopup")) {
                renderAlertHistoryPanel();
                ImGui::EndPopup();
            }

            occupiedX = bellX;
        }

        // Help (?) — sits just left of the alert bell, on every platform. A plain, subtle question
        // mark (no circle) that opens the FAQ.
        {
            ImFont* helpFont = ui::material::Type().iconSmall();
            ImGui::PushFont(helpFont);
            const float helpGlyphW = ImGui::CalcTextSize(ICON_MD_QUESTION_MARK).x;
            ImGui::PopFont();
            const float helpW = helpGlyphW + 10.0f * dp;
            const float helpH = helpFont->LegacySize + 4.0f * dp;
            const float helpX = occupiedX - helpW - gap;

            ImGui::SameLine(helpX);
            ui::material::IconButtonStyle hst;
            hst.color      = ui::material::OnSurfaceMedium();
            hst.hoverColor = ui::material::OnSurface();
            hst.hoverBg    = ui::material::StateHover();
            hst.bgRounding = 4.0f * dp;
            hst.tooltip    = TR("faq_open_tooltip");
            if (ui::material::IconButton("##HelpFaq", ICON_MD_QUESTION_MARK, helpFont,
                                         ImVec2(helpW, helpH), hst)) {
                show_faq_ = true;
            }
            occupiedX = helpX;
        }

        // Version always at far right
        ImGui::SameLine(versionX);
        ImGui::Text("%s", versionBuf);
    }

    ImGui::PopStyleColor(1);  // Text opacity
    ImGui::PopFont(); // status bar font
    ImGui::EndChild();
    ImGui::PopStyleColor(2);  // ChildBg + Border
    ImGui::PopStyleVar(1);    // WindowPadding
}

void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPath)
{
    // Reload background image from the path resolved by the skin system.
    // Each theme specifies its image via [theme] images.background_image in its .toml.
    if (!bgPath.empty()) {
        ImTextureID newTex = 0;
        int w = 0, h = 0;
        bool loaded = false;
#ifdef _WIN32
        // On Windows, try embedded resource by filename if file load fails
        {
            std::filesystem::path p(bgPath);
            std::string filename = p.filename().string();
            const auto* res = resources::getEmbeddedResource(filename);
            if (res && res->data && res->size > 0) {
                loaded = util::LoadTextureFromMemory(res->data, res->size, &newTex, &w, &h);
                if (loaded)
                    DEBUG_LOGF("[App] Loaded theme background from embedded: %s (%dx%d)\n", filename.c_str(), w, h);
            }
        }
#endif
        if (!loaded) {
            loaded = util::LoadTextureFromFile(bgPath.c_str(), &newTex, &w, &h);
            if (loaded)
                DEBUG_LOGF("[App] Loaded theme background image: %s (%dx%d)\n", bgPath.c_str(), w, h);
            else
                DEBUG_LOGF("[App] Warning: Failed to load theme background: %s\n", bgPath.c_str());
        }
        if (loaded)
            gradient_tex_ = newTex;
        else
            gradient_tex_ = 0;  // Clear stale texture when load fails
    } else {
        // No background image specified by theme — clear texture,
        // main loop will fall back to programmatic gradient
        gradient_tex_ = 0;
    }

    // Reset logo loaded flags — will reload on next render frame (ensureLogoTexture re-rasterizes the
    // SVG for the new theme). Free the current texture first so a theme switch doesn't leak it.
    if (logo_tex_) util::DestroyTexture(logo_tex_);
    logo_loaded_ = false;
    logo_tex_ = 0;
    logo_w_ = 0;
    logo_h_ = 0;
    coin_logo_loaded_ = false;
    if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
    coin_logo_tex_ = 0;
    coin_logo_w_ = 0;
    coin_logo_h_ = 0;
}

void App::renderLiteFirstRunPrompt()
{
    // Lite-only guided onboarding (full-node uses its own wizard). Welcome -> (on create) reveal
    // the new seed + birthday with backup warnings -> verify the user wrote it down -> done.
    if (!lite_wallet_ || lite_firstrun_dismissed_) return;

    // Wizard state (persists across frames). The seed is SECRET — wiped in finish().
    static int step = 0;                 // 0 welcome, 1 reveal seed, 2 verify
    static std::string seed;             // full seed phrase
    static unsigned long long birthday = 0;
    static std::vector<std::string> words;                       // correct order
    static std::vector<std::pair<std::string, bool>> chips;      // shuffled (word, consumed)
    static int progress = 0;             // # words confirmed in order
    static double wrongFlashUntil = 0.0; // brief "not the next word" hint
    static bool skipConfirm = false;     // "Skip" backup requires a second confirming click
    static bool creating = false;        // async create (with failover) in flight
    // Restore-from-seed step (step 3). The seed buffer is SECRET — wiped in finish() and on Back.
    static char restoreSeed[512] = {};
    static int  restoreBirthday = 0;
    static bool restoring = false;       // async restore in flight
    static std::string restoreErr;

    // The welcome page is only relevant before any wallet exists; once create has started
    // (creating) or reached reveal/verify (step>0), keep showing it through to completion even
    // though the wallet becomes open.
    if (step == 0 && !creating && (lite_wallet_->walletOpen() || lite_wallet_->walletExists())) return;

    if (!ImGui::IsPopupOpen("##LiteFirstRun")) ImGui::OpenPopup("##LiteFirstRun");
    ImGuiViewport* vp = ImGui::GetMainViewport();
    ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
                            ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));

    auto finish = [&]() {
        wallet::secureWipeLiteSecret(seed);
        sodium_memzero(restoreSeed, sizeof(restoreSeed));
        restoreBirthday = 0;
        restoring = false;
        restoreErr.clear();
        words.clear();
        chips.clear();
        progress = 0;
        step = 0;
        skipConfirm = false;
        creating = false;
        lite_firstrun_dismissed_ = true;
        ImGui::CloseCurrentPopup();
    };
    auto splitWords = [](const std::string& s) {
        std::vector<std::string> out;
        std::string w;
        for (char c : s) {
            if (std::isspace(static_cast<unsigned char>(c))) { if (!w.empty()) { out.push_back(w); w.clear(); } }
            else w.push_back(c);
        }
        if (!w.empty()) out.push_back(w);
        return out;
    };

    if (ImGui::BeginPopupModal("##LiteFirstRun", nullptr,
                               ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
        const float dp = ui::Layout::dpiScale();
        const float btnW = 170.0f * dp;

        if (step == 0) {
            // ── Welcome ──────────────────────────────────────────────────────────
            ImGui::PushFont(ui::material::Type().subtitle1());
            ImGui::TextUnformatted(TR("lite_welcome_title"));
            ImGui::PopFont();
            ImGui::Spacing();
            ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 360.0f * dp);
            ImGui::TextUnformatted(TR("lite_welcome_msg"));
            ImGui::PopTextWrapPos();
            ImGui::Spacing(); ImGui::Spacing();

            if (creating) {
                // Async create (with server failover) is in flight — driven to completion by
                // App::update()'s pumpAsyncOpen(). Poll the controller for the outcome.
                ImGui::TextUnformatted(TR("appx_creating_your_wallet"));
                if (lite_wallet_->walletOpen()) {
                    auto s = lite_wallet_->exportSeed();  // read the new seed back (local, fast)
                    if (s.ok && !s.seedPhrase.empty()) {
                        seed = s.seedPhrase;
                        birthday = s.birthday;
                        wallet::secureWipeLiteSecret(s.seedPhrase);
                        words = splitWords(seed);
                        creating = false;
                        step = 1;
                    } else {
                        ui::Notifications::instance().success(TR("lite_welcome_created"), 8.0f);
                        setCurrentPage(ui::NavPage::Settings);
                        finish();
                    }
                } else if (!lite_wallet_->openInProgress() &&
                           !lite_wallet_->lastOpenError().empty()) {
                    ui::Notifications::instance().warning(
                        std::string(TR("appx_create_failed_prefix")) + lite_wallet_->lastOpenError());
                    creating = false;  // back to the buttons so the user can retry
                }
            } else {
                if (ui::material::TactileButton(TR("lite_welcome_create"), ImVec2(btnW, 0))) {
                    // Async create with the same server failover as open (no UI freeze; a dead
                    // server falls through to the next). On success the wizard reveals the seed.
                    if (lite_wallet_->beginCreateWallet()) {
                        creating = true;
                    } else {
                        ui::Notifications::instance().warning(TR("lite_welcome_create_failed"));
                    }
                }
                ImGui::SameLine();
                if (ui::material::TactileButton(TR("lite_welcome_restore"), ImVec2(btnW, 0))) {
                    step = 3;  // inline seed-entry restore (see step 3 below)
                }
                ImGui::Spacing();
                if (ui::material::TactileButton(TR("lite_welcome_later"),
                                  ImVec2(btnW * 2 + ImGui::GetStyle().ItemSpacing.x, 0))) {
                    finish();
                }
            }
        } else if (step == 1) {
            // ── Reveal the seed + birthday with backup warnings ─────────────────────
            ImGui::PushFont(ui::material::Type().subtitle1());
            ImGui::TextUnformatted(TR("appx_back_up_seed_phrase_title"));
            ImGui::PopFont();
            ImGui::Spacing();
            ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f * dp);
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
            ImGui::TextUnformatted(TR("appx_seed_backup_warning"));
            ImGui::PopStyleColor();
            ImGui::PopTextWrapPos();
            ImGui::Spacing();

            // Numbered word grid (4 columns).
            for (size_t i = 0; i < words.size(); ++i) {
                char cell[96];
                snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
                ImGui::TextUnformatted(cell);
                if ((i % 4) != 3 && i + 1 < words.size()) ImGui::SameLine(((i % 4) + 1) * 130.0f * dp);
            }
            ImGui::Spacing();
            char bday[80];
            snprintf(bday, sizeof(bday), TR("appx_birthday_block_height"), birthday);
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
            ImGui::TextUnformatted(bday);
            ImGui::PopStyleColor();
            ImGui::Spacing(); ImGui::Spacing();

            if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
                chips.clear();
                for (const auto& w : words) chips.emplace_back(w, false);
                std::mt19937 rng{std::random_device{}()};
                std::shuffle(chips.begin(), chips.end(), rng);
                progress = 0;
                skipConfirm = false;
                step = 2;
            }
            ImGui::SameLine();
            if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
            ImGui::SameLine();
            if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
                if (!skipConfirm) {
                    skipConfirm = true;  // require a second, deliberate click
                } else {
                    ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
                    finish();
                }
            }
            if (skipConfirm) {
                ImGui::Spacing();
                ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
                ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
                ImGui::TextUnformatted(TR("appx_seed_not_backed_up_warning"));
                ImGui::PopStyleColor();
                ImGui::PopTextWrapPos();
            }
        } else if (step == 2) {
            // ── Verify: tap the words in order ──────────────────────────────────────
            ImGui::PushFont(ui::material::Type().subtitle1());
            ImGui::TextUnformatted(TR("appx_confirm_your_backup"));
            ImGui::PopFont();
            ImGui::Spacing();
            ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
            ImGui::TextUnformatted(TR("appx_tap_words_in_order"));
            ImGui::PopTextWrapPos();
            ImGui::Spacing();

            ImGui::TextDisabled(TR("appx_progress_n_of_n"), progress, (int)words.size());
            if (ImGui::GetTime() < wrongFlashUntil) {
                ImGui::SameLine();
                ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
                ImGui::TextUnformatted(TR("appx_not_next_word"));
                ImGui::PopStyleColor();
            }
            ImGui::Spacing();

            for (size_t i = 0; i < chips.size(); ++i) {
                ImGui::PushID((int)i);
                if (chips[i].second) {
                    ImGui::BeginDisabled();
                    ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125 * dp, 0));
                    ImGui::EndDisabled();
                } else if (ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125 * dp, 0))) {
                    if (progress < (int)words.size() && chips[i].first == words[progress]) {
                        chips[i].second = true;  // correct next word
                        ++progress;
                    } else {
                        wrongFlashUntil = ImGui::GetTime() + 1.5;  // out of order
                    }
                }
                ImGui::PopID();
                if ((i % 4) != 3 && i + 1 < chips.size()) ImGui::SameLine();
            }
            ImGui::Spacing(); ImGui::Spacing();

            const bool verified = progress == (int)words.size();
            if (!verified) ImGui::BeginDisabled();
            if (ui::material::TactileButton(TR("appx_done"), ImVec2(btnW, 0))) {
                ui::Notifications::instance().success(TR("appx_wallet_created_and_backed_up"), 6.0f);
                finish();
            }
            if (!verified) ImGui::EndDisabled();
            ImGui::SameLine();
            if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { skipConfirm = false; step = 1; }
            ImGui::SameLine();
            if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
                if (!skipConfirm) {
                    skipConfirm = true;  // require a second, deliberate click
                } else {
                    ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
                    finish();
                }
            }
            if (skipConfirm) {
                ImGui::Spacing();
                ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
                ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
                ImGui::TextUnformatted(TR("appx_seed_not_backed_up_warning"));
                ImGui::PopStyleColor();
                ImGui::PopTextWrapPos();
            }
        } else if (step == 3) {
            // ── Restore from an existing seed phrase ────────────────────────────────
            ImGui::PushFont(ui::material::Type().subtitle1());
            ImGui::TextUnformatted(TR("lite_restore_title"));
            ImGui::PopFont();
            ImGui::Spacing();
            ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
            ImGui::TextUnformatted(TR("lite_restore_intro"));
            ImGui::PopTextWrapPos();
            ImGui::Spacing();

            if (restoring) {
                // Async restore (with server failover) in flight — driven by pumpAsyncOpen().
                ImGui::TextUnformatted(TR("appx_restoring_your_wallet"));
                if (lite_wallet_->walletOpen()) {
                    ui::Notifications::instance().success(TR("lite_restore_ok"), 6.0f);
                    finish();  // wipes restoreSeed; the wallet then syncs from the lite server
                } else if (!lite_wallet_->openInProgress() &&
                           !lite_wallet_->lastOpenError().empty()) {
                    restoreErr = lite_wallet_->lastOpenError();
                    restoring = false;  // back to the form so the user can fix and retry
                }
            } else {
                ImGui::TextUnformatted(TR("lite_restore_seed_label"));
                ImGui::InputTextMultiline("##LiteRestoreSeed", restoreSeed, sizeof(restoreSeed),
                                          ImVec2(380.0f * dp, ImGui::GetTextLineHeight() * 3.2f));
                ImGui::Spacing();
                ImGui::TextUnformatted(TR("lite_restore_birthday_label"));
                ImGui::SetNextItemWidth(160.0f * dp);
                ImGui::InputInt("##LiteRestoreBirthday", &restoreBirthday);
                if (restoreBirthday < 0) restoreBirthday = 0;

                if (!restoreErr.empty()) {
                    ImGui::Spacing();
                    ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
                    ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
                    ImGui::TextUnformatted(restoreErr.c_str());
                    ImGui::PopTextWrapPos();
                    ImGui::PopStyleColor();
                }
                ImGui::Spacing(); ImGui::Spacing();

                // Normalize the entered seed (trim, fold exotic Unicode whitespace like NBSP to plain
                // spaces) so the word count and the phrase we submit agree regardless of paste source.
                std::string seedTrim = util::normalizeSeedPhrase(restoreSeed);

                // Require a COMPLETE 24-word phrase before enabling Restore. The SDXL backend only
                // accepts 24-word / 32-byte-entropy seeds; a shorter valid-BIP39 phrase (12/15/18/21)
                // panics it uncaught across the restore FFI, so it must be refused here (matches the
                // Settings restore gate — both go through util::isCompleteRecoveryPhrase).
                int seedWords = util::seedPhraseWordCount(seedTrim);
                bool seedLenOk = util::isCompleteRecoveryPhrase(seedWords);
                if (!seedTrim.empty() && !seedLenOk) {
                    ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning()));
                    ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
                    char recoveryWordsMsg[96];
                    snprintf(recoveryWordsMsg, sizeof(recoveryWordsMsg),
                             TR("appx_recovery_phrase_word_count"), seedWords);
                    ImGui::TextUnformatted(recoveryWordsMsg);
                    ImGui::PopTextWrapPos();
                    ImGui::PopStyleColor();
                    ImGui::Spacing();
                }

                ImGui::BeginDisabled(!seedLenOk);
                if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) {
                    wallet::LiteWalletRestoreRequest req;
                    req.seedPhrase = seedTrim;  // normalized: NBSP-glued pastes restore correctly
                    req.birthday   = static_cast<unsigned long long>(std::max(0, restoreBirthday));
                    req.overwrite  = lite_wallet_->walletExists();  // replace any existing wallet file
                    if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) {
                        restoreErr.clear();
                        restoring = true;
                    } else {
                        restoreErr = lite_wallet_->lastOpenError().empty()
                                         ? std::string(TR("appx_could_not_start_restore"))
                                         : lite_wallet_->lastOpenError();
                    }
                }
                ImGui::EndDisabled();
                ImGui::SameLine();
                if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) {
                    sodium_memzero(restoreSeed, sizeof(restoreSeed));
                    restoreErr.clear();
                    step = 0;
                }
            }
        }
        ImGui::EndPopup();
    }
}

void App::renderLiteUnlockPrompt()
{
    if (!lite_wallet_) return;
    static char pass[128] = "";

    if (lite_unlock_prompt_ && !ImGui::IsPopupOpen("##LiteUnlock")) {
        ImGui::OpenPopup("##LiteUnlock");
    }
    ImGuiViewport* vp = ImGui::GetMainViewport();
    ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
                            ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
    if (ImGui::BeginPopupModal("##LiteUnlock", nullptr,
                               ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
        const float dp = ui::Layout::dpiScale();
        ImGui::PushFont(ui::material::Type().subtitle1());
        ImGui::TextUnformatted(TR("lite_unlock_title"));
        ImGui::PopFont();
        ImGui::Spacing();
        ImGui::TextUnformatted(TR("lite_unlock_msg"));
        ImGui::Spacing();
        ImGui::SetNextItemWidth(280.0f * dp);
        const bool entered = ImGui::InputText("##LiteUnlockPassModal", pass, sizeof(pass),
                                              ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue);
        ImGui::Spacing();
        const float btnW = 130.0f * dp;
        bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
        if (doUnlock) {
            const bool ok = lite_wallet_->unlockWallet(pass);
            sodium_memzero(pass, sizeof(pass));
            if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
            else {
                wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed"));
                ui::Notifications::instance().error(TR("lite_unlock_failed"));
            }
            lite_unlock_prompt_ = false;
            ImGui::CloseCurrentPopup();
        }
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
            sodium_memzero(pass, sizeof(pass));
            lite_unlock_prompt_ = false;
            ImGui::CloseCurrentPopup();
        }
        ImGui::EndPopup();
    }
}

// Group an integer height with thousands separators, e.g. 1234567 -> "1,234,567".
static std::string formatIntWithCommas(long long v)
{
    std::string s = std::to_string(v);
    for (int pos = (int)s.length() - 3; pos > 0; pos -= 3) s.insert(pos, ",");
    return s;
}

// A send-tab-style slider (mirrors RenderAmountBar in send_tab.cpp: rounded track, accent fill,
// centered % label, glass thumb) for the optional rescan start height, stacked above a full-width
// numeric field for exact entry. Both share `buf` — the height as a decimal string — so it's the
// single source of truth. `tipHeight` is the chain tip = the slider's max; when <= 0 (height unknown)
// the slider is omitted and only the field shows. `availW` is the interior width (bar + field span it).
static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bool enabled,
                                    float dp, float availW)
{
    namespace m = ui::material;
    int cur = buf[0] ? std::atoi(buf) : 0;
    if (cur < 0) cur = 0;

    ImGui::BeginDisabled(!enabled);

    // Numeric entry first — the slider sits below it.
    ImGui::SetNextItemWidth(availW);
    ImGui::InputText("##importscanheight", buf, bufSize, ImGuiInputTextFlags_CharsDecimal);
    cur = buf[0] ? std::atoi(buf) : 0;   // reflect a just-typed value in the slider below
    if (cur < 0) cur = 0;

    if (tipHeight > 0) {
        ImGui::Spacing();
        const float barW = availW;                    // slider spans the panel interior, below the field
        const float barH = ImGui::GetFrameHeight();   // match the numeric field's height (already dp-scaled)
        const float barRound = barH * 0.5f;
        const float thumbR = barH * 0.5f;

        const ImVec2 barMin = ImGui::GetCursorScreenPos();
        const ImVec2 barMax(barMin.x + barW, barMin.y + barH);
        ImDrawList* dl = ImGui::GetWindowDrawList();

        // Reserve + interact first so the fill/thumb drawn below reflect the current drag (no lag).
        ImGui::InvisibleButton("##scanbar", ImVec2(barW, barH));
        const bool active  = enabled && ImGui::IsItemActive();
        const bool hovered = ImGui::IsItemHovered();
        if (active) {
            const float clickPct = std::clamp((ImGui::GetIO().MousePos.x - barMin.x) / barW, 0.0f, 1.0f);
            cur = (int)lroundf(clickPct * (float)tipHeight);
            snprintf(buf, bufSize, "%d", cur);
        }
        const float pct = std::clamp((float)cur / (float)tipHeight, 0.0f, 1.0f);

        // Track + accent fill up to the thumb (fill's rounded right edge matches the thumb circle).
        // OnSurface-based (not pure white) so the track/label/thumb stay visible on a light-theme glass
        // panel, which goes opaque white.
        dl->AddRectFilled(barMin, barMax, m::WithAlpha(m::OnSurface(), enabled ? 30 : 14), barRound);
        const float usableW = barW - thumbR * 2.0f;
        const float thumbCenterX = barMin.x + thumbR + usableW * pct;
        const float fillRight = thumbCenterX + thumbR;
        if (fillRight > barMin.x + barRound * 2.0f)
            dl->AddRectFilled(barMin, ImVec2(fillRight, barMax.y),
                              m::WithAlpha(m::Primary(), enabled ? 150 : 55), barRound);

        // Percentage-of-chain label centered on the bar (mirrors the send-tab amount bar).
        {
            ImFont* cap = m::Type().caption();
            char pctBuf[16];
            snprintf(pctBuf, sizeof(pctBuf), "%.0f%%", pct * 100.0f);
            const ImVec2 tsz = cap->CalcTextSizeA(cap->LegacySize, 10000.0f, 0.0f, pctBuf);
            dl->AddText(cap, cap->LegacySize,
                        ImVec2(barMin.x + (barW - tsz.x) * 0.5f, barMin.y + (barH - tsz.y) * 0.5f),
                        m::WithAlpha(m::OnSurface(), enabled ? 200 : 80), pctBuf);
        }

        // Glass thumb (drawn last so it sits above the fill + label).
        const float thumbY = barMin.y + barH * 0.5f;
        dl->AddCircleFilled(ImVec2(thumbCenterX, thumbY), thumbR, m::WithAlpha(m::OnSurface(), enabled ? 215 : 90), 24);
        dl->AddCircle(ImVec2(thumbCenterX, thumbY), thumbR, m::WithAlpha(m::OnSurface(), enabled ? 130 : 50), 24, 1.4f * dp);

        if (hovered && enabled) {
            ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
            m::Tooltip("%s / %s (%.0f%%)", formatIntWithCommas(cur).c_str(),
                       formatIntWithCommas(tipHeight).c_str(), pct * 100.0f);
        }
    }

    ImGui::EndDisabled();
}

void App::renderImportKeyDialog()
{
    namespace m = ui::material;
    float dp = ui::Layout::dpiScale();
    const bool viewMode = import_view_mode_;   // watch-only shielded viewing key vs. spending key

    m::OverlayDialogSpec ov;
    ov.title    = viewMode ? TR("import_viewkey_title") : TR("import_key_title");
    ov.p_open   = &show_import_key_;
    ov.style    = m::OverlayStyle::BlurFloat;
    // Wide enough that the one-line care header under the heading doesn't wrap; the viewing-key note
    // is longer, so that mode gets a wider card.
    ov.cardWidth = viewMode ? 760.0f : 640.0f;
    ov.idSuffix  = "importkey";
    if (!m::BeginOverlayDialog(ov)) return;

    // Fresh dialog on each open (the key buffer was already wiped on the last close).
    if (ImGui::IsWindowAppearing()) {
        import_success_ = false;
        import_status_.clear();
        import_result_address_.clear();
        import_key_reveal_ = false;
        sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_));
        // Preserve a sweep that is still Running OR has a terminal result yet to be seen (a sweep can
        // finish while the dialog is closed) — reset only when there's genuinely nothing to show, so a
        // completed sweep's Done/Error panel survives a close+reopen. It's cleared on close (below).
        if (sweep_step_ == SweepStep::Idle) {
            import_sweep_mode_ = false;
            sweep_dest_mode_ = 0;
            sweep_dest_pick_[0] = '\0';
            sweep_status_.clear();
            sweep_txid_.clear();
            sweep_dest_shown_.clear();
        }
    }
    const bool sweepRunning = (sweep_step_ == SweepStep::Running);
    // Sweep UI is only valid for a spending-key (non-view) dialog; masking by !viewMode stops preserved
    // sweep state from bleeding a "Sweep" button / progress panel into the watch-only viewing dialog.
    const bool sweepMode = import_sweep_mode_ && !viewMode;
    // Esc dismisses (this overlay has no built-in Esc handling).
    if (ImGui::IsKeyPressed(ImGuiKey_Escape)) show_import_key_ = false;

    // Handle-with-care header — same warning icon + color for both modes (the viewing dialog is wider
    // so its longer watch-only note stays on one line, matching the private-key warning).
    m::DialogWarningHeader(viewMode ? TR("import_viewkey_note") : TR("import_key_warn"));
    ImGui::Spacing();

    // A running node is required — say so up front instead of failing only after Import.
    if (!state_.connected) {
        ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_need_node"));
        ImGui::Spacing();
    }

    // Masked key field (no "Key" label — the dialog heading already gives the context); the reveal
    // (eye) toggle sits frameless flush at its right edge, with labelled Paste / Clear buttons beneath.
    // Hovering Paste previews the clipboard INSIDE the field (masked with '*' unless the key is
    // revealed) — like the send tab — so the user can verify before pasting.
    const float fieldH = ImGui::GetFrameHeight();
    const ImVec2 iconSz(fieldH, fieldH);
    const float eyeGap = ui::Layout::spacingSm();   // breathing room between the text and the eye button
    ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - fieldH - eyeGap);
    ImGuiInputTextFlags flags = import_key_reveal_ ? 0 : ImGuiInputTextFlags_Password;
    ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_), flags);
    const ImVec2 fieldMin = ImGui::GetItemRectMin();   // anchor for the paste-preview overlay
    ImGui::SameLine(0, eyeGap);
    m::IconButtonStyle eyeSt;
    eyeSt.hoverBg = m::WithAlpha(m::OnSurface(), 30);
    eyeSt.bgRounding = 4.0f * dp;
    eyeSt.tooltip = TR("import_key_reveal_tip");
    if (m::IconButton("##revealkey", import_key_reveal_ ? ICON_MD_VISIBILITY_OFF : ICON_MD_VISIBILITY,
                      m::Type().iconSmall(), iconSz, eyeSt))
        import_key_reveal_ = !import_key_reveal_;

    // Paste / Clear — centered, slightly larger labelled buttons.
    const float actBtnW = 104.0f * dp;
    const float actTotal = actBtnW * 2.0f + ImGui::GetStyle().ItemSpacing.x;
    const float actOff = (ImGui::GetContentRegionAvail().x - actTotal) * 0.5f;
    if (actOff > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + actOff);
    const bool pasteClicked = m::TactileButton(TR("paste"), ImVec2(actBtnW, 0));
    const bool pasteHovered = ImGui::IsItemHovered();
    if (pasteClicked) {
        const char* clipboard = ImGui::GetClipboardText();
        if (clipboard) {
            std::string trimmed(clipboard);
            while (!trimmed.empty() && (trimmed.front()==' '||trimmed.front()=='\t'||trimmed.front()=='\n'||trimmed.front()=='\r')) trimmed.erase(trimmed.begin());
            while (!trimmed.empty() && (trimmed.back()==' '||trimmed.back()=='\t'||trimmed.back()=='\n'||trimmed.back()=='\r')) trimmed.pop_back();
            snprintf(import_key_input_, sizeof(import_key_input_), "%s", trimmed.c_str());
        }
    }
    ImGui::SameLine();
    if (m::TactileButton(TR("clear"), ImVec2(actBtnW, 0))) sodium_memzero(import_key_input_, sizeof(import_key_input_));

    // While Paste is hovered and the field is empty, preview the trimmed clipboard INSIDE the field —
    // masked with '*' unless the key is revealed — coloured by how it would classify (green ok / amber
    // wrong-type / red unrecognized). Drawn via the draw list only: it verifies the key WITHOUT adding
    // a layout row, since a hover-toggled row would grow the centered dialog and shift the button out
    // from under the cursor (hover flicker).
    if (pasteHovered && import_key_input_[0] == '\0') {
        const char* clip = ImGui::GetClipboardText();
        if (clip && clip[0]) {
            std::string t(clip);
            while (!t.empty() && (t.front()==' '||t.front()=='\t'||t.front()=='\n'||t.front()=='\r')) t.erase(t.begin());
            while (!t.empty() && (t.back()==' '||t.back()=='\t'||t.back()=='\n'||t.back()=='\r')) t.pop_back();
            if (!t.empty()) {
                const bool pView  = services::WalletSecurityController::isViewingKey(t);
                const bool pSpend = services::WalletSecurityController::isRecognizedPrivateKey(t);
                const bool pOk    = viewMode ? pView : pSpend;
                const bool pWrong = viewMode ? pSpend : pView;
                ImVec4 col = pOk ? m::SuccessVec4()
                           : pWrong ? ImVec4(0.8f, 0.6f, 0.0f, 1.0f)
                                    : m::ErrorVec4();
                col.w = 0.6f;   // ghost (uncommitted) preview
                std::string shown = import_key_reveal_ ? t : std::string(std::min(t.size(), (size_t)56), '*');
                if (shown.size() > 48) shown.resize(48);   // fits the field before the flush eye button
                const ImVec2 tp(fieldMin.x + ImGui::GetStyle().FramePadding.x,
                                fieldMin.y + ImGui::GetStyle().FramePadding.y);
                ImGui::GetWindowDrawList()->AddText(tp, ImGui::ColorConvertFloat4ToU32(col), shown.c_str());
            }
        }
    }

    // Trimmed field content drives the type indicator, the Import/Sweep guard, and the scan panel — NOT
    // the hover preview, so hovering can neither shift the layout nor enable the action button.
    std::string keyTrim(import_key_input_);
    while (!keyTrim.empty() && (keyTrim.front()==' '||keyTrim.front()=='\t'||keyTrim.front()=='\n'||keyTrim.front()=='\r')) keyTrim.erase(keyTrim.begin());
    while (!keyTrim.empty() && (keyTrim.back()==' '||keyTrim.back()=='\t'||keyTrim.back()=='\n'||keyTrim.back()=='\r')) keyTrim.pop_back();
    bool isView  = services::WalletSecurityController::isViewingKey(keyTrim);
    bool isSpend = services::WalletSecurityController::isRecognizedPrivateKey(keyTrim);
    bool shielded = services::WalletSecurityController::classifyPrivateKey(keyTrim)
                    == services::WalletSecurityController::KeyKind::Shielded;
    // Each button accepts only its own key type; a key valid for the OTHER button is flagged with a
    // redirect hint rather than lumped in with truly unrecognized input.
    bool recognized = viewMode ? isView : isSpend;
    bool wrongType  = viewMode ? isSpend : isView;

    if (!keyTrim.empty()) {
        if (recognized) {
            const char* label = viewMode ? TR("import_key_type_zview")
                              : shielded  ? TR("import_key_type_zspend")
                                          : TR("import_key_type_tkey");
            ImGui::PushFont(m::Type().iconSmall());
            ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
            ImGui::PopFont();
            ImGui::SameLine(0, 4.0f * dp);
            ImGui::TextColored(m::SuccessVec4(), "%s", label);
        } else {
            const char* msg = wrongType
                ? (viewMode ? TR("import_viewkey_wrong_type") : TR("import_key_wrong_type"))
                : TR("import_key_type_unknown");
            ImGui::PushFont(m::Type().iconSmall());
            ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), wrongType ? ICON_MD_WARNING : ICON_MD_HELP);
            ImGui::PopFont();
            ImGui::SameLine(0, 4.0f * dp);
            ImGui::PushTextWrapPos(0.0f);
            ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", msg);
            ImGui::PopTextWrapPos();
        }
    }
    ImGui::Spacing();

    // Sweep option (spending keys only): rather than keeping the key, import it, move ALL its funds to
    // an address you own, and leave the (now-empty) key behind. A viewing key can't sign, so no sweep.
    if (!viewMode) {
        // Lock the mode + destination for the lifetime of a running sweep so the user can't desync the
        // UI from the in-flight operation (unchecking mid-run would otherwise re-enable Import).
        ImGui::BeginDisabled(sweepRunning);
        ImGui::Checkbox(TR("sweep_toggle"), &import_sweep_mode_);
        if (import_sweep_mode_) {
            ImGui::PushTextWrapPos(0.0f);
            ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("sweep_caveat"));
            ImGui::PopTextWrapPos();
            ImGui::Spacing();
            ImGui::TextUnformatted(TR("sweep_dest_label"));
            std::string curSel = (sweep_dest_mode_ == 1 && sweep_dest_pick_[0])
                ? util::truncateMiddle(sweep_dest_pick_, 18, 8)
                : std::string(TR("sweep_dest_new"));
            ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
            if (ImGui::BeginCombo("##sweepdest", curSel.c_str())) {
                if (ImGui::Selectable(TR("sweep_dest_new"), sweep_dest_mode_ == 0)) {
                    sweep_dest_mode_ = 0;
                    sweep_dest_pick_[0] = '\0';
                }
                const bool anyOwn = !state_.z_addresses.empty() || !state_.t_addresses.empty();
                if (anyOwn) ImGui::Separator();
                auto addrItem = [&](const std::string& a) {
                    const bool sel = (sweep_dest_mode_ == 1 && a == sweep_dest_pick_);
                    if (ImGui::Selectable(util::truncateMiddle(a, 22, 10).c_str(), sel)) {
                        sweep_dest_mode_ = 1;
                        snprintf(sweep_dest_pick_, sizeof(sweep_dest_pick_), "%s", a.c_str());
                    }
                };
                for (const auto& a : state_.z_addresses) addrItem(a.address);
                for (const auto& a : state_.t_addresses) addrItem(a.address);
                ImGui::EndCombo();
            }
        }
        ImGui::EndDisabled();
        ImGui::Spacing();
    }

    // Optional scan-from-height, grouped in a subtle glass panel so it reads as one distinct,
    // clearly-optional block. z_importkey / z_importviewingkey accept a start block, so a shielded
    // import of a recent key needn't rescan the whole chain. Transparent WIF (importprivkey) has no
    // start-height parameter, so the control is disabled + noted once a transparent key is recognized.
    ImGui::Dummy(ImVec2(0, 2.0f * dp));
    {
        const bool transparentKey = !viewMode && isSpend && !shielded;   // recognized transparent WIF
        if (transparentKey && import_key_scan_height_[0])
            sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_));  // no stale height
        const int tip = state_.sync.blocks;   // chain tip = slider max; 0 when unknown

        // Auto-sized glass sub-section (combo-safe local splitter; see GlassSectionScope).
        m::GlassSectionScope section;
        const float availW = section.interiorWidth();

        // Label + current chain height, right-aligned within the interior.
        const float rowStartX = ImGui::GetCursorPosX();
        ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_scan_label"));
        if (tip > 0) {
            char tipBuf[64];
            snprintf(tipBuf, sizeof(tipBuf), "%s %s", TR("import_scan_tip"), formatIntWithCommas(tip).c_str());
            const float targetX = rowStartX + availW - ImGui::CalcTextSize(tipBuf).x;
            ImGui::SameLine();
            if (targetX > ImGui::GetCursorPosX()) ImGui::SetCursorPosX(targetX);
            ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", tipBuf);
        }

        RenderScanHeightControl(import_key_scan_height_, sizeof(import_key_scan_height_),
                                tip, !transparentKey, dp, availW);

        // Only note why the control is inert for a transparent key; the generic "0 = rescan from the
        // start" hint was dropped as self-evident (0 is the field's default).
        if (transparentKey)
            ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("import_scan_transparent"));
    }
    ImGui::Dummy(ImVec2(0, 2.0f * dp));

    // Progress / result / error.
    if (sweepMode) {
        if (sweepRunning) {
            ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
                               sweep_status_.c_str(), m::LoadingDots());
        } else if (sweep_step_ == SweepStep::Done) {
            if (import_key_input_[0]) sodium_memzero(import_key_input_, sizeof(import_key_input_));  // wipe on success
            ImGui::PushFont(m::Type().iconSmall());
            ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
            ImGui::PopFont();
            ImGui::SameLine(0, 4.0f * dp);
            ImGui::TextColored(m::SuccessVec4(), "%s", TR("sweep_done"));
            if (!sweep_dest_shown_.empty()) {
                ImGui::Spacing();
                ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("sweep_to"));
                ui::widgets::AddressCopyField("##sweptto", sweep_dest_shown_);
            }
            if (!sweep_txid_.empty()) {
                ImGui::Spacing();
                ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("sweep_tx"));
                ui::widgets::AddressCopyField("##sweeptx", sweep_txid_);
            }
        } else if (sweep_step_ == SweepStep::Error && !sweep_status_.empty()) {
            ImGui::PushTextWrapPos(0.0f);
            ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", sweep_status_.c_str());
            ImGui::PopTextWrapPos();
        }
    } else if (import_in_progress_) {
        ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
                           TR("import_key_rescanning"), m::LoadingDots());
    } else if (import_success_) {
        ImGui::PushFont(m::Type().iconSmall());
        ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
        ImGui::PopFont();
        ImGui::SameLine(0, 4.0f * dp);
        ImGui::TextColored(m::SuccessVec4(), "%s", TR("import_key_done"));
        if (!import_result_address_.empty()) {
            ImGui::Spacing();
            ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_address"));
            ui::widgets::AddressCopyField("##importedaddr", import_result_address_);
        }
    } else if (!import_status_.empty()) {
        ImGui::PushTextWrapPos(0.0f);
        ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", import_status_.c_str());
        ImGui::PopTextWrapPos();
    }

    ImGui::Spacing();

    const bool running = sweepMode ? sweepRunning : import_in_progress_;
    // Sweep needs a spending key AND a destination (a picked address when not using a fresh one).
    const bool destReady = !sweepMode || sweep_dest_mode_ == 0 || sweep_dest_pick_[0] != '\0';
    const bool canAct = recognized && state_.connected && !running && destReady;
    bool doAction = false, doClose = false;
    m::DialogActionFooter(sweepMode ? TR("sweep_button") : TR("import_key_import"), canAct,
                          TR("close"), doAction, doClose);
    if (doClose) show_import_key_ = false;
    if (doAction) {
        int startHeight = 0;   // 0 = full rescan. Shielded imports (z_importkey) honor it; transparent
        if (import_key_scan_height_[0]) {   // WIF (importprivkey) ignores it.
            startHeight = std::atoi(import_key_scan_height_);
            if (startHeight < 0) startHeight = 0;
            if (state_.sync.blocks > 0 && startHeight > state_.sync.blocks)
                startHeight = state_.sync.blocks;   // never past the chain tip
        }
        if (sweepMode) {
            sweepPrivateKey(std::string(import_key_input_), startHeight, sweep_dest_mode_,
                            sweep_dest_mode_ == 1 ? std::string(sweep_dest_pick_) : std::string());
        } else {
            import_in_progress_ = true;
            import_success_ = false;
            import_status_.clear();
            import_result_address_.clear();
            importPrivateKey(std::string(import_key_input_), startHeight,
                             [this](bool ok, const std::string& err, const std::string& addr) {
                import_in_progress_ = false;
                import_success_ = ok;
                if (ok) {
                    import_result_address_ = addr;
                    import_status_.clear();
                    sodium_memzero(import_key_input_, sizeof(import_key_input_));  // wipe on success
                } else {
                    import_status_ = err;
                }
            });
        }
    }

    // Wipe the key the moment the dialog closes (Close button OR outside-click, which clears
    // show_import_key_ mid-frame). in_progress is left to the callback to clear.
    if (!show_import_key_) {
        sodium_memzero(import_key_input_, sizeof(import_key_input_));   // key already copied into the op
        sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_));
        import_status_.clear();
        import_result_address_.clear();
        import_success_ = false;
        import_key_reveal_ = false;
        // A running sweep is an async daemon op that keeps going in the background — preserve its UI
        // state so reopening shows the running progress and, on completion, the Done/Error panel.
        if (sweep_step_ != SweepStep::Running) {
            import_sweep_mode_ = false;
            sweep_dest_mode_ = 0;
            sweep_dest_pick_[0] = '\0';
            sweep_status_.clear();
            sweep_txid_.clear();
            sweep_dest_shown_.clear();
            sweep_step_ = SweepStep::Idle;
        }
    }

    m::EndOverlayDialog();
}

void App::renderExportKeyDialog()
{
    namespace m = ui::material;
    float dp = ui::Layout::dpiScale();

    m::OverlayDialogSpec ov;
    ov.title     = TR("export_private_key");
    ov.p_open    = &show_export_key_;
    ov.style     = m::OverlayStyle::BlurFloat;
    ov.cardWidth = 600.0f;
    ov.idSuffix  = "exportkey";
    if (!m::BeginOverlayDialog(ov)) {
        // Defensive scrub for a frame where the overlay renders nothing (Begin returns false). The
        // actual dismiss-path wipe (Close / outside-click / Esc) is the in-body block near EndOverlayDialog.
        sodium_memzero(export_result_, sizeof(export_result_));
        export_address_.clear();
        export_in_progress_ = false;
        export_error_ = false;
        return;
    }

    m::DialogWarningHeader(TR("key_export_private_warning"));
    ImGui::Spacing();

    // Address selector (a legitimate need here, unlike the import dialog).
    std::vector<std::string> all_addrs;
    for (const auto& a : state_.t_addresses) all_addrs.push_back(a.address);
    for (const auto& a : state_.z_addresses) all_addrs.push_back(a.address);
    ImGui::SetNextItemWidth(-1);
    const std::string comboLabel = export_address_.empty() ? std::string(TR("select_address"))
                                                           : util::truncateMiddle(export_address_, 24, 10);
    if (ImGui::BeginCombo("##exportaddr", comboLabel.c_str())) {
        for (const auto& addr : all_addrs) {
            const bool selected = (export_address_ == addr);
            if (ImGui::Selectable(util::truncateMiddle(addr, 28, 12).c_str(), selected)) {
                export_address_ = addr;
                sodium_memzero(export_result_, sizeof(export_result_));   // new address → old key stale
                export_error_ = false;
            }
        }
        ImGui::EndCombo();
    }

    // Progress / result / error. The secret key is shown in a read-only char buffer (NOT
    // widgets::AddressCopyField, which would copy it into a lingering std::string) + a Copy button.
    if (export_in_progress_) {
        ImGui::Spacing();
        ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
                           TR("key_export_fetching"), m::LoadingDots());
    } else if (export_result_[0]) {
        ImGui::Spacing();
        ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
        ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
                                  ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
        if (m::TactileButton(TR("copy")))
            ImGui::SetClipboardText(export_result_);
    } else if (export_error_) {
        ImGui::Spacing();
        ImGui::PushTextWrapPos(0.0f);
        ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", TR("key_export_failed"));
        ImGui::PopTextWrapPos();
    }

    ImGui::Spacing();

    bool doExport = false, doClose = false;
    const bool canExport = !export_address_.empty() && !export_in_progress_;
    m::DialogActionFooter(TR("export"), canExport, TR("close"), doExport, doClose);
    if (doClose) show_export_key_ = false;
    if (doExport) {
        export_in_progress_ = true;
        export_error_ = false;
        sodium_memzero(export_result_, sizeof(export_result_));
        exportPrivateKey(export_address_, [this](const std::string& key) {
            // Don't populate the secret buffer if the dialog was closed before the async export
            // returned (it would otherwise linger un-wiped while the dialog is shut).
            export_in_progress_ = false;
            if (!show_export_key_) return;
            if (key.empty()) export_error_ = true;
            else             snprintf(export_result_, sizeof(export_result_), "%s", key.c_str());
        });
    }

    // Scrub the secret whenever the dialog closes THIS frame — Close button OR outside-click/Esc, which
    // BeginOverlayDialog clears mid-frame while still returning true (so the early-return above is not
    // reached on that path). This in-body teardown mirrors the import dialog and is the actual wipe
    // point for the dismiss paths; the early-return only covers a Begin-returns-false frame.
    if (!show_export_key_) {
        sodium_memzero(export_result_, sizeof(export_result_));
        export_address_.clear();
        export_in_progress_ = false;
        export_error_ = false;
    }

    m::EndOverlayDialog();
}

void App::renderBackupDialog()
{
    namespace m = ui::material;

    m::OverlayDialogSpec ov;
    ov.title     = TR("backup_title");
    ov.p_open    = &show_backup_;
    ov.style     = m::OverlayStyle::BlurFloat;
    ov.cardWidth = 560.0f;
    ov.idSuffix  = "backup";
    if (!m::BeginOverlayDialog(ov)) return;

    m::DialogWarningHeader(TR("backup_warn"));
    ImGui::Spacing();

    static char backup_path[512] = "dragonx-backup.txt";
    static bool s_backup_confirm_overwrite = false;
    ImGui::TextUnformatted(TR("backup_destination"));
    ImGui::SetNextItemWidth(-1);
    if (ImGui::InputText("##backuppath", backup_path, sizeof(backup_path)))
        s_backup_confirm_overwrite = false;   // path edited — re-check overwrite on next Save

    if (!backup_status_.empty()) {
        ImGui::Spacing();
        ImGui::PushTextWrapPos(0.0f);
        ImGui::TextColored(backup_success_ ? m::SuccessVec4() : ImVec4(0.8f, 0.3f, 0.3f, 1.0f),
                           "%s", backup_status_.c_str());
        ImGui::PopTextWrapPos();
    }

    ImGui::Spacing();

    bool doSave = false, doClose = false;
    m::DialogActionFooter(TR("backup_create"), true, TR("close"), doSave, doClose);
    if (doClose) {
        show_backup_ = false;
        backup_status_.clear();
        s_backup_confirm_overwrite = false;
    }
    if (doSave) {
        std::string path(backup_path);
        // Trim surrounding whitespace (a pasted path can carry a trailing newline).
        while (!path.empty() && (path.front()==' '||path.front()=='\t'||path.front()=='\n'||path.front()=='\r')) path.erase(path.begin());
        while (!path.empty() && (path.back()==' '||path.back()=='\t'||path.back()=='\n'||path.back()=='\r')) path.pop_back();
        if (!path.empty()) {
            std::error_code ec;
            if (!s_backup_confirm_overwrite && std::filesystem::exists(path, ec)) {
                // Don't clobber an existing file (possibly a good earlier backup) without confirmation.
                s_backup_confirm_overwrite = true;
                backup_success_ = false;
                backup_status_ = TR("backup_overwrite_confirm");
            } else {
                s_backup_confirm_overwrite = false;
                backupWallet(path, [this](bool success, const std::string& msg) {
                    backup_success_ = success;
                    backup_status_ = msg;
                });
            }
        }
    }

    m::EndOverlayDialog();
}

void App::maybeOfferDaemonUpdate()
{
    if (!supportsFullNodeLifecycleActions()) return;   // lite / no embedded-daemon build
    if (capture_mode_ || !settings_) return;           // never during a UI sweep
    auto inst = resources::getInstalledDaemonInfo();
    auto bun  = resources::getBundledDaemonInfo();
    // Only when a daemon is ALREADY installed and its size differs from the bundled one — an absent
    // daemon is extracted normally, and a same-size match needs nothing. Fire once per bundled
    // version (never re-nag a user running a custom node): key on the bundled size last offered.
    if (!inst.exists || !bun.available || inst.size == bun.size) return;
    if ((unsigned long long)settings_->getDaemonUpdatePromptedSize() == bun.size) return;
    daemon_update_bundled_size_ = bun.size;
    show_daemon_update_prompt_ = true;
}

void App::renderDaemonUpdatePrompt()
{
    ui::material::OverlayDialogSpec ov;
    ov.title = TR("daemon_update_prompt_title"); ov.p_open = &show_daemon_update_prompt_;
    ov.style = ui::material::OverlayStyle::BlurFloat;
    ov.cardWidth = 520.0f; ov.idSuffix = "daemonupdprompt";
    if (!ui::material::BeginOverlayDialog(ov))
        return;
    const float dp = ui::Layout::dpiScale();
    ImGui::TextWrapped("%s", TR("daemon_update_body"));
    ImGui::Spacing();
    ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
    ImGui::TextWrapped("%s", TR("daemon_update_safe"));
    ImGui::PopStyleColor();
    ImGui::Spacing();
    if (ui::material::TactileButton(TR("daemon_update_now"), ImVec2(170 * dp, 0))) {
        show_daemon_update_prompt_ = false;
        reinstallBundledDaemon();   // stops the node (managed or external), overwrites, restarts
    }
    ImGui::SameLine();
    if (ui::material::TactileButton(TR("daemon_update_keep"), ImVec2(150 * dp, 0))) {
        show_daemon_update_prompt_ = false;
    }
    ui::material::EndOverlayDialog();
    // Any close path (Update / Keep / Esc / X): record the bundled size so we don't prompt again for
    // this wallet version — a user keeping a custom daemon is asked at most once per version.
    if (!show_daemon_update_prompt_ && settings_) {
        settings_->setDaemonUpdatePromptedSize((long long)daemon_update_bundled_size_);
        settings_->save();
    }
}

void App::renderSeedBackupDialog()
{
    // Wipe the revealed phrase and reset the dialog's state.
    auto closeAndWipe = [this]() {
        if (!seed_backup_phrase_.empty())
            sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
        seed_backup_phrase_.clear();
        seed_backup_status_.clear();
        seed_backup_fetch_started_ = false;
        seed_backup_loading_ = false;
        seed_backup_no_mnemonic_ = false;
        show_seed_backup_ = false;
    };

    // Kick off the async z_exportmnemonic fetch once, and only while unlocked. Never during a UI
    // sweep — the sweep pre-sets fetch_started_ + a demo phrase, but guard the RPC too.
    if (!seed_backup_fetch_started_ && !state_.isLocked() && !capture_mode_) {
        seed_backup_fetch_started_ = true;
        seed_backup_loading_ = true;
        seed_backup_no_mnemonic_ = false;
        seed_backup_status_.clear();
        exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& phrase,
                                const std::string& error) {
            if (!show_seed_backup_) return;  // dialog closed while the fetch was in flight
            seed_backup_loading_ = false;
            if (ok) {
                seed_backup_phrase_ = phrase;  // working copy — wiped on close
            } else if (noMnemonic) {
                seed_backup_no_mnemonic_ = true;
            } else {
                seed_backup_status_ = error.empty() ? std::string(TR("seed_backup_load_failed")) : error;
            }
        });
    }

    ui::material::OverlayDialogSpec ov;
    ov.title     = TR("seed_backup_title");
    ov.p_open    = &show_seed_backup_;
    ov.style     = ui::material::OverlayStyle::BlurFloat;
    ov.cardWidth = 560.0f;
    ov.idSuffix  = "seedbackup";
    if (!ui::material::BeginOverlayDialog(ov)) {
        if (seed_backup_fetch_started_) closeAndWipe();
        return;
    }
    // BeginOverlayDialog flips show_seed_backup_ to false on an Esc / outside-click / X dismiss but
    // still returns true this frame — catch that here so the revealed secret is always scrubbed.
    if (!show_seed_backup_) {
        closeAndWipe();
        ui::material::EndOverlayDialog();
        return;
    }

    // Button widths are logical px; BeginOverlayDialog scales the card by dpiScale() so scale these
    // to match (raw widths render small/left-packed at higher DPI / font scale).
    const float dp = ui::Layout::dpiScale();

    if (state_.isLocked()) {
        ImGui::TextWrapped("%s", TR("seed_backup_locked"));
    } else if (seed_backup_loading_) {
        ImGui::TextWrapped("%s", TR("seed_backup_loading"));
    } else if (seed_backup_no_mnemonic_) {
        ImGui::TextWrapped("%s", TR("seed_backup_none"));
    } else if (!seed_backup_phrase_.empty()) {
        ui::material::DialogWarningHeader(TR("seed_backup_warning"));
        ImGui::Spacing();
        ImGui::TextWrapped("%s", TR("seed_backup_intro"));
        ImGui::Spacing();
        ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
        ImGui::Spacing();
        if (!seed_backup_status_.empty()) {
            ImGui::PushTextWrapPos(0.0f);
            ImGui::TextColored(ui::material::SuccessVec4(), "%s", seed_backup_status_.c_str());
            ImGui::PopTextWrapPos();
            ImGui::Spacing();
        }
        // Footer: Copy | Save | Close — centered as a group (no divider).
        const float cW = 120 * dp, sW = 150 * dp;
        ui::material::PlaceOverlayDialogActions(cW + sW + cW + ImGui::GetStyle().ItemSpacing.x * 2.0f);
        if (ui::material::TactileButton(TR("seed_backup_copy"), ImVec2(cW, 0))) {
            copySecretToClipboard(seed_backup_phrase_);
        }
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("seed_backup_save"), ImVec2(sW, 0))) {
            std::string path = util::Platform::getConfigDir() + "/dragonx-seed-backup.txt";
            std::string content = seed_backup_phrase_ + "\n";
            const bool ok = util::Platform::writeFileAtomically(path, content,
                                                               /*restrictPermissions=*/true);
            sodium_memzero(&content[0], content.size());
            seed_backup_status_ = (ok ? std::string(TR("seed_backup_saved"))
                                      : std::string(TR("seed_backup_save_failed"))) + path;
        }
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("seed_backup_close"), ImVec2(cW, 0))) {
            closeAndWipe();
            ui::material::EndOverlayDialog();
            return;
        }
        ui::material::EndOverlayDialog();
        return;
    } else if (!seed_backup_status_.empty()) {
        ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", seed_backup_status_.c_str());
    }

    ImGui::Spacing();
    // Centered Close (no divider).
    const float cbW = 120 * dp;
    ui::material::PlaceOverlayDialogActions(cbW);
    if (ui::material::TactileButton(TR("seed_backup_close"), ImVec2(cbW, 0))) {
        closeAndWipe();
    }
    ui::material::EndOverlayDialog();
}

void App::renderSeedMigrationDialog()
{
    auto wipeSeed = [this]() {
        if (!seed_migration_seed_.empty())
            sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
        seed_migration_seed_.clear();
    };
    auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };

    // A background op (create/sweep/adopt) can't be cancelled cleanly, so the modal must not be
    // dismissed mid-op.
    const bool busy = seed_migration_in_flight_
                      || seed_migration_step_ == SeedMigrationStep::Sweeping
                      || seed_migration_step_ == SeedMigrationStep::Adopting;

    ui::material::OverlayDialogSpec migOv;
    migOv.title = TR("mig_title"); migOv.p_open = &show_seed_migration_;
    migOv.style = ui::material::OverlayStyle::BlurFloat;
    migOv.cardWidth = 580.0f; migOv.idSuffix = "migrate";
    if (!ui::material::BeginOverlayDialog(migOv)) {
        if (!busy) wipeSeed();
        return;
    }
    // BeginOverlayDialog flips show_seed_migration_ to false on an Esc / outside-click / X dismiss
    // (but still returns true this frame). Veto the dismiss while busy; otherwise it's handled by
    // the post-EndOverlayDialog wipe below.
    if (!show_seed_migration_ && busy) show_seed_migration_ = true;

    const ImVec4 kMedium = ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium());
    // BeginOverlayDialog scales the card by dpiScale(); the button widths below are authored in
    // logical px and must be scaled the same way or they render small/left-packed at higher DPI.
    const float dp = ui::Layout::dpiScale();

    switch (seed_migration_step_) {
    case SeedMigrationStep::Intro: {
        // Pre-flight needs a connected, unlocked wallet — gate on that, then branch on what the
        // probe found so we never offer a pointless (already-seeded) or impossible (old daemon) run.
        if (!isConnected()) {
            ImGui::TextWrapped("%s", TR("mig_not_connected"));
            ImGui::Spacing();
            if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
            break;
        }
        if (state_.isLocked()) {
            ImGui::TextWrapped("%s", TR("mig_unlock_to_migrate"));
            ImGui::Spacing();
            if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
            break;
        }
        if (!seed_migration_precheck_started_ && !capture_mode_) beginSeedMigrationPrecheck();

        switch (seed_migration_precheck_) {
        case SeedMigrationPrecheck::Pending:
            ImGui::TextColored(kMedium, "%s%s", TR("mig_precheck_checking"), ui::material::LoadingDots());
            break;
        case SeedMigrationPrecheck::AlreadyMnemonic:
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::SuccessVec4());
            ImGui::TextWrapped("%s", TR("mig_already_mnemonic"));
            ImGui::PopStyleColor();
            ImGui::Spacing();
            {
                // Size to the label — the fixed 200px clipped the FR/PT/ES/DE/RU translations.
                ImFont* bkFont = ui::material::Type().button();
                const float bkW = std::max(200.0f * dp,
                    bkFont->CalcTextSizeA(bkFont->LegacySize, FLT_MAX, 0, TR("mig_backup_instead")).x
                    + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
                if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(bkW, 0))) {
                    close();
                    showSeedBackupDialog();
                }
            }
            ImGui::SameLine();
            if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
            break;
        case SeedMigrationPrecheck::DaemonTooOld:
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
            ImGui::TextWrapped("%s", TR("mig_daemon_too_old"));
            ImGui::PopStyleColor();
            ImGui::Spacing();
            if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
            break;
        case SeedMigrationPrecheck::CheckFailed:
            ImGui::TextWrapped("%s", TR("mig_check_failed"));
            ImGui::Spacing();
            if (ui::material::TactileButton(TR("mig_recheck"), ImVec2(120 * dp, 0)))
                seed_migration_precheck_started_ = false;  // re-probe next frame
            ImGui::SameLine();
            if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
            break;
        case SeedMigrationPrecheck::Legacy:
        default:
            ImGui::TextWrapped("%s", TR("mig_intro"));
            ImGui::Spacing();
            if (ui::material::TactileButton(TR("mig_create"), ImVec2(180 * dp, 0)))
                beginCreateSeedWallet();
            ImGui::SameLine();
            if (ui::material::TactileButton(TR("cancel"), ImVec2(120 * dp, 0)))
                close();
            break;
        }
        break;
    }

    case SeedMigrationStep::Working:
        ImGui::TextWrapped("%s", TR("mig_working"));
        ImGui::Spacing();
        ImGui::TextColored(kMedium, "%s%s", seed_migration_status_.c_str(), ui::material::LoadingDots());
        break;

    case SeedMigrationStep::ShowSeed: {
        ui::material::DialogWarningHeader(TR("mig_seed_warning"));
        ImGui::Spacing();
        // Box the 24-word grid in a glass panel so the wallet's most critical secret reads as a
        // distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
        {
            ui::material::GlassSectionScope seedPanel;
            ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
        }
        ImGui::Spacing();
        ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
        ImGui::TextWrapped("%s", seed_migration_dest_.c_str());
        ImGui::Spacing();
        if (ui::material::TactileButton(TR("mig_copy_seed"), ImVec2(120 * dp, 0)))
            copySecretToClipboard(seed_migration_seed_);
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("seed_backup_save"), ImVec2(130 * dp, 0))) {
            std::string path = util::Platform::getConfigDir() + "/dragonx-seed-wallet-backup.txt";
            std::string content = seed_migration_seed_ + "\nAddress: " + seed_migration_dest_ + "\n";
            const bool ok = util::Platform::writeFileAtomically(path, content, /*restrict=*/true);
            sodium_memzero(&content[0], content.size());
            seed_migration_status_ = std::string(ok ? TR("seed_backup_saved") : TR("seed_backup_save_failed")) + path;
        }
        if (!seed_migration_status_.empty()) {
            ImGui::TextColored(ui::material::SuccessVec4(), "%s", seed_migration_status_.c_str());
        }
        ImGui::Spacing();
        ImGui::Checkbox(TR("mig_backed_up"), &seed_migration_backed_up_);
        ImGui::Spacing();
        ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
        ImGui::TextWrapped("%s", TR("mig_step1_done"));
        ImGui::PopStyleColor();
        if (!seed_migration_backed_up_) ImGui::BeginDisabled();
        if (ui::material::TactileButton(TR("mig_continue_sweep"), ImVec2(170 * dp, 0))) {
            wipeSeed();  // the sweep/adopt steps only use the address, never the seed itself
            seed_migration_step_ = SeedMigrationStep::Sweep;
            seed_migration_balance_loaded_ = false;
            seed_migration_nofunds_confirmed_ = false;
            refreshSeedMigrationBalance();
        }
        if (!seed_migration_backed_up_) ImGui::EndDisabled();
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0))) {
            // Throw away the isolated new wallet + clear the pending state.
            if (!seed_migration_temp_dir_.empty()) {
                std::error_code ec;
                std::filesystem::remove_all(seed_migration_temp_dir_, ec);
            }
            if (settings_) {
                settings_->setSeedMigrationPending(false);
                settings_->setSeedMigrationDest("");
                settings_->setSeedMigrationTempDir("");
                settings_->save();
            }
            seed_migration_temp_dir_.clear();
            seed_migration_dest_.clear();
            close();
        }
        break;
    }

    case SeedMigrationStep::Sweep: {
        // Wait for the balance to load before deciding funds vs no-funds (0.0 is also the initial
        // value, so gate on the loaded flag to avoid flashing "no funds" while it's still fetching).
        if (!seed_migration_balance_loaded_) {
            ImGui::TextColored(kMedium, "%s%s", TR("mig_checking_balance"), ui::material::LoadingDots());
            ImGui::Spacing();
            if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0))) close();
            break;
        }
        if (seed_migration_balance_ <= DRAGONX_DEFAULT_FEE) {
            const bool sweepSubmitted = settings_ && settings_->getSeedMigrationSweepSubmitted();
            if (!dragonx::sweepGateMayAdoptEmptyWallet(sweepSubmitted)) {
                // A sweep was already broadcast but its txid was lost (its opid didn't survive a
                // daemon/app restart). A ~0 balance is AMBIGUOUS — the sweep may still be unconfirmed
                // in the mempool and could revert on a drop/reorg — so we must NOT adopt (swap
                // wallet.dat) here. Try to locate the sweep tx to resume the confirmation gate (which
                // requires >=1 conf); until then, wait/refresh. If the sweep was dropped the balance
                // simply returns and the normal Sweep UI reappears — the user is never trapped.
                ImGui::TextWrapped("%s", TR("mig_sweep_inflight"));
                ImGui::Spacing();
                ImGui::TextColored(kMedium, "%s%s", TR("mig_checking_sweep"), ui::material::LoadingDots());
                ImGui::Spacing();
                if (ui::material::TactileButton(TR("mig_check_sweep"), ImVec2(190 * dp, 0)))
                    locateSweepTxid();
                ImGui::SameLine();
                if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0))) {
                    refreshSeedMigrationBalance();
                    locateSweepTxid();
                }
                ImGui::SameLine();
                if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
                    close();
                break;
            }
            // Nothing to sweep and no sweep was ever submitted (a genuinely empty wallet) — adopt the
            // fresh seed wallet directly. Its wallet.dat replaces the current (empty) one; adopt still
            // moves the legacy wallet aside to a timestamped backup.
            ImGui::TextWrapped("%s", TR("mig_no_funds"));
            ImGui::Spacing();
            ImGui::TextWrapped("%s%s", TR("mig_to"), seed_migration_dest_.c_str());
            ImGui::Spacing();
            // Adopt swaps wallet.dat — gate it behind an explicit acknowledgement even though no
            // funds are at risk (the legacy wallet is still moved aside to a backup on adopt).
            ImGui::Checkbox(TR("mig_nofunds_confirm"), &seed_migration_nofunds_confirmed_);
            ImGui::Spacing();
            if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
            if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
                beginAdoptSeedWallet();
            if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
            ImGui::SameLine();
            if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
                refreshSeedMigrationBalance();
            ImGui::SameLine();
            if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
                close();
            break;
        }
        ImGui::TextWrapped("%s", TR("mig_sweep_intro"));
        ImGui::Spacing();
        char balbuf[96]; snprintf(balbuf, sizeof(balbuf), TR("mig_balance"), seed_migration_balance_);
        ImGui::TextColored(kMedium, "%s", balbuf);
        ImGui::TextWrapped("%s%s", TR("mig_to"), seed_migration_dest_.c_str());
        if (state_.isLocked()) {
            ImGui::Spacing();
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
            ImGui::TextWrapped("%s", TR("mig_unlock_first"));
            ImGui::PopStyleColor();
        }
        ImGui::Spacing();
        {
            const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
            if (!canSweep) ImGui::BeginDisabled();
            if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
                beginSweepToSeedWallet();
            if (!canSweep) ImGui::EndDisabled();
        }
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
            refreshSeedMigrationBalance();
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
            close();  // pending state persists; resumes here next time
        break;
    }

    case SeedMigrationStep::Sweeping:
        ImGui::TextWrapped("%s", TR("mig_sweeping"));
        ImGui::Spacing();
        ImGui::TextColored(kMedium, "%s%s", seed_migration_status_.c_str(), ui::material::LoadingDots());
        break;

    case SeedMigrationStep::Confirming: {
        const double dust = DRAGONX_DEFAULT_FEE;  // treat <= one fee as "empty"
        const bool confirmed = seed_migration_sweep_confs_ >= 1;
        const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
                                 seed_migration_legacy_remaining_ <= dust;
        ImGui::TextWrapped("%s", TR("mig_confirming"));
        ImGui::Spacing();
        char cbuf[64]; snprintf(cbuf, sizeof(cbuf), TR("mig_confs"), seed_migration_sweep_confs_);
        ImGui::TextColored(kMedium, "%s", cbuf);
        if (!seed_migration_sweep_txid_.empty()) {
            // A 64-hex txid overflows the fixed 580px card; render it in a wrapping, copyable field
            // (same widget the import-key sweep result uses) instead of a raw one-line label.
            ImGui::TextColored(kMedium, "%s", TR("mig_txid"));
            ui::widgets::AddressCopyField("##migtxid", seed_migration_sweep_txid_);
        }
        if (seed_migration_legacy_remaining_ >= 0.0) {
            char rbuf[96]; snprintf(rbuf, sizeof(rbuf), TR("mig_remaining"), seed_migration_legacy_remaining_);
            ImGui::TextColored(kMedium, "%s", rbuf);
        }
        ImGui::Spacing();
        if (confirmed && legacyEmpty) {
            if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
                beginAdoptSeedWallet();
        } else if (confirmed && !legacyEmpty) {
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
            ImGui::TextWrapped("%s", TR("mig_remainder"));
            ImGui::PopStyleColor();
            if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
                beginSweepToSeedWallet();
        } else {
            ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
        }
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
            pollSweepStatus();
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
            close();  // pending state + txid persist; resumes here next time
        break;
    }

    case SeedMigrationStep::Adopting:
        ImGui::TextWrapped("%s", TR("mig_adopting"));
        ImGui::Spacing();
        ImGui::TextColored(kMedium, "%s%s", seed_migration_status_.c_str(), ui::material::LoadingDots());
        ImGui::Spacing();
        ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
        ImGui::TextWrapped("%s", TR("mig_adopting_note"));
        ImGui::PopStyleColor();
        break;

    case SeedMigrationStep::Done:
        ImGui::PushStyleColor(ImGuiCol_Text, ui::material::SuccessVec4());
        ImGui::TextWrapped("%s", TR("mig_done"));
        ImGui::PopStyleColor();
        ImGui::Spacing();
        ImGui::TextWrapped("%s", TR("mig_done_detail"));
        if (!seed_migration_status_.empty()) {  // a non-fatal warning (e.g. the daemon didn't restart)
            ImGui::Spacing();
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
            ImGui::TextWrapped("%s", seed_migration_status_.c_str());
            ImGui::PopStyleColor();
        }
        ImGui::Spacing();
        if (ui::material::TactileButton(TR("mig_done_btn"), ImVec2(120 * dp, 0)))
            close();
        break;

    case SeedMigrationStep::Error:
        ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
        ImGui::TextWrapped("%s", seed_migration_status_.c_str());
        ImGui::PopStyleColor();
        ImGui::Spacing();
        if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0)))
            close();
        break;
    }

    ui::material::EndOverlayDialog();
    // A real dismiss (Esc/outside/X) or a button close() leaves the flag false — scrub the seed.
    if (!show_seed_migration_) wipeSeed();
}

void App::renderAntivirusHelpDialog()
{
#ifdef _WIN32
    if (!pending_antivirus_dialog_) return;
    
    ui::material::OverlayDialogSpec ov;
    ov.title = TR("av_title"); ov.p_open = &pending_antivirus_dialog_;
    ov.style = ui::material::OverlayStyle::BlurFloat;
    ov.cardWidth = 560.0f; ov.idSuffix = "antivirus";
    if (!ui::material::BeginOverlayDialog(ov)) {
        return;
    }
    const float dp = ui::Layout::dpiScale();

    ImGui::TextWrapped("%s", TR("av_intro"));

    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ui::material::Type().text(ui::material::TypeStyle::Subtitle2, TR("av_step1"));
    ImGui::BulletText("%s", TR("av_step1_b1"));
    ImGui::BulletText("%s", TR("av_step1_b2"));
    ImGui::BulletText("%s", TR("av_step1_b3"));

    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ui::material::Type().text(ui::material::TypeStyle::Subtitle2, TR("av_step2"));
    ImGui::BulletText("%s", TR("av_step2_b1"));
    ImGui::BulletText("%s", TR("av_step2_b2"));

    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ui::material::Type().text(ui::material::TypeStyle::Subtitle2, TR("av_step3"));

    ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));

    float btnW = 160.0f * dp;
    if (ui::material::TactileButton(TR("av_open_security"), ImVec2(btnW, 0))) {
        // Open Windows Security app to the exclusions page
        ShellExecuteA(NULL, "open", "windowsdefender://threat", NULL, NULL, SW_SHOWNORMAL);
    }
    ImGui::SameLine();
    if (ui::material::TactileButton(TR("close"), ImVec2(100.0f * dp, 0))) {
        pending_antivirus_dialog_ = false;
    }

    ui::material::EndOverlayDialog();
#endif
}

// Auto-shown when the node auto-recovered (salvaged) wallet.dat: the real wallet is safe in a
// wallet.<timestamp>.bak, but a possibly-incomplete salvaged copy is now loaded — warn loudly and point
// the user at the datadir so they can restore the original instead of mistaking it for fund loss.
void App::renderWalletRecoveredDialog()
{
    if (!show_wallet_recovered_dialog_) return;

    ui::material::OverlayDialogSpec ov;
    ov.title = TR("wallet_recovered_title");
    // Dismiss (backdrop / [X]) is disabled during Working — files are mid-swap. A null p_open is safe:
    // BeginOverlayDialog guards both the close button and backdrop-close on it.
    ov.p_open = (recovery_phase_ == RecoveryPhase::Working) ? nullptr : &show_wallet_recovered_dialog_;
    ov.style = ui::material::OverlayStyle::BlurFloat;
    ov.cardWidth = 620.0f;   // wide enough for the two side-by-side choice cards
    ov.idSuffix = "walletrecovered";
    if (!ui::material::BeginOverlayDialog(ov)) return;
    const float dp = ui::Layout::dpiScale();
    // Size each button to its label (with a modest floor) instead of a magic fixed width, so
    // translations of these keys — added additively to res/lang later — can't silently clip.
    ImFont* rbf = ui::material::Type().button();
    auto fitBtnW = [&](const char* label) {
        return std::max(96.0f * dp,
            rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x
            + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
    };
    // A full-width action row: the button, then a dim wrapped one-line explanation of what it does.
    auto actionRow = [&](const char* label, const char* sub) -> bool {
        const bool clicked = ui::material::TactileButton(label, ImVec2(fitBtnW(label), 0));
        if (sub && sub[0]) {
            ImGui::PushFont(ui::material::Type().caption());
            ImGui::PushTextWrapPos(0.0f);
            ImGui::TextDisabled("%s", sub);
            ImGui::PopTextWrapPos();
            ImGui::PopFont();
        }
        return clicked;
    };
    // A disclosure that recedes: no filled bar, dim label — so the primary action stays dominant.
    auto quietHeader = [&](const char* label) -> bool {
        ImGui::PushStyleColor(ImGuiCol_Header,        ImVec4(0, 0, 0, 0));
        ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ui::material::WithAlpha(ui::material::OnSurface(), 20));
        ImGui::PushStyleColor(ImGuiCol_HeaderActive,  ui::material::WithAlpha(ui::material::OnSurface(), 32));
        ImGui::PushStyleColor(ImGuiCol_Text,          ui::material::OnSurfaceMedium());
        const bool open = ImGui::CollapsingHeader(label);
        ImGui::PopStyleColor(4);
        return open;
    };
    // Opt-in daemon log — the same lines that used to be dumped on screen, now behind a quiet disclosure.
    auto techDetails = [&]() {
        if (!daemon_controller_) return;
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        if (quietHeader(TR("wallet_recovery_details_label"))) {
            ImGui::PushFont(ui::material::Type().caption());
            ImGui::PushTextWrapPos(0.0f);
            for (const auto& ln : daemon_controller_->recentLines(8))
                ImGui::TextDisabled("%s", ln.c_str());
            ImGui::PopTextWrapPos();
            ImGui::PopFont();
        }
    };
    // Funds-safety hero: a shield icon + the reassurance, made the visual focal point of the screen.
    auto safetyHero = [&]() {
        ImFont* icoF = ui::material::Type().iconLarge();
        ImFont* txtF = ui::material::Type().subtitle1();
        const float rowTop = ImGui::GetCursorPosY();
        ImGui::PushFont(icoF);
        ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_HEALTH_AND_SAFETY);
        ImGui::PopFont();
        ImGui::SameLine();
        const float iconH = icoF->LegacySize;
        const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize();
        if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f);
        ImGui::PushFont(txtF);
        ImGui::TextColored(ui::material::SuccessVec4(), "%s", TR("wallet_recovered_safety"));
        ImGui::PopFont();
    };
    // Accent-tinted primary action so the recommended button clearly dominates the quiet disclosures
    // (a translucent Primary tint over the glass button — keeps the label readable on any theme).
    auto primaryAction = [&](const char* label, const char* sub) -> bool {
        ImGui::PushStyleColor(ImGuiCol_Button,        ui::material::WithAlpha(ui::material::Primary(), 60));
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 90));
        ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ui::material::WithAlpha(ui::material::Primary(), 115));
        const bool clicked = actionRow(label, sub);
        ImGui::PopStyleColor(3);
        return clicked;
    };
    // A choice card: icon + title (+ optional "recommended" chip) + wrapped blurb + a full-width button
    // pinned to the card bottom. The recommended card gets a gold-tinted fill + border so the choice
    // reads at a glance. cardH is precomputed by the caller so side-by-side cards stay equal height.
    auto renderCard = [&](const char* id, const char* glyph, const char* title, const char* desc,
                          const char* btn, bool recommended, float cardW, float cardH) -> bool {
        const float pad = ui::Layout::spacingMd();
        ImGui::PushStyleColor(ImGuiCol_ChildBg,
            recommended ? ui::material::WithAlpha(ui::material::Primary(), 22)
                        : ui::material::WithAlpha(ui::material::OnSurface(), 8));
        ImGui::PushStyleColor(ImGuiCol_Border,
            recommended ? ui::material::WithAlpha(ui::material::Primary(), 140)
                        : ui::material::WithAlpha(ui::material::OnSurface(), 28));
        ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp);
        ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f);
        ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad));
        ImGui::BeginChild(id, ImVec2(cardW, cardH), true,
                          ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
        // Zero implicit item spacing so the only vertical gaps are the explicit Dummy()s below — that
        // keeps the caller's measured cardH exact, so the bottom-pinned button never clips.
        ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f));

        ImGui::PushFont(ui::material::Type().iconMed());
        ImGui::TextColored(recommended ? ui::material::PrimaryVec4()
                                       : ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()),
                           "%s", glyph);
        ImGui::PopFont();
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));

        ImGui::PushFont(ui::material::Type().subtitle2());
        ImGui::TextUnformatted(title);
        ImGui::PopFont();
        if (recommended) {
            ImGui::PushFont(ui::material::Type().caption());
            ImGui::TextColored(ui::material::PrimaryVec4(), "%s", TR("wallet_recovery_recommended"));
            ImGui::PopFont();
        }
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));

        ImGui::PushFont(ui::material::Type().caption());
        ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
        ImGui::PushTextWrapPos(0.0f);
        ImGui::TextWrapped("%s", desc);
        ImGui::PopTextWrapPos();
        ImGui::PopStyleColor();
        ImGui::PopFont();

        // Pin the button to the card bottom so both cards' buttons line up.
        const float btnH = ImGui::GetFrameHeight() + 6.0f * dp;
        const float remaining = ImGui::GetContentRegionAvail().y - btnH;
        if (remaining > 0.0f) ImGui::Dummy(ImVec2(0, remaining));
        bool clicked;
        if (recommended) {
            ImGui::PushStyleColor(ImGuiCol_Button,        ui::material::WithAlpha(ui::material::Primary(), 65));
            ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100));
            ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ui::material::WithAlpha(ui::material::Primary(), 125));
            clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH));
            ImGui::PopStyleColor(3);
        } else {
            clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH));
        }
        ImGui::PopStyleVar();   // ItemSpacing
        ImGui::EndChild();
        ImGui::PopStyleVar(3);
        ImGui::PopStyleColor(2);
        return clicked;
    };
    // A quiet clickable text link for the footer (Show me the files / Not now).
    auto linkText = [&](const char* label) -> bool {
        ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
        ImGui::TextUnformatted(label);
        ImGui::PopStyleColor();
        const bool clicked = ImGui::IsItemClicked();
        if (ImGui::IsItemHovered()) {
            ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
            const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax();
            ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y),
                                                ui::material::OnSurface());
        }
        return clicked;
    };

    switch (recovery_phase_) {
    case RecoveryPhase::Offer: {
        safetyHero();
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        ImGui::TextWrapped("%s", TR("wallet_recovered_warn"));
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));

        // The two real actions as side-by-side CHOICE CARDS — the recommended one gold-tinted, so the
        // decision reads at a glance instead of hiding in a menu. Rebuild is the more complete fix; when
        // its helper is missing, Restore becomes the single recommended card (never a dead end).
        const bool canRebuild = walletRebuildAvailable();
        const float cardGap  = ui::Layout::spacingMd();
        const float pad      = cardGap;
        const float contentW = ImGui::GetContentRegionAvail().x;
        const float cardW    = canRebuild ? (contentW - cardGap) * 0.5f
                                          : std::min(contentW, 320.0f * dp);
        // Fix a shared card height off the taller card body so the two cards line up. Measure title AND
        // desc wraps (titles/descs can be multi-line, esp. after translation), with a little width slack.
        ImFont* descF  = ui::material::Type().caption();
        ImFont* titleF = ui::material::Type().subtitle2();
        const float innerW = std::max(1.0f, cardW - 2.0f * pad - 6.0f * dp);
        auto measureH = [&](ImFont* f, const char* s) {
            return f->CalcTextSizeA(f->LegacySize, FLT_MAX, innerW, s).y;
        };
        const float titleH = std::max(measureH(titleF, TR("wallet_recovery_rebuild_card")),
                                      measureH(titleF, TR("wallet_recovery_restore_card")));
        const float descH  = std::max(measureH(descF, TR("wallet_recovery_rebuild_card_desc")),
                                      measureH(descF, TR("wallet_recovery_restore_card_desc")));
        const float cardH = 2.0f * pad
            + ui::material::Type().iconMed()->LegacySize + ui::Layout::spacingXs()   // icon + gap
            + titleH + descF->LegacySize                                             // title + RECOMMENDED chip
            + ui::Layout::spacingXs()                                                // gap before desc
            + descH + ui::Layout::spacingSm()                                        // desc + gap before button
            + ImGui::GetFrameHeight() + 6.0f * dp                                    // button
            + ui::Layout::spacingXs();                                               // small buffer

        if (canRebuild) {
            const bool r = renderCard("##rcRepair", ICON_MD_AUTO_FIX_HIGH, TR("wallet_recovery_rebuild_card"),
                                      TR("wallet_recovery_rebuild_card_desc"), TR("wallet_recovery_repair_go"),
                                      true, cardW, cardH);
            ImGui::SameLine(0, cardGap);
            const bool s = renderCard("##rcRestore", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"),
                                      TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"),
                                      false, cardW, cardH);
            if (r) rebuildWalletDatabase();
            if (s) restoreOriginalWallet();
        } else {
            const float indent = (contentW - cardW) * 0.5f;
            if (indent > 0.0f) ImGui::Indent(indent);
            const bool s = renderCard("##rcRestoreOnly", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"),
                                      TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"),
                                      true, cardW, cardH);
            if (indent > 0.0f) ImGui::Unindent(indent);
            if (s) restoreOriginalWallet();
        }

        // Quiet footer: inspect-files link + a clearly-labelled, reversible dismiss (with a tooltip that
        // spells out the consequence — plain "Not now" was ambiguous).
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
        if (linkText(TR("wallet_recovered_open_folder")))
            util::Platform::openFolder(util::Platform::getDragonXDataDir());
        ImGui::SameLine(0, ui::Layout::spacingSm());
        ImGui::TextDisabled("\xC2\xB7");   // middle dot separator
        ImGui::SameLine(0, ui::Layout::spacingSm());
        if (linkText(TR("wallet_recovery_decide_later"))) {
            show_wallet_recovered_dialog_ = false;   // non-destructive; reopen from the status bar
            recovery_phase_ = RecoveryPhase::Offer;
        }
        if (ImGui::IsItemHovered()) {
            ImGui::BeginTooltip();
            ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f);
            ImGui::TextUnformatted(TR("wallet_recovery_decide_later_tip"));
            ImGui::PopTextWrapPos();
            ImGui::EndTooltip();
        }
        // Plain-language "what happens to my files" — replaces the unhelpful raw daemon log on this screen
        // (the log is still available in the Console tab for troubleshooting).
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        if (quietHeader(TR("wallet_recovery_files_label"))) {
            ImGui::PushFont(ui::material::Type().caption());
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
            ImGui::PushTextWrapPos(0.0f);
            ImGui::TextWrapped("%s", TR("wallet_recovery_files_detail"));
            ImGui::PopTextWrapPos();
            ImGui::PopStyleColor();
            ImGui::PopFont();
        }
        break;
    }
    case RecoveryPhase::Working: {
        ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_rebuild_started")
                                                               : TR("wallet_restore_started"));
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
        ImGui::Text("%s%s", TR("wallet_recovery_working_label"), ui::material::LoadingDots());
        break;
    }
    case RecoveryPhase::Done: {
        safetyHero();
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_recovery_success_body")
                                                               : TR("wallet_recovery_success_restore"));
        // A sev-1 warning (e.g. restored but the node didn't relaunch) carries a specific message.
        if (recovery_outcome_sev_ == 1 && !recovery_outcome_msg_.empty()) {
            ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
            ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str());
            ImGui::PopStyleColor();
        }
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
        if (ui::material::TactileButton(TR("wallet_recovery_done"), ImVec2(fitBtnW(TR("wallet_recovery_done")), 0))) {
            show_wallet_recovered_dialog_ = false;
            recovery_phase_ = RecoveryPhase::Offer;
            // Clean success — clear the session flag so a later unrelated disconnect doesn't re-raise the
            // "repair available" chip/dialog for a wallet that's already fixed. (Warnings keep it set.)
            if (recovery_outcome_sev_ == 0) wallet_auto_recovered_ = false;
        }
        techDetails();
        break;
    }
    case RecoveryPhase::Failed: {
        ImGui::PushFont(ui::material::Type().subtitle1());
        ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
        ImGui::TextWrapped("%s", TR("wallet_recovery_failure_title"));
        ImGui::PopStyleColor();
        ImGui::PopFont();
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        ImGui::TextWrapped("%s", TR("wallet_recovery_failure_body"));
        if (!recovery_outcome_msg_.empty()) {
            ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
            ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str());
            ImGui::PopStyleColor();
        }
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
        // Offer the UNtried option (Restore needs no helper; Rebuild only if its helper exists).
        if (recovery_last_action_rebuild_) {
            if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_restore_sub")))
                restoreOriginalWallet();
        } else if (walletRebuildAvailable()) {
            if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_rebuild_sub")))
                rebuildWalletDatabase();
        }
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        if (actionRow(TR("wallet_recovered_open_folder"), TR("wallet_recovered_open_folder_sub")))
            util::Platform::openFolder(util::Platform::getDragonXDataDir());
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        if (ui::material::TactileButton(TR("close"), ImVec2(fitBtnW(TR("close")), 0))) {
            show_wallet_recovered_dialog_ = false;
            recovery_phase_ = RecoveryPhase::Offer;
        }
        techDetails();
        break;
    }
    }

    ui::material::EndOverlayDialog();
}

// Auto-shown when the active wallet loaded EMPTY but a sibling wallet file in the datadir still holds keys
// (see maybeWarnEmptyWalletWithFundedSiblings). Funds are not lost — they're in another file, most likely a
// wallet.<ts>.bak left by an earlier BDB salvage. This routes the user to the wallet manager to switch, and
// remembers a per-file dismissal so it never nags again for this wallet.
void App::renderEmptyWalletWarningDialog()
{
    if (!show_empty_wallet_warning_) return;

    const bool salvage = empty_wallet_has_salvage_bak_;   // salvage .bak → offer Restore; else → switch wallet

    ui::material::OverlayDialogSpec ov;
    ov.title = TR(salvage ? "empty_wallet_salvage_title" : "empty_wallet_warning_title");
    ov.p_open = &show_empty_wallet_warning_;
    ov.style = ui::material::OverlayStyle::BlurFloat;
    ov.cardWidth = 560.0f;
    ov.idSuffix = "emptywalletwarn";
    if (!ui::material::BeginOverlayDialog(ov)) return;
    const float dp = ui::Layout::dpiScale();

    // Header: wallet icon in a warning tint + a calm "your coins are likely in another file" framing.
    {
        ImFont* icoF = ui::material::Type().iconLarge();
        const float rowTop = ImGui::GetCursorPosY();
        ImGui::PushFont(icoF);
        ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
        ImGui::TextUnformatted(ICON_MD_ACCOUNT_BALANCE_WALLET);
        ImGui::PopStyleColor();
        ImGui::PopFont();
        ImGui::SameLine();
        ImFont* txtF = ui::material::Type().subtitle1();
        const float iconH = icoF->LegacySize;
        const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize();
        if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f);
        ImGui::PushFont(txtF);
        ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_headline" : "empty_wallet_warning_headline"));
        ImGui::PopFont();
    }
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));

    ImGui::PushTextWrapPos(0.0f);
    ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_body" : "empty_wallet_warning_body"));
    ImGui::PopTextWrapPos();

    // For the "wrong wallet" case, name the other wallet file(s) that hold keys, with a compact key count —
    // concrete evidence the coins are recoverable from them. The count is built with std::to_string so no
    // printf format lives in a translatable string (translations are additive and could otherwise drop a %d).
    // (The salvage case has no sibling list — restoreOriginalWallet() finds the backup itself.)
    if (!empty_wallet_funded_siblings_.empty()) {
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        for (const auto& s : empty_wallet_funded_siblings_) {
            ImGui::Bullet();
            ImGui::SameLine();
            ImGui::TextUnformatted(s.fileName.c_str());
            ImGui::PushFont(ui::material::Type().caption());
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
            const std::string keys = "  " + std::to_string(s.transparentKeys + s.shieldedKeys)
                                   + " " + TR("empty_wallet_keys_suffix");
            ImGui::SameLine();
            ImGui::TextUnformatted(keys.c_str());
            ImGui::PopStyleColor();
            ImGui::PopFont();
        }
    }

    // Primary action: route the user to the wallet manager to switch files (accent-tinted so it dominates).
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
    ImFont* rbf = ui::material::Type().button();
    auto fitBtnW = [&](const char* label) {
        return std::max(120.0f * dp,
            rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x
            + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
    };
    const char* primaryLabel = salvage ? TR("empty_wallet_restore") : TR("empty_wallet_open_manager");
    ImGui::PushStyleColor(ImGuiCol_Button,        ui::material::WithAlpha(ui::material::Primary(), 65));
    ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100));
    ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ui::material::WithAlpha(ui::material::Primary(), 125));
    if (ui::material::TactileButton(primaryLabel, ImVec2(fitBtnW(primaryLabel), 0))) {
        show_empty_wallet_warning_ = false;
        if (salvage)
            restoreOriginalWallet();   // self-contained: swaps the .bak back + drives the recovery dialog's progress
        else
            ui::WalletsDialog::show(this);
    }
    ImGui::PopStyleColor(3);

    // Quiet footer: open the data folder, or dismiss permanently for THIS wallet file.
    auto linkText = [&](const char* label) -> bool {
        ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
        ImGui::TextUnformatted(label);
        ImGui::PopStyleColor();
        const bool clicked = ImGui::IsItemClicked();
        if (ImGui::IsItemHovered()) {
            ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
            const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax();
            ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y),
                                                ui::material::OnSurface());
        }
        return clicked;
    };
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
    if (linkText(TR("wallet_recovered_open_folder")))
        util::Platform::openFolder(util::Platform::getDragonXDataDir());
    ImGui::SameLine(0, ui::Layout::spacingSm());
    ImGui::TextDisabled("\xC2\xB7");   // middle dot separator
    ImGui::SameLine(0, ui::Layout::spacingSm());
    if (linkText(TR("empty_wallet_warning_dismiss"))) {
        if (settings_) {
            settings_->ackEmptyWalletWarn(settings_->getActiveWalletFile());
            settings_->save();
        }
        show_empty_wallet_warning_ = false;
    }
    if (ImGui::IsItemHovered()) {
        ImGui::BeginTooltip();
        ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f);
        ImGui::TextUnformatted(TR("empty_wallet_warning_dismiss_tip"));
        ImGui::PopTextWrapPos();
        ImGui::EndTooltip();
    }

    ui::material::EndOverlayDialog();
}

// Auto-shown when the embedded node aborts on an unreadable block database — offers the one-click
// -reindex rebuild instead of leaving the wallet stuck on a silent zero balance.
void App::renderBlockDbReindexDialog()
{
    if (!show_block_db_reindex_confirm_) return;

    ui::material::OverlayDialogSpec ov;
    ov.title = TR("block_db_reindex_title");
    ov.p_open = &show_block_db_reindex_confirm_;   // X / backdrop dismisses (offer remains; loop stays held)
    ov.style = ui::material::OverlayStyle::BlurFloat;
    ov.cardWidth = 540.0f;
    ov.idSuffix = "blockdbreindex";
    if (!ui::material::BeginOverlayDialog(ov)) return;
    const float dp = ui::Layout::dpiScale();

    ui::material::DialogWarningHeader(TR("block_db_reindex_warn"));
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ImGui::TextWrapped("%s", TR("block_db_reindex_body"));
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
    if (ui::material::TactileButton(TR("block_db_reindex_confirm"), ImVec2(260.0f * dp, 0))) {
        reindexBlockDatabase();   // clears show_block_db_reindex_confirm_ + block_db_reindex_available_
    }
    ImGui::SameLine();
    if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
        show_block_db_reindex_confirm_ = false;
        // Leave block_db_reindex_available_ set: the connect loop keeps HOLDING (no crash-restart storm)
        // rather than looping into the same abort; the user can rebuild later from Settings.
    }
    ui::material::EndOverlayDialog();
}

void App::renderSwitchStopDaemonDialog()
{
    const bool confirm  = show_switch_stop_daemon_confirm_;
    const bool progress = wallet_switch_dialog_open_.load();
    if (!confirm && !progress) return;

    const auto phase  = static_cast<WalletSwitchPhase>(wallet_switch_phase_.load());
    const bool failed = progress && phase == WalletSwitchPhase::Failed;

    // Prettify a wallet filename for display (wallet.dat → "Default wallet", wallet-savings.dat → "savings").
    auto pretty = [this](const std::string& f) -> std::string {
        if (f.empty() || f == "wallet.dat") return TR("switch_progress_default_wallet");
        std::string s = f;
        if (s.size() > 4 && s.substr(s.size() - 4) == ".dat") s.resize(s.size() - 4);
        if (s.rfind("wallet-ip-", 0) == 0)  return TR("switch_progress_external_wallet");
        if (s.rfind("wallet-", 0) == 0)     s = s.substr(7);
        return s;
    };
    const std::string toName = pretty(settings_ ? settings_->getActiveWalletFile() : std::string());

    // Progress title carries the target wallet ("Switching wallet — savings"); keep the string alive.
    std::string titleStr = confirm ? std::string(TR("switch_stopnode_title"))
                         : failed  ? std::string(TR("switch_progress_failed_title"))
                                   : std::string(TR("switch_progress_title")) + " — " + toName;

    ui::material::OverlayDialogSpec ov;
    ov.title = titleStr.c_str();
    // A confirm can be dismissed via X/backdrop (= cancel); a running switch cannot (stay until it's up).
    ov.p_open = confirm ? &show_switch_stop_daemon_confirm_ : nullptr;
    ov.style = ui::material::OverlayStyle::BlurFloat;
    ov.cardWidth = 520.0f; ov.idSuffix = "switchstopnode";
    if (!ui::material::BeginOverlayDialog(ov)) {
        if (confirm && !show_switch_stop_daemon_confirm_) pending_switch_wallet_file_.clear();  // X = cancel
        return;
    }
    const float dp = ui::Layout::dpiScale();

    if (confirm) {
        ui::material::DialogWarningHeader(TR("switch_stopnode_warn"));
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        ImGui::TextWrapped("%s", TR("switch_stopnode_body"));
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
        if (ui::material::TactileButton(TR("switch_stopnode_confirm"), ImVec2(200.0f * dp, 0))) {
            const std::string w = pending_switch_wallet_file_;
            pending_switch_wallet_file_.clear();
            show_switch_stop_daemon_confirm_ = false;
            switchToWallet(w, /*stopDaemonConfirmed=*/true);   // opens the live-progress phase below
        }
        ImGui::SameLine();
        if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
            pending_switch_wallet_file_.clear();
            show_switch_stop_daemon_confirm_ = false;
        }
    } else if (failed) {
        // The title already says "Wallet switch failed" — put the actual reason in the warning header
        // rather than repeating the title.
        ui::material::DialogWarningHeader(wallet_switch_error_.c_str());
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
        // If the node reported the target wallet as corrupt, offer a one-click -salvagewallet repair
        // (retries the switch to that wallet with recovery enabled).
        if (switch_wallet_corrupt_.load()) {
            if (ui::material::TactileButton(TR("switch_corrupt_repair"), ImVec2(240.0f * dp, 0))) {
                const std::string target = wallet_switch_target_file_;
                switchToWallet(target, /*stopDaemonConfirmed=*/true, /*salvage=*/true);  // reopens the progress modal
            }
            ImGui::SameLine();
        }
        if (ui::material::TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) {
            wallet_switch_dialog_open_.store(false);
            wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
            wallet_switch_error_.clear();
        }
    } else {
        // Live progress, kept redrawing by isWalletSwitchInProgress().
        // Source context (the target is already in the title).
        ImGui::PushFont(ui::material::Type().caption());
        ImGui::TextDisabled("%s %s", TR("switch_progress_from_label"), pretty(wallet_switch_prev_file_).c_str());
        ImGui::PopFont();
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));

        // Primary status + detail. During Reconnecting the node is loading the block index / verifying /
        // rescanning — surface its live init stage (state_.warmup_status/description) instead of a bare
        // "Reconnecting…", so the ~30-60s wait shows real progress.
        std::string primary, detail;
        if (phase == WalletSwitchPhase::Stopping) {
            primary = TR("switch_progress_stopping");
            detail  = TR("switch_progress_hint");
        } else if (phase == WalletSwitchPhase::Starting) {
            primary = TR("switch_progress_starting");
        } else {  // Reconnecting
            if (!state_.warmup_status.empty()) { primary = state_.warmup_status; detail = state_.warmup_description; }
            else                                 primary = TR("switch_progress_reconnecting");
        }
        ui::material::Type().text(ui::material::TypeStyle::Subtitle1,
                                  (primary + ui::material::LoadingDots()).c_str());
        if (!detail.empty()) {
            ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
            ImGui::PushFont(ui::material::Type().body2());
            ImGui::TextWrapped("%s", detail.c_str());
            ImGui::PopFont();
        }

        // Elapsed timer — captured on the first progress frame (reset per switch in switchToWallet).
        if (wallet_switch_started_time_ <= 0.0) wallet_switch_started_time_ = ImGui::GetTime();
        const int el = static_cast<int>(ImGui::GetTime() - wallet_switch_started_time_);
        ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
        ImGui::PushFont(ui::material::Type().caption());
        ImGui::TextDisabled("%s %d:%02d", TR("switch_progress_elapsed"), el / 60, el % 60);
        ImGui::PopFont();

        ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
        // Escape hatch — the switch keeps running in the background (a toast reports the result).
        if (ui::material::TactileButton(TR("switch_progress_background"), ImVec2(220.0f * dp, 0)))
            wallet_switch_dialog_open_.store(false);
    }

    ui::material::EndOverlayDialog();
}

void App::refreshNow()
{
    // Trigger immediate refresh on all categories
    network_refresh_.markImmediateRefresh();
    transactions_dirty_ = true;        // Force transaction list update
    addresses_dirty_ = true;           // Force address/balance update
    last_tx_block_height_ = -1;        // Reset tx cache
    invalidateShieldedHistoryScanProgress(true);
}

void App::skipDaemonOutputBacklog()
{
    // While minimized, App::update() is paused, so daemon_output_offset_ is never advanced and a large
    // backlog of daemon output piles up. Parsing it all at once on restore would replay a background
    // witness rebuild's progress + completion in a single batch and fire a spurious "Blockchain rescan
    // complete" toast. Advance the offset to the current end so only NEW (post-restore) output is parsed.
    // A genuine user-initiated rescan still surfaces completion via the getrescaninfo monitor.
    if (daemon_controller_ && daemon_controller_->isRunning()) {
        (void)daemon_controller_->outputSince(daemon_output_offset_);  // advances daemon_output_offset_ to the end
    }
}

void App::handlePaymentURI(const std::string& uri)
{
    auto payment = util::parsePaymentURI(uri);
    
    if (!payment.valid) {
        ui::Notifications::instance().error(std::string(TR("appx_invalid_payment_uri_prefix")) + payment.error);
        return;
    }
    
    // Store pending payment
    pending_payment_valid_ = true;
    pending_to_address_ = payment.address;
    pending_amount_ = payment.amount;
    pending_memo_ = payment.memo;
    pending_label_ = payment.label;
    
    // Switch to Send page
    setCurrentPage(ui::NavPage::Send);
    
    // Notify user
    std::string msg = TR("appx_payment_request_loaded");
    if (payment.amount > 0) {
        char buf[64];
        snprintf(buf, sizeof(buf), " for %.8f DRGX", payment.amount);
        msg += buf;
    }
    ui::Notifications::instance().info(msg);
}

void App::setCurrentTab(int tab) {
    // Legacy int-to-NavPage mapping (used by balance_tab context menus)
    static const ui::NavPage kTabMap[] = {
        ui::NavPage::Overview, // 0 = Balance
        ui::NavPage::Send,     // 1 = Send
        ui::NavPage::Receive,  // 2 = Receive
        ui::NavPage::History,  // 3 = Transactions
        ui::NavPage::Mining,   // 4 = Mining
        ui::NavPage::Peers,     // 5 = Peers
        ui::NavPage::Market,    // 6 = Market
        ui::NavPage::Console,   // 7 = Console
        ui::NavPage::Explorer,  // 8 = Explorer
        ui::NavPage::Settings,  // 9 = Settings
    };
    if (tab >= 0 && tab < static_cast<int>(sizeof(kTabMap)/sizeof(kTabMap[0])))
        setCurrentPage(kTabMap[tab]);
}

std::string App::getDaemonStatus() const
{
    std::lock_guard<std::mutex> lk(daemon_status_mutex_);
    return daemon_status_;  // a copy — safe to use after the lock is released
}

void App::setDaemonStatus(std::string s)
{
    std::lock_guard<std::mutex> lk(daemon_status_mutex_);
    daemon_status_ = std::move(s);
}

bool App::startEmbeddedDaemon()
{
    if (!supportsEmbeddedDaemon()) {
        DEBUG_LOGF("Embedded daemon support unavailable in this build, not starting\n");
        return false;
    }

    if (!isUsingEmbeddedDaemon()) {
        DEBUG_LOGF("Embedded daemon disabled, not starting\n");
        return false;
    }
    
    // Check if Sapling params exist - try extracting if embedded
    if (!rpc::Connection::verifySaplingParams()) {
        DEBUG_LOGF("Sapling params not found, checking for embedded resources...\n");
        
        // Try to extract embedded resources if available
        if (resources::hasEmbeddedResources()) {
            DEBUG_LOGF("Extracting embedded Sapling params...\n");
            setDaemonStatus(TR("sb_extracting_sapling"));
            if (!resources::extractEmbeddedResources()) {
                setDaemonStatus(TR("sb_daemon_extract_failed"));
                DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n");
                return false;
            }
            
            // Check again after extraction
            if (!rpc::Connection::verifySaplingParams()) {
                setDaemonStatus(TR("sb_sapling_failed"));
                DEBUG_LOGF("Sapling params still not found after extraction!\n");
                DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
                return false;
            }
            DEBUG_LOGF("Sapling params extracted successfully\n");
        } else {
            // Fallback: check for params bundled alongside the executable
            // (zip distributions bundle sapling-*.params next to the binary)
            namespace fs = std::filesystem;
            std::string exe_dir = util::Platform::getExecutableDirectory();
            std::string daemon_dir = resources::getDaemonDirectory();
            const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
            bool copied = false;
            if (!exe_dir.empty()) {
                std::string dirErr;
                if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
                    setDaemonStatus(dirErr);
                    DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
                    return false;
                }
                std::error_code ec;

                // On macOS .app bundles, params are in Contents/Resources/
                // while the executable is in Contents/MacOS/
                std::vector<std::string> searchDirs = { exe_dir };
#ifdef __APPLE__
                {
                    fs::path resourcesDir = fs::path(exe_dir).parent_path() / "Resources";
                    if (fs::is_directory(resourcesDir))
                        searchDirs.insert(searchDirs.begin(), resourcesDir.string());
                }
#endif

                for (const char* name : paramFiles) {
                    fs::path dst = fs::path(daemon_dir) / name;
                    if (fs::exists(dst)) continue;
                    for (const auto& dir : searchDirs) {
                        fs::path src = fs::path(dir) / name;
                        if (fs::exists(src)) {
                            DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
                            fs::copy_file(src, dst, ec);
                            if (!ec) copied = true;
                            break;
                        }
                    }
                }
            }
            if (copied && rpc::Connection::verifySaplingParams()) {
                DEBUG_LOGF("Sapling params copied from exe directory successfully\n");
            } else {
                setDaemonStatus(TR("sb_sapling_not_found"));
                DEBUG_LOGF("Sapling params not found and no embedded resources available!\n");
                DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
                return false;
            }
        }
    }
    
    // Ensure asmap.dat and daemon binaries are copied from the bundle
    // to the daemon directory even if sapling params already existed.
    {
        namespace fs = std::filesystem;
        std::string exe_dir = util::Platform::getExecutableDirectory();
        std::string daemon_dir = resources::getDaemonDirectory();
        if (!exe_dir.empty()) {
            std::string dirErr;
            if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
                setDaemonStatus(dirErr);
                DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
                return false;
            }
            std::error_code ec;

            std::vector<std::string> searchDirs = { exe_dir };
#ifdef __APPLE__
            {
                fs::path resourcesDir = fs::path(exe_dir).parent_path() / "Resources";
                if (fs::is_directory(resourcesDir))
                    searchDirs.insert(searchDirs.begin(), resourcesDir.string());
            }
#endif
            const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
            bool copyFailed = false;
            for (const char* name : extraFiles) {
                fs::path dst = fs::path(daemon_dir) / name;
                if (fs::exists(dst)) continue;
                for (const auto& dir : searchDirs) {
                    fs::path src = fs::path(dir) / name;
                    if (fs::exists(src)) {   // an absent source is optional; only a real copy error counts
                        DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
                        fs::copy_file(src, dst, ec);
                        if (ec) {
                            DEBUG_LOGF("[ERROR] Failed to copy %s: %s\n", name, ec.message().c_str());
                            copyFailed = true;
                            ec.clear();
                        }
                        break;
                    }
                }
            }
            if (copyFailed) {
                char buf[512];
                snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
                setDaemonStatus(buf);
                DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str());
                return false;
            }
        }
    }

    // Create daemon controller if needed
    if (!daemon_controller_) {
        daemon_controller_ = std::make_unique<daemon::DaemonController>();
        
        // Set up state callback
        daemon_controller_->setStateCallback([this](daemon::EmbeddedDaemon::State state, const std::string& msg) {
            // NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
            switch (state) {
                case daemon::EmbeddedDaemon::State::Starting:
                    setDaemonStatus(TR("sb_starting_daemon"));
                    break;
                case daemon::EmbeddedDaemon::State::Running:
                    setDaemonStatus(TR("sb_dragonxd_running"));
                    break;
                case daemon::EmbeddedDaemon::State::Stopping:
                    setDaemonStatus(TR("sb_dragonxd_stopping"));
                    break;
                case daemon::EmbeddedDaemon::State::Stopped:
                    setDaemonStatus(TR("sb_dragonxd_stopped"));
                    break;
                case daemon::EmbeddedDaemon::State::Error:
                    setDaemonStatus("Error: " + msg);
                    break;
            }
        });
    }

    return daemon_controller_->start(settings_.get());
}

void App::stopEmbeddedDaemon()
{
    if (!daemon_controller_) return;

    auto shutdownDecision = daemon_controller_->shutdownDecision(
        false, settings_ && settings_->getStopExternalDaemon());
    if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
        DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason);
        return;
    }

    // Do we hold a live process handle for this node? Capture it BEFORE stop() reaps the pid. If owned,
    // daemon_controller_->stop() below BLOCKS until the process actually exits. If NOT owned (external /
    // adopted / direct-connected), stop() returns at once — we can only ask it over RPC and then watch
    // for it to disappear (handled after the stop() call).
    const bool owned = daemon_controller_->isRunning();

    // Send RPC "stop" command — this is the graceful path that lets the
    // daemon flush state, save block indexes, close sockets, etc.
    bool stop_sent = false;
    
    // Try the existing RPC connection first. beginShutdown() called rpc_->requestAbort() to unblock any
    // in-flight request — but that sticky abort (cleared only by connect()) would make THIS "stop" self-
    // abort mid-flight (CURLE_ABORTED_BY_CALLBACK). The old async rpc_->stop() swallowed that error yet
    // set stop_sent=true, so the daemon never heard "stop" and the working temp-connection fallback was
    // skipped — which is why "Stop external daemon" left an external dragonxd running (it only died via
    // the 20s force-kill). Clear the abort and send SYNCHRONOUSLY so real success is surfaced.
    if (rpc_ && rpc_->isConnected()) {
        DEBUG_LOGF("Sending stop command via existing RPC connection...\n");
        rpc_->clearAbort();
        if (sendStopCommandSafely(*rpc_, "existing connection stop"))
            stop_sent = true;
    }
    
    // If the main connection wasn't established (e.g. daemon was still
    // starting up when user closed the window), create a temporary
    // RPC connection just to send the stop command.
    if (!stop_sent) {
        DEBUG_LOGF("Main RPC not connected — creating temporary connection for stop...\n");
        auto config = rpc::Connection::autoDetectConfig();
        if (!config.rpcuser.empty() && !config.rpcpassword.empty()) {
            auto tmp_rpc = std::make_unique<rpc::RPCClient>();
            // Use a short timeout — if daemon isn't listening yet, don't block
            if (tmp_rpc->connect(config.host, config.port,
                                 config.rpcuser, config.rpcpassword,
                                 config.use_tls)) {
                DEBUG_LOGF("Temporary RPC connected, sending stop...\n");
                if (sendStopCommandSafely(*tmp_rpc, "Temporary daemon stop")) {
                    stop_sent = true;
                }
                tmp_rpc->disconnect();
            } else {
                DEBUG_LOGF("Could not establish temporary RPC connection\n");
            }
        } else {
            DEBUG_LOGF("No RPC credentials available (DRAGONX.conf missing?)\n");
        }
    }
    
    if (stop_sent) {
        DEBUG_LOGF("Waiting for daemon to flush block index and shut down...\n");
        shutdown_status_ = "Waiting for daemon to flush block index...";
        // Give the daemon time to flush LevelDB to disk before we
        // escalate to SIGTERM.  On macOS/APFS, LevelDB compaction +
        // fsync can take 15-20s on a large chain.  The stop() method
        // will wait this long for a *natural* exit (via the RPC stop
        // we already sent) before falling back to SIGTERM.
    }
    
    shutdown_status_ = "Waiting for dragonxd process to exit...";
    // 20s grace period for the RPC "stop" to complete (LevelDB flush).
    // Only after that does stop() escalate to SIGTERM, then SIGKILL.
    daemon_controller_->stop(20000);

    // EXTERNAL / adopted node during app shutdown: we hold no process handle, so the stop() above returned
    // immediately (it can only wait on a node WE spawned). But the user turned on "Stop external daemon",
    // so keep the window on the shutdown screen and poll until the node is actually gone — surfacing a live
    // status so they can SEE it stop — rather than closing while it's still flushing. Bounded ~120s (a
    // graceful full-node shutdown can flush LevelDB for 60-90s). Scoped to real shutdown; the shutdown
    // screen's Force Quit stays available and flips shutdown_complete_, which breaks us out at once.
    if (stop_sent && !owned && shutting_down_) {
        auto stillUp = []() {
            return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
        };
        // Set the phase text ONCE, then just poll — the shutdown screen already renders a live "N seconds"
        // elapsed counter on the UI thread, so the user still sees time passing. (shutdown_status_ is now a
        // GuardedStatus, so per-iteration writes would be race-safe; the single write is just a UX choice.)
        shutdown_status_ = "Waiting for the external node to stop...";
        for (int i = 0; i < 1200 && stillUp() && !shutdown_complete_; ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        shutdown_status_ = stillUp() ? "External node still stopping — closing anyway..."
                                     : "External node stopped";
        DEBUG_LOGF("stopEmbeddedDaemon: external node %s\n", stillUp() ? "still up (timed out)" : "confirmed stopped");
    }
}

bool App::isEmbeddedDaemonRunning() const
{
    return daemon_controller_ && daemon_controller_->isRunning();
}

bool App::stopDaemonForWalletSwitch()
{
    // "Owned" means we spawned the node this session and hold a live process handle. That is the ONLY
    // reliable ownership signal here: externalDaemonDetected() is latched only inside EmbeddedDaemon::start(),
    // so when the app just direct-connects to an already-running, config-provided node (the common
    // keep-node-running case) start() is never called and that flag stays false even though we clearly did
    // not start the node. Gate on the handle instead.
    const bool owned = daemon_controller_ && isEmbeddedDaemonRunning();
    if (owned) {
        // We spawned it — stopEmbeddedDaemon() BLOCKS for full process exit (handle wait + SIGTERM/SIGKILL
        // escalation), so the datadir lock + RPC port are released by the time it returns; a short port
        // check just confirms.
        stopEmbeddedDaemon();
        for (int i = 0; i < 200 && daemon::EmbeddedDaemon::isRpcPortInUse() && !shutting_down_; ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        return shutting_down_ || !daemon::EmbeddedDaemon::isRpcPortInUse();
    }

    // Adopted OR direct-connected: no process handle, so RPC "stop" is the only lever, and
    // stopEmbeddedDaemon()'s temp connection (autoDetectConfig) can't be trusted here. Send a graceful RPC
    // "stop" over the exact creds we're connected with (saved_config_) — guaranteed to reach our node, and
    // only OUR node accepts them, so a foreign dragonxd (different rpcpassword) is a safe no-op. switchToWallet
    // disconnects rpc_ before this worker runs, so build a fresh temporary connection.
    bool sent = false;
    {
        auto tmp = std::make_unique<rpc::RPCClient>();
        if (tmp->connect(saved_config_.host, saved_config_.port, saved_config_.rpcuser,
                         saved_config_.rpcpassword, saved_config_.use_tls)) {
            sent = sendStopCommandSafely(*tmp, "wallet-switch stop");
            tmp->disconnect();
        }
    }
    DEBUG_LOGF("[App] wallet-switch stop of unowned node (saved_config_): %s\n", sent ? "sent" : "FAILED");

    // CRITICAL: wait for the node to FULLY EXIT before the caller starts the replacement — NOT just for the
    // RPC port. On Windows isRpcPortInUse() is a connect() probe that reads "free" the moment the daemon
    // stops accepting RPC (early in shutdown), but the process keeps the DATADIR LOCK until it exits, and a
    // graceful shutdown can take 60-90s when the node's network threads block on peer timeouts. Starting a
    // replacement into a still-locked datadir fails ("Cannot obtain a lock…") and wedges the switch. So gate
    // on the PROCESS being gone (isDaemonProcessRunning) AND the port free. Bounded ~120s while stopping; if
    // we couldn't even send the stop (foreign node / bad creds → its port never frees), only a brief grace.
    const int maxTicks = sent ? 1200 : 50;
    auto stillUp = []() {
        return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
    };
    for (int i = 0; i < maxTicks && stillUp() && !shutting_down_; ++i)
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    return shutting_down_ || !stillUp();
}

void App::rescanBlockchain()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
        return;
    }

    auto decision = daemon::DaemonController::evaluateLifecycleOperation(
        daemon::DaemonController::LifecycleOperation::Rescan,
        isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
    if (!decision.allowed) {
        ui::Notifications::instance().warning(decision.warning);
        return;
    }

    // Re-entrancy guard: a rescan/repair (both drive state_.sync.rescanning) or this exact task already
    // running would stomp each other — a second confirm must not launch a duplicate operation.
    if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
        ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress"));
        return;
    }
    // Don't race a wallet switch / seed-adopt / encryption restart, which drive their own daemon stop/start.
    if (daemon_restarting_) {
        ui::Notifications::instance().warning(TR("appx_node_busy_restarting"));
        return;
    }

    DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
    ui::Notifications::instance().info(TR("appx_restarting_daemon_rescan_flag"));
    
    // Initialize rescan state for status bar display. rescan_confirmed_active_ stays false until we
    // actually observe the restarted daemon rescanning — so the first poll (which may still reach the
    // pre-restart daemon and see rescanning=false) can't be misread as instant completion.
    state_.sync.rescanning = true;
    rescan_confirmed_active_ = false;
    user_initiated_rescan_ = true;   // user-triggered rescan → its completion should toast
    state_.sync.rescan_progress = 0.0f;
    state_.sync.rescan_status = decision.status;
    transactions_dirty_ = true;
    last_tx_block_height_ = -1;
    invalidateShieldedHistoryScanProgress(true);
    
    // Set rescan flag BEFORE stopping so it's ready when we restart
    daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
    DEBUG_LOGF("[App] Rescan flag set, rescanOnNextStart=%d\n", daemon_controller_->rescanOnNextStart() ? 1 : 0);
    
    // Stop daemon, then restart
    async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
        DEBUG_LOGF("[App] Stopping daemon for rescan...\n");
        AppDaemonLifecycleRuntime runtime(*this);
        daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
        daemon_controller_->executeLifecycleOperation(decision, runtime, context);
    });
}

void App::repairWallet()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
        return;
    }

    auto decision = daemon::DaemonController::evaluateLifecycleOperation(
        daemon::DaemonController::LifecycleOperation::RepairWallet,
        isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
    if (!decision.allowed) {
        ui::Notifications::instance().warning(decision.warning);
        return;
    }

    if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
        ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress"));
        return;
    }
    if (daemon_restarting_) {
        ui::Notifications::instance().warning(TR("appx_node_busy_restarting"));
        return;
    }

    DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
    ui::Notifications::instance().info(TR("appx_restarting_daemon_zapwallettxes"));

    // -zapwallettxes=2 deletes and rebuilds every wallet tx/note record, then rescans the whole
    // chain — so reuse the rescan status UI (status bar + warmup-end completion detection). Same
    // confirmed-active gating as rescan: the first poll may still reach the pre-restart daemon.
    state_.sync.rescanning = true;
    rescan_confirmed_active_ = false;
    user_initiated_rescan_ = true;   // user-triggered repair (implies rescan) → completion should toast
    state_.sync.rescan_progress = 0.0f;
    state_.sync.rescan_status = decision.status;
    transactions_dirty_ = true;
    last_tx_block_height_ = -1;
    invalidateShieldedHistoryScanProgress(true);

    // Set the zap flag BEFORE stopping so it's ready when we restart
    daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
    DEBUG_LOGF("[App] Wallet-repair flag set, zapOnNextStart=%d\n", daemon_controller_->zapOnNextStart() ? 1 : 0);

    async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
        DEBUG_LOGF("[App] Stopping daemon for wallet repair...\n");
        AppDaemonLifecycleRuntime runtime(*this);
        daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
        daemon_controller_->executeLifecycleOperation(decision, runtime, context);
    });
}

void App::reinstallBundledDaemon()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
        return;
    }
    if (!resources::getBundledDaemonInfo().available) {
        ui::Notifications::instance().warning(TR("appx_no_bundled_daemon_to_install"));
        return;
    }
    // Require embedded-daemon *support*, but NOT an active daemon_controller_. When the wallet is
    // attached to an external or leftover dragonxd (controller null, so stopEmbeddedDaemon() no-ops)
    // we can still RPC-stop that node, overwrite the binaries, and bring up our own managed daemon —
    // which is exactly the state that previously blocked "Install bundled" with a cryptic warning.
    if (!supportsEmbeddedDaemon()) {
        ui::Notifications::instance().warning(TR("appx_no_embedded_daemon_to_install"));
        return;
    }

    if (async_tasks_.isRunning("reinstall-daemon")) {
        ui::Notifications::instance().warning(TR("appx_daemon_reinstall_in_progress"));
        return;
    }

    DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n");
    ui::Notifications::instance().info(TR("appx_installing_bundled_daemon"));

    async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) {
        AppDaemonLifecycleRuntime runtime(*this);
        daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_);
        // Stop the running node so its binary is free. stopDaemonWithPolicy() covers a wallet-managed
        // daemon; if we're instead attached to an external/leftover dragonxd (daemon_controller_ is
        // null, so stopEmbeddedDaemon() returns immediately), send an explicit RPC stop so that node
        // exits too — otherwise it keeps the RPC port and the fresh daemon can't start.
        if (!daemon_controller_ && rpc_ && rpc_->isConnected()) {
            sendStopCommandSafely(*rpc_, "reinstall-daemon");
        }
        runtime.stopDaemonWithPolicy();
        // Wait (bounded, ~12s) for the process to exit and release the binary before overwriting.
        for (int i = 0; i < 120 && daemon::EmbeddedDaemon::isRpcPortInUse()
                        && !ctx.cancelled() && !ctx.shuttingDown(); ++i) {
            ctx.sleepForMs(100);
        }
        // Overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones.
        const bool ok = resources::reextractBundledDaemon();
        DEBUG_LOGF("[App] Reinstall bundled daemon: %s\n", ok ? "ok" : "FAILED");
        // Restart on the freshly-installed binary.
        runtime.resetOutputOffset();
        if (!ctx.cancelled() && !ctx.shuttingDown()) {
            runtime.startDaemon();
        }
    });
}

void App::deleteBlockchainData()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
        return;
    }

    auto decision = daemon::DaemonController::evaluateLifecycleOperation(
        daemon::DaemonController::LifecycleOperation::DeleteBlockchainData,
        isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
    if (!decision.allowed) {
        ui::Notifications::instance().warning(decision.warning);
        return;
    }

    if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
        ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress"));
        return;
    }

    DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n");
    ui::Notifications::instance().info(TR("appx_stopping_daemon_deleting_blockchain"));

    daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
    async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
        DEBUG_LOGF("[App] Stopping daemon for blockchain deletion...\n");
        AppDaemonLifecycleRuntime runtime(*this);
        daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
        auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context);
        DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", result.deletedItems);
        // Hand the count to the main thread for a completion toast (Notifications isn't thread-safe).
        pending_delete_result_.store(result.deletedItems, std::memory_order_relaxed);
    });
}

bool App::stopDaemonForBootstrap()
{
    if (!supportsFullNodeLifecycleActions()) {
        return false;
    }

    auto decision = daemon::DaemonController::evaluateLifecycleOperation(
        daemon::DaemonController::LifecycleOperation::BootstrapStop,
        isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
    if (decision.wasRunning) {
        DEBUG_LOGF("[App] Stopping embedded daemon for bootstrap download...\n");
        daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
        AppDaemonLifecycleRuntime runtime(*this);
        daemon::ImmediateLifecycleTaskContext context;
        daemon_controller_->executeLifecycleOperation(decision, runtime, context);
    }
    return decision.wasRunning;
}

double App::getDaemonMemoryUsageMB() const
{
    // The Mining tab reads this every frame, but the probe is expensive (Linux embedded/external paths
    // scan /proc; macOS forks a shell). Throttle to ~1.5s so a 60Hz caller doesn't hammer the OS.
    const double now = ImGui::GetTime();
    if (daemon_mem_probe_at_ > 0.0 && now - daemon_mem_probe_at_ < 1.5)
        return daemon_mem_cached_mb_;
    daemon_mem_probe_at_ = now;

    double result = 0.0;
    // If we have an embedded daemon with a tracked process handle, use it
    // directly — more reliable than a process scan since we own the handle.
    if (daemon_controller_ && daemon_controller_->isRunning()) {
        double mb = daemon_controller_->memoryUsageMB();
        daemon_mem_diag_ = "embedded";
        result = (mb > 0.0) ? mb : util::Platform::getDaemonMemoryUsageMB();
    } else {
        daemon_mem_diag_ = "process scan";
        result = util::Platform::getDaemonMemoryUsageMB();  // external daemon
    }
    daemon_mem_cached_mb_ = result;
    return result;
}

// ============================================================================
// Shutdown
// ============================================================================

void App::requestQuit()
{
    beginShutdown();
}

void App::beginShutdown()
{
    // Only start shutdown once
    if (shutting_down_) return;

    // Guard: don't silently discard an in-progress witness-cache rebuild. If we're about to stop the
    // daemon while it's rebuilding (stopping now forces a multi-minute rebuild on the next launch),
    // defer shutdown and let render() show the confirm modal. The user's choice re-enters beginShutdown()
    // with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_).
    if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) {
        pending_shutdown_confirm_ = true;
        return;   // NOT shutting down yet — the normal UI + modal keep rendering
    }

    shutting_down_ = true;
    quit_requested_ = true;
    shutdown_timer_ = 0.0f;
    shutdown_start_time_ = std::chrono::steady_clock::now();
    async_tasks_.cancelAll();
    // The seed-migration adopt task drives daemon stop/start and swaps wallet.dat; let it finish
    // before shutdown touches the daemon, so the two never race. shutting_down_ is already set, so
    // the adopt task will NOT restart the daemon (it checks shutting_down_ before startEmbeddedDaemon).
    if (async_tasks_.isRunning("Adopt seed wallet"))
        async_tasks_.join("Adopt seed wallet");
    // The wallet-switch task also drives daemon stop/start — let it finish before shutdown touches the
    // daemon so it can't orphan a freshly-started dragonxd (it checks shutting_down_ before starting).
    if (async_tasks_.isRunning("Switch wallet"))
        async_tasks_.join("Switch wallet");

    // Signal the RPC worker to stop accepting new tasks (non-blocking), and abort any call
    // already in flight so the later join() doesn't wait out a request timeout.
    // The actual thread join + rpc disconnect happen in shutdown() after
    // the render loop exits, so the UI stays responsive.
    if (rpc_) rpc_->requestAbort();
    if (fast_rpc_) fast_rpc_->requestAbort();
    if (worker_) {
        worker_->requestStop();
    }
    if (fast_worker_) {
        fast_worker_->requestStop();
    }

    // Drain + join the mining-control thread FIRST so no async start/stop job runs while we tear the miner
    // down here (avoids two threads driving xmrig_manager_ during shutdown). (M-03 cluster)
    stopMiningControlThread();

    // Stop xmrig pool miner before stopping the daemon
    if (xmrig_manager_ && xmrig_manager_->isRunning()) {
        shutdown_status_ = TR("appx_stopping_pool_miner");
        xmrig_manager_->stop(3000);
    }
    
        DEBUG_LOGF("beginShutdown: starting (daemon_controller_=%s)\n",
            daemon_controller_ ? "yes" : "no");
    
    // If no embedded daemon, just mark done — don't stop
    // an externally-managed daemon that the user started themselves.
    // Worker join + RPC disconnect happen in shutdown().
    if (!daemon_controller_) {
        DEBUG_LOGF("beginShutdown: no embedded daemon, disconnecting only\n");
        shutdown_status_ = TR("appx_disconnecting");
        if (settings_) {
            settings_->save();
        }
        shutdown_complete_ = true;
        return;
    }
    
    // Save settings now (safe to do on main thread)
    if (settings_) {
        settings_->save();
    }
    
    auto shutdownDecision = daemon_controller_->shutdownDecision(
        (settings_ && settings_->getKeepDaemonRunning()) || shutdown_keep_daemon_override_,
        settings_ && settings_->getStopExternalDaemon());
    if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
        DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason);
        shutdown_status_ = shutdownDecision.status;
        shutdown_complete_ = true;
        return;
    }

    shutdown_status_ = shutdownDecision.status;
    DEBUG_LOGF("beginShutdown: spawning shutdown thread for daemon stop\n");
    
    // Run the daemon shutdown on a background thread so the UI
    // keeps rendering the shutdown screen (like SilentDragonX's
    // modal "Please wait" dialog).
    shutdown_thread_ = std::thread([this]() {
        DEBUG_LOGF("shutdown thread: calling stopEmbeddedDaemon()\n");
        shutdown_status_ = TR("appx_sending_stop_command_to_daemon");
        // Send RPC stop command
        stopEmbeddedDaemon();
        
        DEBUG_LOGF("shutdown thread: daemon stopped, disconnecting RPC\n");
        shutdown_status_ = TR("appx_cleaning_up");
        
        DEBUG_LOGF("shutdown thread: complete\n");
        shutdown_status_ = TR("appx_shutdown_complete");
        shutdown_complete_ = true;
    });
}

std::vector<std::string> App::tailDaemonDebugLog(int maxLines) const
{
    std::vector<std::string> out;
    if (maxLines <= 0) return out;
    const std::string path = util::Platform::getDataDir() + "debug.log";
    std::error_code ec;
    const auto sz = std::filesystem::file_size(path, ec);
    if (ec || sz == 0) return out;
    std::ifstream f(path, std::ios::binary);
    if (!f) return out;

    // Read only the last ~16 KB — plenty for a handful of lines, cheap even for a multi-GB log.
    const std::uintmax_t kTailBytes = 16 * 1024;
    const std::uintmax_t start = sz > kTailBytes ? sz - kTailBytes : 0;
    f.seekg(static_cast<std::streamoff>(start), std::ios::beg);
    std::string chunk(static_cast<std::size_t>(sz - start), '\0');
    f.read(&chunk[0], static_cast<std::streamsize>(chunk.size()));
    chunk.resize(static_cast<std::size_t>(f.gcount()));

    std::vector<std::string> lines;
    std::string cur;
    for (char c : chunk) {
        if (c == '\n') { if (!cur.empty()) lines.push_back(cur); cur.clear(); }
        else if (c != '\r') cur.push_back(c);
    }
    if (!cur.empty()) lines.push_back(cur);
    // When we seeked into the middle of the file the first line is a fragment — drop it.
    if (start > 0 && !lines.empty()) lines.erase(lines.begin());
    if (static_cast<int>(lines.size()) > maxLines)
        lines.erase(lines.begin(), lines.end() - static_cast<std::ptrdiff_t>(maxLines));
    return lines;
}

// Parse a "YYYY-MM-DD HH:MM:SS ..." debug.log line prefix to time_t. Interpreted as local time, but it
// is only ever used for DELTAS between two lines of the SAME log, so the timezone cancels. Returns 0 if
// the line has no such timestamp prefix.
static std::time_t parseDaemonLogTimestamp(const std::string& line)
{
    int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0;
    if (std::sscanf(line.c_str(), "%d-%d-%d %d:%d:%d", &y, &mo, &d, &h, &mi, &s) != 6) return 0;
    std::tm tm{};
    tm.tm_year = y - 1900; tm.tm_mon = mo - 1; tm.tm_mday = d;
    tm.tm_hour = h; tm.tm_min = mi; tm.tm_sec = s; tm.tm_isdst = -1;
    return std::mktime(&tm);
}

bool App::daemonWitnessRebuildActive() const
{
    // Scan the debug.log tail for the daemon's witness-rebuild markers (wallet.cpp): "Cleared witness
    // data from" (start), "Reading blocks for witness rebuild" / "Setting Initial Sapling Witness"
    // (progress), vs. "rebuilt N note witness cache(s)" / "aborting…" (finished).
    //
    // A genuine ongoing rebuild logs progress CONTINUOUSLY. The routine per-tx witness set the daemon
    // does as each new wallet tx lands during normal sync is SPARSE (minutes apart) and must NOT trip
    // this — that was firing the "node is rebuilding" prompt on wallets that just receive frequently.
    // So require the last progress marker to be (a) later than any completion AND (b) part of the CURRENT
    // activity — within a few seconds of the newest log line (same-log timestamp delta → timezone-free).
    const auto lines = tailDaemonDebugLog(120);
    std::time_t newest = 0, lastProgress = 0, lastDone = 0;
    for (const auto& l : lines) {
        const std::time_t ts = parseDaemonLogTimestamp(l);
        if (ts > newest) newest = ts;
        if (l.find("note witness cache(s) to height") != std::string::npos ||
            l.find("aborting witness rebuild") != std::string::npos ||
            l.find("aborted during witness rebuild") != std::string::npos) {
            if (ts > lastDone) lastDone = ts;
        } else if (l.find("Reading blocks for witness rebuild") != std::string::npos ||
                   l.find("Setting Initial Sapling Witness") != std::string::npos ||
                   l.find("Cleared witness data from") != std::string::npos) {
            if (ts > lastProgress) lastProgress = ts;
        }
    }
    if (lastProgress == 0 || lastDone >= lastProgress || newest == 0) return false;
    return (newest - lastProgress) <= 15;   // progress is part of the current activity → ongoing rebuild
}

bool App::shouldConfirmDaemonStop() const
{
    if (!daemon_controller_) return false;
    // Only relevant when this shutdown would actually STOP the daemon (embedded, or external with
    // stop-on-exit) — a DisconnectOnly shutdown leaves it running and loses nothing.
    const auto decision = daemon_controller_->shutdownDecision(
        settings_ && settings_->getKeepDaemonRunning(),
        settings_ && settings_->getStopExternalDaemon());
    if (decision.action != daemon::DaemonController::ShutdownAction::StopDaemon) return false;
    // v1.3.0+ checkpoints witness-rescan progress, so stopping mid-rebuild resumes on the next start
    // instead of redoing it from scratch — the warning's premise no longer holds, so don't prompt.
    // (daemon_version encodes major*1e6 + minor*1e4 + rev*100 + build; v1.3.0 == 1030000.)
    if (state_.daemon_version >= 1030000) return false;
    return daemonWitnessRebuildActive();
}

void App::renderDaemonStopConfirm()
{
    using namespace ui::material;
    if (pending_shutdown_confirm_) {
        ImGui::OpenPopup("##DaemonStopConfirm");
        pending_shutdown_confirm_ = false;
        daemon_stop_confirm_open_ = true;
    }
    if (!daemon_stop_confirm_open_) return;

    ImVec2 center = ImGui::GetMainViewport()->GetCenter();
    ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
    bool proceed = false;
    if (ImGui::BeginPopupModal("##DaemonStopConfirm", nullptr,
                               ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
        if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1());
        ImGui::TextUnformatted(TR("appx_node_rebuilding_witness_cache"));
        if (Type().subtitle1()) ImGui::PopFont();
        ImGui::Spacing();
        ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f);
        ImGui::TextUnformatted(TR("appx_stopping_node_discards_rebuild"));
        ImGui::PopTextWrapPos();
        ImGui::Spacing();
        ImGui::Spacing();

        if (TactileButton(TR("appx_keep_node_running_and_quit"), ImVec2(0, 0))) {
            shutdown_keep_daemon_override_ = true;
            shutdown_confirmed_ = true;
            daemon_stop_confirm_open_ = false;
            proceed = true;
            ImGui::CloseCurrentPopup();
        }
        ImGui::SameLine();
        ImGui::PushStyleColor(ImGuiCol_Button,        ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210)));
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error()));
        ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160)));
        const bool stopAnyway = TactileButton(TR("appx_stop_anyway_and_quit"), ImVec2(0, 0));
        ImGui::PopStyleColor(3);
        if (stopAnyway) {
            shutdown_confirmed_ = true;
            daemon_stop_confirm_open_ = false;
            proceed = true;
            ImGui::CloseCurrentPopup();
        }
        ImGui::SameLine();
        if (TactileButton(TR("appx_cancel"), ImVec2(0, 0))) {
            daemon_stop_confirm_open_ = false;   // abort the quit; stay open
            ImGui::CloseCurrentPopup();
        }
        ImGui::EndPopup();
    } else {
        daemon_stop_confirm_open_ = false;   // dismissed via Esc / click-away = Cancel
    }

    // Re-enter shutdown outside the popup scope now that the user has chosen (shutdown_confirmed_ set).
    if (proceed) beginShutdown();
}

void App::renderShutdownScreen()
{
    using namespace ui::material;
    const float dp = ui::Layout::dpiScale();
    auto shutElem = [](const char* key, float fb) {
        float v = ui::schema::UI().drawElement("components.shutdown", key).size;
        return v >= 0 ? v : fb;
    };
    shutdown_timer_ += ImGui::GetIO().DeltaTime;

    // Track how long the status text has been unchanged. A normal shutdown keeps updating the status
    // ("Stopping pool miner…" -> "Flushing…" -> "Cleaning up…"); a genuine hang stalls it. Offer Force
    // Quit only once the status has STALLED (likely hung), not on a bare timer, with a hard ceiling so
    // it's never impossible to escape.
    static std::string s_lastShutStatus;
    static float s_shutStallTimer = 0.0f;
    const std::string curShut = shutdown_status_.get();   // one consistent snapshot per frame (M-05)
    if (curShut != s_lastShutStatus) { s_lastShutStatus = curShut; s_shutStallTimer = 0.0f; }
    else s_shutStallTimer += ImGui::GetIO().DeltaTime;
    const bool shutdownStalled = s_shutStallTimer >= 8.0f;
    const bool allowForceQuit  = shutdownStalled || shutdown_timer_ >= 20.0f;

    // Use the main viewport so the overlay covers the primary window
    ImGuiViewport* vp = ImGui::GetMainViewport();
    ImVec2 vp_pos  = vp->Pos;
    ImVec2 vp_size = vp->Size;
    float cx = vp_size.x * 0.5f;  // horizontal centre (local coords)

    // Semi-transparent dark overlay covering the entire main window
    ImGui::SetNextWindowPos(vp_pos);
    ImGui::SetNextWindowSize(vp_size);
    ImGui::SetNextWindowFocus();
    // Shared overlay-scrim tone (matches dialog backdrops); the shutdown screen stays an opaque
    // scrim (no blur) so it reads as "closing", not a modal over your work.
    ImGui::PushStyleColor(ImGuiCol_WindowBg, OverlayScrimColor(0.92f));
    ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
    ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
    ImGuiWindowFlags shutdownFlags =
        ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
        ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
        ImGuiWindowFlags_NoSavedSettings;
    // Allow input only once Force Quit is offered (status stalled / hard ceiling), so the screen stays
    // click-through while a normal shutdown is progressing.
    if (!allowForceQuit)
        shutdownFlags |= ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav;
    ImGui::Begin("##ShutdownOverlay", nullptr, shutdownFlags);

    ImDrawList* dl = ImGui::GetWindowDrawList();
    // Convert local centre to screen coords for draw-list primitives
    ImVec2 wp = ImGui::GetWindowPos();
    constexpr float kPi = 3.14159265f;

    // -------------------------------------------------------------------
    // Vertical centering: estimate total content height
    // -------------------------------------------------------------------
    float lineH   = ImGui::GetTextLineHeightWithSpacing();
    float titleH   = Type().h5() ? Type().h5()->LegacySize : lineH * 1.5f;
    float spinnerD = shutElem("spinner-radius", 20.0f) * dp * 2.0f + 12.0f * dp;
    float statusH  = lineH * 2.0f;
    float sepH     = lineH;
    float panelH   = shutElem("panel-max-height", 160.0f);
    float totalH   = titleH + spinnerD + statusH + sepH + panelH + lineH * 4.0f;
    float y_start  = (vp_size.y - totalH) * 0.5f;
    if (y_start < lineH * 2.0f) y_start = lineH * 2.0f;
    ImGui::SetCursorPosY(y_start);

    // -------------------------------------------------------------------
    // 1. Title — large, gold/amber
    // -------------------------------------------------------------------
    {
        const char* title = "Shutting Down";
        ImGui::PushFont(Type().h5());
        ImVec2 ts = ImGui::CalcTextSize(title);
        ImGui::SetCursorPosX(cx - ts.x * 0.5f);
        ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));  // theme amber
        ImGui::TextUnformatted(title);
        ImGui::PopStyleColor();
        ImGui::PopFont();
    }

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

    // -------------------------------------------------------------------
    // 2. Animated arc spinner
    // -------------------------------------------------------------------
    {
        float r = shutElem("spinner-radius", 20.0f) * dp;
        float thick = shutElem("spinner-thickness", 3.0f) * dp;
        // Screen-space centre for draw list
        ImVec2 sc(wp.x + cx, wp.y + ImGui::GetCursorPosY() + r + 2.0f * dp);

        // Background ring (dim)
        dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48);
        dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-track)", IM_COL32(255, 255, 255, 25)), 0, thick);

        // Spinning foreground arc (~270°)
        float angle = shutdown_timer_ * shutElem("spinner-speed", 2.5f);
        float a_min = angle;
        float a_max = angle + kPi * 1.5f;
        dl->PathArcTo(sc, r, a_min, a_max, 36);
        dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)", IM_COL32(255, 218, 0, 200)), 0, thick);

        // Advance cursor past the spinner
        ImGui::Dummy(ImVec2(0, r * 2.0f + 8.0f * dp));
    }

    ImGui::Spacing();

    // -------------------------------------------------------------------
    // 3. Phase status (what the shutdown thread is doing)
    // -------------------------------------------------------------------
    if (!curShut.empty()) {
        ImVec2 ts = ImGui::CalcTextSize(curShut.c_str());
        ImGui::SetCursorPosX(cx - ts.x * 0.5f);
        ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.75f, 0.75f, 0.75f, 1.0f));
        ImGui::TextUnformatted(curShut.c_str());
        ImGui::PopStyleColor();
    }

    ImGui::Spacing();

    // -------------------------------------------------------------------
    // 4. Elapsed time — small, dim
    // -------------------------------------------------------------------
    {
        char elapsed[64];
        int secs = (int)shutdown_timer_;
        if (secs < 60)
            snprintf(elapsed, sizeof(elapsed), TR("appx_n_seconds"), secs);
        else
            snprintf(elapsed, sizeof(elapsed), TR("appx_n_min_n_sec"), secs / 60, secs % 60);

        ImGui::PushFont(Type().caption());
        ImVec2 ts = ImGui::CalcTextSize(elapsed);
        ImGui::SetCursorPosX(cx - ts.x * 0.5f);
        ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.45f, 0.45f, 0.45f, 1.0f));
        ImGui::TextUnformatted(elapsed);
        ImGui::PopStyleColor();
        ImGui::PopFont();
    }

    // -------------------------------------------------------------------
    // 4b. Force Quit button — appears once the shutdown appears stalled (or a hard-ceiling backstop)
    // -------------------------------------------------------------------
    if (allowForceQuit) {
        ImGui::Spacing();
        ImGui::Spacing();
        // State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the
        // chainstate; say so instead of a bare button.
        if (shutdownStalled && !curShut.empty()) {
            std::string stalledMsg = std::string(TR("appx_still_status_prefix")) + curShut +
                                      TR("appx_still_status_suffix");
            ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str());
            ImGui::SetCursorPosX(cx - ms.x * 0.5f);
            ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
            ImGui::TextUnformatted(stalledMsg.c_str());
            ImGui::PopStyleColor();
            ImGui::Spacing();
        }
        const char* forceLabel = TR("force_quit");
        ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f * dp, 0);
        ImGui::SetCursorPosX(cx - btnSize.x * 0.5f);
        ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.15f, 0.15f, 0.9f));
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.75f, 0.2f, 0.2f, 1.0f));
        ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.5f, 0.1f, 0.1f, 1.0f));
        if (ui::material::TactileButton(forceLabel, btnSize)) {
            force_quit_confirm_ = true;
        }
        if (ImGui::IsItemHovered()) {
            ui::material::Tooltip("%s", TR("force_quit_warning"));
        }
        ImGui::PopStyleColor(3);
    }

    // Force Quit confirmation popup
    if (force_quit_confirm_) {
        ImGui::OpenPopup("##ForceQuitConfirm");
        force_quit_confirm_ = false;
    }
    ImVec2 popupCenter(wp.x + vp_size.x * 0.5f, wp.y + vp_size.y * 0.5f);
    ImGui::SetNextWindowPos(popupCenter, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
    if (ImGui::BeginPopupModal("##ForceQuitConfirm", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
        ImGui::PushFont(Type().subtitle1());
        ImGui::TextUnformatted(TR("force_quit_confirm_title"));
        ImGui::PopFont();
        ImGui::Spacing();
        ImGui::TextUnformatted(TR("force_quit_confirm_msg"));
        ImGui::Spacing();
        ImGui::Spacing();

        float btnW = 120.0f * dp;
        float totalW = btnW * 2 + ImGui::GetStyle().ItemSpacing.x;
        ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) * 0.5f);

        if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
            ImGui::CloseCurrentPopup();
        }
        ImGui::SameLine();
        // Destructive action — theme error color (alpha-varied for hover/active) instead of hardcoded red.
        ImGui::PushStyleColor(ImGuiCol_Button,        ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210)));
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error()));
        ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160)));
        if (ui::material::TactileButton(TR("force_quit_yes"), ImVec2(btnW, 0))) {
            DEBUG_LOGF("Force quit confirmed by user after %.0fs\n", shutdown_timer_);
            shutdown_complete_ = true;
            ImGui::CloseCurrentPopup();
        }
        ImGui::PopStyleColor(3);
        ImGui::EndPopup();
    }

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

    // -------------------------------------------------------------------
    // 5. Separator line
    // -------------------------------------------------------------------
    {
        float pad = vp_size.x * shutElem("separator-pad-fraction", 0.25f);
        ImVec2 p0(wp.x + pad, wp.y + ImGui::GetCursorPosY());
        ImVec2 p1(wp.x + vp_size.x - pad, p0.y);
        dl->AddLine(p0, p1, ui::schema::UI().resolveColor("var(--status-divider)", IM_COL32(255, 255, 255, 30)), 1.0f);
        ImGui::Dummy(ImVec2(0, 4.0f * dp));
    }

    ImGui::Spacing();

    // -------------------------------------------------------------------
    // 6. Daemon output panel — terminal-style box
    // -------------------------------------------------------------------
    if (daemon_controller_) {
        auto lines = daemon_controller_->recentLines(8);
        // External daemon (attached, not spawned) has no captured stdout — tail its debug.log directly
        // so the user can still watch the node flush the block index and exit.
        if (lines.empty())
            lines = tailDaemonDebugLog(8);
        if (!lines.empty()) {
            float panelW = vp_size.x * shutElem("panel-width-fraction", 0.70f);
            float panelX = cx - panelW * 0.5f;
            float panelH = shutElem("panel-max-height", 160.0f);

            // Panel background (dark, rounded)
            ImVec2 panelMin(wp.x + panelX, wp.y + ImGui::GetCursorPosY());
            ImVec2 panelMax(panelMin.x + panelW, panelMin.y + panelH);
            dl->AddRectFilled(panelMin, panelMax,
                              ui::schema::UI().resolveColor("var(--shutdown-panel-bg)", IM_COL32(12, 14, 20, 220)), shutElem("panel-rounding", 6.0f));
            dl->AddRect(panelMin, panelMax,
                        ui::schema::UI().resolveColor("var(--shutdown-panel-border)", IM_COL32(255, 255, 255, 18)), shutElem("panel-rounding", 6.0f), 0, 1.0f);

            // Label above lines
            float panelPad = shutElem("panel-padding", 12.0f);
            ImGui::SetCursorPos(ImVec2(panelX + panelPad,
                                       ImGui::GetCursorPosY() + panelPad));
            ImGui::PushFont(Type().caption());
            ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.45f, 0.45f, 0.50f, 1.0f));
            ImGui::TextUnformatted(TR("appx_dragonxd_output"));
            ImGui::PopStyleColor();
            ImGui::PopFont();

            ImGui::Spacing();

            // Output lines in green — clip to panel bounds
            float leftPad = panelX + panelPad;
            float maxTextW = panelW - panelPad * 2.0f;
            ImGui::PushClipRect(panelMin, panelMax, true);
            ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.78f, 0.4f, 0.9f));
            ImGui::PushFont(Type().caption());
            for (const auto& line : lines) {
                // Stop rendering if we've reached near the panel bottom
                float curY = wp.y + ImGui::GetCursorPosY();
                if (curY + ImGui::GetTextLineHeight() > panelMax.y - panelPad)
                    break;

                ImGui::SetCursorPosX(leftPad);
                // Truncate if wider than panel
                ImVec2 ts = ImGui::CalcTextSize(line.c_str());
                if (ts.x > maxTextW && line.size() > 10) {
                    // Binary search for fit is overkill; just trim chars
                    size_t maxChars = (size_t)(line.size() * (maxTextW / ts.x));
                    if (maxChars > 3) {
                        std::string trunc = line.substr(0, maxChars - 3) + "...";
                        ImGui::TextUnformatted(trunc.c_str());
                    } else {
                        ImGui::TextUnformatted("...");
                    }
                } else {
                    ImGui::TextUnformatted(line.c_str());
                }
            }
            ImGui::PopFont();
            ImGui::PopStyleColor();
            ImGui::PopClipRect();

            // Advance cursor past the panel
            float panelBottom = panelMax.y - wp.y;
            ImGui::SetCursorPosY(panelBottom + 4.0f * dp);
            ImGui::Dummy(ImVec2(0, 0));
        }
    }

    ImGui::End();
    ImGui::PopStyleVar(2);
    ImGui::PopStyleColor();
}

// ============================================================================
// Loading Overlay — shown inside content area while daemon is starting/syncing
// ============================================================================

void App::renderLoadingOverlay(float contentH)
{
    using namespace ui::material;
    constexpr float kPi = 3.14159265f;

    // The wallet-recovery dialog owns the screen while a salvage is pending — suppress the loading
    // spinner underneath it so "still loading" and "make a decision" are never the same screen.
    if (show_wallet_recovered_dialog_) return;

    auto loadElem = [](const char* key, float fb) {
        float v = ui::schema::UI().drawElement("screens.loading", key).size;
        return v >= 0 ? v : fb;
    };

    loading_timer_ += ImGui::GetIO().DeltaTime;

    ImDrawList* dl = ImGui::GetWindowDrawList();
    ImVec2 wp = ImGui::GetWindowPos();
    ImVec2 ws = ImGui::GetWindowSize();

    // Hand-drawn geometry here is in logical px; the fonts drawn between the elements are DPI-baked,
    // so every spinner/bar/gap constant must be multiplied by dpiScale or it renders native-tiny and
    // the spacing/centering drifts at HiDPI / font_scale > 1. (Font metrics are already scaled.)
    const float dpi = ui::Layout::dpiScale();

    // Layout constants
    float lineH    = ImGui::GetTextLineHeightWithSpacing();
    float spinnerR = loadElem("spinner-radius", 18.0f) * dpi;
    float gap      = loadElem("vertical-gap", 8.0f) * dpi;
    float barH     = loadElem("progress-bar", 6.0f) * dpi;
    float barW     = loadElem("progress-width", 260.0f) * dpi;
    float cx       = ws.x * 0.5f;  // centre X (local coords)

    // Estimate total block height for vertical centering
    float totalH = spinnerR * 2.0f + gap + lineH * 2.0f + gap + barH + gap + lineH;
    float yOff = (ws.y - totalH) * 0.5f;
    if (yOff < lineH * 2.0f) yOff = lineH * 2.0f;

    // Screen-space Y cursor (absolute)
    float curY = wp.y + yOff;

    // -------------------------------------------------------------------
    // 1. Animated arc spinner
    // -------------------------------------------------------------------
    {
        float r     = spinnerR;
        float thick = loadElem("spinner-thickness", 2.5f) * dpi;
        ImVec2 sc(wp.x + cx, curY + r + 2.0f * dpi);

        // Background ring (dim)
        dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48);
        dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-track)",
            IM_COL32(255, 255, 255, 25)), 0, thick);

        // Spinning foreground arc (~270°)
        float angle = loading_timer_ * loadElem("spinner-speed", 2.5f);
        dl->PathArcTo(sc, r, angle, angle + kPi * 1.5f, 36);
        dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)",
            IM_COL32(255, 218, 0, 200)), 0, thick);

        curY += r * 2.0f + gap + 4.0f * dpi;
    }

    // -------------------------------------------------------------------
    // Post-repair rescan — a calm, recovery-aware screen (not the generic "daemon stuck / RPC timeout /
    // restart daemon" text). The daemon was intentionally restarted with a full rescan, which
    // legitimately takes minutes and won't answer RPC yet; reassure + show elapsed and the growing size.
    // -------------------------------------------------------------------
    // Only while the daemon is actually alive-and-rescanning. If it EXITED (crashed for a reason distinct
    // from the wallet file), fall through to the normal error/stall UI — tryConnect() clears the flag, but
    // gate here too so a lingering frame never shows "everything's fine" over a dead daemon.
    if (post_recovery_rescan_ &&
        !(daemon_controller_ && daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error)) {
        if (post_recovery_rescan_since_ <= 0.0) post_recovery_rescan_since_ = ImGui::GetTime();
        ImFont* titleF = Type().subtitle1(); if (!titleF) titleF = ImGui::GetFont();
        ImFont* capF   = Type().caption();   if (!capF)   capF   = ImGui::GetFont();

        auto centeredLine = [&](ImFont* f, ImU32 col, const char* s, float wrapW) {
            ImVec2 ts = f->CalcTextSizeA(f->LegacySize, FLT_MAX, wrapW, s);
            float x = (wrapW > 0.0f) ? (wp.x + cx - wrapW * 0.5f) : (wp.x + cx - ts.x * 0.5f);
            dl->AddText(f, f->LegacySize, ImVec2(x, curY), col, s, nullptr, wrapW);
            curY += ts.y + gap;
        };

        centeredLine(titleF, IM_COL32(230, 210, 90, 235), TR("wallet_recovery_rescan_title"), 0.0f);
        float wrapW = ws.x * 0.8f; if (wrapW > 620.0f * dpi) wrapW = 620.0f * dpi;
        centeredLine(capF, IM_COL32(200, 200, 200, 220), TR("wallet_recovery_rescan_body"), wrapW);

        // Elapsed + the growing wallet size — concrete "it's working" feedback while RPC is silent. Recompute
        // at most once a second (the display granularity) to avoid a stat()+format on every frame.
        int secs = (int)(ImGui::GetTime() - post_recovery_rescan_since_); if (secs < 0) secs = 0;
        static int s_lastSec = -1;
        static std::string s_elapsed, s_size;
        if (secs != s_lastSec) {
            s_lastSec = secs;
            char clock[16]; snprintf(clock, sizeof(clock), "%d:%02d", secs / 60, secs % 60);
            char ebuf[96];  snprintf(ebuf, sizeof(ebuf), TR("wallet_recovery_rescan_elapsed"), clock);
            s_elapsed = ebuf;
            const std::string walletPath = util::Platform::getDragonXDataDir() + "/" +
                ((settings_ && !settings_->getActiveWalletFile().empty())
                     ? settings_->getActiveWalletFile() : std::string("wallet.dat"));
            uint64_t wsz = util::Platform::getFileSize(walletPath);
            if (wsz > 0) { char sbuf[96]; snprintf(sbuf, sizeof(sbuf), TR("wallet_recovery_rescan_size"),
                              util::Platform::formatFileSize(wsz).c_str()); s_size = sbuf; }
            else s_size.clear();
        }
        curY += gap * 0.5f;
        centeredLine(capF, IM_COL32(150, 150, 150, 220), s_elapsed.c_str(), 0.0f);
        if (!s_size.empty()) centeredLine(capF, IM_COL32(130, 130, 130, 210), s_size.c_str(), 0.0f);

        // Backstop for a genuinely slow (but still running) rescan: after several minutes add a gentle
        // "it's safe to leave running / watch the Console" line — never the scary "restart the node".
        if (secs > 600) {
            curY += gap * 0.5f;
            centeredLine(capF, IM_COL32(150, 150, 150, 200), TR("wallet_recovery_rescan_slow"), wrapW);
        }
        return;   // skip the generic status / stall / error / daemon-log sections
    }

    // -------------------------------------------------------------------
    // 2. Connection / daemon status text
    // -------------------------------------------------------------------
    {
        const char* statusText = connection_status_.c_str();
        ImFont* font = Type().subtitle1();
        if (!font) font = ImGui::GetFont();
        // Short statuses (the common case: "Connected", "Starting daemon") stay centered; long ones
        // (a full dir_error path, a libcurl connect error) wrap to a clamped box instead of running
        // off both edges of the overlay — the same clamp the daemon-error blocks below use.
        float wrapW = ws.x * 0.8f; if (wrapW > 640.0f * dpi) wrapW = 640.0f * dpi;
        ImVec2 full = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText);
        if (full.x <= wrapW) {
            dl->AddText(font, font->LegacySize,
                        ImVec2(wp.x + cx - full.x * 0.5f, curY),
                        IM_COL32(220, 220, 220, 255), statusText);
            curY += full.y + gap;
        } else {
            ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, wrapW, statusText);
            dl->AddText(font, font->LegacySize,
                        ImVec2(wp.x + cx - wrapW * 0.5f, curY),
                        IM_COL32(220, 220, 220, 255), statusText, nullptr, wrapW);
            curY += ts.y + gap;
        }
    }

    // -------------------------------------------------------------------
    // 2b. Warmup description (subtitle explaining what's happening)
    // -------------------------------------------------------------------
    if ((state_.warming_up || state_.daemon_initializing) && !state_.warmup_description.empty()) {
        const char* descText = state_.warmup_description.c_str();
        ImFont* capFont = Type().caption();
        if (!capFont) capFont = ImGui::GetFont();
        ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, descText);
        dl->AddText(capFont, capFont->LegacySize,
                    ImVec2(wp.x + cx - ts.x * 0.5f, curY),
                    IM_COL32(160, 160, 160, 200), descText);
        curY += ts.y + gap;
    }

    // -------------------------------------------------------------------
    // 2c. Sapling note witness rebuild progress (post-rescan phase, runs inside warmup)
    // -------------------------------------------------------------------
    if (state_.sync.building_witnesses) {
        float progress = state_.sync.witness_progress;
        if (progress < 0.0f) progress = 0.0f;
        if (progress > 1.0f) progress = 1.0f;
        float barRadius = loadElem("progress-bar", 3.0f) * dpi;
        float barX = wp.x + cx - barW * 0.5f;
        ImVec2 barMin(barX, curY);
        ImVec2 barMax(barX + barW, curY + barH);
        dl->AddRectFilled(barMin, barMax,
            ui::schema::UI().resolveColor("var(--progress-track)", IM_COL32(255, 255, 255, 30)), barRadius);
        ImVec2 fillMax(barMin.x + barW * progress, barMax.y);
        if (fillMax.x > barMin.x + 1.0f) {
            dl->AddRectFilled(barMin, fillMax,
                ui::schema::UI().resolveColor("var(--primary)", IM_COL32(255, 218, 0, 200)), barRadius);
        }
        curY += barH + gap;

        char wbuf[128];
        if (state_.sync.witness_phase == 2 && progress > 0.01f && state_.sync.witness_remaining > 0)
            snprintf(wbuf, sizeof(wbuf), TR("appx_rebuilding_witness_cache_blocks_left"),
                     progress * 100.0f, state_.sync.witness_remaining);
        else if (state_.sync.witness_phase == 2 && progress > 0.01f)
            snprintf(wbuf, sizeof(wbuf), TR("appx_rebuilding_witness_cache_pct"), progress * 100.0f);
        else if (progress > 0.01f)
            snprintf(wbuf, sizeof(wbuf), TR("appx_setting_initial_sapling_witnesses"), progress * 100.0f);
        else
            snprintf(wbuf, sizeof(wbuf), "%s", TR("appx_rebuilding_sapling_note_witnesses"));
        ImFont* capFont = Type().caption();
        if (!capFont) capFont = ImGui::GetFont();
        ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, wbuf);
        dl->AddText(capFont, capFont->LegacySize,
                    ImVec2(wp.x + cx - ts.x * 0.5f, curY),
                    IM_COL32(150, 150, 150, 255), wbuf);
        curY += ts.y + gap;
    }

    // -------------------------------------------------------------------
    // 3. Sync progress bar (if connected and syncing)
    // -------------------------------------------------------------------
    if (state_.connected && state_.sync.syncing) {
        float progress = static_cast<float>(state_.sync.verification_progress);
        float barRadius = loadElem("progress-bar", 3.0f) * dpi;

        float barX = wp.x + cx - barW * 0.5f;
        ImVec2 barMin(barX, curY);
        ImVec2 barMax(barX + barW, curY + barH);

        // Track background
        dl->AddRectFilled(barMin, barMax,
            ui::schema::UI().resolveColor("var(--progress-track)",
                IM_COL32(255, 255, 255, 30)), barRadius);

        // Filled portion
        ImVec2 fillMax(barMin.x + barW * progress, barMax.y);
        if (fillMax.x > barMin.x + 1.0f) {
            dl->AddRectFilled(barMin, fillMax,
                ui::schema::UI().resolveColor("var(--primary)",
                    IM_COL32(255, 218, 0, 200)), barRadius);
        }

        curY += barH + gap;

        // Progress text — "Syncing 45.2% — Block 123456 / 234567"
        char syncBuf[128];
        snprintf(syncBuf, sizeof(syncBuf), TR("appx_syncing_pct_block_n_of_n"),
                 progress * 100.0f, state_.sync.blocks, state_.sync.headers);
        ImFont* capFont = Type().caption();
        if (!capFont) capFont = ImGui::GetFont();
        ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, syncBuf);
        dl->AddText(capFont, capFont->LegacySize,
                    ImVec2(wp.x + cx - ts.x * 0.5f, curY),
                    IM_COL32(150, 150, 150, 255), syncBuf);
        curY += ts.y + gap;
    } else if (!state_.connected && state_.sync.blocks > 0) {
        // Show last known block height while reconnecting
        char blockBuf[64];
        snprintf(blockBuf, sizeof(blockBuf), TR("appx_last_block_n"), state_.sync.blocks);
        ImFont* capFont = Type().caption();
        if (!capFont) capFont = ImGui::GetFont();
        ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, blockBuf);
        dl->AddText(capFont, capFont->LegacySize,
                    ImVec2(wp.x + cx - ts.x * 0.5f, curY),
                    IM_COL32(130, 130, 130, 255), blockBuf);
        curY += ts.y + gap;
    }

    // -------------------------------------------------------------------
    // 3b. Deferred encryption status
    // -------------------------------------------------------------------
    if (wallet_security_.hasDeferredEncryption()) {
        curY += gap;
        ImFont* capFont = Type().caption();
        if (!capFont) capFont = ImGui::GetFont();

        const char* encLabel = encrypt_in_progress_
            ? TR("appx_encrypting_wallet")
            : TR("appx_waiting_for_daemon_to_encrypt_wallet");
        ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, encLabel);
        ImU32 encCol = IM_COL32(255, 218, 0, 200);
        dl->AddText(capFont, capFont->LegacySize,
                    ImVec2(wp.x + cx - ts.x * 0.5f, curY), encCol, encLabel);
        curY += ts.y + gap * 0.5f;

        // Indeterminate progress bar
        float encBarW = barW * 0.6f;
        float encBarH = 4.0f * dpi;
        float encBarX = wp.x + cx - encBarW * 0.5f;
        dl->AddRectFilled(ImVec2(encBarX, curY), ImVec2(encBarX + encBarW, curY + encBarH),
                          IM_COL32(255, 255, 255, 20), 2.0f);
        float t = loading_timer_;
        float pulse = 0.5f + 0.5f * sinf(t * 2.0f);
        float segW = encBarW * 0.3f;
        float segX = encBarX + (encBarW - segW) * pulse;
        dl->AddRectFilled(ImVec2(segX, curY), ImVec2(segX + segW, curY + encBarH),
                          encCol, 2.0f);
        curY += encBarH + gap;
    }

    // -------------------------------------------------------------------
    // 3c. Daemon crash error message
    // -------------------------------------------------------------------
    if (daemon_controller_ &&
        daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error &&
        !wallet_auto_recovered_) {   // a salvage is NOT a crash — it has its own recovery dialog
        curY += gap;
        ImFont* capFont = Type().caption();
        if (!capFont) capFont = ImGui::GetFont();
        ImFont* bodyFont2 = Type().body2();
        if (!bodyFont2) bodyFont2 = ImGui::GetFont();

        // Error title
        const char* errTitle = TR("appx_daemon_error");
        ImVec2 ts = bodyFont2->CalcTextSizeA(bodyFont2->LegacySize, FLT_MAX, 0.0f, errTitle);
        dl->AddText(bodyFont2, bodyFont2->LegacySize,
                    ImVec2(wp.x + cx - ts.x * 0.5f, curY),
                    IM_COL32(255, 90, 90, 255), errTitle);
        curY += ts.y + gap * 0.5f;

        // Error details (wrapped) — full diagnostic info for a genuine (non-recovery) crash.
        const std::string& errDetail = daemon_controller_->lastError();
        if (!errDetail.empty()) {
            float wrapW = ws.x * 0.8f;
            if (wrapW > 700.0f * dpi) wrapW = 700.0f * dpi;
            ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str());
            dl->AddText(capFont, capFont->LegacySize,
                        ImVec2(wp.x + cx - wrapW * 0.5f, curY),
                        IM_COL32(255, 180, 180, 220), errDetail.c_str(), nullptr, wrapW);
            curY += es.y + gap;
        }
        // Crash count hint
        if (daemon_controller_->crashCount() >= 3) {
            const char* hint = TR("appx_use_settings_restart_daemon_hint");
            ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint);
            dl->AddText(capFont, capFont->LegacySize,
                        ImVec2(wp.x + cx - hs2.x * 0.5f, curY),
                        IM_COL32(200, 200, 200, 180), hint);
            curY += hs2.y + gap;
        }
    }

    // 3d. The "Taking longer than expected" stall notice was intentionally removed — it added
    //     clutter to the startup screen. The live daemon-output panel below is the real signal that
    //     the node is making progress. (connect_stall_since_ is still maintained in app_network.cpp
    //     for connection bookkeeping; it just no longer drives any on-screen text.)

    // -------------------------------------------------------------------
    // 4. Daemon output snippet (last few lines, if embedded)
    // -------------------------------------------------------------------
    if (daemon_controller_ && !wallet_auto_recovered_) {   // recovery moves the log behind the dialog's disclosure
        auto lines = daemon_controller_->recentLines(8);
        if (!lines.empty()) {
            curY += gap;

            float panelW = ws.x * 0.85f;
            if (panelW > 900.0f * dpi) panelW = 900.0f * dpi;
            float panelX = wp.x + cx - panelW * 0.5f;
            float panelPad = 8.0f * dpi;

            ImFont* capFont = Type().caption();
            if (!capFont) capFont = ImGui::GetFont();
            float panelLineH = capFont->LegacySize + 4.0f * dpi;
            float panelContentH = panelPad * 2.0f + panelLineH * (float)lines.size();

            ImVec2 panelMin(panelX, curY);
            ImVec2 panelMax(panelX + panelW, curY + panelContentH);

            dl->AddRectFilled(panelMin, panelMax,
                ui::schema::UI().resolveColor("var(--shutdown-panel-bg)",
                    IM_COL32(12, 14, 20, 180)), 6.0f);
            dl->AddRect(panelMin, panelMax,
                ui::schema::UI().resolveColor("var(--shutdown-panel-border)",
                    IM_COL32(255, 255, 255, 15)), 6.0f, 0, 1.0f);

            float textY = curY + panelPad;
            float maxTextW = panelW - panelPad * 2.0f;
            ImU32 textCol = IM_COL32(102, 199, 102, 217);  // green tint

            for (const auto& line : lines) {
                std::string display = line;
                ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, display.c_str());
                if (ts.x > maxTextW && display.size() > 10) {
                    size_t maxChars = (size_t)(display.size() * (maxTextW / ts.x));
                    if (maxChars > 3) display = display.substr(0, maxChars - 3) + "...";
                    else display = "...";
                }
                dl->AddText(capFont, capFont->LegacySize,
                            ImVec2(panelX + panelPad, textY),
                            textCol, display.c_str());
                textY += panelLineH;
            }
        }
    }
}

void App::shutdown()
{
    // Ensure the mining-control thread is stopped + joined (idempotent; beginShutdown already did it on the
    // normal quit path, but shutdown() can also run without it). Must precede xmrig_manager_ teardown.
    stopMiningControlThread();

    // Wipe any copied secret from the OS clipboard before we exit — the 45s auto-clear timer
    // never fires if the user quits sooner, which would otherwise leave a key/seed resident.
    // (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().)
    clearSecretClipboardIfArmed();

    // Clean up bootstrap if running
    if (bootstrap_) {
        bootstrap_->cancel();
        bootstrap_.reset();
    }

    // If beginShutdown() was never called (e.g. direct exit),
    // do synchronous shutdown as fallback.
    if (!shutting_down_) {
        DEBUG_LOGF("Synchronous shutdown fallback...\n");
        async_tasks_.cancelAll();
        async_tasks_.joinAll();
        if (rpc_) rpc_->requestAbort();          // unblock any in-flight curl before joining
        if (fast_rpc_) fast_rpc_->requestAbort();
        if (worker_) {
            worker_->stop();
        }
        if (fast_worker_) {
            fast_worker_->stop();
        }
        if (settings_) {
            settings_->save();
        }
        if (daemon_controller_) {
            stopEmbeddedDaemon();
        }
        if (rpc_) {
            rpc_->disconnect();
        }
        if (fast_rpc_) {
            fast_rpc_->disconnect();
        }
        return;
    }
    
    // Wait for the async shutdown thread to finish
    if (shutdown_thread_.joinable()) {
        shutdown_thread_.join();
    }
    async_tasks_.joinAll();
    // Join the RPC worker thread (was signaled in beginShutdown via requestStop)
    if (worker_) {
        worker_->stop();
    }
    if (fast_worker_) {
        fast_worker_->stop();
    }
    // Disconnect RPC after worker is fully stopped (safe — no curl in flight)
    if (rpc_) {
        rpc_->disconnect();
    }
    if (fast_rpc_) {
        fast_rpc_->disconnect();
    }
}

// ===========================================================================
// First-run detection
// ===========================================================================

bool App::isFirstRun() const {
    std::string dataDir = util::Platform::getDragonXDataDir();
    std::error_code ec;
    return !std::filesystem::exists(dataDir, ec) ||
           !std::filesystem::exists(std::filesystem::path(dataDir) / "blocks", ec);
}

bool App::hasPinVault() const {
    // Gate purely on the (now per-wallet) vault presence — a vault exists iff a PIN was set for THIS
    // wallet. The old extra `getPinEnabled()` gate was a GLOBAL flag, so disabling PIN on one wallet
    // wrongly suppressed another wallet's PIN quick-unlock after a switch.
    return vault_ && vault_->hasVault();
}

bool App::debugGateRequiresAuth() const {
    return hasPinVault() || state_.encrypted;
}

void App::verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb) {
    if (!cb) return;
    // A quick-unlock PIN vault → verify the PIN by deriving it (pure crypto, no wallet side effects).
    if (hasPinVault()) {
        auto* w = worker_.get();
        if (!w) { cb(false); return; }
        w->post([this, s = secret, cb]() mutable -> rpc::RPCWorker::MainCb {
            std::string pass;
            bool ok = vault_ && vault_->retrieve(s, pass);
            if (!pass.empty()) util::SecureVault::secureZero(pass.data(), pass.size());
            if (!s.empty())    util::SecureVault::secureZero(&s[0], s.size());
            return [cb, ok]() { cb(ok); };
        });
        return;
    }
    // Encrypted wallet, no PIN → verify the passphrase via walletpassphrase (re-unlocks with the
    // usual timeout; a wrong passphrase throws).
    if (state_.encrypted) {
        auto* w = worker_.get();
        auto* r = rpc_.get();
        if (!w || !r) { cb(false); return; }
        int autoLock = settings_ ? settings_->getAutoLockTimeout() : 300;
        int timeout = (autoLock > 0) ? std::max(600, autoLock * 2) : 86400;
        w->post([r, s = secret, timeout, cb]() mutable -> rpc::RPCWorker::MainCb {
            bool ok = false;
            try {
                if (r && r->isConnected()) { r->call("walletpassphrase", {s, timeout}); ok = true; }
            } catch (...) { ok = false; }
            if (!s.empty()) util::SecureVault::secureZero(&s[0], s.size());
            return [cb, ok]() { cb(ok); };
        });
        return;
    }
    // No credential set — nothing to verify.
    cb(true);
}

bool App::hasPendingRPCResults() const {
    return (worker_ && worker_->hasPendingResults())
        || (fast_worker_ && fast_worker_->hasPendingResults());
}

std::string App::transactionSendProgressText() const
{
    using Job = services::NetworkRefreshService::Job;
    if (send_submissions_in_flight_ > 0) return TR("tx_progress_submitting");

    if (!pending_opids_.empty()) {
        char buf[128];
        snprintf(buf, sizeof(buf), TR("tx_progress_waiting_ops"), (int)pending_opids_.size());
        return buf;
    }

    if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) {
        return TR("tx_progress_balances");
    }

    if (transactions_dirty_ || network_refresh_.jobInProgress(Job::Transactions)) {
        return TR("tx_progress_history");
    }

    return TR("tx_progress_finalizing");
}

std::string App::transactionRefreshProgressText() const
{
    using Job = services::NetworkRefreshService::Job;
    bool running = network_refresh_.jobInProgress(Job::Transactions);
    bool canRefresh = state_.connected && !state_.warming_up && !state_.isLocked();
    if (!running && !(canRefresh && transactions_dirty_)) return {};

    char buf[128];

    // A just-sent transaction being enriched always surfaces — the user is actively waiting on it.
    if (!send_txids_.empty()) {
        snprintf(buf, sizeof(buf), TR("tx_loading_enriching_sends"), (int)send_txids_.size());
        return buf;
    }

    // Initial bulk history load: the incremental shielded scan streams older transactions in over
    // many refresh cycles, so the first batch can appear long before the history is complete. Keep
    // the user informed (with a %) even after rows are on screen, so they know it's still loading.
    // Progress = fraction of z-addresses scanned at least once (monotonic). Goes quiet for good once
    // the first full scan completes; routine per-block re-scans don't re-trigger it.
    if (!initial_history_scan_complete_ && !state_.z_addresses.empty()) {
        std::size_t total = state_.z_addresses.size();
        std::size_t scanned = std::min(shielded_history_scan_heights_.size(), total);
        int pct = total > 0 ? static_cast<int>((scanned * 100) / total) : 0;
        if (pct > 99) pct = 99;  // 100% is the "complete" state, which suppresses the banner
        snprintf(buf, sizeof(buf), TR("tx_loading_history_progress"), pct);
        return buf;
    }

    // Once history is on screen, stay quiet for routine background refreshes. The incremental
    // shielded-receive scan re-dirties transactions_dirty_ on EVERY new block (each block moves the
    // "scanned at tip" bar, invalidating prior per-address scans), so for a wallet with more
    // z-addresses than the per-cycle scan budget the flag never clears — which left this banner lit
    // indefinitely. New receives still appear live as they're scanned; only the INITIAL load
    // (nothing displayed yet) and send enrichment (above) warrant a loading banner.
    if (!state_.transactions.empty()) return {};

    if (!running && transactions_dirty_) return TR("tx_loading_queued");

    if (!state_.z_addresses.empty()) {
        snprintf(buf, sizeof(buf), TR("tx_loading_scanning_shielded"), (int)state_.z_addresses.size());
        return buf;
    }

    return TR("tx_loading_fetching_transparent");
}

void App::copySecretToClipboard(const std::string& secret)
{
    ImGui::SetClipboardText(secret.c_str());
    // Keep only an FNV-1a hash of the secret so we can later confirm the clipboard still holds it
    // (and shouldn't be clobbered) WITHOUT retaining the plaintext.
    std::uint64_t h = 1469598103934665603ULL;
    for (unsigned char c : secret) h = (h ^ c) * 1099511628211ULL;
    clipboard_secret_hash_ = secret.empty() ? 0 : h;
    clipboard_clear_deadline_ = secret.empty() ? 0.0 : (ImGui::GetTime() + 45.0);
    if (!secret.empty())
        ui::Notifications::instance().info(TR("appx_copied_clipboard_autoclears"), 4.0f);
}

void App::clearSecretClipboardIfArmed()
{
    if (clipboard_secret_hash_ == 0) return;
    // Only clear if the clipboard STILL holds our secret (the user may have copied something else).
    if (const char* cb = ImGui::GetClipboardText()) {
        std::uint64_t h = 1469598103934665603ULL;
        for (const char* p = cb; *p; ++p) h = (h ^ static_cast<unsigned char>(*p)) * 1099511628211ULL;
        if (h == clipboard_secret_hash_) ImGui::SetClipboardText("");
    }
    clipboard_clear_deadline_ = 0.0;
    clipboard_secret_hash_ = 0;
}

void App::pumpSecretClipboardClear()
{
    if (clipboard_clear_deadline_ <= 0.0) return;
    if (ImGui::GetTime() < clipboard_clear_deadline_) return;
    clearSecretClipboardIfArmed();
}

void App::maybeFinishTransactionSendProgress()
{
    using Job = services::NetworkRefreshService::Job;
    if (!send_progress_active_) return;
    if (send_submissions_in_flight_ > 0 || !pending_opids_.empty()) return;
    // Finish once the spend is reflected in the balance (addresses). Do NOT wait on the transaction
    // history scan: the sent tx is already shown via the optimistic pending insert, and the shielded
    // history scan can stay "dirty" for a long time on wallets with many z-addresses — gating on it
    // left the send-progress indicator stuck on indefinitely.
    if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) return;
    send_progress_active_ = false;
}
std::string App::buildDiagnosticsReport()
{
    std::ostringstream os;
    os << "=== ObsidianDragon diagnostics ===\n";
    os << "version: " << DRAGONX_VERSION << "\n";
#if DRAGONX_LITE_BUILD
    os << "variant: Lite\n";
#else
    os << "variant: Full-node\n";
#endif
#if defined(_WIN32)
    os << "platform: windows\n";
#elif defined(__APPLE__)
    os << "platform: macos\n";
#else
    os << "platform: linux\n";
#endif
    os << "connected: " << (state_.connected ? "yes" : "no") << "\n";
    os << "status: " << connection_status_ << "\n";

    const std::string activeWallet = settings_ ? settings_->getActiveWalletFile() : std::string("(none)");
    os << "active wallet: " << activeWallet << "\n";
    {
        std::error_code ec;
        const std::string wp = util::Platform::getDragonXDataDir() + "/" + activeWallet;
        const bool present = std::filesystem::exists(wp, ec);
        os << "  path: " << wp << (present ? "  [present" : "  [MISSING");
        if (present) { auto sz = std::filesystem::file_size(wp, ec); if (!ec) os << ", " << sz << " bytes"; }
        os << "]\n";
    }
    os << "encryption: "
       << (state_.encryption_state_known
               ? (state_.encrypted ? (state_.locked ? "encrypted, locked" : "encrypted, unlocked") : "unencrypted")
               : "unknown")
       << "\n";
    os << "sync: block " << state_.sync.blocks << " / " << state_.sync.headers
       << (state_.sync.syncing ? "  (syncing)" : "")
       << (state_.warming_up ? "  (warming up)" : "") << "\n";

#if !DRAGONX_LITE_BUILD
    os << "daemon status: " << getDaemonStatus() << "\n";
    if (daemon_controller_) {
        os << "daemon running: " << (daemon_controller_->isRunning() ? "yes" : "no")
           << ", crashes: " << daemon_controller_->crashCount() << "\n";
        const std::string derr = daemon_controller_->lastError();
        if (!derr.empty()) os << "daemon lastError: " << derr << "\n";
    }
#endif

    const std::string cfg = util::Platform::getObsidianDragonDir();
    os << "log folder: " << cfg << "\n";
    os << "  " << cfg << "/dragonx-debug.log\n";
    os << "  " << cfg << "/dragonx-crash.log\n";
    return os.str();
}

void App::restartDaemon()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
        return;
    }

    auto decision = daemon::DaemonController::evaluateLifecycleOperation(
        daemon::DaemonController::LifecycleOperation::ManualRestart,
        isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
    if (!decision.allowed) return;
    daemon_restarting_ = true;

    daemon_controller_->prepareLifecycleOperation(decision, settings_.get());

    DEBUG_LOGF("[App] Restarting embedded daemon...\n");
    connection_status_ = TR("sb_restarting_daemon");

    // Disconnect RPC so the loading overlay appears
    if (decision.disconnectRpc && rpc_ && rpc_->isConnected()) {
        rpc_->disconnect();
    }
    onDisconnected("Daemon restart");

    // Run stop + start on a background thread to avoid blocking the UI.
    // The 5-second auto-retry in render() will reconnect once the daemon
    // is back up.
    async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
        AppDaemonLifecycleRuntime runtime(*this);
        daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
        daemon_controller_->executeLifecycleOperation(decision, runtime, context);
        daemon_restarting_ = false;
        DEBUG_LOGF("[App] Daemon restart complete — waiting for RPC...\n");
    });
}

// One-click recovery for an unreadable block database: arm the one-shot -reindex flag, then un-gate the
// connect loop (which detected the abort and stopped restarting). Its next attempt calls
// startEmbeddedDaemon(), which consumes the flag → the node rebuilds its block index + chainstate from
// the raw blocks. The daemon is not running here (it aborted), so no explicit stop/restart is needed.
void App::reindexBlockDatabase()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
        return;
    }
    if (!daemon_controller_) return;
    daemon_controller_->setReindexOnNextStart(true);
    user_initiated_rescan_ = true;   // reindex implies a rescan → its completion should toast
    daemon_controller_->resetCrashCount();      // the abort no longer counts against the restart budget
    show_block_db_reindex_confirm_ = false;
    block_db_reindex_available_ = false;         // un-gate → the connect loop restarts the node with -reindex
    connection_status_ = TR("sb_starting_daemon");
    ui::Notifications::instance().info(TR("block_db_reindex_started"), 12.0f);
    DEBUG_LOGF("[App] Block-database reindex requested — restarting node with -reindex\n");
}

} // namespace dragonx
