// 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 "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 "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/lite_console_tab.h"
#include "ui/windows/explorer_tab.h"
#include "ui/windows/market_tab.h"
#include "ui/windows/settings_window.h"
#include "ui/windows/about_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/address_book_dialog.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/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/notifications.h"
#include "util/i18n.h"
#include "util/platform.h"
#include "util/payment_uri.h"
#include "util/texture_loader.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 <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() = default;
App::~App() = default;

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");
    }
    // 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());

    // 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();

    // Initialize PIN vault
    vault_ = std::make_unique<util::SecureVault>();

    // 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);

    // Forward error/warning notifications to the console tab
    // Use ConsoleTab colors so the Errors filter works correctly
    ui::Notifications::instance().setConsoleCallback(
        [this](const std::string& msg, bool is_error) {
            ImU32 color = is_error ? ui::ConsoleTab::COLOR_ERROR : ui::material::Warning();
            console_tab_.addLine(msg, color);
        });

    // 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: errors in red, warnings in warning color,
            // everything else in the default info color.
            ImU32 color = ui::ConsoleTab::COLOR_INFO;
            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) {
                color = ui::ConsoleTab::COLOR_ERROR;
            } else if (msg.find("[WARN]") != std::string::npos ||
                       msg.find("warn") != std::string::npos) {
                color = ui::material::Warning();
            }
            // Strip trailing newline so console tab lines look clean
            std::string trimmed = msg;
            while (!trimmed.empty() && (trimmed.back() == '\n' || trimmed.back() == '\r'))
                trimmed.pop_back();
            if (!trimmed.empty())
                console_tab_.addLine("[app] " + trimmed, color);
        });

    // 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();
    }

    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());
    }
}

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;
}
} // 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();
}

void App::update()
{
    PERF_SCOPE("Update.Total");
    ImGuiIO& io = ImGui::GetIO();
    
    // 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("Wallet open failed: ") + 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_);
        }
        // Deliver a completed async send/shield result to the waiting send_tab callback.
        wallet::LiteBroadcastResult broadcast;
        if (lite_wallet_->takeBroadcastResult(broadcast)) {
            if (lite_send_callback_) {
                lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
                lite_send_callback_ = nullptr;
            }
        }

        // 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();

    // 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).
    if (state_.sync.syncing != 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.
    if (network_refresh_.consumeDue(RefreshTimer::Fast)) {
        if (rpcConnected && !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 (!state_.warming_up) {
                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).
            if (state_.sync.rescanning && 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.
                                ui::Notifications::instance().success("Blockchain rescan complete");
                                state_.sync.rescanning = false;
                                rescan_confirmed_active_ = false;
                                state_.sync.rescan_progress = 1.0f;
                                state_.sync.rescan_status.clear();
                            }
                            // 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; };
                    }
                });
            }
        }

        // 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;
            auto& xs = xmrig_manager_->getStats();
            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.connected     = xs.connected;
            // 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;
        }

        // 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() && fast_worker_) {
                fast_worker_->post([this, output = std::move(newOutput)]() -> rpc::RPCWorker::MainCb {
                    // Parse on worker thread — pure string work, no shared state access
                    bool foundRescan = false;
                    bool finished    = false;
                    float rescanPct  = 0.0f;
                    std::string lastStatus;

                    size_t pos = 0;
                    while (pos < output.size()) {
                        size_t eol = output.find('\n', pos);
                        if (eol == std::string::npos) eol = output.size();
                        std::string line = output.substr(pos, eol - pos);
                        pos = eol + 1;

                        if (line.find("Rescanning from height") != std::string::npos ||
                            line.find("Rescanning last") != std::string::npos) {
                            foundRescan = true;
                            lastStatus = line;
                        }

                        auto stillIdx = line.find("Still rescanning");
                        if (stillIdx != std::string::npos) {
                            foundRescan = true;
                            auto progIdx = line.find("Progress=");
                            if (progIdx != std::string::npos) {
                                size_t numStart = progIdx + 9;
                                size_t numEnd = numStart;
                                while (numEnd < line.size() && (std::isdigit(line[numEnd]) || line[numEnd] == '.')) {
                                    numEnd++;
                                }
                                if (numEnd > numStart) {
                                    try { rescanPct = std::stof(line.substr(numStart, numEnd - numStart)) * 100.0f; } catch (...) {}
                                }
                            }
                            lastStatus = line;
                        }

                        auto rescIdx = line.find("Rescanning...");
                        if (rescIdx != std::string::npos) {
                            foundRescan = true;
                            auto pctIdx = line.find('%');
                            if (pctIdx != std::string::npos && pctIdx > 0) {
                                size_t numEnd = pctIdx;
                                size_t numStart = numEnd;
                                while (numStart > 0 && (std::isdigit(line[numStart - 1]) || line[numStart - 1] == '.')) {
                                    numStart--;
                                }
                                if (numStart < numEnd) {
                                    try { rescanPct = std::stof(line.substr(numStart, numEnd - numStart)); } catch (...) {}
                                }
                            }
                            lastStatus = line;
                        }

                        if (line.find("Done rescanning") != std::string::npos ||
                            line.find("Rescan complete") != std::string::npos) {
                            finished = true;
                        }

                        // This daemon prints no "Done rescanning" line; instead it logs the rescan
                        // benchmark timing exactly when the scan finishes, e.g.:
                        //   "...  rescan             16760577ms"
                        // Match the lowercase " rescan " bench category ending in "<digits>ms" (the
                        // progress lines are "Still rescanning"/"Rescanning...", which never end in ms).
                        if (line.find(" rescan ") != std::string::npos) {
                            std::string t = line;
                            while (!t.empty() && (t.back() == '\r' || t.back() == '\n' || t.back() == ' '))
                                t.pop_back();
                            if (t.size() >= 3 && t.compare(t.size() - 2, 2, "ms") == 0 &&
                                std::isdigit(static_cast<unsigned char>(t[t.size() - 3]))) {
                                finished = true;
                            }
                        }
                    }

                    // Return callback to apply results on main thread
                    return [this, foundRescan, finished, rescanPct, status = std::move(lastStatus)]() {
                        if (finished) {
                            if (state_.sync.rescanning) {
                                ui::Notifications::instance().success("Blockchain rescan complete");
                            }
                            state_.sync.rescanning = false;
                            rescan_confirmed_active_ = false;
                            state_.sync.rescan_progress = 1.0f;
                            state_.sync.rescan_status.clear();
                        } else if (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 (rescanPct > 0.0f) {
                                state_.sync.rescan_progress = 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;
            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());
            } catch (...) {
                return [this](){ opid_poll_in_progress_ = false; };
            }
            auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
            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, msg] : parsed.failureByOpid) {
                    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();
                }
            };
        });
    }

    // 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 (network_refresh_.consumeDue(RefreshTimer::Core)) {
            refreshCoreData();
        }
        // Skip balance/tx/address refresh during warmup — RPC calls fail with -28
        if (!state_.warming_up) {
            if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
                if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
                    refreshTransactionData();
                } else if (walletDataPage && shouldRefreshRecentTransactions()) {
                    refreshRecentTransactionData();
                }
            }
            if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
                if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) {
                    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();
        }
    }

    // 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("Theme: " + 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 ? "Low-spec mode enabled" : "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 ? "Theme effects enabled" : "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 ? "Simple background enabled" : "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");
        }
    }
}

void App::render()
{
    // 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();

    // 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);
            dl->AddText(titleFont, titleFontSize,
                        ImVec2(logoX, textY),
                        textCol, "ObsidianDragon");
        }
    }

    // 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;
    };
    float statusBarH = ui::schema::UI().window("components.status-bar").height;
    if (statusBarH <= 0.0f) statusBarH = 24.0f;  // 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;

    // Load logo texture lazily on first frame (or after theme change)
    // Also reload when dark↔light mode changes so the correct variant shows
    {
        bool wantDark = ui::material::IsDarkTheme();
        if (!logo_loaded_ || (wantDark != logo_is_dark_variant_)) {
            logo_loaded_ = true;
            logo_is_dark_variant_ = wantDark;
            logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;

            // 1) Check for theme-override logo from 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 {
                    // Fallback filenames
                    iconFile = wantDark ? "logos/logo_ObsidianDragon_dark.png" : "logos/logo_ObsidianDragon_light.png";
                }
                logoPath = util::getExecutableDirectory() + "/res/img/" + iconFile;
            }
            if (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 {
                // Try embedded data fallback — use actual filename from path
                // so light/dark variants resolve correctly on Windows single-file
                std::string embeddedName = std::filesystem::path(logoPath).filename().string();
                const auto* logoRes = resources::getEmbeddedResource(embeddedName);
                if (!logoRes || !logoRes->data || logoRes->size == 0) {
                    // Final fallback: try the default dark logo constant
                    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());
                }
            }
        }
    }

    // Load coin logo texture lazily (DragonX currency icon for balance tab)
    if (!coin_logo_loaded_) {
        coin_logo_loaded_ = true;
        coin_logo_tex_ = 0; coin_logo_w_ = 0; coin_logo_h_ = 0;

        // Read coin icon filename from ui.toml
        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;
        if (util::LoadTextureFromFile(coinPath.c_str(), &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
            DEBUG_LOGF("Loaded coin logo from %s (%dx%d)\n", coinPath.c_str(), coin_logo_w_, coin_logo_h_);
        } else {
            // Try embedded resource fallback (Windows single-file distribution)
            std::string coinBasename = std::filesystem::path(coinFile).filename().string();
            const auto* coinRes = resources::getEmbeddedResource(coinBasename);
            if (coinRes && coinRes->data && coinRes->size > 0) {
                if (util::LoadTextureFromMemory(coinRes->data, coinRes->size, &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
                    DEBUG_LOGF("Loaded coin logo from embedded: %s (%dx%d)\n", coinBasename.c_str(), coin_logo_w_, coin_logo_h_);
                } else {
                    DEBUG_LOGF("Note: Failed to decode embedded coin logo\n");
                }
            } else {
                DEBUG_LOGF("Note: Coin logo not found at %s\n", coinPath.c_str());
            }
        }
    }

    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)
    {
        int unconf = 0;
        for (const auto& tx : state_.transactions) {
            if (tx.confirmations == 0) ++unconf;
        }
        sbStatus.unconfirmedTxCount = unconf;
    }

    // Sidebar margins from ui.toml schema (DPI-scaled like all sidebar values)
    const float sbMarginTop    = sbde("margin-top", 0.0f);
    const float sbMarginBottom = sbde("margin-bottom", 0.0f);
    const float sbMinHeight    = sbde("min-height", 360.0f);

    // Ensure sidebar is tall enough to fit all buttons — shrink margins if needed
    float sidebarH = contentH - sbMarginTop - sbMarginBottom;
    float effectiveMarginTop = sbMarginTop;
    if (sidebarH < sbMinHeight) {
        float available = contentH - sbMinHeight;
        if (available > 0.0f) {
            float ratio = available / (sbMarginTop + sbMarginBottom);
            effectiveMarginTop = sbMarginTop * ratio;
        } else {
            effectiveMarginTop = 0.0f;
        }
        sidebarH = std::max(contentH - effectiveMarginTop, sbMinHeight);
    }

    // Sidebar navigation
    // Save cursor Y before applying sidebar margin so the content area
    // (placed via SameLine) starts at the original row position, not the
    // margin-shifted one.
    float preSidebarCursorY = ImGui::GetCursorPosY();
    if (effectiveMarginTop > 0.0f)
        ImGui::SetCursorPosY(preSidebarCursorY + effectiveMarginTop);
    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;
        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));

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

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

    // 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::Mining:
            ui::RenderMiningTab(this);
            break;
        case ui::NavPage::Peers:
            ui::RenderPeersTab(this);
            break;
        case ui::NavPage::LiteNetwork:
            ui::RenderLiteNetworkTab(this);
            break;
        case ui::NavPage::LiteConsole:
            ui::RenderLiteConsoleTab(this);
            break;
        case ui::NavPage::Explorer:
            ui::RenderExplorerTab(this);
            break;
        case ui::NavPage::Market:
            ui::RenderMarketTab(this);
            break;
        case ui::NavPage::Console:
            // Use fast-lane worker for console commands to avoid head-of-line
            // blocking behind the consolidated refreshData() batch.
            // Fall back to main rpc/worker if fast-lane hasn't connected yet.
            console_tab_.render(daemon_controller_ ? daemon_controller_->daemon() : nullptr,
                (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(),
                (fast_rpc_ && fast_rpc_->isConnected() && fast_worker_) ? fast_worker_.get() : worker_.get(),
                xmrig_manager_.get());
            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_);
    }

    // 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();
    }

    // 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 RPC Command Reference popup
    console_tab_.renderCommandsPopupModal();

    // 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);
    
    // Address book dialog (triggered from Edit menu)
    ui::AddressBookDialog::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();

    // 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();

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

void App::renderStatusBar()
{
    // Status bar layout from unified UI schema
    const auto& S = ui::schema::UI();
    const auto& sbWin              = S.window("components.status-bar");
    const float sbHeight           = sbWin.height;
    const float sbPadX             = sbWin.padding[0];
    const float sbPadY             = sbWin.padding[1];
    const float sbIconTextGap      = S.drawElement("components.status-bar", "icon-text-gap").size;
    const float sbSectionGap       = S.drawElement("components.status-bar", "section-gap").size;
    const float sbSeparatorGap     = S.drawElement("components.status-bar", "separator-gap").size;

    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.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.
    {
        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;
        if (!connection_status_.empty() && connection_status_ != "Connected") {
            float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
            float statusX = versionX - statusW - gap;
            ImGui::SameLine(statusX);
            ImGui::TextDisabled("%s", connection_status_.c_str());
        } else if (!daemon_status_.empty() && daemon_status_.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);
        }

        // 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
    logo_loaded_ = false;
    logo_tex_ = 0;
    logo_w_ = 0;
    logo_h_ = 0;
    coin_logo_loaded_ = false;
    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 creating = false;        // async create (with failover) in flight

    // 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);
        words.clear();
        chips.clear();
        progress = 0;
        step = 0;
        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 btnW = 170.0f;

        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);
            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("Creating your wallet\xE2\x80\xA6");
                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("Create failed: ") + lite_wallet_->lastOpenError());
                    creating = false;  // back to the buttons so the user can retry
                }
            } else {
                if (ImGui::Button(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 (ImGui::Button(TR("lite_welcome_restore"), ImVec2(btnW, 0))) {
                    ui::Notifications::instance().info(TR("lite_welcome_restore_hint"), 8.0f);
                    setCurrentPage(ui::NavPage::Settings);
                    finish();
                }
                ImGui::Spacing();
                if (ImGui::Button(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("Back up your seed phrase");
            ImGui::PopFont();
            ImGui::Spacing();
            ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
            ImGui::TextUnformatted("These 24 words are the ONLY way to restore your wallet. "
                                   "Write them down in order, store them offline, and never share "
                                   "them. If you lose them, your funds are gone forever.");
            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);
            }
            ImGui::Spacing();
            char bday[80];
            snprintf(bday, sizeof(bday), "Birthday (block height): %llu  —  back this up too.", birthday);
            ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
            ImGui::TextUnformatted(bday);
            ImGui::PopStyleColor();
            ImGui::Spacing(); ImGui::Spacing();

            if (ImGui::Button("I've 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;
                step = 2;
            }
            ImGui::SameLine();
            if (ImGui::Button("Copy", ImVec2(80, 0))) copySecretToClipboard(seed);
            ImGui::SameLine();
            if (ImGui::Button("Skip", ImVec2(80, 0))) {
                ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
                finish();
            }
        } else {  // step == 2
            // ── Verify: tap the words in order ──────────────────────────────────────
            ImGui::PushFont(ui::material::Type().subtitle1());
            ImGui::TextUnformatted("Confirm your backup");
            ImGui::PopFont();
            ImGui::Spacing();
            ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
            ImGui::TextUnformatted("Tap the words in the correct order to confirm you saved them.");
            ImGui::PopTextWrapPos();
            ImGui::Spacing();

            ImGui::TextDisabled("Progress: %d / %d", progress, (int)words.size());
            if (ImGui::GetTime() < wrongFlashUntil) {
                ImGui::SameLine();
                ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
                ImGui::TextUnformatted("  — that's not the 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();
                    ImGui::Button(chips[i].first.c_str(), ImVec2(125, 0));
                    ImGui::EndDisabled();
                } else if (ImGui::Button(chips[i].first.c_str(), ImVec2(125, 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 (ImGui::Button("Done", ImVec2(btnW, 0))) {
                ui::Notifications::instance().success("Wallet created and backed up.", 6.0f);
                finish();
            }
            if (!verified) ImGui::EndDisabled();
            ImGui::SameLine();
            if (ImGui::Button("Back", ImVec2(80, 0))) step = 1;
            ImGui::SameLine();
            if (ImGui::Button("Skip", ImVec2(80, 0))) {
                ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
                finish();
            }
        }
        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)) {
        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);
        const bool entered = ImGui::InputText("##LiteUnlockPassModal", pass, sizeof(pass),
                                              ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue);
        ImGui::Spacing();
        const float btnW = 130.0f;
        bool doUnlock = ImGui::Button(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    ui::Notifications::instance().error(TR("lite_unlock_failed"));
            lite_unlock_prompt_ = false;
            ImGui::CloseCurrentPopup();
        }
        ImGui::SameLine();
        if (ImGui::Button(TR("cancel"), ImVec2(btnW, 0))) {
            sodium_memzero(pass, sizeof(pass));
            lite_unlock_prompt_ = false;
            ImGui::CloseCurrentPopup();
        }
        ImGui::EndPopup();
    }
}

void App::renderAboutDialog()
{
    auto dlg = ui::schema::UI().drawElement("inline-dialogs", "about");
    auto dlgF = [&](const char* key, float fb) -> float {
        auto it = dlg.extraFloats.find(key);
        return it != dlg.extraFloats.end() ? it->second : fb;
    };
    
    if (!ui::material::BeginOverlayDialog("About ObsidianDragon", &show_about_, dlgF("width", 400.0f), 0.94f)) {
        return;
    }
    
    ui::material::Type().text(ui::material::TypeStyle::H6, DRAGONX_APP_NAME);
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    
    ImGui::Text("Version: %s", DRAGONX_VERSION);
    ImGui::Text("ImGui: %s", IMGUI_VERSION);
    ImGui::Spacing();
    
    ImGui::TextWrapped("A shielded cryptocurrency wallet for DragonX (DRGX), "
                      "built with Dear ImGui for a lightweight, portable experience.");
    ImGui::Spacing();
    
    ImGui::Text("Copyright 2024-2026 The Hush Developers");
    ImGui::Text("Released under the GPLv3 License");
    
    ImGui::Spacing();
    ImGui::Separator();
    
    if (ui::material::StyledButton("Close", ImVec2(dlgF("close-button-width", 120.0f), 0), ui::material::resolveButtonFont((int)dlgF("button-font", 1)))) {
        show_about_ = false;
    }
    
    ui::material::EndOverlayDialog();
}

void App::renderImportKeyDialog()
{
    auto dlg = ui::schema::UI().drawElement("inline-dialogs", "import-key");
    auto dlgF = [&](const char* key, float fb) -> float {
        auto it = dlg.extraFloats.find(key);
        return it != dlg.extraFloats.end() ? it->second : fb;
    };
    
    int btnFont = (int)dlgF("button-font", 1);
    float btnW = dlgF("button-width", 120.0f);
    
    if (!ui::material::BeginOverlayDialog("Import Private Key", &show_import_key_, dlgF("width", 500.0f), 0.94f)) {
        return;
    }
    
    ui::material::Type().text(ui::material::TypeStyle::H6, "Import Private Key");
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ImGui::TextWrapped("Enter a private key to import. The wallet will rescan the blockchain for transactions.");
    ImGui::Spacing();
    
    ImGui::Text("Private Key:");
    ImGui::SetNextItemWidth(-1);
    ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_));
    
    // Paste & Clear buttons
    if (ui::material::StyledButton(TR("paste"), ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
        const char* clipboard = ImGui::GetClipboardText();
        if (clipboard) {
            snprintf(import_key_input_, sizeof(import_key_input_), "%s", clipboard);
            // Trim whitespace
            std::string trimmed(import_key_input_);
            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 (ui::material::StyledButton(TR("clear"), ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
        memset(import_key_input_, 0, sizeof(import_key_input_));
    }
    
    // Key validation indicator
    if (import_key_input_[0] != '\0') {
        std::string k(import_key_input_);
        bool isZKey = (k.substr(0, 20) == "secret-extended-key-") ||
                      (k.length() >= 2 && k[0] == 'S' && k[1] == 'K');
        bool isTKey = (k.length() >= 51 && k.length() <= 52 &&
                      (k[0] == '5' || k[0] == 'K' || k[0] == 'L' || k[0] == 'U'));
        if (isZKey || isTKey) {
            ImGui::PushFont(ui::material::Type().iconSmall());
            ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), ICON_MD_CHECK_CIRCLE);
            ImGui::PopFont();
            ImGui::SameLine(0, 4.0f);
            ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s",
                               isZKey ? "Shielded spending key" : "Transparent private key");
        } else {
            ImGui::PushFont(ui::material::Type().iconSmall());
            ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), ICON_MD_HELP);
            ImGui::PopFont();
            ImGui::SameLine(0, 4.0f);
            ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "Unrecognized key format");
        }
    }
    
    ImGui::Spacing();
    
    if (!import_status_.empty()) {
        if (import_success_) {
            ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", import_status_.c_str());
        } else {
            ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", import_status_.c_str());
        }
    }
    
    ImGui::Spacing();
    ImGui::Separator();
    
    if (ui::material::StyledButton("Import", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
        std::string key(import_key_input_);
        if (!key.empty()) {
            importPrivateKey(key, [this](bool success, const std::string& msg) {
                import_success_ = success;
                import_status_ = msg;
                if (success) {
                    memset(import_key_input_, 0, sizeof(import_key_input_));
                }
            });
        }
    }
    ImGui::SameLine();
    if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
        show_import_key_ = false;
        import_status_.clear();
        memset(import_key_input_, 0, sizeof(import_key_input_));
    }
    
    ui::material::EndOverlayDialog();
}

void App::renderExportKeyDialog()
{
    auto dlg = ui::schema::UI().drawElement("inline-dialogs", "export-key");
    auto dlgF = [&](const char* key, float fb) -> float {
        auto it = dlg.extraFloats.find(key);
        return it != dlg.extraFloats.end() ? it->second : fb;
    };
    int btnFont = (int)dlgF("button-font", 1);
    
    if (!ui::material::BeginOverlayDialog("Export Private Key", &show_export_key_, dlgF("width", 600.0f), 0.94f)) {
        return;
    }
    
    ui::material::Type().text(ui::material::TypeStyle::H6, "Export Private Key");
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ImGui::TextColored(ImVec4(0.9f, 0.4f, 0.4f, 1.0f), 
        "WARNING: Anyone with this key can spend your coins!");
    ImGui::Spacing();
    
    // Address selector
    ImGui::Text("Select Address:");
    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);
    
    int addrFrontLen = (int)dlgF("addr-front-len", 20);
    int addrBackLen  = (int)dlgF("addr-back-len", 8);
    if (ImGui::BeginCombo("##exportaddr", export_address_.empty() ? "Select address..." : export_address_.c_str())) {
        for (const auto& addr : all_addrs) {
            bool selected = (export_address_ == addr);
            std::string display = addr.substr(0, addrFrontLen) + "..." + addr.substr(addr.length() - addrBackLen);
            if (ImGui::Selectable(display.c_str(), selected)) {
                export_address_ = addr;
                export_result_.clear();
            }
        }
        ImGui::EndCombo();
    }
    
    ImGui::SameLine();
    if (ui::material::StyledButton("Export", ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
        if (!export_address_.empty()) {
            exportPrivateKey(export_address_, [this](const std::string& key) {
                export_result_ = key;
            });
        }
    }
    
    ImGui::Spacing();
    
    if (!export_result_.empty()) {
        ImGui::Text("Private Key:");
        ImGui::InputTextMultiline("##exportresult", (char*)export_result_.c_str(), 
            export_result_.size() + 1, ImVec2(-1, dlgF("key-display-height", 60.0f)), ImGuiInputTextFlags_ReadOnly);
        
        if (ui::material::StyledButton("Copy to Clipboard", ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
            ImGui::SetClipboardText(export_result_.c_str());
        }
    }
    
    ImGui::Spacing();
    ImGui::Separator();
    
    int closeBtnFont = (int)dlgF("close-button-font", -1);
    if (closeBtnFont < 0) closeBtnFont = btnFont;
    if (ui::material::StyledButton("Close", ImVec2(dlgF("close-button-width", 120.0f), 0), ui::material::resolveButtonFont(closeBtnFont))) {
        show_export_key_ = false;
        export_result_.clear();
        export_address_.clear();
    }
    
    ui::material::EndOverlayDialog();
}

void App::renderBackupDialog()
{
    auto dlg = ui::schema::UI().drawElement("inline-dialogs", "backup");
    auto dlgF = [&](const char* key, float fb) -> float {
        auto it = dlg.extraFloats.find(key);
        return it != dlg.extraFloats.end() ? it->second : fb;
    };
    int btnFont = (int)dlgF("button-font", 1);
    float btnW = dlgF("button-width", 120.0f);
    
    if (!ui::material::BeginOverlayDialog("Backup Wallet", &show_backup_, dlgF("width", 500.0f), 0.94f)) {
        return;
    }
    
    ui::material::Type().text(ui::material::TypeStyle::H6, "Backup Wallet");
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ImGui::TextWrapped("Export all private keys to a file. Keep this file secure!");
    ImGui::Spacing();
    
    ImGui::Text("Backup File Path:");
    static char backup_path[512] = "dragonx-backup.txt";
    ImGui::SetNextItemWidth(-1);
    ImGui::InputText("##backuppath", backup_path, sizeof(backup_path));
    
    ImGui::Spacing();
    
    if (!backup_status_.empty()) {
        if (backup_success_) {
            ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", backup_status_.c_str());
        } else {
            ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", backup_status_.c_str());
        }
    }
    
    ImGui::Spacing();
    ImGui::Separator();
    
    if (ui::material::StyledButton("Save Backup", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
        std::string path(backup_path);
        if (!path.empty()) {
            backupWallet(path, [this](bool success, const std::string& msg) {
                backup_success_ = success;
                backup_status_ = msg;
            });
        }
    }
    ImGui::SameLine();
    if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
        show_backup_ = false;
        backup_status_.clear();
    }
    
    ui::material::EndOverlayDialog();
}

void App::renderAntivirusHelpDialog()
{
#ifdef _WIN32
    if (!pending_antivirus_dialog_) return;
    
    if (!ui::material::BeginOverlayDialog("Windows Defender Blocked Miner", &pending_antivirus_dialog_, 560.0f, 0.94f)) {
        return;
    }
    
    ui::material::Type().text(ui::material::TypeStyle::H6, "Windows Defender Blocked xmrig");
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    
    ImGui::TextWrapped(
        "Mining software is often flagged as potentially unwanted. "
        "Follow these steps to enable pool mining:");
    
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ui::material::Type().text(ui::material::TypeStyle::Subtitle2, "Step 1: Add Exclusion");
    ImGui::BulletText("Open Windows Security > Virus & threat protection");
    ImGui::BulletText("Click Manage settings > Exclusions > Add or remove");
    ImGui::BulletText("Add folder: %%APPDATA%%\\ObsidianDragon\\");
    
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ui::material::Type().text(ui::material::TypeStyle::Subtitle2, "Step 2: Restore from Quarantine (if needed)");
    ImGui::BulletText("Windows Security > Protection history");
    ImGui::BulletText("Find xmrig.exe and click Restore");
    
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    ui::material::Type().text(ui::material::TypeStyle::Subtitle2, "Step 3: Restart wallet and try again");
    
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
    ImGui::Separator();
    ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
    
    float btnW = 160.0f;
    if (ui::material::StyledButton("Open Windows 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::StyledButton("Close", ImVec2(100.0f, 0))) {
        pending_antivirus_dialog_ = false;
    }
    
    ui::material::EndOverlayDialog();
#endif
}

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::handlePaymentURI(const std::string& uri)
{
    auto payment = util::parsePaymentURI(uri);
    
    if (!payment.valid) {
        ui::Notifications::instance().error("Invalid payment URI: " + 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 = "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]);
}

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");
            daemon_status_ = TR("sb_extracting_sapling");
            resources::extractEmbeddedResources();
            
            // Check again after extraction
            if (!rpc::Connection::verifySaplingParams()) {
                daemon_status_ = 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::error_code ec;
                fs::create_directories(daemon_dir, 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 {
                daemon_status_ = 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::error_code ec;
            fs::create_directories(daemon_dir, 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" };
            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)) {
                        DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
                        fs::copy_file(src, dst, ec);
                        break;
                    }
                }
            }
        }
    }

    // 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) {
            switch (state) {
                case daemon::EmbeddedDaemon::State::Starting:
                    daemon_status_ = TR("sb_starting_daemon");
                    break;
                case daemon::EmbeddedDaemon::State::Running:
                    daemon_status_ = TR("sb_dragonxd_running");
                    break;
                case daemon::EmbeddedDaemon::State::Stopping:
                    daemon_status_ = TR("sb_dragonxd_stopping");
                    break;
                case daemon::EmbeddedDaemon::State::Stopped:
                    daemon_status_ = TR("sb_dragonxd_stopped");
                    break;
                case daemon::EmbeddedDaemon::State::Error:
                    daemon_status_ = "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;
    }

    // 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
    if (rpc_ && rpc_->isConnected()) {
        DEBUG_LOGF("Sending stop command via existing RPC connection...\n");
        try {
            rpc_->stop([](const json&) {
                DEBUG_LOGF("Stop command acknowledged by daemon\n");
            });
            stop_sent = true;
        } catch (...) {
            DEBUG_LOGF("Failed to send stop via existing connection\n");
        }
    }
    
    // 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);
}

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

void App::rescanBlockchain()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
        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;
    }

    DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
    ui::Notifications::instance().info("Restarting daemon with -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;
    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::deleteBlockchainData()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
        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;
    }

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

    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);
    });
}

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
{
    // 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";
        if (mb > 0.0) return mb;
    } else {
        daemon_mem_diag_ = "process scan";
    }
    // Fall back to platform-level process scan (external daemon)
    return util::Platform::getDaemonMemoryUsageMB();
}

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

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

void App::beginShutdown()
{
    // Only start shutdown once
    if (shutting_down_) return;
    shutting_down_ = true;
    quit_requested_ = true;
    shutdown_timer_ = 0.0f;
    shutdown_start_time_ = std::chrono::steady_clock::now();
    async_tasks_.cancelAll();
    
    // 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();
    }

    // Stop xmrig pool miner before stopping the daemon
    if (xmrig_manager_ && xmrig_manager_->isRunning()) {
        shutdown_status_ = "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_ = "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(),
        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_ = "Sending stop command to daemon...";
        // Send RPC stop command
        stopEmbeddedDaemon();
        
        DEBUG_LOGF("shutdown thread: daemon stopped, disconnecting RPC\n");
        shutdown_status_ = "Cleaning up...";
        
        DEBUG_LOGF("shutdown thread: complete\n");
        shutdown_status_ = "Shutdown complete";
        shutdown_complete_ = true;
    });
}

void App::renderShutdownScreen()
{
    using namespace ui::material;
    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;

    // 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();
    ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.06f, 0.06f, 0.08f, 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 after 10s so Force Quit button is clickable
    if (shutdown_timer_ < 10.0f)
        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) * 2.0f + 12.0f;
    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, ImVec4(1.0f, 0.85f, 0.0f, 1.0f));
        ImGui::TextUnformatted(title);
        ImGui::PopStyleColor();
        ImGui::PopFont();
    }

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

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

        // 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));
    }

    ImGui::Spacing();

    // -------------------------------------------------------------------
    // 3. Phase status (what the shutdown thread is doing)
    // -------------------------------------------------------------------
    if (!shutdown_status_.empty()) {
        ImVec2 ts = ImGui::CalcTextSize(shutdown_status_.c_str());
        ImGui::SetCursorPosX(cx - ts.x * 0.5f);
        ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.75f, 0.75f, 0.75f, 1.0f));
        ImGui::TextUnformatted(shutdown_status_.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), "%d seconds", secs);
        else
            snprintf(elapsed, sizeof(elapsed), "%d min %d 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 after 10 seconds
    // -------------------------------------------------------------------
    if (shutdown_timer_ >= 10.0f) {
        ImGui::Spacing();
        ImGui::Spacing();
        const char* forceLabel = TR("force_quit");
        ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f, 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 (ImGui::Button(forceLabel, btnSize)) {
            force_quit_confirm_ = true;
        }
        if (ImGui::IsItemHovered()) {
            ImGui::SetTooltip("%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;
        float totalW = btnW * 2 + ImGui::GetStyle().ItemSpacing.x;
        ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) * 0.5f);

        if (ImGui::Button(TR("cancel"), ImVec2(btnW, 0))) {
            ImGui::CloseCurrentPopup();
        }
        ImGui::SameLine();
        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 (ImGui::Button(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));
    }

    ImGui::Spacing();

    // -------------------------------------------------------------------
    // 6. Daemon output panel — terminal-style box
    // -------------------------------------------------------------------
    if (daemon_controller_) {
        auto lines = daemon_controller_->recentLines(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("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);
            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;

    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();

    // Layout constants
    float lineH    = ImGui::GetTextLineHeightWithSpacing();
    float spinnerR = loadElem("spinner-radius", 18.0f);
    float gap      = loadElem("vertical-gap", 8.0f);
    float barH     = loadElem("progress-bar", 6.0f);
    float barW     = loadElem("progress-width", 260.0f);
    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);
        ImVec2 sc(wp.x + cx, curY + r + 2.0f);

        // 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;
    }

    // -------------------------------------------------------------------
    // 2. Connection / daemon status text
    // -------------------------------------------------------------------
    {
        const char* statusText = connection_status_.c_str();
        ImFont* font = Type().subtitle1();
        if (!font) font = ImGui::GetFont();
        ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText);
        dl->AddText(font, font->LegacySize,
                    ImVec2(wp.x + cx - ts.x * 0.5f, curY),
                    IM_COL32(220, 220, 220, 255), statusText);
        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. Live daemon console tail (init/warmup only) — show the last few lines the node
    //     printed so the user can watch real progress (UpdateTip height=…, Verifying blocks…)
    //     without leaving the blocked overlay. Full-node only (lite has no daemon_controller_).
    // -------------------------------------------------------------------
    if ((state_.daemon_initializing || state_.warming_up) && daemon_controller_) {
        const auto lines = daemon_controller_->recentLines(4);
        if (!lines.empty()) {
            ImFont* logFont = Type().caption();
            if (!logFont) logFont = ImGui::GetFont();
            const float blockW = std::min(ws.x * 0.8f, 560.0f);
            const ImU32 logCol = IM_COL32(140, 150, 165, 150);
            curY += gap * 0.5f;
            for (const auto& raw : lines) {
                // Trim trailing CR/whitespace; skip blanks.
                std::string line = raw;
                while (!line.empty() && (line.back() == '\r' || line.back() == '\n' ||
                                         line.back() == ' ' || line.back() == '\t'))
                    line.pop_back();
                if (line.empty()) continue;
                // Truncate (with an ellipsis) to keep each line on one row within blockW.
                if (logFont->CalcTextSizeA(logFont->LegacySize, FLT_MAX, 0.0f, line.c_str()).x > blockW) {
                    while (line.size() > 1 &&
                           logFont->CalcTextSizeA(logFont->LegacySize, FLT_MAX, 0.0f, (line + "…").c_str()).x > blockW)
                        line.pop_back();
                    line += "…";
                }
                dl->AddText(logFont, logFont->LegacySize,
                            ImVec2(wp.x + cx - blockW * 0.5f, curY), logCol, line.c_str());
                curY += logFont->LegacySize + 2.0f;
            }
            curY += 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);

        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), "Syncing %.1f%% — Block %d / %d",
                 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), "Last block: %d", 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_
            ? "Encrypting wallet..."
            : "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;
        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) {
        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 = "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) — show full diagnostic info
        const std::string& errDetail = daemon_controller_->lastError();
        if (!errDetail.empty()) {
            float wrapW = ws.x * 0.8f;
            if (wrapW > 700.0f) wrapW = 700.0f;
            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 = "Use Settings > Restart Daemon to try again";
            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;
        }
    }

    // -------------------------------------------------------------------
    // 4. Daemon output snippet (last few lines, if embedded)
    // -------------------------------------------------------------------
    if (daemon_controller_) {
        auto lines = daemon_controller_->recentLines(8);
        if (!lines.empty()) {
            curY += gap;

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

            ImFont* capFont = Type().caption();
            if (!capFont) capFont = ImGui::GetFont();
            float panelLineH = capFont->LegacySize + 4.0f;
            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()
{
    // 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 {
    return vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
}

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("Copied — clipboard auto-clears in 45s", 4.0f);
}

void App::pumpSecretClipboardClear()
{
    if (clipboard_clear_deadline_ <= 0.0) return;
    if (ImGui::GetTime() < clipboard_clear_deadline_) 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::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;
}
void App::restartDaemon()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
        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");
    });
}

} // namespace dragonx
