// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// app_network.cpp — RPC connection, data refresh, and network operations.
// Split from app.cpp for maintainability.
//
// Connection state machine:
//
//   [Disconnected]
//        │
//        ▼  tryConnect() every 5s
//   Auto-detect DRAGONX.conf (host, port, rpcuser, rpcpassword)
//        │
//        ├─ no config found ──► start embedded daemon ──► retry
//        │
//        ▼  post async rpc_->connect() to worker_
//   [Connecting]
//        │
//        ├─ success ──► onConnected()  ──► [Connected]
//        │                                     │
//        │                                     ▼  refreshData() every 5s
//        │                                  [Running]
//        │                                     │
//        │                                     ├─ RPC error ──► onDisconnected()
//        │                                     │                     │
//        ├─ auth 401 ──► .cookie auth ──► retry│                     ▼
//        │                                     │              [Disconnected]
//        └─ failure ──► onDisconnected(reason) ┘
//                       may restart daemon

#include "app.h"
#include "rpc/rpc_client.h"
#include "rpc/rpc_worker.h"
#include "rpc/connection.h"
#include "config/settings.h"
#include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
#include "daemon/xmrig_manager.h"
#include "ui/notifications.h"
#include "default_banlist_embedded.h"
#include "util/amount_format.h"
#include "util/platform.h"
#include "util/perf_log.h"
#include "util/i18n.h"
#include "util/secure_vault.h"

#include <nlohmann/json.hpp>
#include <curl/curl.h>
#include <algorithm>
#include <cmath>
#include <ctime>
#include <fstream>
#include <utility>

namespace dragonx {

using json = nlohmann::json;
using NetworkRefreshService = services::NetworkRefreshService;

namespace {

bool isPageEnabledForBuild(ui::NavPage page)
{
    return wallet::isUiSurfaceAvailable(
        wallet::currentWalletCapabilities(), ui::NavPageSurface(page));
}

std::string unencryptedTransactionHistoryCacheKey(const std::string& walletIdentity)
{
    return std::string("obsidian-dragon-unencrypted-tx-cache-v1:") +
           data::TransactionHistoryCache::walletIdentityHash(walletIdentity);
}

class AppRefreshRpcGateway final : public NetworkRefreshService::RefreshRpcGateway {
public:
    AppRefreshRpcGateway(rpc::RPCClient& rpc, std::string source)
        : rpc_(rpc), source_(std::move(source)) {}

    json call(const std::string& method, const json& params) override
    {
        rpc::RPCClient::TraceScope trace(source_);
        return rpc_.call(method, params);
    }

private:
    rpc::RPCClient& rpc_;
    std::string source_;
};

const char* tracePageName(ui::NavPage page)
{
    switch (page) {
        case ui::NavPage::Overview: return "Overview tab";
        case ui::NavPage::Send: return "Send tab";
        case ui::NavPage::Receive: return "Receive tab";
        case ui::NavPage::History: return "History tab";
        case ui::NavPage::Mining: return "Mining tab";
        case ui::NavPage::Market: return "Market tab";
        case ui::NavPage::Console: return "Console tab";
        case ui::NavPage::Peers: return "Network tab";
        case ui::NavPage::Explorer: return "Explorer tab";
        case ui::NavPage::Settings: return "Settings";
        case ui::NavPage::Count_: break;
    }
    return "App";
}

std::string traceSource(ui::NavPage page, const char* process)
{
    std::string source = tracePageName(page);
    if (process && process[0] != '\0') {
        source += " / ";
        source += process;
    }
    return source;
}

std::size_t shieldedReceiveScanBudget(ui::NavPage page)
{
    return page == ui::NavPage::History ? 8u : 4u;
}

} // namespace

// ============================================================================
// Warmup Message Translation
// Maps raw daemon RPC warmup messages to user-friendly text.
// ============================================================================

struct WarmupText {
    const char* title;
    const char* description;
};

static WarmupText translateWarmup(const std::string& raw)
{
    if (raw.find("Loading") != std::string::npos)
        return {"Loading blockchain data...",
                "Reading the block database from disk. This may take a few minutes after updates."};
    if (raw.find("Verifying") != std::string::npos)
        return {"Verifying blockchain...",
                "Checking recent blocks to make sure your chain data is valid."};
    if (raw.find("Activating") != std::string::npos)
        return {"Processing blocks...",
                "Applying blocks to build the current chain state."};
    if (raw.find("Rewinding") != std::string::npos)
        return {"Reorganizing chain...",
                "A chain reorganization was detected. Reverting to the correct chain."};
    if (raw.find("Rescanning") != std::string::npos)
        return {"Scanning for transactions...",
                "Searching the blockchain for transactions belonging to your wallet. This can take a while."};
    if (raw.find("Pruning") != std::string::npos)
        return {"Optimizing storage...",
                "Removing old block data to free up disk space."};
    // Fallback: use the raw message
    return {raw.c_str(), ""};
}

// ============================================================================
// Connection Management
// ============================================================================

void App::tryConnect()
{
    if (connection_in_progress_) return;
    
    static int connect_attempt = 0;
    ++connect_attempt;

    connection_in_progress_ = true;
    connection_status_ = TR("sb_loading_config");
    
    // Auto-detect configuration (file I/O — fast, safe on main thread)
    auto config = rpc::Connection::autoDetectConfig();
    
    if (config.rpcuser.empty() || config.rpcpassword.empty()) {
        connection_in_progress_ = false;
        std::string confPath = rpc::Connection::getDefaultConfPath();
        VERBOSE_LOGF("[connect #%d] No valid config — DRAGONX.conf missing or no rpcuser/rpcpassword (looked at: %s)\n",
                   connect_attempt, confPath.c_str());
        
        // If we already know an external daemon is on the port, just wait
        // for the config file to appear (the daemon creates it on first run).
        if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) {
            connection_status_ = TR("sb_waiting_config");
            VERBOSE_LOGF("[connect #%d] External daemon detected on port, waiting for config file to appear\n", connect_attempt);
            network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
                                        services::RefreshScheduler::kCoreDefault - 1.0f);
            return;
        }
        
        connection_status_ = TR("sb_no_conf");
        
        // Try to start embedded daemon if enabled
        if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
            connection_status_ = TR("sb_starting_daemon");
            if (startEmbeddedDaemon()) {
                // Will retry connection after daemon starts
                VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", connect_attempt);
                network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
                                            services::RefreshScheduler::kCoreDefault - 1.0f);
            } else if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) {
                connection_status_ = TR("sb_waiting_config");
                VERBOSE_LOGF("[connect #%d] External daemon detected but no config yet, will retry...\n", connect_attempt);
                network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
                                            services::RefreshScheduler::kCoreDefault - 1.0f);
            } else {
                VERBOSE_LOGF("[connect #%d] startEmbeddedDaemon() failed — lastError: %s, binary: %s\n",
                           connect_attempt,
                           daemon_controller_ ? daemon_controller_->lastError().c_str() : "(no daemon object)",
                           daemon::EmbeddedDaemon::findDaemonBinary().c_str());
            }
        } else if (!isUsingEmbeddedDaemon()) {
            VERBOSE_LOGF("[connect #%d] Embedded daemon disabled (using external). No config found at %s\n",
                       connect_attempt, confPath.c_str());
        }
        return;
    }
    
    connection_status_ = TR("sb_connecting_daemon");
    VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
               connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());

    if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) {
        remote_rpc_plaintext_warning_shown_ = true;
        ui::Notifications::instance().warning(
            "Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.",
            10.0f);
    }
    
    // Run the blocking rpc_->connect() on the worker thread so the UI
    // stays responsive (curl connect timeout can be up to 10 seconds).
    if (!worker_) {
        connection_in_progress_ = false;
        VERBOSE_LOGF("[connect #%d] No worker thread available!\n", connect_attempt);
        return;
    }
    
    // Capture daemon state before posting to worker
    bool daemonStarting = daemon_controller_ &&
        (daemon_controller_->state() == daemon::EmbeddedDaemon::State::Starting ||
         daemon_controller_->state() == daemon::EmbeddedDaemon::State::Running);
    bool externalDetected = daemon_controller_ && daemon_controller_->externalDaemonDetected();
    int attempt = connect_attempt;
    
    // Log detailed daemon state for diagnostics
    if (daemon_controller_) {
        const char* stateStr = "unknown";
        switch (daemon_controller_->state()) {
            case daemon::EmbeddedDaemon::State::Stopped:  stateStr = "Stopped";  break;
            case daemon::EmbeddedDaemon::State::Starting: stateStr = "Starting"; break;
            case daemon::EmbeddedDaemon::State::Running:  stateStr = "Running";  break;
            case daemon::EmbeddedDaemon::State::Stopping: stateStr = "Stopping"; break;
            case daemon::EmbeddedDaemon::State::Error:    stateStr = "Error";    break;
        }
        VERBOSE_LOGF("[connect #%d] Daemon state: %s, running: %s, external: %s, crashes: %d, lastErr: %s\n",
                   attempt, stateStr,
                   daemon_controller_->isRunning() ? "yes" : "no",
                   externalDetected ? "yes" : "no",
                   daemon_controller_->crashCount(),
                   daemon_controller_->lastError().empty() ? "(none)" : daemon_controller_->lastError().c_str());
    } else {
        VERBOSE_LOGF("[connect #%d] No embedded daemon object (use_embedded=%s)\n",
                   attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
    }
    
    worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
        bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
        std::string connectErr = rpc_->getLastConnectError();
        bool warmingUp = rpc_->isWarmingUp();
        std::string warmupStatus = rpc_->getWarmupStatus();
        
        return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr]() {
            if (connected) {
                VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
                           warmingUp ? " (daemon warming up)" : "");
                saved_config_ = config;  // save for fast-lane connection
                onConnected();
                if (warmingUp) {
                    // Daemon is reachable and auth works, but RPC calls will
                    // fail until warmup completes.  Set the warmup state so
                    // the UI shows status instead of a blocking overlay.
                    state_.warming_up = true;
                    auto wt = translateWarmup(warmupStatus);
                    state_.warmup_status = wt.title;
                    state_.warmup_description = wt.description;
                    // Append current block height from daemon output
                    if (daemon_controller_) {
                        int h = daemon_controller_->lastBlockHeight();
                        if (h > 0)
                            state_.warmup_status += " (Block " + std::to_string(h) + ")";
                    }
                    connection_status_ = state_.warmup_status;
                }
            } else {
                // HTTP 401 = authentication failure.  The daemon is running
                // but our rpcuser/rpcpassword don't match.  Don't retry
                // endlessly — tell the user what's wrong.
                bool authFailure = (connectErr.find("401") != std::string::npos);
                if (authFailure) {
                    rpc::ConnectionConfig cookieConfig;
                    if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
                        VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
                                   attempt, cookieConfig.hush_dir.c_str());
                        worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb {
                            bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
                                                    cookieConfig.rpcuser, cookieConfig.rpcpassword,
                                                    cookieConfig.use_tls);
                            return [this, cookieConfig, ok, attempt]() {
                                connection_in_progress_ = false;
                                if (ok) {
                                    VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
                                    saved_config_ = cookieConfig;
                                    onConnected();
                                } else {
                                    state_.connected = false;
                                    connection_status_ = TR("sb_auth_failed");
                                    VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt);
                                    ui::Notifications::instance().error(
                                        "RPC authentication failed (HTTP 401). "
                                        "The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. "
                                        "Restart the daemon or correct the credentials.");
                                }
                            };
                        });
                        return;  // async retry in progress
                    }
                    state_.connected = false;
                    std::string confPath = rpc::Connection::getDefaultConfPath();
                    connection_status_ = TR("sb_auth_failed");
                    VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. "
                               "Edit the file or restart the daemon to regenerate credentials.\n",
                               attempt, confPath.c_str());
                    ui::Notifications::instance().error(
                        "RPC authentication failed (HTTP 401). "
                        "The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. "
                        "Restart the daemon or correct the credentials.");
                } else if (daemonStarting) {
                    state_.connected = false;
                    // Show the actual RPC error alongside the waiting message so
                    // auth mismatches and timeouts aren't silently hidden.
                    if (!connectErr.empty()) {
                        char buf[256]; snprintf(buf, sizeof(buf), TR("sb_waiting_daemon_err"), connectErr.c_str());
                        connection_status_ = buf;
                    } else {
                        connection_status_ = TR("sb_waiting_daemon");
                    }
                    VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, will retry...\n",
                               attempt, connectErr.c_str());
                    network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
                                                services::RefreshScheduler::kCoreDefault - 1.0f);
                } else if (externalDetected) {
                    state_.connected = false;
                    if (!connectErr.empty()) {
                        char buf[256]; snprintf(buf, sizeof(buf), TR("sb_connecting_err"), connectErr.c_str());
                        connection_status_ = buf;
                    } else {
                        connection_status_ = TR("sb_connecting_external");
                    }
                    VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), will retry...\n",
                               attempt, connectErr.c_str());
                    network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
                                                services::RefreshScheduler::kCoreDefault - 1.0f);
                } else {
                    onDisconnected("Connection failed");
                    VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
                    
                    if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
                        // Prevent infinite crash-restart loop
                        if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
                            { char buf[128]; snprintf(buf, sizeof(buf), TR("sb_daemon_crashed"), daemon_controller_->crashCount());
                            connection_status_ = buf; }
                            VERBOSE_LOGF("[connect #%d] Daemon crashed %d times — not restarting (use Settings > Restart Daemon to retry)\n",
                                   attempt, daemon_controller_->crashCount());
                        } else {
                            connection_status_ = TR("sb_starting_daemon");
                            if (startEmbeddedDaemon()) {
                                VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", attempt);
                            } else if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) {
                                connection_status_ = TR("sb_connecting_generic");
                                VERBOSE_LOGF("[connect #%d] External daemon detected, will connect via RPC...\n", attempt);
                            } else {
                                VERBOSE_LOGF("[connect #%d] Failed to start embedded daemon — lastError: %s\n",
                                           attempt,
                                           daemon_controller_ ? daemon_controller_->lastError().c_str() : "(no daemon object)");
                            }
                        }
                    } else if (!isUsingEmbeddedDaemon()) {
                        VERBOSE_LOGF("[connect #%d] Embedded daemon disabled — external daemon at %s:%s not responding\n",
                                   attempt, config.host.c_str(), config.port.c_str());
                    } else {
                        VERBOSE_LOGF("[connect #%d] Embedded daemon is running but RPC failed — daemon may be initializing\n", attempt);
                    }
                }
            }
            connection_in_progress_ = false;
        };
    });
}

void App::onConnected()
{
    state_.connected = true;
    connection_status_ = TR("connected");
    
    // Reset crash counter on successful connection
    if (daemon_controller_) {
        daemon_controller_->resetCrashCount();
    }
    
    // Get daemon info + wallet encryption state on the worker thread.
    // Fetching getwalletinfo here (before refreshData) ensures the lock
    // screen appears immediately instead of after 6+ queued RPC calls.
    bool initialPrefetchQueued = false;
    if (worker_ && rpc_) {
        auto prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
        auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this, prefetchedInfo]() -> rpc::RPCWorker::MainCb {
            AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Connection init");
            auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc, prefetchedInfo);
            return [this, result]() {
                NetworkRefreshService::applyConnectionInitResult(state_, result);
                if (state_.isLocked()) {
                    resetTransactionHistoryCacheSession();
                } else if (state_.transactions.empty()) {
                    loadTransactionHistoryCacheIfAvailable();
                } else {
                    storeTransactionHistoryCacheIfAvailable();
                }
            };
        }, 3);
        initialPrefetchQueued = enqueued.enqueued;
    }
    
    // onConnected already fetched getwalletinfo — tell refreshData to skip
    // the duplicate call on the very first cycle.
    encryption_state_prefetched_ = initialPrefetchQueued;

    // Addresses are unknown on fresh connect — force a fetch
    addresses_dirty_ = true;

    // Start the fast-lane RPC connection (dedicated to 1-second mining polls).
    // Uses its own curl handle + worker thread so getlocalsolps never blocks
    // behind the main refresh batch.
    if (!fast_rpc_) {
        fast_rpc_ = std::make_unique<rpc::RPCClient>();
    }
    if (!fast_worker_) {
        fast_worker_ = std::make_unique<rpc::RPCWorker>();
        fast_worker_->start();
    }
    // Connect on the fast worker's own thread (non-blocking to main)
    fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
        bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
                                     saved_config_.rpcuser, saved_config_.rpcpassword,
                                     saved_config_.use_tls);
        return [ok]() {
            if (!ok) {
                DEBUG_LOGF("[FastLane] Failed to connect secondary RPC client\\n");
            } else {
                DEBUG_LOGF("[FastLane] Secondary RPC client connected\\n");
            }
        };
    });

    // Initial data refresh
    refreshData();
    refreshMarketData();

    // Apply compiled-in default ban list
    applyDefaultBanlist();
}

void App::onDisconnected(const std::string& reason)
{
    state_.connected = false;
    state_.warming_up = false;
    state_.warmup_status.clear();
    state_.clear();
    connection_status_ = reason;

    // Clear RPC result caches
    viewtx_cache_.clear();
    confirmed_tx_cache_.clear();
    confirmed_tx_ids_.clear();
    confirmed_cache_block_ = -1;
    last_tx_block_height_ = -1;
    pending_opids_.clear();
    pending_send_info_.clear();
    send_progress_active_ = false;
    send_submissions_in_flight_ = 0;
    network_refresh_.resetJobs();
    rescan_status_poll_in_progress_ = false;
    opid_poll_in_progress_ = false;
    address_validation_cache_dirty_ = true;
    resetTransactionHistoryCacheSession();

    // Tear down the fast-lane connection
    if (fast_worker_) {
        fast_worker_->stop();
    }
    if (fast_rpc_) {
        fast_rpc_->disconnect();
    }
}

// ============================================================================
// Data Refresh — Tab-Aware Prioritized System
//
// Data is split into independent categories, each with its own refresh
// function, timer, and in-progress guard.  The orchestrator (refreshData)
// dispatches all categories, but each can also be called independently
// (e.g. on tab switch for immediate refresh).
//
// Categories:
//   Core         — z_gettotalbalance + getblockchaininfo (balance, sync)
//   Addresses    — z_listaddresses + listunspent (address list, per-addr balances)
//   Transactions — listtransactions + z_listreceivedbyaddress + z_viewtransaction
//   Peers        — getpeerinfo + listbanned (already standalone)
//   Encryption   — getwalletinfo (one-shot on connect)
//
// Intervals are adjusted by applyRefreshPolicy() based on the active tab,
// so the user sees faster updates for the data they're interacting with.
// ============================================================================

App::RefreshIntervals App::getIntervalsForPage(ui::NavPage page)
{
    return services::NetworkRefreshService::intervalsForPage(page);
}

void App::applyRefreshPolicy(ui::NavPage page)
{
    network_refresh_.setIntervals(getIntervalsForPage(page));
}

bool App::currentPageNeedsWalletDataRefresh() const
{
    using NP = ui::NavPage;
    return current_page_ == NP::Overview ||
           current_page_ == NP::Send ||
           current_page_ == NP::Receive ||
           current_page_ == NP::History;
}

bool App::shouldRunWalletTransactionRefresh() const
{
    if (currentPageNeedsWalletDataRefresh()) return true;
    if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
    return transactions_dirty_ && !shielded_history_scan_pending_;
}

void App::setCurrentPage(ui::NavPage page)
{
    if (!isPageEnabledForBuild(page)) {
        page = ui::NavPage::Overview;
    }
    if (page == current_page_) return;
    current_page_ = page;
    applyRefreshPolicy(page);
    using RefreshTimer = services::NetworkRefreshService::Timer;

    // Immediate refresh for the incoming tab's priority data
    if (state_.connected && !state_.isLocked()) {
        using NP = ui::NavPage;
        switch (page) {
            case NP::Overview:
                refreshCoreData();
                network_refresh_.reset(RefreshTimer::Core);
                break;
            case NP::History:
                transactions_dirty_ = true;
                refreshTransactionData();
                network_refresh_.reset(RefreshTimer::Transactions);
                break;
            case NP::Send:
            case NP::Receive:
                addresses_dirty_ = true;
                refreshAddressData();
                network_refresh_.reset(RefreshTimer::Addresses);
                break;
            case NP::Peers:
                refreshPeerInfo();
                network_refresh_.reset(RefreshTimer::Peers);
                break;
            case NP::Mining:
                refreshMiningInfo();
                break;
            default:
                break;
        }
    }
}

bool App::shouldRefreshTransactions() const
{
    const int currentBlocks = state_.sync.blocks;
    return network_refresh_.shouldRefreshTransactions(last_tx_block_height_,
                                                        currentBlocks,
                                                        transactions_dirty_);
}

bool App::shouldRefreshRecentTransactions() const
{
    using RefreshTimer = services::NetworkRefreshService::Timer;
    return network_refresh_.isDue(RefreshTimer::TxAge)
        && last_tx_block_height_ >= 0
        && state_.sync.blocks == last_tx_block_height_
        && !state_.transactions.empty()
        && !transactions_dirty_
        && !addresses_dirty_;
}

void App::upsertPendingSendTransaction(const std::string& opid,
                                       const std::string& from,
                                       const std::string& to,
                                       double amount,
                                       const std::string& memo)
{
    if (opid.empty()) return;

    bool newPending = pending_send_info_.find(opid) == pending_send_info_.end();
    auto& pendingInfo = pending_send_info_[opid];
    if (pendingInfo.timestamp == 0) pendingInfo.timestamp = static_cast<std::int64_t>(std::time(nullptr));
    pendingInfo.from = from;
    pendingInfo.to = to;
    pendingInfo.amount = std::abs(amount);
    pendingInfo.memo = memo;

    TransactionInfo pending;
    pending.txid = opid;
    pending.type = "send";
    pending.amount = -pendingInfo.amount;
    pending.timestamp = pendingInfo.timestamp;
    pending.confirmations = 0;
    pending.address = pendingInfo.to;
    pending.from_address = pendingInfo.from;
    pending.memo = pendingInfo.memo;

    auto existing = std::find_if(state_.transactions.begin(), state_.transactions.end(),
        [&](const TransactionInfo& transaction) { return transaction.txid == opid; });
    if (existing != state_.transactions.end()) {
        *existing = std::move(pending);
    } else {
        state_.transactions.insert(state_.transactions.begin(), std::move(pending));
    }
    if (newPending) {
        auto applyDelta = [&](std::vector<AddressInfo>& addresses) {
            for (auto& address : addresses) {
                if (address.address == pendingInfo.from) {
                    address.balance = std::max(0.0, address.balance - pendingInfo.amount);
                    return true;
                }
            }
            return false;
        };
        if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses);
        if (!pendingInfo.from.empty() && pendingInfo.from[0] == 'z') {
            state_.privateBalance = std::max(0.0, state_.privateBalance - pendingInfo.amount);
        } else {
            state_.transparentBalance = std::max(0.0, state_.transparentBalance - pendingInfo.amount);
        }
        state_.totalBalance = std::max(0.0, state_.totalBalance - pendingInfo.amount);
    }
    state_.last_tx_update = std::time(nullptr);
}

void App::markPendingSendTransactionSucceeded(const std::string& opid,
                                              const std::string& txid)
{
    if (opid.empty() || txid.empty()) return;

    auto pending = std::find_if(state_.transactions.begin(), state_.transactions.end(),
        [&](const TransactionInfo& transaction) { return transaction.txid == opid; });
    if (pending == state_.transactions.end()) return;

    bool duplicateRealTx = std::any_of(state_.transactions.begin(), state_.transactions.end(),
        [&](const TransactionInfo& transaction) { return transaction.txid == txid; });
    if (duplicateRealTx) {
        state_.transactions.erase(pending);
    } else {
        pending->txid = txid;
        pending->confirmations = 0;
        if (pending->timestamp == 0) pending->timestamp = static_cast<std::int64_t>(std::time(nullptr));
    }
    state_.last_tx_update = std::time(nullptr);
}

void App::removePendingSendTransactions(const std::vector<std::string>& opids,
                                        bool restoreBalances)
{
    if (opids.empty()) return;
    std::unordered_set<std::string> opidSet(opids.begin(), opids.end());
    if (restoreBalances) {
        for (const auto& opid : opidSet) {
            auto pending = pending_send_info_.find(opid);
            if (pending == pending_send_info_.end()) continue;
            auto restoreBalance = [&](std::vector<AddressInfo>& addresses) {
                for (auto& address : addresses) {
                    if (address.address == pending->second.from) {
                        address.balance += pending->second.amount;
                        return true;
                    }
                }
                return false;
            };
            if (!restoreBalance(state_.z_addresses)) restoreBalance(state_.t_addresses);
            if (!pending->second.from.empty() && pending->second.from[0] == 'z') {
                state_.privateBalance += pending->second.amount;
            } else {
                state_.transparentBalance += pending->second.amount;
            }
            state_.totalBalance += pending->second.amount;
        }
    }
    state_.transactions.erase(
        std::remove_if(state_.transactions.begin(), state_.transactions.end(),
            [&](const TransactionInfo& transaction) {
                return opidSet.find(transaction.txid) != opidSet.end();
            }),
        state_.transactions.end());
    for (const auto& opid : opidSet) pending_send_info_.erase(opid);
    state_.last_tx_update = std::time(nullptr);
}

void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances)
{
    for (const auto& [opid, pending] : pending_send_info_) {
        (void)opid;
        auto applyDelta = [&](std::vector<AddressInfo>& addresses) {
            for (auto& address : addresses) {
                if (address.address == pending.from) {
                    address.balance = std::max(0.0, address.balance - pending.amount);
                    return true;
                }
            }
            return false;
        };
        if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses);
        if (includeAggregateBalances) {
            if (!pending.from.empty() && pending.from[0] == 'z') {
                state_.privateBalance = std::max(0.0, state_.privateBalance - pending.amount);
            } else {
                state_.transparentBalance = std::max(0.0, state_.transparentBalance - pending.amount);
            }
            state_.totalBalance = std::max(0.0, state_.totalBalance - pending.amount);
        }
    }
}

std::string App::transactionHistoryCacheWalletIdentity() const
{
    std::vector<std::string> shieldedAddresses;
    std::vector<std::string> transparentAddresses;
    shieldedAddresses.reserve(state_.z_addresses.size());
    transparentAddresses.reserve(state_.t_addresses.size());
    for (const auto& address : state_.z_addresses) {
        if (!address.address.empty()) shieldedAddresses.push_back(address.address);
    }
    for (const auto& address : state_.t_addresses) {
        if (!address.address.empty()) transparentAddresses.push_back(address.address);
    }
    return data::TransactionHistoryCache::walletIdentityFromAddresses(
        shieldedAddresses, transparentAddresses);
}

void App::wipePendingTransactionHistoryCachePassphrase()
{
    if (!pending_transaction_history_cache_passphrase_.empty()) {
        util::SecureVault::secureZero(pending_transaction_history_cache_passphrase_.data(),
                                      pending_transaction_history_cache_passphrase_.size());
        pending_transaction_history_cache_passphrase_.clear();
    }
}

void App::resetTransactionHistoryCacheSession()
{
    transaction_history_cache_.lockKey();
    wipePendingTransactionHistoryCachePassphrase();
    transaction_history_cache_loaded_ = false;
    invalidateShieldedHistoryScanProgress(false);
}

void App::pruneShieldedHistoryScanProgress()
{
    std::unordered_set<std::string> currentShieldedAddresses;
    currentShieldedAddresses.reserve(state_.z_addresses.size());
    for (const auto& address : state_.z_addresses) {
        if (!address.address.empty()) currentShieldedAddresses.insert(address.address);
    }

    for (auto it = shielded_history_scan_heights_.begin(); it != shielded_history_scan_heights_.end();) {
        if (currentShieldedAddresses.find(it->first) == currentShieldedAddresses.end()) {
            it = shielded_history_scan_heights_.erase(it);
        } else {
            ++it;
        }
    }
}

void App::invalidateShieldedHistoryScanProgress(bool persistCache)
{
    shielded_history_scan_cursor_ = 0;
    shielded_history_scan_pending_ = false;
    shielded_history_scan_heights_.clear();
    if (persistCache) storeTransactionHistoryCacheIfAvailable();
}

bool App::ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity)
{
    if (walletIdentity.empty()) return false;
    if (transaction_history_cache_.isUnlockedFor(walletIdentity)) return true;

    if (!pending_transaction_history_cache_passphrase_.empty()) {
        std::string passphrase = pending_transaction_history_cache_passphrase_;
        bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase);
        if (unlocked) wipePendingTransactionHistoryCachePassphrase();
        util::SecureVault::secureZero(passphrase.data(), passphrase.size());
        if (unlocked) return true;
    }

    if (state_.encryption_state_known && !state_.encrypted) {
        std::string cacheKey = unencryptedTransactionHistoryCacheKey(walletIdentity);
        bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, cacheKey);
        util::SecureVault::secureZero(cacheKey.data(), cacheKey.size());
        return unlocked;
    }

    return false;
}

void App::unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase)
{
    if (passphrase.empty()) return;

    std::string walletIdentity = transactionHistoryCacheWalletIdentity();
    if (walletIdentity.empty()) {
        wipePendingTransactionHistoryCachePassphrase();
        pending_transaction_history_cache_passphrase_ = passphrase;
        return;
    }

    if (transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase)) {
        wipePendingTransactionHistoryCachePassphrase();
        if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
        else storeTransactionHistoryCacheIfAvailable();
    }
}

void App::loadTransactionHistoryCacheIfAvailable()
{
    if (transaction_history_cache_loaded_ || !state_.transactions.empty()) return;

    std::string walletIdentity = transactionHistoryCacheWalletIdentity();
    if (walletIdentity.empty()) return;

    if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;

    auto loaded = transaction_history_cache_.load(walletIdentity,
                                                  state_.sync.blocks,
                                                  state_.sync.best_blockhash);
    if (!loaded.loaded) return;

    state_.transactions = std::move(loaded.transactions);
    shielded_history_scan_heights_ = std::move(loaded.shieldedScanHeights);
    pruneShieldedHistoryScanProgress();
    state_.last_tx_update = loaded.updatedAt;
    last_tx_block_height_ = loaded.tipHeight;
    confirmed_tx_cache_.clear();
    confirmed_tx_ids_.clear();
    for (const auto& transaction : state_.transactions) {
        if (transaction.confirmations >= 10 && transaction.timestamp != 0) {
            confirmed_tx_ids_.insert(transaction.txid);
            confirmed_tx_cache_.push_back(transaction);
        }
    }
    confirmed_cache_block_ = loaded.tipHeight;
    transaction_history_cache_loaded_ = true;
    transactions_dirty_ = true;
    network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
}

void App::storeTransactionHistoryCacheIfAvailable()
{
    if (state_.transactions.empty()) return;

    std::string walletIdentity = transactionHistoryCacheWalletIdentity();
    if (walletIdentity.empty()) return;

    if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
    pruneShieldedHistoryScanProgress();

    std::unordered_set<std::string> pendingOpids(pending_opids_.begin(), pending_opids_.end());
    std::vector<TransactionInfo> cacheTransactions;
    cacheTransactions.reserve(state_.transactions.size());
    for (const auto& transaction : state_.transactions) {
        if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue;
        cacheTransactions.push_back(transaction);
    }
    if (cacheTransactions.empty()) return;

    std::time_t updatedAt = state_.last_tx_update != 0
        ? static_cast<std::time_t>(state_.last_tx_update)
        : std::time(nullptr);
    transaction_history_cache_.replace(walletIdentity,
                                       state_.sync.blocks,
                                       state_.sync.best_blockhash,
                                       cacheTransactions,
                                       updatedAt,
                                       shielded_history_scan_heights_);
}

void App::refreshData()
{
    if (!state_.connected || !rpc_ || !worker_) return;

    // During warmup, only poll for warmup completion via refreshCoreData.
    // Other RPC calls (balance, addresses, transactions) will fail with -28.
    if (state_.warming_up) {
        refreshCoreData();
        return;
    }

    // Dispatch each category independently — results trickle into the UI
    // as each completes, rather than waiting for the slowest phase.
    refreshCoreData();

    bool addressRefreshNeeded = addresses_dirty_;
    bool walletDataPage = currentPageNeedsWalletDataRefresh();
    if (addressRefreshNeeded)
        refreshAddressData();

    if (!addressRefreshNeeded && shouldRunWalletTransactionRefresh() && shouldRefreshTransactions())
        refreshTransactionData();
    else if (!addressRefreshNeeded && walletDataPage && shouldRefreshRecentTransactions())
        refreshRecentTransactionData();

    if (current_page_ == ui::NavPage::Peers)
        refreshPeerInfo();

    if (!state_.encryption_state_known &&
        !network_refresh_.jobInProgress(services::NetworkRefreshService::Job::ConnectionInit)) {
        encryption_state_prefetched_ = refreshEncryptionState();
    }
}

// ============================================================================
// Core Data: balance + blockchain info (~50-100ms, 2 RPC calls)
// Uses fast_worker_ when on Overview tab for lower latency.
// ============================================================================

void App::refreshCoreData()
{
    if (!state_.connected) return;

    // During warmup, poll getinfo to detect when warmup ends.
    // Most RPC calls (balance, blockchain info) will fail with -28 during warmup.
    if (state_.warming_up) {
        if (!worker_) return;
        auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *worker_, [this]() -> rpc::RPCWorker::MainCb {
            AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Warmup poll");
            auto result = NetworkRefreshService::collectWarmupPollResult(refreshRpc);
            return [this, result = std::move(result)]() {
                if (result.ready) {
                    // Warmup finished — daemon is fully ready
                    state_.warming_up = false;
                    state_.warmup_status.clear();
                    state_.warmup_description.clear();
                    connection_status_ = TR("connected");
                    VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
                    NetworkRefreshService::applyConnectionInfoResult(state_, result.info);
                    // Trigger full data refresh now that daemon is ready
                    refreshData();
                } else {
                    // Still warming up — update status
                    auto wt = translateWarmup(result.errorMessage);
                    state_.warmup_status = wt.title;
                    state_.warmup_description = wt.description;
                    if (daemon_controller_) {
                        int h = daemon_controller_->lastBlockHeight();
                        if (h > 0)
                            state_.warmup_status += " (Block " + std::to_string(h) + ")";
                    }
                    connection_status_ = state_.warmup_status;
                    VERBOSE_LOGF("[warmup] Still warming up: %s\n", result.errorMessage.c_str());
                }
            };
        }, 3);
        if (!enqueued.enqueued) return;
        return;
    }

    // Use fast-lane on Overview for snappier balance updates
    bool useFast = (current_page_ == ui::NavPage::Overview);
    auto* w   = useFast && fast_worker_ && fast_worker_->isRunning()
                ? fast_worker_.get() : worker_.get();
    auto* rpc = useFast && fast_rpc_ && fast_rpc_->isConnected()
                ? fast_rpc_.get() : rpc_.get();
    if (!w || !rpc) return;
    ui::NavPage tracePage = current_page_;

    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage]() -> rpc::RPCWorker::MainCb {
        AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
        auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc);
        return [this, result]() {
            try {
                NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
                applyPendingSendBalanceDeltas(true);
                // Auto-shield transparent funds if enabled
                if (result.balanceOk && settings_ && settings_->getAutoShield() &&
                    state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
                    !auto_shield_pending_.exchange(true)) {
                    std::string targetZAddr;
                    for (const auto& addr : state_.addresses) {
                        if (addr.isShielded()) {
                            targetZAddr = addr.address;
                            break;
                        }
                    }
                    if (!targetZAddr.empty() && rpc_) {
                        DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
                               state_.transparent_balance, targetZAddr.c_str());
                        rpc_->z_shieldCoinbase("*", targetZAddr, 0.0001, 50,
                            [this](const json& result) {
                                if (result.contains("opid")) {
                                    DEBUG_LOGF("[AutoShield] Started: %s\n",
                                           result["opid"].get<std::string>().c_str());
                                }
                                auto_shield_pending_ = false;
                            },
                            [this](const std::string& err) {
                                DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str());
                                auto_shield_pending_ = false;
                            });
                    } else {
                        auto_shield_pending_ = false;
                    }
                }
            } catch (const std::exception& e) {
                DEBUG_LOGF("[refreshCoreData] callback error: %s\n", e.what());
            }
        };
    }, 3);
    if (!enqueued.enqueued) return;
}

// ============================================================================
// Address Data: z/t address lists + per-address balances
// ============================================================================

void App::refreshAddressData()
{
    if (!worker_ || !rpc_ || !state_.connected) return;
    const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
    const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity();
    auto addressSnapshot = address_validation_cache_dirty_
        ? NetworkRefreshService::AddressRefreshSnapshot{}
        : NetworkRefreshService::buildAddressRefreshSnapshot(state_);
    ui::NavPage tracePage = current_page_;
    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, addressSnapshot = std::move(addressSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
        AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh"));
        auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);

        return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
            NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
            applyPendingSendBalanceDeltas(false);
            address_validation_cache_dirty_ = false;
            address_list_dirty_ = true;
            addresses_dirty_ = false;
            const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
            const bool addressSetChanged = currentAddressCount != previousAddressCount ||
                transactionHistoryCacheWalletIdentity() != previousWalletIdentity;
            if (state_.transactions.empty() || addressSetChanged) {
                if (addressSetChanged) {
                    invalidateShieldedHistoryScanProgress(false);
                }
                transactions_dirty_ = true;
                last_tx_block_height_ = -1;
                network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
            }
            if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
            else storeTransactionHistoryCacheIfAvailable();
            maybeFinishTransactionSendProgress();
        };
    }, 3);
    if (!enqueued.enqueued) return;
}

// ============================================================================
// Transaction Data: transparent + shielded receives + z_viewtransaction enrichment
// ============================================================================

void App::refreshTransactionData()
{
    if (!worker_ || !rpc_ || !state_.connected) return;
    if (addresses_dirty_) {
        refreshAddressData();
        network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
        return;
    }

    const int currentBlocks = state_.sync.blocks;
    if (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ ||
        !shielded_history_scan_pending_) {
        shielded_history_scan_cursor_ = 0;
        shielded_history_scan_pending_ = false;
    }
    auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
        state_, viewtx_cache_, send_txids_);
    transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
    if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
    transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
    transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
    transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
    ui::NavPage tracePage = current_page_;

    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
                   transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
        AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Transaction refresh"));
        auto result = NetworkRefreshService::collectTransactionRefreshResult(
            refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE);

        return [this, result = std::move(result)]() mutable {
            bool shieldedScanComplete = result.shieldedScanComplete;
            std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
            auto shieldedScanHeights = std::move(result.shieldedScanHeights);
            NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
                viewtx_cache_,
                send_txids_,
                confirmed_tx_cache_,
                confirmed_tx_ids_,
                confirmed_cache_block_,
                last_tx_block_height_
            };
            NetworkRefreshService::applyTransactionRefreshResult(
                state_, cacheUpdate, std::move(result), std::time(nullptr));
            shielded_history_scan_heights_ = std::move(shieldedScanHeights);
            storeTransactionHistoryCacheIfAvailable();
            shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
            shielded_history_scan_pending_ = !shieldedScanComplete;
            transactions_dirty_ = !shieldedScanComplete;
            maybeFinishTransactionSendProgress();
        };
    }, 3);
    if (!enqueued.enqueued) return;

    network_refresh_.resetTxAge();
}

void App::refreshRecentTransactionData()
{
    if (!worker_ || !rpc_ || !state_.connected) return;
    if (!shouldRefreshRecentTransactions()) return;

    const int currentBlocks = state_.sync.blocks;
    auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
        state_, viewtx_cache_, send_txids_);
    transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
    if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
    transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
    transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
    transactionSnapshot.maxShieldedReceiveScans = 1;
    ui::NavPage tracePage = current_page_;

    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
                   transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
        AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll"));
        auto result = NetworkRefreshService::collectRecentTransactionRefreshResult(
            refreshRpc, transactionSnapshot, currentBlocks);

        return [this, result = std::move(result)]() mutable {
            std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
            auto shieldedScanHeights = std::move(result.shieldedScanHeights);
            NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
                viewtx_cache_,
                send_txids_,
                confirmed_tx_cache_,
                confirmed_tx_ids_,
                confirmed_cache_block_,
                last_tx_block_height_
            };
            NetworkRefreshService::applyTransactionRefreshResult(
                state_, cacheUpdate, std::move(result), std::time(nullptr));
            shielded_history_scan_heights_ = std::move(shieldedScanHeights);
            shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
            storeTransactionHistoryCacheIfAvailable();
            maybeFinishTransactionSendProgress();
        };
    }, 3);
    if (!enqueued.enqueued) return;

    network_refresh_.resetTxAge();
}

// ============================================================================
// Encryption State: wallet info (one-shot on connect, lightweight)
// ============================================================================

bool App::refreshEncryptionState()
{
    if (!worker_ || !rpc_ || !state_.connected) return false;

    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Encryption, *worker_, [this]() -> rpc::RPCWorker::MainCb {
        json walletInfo;
        bool ok = false;
        try {
            rpc::RPCClient::TraceScope trace("Startup / Wallet encryption state");
            walletInfo = rpc_->call("getwalletinfo");
            ok = true;
        } catch (...) {}

        if (!ok) return nullptr;

        auto result = NetworkRefreshService::parseWalletEncryptionResult(walletInfo);
        return [this, result]() {
            NetworkRefreshService::applyWalletEncryptionResult(state_, result);
            if (state_.isLocked()) {
                resetTransactionHistoryCacheSession();
            } else if (state_.transactions.empty()) {
                loadTransactionHistoryCacheIfAvailable();
            } else {
                storeTransactionHistoryCacheIfAvailable();
            }
        };
    }, 3);
    return enqueued.enqueued;
}

void App::refreshBalance()
{
    refreshCoreData();
}

void App::refreshAddresses()
{
    addresses_dirty_ = true;
    refreshAddressData();
}

void App::refreshMiningInfo()
{
    // Use the dedicated fast-lane worker + connection so mining polls
    // never block behind the main refresh batch.  Falls back to the main
    // worker if the fast lane isn't ready yet (e.g. during initial connect).
    auto* w   = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
    auto* rpc = (fast_rpc_ && fast_rpc_->isConnected())     ? fast_rpc_.get()    : rpc_.get();
    if (!w || !rpc) return;

    // Capture daemon memory outside (may be accessed on main thread)
    double daemonMemMb = 0.0;
    if (daemon_controller_) {
        daemonMemMb = daemon_controller_->memoryUsageMB();
    }
    
    // Slow-tick counter: run full getmininginfo every ~5 seconds
    // to reduce RPC overhead. getlocalsolps is only needed while solo mining
    // or while the Mining tab is actively showing live local hashrate.
    // NOTE: getinfo is NOT called here — longestchain/notarized are updated by
    // refreshBalance (via getblockchaininfo), and daemon_version/protocol_version/
    // p2p_port are static for the lifetime of a connection (set in onConnected).
    bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0);
    bool includeLocalHashrate = state_.mining.generate || current_page_ == ui::NavPage::Mining;
    if (!includeLocalHashrate && !doSlowRefresh) return;
    ui::NavPage tracePage = current_page_;
    
    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
        [this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
            AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Mining refresh"));
            auto result = NetworkRefreshService::collectMiningRefreshResult(
                refreshRpc, daemonMemMb, doSlowRefresh, includeLocalHashrate);
            return [this, result]() {
                try {
                    NetworkRefreshService::applyMiningRefreshResult(state_, result, std::time(nullptr));
                } catch (const std::exception& e) {
                    DEBUG_LOGF("[refreshMiningInfo] callback error: %s\n", e.what());
                }
            };
        }, 2);
    if (!enqueued.enqueued) return;
}

void App::refreshPeerInfo()
{
    if (!rpc_) return;

    // Use fast-lane worker to bypass head-of-line blocking behind refreshData.
    auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
    auto* r = (fast_rpc_ && fast_rpc_->isConnected())     ? fast_rpc_.get()    : rpc_.get();
    if (!w) return;
    ui::NavPage tracePage = current_page_;

    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r, tracePage]() -> rpc::RPCWorker::MainCb {
        AppRefreshRpcGateway refreshRpc(*r, traceSource(tracePage, "Peer refresh"));
        auto result = NetworkRefreshService::collectPeerRefreshResult(refreshRpc);
        return [this, result = std::move(result)]() mutable {
            NetworkRefreshService::applyPeerRefreshResult(state_, std::move(result), std::time(nullptr));
        };
    }, 2);
    if (!enqueued.enqueued) return;
}

void App::refreshPrice()
{
    // Skip if price fetching is disabled
    if (!settings_->getFetchPrices()) return;
    if (!worker_) return;
    
    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Price, *worker_, [this]() -> rpc::RPCWorker::MainCb {
        // --- Worker thread: blocking HTTP GET to CoinGecko ---
        NetworkRefreshService::PriceHttpResult result;
        
        try {
            CURL* curl = curl_easy_init();
            if (!curl) {
                DEBUG_LOGF("Failed to initialize curl for price fetch\n");
                result.errorMessage = "Price fetch failed: failed to initialize curl";
            } else {
                std::string response_data;
                const char* url = "https://api.coingecko.com/api/v3/simple/price?ids=dragonx-2&vs_currencies=usd,btc&include_24hr_change=true&include_24hr_vol=true&include_market_cap=true";

                auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
                    size_t totalSize = size * nmemb;
                    userp->append((char*)contents, totalSize);
                    return totalSize;
                };

                curl_easy_setopt(curl, CURLOPT_URL, url);
                curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
                curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_data);
                curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
                curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
                curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
                curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");

                CURLcode res = curl_easy_perform(curl);
                long http_code = 0;
                curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
                curl_easy_cleanup(curl);

                NetworkRefreshService::PriceHttpResponse response{
                    res == CURLE_OK,
                    http_code,
                    std::move(response_data),
                    res == CURLE_OK ? std::string() : std::string(curl_easy_strerror(res))
                };
                result = NetworkRefreshService::parsePriceHttpResponse(response, std::time(nullptr));
            }
        } catch (const std::exception& e) {
            DEBUG_LOGF("Price fetch error: %s\n", e.what());
            result.errorMessage = std::string("Price fetch error: ") + e.what();
        } catch (...) {
            DEBUG_LOGF("Price fetch error: unknown exception\n");
            result.errorMessage = "Price fetch error: unknown exception";
        }

        if (result.price) {
            DEBUG_LOGF("Price updated: $%.6f USD\n", result.price->market.price_usd);
        } else {
            DEBUG_LOGF("%s\n", result.errorMessage.c_str());
        }

        return [this, result = std::move(result)]() mutable {
            if (result.price) {
                NetworkRefreshService::applyPriceRefreshResult(state_, *result.price, std::chrono::steady_clock::now());
            } else {
                NetworkRefreshService::applyPriceRefreshFailure(state_, result.errorMessage);
            }
        };
    }, 0);
    if (!enqueued.enqueued) return;
    NetworkRefreshService::markPriceRefreshStarted(state_);
}

void App::refreshMarketData()
{
    refreshPrice();
}

// ============================================================================
// Mining Operations
// ============================================================================

void App::startMining(int threads)
{
    if (!supportsSoloMining()) {
        (void)threads;
        ui::Notifications::instance().warning("Solo mining is unavailable in lite build");
        return;
    }
    if (!state_.connected || !rpc_ || !worker_) return;
    if (mining_toggle_in_progress_.exchange(true)) return; // already in progress

    worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
        bool ok = false;
        std::string errMsg;
        try {
            rpc::RPCClient::TraceScope trace("Mining tab / Start mining");
            rpc_->call("setgenerate", {true, threads});
            ok = true;
        } catch (const std::exception& e) {
            errMsg = e.what();
        }
        return [this, threads, ok, errMsg]() {
            mining_toggle_in_progress_.store(false);
            if (ok) {
                state_.mining.generate = true;
                state_.mining.genproclimit = threads;
                DEBUG_LOGF("Mining started with %d threads\n", threads);
            } else {
                DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str());
                ui::Notifications::instance().error("Mining failed: " + errMsg);
            }
        };
    });
}

void App::stopMining()
{
    if (!supportsSoloMining()) return;
    if (!state_.connected || !rpc_ || !worker_) return;
    if (mining_toggle_in_progress_.exchange(true)) return; // already in progress

    worker_->post([this]() -> rpc::RPCWorker::MainCb {
        bool ok = false;
        try {
            rpc::RPCClient::TraceScope trace("Mining tab / Stop mining");
            rpc_->call("setgenerate", {false, 0});
            ok = true;
        } catch (const std::exception& e) {
            DEBUG_LOGF("Failed to stop mining: %s\n", e.what());
        }
        return [this, ok]() {
            mining_toggle_in_progress_.store(false);
            if (ok) {
                state_.mining.generate = false;
                state_.mining.localHashrate = 0.0;
                DEBUG_LOGF("Mining stopped\n");
            }
        };
    });
}

void App::startPoolMining(int threads)
{
    if (!supportsPoolMining()) {
        (void)threads;
        ui::Notifications::instance().warning("Pool mining is unavailable in this build");
        return;
    }

    if (!xmrig_manager_)
        xmrig_manager_ = std::make_unique<daemon::XmrigManager>();

    // If already running, stop first (e.g. thread count change)
    if (xmrig_manager_->isRunning()) {
        xmrig_manager_->stop();
    }

    // Stop solo mining first if active
    if (state_.mining.generate) stopMining();

    daemon::XmrigManager::Config cfg;
    cfg.pool_url       = settings_->getPoolUrl();
    cfg.worker_name    = settings_->getPoolWorker();
    cfg.algo           = settings_->getPoolAlgo();
    cfg.threads        = threads;  // Use the same thread selection as solo mining
    cfg.tls            = settings_->getPoolTls();
    cfg.hugepages      = settings_->getPoolHugepages();

    // Use first shielded address as the mining wallet address, fall back to transparent
    for (const auto& addr : state_.z_addresses) {
        if (!addr.address.empty()) {
            cfg.wallet_address = addr.address;
            break;
        }
    }
    if (cfg.wallet_address.empty()) {
        for (const auto& addr : state_.addresses) {
            if (addr.type == "transparent" && !addr.address.empty()) {
                cfg.wallet_address = addr.address;
                break;
            }
        }
    }

    // Fallback: use pool worker address from settings (available even before
    // the daemon is connected or the blockchain is synced).
    if (cfg.wallet_address.empty() && !cfg.worker_name.empty()) {
        cfg.wallet_address = cfg.worker_name;
    }

    if (cfg.wallet_address.empty()) {
        DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
        ui::Notifications::instance().error("No wallet address available — generate a Z address in the Receive tab");
        return;
    }

    if (!xmrig_manager_->start(cfg)) {
        std::string err = xmrig_manager_->getLastError();
        DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
        
        // Check for Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
        if (err.find("error 225") != std::string::npos || 
            err.find("virus") != std::string::npos) {
            ui::Notifications::instance().error(
                "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
#ifdef _WIN32
            // Offer to open Windows Security settings
            pending_antivirus_dialog_ = true;
#endif
        } else {
            ui::Notifications::instance().error("Failed to start pool miner: " + err);
        }
    }
}

void App::stopPoolMining()
{
    if (xmrig_manager_ && xmrig_manager_->isRunning()) {
        xmrig_manager_->stop(3000);
    }
}

// ============================================================================
// Peer Operations
// ============================================================================

void App::banPeer(const std::string& ip, int duration_seconds)
{
    if (!state_.connected || !rpc_) return;
    
    rpc_->setBan(ip, "add", [this](const json&) {
        refreshPeerInfo();
    }, nullptr, duration_seconds);
}

void App::unbanPeer(const std::string& ip)
{
    if (!state_.connected || !rpc_) return;
    
    rpc_->setBan(ip, "remove", [this](const json&) {
        refreshPeerInfo();
    });
}

void App::clearBans()
{
    if (!state_.connected || !rpc_) return;
    
    rpc_->clearBanned([this](const json&) {
        state_.banned_peers.clear();
    });
}

void App::applyDefaultBanlist()
{
    if (!state_.connected || !rpc_ || !worker_) return;

    // Parse the embedded default_banlist.txt (compiled from res/default_banlist.txt)
    std::string data(reinterpret_cast<const char*>(embedded::default_banlist_data),
                     embedded::default_banlist_size);

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

        // Strip carriage return (Windows line endings)
        if (!line.empty() && line.back() == '\r') line.pop_back();
        // Strip leading/trailing whitespace
        size_t start = line.find_first_not_of(" \t");
        if (start == std::string::npos) continue;
        line = line.substr(start, line.find_last_not_of(" \t") - start + 1);
        // Skip empty lines and comments
        if (line.empty() || line[0] == '#') continue;

        ips.push_back(line);
    }

    if (ips.empty()) return;

    // Apply bans on the worker thread to avoid blocking the UI
    worker_->post([this, ips]() -> rpc::RPCWorker::MainCb {
        int applied = 0;
        for (const auto& ip : ips) {
            try {
                rpc::RPCClient::TraceScope trace("Startup / Default banlist");
                // 0 = permanent ban (until node restart or manual unban)
                // Using a very long duration (10 years) for effectively permanent bans
                rpc_->call("setban", {ip, "add", 315360000});
                applied++;
            } catch (...) {
                // Already banned or invalid — skip silently
            }
        }
        return [applied]() {
            if (applied > 0) {
                DEBUG_LOGF("[Banlist] Applied %d default bans\n", applied);
            }
        };
    });
}

// ============================================================================
// Address Operations
// ============================================================================

void App::createNewZAddress(std::function<void(const std::string&)> callback)
{
    if (!state_.connected || !rpc_ || !worker_) return;

    worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
        std::string addr;
        try {
            rpc::RPCClient::TraceScope trace("Receive tab / New shielded address");
            json result = rpc_->call("z_getnewaddress");
            addr = result.get<std::string>();
        } catch (const std::exception& e) {
            DEBUG_LOGF("z_getnewaddress error: %s\n", e.what());
        }
        return [this, callback, addr]() {
            if (!addr.empty()) {
                // Inject immediately so UI can select the address next frame
                AddressInfo info;
                info.address = addr;
                info.type = "shielded";
                info.balance = 0.0;
                state_.z_addresses.push_back(info);
                address_list_dirty_ = true;
                // Also trigger full refresh to get proper balances
                addresses_dirty_ = true;
                refreshAddresses();
            }
            if (callback) callback(addr);
        };
    });
}

void App::createNewTAddress(std::function<void(const std::string&)> callback)
{
    if (!state_.connected || !rpc_ || !worker_) return;

    worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
        std::string addr;
        try {
            rpc::RPCClient::TraceScope trace("Receive tab / New transparent address");
            json result = rpc_->call("getnewaddress");
            addr = result.get<std::string>();
        } catch (const std::exception& e) {
            DEBUG_LOGF("getnewaddress error: %s\n", e.what());
        }
        return [this, callback, addr]() {
            if (!addr.empty()) {
                // Inject immediately so UI can select the address next frame
                AddressInfo info;
                info.address = addr;
                info.type = "transparent";
                info.balance = 0.0;
                state_.t_addresses.push_back(info);
                address_list_dirty_ = true;
                // Also trigger full refresh to get proper balances
                addresses_dirty_ = true;
                refreshAddresses();
            }
            if (callback) callback(addr);
        };
    });
}

void App::hideAddress(const std::string& addr)
{
    if (settings_) {
        settings_->hideAddress(addr);
        settings_->save();
    }
}

void App::unhideAddress(const std::string& addr)
{
    if (settings_) {
        settings_->unhideAddress(addr);
        settings_->save();
    }
}

bool App::isAddressHidden(const std::string& addr) const
{
    return settings_ && settings_->isAddressHidden(addr);
}

int App::getHiddenAddressCount() const
{
    return settings_ ? settings_->getHiddenAddressCount() : 0;
}

void App::favoriteAddress(const std::string& addr)
{
    if (settings_) {
        settings_->favoriteAddress(addr);
        settings_->save();
    }
}

void App::unfavoriteAddress(const std::string& addr)
{
    if (settings_) {
        settings_->unfavoriteAddress(addr);
        settings_->save();
    }
}

bool App::isAddressFavorite(const std::string& addr) const
{
    return settings_ && settings_->isAddressFavorite(addr);
}

void App::setAddressLabel(const std::string& addr, const std::string& label)
{
    if (settings_) {
        settings_->setAddressLabel(addr, label);
        settings_->save();
    }
}

void App::setAddressIcon(const std::string& addr, const std::string& icon)
{
    if (settings_) {
        settings_->setAddressIcon(addr, icon);
        settings_->save();
    }
}

std::string App::getAddressLabel(const std::string& addr) const
{
    if (!settings_) return "";
    return settings_->getAddressMeta(addr).label;
}

std::string App::getAddressIcon(const std::string& addr) const
{
    if (!settings_) return "";
    return settings_->getAddressMeta(addr).icon;
}

int App::getAddressSortOrder(const std::string& addr) const
{
    if (!settings_) return -1;
    return settings_->getAddressMeta(addr).sortOrder;
}

void App::setAddressSortOrder(const std::string& addr, int order)
{
    if (settings_) {
        settings_->setAddressSortOrder(addr, order);
        settings_->save();
    }
}

int App::getNextSortOrder() const
{
    return settings_ ? settings_->getNextSortOrder() : 0;
}

void App::swapAddressOrder(const std::string& a, const std::string& b)
{
    if (settings_) {
        settings_->swapAddressOrder(a, b);
        settings_->save();
    }
}

bool App::isMiningAddress(const std::string& addr) const
{
    return settings_ && settings_->isMiningAddress(addr);
}

void App::setMiningAddress(const std::string& addr, bool mining)
{
    if (settings_) {
        settings_->setMiningAddress(addr, mining);
        settings_->save();
        invalidateShieldedHistoryScanProgress(true);
        transactions_dirty_ = true;
        last_tx_block_height_ = -1;
        network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
    }
}

void App::invalidateAddressValidationCache()
{
    address_validation_cache_dirty_ = true;
    addresses_dirty_ = true;
    invalidateShieldedHistoryScanProgress(true);
}

// ============================================================================
// Key Export/Import Operations
// ============================================================================

void App::exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback)
{
    if (!state_.connected || !rpc_) {
        if (callback) callback("");
        return;
    }
    
    auto keyKind = services::WalletSecurityController::classifyAddress(address);
    if (keyKind == services::WalletSecurityController::KeyKind::Shielded) {
        // Z-address: use z_exportkey
        rpc::RPCClient::TraceScope trace("Settings / Export private key");
        rpc_->z_exportKey(address, [callback](const json& result) {
            if (callback) callback(result.get<std::string>());
        }, [callback](const std::string& error) {
            DEBUG_LOGF("Export z-key error: %s\n", error.c_str());
            ui::Notifications::instance().error("Key export failed: " + error);
            if (callback) callback("");
        });
    } else {
        // T-address: use dumpprivkey
        rpc::RPCClient::TraceScope trace("Settings / Export private key");
        rpc_->dumpPrivKey(address, [callback](const json& result) {
            if (callback) callback(result.get<std::string>());
        }, [callback](const std::string& error) {
            DEBUG_LOGF("Export t-key error: %s\n", error.c_str());
            ui::Notifications::instance().error("Key export failed: " + error);
            if (callback) callback("");
        });
    }
}

void App::exportAllKeys(std::function<void(const std::string&)> callback)
{
    if (!state_.connected || !rpc_) {
        if (callback) callback("");
        return;
    }
    
    // Collect all keys into a string
    auto keys_result = std::make_shared<std::string>();
    auto pending = std::make_shared<int>(0);
    auto total = std::make_shared<int>(0);
    
    // First get all addresses
    auto all_addresses = std::make_shared<std::vector<std::string>>();
    
    // Add t-addresses
    for (const auto& addr : state_.t_addresses) {
        all_addresses->push_back(addr.address);
    }
    // Add z-addresses
    for (const auto& addr : state_.z_addresses) {
        all_addresses->push_back(addr.address);
    }
    
    *total = all_addresses->size();
    *pending = *total;
    
    if (*total == 0) {
        if (callback) callback("# No addresses to export\n");
        return;
    }
    
    *keys_result = "# DragonX Wallet Private Keys Export\n";
    *keys_result += "# WARNING: Keep this file secure! Anyone with these keys can spend your coins!\n\n";
    
    for (const auto& addr : *all_addresses) {
        exportPrivateKey(addr, [keys_result, pending, total, callback, addr](const std::string& key) {
            if (!key.empty()) {
                *keys_result += "# " + addr + "\n";
                *keys_result += key + "\n\n";
            }
            (*pending)--;
            if (*pending == 0 && callback) {
                callback(*keys_result);
            }
        });
    }
}

void App::importPrivateKey(const std::string& key, std::function<void(bool, const std::string&)> callback)
{
    if (!state_.connected || !rpc_) {
        if (callback) callback(false, "Not connected");
        return;
    }
    
    auto keyKind = services::WalletSecurityController::classifyPrivateKey(key);
    
    if (keyKind == services::WalletSecurityController::KeyKind::Shielded) {
        rpc::RPCClient::TraceScope trace("Settings / Import private key");
        rpc_->z_importKey(key, true, [this, callback](const json& result) {
            invalidateAddressValidationCache();
            refreshAddresses();
            if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
                services::WalletSecurityController::KeyKind::Shielded));
        }, [callback](const std::string& error) {
            if (callback) callback(false, error);
        });
    } else {
        rpc::RPCClient::TraceScope trace("Settings / Import private key");
        rpc_->importPrivKey(key, true, [this, callback](const json& result) {
            invalidateAddressValidationCache();
            refreshAddresses();
            if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
                services::WalletSecurityController::KeyKind::Transparent));
        }, [callback](const std::string& error) {
            if (callback) callback(false, error);
        });
    }
}

void App::backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback)
{
    if (!state_.connected || !rpc_) {
        if (callback) callback(false, "Not connected");
        return;
    }
    
    // Use z_exportwallet or similar to export all keys
    // For now, we'll use exportAllKeys and save to file
    exportAllKeys([destination, callback](const std::string& keys) {
        if (keys.empty()) {
            if (callback) callback(false, "Failed to export keys");
            return;
        }
        
        // Write to file
        std::ofstream file(destination);
        if (!file.is_open()) {
            if (callback) callback(false, "Could not open file: " + destination);
            return;
        }
        
        file << keys;
        file.close();
        
        if (callback) callback(true, "Wallet backup saved to: " + destination);
    });
}

// ============================================================================
// Transaction Operations
// ============================================================================

void App::sendTransaction(const std::string& from, const std::string& to, 
                         double amount, double fee, const std::string& memo,
                         std::function<void(bool success, const std::string& result)> callback)
{
    if (!state_.connected || !rpc_) {
        if (callback) callback(false, "Not connected");
        return;
    }
    
    // Check that we have the spending key for the from address
    if (!from.empty() && from[0] == 'z') {
        bool spendable = false;
        for (const auto& addr : state_.z_addresses) {
            if (addr.address == from) {
                spendable = addr.has_spending_key;
                break;
            }
        }
        if (!spendable) {
            if (callback) callback(false, "This is a view-only address (no spending key). Import the spending key to send from this address.");
            return;
        }
    }
    
    // Build recipients array
    nlohmann::json recipients = nlohmann::json::array();
    nlohmann::json recipient;
    recipient["address"] = to;
    recipient["amount"] = util::formatAmountFixed(amount);
    if (!memo.empty()) {
        recipient["memo"] = memo;
    }
    recipients.push_back(recipient);
    
    // Run z_sendmany on worker thread to avoid blocking UI
    if (!worker_) {
        send_progress_active_ = false;
        if (callback) callback(false, "RPC worker unavailable");
        return;
    }

    send_progress_active_ = true;
    ++send_submissions_in_flight_;
    worker_->post([this, from, to, amount, memo, recipients, callback]() -> rpc::RPCWorker::MainCb {
        bool ok = false;
        std::string result_str;
        try {
            rpc::RPCClient::TraceScope trace("Send tab / Submit transaction");
            auto result = rpc_->call("z_sendmany", {from, recipients});
            result_str = result.get<std::string>();
            ok = true;
        } catch (const std::exception& e) {
            result_str = e.what();
        }
        return [this, callback, ok, result_str, from, to, amount, memo]() {
            if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
            if (ok) {
                // A send changes address balances — refresh on next cycle
                addresses_dirty_ = true;
                // Force transaction list refresh so the sent tx appears immediately
                transactions_dirty_ = true;
                last_tx_block_height_ = -1;
                network_refresh_.markWalletMutationRefresh();
                // Track the opid so we can poll for completion
                if (!result_str.empty()) {
                    pending_opids_.push_back(result_str);
                    upsertPendingSendTransaction(result_str, from, to, amount, memo);
                }
            } else {
                send_progress_active_ = false;
            }
            if (callback) callback(ok, result_str);
        };
    });
}

} // namespace dragonx
