#include "network_refresh_service.h"
#include "../util/logger.h"

#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <map>
#include <set>
#include <unordered_map>

using json = nlohmann::json;

namespace dragonx {
namespace services {

namespace {

template <typename T>
std::optional<T> readOptional(const json& source, const char* key)
{
    try {
        if (source.contains(key)) return source[key].get<T>();
    } catch (...) {}
    return std::nullopt;
}

std::optional<double> readBalanceString(const json& source, const char* key)
{
    try {
        if (source.contains(key)) return std::stod(source[key].get<std::string>());
    } catch (...) {}
    return std::nullopt;
}

void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& unspent)
{
    if (!unspent.is_array()) return;

    // Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
    // the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
    // minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
    std::map<std::string, double> total;
    std::map<std::string, double> spendable;
    for (const auto& output : unspent) {
        auto address = readOptional<std::string>(output, "address");
        auto amount  = readOptional<double>(output, "amount");
        if (!address || !amount) continue;
        total[*address] += *amount;
        auto conf = readOptional<int>(output, "confirmations");
        if (conf && *conf >= 1) spendable[*address] += *amount;
    }

    // The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
    // notes in this set is 0, and spendableBalance is always a subset sum of balance.
    for (auto& info : addresses) {
        auto t = total.find(info.address);
        auto s = spendable.find(info.address);
        info.balance          = (t != total.end())    ? t->second : 0.0;
        info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
    }
}

bool hasTransactionType(const std::vector<TransactionInfo>& transactions,
                        const std::string& txid,
                        const std::string& type)
{
    for (const auto& transaction : transactions) {
        if (transaction.txid == txid && transaction.type == type) return true;
    }
    return false;
}

bool tryFetchRawTransaction(NetworkRefreshService::RefreshRpcGateway& rpc,
                            const std::string& txid,
                            json& rawTransaction)
{
    try {
        rawTransaction = rpc.call("gettransaction", json::array({txid}));
        return !rawTransaction.is_null();
    } catch (...) {
        return false;
    }
}

void applyRawTransactionMetadata(TransactionInfo& info,
                                 const json& rawTransaction,
                                 bool includeSendDetails)
{
    if (rawTransaction.is_null()) return;

    if (auto value = readOptional<std::int64_t>(rawTransaction, "time")) info.timestamp = *value;
    else if (auto value = readOptional<std::int64_t>(rawTransaction, "timereceived")) info.timestamp = *value;
    if (auto value = readOptional<int>(rawTransaction, "confirmations")) info.confirmations = *value;

    if (!includeSendDetails) return;

    if (auto value = readOptional<double>(rawTransaction, "amount")) info.amount = *value;

    if (!rawTransaction.contains("details") || !rawTransaction["details"].is_array()) return;
    for (const auto& detail : rawTransaction["details"]) {
        auto category = readOptional<std::string>(detail, "category");
        if (!category || *category != "send") continue;

        if (auto value = readOptional<double>(detail, "amount")) info.amount = *value;
        if (auto value = readOptional<std::string>(detail, "address")) info.address = *value;
        break;
    }
}

void applyRawTransactionMetadata(NetworkRefreshService::TransactionViewCacheEntry& entry,
                                 const json& rawTransaction)
{
    if (rawTransaction.is_null()) return;

    if (auto value = readOptional<std::int64_t>(rawTransaction, "time")) entry.timestamp = *value;
    else if (auto value = readOptional<std::int64_t>(rawTransaction, "timereceived")) entry.timestamp = *value;
    if (auto value = readOptional<int>(rawTransaction, "confirmations")) entry.confirmations = *value;
}

void appendTrackedSendPlaceholder(std::vector<TransactionInfo>& transactions,
                                  const std::string& txid,
                                  const json* rawTransaction)
{
    if (hasTransactionType(transactions, txid, "send")) return;

    TransactionInfo info;
    info.txid = txid;
    info.type = "send";
    if (rawTransaction) applyRawTransactionMetadata(info, *rawTransaction, true);
    transactions.push_back(std::move(info));
}

void appendMissingPreviousTransactions(std::vector<TransactionInfo>& transactions,
                                       const std::vector<TransactionInfo>& previousTransactions,
                                       bool includeAll,
                                       const std::unordered_set<std::string>& pinnedTxids)
{
    for (const auto& transaction : previousTransactions) {
        bool pinned = pinnedTxids.find(transaction.txid) != pinnedTxids.end();
        if (!includeAll && !pinned) {
            bool shieldedRelated = (!transaction.address.empty() && transaction.address[0] == 'z') ||
                                   (!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
                                   !transaction.memo.empty();
            if (!shieldedRelated) continue;
        }
        if (hasTransactionType(transactions, transaction.txid, transaction.type)) continue;
        transactions.push_back(transaction);
    }
}

using HushChatMemoOutputMap = std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>;

std::size_t readMemoPosition(const json& source, std::size_t fallback)
{
    for (const char* key : {"position", "outputIndex", "outindex"}) {
        auto value = readOptional<int>(source, key);
        if (value && *value >= 0) return static_cast<std::size_t>(*value);
    }
    return fallback;
}

void recordHushChatMemoOutput(HushChatMemoOutputMap* outputs,
                              const std::string& txid,
                              const std::string& memo,
                              std::size_t position)
{
    if (!outputs || txid.empty() || memo.empty()) return;
    (*outputs)[txid].push_back(chat::HushChatMemoOutput{position, memo});
}

void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
                                     const std::string& txid,
                                     const std::vector<chat::HushChatMemoOutput>& outputs)
{
    auto extracted = chat::extractHushChatTransactionMetadata(chat::HushChatTransactionInput{txid, outputs});
    for (auto& metadata : extracted.metadata) {
        destination.push_back(std::move(metadata));
    }
}

void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
                                     const HushChatMemoOutputMap& outputsByTxid)
{
    if (!chat::hushChatFeatureEnabledAtBuild()) return;

    for (const auto& [txid, outputs] : outputsByTxid) {
        appendExtractedHushChatMetadata(destination, txid, outputs);
    }
}

} // namespace

NetworkRefreshService::ConnectionInfoResult NetworkRefreshService::parseConnectionInfoResult(const json& info)
{
    ConnectionInfoResult result;
    if (!info.is_object()) return result;

    result.ok = true;
    result.daemonVersion = readOptional<int>(info, "version");
    result.protocolVersion = readOptional<int>(info, "protocolversion");
    result.p2pPort = readOptional<int>(info, "p2pport");
    result.longestChain = readOptional<int>(info, "longestchain");
    result.notarized = readOptional<int>(info, "notarized");
    result.blocks = readOptional<int>(info, "blocks");
    return result;
}

NetworkRefreshService::WalletEncryptionResult NetworkRefreshService::parseWalletEncryptionResult(const json& walletInfo)
{
    WalletEncryptionResult result;
    if (!walletInfo.is_object()) return result;

    result.ok = true;
    result.encrypted = walletInfo.contains("unlocked_until");
    if (result.encrypted) {
        try {
            result.unlockedUntil = walletInfo["unlocked_until"].get<std::int64_t>();
        } catch (...) {
            result.ok = false;
        }
    }
    return result;
}

NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPollResult(RefreshRpcGateway& rpc)
{
    WarmupPollResult result;
    try {
        json info = rpc.call("getinfo", json::array());
        result.ready = true;
        result.info = parseConnectionInfoResult(info);
    } catch (const std::exception& e) {
        result.errorMessage = e.what();
    }
    return result;
}

NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnectionInitResult(
    RefreshRpcGateway& rpc,
    const std::optional<ConnectionInfoResult>& prefetchedInfo)
{
    ConnectionInitResult result;

    if (prefetchedInfo && prefetchedInfo->ok) {
        result.info = *prefetchedInfo;
    } else {
        try {
            json info = rpc.call("getinfo", json::array());
            result.info = parseConnectionInfoResult(info);
        } catch (...) {}
    }

    try {
        json walletInfo = rpc.call("getwalletinfo", json::array());
        result.encryption = parseWalletEncryptionResult(walletInfo);
    } catch (...) {}

    return result;
}

NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
    const json& totalBalance, const json& spendableBalance, bool balanceOk, const json& blockInfo, bool blockOk)
{
    CoreRefreshResult result;
    result.balanceOk = balanceOk && totalBalance.is_object();
    if (result.balanceOk) {
        result.shieldedBalance = readBalanceString(totalBalance, "private");
        result.transparentBalance = readBalanceString(totalBalance, "transparent");
        result.totalBalance = readBalanceString(totalBalance, "total");
    }
    if (spendableBalance.is_object()) {   // confirmed totals (minconf=1); left unset on old daemons
        result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
        result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
        result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
    }

    result.blockchainOk = blockOk && blockInfo.is_object();
    if (result.blockchainOk) {
        result.blocks = readOptional<int>(blockInfo, "blocks");
        result.headers = readOptional<int>(blockInfo, "headers");
        result.bestBlockHash = readOptional<std::string>(blockInfo, "bestblockhash");
        result.verificationProgress = readOptional<double>(blockInfo, "verificationprogress");
        result.longestChain = readOptional<int>(blockInfo, "longestchain");
        result.notarized = readOptional<int>(blockInfo, "notarized");
    }
    return result;
}

NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
{
    json totalBalance;
    json spendableBalance;
    json blockInfo;
    bool balanceOk = false;
    bool blockOk = false;
    double balanceScanMs = 0.0;

    // getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else
    // (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance
    // scan keeps sync detection from being delayed behind (or, under contention, starved by) that
    // scan — the failure mode where the wallet kept reading "synced" while actually falling behind,
    // so kSyncProfile never engaged. See effectivelySyncing()'s sticky-behind latch.
    try {
        blockInfo = rpc.call("getblockchaininfo", json::array());
        blockOk = true;
    } catch (const std::exception& e) {
        DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
    }

    // If getblockchaininfo shows we're behind (same 2-block tolerance as applyCoreRefreshResult), skip
    // the balance scan this cycle regardless of includeBalance: the balance is incomplete mid-sync
    // anyway, and skipping it lets the sync-state update — and hence kSyncProfile — take effect at the
    // end of THIS (now-cheap) task instead of being delayed ~20s behind the scan. This is what lets the
    // sticky-behind latch engage promptly the first time the node falls behind.
    bool behind = false;
    if (blockOk && blockInfo.is_object()) {
        const long long b  = blockInfo.value("blocks", 0LL);
        const long long lc = blockInfo.value("longestchain", 0LL);
        if (lc > 0 && b < lc - 2) behind = true;
    }

    if (includeBalance && !behind) {
        // z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
        // seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
        // (balanceRefreshDue()), keeping balance scans from starving block connection.
        const auto balanceStart = std::chrono::steady_clock::now();
        try {   // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
            totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
            balanceOk = true;
        } catch (const std::exception& e) {
            DEBUG_LOGF("Balance error: %s\n", e.what());
        }
        try {   // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
            spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
        } catch (...) {}
        balanceScanMs = std::chrono::duration<double, std::milli>(
            std::chrono::steady_clock::now() - balanceStart).count();
    }

    auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
    result.balanceScanMs = balanceScanMs;
    return result;
}

NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
    const json& miningInfo, bool miningOk, const json& localHashrate, bool hashrateOk, double daemonMemoryMb)
{
    MiningRefreshResult result;
    result.daemonMemoryMb = daemonMemoryMb;

    if (hashrateOk) {
        try {
            result.localHashrate = localHashrate.get<double>();
        } catch (...) {}
    }

    result.miningOk = miningOk && miningInfo.is_object();
    if (result.miningOk) {
        result.generate = readOptional<bool>(miningInfo, "generate");
        result.genproclimit = readOptional<int>(miningInfo, "genproclimit");
        result.blocks = readOptional<int>(miningInfo, "blocks");
        result.difficulty = readOptional<double>(miningInfo, "difficulty");
        result.networkHashrate = readOptional<double>(miningInfo, "networkhashps");
        result.chain = readOptional<std::string>(miningInfo, "chain");
    }
    return result;
}

NetworkRefreshService::MiningRefreshResult NetworkRefreshService::collectMiningRefreshResult(
    RefreshRpcGateway& rpc,
    double daemonMemoryMb,
    bool includeSlowRefresh,
    bool includeLocalHashrate)
{
    json miningInfo;
    json localHashrate;
    bool miningOk = false;
    bool hashrateOk = false;

    if (includeLocalHashrate) {
        try {
            localHashrate = rpc.call("getlocalsolps", json::array());
            hashrateOk = true;
        } catch (const std::exception& e) {
            DEBUG_LOGF("getLocalHashrate error: %s\n", e.what());
        }
    }

    if (includeSlowRefresh) {
        try {
            miningInfo = rpc.call("getmininginfo", json::array());
            miningOk = true;
        } catch (const std::exception& e) {
            DEBUG_LOGF("getMiningInfo error: %s\n", e.what());
        }
    }

    return parseMiningRefreshResult(miningInfo, miningOk, localHashrate, hashrateOk, daemonMemoryMb);
}

NetworkRefreshService::PeerRefreshResult NetworkRefreshService::parsePeerRefreshResult(
    const json& peers, const json& bannedPeers)
{
    PeerRefreshResult result;

    if (peers.is_array()) {
        for (const auto& peer : peers) {
            PeerInfo info;
            if (auto value = readOptional<int>(peer, "id")) info.id = *value;
            if (auto value = readOptional<std::string>(peer, "addr")) info.addr = *value;
            if (auto value = readOptional<std::string>(peer, "subver")) info.subver = *value;
            if (auto value = readOptional<std::string>(peer, "services")) info.services = *value;
            if (auto value = readOptional<int>(peer, "version")) info.version = *value;
            if (auto value = readOptional<std::int64_t>(peer, "conntime")) info.conntime = *value;
            if (auto value = readOptional<int>(peer, "banscore")) info.banscore = *value;
            if (auto value = readOptional<double>(peer, "pingtime")) info.pingtime = *value;
            if (auto value = readOptional<std::int64_t>(peer, "bytessent")) info.bytessent = *value;
            if (auto value = readOptional<std::int64_t>(peer, "bytesrecv")) info.bytesrecv = *value;
            if (auto value = readOptional<int>(peer, "startingheight")) info.startingheight = *value;
            if (auto value = readOptional<int>(peer, "synced_headers")) info.synced_headers = *value;
            if (auto value = readOptional<int>(peer, "synced_blocks")) info.synced_blocks = *value;
            if (auto value = readOptional<bool>(peer, "inbound")) info.inbound = *value;
            if (auto value = readOptional<std::string>(peer, "tls_cipher")) info.tls_cipher = *value;
            if (auto value = readOptional<bool>(peer, "tls_verified")) info.tls_verified = *value;
            result.peers.push_back(std::move(info));
        }
    }

    if (bannedPeers.is_array()) {
        for (const auto& ban : bannedPeers) {
            BannedPeer info;
            if (auto value = readOptional<std::string>(ban, "address")) info.address = *value;
            if (auto value = readOptional<std::int64_t>(ban, "banned_until")) info.banned_until = *value;
            result.bannedPeers.push_back(std::move(info));
        }
    }

    return result;
}

NetworkRefreshService::PeerRefreshResult NetworkRefreshService::collectPeerRefreshResult(RefreshRpcGateway& rpc)
{
    json peers = json::array();
    json bannedPeers = json::array();
    bool peersOk = false;
    bool bannedOk = false;

    try {
        peers = rpc.call("getpeerinfo", json::array());
        peersOk = true;
    } catch (const std::exception& e) {
        DEBUG_LOGF("getPeerInfo error: %s\n", e.what());
    }

    try {
        bannedPeers = rpc.call("listbanned", json::array());
        bannedOk = true;
    } catch (const std::exception& e) {
        DEBUG_LOGF("listBanned error: %s\n", e.what());
    }

    return parsePeerRefreshResult(peersOk ? peers : json::array(),
                                  bannedOk ? bannedPeers : json::array());
}

std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::parseCoinGeckoPriceResponse(
    const std::string& response, std::time_t fetchedAt)
{
    try {
        auto parsed = json::parse(response);
        if (!parsed.contains("dragonx-2")) return std::nullopt;

        const auto& data = parsed["dragonx-2"];
        // CoinGecko emits JSON null (not an omitted key) for fields it can't currently compute —
        // commonly usd_24h_change on illiquid/newly-listed tokens — while still returning a valid
        // spot price in the same object. .value(key, default) throws type_error on a PRESENT null,
        // which the outer catch turns into "no price update at all", so read null-tolerantly and
        // keep the valid usd/btc rather than discarding the whole refresh.
        auto num = [&data](const char* key, double def) {
            auto it = data.find(key);
            return (it != data.end() && it->is_number()) ? it->get<double>() : def;
        };
        PriceRefreshResult result;
        result.market.price_usd = num("usd", 0.0);
        result.market.price_btc = num("btc", 0.0);
        result.market.change_24h = num("usd_24h_change", 0.0);
        result.market.volume_24h = num("usd_24h_vol", 0.0);
        result.market.market_cap = num("usd_market_cap", 0.0);

        char buf[64];
        // Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
        // reentrant variant into a local tm (matches the rest of the codebase).
        std::tm tmv{};
#ifdef _WIN32
        localtime_s(&tmv, &fetchedAt);
#else
        localtime_r(&fetchedAt, &tmv);
#endif
        if (std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tmv) > 0) {
            result.market.last_updated = buf;
        }
        return result;
    } catch (...) {
        return std::nullopt;
    }
}

std::vector<std::pair<std::time_t, double>> NetworkRefreshService::parseCoinGeckoMarketChart(
    const std::string& response)
{
    std::vector<std::pair<std::time_t, double>> series;
    try {
        auto parsed = json::parse(response);
        if (!parsed.contains("prices") || !parsed["prices"].is_array()) return series;
        const auto& prices = parsed["prices"];
        series.reserve(prices.size());
        for (const auto& row : prices) {
            // Each row is [milliseconds, price]; skip anything malformed.
            if (!row.is_array() || row.size() < 2) continue;
            if (!row[0].is_number() || !row[1].is_number()) continue;
            const double ms = row[0].get<double>();
            const double price = row[1].get<double>();
            series.emplace_back(static_cast<std::time_t>(ms / 1000.0), price);
        }
    } catch (...) {
        series.clear();
    }
    return series;
}

NetworkRefreshService::PriceHttpResult NetworkRefreshService::parsePriceHttpResponse(
    const PriceHttpResponse& response,
    std::time_t fetchedAt)
{
    PriceHttpResult result;

    if (!response.transportOk || response.httpStatus != 200) {
        const std::string reason = response.transportOk ? "OK" : response.transportError;
        result.errorMessage = "Price fetch failed: " + reason +
            " (HTTP " + std::to_string(response.httpStatus) + ")";
        return result;
    }

    auto parsed = parseCoinGeckoPriceResponse(response.body, fetchedAt);
    if (!parsed) {
        result.errorMessage = "Price fetch returned an unrecognized response";
        return result;
    }

    result.price = std::move(*parsed);
    return result;
}

AddressInfo NetworkRefreshService::buildShieldedAddressInfo(const std::string& address,
                                                            const json& validation,
                                                            bool validationSucceeded)
{
    AddressInfo info;
    info.address = address;
    info.type = "shielded";

    if (!validationSucceeded) {
        info.has_spending_key = true;
        return info;
    }

    info.has_spending_key = false;
    if (auto isMine = readOptional<bool>(validation, "ismine")) {
        info.has_spending_key = *isMine;
    }
    return info;
}

AddressInfo NetworkRefreshService::buildTransparentAddressInfo(const std::string& address)
{
    AddressInfo info;
    info.address = address;
    info.type = "transparent";
    return info;
}

std::vector<AddressInfo> NetworkRefreshService::parseTransparentAddressList(const json& addressList)
{
    std::vector<AddressInfo> addresses;
    if (!addressList.is_array()) return addresses;

    for (const auto& addressJson : addressList) {
        try {
            addresses.push_back(buildTransparentAddressInfo(addressJson.get<std::string>()));
        } catch (...) {}
    }
    return addresses;
}

void NetworkRefreshService::applyShieldedBalancesFromUnspent(std::vector<AddressInfo>& addresses,
                                                             const json& unspent)
{
    applyBalancesFromUnspent(addresses, unspent);
}

void NetworkRefreshService::applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
                                                                const json& unspent)
{
    applyBalancesFromUnspent(addresses, unspent);
}

NetworkRefreshService::AddressRefreshSnapshot NetworkRefreshService::buildAddressRefreshSnapshot(const WalletState& state)
{
    AddressRefreshSnapshot snapshot;
    for (const auto& info : state.z_addresses) {
        if (!info.address.empty()) snapshot.shieldedSpendingKeys[info.address] = info.has_spending_key;
    }
    return snapshot;
}

NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(
    RefreshRpcGateway& rpc,
    const AddressRefreshSnapshot& snapshot)
{
    AddressRefreshResult result;
    // Time the whole scan — z_listunspent (and per-address z_getbalance fallback) hold the daemon's
    // cs_main for the duration, seconds on a large shielded wallet. The measured cost feeds the
    // caller's adaptive throttle so the address poll can't starve block connection.
    const auto scanStart = std::chrono::steady_clock::now();

    try {
        json zList = rpc.call("z_listaddresses", json::array());
        if (zList.is_array()) {
            for (const auto& addressJson : zList) {
                std::string address;
                try {
                    address = addressJson.get<std::string>();
                } catch (...) {
                    continue;
                }

                auto cached = snapshot.shieldedSpendingKeys.find(address);
                if (cached != snapshot.shieldedSpendingKeys.end()) {
                    AddressInfo info;
                    info.address = address;
                    info.type = "shielded";
                    info.has_spending_key = cached->second;
                    result.shieldedAddresses.push_back(std::move(info));
                } else {
                    json validationResult;
                    bool validationSucceeded = false;
                    try {
                        validationResult = rpc.call("z_validateaddress", json::array({address}));
                        validationSucceeded = true;
                    } catch (...) {
                        // Older daemons can fail validation for wallet-owned addresses.
                    }
                    result.shieldedAddresses.push_back(
                        buildShieldedAddressInfo(address, validationResult, validationSucceeded));
                }
            }
        }
    } catch (const std::exception& e) {
        DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
        result.addressListOk = false;   // enumeration failed → the shielded list may be falsely short
    }

    try {
        json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false}));  // minconf=0 → include 0-conf change
        applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
        // Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead
        // of issuing its own z_listunspent. rawconfirmations is the TRUE depth; `confirmations` is
        // dPoW-clamped to 1 and understates it.
        if (unspent.is_array()) {
            result.unspentNotes.reserve(unspent.size());
            for (const auto& nz : unspent) {
                if (!nz.is_object()) continue;
                UnspentNoteLite lite;
                lite.amount = nz.value("amount", 0.0);
                lite.locked = nz.value("locked", false);
                lite.confirmations = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
                                     ? nz["rawconfirmations"].get<int>()
                                     : nz.value("confirmations", 0);
                result.unspentNotes.push_back(lite);
            }
        }
    } catch (const std::exception& e) {
        DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
        for (auto& info : result.shieldedAddresses) {
            try {   // display total (minconf=0, includes pending change)
                json total = rpc.call("z_getbalance", json::array({info.address, 0}));
                if (!total.is_null()) info.balance = total.get<double>();
            } catch (...) {}
            try {   // spendable (minconf=1); degrade to the display value on old daemons
                json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
                info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
            } catch (...) { info.spendableBalance = info.balance; }
        }
    }

    try {
        json tList = rpc.call("getaddressesbyaccount", json::array({""}));
        result.transparentAddresses = parseTransparentAddressList(tList);
    } catch (const std::exception& e) {
        DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
        result.addressListOk = false;   // enumeration failed → the transparent list may be falsely short
    }

    try {
        json unspent = rpc.call("listunspent", json::array({0}));  // minconf=0 → include 0-conf change
        applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
    } catch (const std::exception& e) {
        DEBUG_LOGF("listunspent error: %s\n", e.what());
    }

    result.scanMs = std::chrono::duration<double, std::milli>(
        std::chrono::steady_clock::now() - scanStart).count();
    return result;
}

NetworkRefreshService::TransactionRefreshSnapshot NetworkRefreshService::buildTransactionRefreshSnapshot(
    const WalletState& state,
    const TransactionViewCache& viewTxCache,
    const std::unordered_set<std::string>& sendTxids)
{
    TransactionRefreshSnapshot snapshot;

    for (const auto& address : state.z_addresses) {
        if (!address.address.empty()) snapshot.shieldedAddresses.push_back(address.address);
    }

    for (const auto& cachedView : viewTxCache) {
        snapshot.fullyEnrichedTxids.insert(cachedView.first);
    }
    for (const auto& transaction : state.transactions) {
        if (transaction.confirmations > 6 && transaction.timestamp != 0 && !transaction.txid.empty()) {
            snapshot.fullyEnrichedTxids.insert(transaction.txid);
        }
    }

    snapshot.viewTxCache = viewTxCache;
    snapshot.sendTxids = sendTxids;
    snapshot.previousTransactions = state.transactions;
    return snapshot;
}

void NetworkRefreshService::appendTransparentTransactions(std::vector<TransactionInfo>& transactions,
                                                          std::set<std::string>& knownTxids,
                                                          const json& result,
                                                          const std::set<std::string>& miningAddresses)
{
    if (!result.is_array()) return;

    for (const auto& transactionJson : result) {
        TransactionInfo info;
        if (auto value = readOptional<std::string>(transactionJson, "txid")) info.txid = *value;
        if (auto value = readOptional<std::string>(transactionJson, "category")) info.type = *value;
        if (auto value = readOptional<double>(transactionJson, "amount")) info.amount = *value;
        if (auto value = readOptional<std::int64_t>(transactionJson, "time")) {
            info.timestamp = *value;
        } else if (auto received = readOptional<std::int64_t>(transactionJson, "timereceived")) {
            info.timestamp = *received;
        }
        if (auto value = readOptional<int>(transactionJson, "confirmations")) info.confirmations = *value;
        if (auto value = readOptional<std::string>(transactionJson, "address")) info.address = *value;
        if (info.type == "receive" && !info.address.empty() && miningAddresses.count(info.address)) {
            info.type = "mined";
        }
        if (!info.txid.empty()) knownTxids.insert(info.txid);
        transactions.push_back(std::move(info));
    }
}

void NetworkRefreshService::appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
                                                               std::set<std::string>& knownTxids,
                                                               const std::string& address,
                                                               const json& received,
                                                               const std::set<std::string>& miningAddresses,
                                                               HushChatMemoOutputMap* chatMemoOutputs)
{
    if (received.is_null() || !received.is_array()) return;

    std::size_t fallbackPosition = 0;
    for (const auto& note : received) {
        const std::size_t notePosition = readMemoPosition(note, fallbackPosition++);
        auto txid = readOptional<std::string>(note, "txid");
        if (!txid || txid->empty()) continue;

        auto change = readOptional<bool>(note, "change");
        if (change && *change) continue;

        auto memoValue = readOptional<std::string>(note, "memoStr");
        if (memoValue) recordHushChatMemoOutput(chatMemoOutputs, *txid, *memoValue, notePosition);

        bool dominated = false;
        for (const auto& existing : transactions) {
            if (existing.txid == *txid && existing.type == "receive") {
                dominated = true;
                break;
            }
        }
        if (dominated) continue;

        TransactionInfo info;
        info.txid = *txid;
        info.type = miningAddresses.count(address) ? "mined" : "receive";
        info.address = address;
        if (auto value = readOptional<double>(note, "amount")) info.amount = *value;
        if (auto value = readOptional<int>(note, "confirmations")) info.confirmations = *value;
        if (auto value = readOptional<std::int64_t>(note, "time")) info.timestamp = *value;
        if (memoValue) info.memo = *memoValue;
        knownTxids.insert(*txid);
        transactions.push_back(std::move(info));
    }
}

NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseViewTransactionCacheEntry(
    const json& viewTransaction)
{
    TransactionViewCacheEntry entry;
    if (!viewTransaction.is_object()) return entry;

    if (viewTransaction.contains("spends") && viewTransaction["spends"].is_array()) {
        for (const auto& spend : viewTransaction["spends"]) {
            if (auto address = readOptional<std::string>(spend, "address")) {
                entry.from_address = *address;
                break;
            }
        }
    }

    if (viewTransaction.contains("outputs") && viewTransaction["outputs"].is_array()) {
        std::size_t fallbackPosition = 0;
        for (const auto& output : viewTransaction["outputs"]) {
            const std::size_t outputPosition = readMemoPosition(output, fallbackPosition++);
            bool outgoing = false;
            if (auto value = readOptional<bool>(output, "outgoing")) outgoing = *value;
            if (!outgoing) continue;

            TransactionViewCacheEntry::Output out;
            if (auto value = readOptional<std::string>(output, "address")) out.address = *value;
            if (auto value = readOptional<double>(output, "value")) out.value = *value;
            if (auto value = readOptional<std::string>(output, "memoStr")) out.memo = *value;
            out.position = outputPosition;
            entry.outgoing_outputs.push_back(std::move(out));
        }
    }

    return entry;
}

void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
                                                         const std::string& txid,
                                                         const TransactionViewCacheEntry& entry)
{
    for (const auto& out : entry.outgoing_outputs) {
        bool alreadyTracked = false;
        for (const auto& existing : transactions) {
            // Key on address as well as amount: two outgoing outputs of EQUAL value to DIFFERENT
            // recipients in one tx are distinct rows — an amount-only match would drop the second.
            if (existing.txid == txid && existing.type == "send" &&
                std::abs(existing.amount + out.value) < 0.00000001 &&
                existing.address == out.address) {
                alreadyTracked = true;
                break;
            }
        }
        if (alreadyTracked) continue;

        TransactionInfo info;
        info.txid = txid;
        info.type = "send";
        info.address = out.address;
        info.amount = -out.value;
        info.memo = out.memo;
        info.from_address = entry.from_address;
        bool foundExistingMetadata = false;
        for (const auto& existing : transactions) {
            if (existing.txid == txid) {
                info.confirmations = existing.confirmations;
                info.timestamp = existing.timestamp;
                foundExistingMetadata = true;
                break;
            }
        }
        if (!foundExistingMetadata) {
            info.confirmations = entry.confirmations;
            info.timestamp = entry.timestamp;
        }
        transactions.push_back(std::move(info));
    }
}

void NetworkRefreshService::appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
                                                         const std::string& txid,
                                                         const nlohmann::json& sentEntry)
{
    if (!sentEntry.is_object() || !sentEntry.contains("sends") || !sentEntry["sends"].is_object())
        return;
    const auto& sends = sentEntry["sends"];
    auto it = sends.find("transparentSends");
    if (it == sends.end() || !it->is_array()) return;

    // Per-tx metadata for the synthesized rows.
    int confirmations = 0;
    std::int64_t timestamp = 0;
    if (auto v = readOptional<int>(sentEntry, "confirmations")) confirmations = *v;
    if (auto v = readOptional<std::int64_t>(sentEntry, "blocktime")) timestamp = *v;
    if (timestamp == 0) {
        if (auto v = readOptional<std::int64_t>(sentEntry, "time")) timestamp = *v;
    }

    for (const auto& out : *it) {
        auto amount = readOptional<double>(out, "amount");
        if (!amount) continue;
        const double debit = -std::abs(*amount);  // the daemon reports -nValue; a send is a debit
        std::string address;
        if (auto a = readOptional<std::string>(out, "address")) address = *a;

        // Dedup against rows already present — notably listtransactions-derived t->t sends, which
        // this whole-wallet payload ALSO reports. Same txid+address+amount == the same output.
        bool exists = false;
        for (const auto& e : transactions) {
            if (e.txid == txid && e.type == "send" && e.address == address &&
                std::abs(e.amount - debit) < 0.00000001) {
                exists = true;
                break;
            }
        }
        if (exists) continue;

        TransactionInfo info;
        info.txid = txid;
        info.type = "send";
        info.address = address;
        info.amount = debit;
        info.confirmations = confirmations;
        info.timestamp = timestamp;
        transactions.push_back(std::move(info));
    }
}

void NetworkRefreshService::sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions)
{
    std::sort(transactions.begin(), transactions.end(),
        [](const TransactionInfo& left, const TransactionInfo& right) {
            return left.timestamp > right.timestamp;
        });
}

NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTransactionRefreshResult(
    RefreshRpcGateway& rpc,
    const TransactionRefreshSnapshot& snapshot,
    int currentBlockHeight,
    int maxViewTransactionsPerCycle)
{
    TransactionRefreshResult result;
    result.blockHeight = currentBlockHeight;
    result.shieldedAddressCount = snapshot.shieldedAddresses.size();
    result.shieldedScanHeights = snapshot.shieldedScanHeights;
    // Time the whole scan — the per-address z_listreceivedbyaddress pass is O(mapWallet) and holds the
    // daemon's cs_main. The measured cost feeds the caller's adaptive throttle (txRefreshDue()) so the
    // routine full history rescan can't starve block connection on a large wallet.
    const auto scanStart = std::chrono::steady_clock::now();

    std::set<std::string> knownTxids;
    HushChatMemoOutputMap hushChatReceivedOutputs;
    HushChatMemoOutputMap* hushChatReceivedOutputsPtr = chat::hushChatFeatureEnabledAtBuild()
        ? &hushChatReceivedOutputs
        : nullptr;

    bool transactionRpcError = false;
    try {
        constexpr int pageSize = 1000;
        int skip = 0;
        while (true) {
            json transactions = rpc.call("listtransactions", json::array({"", pageSize, skip}));
            appendTransparentTransactions(result.transactions, knownTxids, transactions, snapshot.miningAddresses);
            if (!transactions.is_array() || transactions.size() < static_cast<std::size_t>(pageSize)) break;
            skip += pageSize;
        }
    } catch (const std::exception& e) {
        transactionRpcError = true;
        DEBUG_LOGF("listtransactions error: %s\n", e.what());
    }

    // An address counts as "scanned at tip" if its last scan is within shieldedScanTipTolerance
    // blocks of the current tip. Tolerance 0 means strict (exact tip); a small tolerance lets a
    // multi-cycle pass over many addresses complete even as the tip advances during it.
    const int scanStaleThreshold = currentBlockHeight - std::max(0, snapshot.shieldedScanTipTolerance);
    auto scannedAtTip = [&](const std::string& address) {
        if (currentBlockHeight < 0) return false;
        auto it = result.shieldedScanHeights.find(address);
        return it != result.shieldedScanHeights.end() && it->second >= scanStaleThreshold;
    };

    std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
    if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
    std::size_t nextSearchIndex = shieldedStart;

    for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
        const auto& address = snapshot.shieldedAddresses[index];
        if (scannedAtTip(address)) {
            nextSearchIndex = index + 1;
            continue;
        }
        if (snapshot.maxShieldedReceiveScans > 0 &&
            result.shieldedAddressesScanned >= snapshot.maxShieldedReceiveScans) {
            nextSearchIndex = index;
            break;
        }
        ++result.shieldedAddressesScanned;
        nextSearchIndex = index + 1;
        try {
            json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
            appendShieldedReceivedTransactions(result.transactions,
                                               knownTxids,
                                               address,
                                               received,
                                               snapshot.miningAddresses,
                                               hushChatReceivedOutputsPtr);
            if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
        } catch (const std::exception& e) {
            transactionRpcError = true;
            DEBUG_LOGF("z_listreceivedbyaddress error: %s\n", e.what());
        }
    }

    auto findPendingShieldedIndex = [&]() -> std::optional<std::size_t> {
        if (snapshot.shieldedAddresses.empty()) return std::nullopt;
        std::size_t start = nextSearchIndex < snapshot.shieldedAddresses.size() ? nextSearchIndex : 0;
        for (std::size_t offset = 0; offset < snapshot.shieldedAddresses.size(); ++offset) {
            std::size_t index = (start + offset) % snapshot.shieldedAddresses.size();
            if (!scannedAtTip(snapshot.shieldedAddresses[index])) return index;
        }
        return std::nullopt;
    };

    auto pendingShieldedIndex = findPendingShieldedIndex();
    result.shieldedScanComplete = !pendingShieldedIndex.has_value();
    result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);

    // Discover outgoing shielded (z->z) sends. A pure z->z send moves no value in or out of the
    // transparent pool (valueBalance == 0), so the daemon's `listtransactions` never reports it,
    // and — being a send rather than a receive — it never shows up in `z_listreceivedbyaddress`
    // either. Its txid therefore never reaches `knownTxids`, so the z_viewtransaction enrichment
    // below is never run for it and the send is invisible in History (the reported bug).
    //
    // `z_listsentbyaddress` scans the whole wallet for sends — the address argument is NOT used to
    // filter (the daemon iterates every wallet tx against "*"), so a single call yields all send
    // txids. Two legs need handling:
    //   * SHIELDED (z->z) outgoing legs are surfaced by z_viewtransaction, so we fold the txid into
    //     `knownTxids` and let the z_viewtransaction / appendViewTransactionOutputs path build them.
    //   * TRANSPARENT outgoing legs (z->t deshields) are NOT surfaced by z_viewtransaction (it
    //     enumerates only Sapling outputs), and for a z-originated send listtransactions also omits
    //     the external t-vout (GetAmounts' isFromMyTaddr is false when inputs are shielded). So we
    //     build those rows directly from the payload's `sends.transparentSends[]` here. They are
    //     deduped (by txid+address+amount) against listtransactions-derived t->t sends above, so a
    //     purely-transparent (t->t) send — which the payload ALSO reports — is not double-counted.
    //
    // Gated on shieldedScanComplete so it adds no cost during the multi-cycle initial received
    // scan: it is one more O(mapWallet) pass holding cs_main (comparable to the listtransactions
    // pass), and the caller's adaptive throttle (txRefreshDue) already accounts for it via scanMs.
    if (result.shieldedScanComplete && !snapshot.shieldedAddresses.empty()) {
        try {
            json sent = rpc.call("z_listsentbyaddress",
                                 json::array({snapshot.shieldedAddresses.front(), 0}));
            if (sent.is_array()) {
                for (const auto& sentTx : sent) {
                    auto txid = readOptional<std::string>(sentTx, "txid");
                    if (!txid || txid->empty()) continue;
                    appendSentTransparentOutputs(result.transactions, *txid, sentTx);
                    knownTxids.insert(*txid);  // shielded (z->z) legs enriched via z_viewtransaction
                }
            }
        } catch (const std::exception& e) {
            transactionRpcError = true;
            DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
        }
    }

    for (const auto& txid : snapshot.sendTxids) {
        knownTxids.insert(txid);
    }

    for (const auto& [txid, cachedView] : snapshot.viewTxCache) {
        if (!cachedView.outgoing_outputs.empty()) knownTxids.insert(txid);
    }

    int viewTxCount = 0;
    for (const auto& txid : knownTxids) {
        bool trackedSend = snapshot.sendTxids.count(txid) > 0;

        auto cached = snapshot.viewTxCache.find(txid);
        if (cached != snapshot.viewTxCache.end()) {
            if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
                appendViewTransactionOutputs(result.transactions, txid, cached->second);
                // NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
                // everything Incoming, so that duplicates every sent message as a phantom "from peer"
                // entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
                // recorded by the local echo. (The recent-refresh variant already omits this.)
                continue;
            }
        }

        if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid)) continue;

        if (viewTxCount >= maxViewTransactionsPerCycle) break;
        ++viewTxCount;

        try {
            json viewTransaction = rpc.call("z_viewtransaction", json::array({txid}));
            if (viewTransaction.is_null() || !viewTransaction.is_object()) {
                if (trackedSend) {
                    json rawTransaction;
                    bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
                    appendTrackedSendPlaceholder(result.transactions,
                                                 txid,
                                                 hasRawTransaction ? &rawTransaction : nullptr);
                }
                continue;
            }

            auto entry = parseViewTransactionCacheEntry(viewTransaction);
            appendViewTransactionOutputs(result.transactions, txid, entry);
            // (Chat metadata is harvested only from RECEIVED notes, not our own outgoing memos — see
            // the cached-view branch above.)

            json rawTransaction;
            bool hasRawTransaction = false;
            bool needsRawTransaction = false;
            for (const auto& info : result.transactions) {
                if (info.txid == txid && info.timestamp == 0) {
                    needsRawTransaction = true;
                    break;
                }
            }
            if (needsRawTransaction) {
                hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
                if (hasRawTransaction) {
                    applyRawTransactionMetadata(entry, rawTransaction);
                    for (auto& info : result.transactions) {
                        if (info.txid == txid && info.timestamp == 0) {
                            applyRawTransactionMetadata(info, rawTransaction, false);
                        }
                    }
                }
            }

            if (trackedSend && !hasTransactionType(result.transactions, txid, "send")) {
                if (!hasRawTransaction) hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
                appendTrackedSendPlaceholder(result.transactions,
                                             txid,
                                             hasRawTransaction ? &rawTransaction : nullptr);
            }

            if (!trackedSend || !entry.outgoing_outputs.empty()) {
                result.newViewTxEntries[txid] = std::move(entry);
            }
        } catch (const std::exception& e) {
            (void)e;
            if (trackedSend) {
                json rawTransaction;
                bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
                appendTrackedSendPlaceholder(result.transactions,
                                             txid,
                                             hasRawTransaction ? &rawTransaction : nullptr);
            }
        }
    }

    appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);

    if (!snapshot.previousTransactions.empty()) {
        if (transactionRpcError) {
            appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, snapshot.pendingOpids);
        } else if (snapshot.shieldedAddresses.empty() || !result.shieldedScanComplete ||
                   result.shieldedAddressesScanned < result.shieldedAddressCount) {
            appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, snapshot.pendingOpids);
        }
    }

    sortTransactionsNewestFirst(result.transactions);
    result.scanMs = std::chrono::duration<double, std::milli>(
        std::chrono::steady_clock::now() - scanStart).count();
    return result;
}

NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRecentTransactionRefreshResult(
    RefreshRpcGateway& rpc,
    const TransactionRefreshSnapshot& snapshot,
    int currentBlockHeight,
    int pageSize)
{
    TransactionRefreshResult result;
    result.blockHeight = currentBlockHeight;
    result.transactions = snapshot.previousTransactions;
    result.shieldedAddressCount = snapshot.shieldedAddresses.size();
    result.shieldedScanHeights = snapshot.shieldedScanHeights;
    HushChatMemoOutputMap hushChatReceivedOutputs;
    HushChatMemoOutputMap* hushChatReceivedOutputsPtr = chat::hushChatFeatureEnabledAtBuild()
        ? &hushChatReceivedOutputs
        : nullptr;

    // Index result.transactions by (txid, type) once so the two replace-loops below
    // can find-and-replace in O(1) instead of a nested linear scan over the full
    // (potentially thousands-large) tx list on every 'recent' refresh cycle. The map
    // holds the index of the FIRST occurrence of each key (preserving the linear
    // scan's break-on-first-match), and is kept in sync on every append so a later
    // entry in the same cycle still finds an earlier appended one — exactly as the
    // re-scanned vector did before.
    auto txKey = [](const TransactionInfo& tx) {
        return tx.txid + '\x1f' + tx.type;
    };
    std::unordered_map<std::string, std::size_t> byKey;
    byKey.reserve(result.transactions.size());
    for (std::size_t i = 0; i < result.transactions.size(); ++i) {
        byKey.emplace(txKey(result.transactions[i]), i);  // keep first-occurrence index
    }

    try {
        std::set<std::string> recentTxids;
        std::vector<TransactionInfo> recentTransactions;
        json transactions = rpc.call("listtransactions", json::array({"", pageSize, 0}));
        appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);

        for (auto& recent : recentTransactions) {
            auto it = byKey.find(txKey(recent));
            if (it != byKey.end()) {
                result.transactions[it->second] = recent;
            } else {
                byKey.emplace(txKey(recent), result.transactions.size());
                result.transactions.push_back(std::move(recent));
            }
        }
    } catch (const std::exception& e) {
        DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
    }

    std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
    if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
    std::size_t shieldedLimit = snapshot.shieldedAddresses.size() - shieldedStart;
    if (snapshot.maxShieldedReceiveScans > 0) {
        shieldedLimit = std::min(shieldedLimit, snapshot.maxShieldedReceiveScans);
    }

    for (std::size_t index = shieldedStart; index < shieldedStart + shieldedLimit; ++index) {
        const auto& address = snapshot.shieldedAddresses[index];
        try {
            std::vector<TransactionInfo> scannedTransactions;
            std::set<std::string> scannedTxids;
            json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
            appendShieldedReceivedTransactions(scannedTransactions,
                                               scannedTxids,
                                               address,
                                               received,
                                               snapshot.miningAddresses,
                                               hushChatReceivedOutputsPtr);
            for (auto& scanned : scannedTransactions) {
                auto it = byKey.find(txKey(scanned));
                if (it != byKey.end()) {
                    result.transactions[it->second] = scanned;
                } else {
                    byKey.emplace(txKey(scanned), result.transactions.size());
                    result.transactions.push_back(std::move(scanned));
                }
            }
            if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
            ++result.shieldedAddressesScanned;
        } catch (const std::exception& e) {
            DEBUG_LOGF("recent z_listreceivedbyaddress error: %s\n", e.what());
        }
    }

    result.nextShieldedScanStartIndex =
        (shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
    result.shieldedScanComplete = true;

    appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);

    sortTransactionsNewestFirst(result.transactions);
    return result;
}

NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOperationStatusPoll(
    const json& result,
    const std::vector<std::string>& requestedOpids)
{
    OperationStatusPollResult parsed;
    if (!result.is_array()) return parsed;

    // We poll z_getoperationstatus with no filter (this daemon rejects the per-opid filtered form),
    // so the result lists ALL operations. Only act on the ones we're tracking — otherwise an
    // unrelated failed/old operation would fire a spurious error toast or pollute send state.
    const std::set<std::string> requested(requestedOpids.begin(), requestedOpids.end());

    std::set<std::string> reported;
    for (const auto& op : result) {
        if (!op.is_object()) continue;
        // Type-checked reads: .value(key, default) throws if the key is PRESENT with a non-string
        // type, which would abort the whole poll (and wedge it for the session — see the call site).
        if (!op.contains("id") || !op["id"].is_string()) continue;
        std::string opid = op["id"].get<std::string>();
        if (opid.empty()) continue;
        if (requested.find(opid) == requested.end()) continue;  // not one of ours — ignore
        reported.insert(opid);

        std::string status = (op.contains("status") && op["status"].is_string())
                                 ? op["status"].get<std::string>() : std::string();
        if (status == "success") {
            parsed.doneOpids.push_back(opid);
            parsed.anySuccess = true;
            if (op.contains("result") && op["result"].is_object() && op["result"].contains("txid")) {
                try {
                    std::string txid = op["result"]["txid"].get<std::string>();
                    if (!txid.empty()) {
                        parsed.successTxids.push_back(txid);
                        parsed.successTxidsByOpid[opid] = std::move(txid);
                    }
                } catch (...) {}
            }
        } else if (status == "failed" || status == "cancelled" || status == "canceled") {
            parsed.doneOpids.push_back(opid);
            std::string msg = status == "failed" ? "Transaction failed" : "Transaction cancelled";
            if (op.contains("error") && op["error"].is_object() && op["error"].contains("message")) {
                try {
                    msg = op["error"]["message"].get<std::string>();
                } catch (...) {}
            }
            parsed.failureMessages.push_back(msg);
            parsed.failureByOpid[opid] = msg;
        }
    }

    for (const auto& opid : requestedOpids) {
        if (!opid.empty() && reported.find(opid) == reported.end()) {
            parsed.staleOpids.push_back(opid);
        }
    }

    return parsed;
}

void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
{
    if (!result.ok) return;

    if (result.daemonVersion) state.daemon_version = *result.daemonVersion;
    if (result.protocolVersion) state.protocol_version = *result.protocolVersion;
    if (result.p2pPort) state.p2p_port = *result.p2pPort;
    if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
    if (result.notarized) state.notarized = *result.notarized;
    if (result.blocks) state.sync.blocks = *result.blocks;
    if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
}

void NetworkRefreshService::applyWalletEncryptionResult(WalletState& state, const WalletEncryptionResult& result)
{
    if (!result.ok) return;

    if (result.encrypted) {
        state.encrypted = true;
        state.unlocked_until = result.unlockedUntil;
        state.locked = (result.unlockedUntil == 0);
    } else {
        state.encrypted = false;
        state.locked = false;
        state.unlocked_until = 0;
    }
    state.encryption_state_known = true;
}

void NetworkRefreshService::applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result)
{
    applyConnectionInfoResult(state, result.info);
    applyWalletEncryptionResult(state, result.encryption);
}

void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
                                                   const CoreRefreshResult& result,
                                                   std::time_t updatedAt)
{
    if (result.balanceOk) {
        if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
        if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
        if (result.totalBalance) state.total_balance = *result.totalBalance;
        // Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
        // display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
        state.spendablePrivateBalance     = result.spendableShieldedBalance.value_or(state.privateBalance);
        state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
        state.spendableTotalBalance       = result.spendableTotalBalance.value_or(state.totalBalance);
        state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
        state.last_balance_update = updatedAt;
    }

    if (!result.blockchainOk) return;

    if (result.blocks) state.sync.blocks = *result.blocks;
    if (result.headers) state.sync.headers = *result.headers;
    if (result.bestBlockHash) state.sync.best_blockhash = *result.bestBlockHash;
    if (result.verificationProgress) state.sync.verification_progress = *result.verificationProgress;
    // A peerless daemon reports longestchain == 0 (no peer heights). Track that as "tip unknown" from
    // THIS refresh's raw value (not the retained state, which keeps the last positive value) so a
    // peerless node isn't reported synced against its own stale local height. (audit: peerless-synced)
    state.sync.tip_known = (result.longestChain.has_value() && *result.longestChain > 0);
    if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
    if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
    if (state.longestchain > 0)
        state.sync.syncing = (state.sync.blocks < state.longestchain - 2);
    else
        state.sync.syncing = (state.sync.blocks < state.sync.headers - 2);
    if (result.notarized) state.notarized = *result.notarized;
}

void NetworkRefreshService::applyMiningRefreshResult(WalletState& state,
                                                     const MiningRefreshResult& result,
                                                     std::time_t updatedAt)
{
    if (result.localHashrate) {
        state.mining.localHashrate = *result.localHashrate;
        state.mining.hashrate_history.push_back(state.mining.localHashrate);
        if (state.mining.hashrate_history.size() > MiningInfo::MAX_HISTORY) {
            state.mining.hashrate_history.erase(state.mining.hashrate_history.begin());
        }
    }

    if (result.miningOk) {
        if (result.generate) state.mining.generate = *result.generate;
        if (result.genproclimit) state.mining.genproclimit = *result.genproclimit;
        if (result.blocks) state.mining.blocks = *result.blocks;
        if (result.difficulty) state.mining.difficulty = *result.difficulty;
        if (result.networkHashrate) state.mining.networkHashrate = *result.networkHashrate;
        if (result.chain) state.mining.chain = *result.chain;
        state.last_mining_update = updatedAt;
    }

    state.mining.daemon_memory_mb = result.daemonMemoryMb;
}

void NetworkRefreshService::applyPeerRefreshResult(WalletState& state,
                                                   PeerRefreshResult&& result,
                                                   std::time_t updatedAt)
{
    state.peers = std::move(result.peers);
    state.bannedPeers = std::move(result.bannedPeers);
    state.last_peer_update = updatedAt;
}

void NetworkRefreshService::markPriceRefreshStarted(WalletState& state)
{
    state.market.price_loading = true;
    state.market.price_error.clear();
}

void NetworkRefreshService::applyPriceRefreshResult(WalletState& state,
                                                    const PriceRefreshResult& result,
                                                    std::chrono::steady_clock::time_point fetchedAt)
{
    state.market.price_loading = false;
    state.market.price_error.clear();
    state.market.price_usd = result.market.price_usd;
    state.market.price_btc = result.market.price_btc;
    state.market.change_24h = result.market.change_24h;
    state.market.volume_24h = result.market.volume_24h;
    state.market.market_cap = result.market.market_cap;
    state.market.last_updated = result.market.last_updated;
    state.market.last_fetch_time = fetchedAt;

    state.market.price_history.push_back(result.market.price_usd);
    if (state.market.price_history.size() > MarketInfo::MAX_HISTORY) {
        state.market.price_history.erase(state.market.price_history.begin());
    }
}

void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
                                                     const std::string& errorMessage)
{
    state.market.price_loading = false;
    state.market.price_error = errorMessage.empty()
        ? std::string("Price fetch failed")
        : errorMessage;
}

void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
                                                      AddressRefreshResult&& result)
{
    state.z_addresses = std::move(result.shieldedAddresses);
    state.t_addresses = std::move(result.transparentAddresses);
    // Keep the combined `addresses` view in sync with the authoritative z/t lists.
    // Consumers that read state.addresses (Market-tab portfolio, auto-shield target
    // selection, pool-mining transparent fallback) depend on this being rebuilt — the
    // full-node refresh path is the only place that bulk-updates the address lists.
    state.rebuildAddressList();
}

void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
                                                          TransactionCacheUpdate cacheUpdate,
                                                          TransactionRefreshResult&& result,
                                                          std::time_t updatedAt)
{
    state.transactions = std::move(result.transactions);
    state.last_tx_update = updatedAt;
    cacheUpdate.lastTxBlockHeight = result.blockHeight;

    for (auto& [txid, entry] : result.newViewTxEntries) {
        bool hasOutgoingOutputs = !entry.outgoing_outputs.empty();
        cacheUpdate.viewTxCache[txid] = std::move(entry);
        if (hasOutgoingOutputs) cacheUpdate.sendTxids.erase(txid);
    }

    cacheUpdate.confirmedTxCache.clear();
    cacheUpdate.confirmedTxIds.clear();
    for (const auto& tx : state.transactions) {
        if (tx.confirmations >= 10 && tx.timestamp != 0) {
            cacheUpdate.confirmedTxIds.insert(tx.txid);
            cacheUpdate.confirmedTxCache.push_back(tx);
        }
    }
    cacheUpdate.confirmedCacheBlock = result.blockHeight;
}

bool NetworkRefreshService::shouldRefreshTransactions(int lastTxBlockHeight,
                                                      int currentBlockHeight,
                                                      bool transactionsDirty) const
{
    return scheduler_.shouldRefreshTransactions(lastTxBlockHeight,
                                                currentBlockHeight,
                                                transactionsDirty);
}

bool NetworkRefreshService::beginJob(Job job)
{
    return beginDispatch(job).accepted;
}

bool NetworkRefreshService::beginJob(Job job, std::size_t queuedWork, std::size_t maxQueuedWork)
{
    return beginDispatch(job, queuedWork, maxQueuedWork).accepted;
}

void NetworkRefreshService::finishJob(Job job)
{
    jobFlag(job).store(false, std::memory_order_release);
}

bool NetworkRefreshService::jobInProgress(Job job) const
{
    return jobFlag(job).load(std::memory_order_acquire);
}

void NetworkRefreshService::resetJobs()
{
    coreInProgress_.store(false, std::memory_order_release);
    addressesInProgress_.store(false, std::memory_order_release);
    transactionsInProgress_.store(false, std::memory_order_release);
    miningInProgress_.store(false, std::memory_order_release);
    peersInProgress_.store(false, std::memory_order_release);
    priceInProgress_.store(false, std::memory_order_release);
    encryptionInProgress_.store(false, std::memory_order_release);
    connectionInitInProgress_.store(false, std::memory_order_release);

    // Bump every job's generation so any ticket minted before this reset (a refresh still
    // in flight on the worker when a disconnect cleared state_) is treated as stale by
    // completeDispatch — preventing last-connection data from landing on the new session.
    for (auto& generation : generations_) {
        generation.fetch_add(1, std::memory_order_acq_rel);
    }
}

NetworkRefreshService::DaemonRescanScan NetworkRefreshService::parseDaemonRescanOutput(const std::string& output)
{
    DaemonRescanScan scan;
    bool& foundRescan = scan.foundRescan;
    bool& finished    = scan.finished;
    float& rescanPct  = scan.rescanPct;
    std::string& lastStatus = scan.lastStatus;

    // Sapling witness rebuild progress. Two daemon signals:
    //   "Setting Initial Sapling Witness for tx <hash>, <i> of <N>" (the common one —
    //      one per wallet tx; <hash> is the stable per-tx key, <N> the total tx count)
    //   "Building Witnesses for block <h> <frac> complete, <n> remaining" (rarer)
    bool&  foundWitness     = scan.foundWitness;
    float& witnessPct       = scan.witnessPct;        // -1 = no fraction parsed this batch
    int&   witnessRemaining = scan.witnessRemaining;
    int&   witnessReadTotal = scan.witnessReadTotal;  // new daemon: the <total> in "<done> / <total>"
    bool&  witnessRebuilt   = scan.witnessRebuilt;    // new daemon's completion line seen this batch
    std::vector<std::string>& witnessTxids = scan.witnessTxids;  // distinct-tx keys seen this batch
    int&   witnessTotalTxs  = scan.witnessTotalTxs;             // max N seen this batch

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

        // Sapling note witness rebuild: "Building Witnesses for block <h> <frac>
        // complete, <n> remaining". The float before "complete" is a 0..1 fraction.
        auto witIdx = line.find("Building Witnesses for block");
        if (witIdx != std::string::npos) {
            foundWitness = true;
            auto compIdx = line.find(" complete", witIdx);
            if (compIdx != std::string::npos) {
                size_t numEnd = compIdx, numStart = numEnd;
                while (numStart > 0 && (std::isdigit((unsigned char)line[numStart - 1]) ||
                                        line[numStart - 1] == '.')) numStart--;
                if (numStart < numEnd) {
                    try { witnessPct = std::stof(line.substr(numStart, numEnd - numStart)) * 100.0f; } catch (...) {}
                }
            }
            auto remIdx = line.find(" remaining", witIdx);
            if (remIdx != std::string::npos) {
                size_t numEnd = remIdx, numStart = numEnd;
                while (numStart > 0 && std::isdigit((unsigned char)line[numStart - 1])) numStart--;
                if (numStart < numEnd) {
                    try { witnessRemaining = std::stoi(line.substr(numStart, numEnd - numStart)); } catch (...) {}
                }
            }
            lastStatus = line;
        }

        // Newer multi-threaded daemon: "Reading blocks for witness rebuild: <done> / <total>".
        // This replaced the per-block "Building Witnesses" line and is an exact done/total
        // count, so capture BOTH (total drives the denominator directly — no peak anchoring).
        auto rdIdx = line.find("Reading blocks for witness rebuild:");
        if (rdIdx != std::string::npos) {
            foundWitness = true;
            auto slash = line.find('/', rdIdx);
            if (slash != std::string::npos) {
                size_t dEnd = slash;
                while (dEnd > rdIdx && !std::isdigit((unsigned char)line[dEnd - 1])) dEnd--;
                size_t dStart = dEnd;
                while (dStart > rdIdx && std::isdigit((unsigned char)line[dStart - 1])) dStart--;
                size_t tStart = slash + 1;
                while (tStart < line.size() && !std::isdigit((unsigned char)line[tStart])) tStart++;
                size_t tEnd = tStart;
                while (tEnd < line.size() && std::isdigit((unsigned char)line[tEnd])) tEnd++;
                if (dStart < dEnd && tStart < tEnd) {
                    try {
                        long done = std::stol(line.substr(dStart, dEnd - dStart));
                        long total = std::stol(line.substr(tStart, tEnd - tStart));
                        if (total > 0 && done >= 0 && done <= total) {
                            witnessRemaining = static_cast<int>(total - done);
                            witnessReadTotal = static_cast<int>(total);
                        }
                    } catch (...) {}
                }
            }
            lastStatus = line;
        }

        // New daemon's witness-rebuild completion line:
        //   "... rebuilt <n> note witness cache(s) to height <h> in <ms>ms using <t> thread(s)".
        // The parallel rebuild logs no progress, so on completion snap the bar to 100%.
        if (line.find("note witness cache") != std::string::npos &&
            line.find("thread(s)") != std::string::npos) {
            foundWitness = true;
            witnessRebuilt = true;
            lastStatus = line;
        }

        // Primary signal: "Setting Initial Sapling Witness for tx <hash>, <i> of <N>".
        // <i> is the tx's slot in an unordered map (bounces — useless as progress), so
        // we key on <hash> (one per tx) and count distinct txs against <N>. Extract the
        // hash between "for tx " and the following comma, and N after " of ".
        {
            auto setIdx = line.find("Setting Initial Sapling Witness for tx ");
            if (setIdx != std::string::npos) {
                foundWitness = true;
                size_t hStart = setIdx + std::string("Setting Initial Sapling Witness for tx ").size();
                size_t comma = line.find(',', hStart);
                if (comma != std::string::npos && comma > hStart) {
                    witnessTxids.push_back(line.substr(hStart, comma - hStart));
                    auto ofIdx = line.find(" of ", comma);
                    if (ofIdx != std::string::npos) {
                        size_t nStart = ofIdx + 4, nEnd = nStart;
                        while (nEnd < line.size() && std::isdigit((unsigned char)line[nEnd])) nEnd++;
                        if (nEnd > nStart) {
                            try {
                                int n = std::stoi(line.substr(nStart, nEnd - nStart));
                                if (n > witnessTotalTxs) witnessTotalTxs = n;
                            } catch (...) {}
                        }
                    }
                }
                lastStatus = line;
            }
        }
    }

    return scan;
}

NetworkRefreshService::DispatchTicket NetworkRefreshService::beginDispatch(
    Job job, std::size_t queuedWork, std::size_t maxQueuedWork)
{
    std::size_t index = jobIndex(job);
    lastQueueDepth_[index].store(queuedWork, std::memory_order_release);
    if (maxQueuedWork > 0 && queuedWork >= maxQueuedWork) {
        skippedQueuePressure_[index].fetch_add(1, std::memory_order_relaxed);
        return {job, generations_[index].load(std::memory_order_acquire), false};
    }

    bool expected = false;
    if (!jobFlag(job).compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
        skippedInFlight_[index].fetch_add(1, std::memory_order_relaxed);
        return {job, generations_[index].load(std::memory_order_acquire), false};
    }

    std::uint64_t generation = generations_[index].fetch_add(1, std::memory_order_acq_rel) + 1;
    started_[index].fetch_add(1, std::memory_order_relaxed);
    return {job, generation, true};
}

bool NetworkRefreshService::completeDispatch(const DispatchTicket& ticket)
{
    if (!ticket.accepted) return false;
    std::size_t index = jobIndex(ticket.job);
    if (generations_[index].load(std::memory_order_acquire) != ticket.generation) {
        staleCallbacks_[index].fetch_add(1, std::memory_order_relaxed);
        return false;
    }
    jobFlag(ticket.job).store(false, std::memory_order_release);
    finished_[index].fetch_add(1, std::memory_order_relaxed);
    return true;
}

void NetworkRefreshService::cancelDispatch(const DispatchTicket& ticket)
{
    if (!ticket.accepted) return;
    std::size_t index = jobIndex(ticket.job);
    if (generations_[index].load(std::memory_order_acquire) == ticket.generation) {
        jobFlag(ticket.job).store(false, std::memory_order_release);
    } else {
        staleCallbacks_[index].fetch_add(1, std::memory_order_relaxed);
    }
}

NetworkRefreshService::JobStats NetworkRefreshService::stats(Job job) const
{
    std::size_t index = jobIndex(job);
    return {
        started_[index].load(std::memory_order_acquire),
        finished_[index].load(std::memory_order_acquire),
        skippedInFlight_[index].load(std::memory_order_acquire),
        skippedQueuePressure_[index].load(std::memory_order_acquire),
        staleCallbacks_[index].load(std::memory_order_acquire),
        lastQueueDepth_[index].load(std::memory_order_acquire)
    };
}

std::atomic<bool>& NetworkRefreshService::jobFlag(Job job)
{
    switch (job) {
        case Job::Core:         return coreInProgress_;
        case Job::Addresses:    return addressesInProgress_;
        case Job::Transactions: return transactionsInProgress_;
        case Job::Mining:       return miningInProgress_;
        case Job::Peers:        return peersInProgress_;
        case Job::Price:        return priceInProgress_;
        case Job::Encryption:   return encryptionInProgress_;
        case Job::ConnectionInit: return connectionInitInProgress_;
        case Job::Count:        return coreInProgress_;
    }
    return coreInProgress_;
}

const std::atomic<bool>& NetworkRefreshService::jobFlag(Job job) const
{
    switch (job) {
        case Job::Core:         return coreInProgress_;
        case Job::Addresses:    return addressesInProgress_;
        case Job::Transactions: return transactionsInProgress_;
        case Job::Mining:       return miningInProgress_;
        case Job::Peers:        return peersInProgress_;
        case Job::Price:        return priceInProgress_;
        case Job::Encryption:   return encryptionInProgress_;
        case Job::ConnectionInit: return connectionInitInProgress_;
        case Job::Count:        return coreInProgress_;
    }
    return coreInProgress_;
}

std::size_t NetworkRefreshService::jobIndex(Job job)
{
    std::size_t index = static_cast<std::size_t>(job);
    constexpr std::size_t count = static_cast<std::size_t>(Job::Count);
    return index < count ? index : 0;
}

} // namespace services
} // namespace dragonx
