#include "chat/chat_crypto.h"
#include "chat/chat_identity.h"
#include "chat/chat_service.h"
#include "chat/chat_database.h"
#include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
#include "util/connect_stall.h"
#include "util/logger.h"
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
#include "data/wallet_index.h"
#include "util/secure_vault.h"
#include "daemon/lifecycle_adapters.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
#include "resources/embedded_resources.h"
#include "services/network_refresh_service.h"
#include "services/refresh_scheduler.h"
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
#include "services/wallet_security_workflow_executor.h"
#include "ui/explorer/explorer_block_cache.h"
#include "ui/windows/balance_address_list.h"
#include "ui/windows/balance_recent_tx.h"
#include "ui/windows/console_input_model.h"
#include "ui/windows/console_model.h"
#include "ui/windows/console_output_model.h"
#include "ui/windows/console_scroll_controller.h"
#include "ui/windows/console_selection_controller.h"
#include "ui/windows/console_tab_helpers.h"
#include "ui/windows/console_text_layout.h"
#include "ui/windows/mining_benchmark.h"
#include "ui/windows/mining_pool_panel.h"
#include "ui/windows/mining_tab_helpers.h"
#include "ui/node_status_banner.h"
#include "ui/staleness_badge.h"
#include "ui/notifications.h"
#include "data/seed_migration_resume.h"
#include "util/address_validation.h"
#include "util/seed_phrase.h"
#include "daemon/daemon_startup_diagnosis.h"
#include "util/amount_format.h"
#include "util/payment_uri.h"
#include "util/platform.h"
#include "util/xmrig_updater.h"
#include "util/pool_registry.h"
#include "util/daemon_updater.h"
#include "util/lite_server_probe.h"
#include "util/wallet_file_probe.h"
#include "wallet/lite_connection_service.h"
#include "wallet/lite_diagnostics.h"
#include "wallet/lite_owned_string.h"
#include "wallet/lite_rollout_policy.h"
#include "wallet/lite_wallet_controller.h"
#include "wallet/lite_wallet_gateway.h"
#include "wallet/lite_wallet_state_mapper.h"
#include "config/settings.h"
#include "data/wallet_state.h"
#include "data/portfolio.h"
#include "data/market_series.h"
#include "data/exchange_candles.h"
#include "fake_lite_backend.h"

#include <chrono>
#include <cmath>
#include <thread>
#include <atomic>
#include <cstdlib>
#include <deque>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <new>
#include <set>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
#include <sodium.h>

namespace fs = std::filesystem;

namespace {

int g_failures = 0;

void expectTrue(bool value, const char* expr, const char* file, int line)
{
    if (value) return;
    std::cerr << file << ":" << line << " expected true: " << expr << "\n";
    ++g_failures;
}

template <typename T, typename U>
void expectEqual(const T& actual, const U& expected, const char* actualExpr,
                 const char* expectedExpr, const char* file, int line)
{
    if (actual == expected) return;
    std::cerr << file << ":" << line << " expected " << actualExpr << " == "
              << expectedExpr << "\n";
    ++g_failures;
}

void expectNear(double actual, double expected, double epsilon, const char* file, int line)
{
    if (std::abs(actual - expected) <= epsilon) return;
    std::cerr << file << ":" << line << " expected near " << expected
              << " (actual=" << actual << ")\n";
    ++g_failures;
}

#define EXPECT_TRUE(expr) expectTrue((expr), #expr, __FILE__, __LINE__)
#define EXPECT_FALSE(expr) expectTrue(!(expr), "!(" #expr ")", __FILE__, __LINE__)
#define EXPECT_EQ(actual, expected) expectEqual((actual), (expected), #actual, #expected, __FILE__, __LINE__)
#define EXPECT_NEAR(actual, expected, epsilon) expectNear((actual), (expected), (epsilon), __FILE__, __LINE__)

fs::path makeTempDir()
{
    auto now = std::chrono::steady_clock::now().time_since_epoch().count();
    fs::path dir = fs::temp_directory_path() / ("obsidian_phase4_tests_" + std::to_string(now));
    fs::create_directories(dir);
    return dir;
}

void writeTestFile(const fs::path& path, const std::string& content)
{
    std::ofstream output(path, std::ios::binary);
    output << content;
}





































































































































































int g_liteBridgeRuntimeFakeCallCount = 0;
int g_liteBridgeRuntimeTrackedFreeCount = 0;
std::vector<std::string> g_liteBridgeRuntimeTrackedFreedValues;

char* makeLiteBridgeRuntimeOwnedCString(const std::string& value)
{
    char* rawValue = new char[value.size() + 1];
    for (std::size_t index = 0; index < value.size(); ++index) {
        rawValue[index] = value[index];
    }
    rawValue[value.size()] = '\0';
    return rawValue;
}

void resetLiteBridgeRuntimeTrackedFree()
{
    g_liteBridgeRuntimeTrackedFreeCount = 0;
    g_liteBridgeRuntimeTrackedFreedValues.clear();
}

void fakeLiteBridgeRuntimeTrackedFreeString(char* value)
{
    ++g_liteBridgeRuntimeTrackedFreeCount;
    if (value) {
        g_liteBridgeRuntimeTrackedFreedValues.push_back(value);
    }
    delete[] value;
}

bool fakeLiteBridgeRuntimeWalletExists(const char*)
{
    ++g_liteBridgeRuntimeFakeCallCount;
    return true;
}

char* fakeLiteBridgeRuntimeInitializeNew(bool, const char*)
{
    ++g_liteBridgeRuntimeFakeCallCount;
    return nullptr;
}

char* fakeLiteBridgeRuntimeInitializeNewFromPhrase(bool,
                                                   const char*,
                                                   const char*,
                                                   unsigned long long,
                                                   unsigned long long,
                                                   bool)
{
    ++g_liteBridgeRuntimeFakeCallCount;
    return nullptr;
}

char* fakeLiteBridgeRuntimeInitializeExisting(bool, const char*)
{
    ++g_liteBridgeRuntimeFakeCallCount;
    return nullptr;
}

char* fakeLiteBridgeRuntimeExecute(const char*, const char*)
{
    ++g_liteBridgeRuntimeFakeCallCount;
    return nullptr;
}

char* fakeLiteBridgeRuntimeOwnedExecute(const char*, const char*)
{
    ++g_liteBridgeRuntimeFakeCallCount;
    return makeLiteBridgeRuntimeOwnedCString("bridge-runtime-ok");
}

void fakeLiteBridgeRuntimeFreeString(char*)
{
    ++g_liteBridgeRuntimeFakeCallCount;
}

bool fakeLiteBridgeRuntimeCheckServerOnline(const char*)
{
    ++g_liteBridgeRuntimeFakeCallCount;
    return true;
}

void fakeLiteBridgeRuntimeShutdown()
{
    ++g_liteBridgeRuntimeFakeCallCount;
}

dragonx::wallet::LiteClientBridgeApi makeCompleteFakeLiteBridgeRuntimeApi()
{
    return dragonx::wallet::LiteClientBridgeApi{
        &fakeLiteBridgeRuntimeWalletExists,
        &fakeLiteBridgeRuntimeInitializeNew,
        &fakeLiteBridgeRuntimeInitializeNewFromPhrase,
        &fakeLiteBridgeRuntimeInitializeExisting,
        &fakeLiteBridgeRuntimeExecute,
        &fakeLiteBridgeRuntimeFreeString,
        &fakeLiteBridgeRuntimeCheckServerOnline,
        &fakeLiteBridgeRuntimeShutdown,
    };
}

class MockWalletSecurityRpc : public dragonx::services::WalletSecurityController::RpcGateway {
public:
    bool encryptResult = true;
    std::string encryptError;
    int encryptCalls = 0;
    std::string lastPassphrase;

    bool encryptWallet(const std::string& passphrase, std::string& error) override
    {
        ++encryptCalls;
        lastPassphrase = passphrase;
        error = encryptError;
        return encryptResult;
    }

    bool unlockWallet(const std::string&, int, std::string&) override { return true; }
    bool exportWallet(const std::string&, long, std::string&) override { return true; }
    bool importWallet(const std::string&, long, std::string&) override { return true; }
};

    class MockDaemonLifecycleRuntime : public dragonx::daemon::DaemonController::LifecycleRuntime {
    public:
        std::vector<std::string> calls;
        int deletedItems = 7;
        bool startResult = true;
        std::string rpcStopContext;
        std::string disconnectReason;

        void stopDaemonWithPolicy() override { calls.push_back("stop"); }
        bool startDaemon() override
        {
            calls.push_back("start");
            return startResult;
        }
        int deleteBlockchainData() override
        {
            calls.push_back("delete");
            return deletedItems;
        }
        void resetOutputOffset() override { calls.push_back("reset-output"); }
        void requestRpcStopAndDisconnect(const char* context, const char* reason) override
        {
            calls.push_back("rpc-stop-disconnect");
            rpcStopContext = context ? context : "";
            disconnectReason = reason ? reason : "";
        }
    };

    class MockDaemonLifecycleTask : public dragonx::daemon::DaemonController::LifecycleTaskContext {
    public:
        bool isCancelled = false;
        bool isShuttingDown = false;
        int cancelAfterSleeps = 0;
        int sleeps = 0;
        int sleptMs = 0;

        bool cancelled() const override { return isCancelled; }
        bool shuttingDown() const override { return isShuttingDown; }
        void sleepForMs(int milliseconds) override
        {
            ++sleeps;
            sleptMs += milliseconds;
            if (cancelAfterSleeps > 0 && sleeps >= cancelAfterSleeps) {
                isCancelled = true;
            }
        }
    };

class MockWalletSecurityVault : public dragonx::services::WalletSecurityController::VaultGateway {
public:
    bool storeResult = true;
    int storeCalls = 0;
    std::string lastPin;
    std::string lastPassphrase;

    bool storePin(const std::string& pin, const std::string& passphrase) override
    {
        ++storeCalls;
        lastPin = pin;
        lastPassphrase = passphrase;
        return storeResult;
    }
};

class MockWorkflowRpc : public dragonx::services::WalletSecurityWorkflowExecutor::RpcGateway {
public:
    bool unlockResult = true;
    bool exportResult = true;
    bool stopResult = true;
    int probeSuccessOnCall = 1;
    std::string unlockError;
    std::string exportError;
    std::string stopError;
    std::string probeError;
    int unlockCalls = 0;
    int exportCalls = 0;
    int stopCalls = 0;
    int probeCalls = 0;
    std::string lastPassphrase;
    std::string lastExportFile;

    bool unlockWallet(const std::string& passphrase, int, std::string& error) override
    {
        ++unlockCalls;
        lastPassphrase = passphrase;
        error = unlockError;
        return unlockResult;
    }

    bool exportWallet(const std::string& fileName, long, std::string& error) override
    {
        ++exportCalls;
        lastExportFile = fileName;
        error = exportError;
        return exportResult;
    }

    bool requestDaemonStop(std::string& error) override
    {
        ++stopCalls;
        error = stopError;
        return stopResult;
    }

    bool probeDaemon(std::string& error) override
    {
        ++probeCalls;
        error = probeError;
        return probeCalls >= probeSuccessOnCall;
    }
};

class MockWorkflowFiles : public dragonx::services::WalletSecurityWorkflowExecutor::FileGateway {
public:
    std::string dir = "/tmp/dragonx/";
    bool backupResult = true;
    std::string backupError;
    int backupCalls = 0;

    std::string dataDir() override { return dir; }
    bool backupEncryptedWallet(const dragonx::services::WalletSecurityWorkflowExecutor::WalletFilePlan&,
                               std::string& error) override
    {
        ++backupCalls;
        error = backupError;
        return backupResult;
    }
};

class MockWorkflowDaemon : public dragonx::services::WalletSecurityWorkflowExecutor::DaemonGateway {
public:
    bool usingEmbedded = true;
    bool isCancelled = false;
    bool isShuttingDown = false;
    int cancelAfterSleeps = 0;
    int sleepCalls = 0;
    int sleptMs = 0;
    int stopCalls = 0;
    int startCalls = 0;

    bool isUsingEmbeddedDaemon() const override { return usingEmbedded; }
    void stopEmbeddedDaemon() override { ++stopCalls; }
    bool startEmbeddedDaemon() override { ++startCalls; return true; }
    bool cancelled() const override { return isCancelled; }
    bool shuttingDown() const override { return isShuttingDown; }
    void sleepForMs(int milliseconds) override
    {
        ++sleepCalls;
        sleptMs += milliseconds;
        if (cancelAfterSleeps > 0 && sleepCalls >= cancelAfterSleeps) {
            isCancelled = true;
        }
    }
};

class MockWorkflowImporter : public dragonx::services::WalletSecurityWorkflowExecutor::ImportGateway {
public:
    bool result = true;
    std::string importError;
    int importCalls = 0;
    std::string lastExportPath;

    bool importWallet(const std::string& exportPath, long, std::string& error) override
    {
        ++importCalls;
        lastExportPath = exportPath;
        error = importError;
        return result;
    }
};

class FakeRefreshWorker {
public:
    std::size_t reportedPending = 0;
    std::vector<dragonx::rpc::RPCWorker::WorkFn> tasks;

    std::size_t pendingTaskCount() const { return reportedPending; }
    void post(dragonx::rpc::RPCWorker::WorkFn work)
    {
        tasks.push_back(std::move(work));
    }
    dragonx::rpc::RPCWorker::MainCb runNext()
    {
        auto work = std::move(tasks.front());
        tasks.erase(tasks.begin());
        return work();
    }
    dragonx::rpc::RPCWorker::MainCb runAt(std::size_t index)
    {
        auto work = std::move(tasks[index]);
        tasks.erase(tasks.begin() + static_cast<std::ptrdiff_t>(index));
        return work();
    }
};

class MockRefreshRpc : public dragonx::services::NetworkRefreshService::RefreshRpcGateway {
public:
    struct RecordedCall {
        std::string method;
        nlohmann::json params;
    };

    struct Response {
        nlohmann::json value;
        bool throws = false;
        std::string error = "mock rpc failure";
    };

    std::vector<RecordedCall> calls;
    std::unordered_map<std::string, std::deque<Response>> responses;

    void addResponse(const std::string& method, nlohmann::json value)
    {
        responses[method].push_back({std::move(value), false, {}});
    }

    void addFailure(const std::string& method, std::string error = "mock rpc failure")
    {
        responses[method].push_back({nlohmann::json(), true, std::move(error)});
    }

    nlohmann::json call(const std::string& method, const nlohmann::json& params) override
    {
        calls.push_back({method, params});
        auto& queue = responses[method];
        if (queue.empty()) {
            throw std::runtime_error("unexpected rpc call: " + method);
        }
        auto response = std::move(queue.front());
        queue.pop_front();
        if (response.throws) throw std::runtime_error(response.error);
        return response.value;
    }

    std::vector<std::string> methodNames() const
    {
        std::vector<std::string> names;
        names.reserve(calls.size());
        for (const auto& call : calls) names.push_back(call.method);
        return names;
    }
};

void testConnectionConfig()
{
    using dragonx::rpc::AuthSource;
    using dragonx::rpc::Connection;

    fs::path dir = makeTempDir();
    fs::path conf = dir / "DRAGONX.conf";
    {
        std::ofstream out(conf);
        out << "rpcuser=alice\n"
            << "rpcpassword = secret \r\n"
            << "rpcconnect=rpc.example.test\n"
            << "rpcport=12345\n"
            << "rpctls=yes\n";
    }

    auto config = Connection::parseConfFile(conf.string());
    EXPECT_EQ(config.rpcuser, std::string("alice"));
    EXPECT_EQ(config.rpcpassword, std::string("secret"));
    EXPECT_EQ(config.host, std::string("rpc.example.test"));
    EXPECT_EQ(config.port, std::string("12345"));
    EXPECT_TRUE(config.use_tls);
    EXPECT_EQ(config.auth_source, AuthSource::ConfigFile);
    EXPECT_FALSE(Connection::usesPlaintextRemote(config));

    fs::path tlsConf = dir / "tls.conf";
    {
        std::ofstream out(tlsConf);
        out << "rpcuser=bob\n"
            << "rpcpassword=remote-secret\n"
            << "rpcconnect=198.51.100.10\n"
            << "rpcssl=1\n";
    }
    auto tlsConfig = Connection::parseConfFile(tlsConf.string());
    EXPECT_TRUE(tlsConfig.use_tls);
    EXPECT_FALSE(Connection::usesPlaintextRemote(tlsConfig));

    config.use_tls = false;
    EXPECT_TRUE(Connection::usesPlaintextRemote(config));
    config.host = "127.4.5.6";
    EXPECT_FALSE(Connection::usesPlaintextRemote(config));
    config.host = "[::1]";
    EXPECT_FALSE(Connection::usesPlaintextRemote(config));

    fs::path cookieDir = dir / "cookie";
    fs::create_directories(cookieDir);
    {
        std::ofstream out(cookieDir / ".cookie");
        out << "__cookie__:cookie-secret\r\n";
    }

    dragonx::rpc::ConnectionConfig cookieBase;
    cookieBase.host = "rpc.example.test";
    cookieBase.hush_dir = cookieDir.string();
    cookieBase.auth_source = AuthSource::ConfigFile;
    cookieBase.use_tls = true;

    dragonx::rpc::ConnectionConfig cookieConfig;
    EXPECT_TRUE(Connection::buildCookieAuthConfig(cookieBase, cookieConfig));
    EXPECT_EQ(cookieConfig.rpcuser, std::string("__cookie__"));
    EXPECT_EQ(cookieConfig.rpcpassword, std::string("cookie-secret"));
    EXPECT_EQ(cookieConfig.auth_source, AuthSource::Cookie);
    EXPECT_TRUE(cookieConfig.use_tls);

    fs::remove_all(dir);
}

void testPaymentUri()
{
    // Real checksummed addresses — the parser now checksum-validates the recipient (not a bare
    // prefix/length filter), so it accepts P2PKH / P2SH / shielded and rejects transcription errors.
    std::string taddr = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti";                 // P2PKH (v60)
    auto parsed = dragonx::util::parsePaymentURI(
        "drgx:" + taddr + "?amount=1.25000000&label=Main+Wallet&memo=hello%20there&message=thanks");

    EXPECT_TRUE(parsed.valid);
    EXPECT_EQ(parsed.address, taddr);
    EXPECT_NEAR(parsed.amount, 1.25, 0.00000001);
    EXPECT_EQ(parsed.label, std::string("Main Wallet"));
    EXPECT_EQ(parsed.memo, std::string("hello there"));
    EXPECT_EQ(parsed.message, std::string("thanks"));

    std::string zaddr = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5";
    auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5");
    EXPECT_TRUE(zparsed.valid);
    EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001);

    // Regression: a P2SH/multisig recipient ("b…", v85) must parse — the old 'R'/'t'-only filter dropped it.
    auto p2sh = dragonx::util::parsePaymentURI("drgx:bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C?amount=1");
    EXPECT_TRUE(p2sh.valid);

    // A transcription error (flipped checksum char) is now rejected at parse time.
    auto typo = dragonx::util::parsePaymentURI("drgx:R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX?amount=1");
    EXPECT_FALSE(typo.valid);

    auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
    EXPECT_FALSE(invalid.valid);
    EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
}

void testWalletFileProbe()
{
    namespace fs = std::filesystem;
    using dragonx::util::probeWalletFile;
    const fs::path dir = fs::path("/tmp/dragonx") / "probe";
    std::error_code ec; fs::create_directories(dir, ec);
    auto writef = [](const fs::path& p, const std::string& d) {
        std::ofstream o(p, std::ios::binary); o.write(d.data(), (std::streamsize)d.size());
    };
    // A valid Berkeley DB btree metapage has the magic 0x00053162 at byte offset 12 (little-endian here).
    auto bdb = []() { std::string h(16, '\0'); h[12] = 0x62; h[13] = 0x31; h[14] = 0x05; h[15] = 0x00; return h; };
    // Wallet records are keyed by a CompactSize-length-prefixed name, e.g. "mkey" -> {0x04,'m','k','e','y'}.
    auto rec = [](const std::string& name) { return std::string(1, (char)name.size()) + name; };

    // 1) Non-BDB file → not recognized, no flags.
    writef(dir / "junk.dat", std::string(4096, 'x'));
    { auto p = probeWalletFile((dir / "junk.dat").string());
      EXPECT_FALSE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }

    // 2) BDB with no markers → legacy plaintext; a complete scan makes the negatives trustworthy.
    writef(dir / "legacy.dat", bdb() + std::string(2000, '\0'));
    { auto p = probeWalletFile((dir / "legacy.dat").string());
      EXPECT_TRUE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed);
      EXPECT_FALSE(p.hasShielded); EXPECT_TRUE(p.scanComplete); }

    // 3) Encrypted (mkey) + HD (hdchain) + shielded (csapzkey).
    writef(dir / "enc.dat", bdb() + std::string(500, 'a') + rec("mkey") + std::string(300, 'b') +
                            rec("hdchain") + std::string(300, 'c') + rec("csapzkey") + std::string(500, 'd'));
    { auto p = probeWalletFile((dir / "enc.dat").string());
      EXPECT_TRUE(p.encrypted); EXPECT_TRUE(p.hdSeed); EXPECT_TRUE(p.hasShielded); }

    // 4) False-positive guard: the bare word "mkey" WITHOUT the 0x04 length prefix must not count.
    writef(dir / "bare.dat", bdb() + std::string(100, '\0') + "mkey" + std::string(100, '\0'));
    { auto p = probeWalletFile((dir / "bare.dat").string()); EXPECT_FALSE(p.encrypted); }

    // 5) Byte cap: a marker beyond maxBytes is missed AND the scan is flagged incomplete (so a caller
    //    won't wrongly assert "legacy"); the same file scanned fully finds it and reports complete.
    { std::string d = bdb(); d.resize(2u * 1024 * 1024, 'q'); d += rec("mkey"); writef(dir / "deep.dat", d);
      auto capped = probeWalletFile((dir / "deep.dat").string(), 1024u * 1024u);
      EXPECT_TRUE(capped.isBerkeleyDB); EXPECT_FALSE(capped.encrypted); EXPECT_FALSE(capped.scanComplete);
      EXPECT_TRUE(capped.bytesRead <= 1024u * 1024u);         // budget accounting reflects a truncated read
      auto full = probeWalletFile((dir / "deep.dat").string());
      EXPECT_TRUE(full.encrypted); EXPECT_TRUE(full.scanComplete); }

    // 6) Exact-boundary case: a file whose length is an exact 1 MiB multiple (and == maxBytes) must still
    //    report scanComplete=true (the peek() guard), so a fully-scanned wallet isn't mislabeled truncated.
    { std::string d = bdb(); d.resize(1024u * 1024u, 'e');    // exactly one 1 MiB chunk, no markers
      writef(dir / "exact.dat", d);
      auto p = probeWalletFile((dir / "exact.dat").string(), 1024u * 1024u);
      EXPECT_TRUE(p.isBerkeleyDB); EXPECT_TRUE(p.scanComplete); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }

    // 7) Btree record counting (tier 2): hand-build a minimal single-database BDB btree — a 512-byte
    //    metapage (magic/pagesize/root) + one leaf page holding "tx", "key", and a "keymeta" record — and
    //    confirm parseWalletBtree walks it, tallies the counts, and reads the keymeta nCreateTime (wallet
    //    birthday). Also that a non-BDB file yields parsed=false.
    {
        std::string w(1024, '\0');
        auto put16 = [&](std::size_t o, unsigned v) { w[o] = (char)(v & 0xff); w[o+1] = (char)((v >> 8) & 0xff); };
        auto put32 = [&](std::size_t o, unsigned v) { for (int k=0;k<4;k++) w[o+k] = (char)((v >> (8*k)) & 0xff); };
        put32(12, 0x00053162u); put32(20, 512); w[25] = (char)9; put32(88, 1);   // page 0: metapage, root = page 1
        const std::size_t P = 512;                                                // page 1: a btree leaf
        put16(P + 20, 6); w[P + 24] = (char)1; w[P + 25] = (char)5;               // entries=6 (3 pairs), level 1, P_LBTREE
        // index array → (key,data) item offsets, laid out high→low without overlap:
        put16(P + 26, 500); put16(P + 28, 496);   // tx
        put16(P + 30, 488); put16(P + 32, 484);   // key
        put16(P + 34, 472); put16(P + 36, 456);   // keymeta
        auto keyItem  = [&](std::size_t off, const std::string& type) {          // BKEYDATA holding CompactSize(len)+type
            std::string kd; kd += (char)type.size(); kd += type;
            put16(P + off, (unsigned)kd.size()); w[P + off + 2] = (char)1;
            for (std::size_t k = 0; k < kd.size(); ++k) w[P + off + 3 + k] = kd[k];
        };
        auto dataItem = [&](std::size_t off) { put16(P + off, 1); w[P + off + 2] = (char)1; w[P + off + 3] = 0; };
        keyItem(500, "tx"); dataItem(496);
        keyItem(488, "key"); dataItem(484);
        keyItem(472, "keymeta");
        // keymeta data = CKeyMetadata: nVersion(int32=10) + nCreateTime(int64) — little-endian.
        const long long kCreated = 1700000000LL;
        put16(P + 456, 12); w[P + 456 + 2] = (char)1;                             // BKEYDATA len=12, type=1, data@459
        put32(456 + 3 + P, 10);                                                   // nVersion
        for (int k = 0; k < 8; ++k) w[P + 456 + 3 + 4 + k] = (char)((kCreated >> (8 * k)) & 0xff);
        writef(dir / "btree.dat", w);
        auto s = dragonx::util::parseWalletBtree((dir / "btree.dat").string());
        EXPECT_TRUE(s.parsed); EXPECT_TRUE(s.complete);
        EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1);
        EXPECT_EQ(s.createdEpoch, kCreated);
        EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);

        // Byte-collecting reader (the wallet-REBUILD read half): same walk, but collects (key,value) bytes.
        auto ex = dragonx::util::extractWalletBtreeRecords((dir / "btree.dat").string());
        EXPECT_TRUE(ex.parsed);
        EXPECT_TRUE(ex.records.size() >= static_cast<size_t>(3));   // tx + key + keymeta captured verbatim
        EXPECT_EQ(ex.keyRecords, 1);                                // the "key" record is fund-critical
        bool foundKeyRec = false;                                   // value copied byte-for-byte, name intact
        for (const auto& kv : ex.records)
            if (kv.first.size() >= 4 && (unsigned char)kv.first[0] == 3 && kv.first.compare(1, 3, "key") == 0
                && !kv.second.empty()) foundKeyRec = true;
        EXPECT_TRUE(foundKeyRec);
        EXPECT_FALSE(dragonx::util::extractWalletBtreeRecords((dir / "junk.dat").string()).parsed);
    }

    // 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the
    //    CHDChain value once nVersion>=3; v1/v2 have no such field (→ "no phrase"), and a short/garbage
    //    value is undecidable (0), never mislabeled "legacy".
    {
        using dragonx::util::hdChainMnemonicFlag;
        auto chdchain = [](int32_t ver, int flag /* -1 = omit the trailing byte */) {
            std::string v;
            for (int k = 0; k < 4; ++k) v += (char)(((uint32_t)ver >> (8 * k)) & 0xff);  // nVersion, LE
            v.append(32 + 8 + 4 + 4, '\x07');                                            // seedFp+times+counters (48B)
            if (flag >= 0) v += (char)(flag ? 1 : 0);                                    // fMnemonicSeed @ offset 52
            return v;
        };
        auto flagOf = [&](const std::string& v) {
            return hdChainMnemonicFlag(reinterpret_cast<const unsigned char*>(v.data()), (uint32_t)v.size());
        };
        EXPECT_EQ(flagOf(chdchain(3, 1)), 1);        // v3, fMnemonicSeed=1 → seed phrase
        EXPECT_EQ(flagOf(chdchain(3, 0)), 2);        // v3, fMnemonicSeed=0 → HD, no phrase
        EXPECT_EQ(flagOf(chdchain(2, -1)), 2);       // v2 (field not serialized) → no phrase
        EXPECT_EQ(flagOf(chdchain(1, -1)), 2);       // v1 → no phrase
        EXPECT_EQ(flagOf(chdchain(3, -1)), 0);       // v3 but value ends before offset 52 → undecidable
        EXPECT_EQ(flagOf(chdchain(999, 1)), 0);      // implausible version → undecidable, NOT legacy
        EXPECT_EQ(hdChainMnemonicFlag(nullptr, 0), 0);
        EXPECT_EQ(flagOf(std::string(3, '\0')), 0);  // < 4 bytes → undecidable
    }

    // 9) End-to-end: a minimal btree whose single record is an hdchain with a v3 CHDChain value →
    //    parseWalletBtree surfaces hdSeed AND the decoded mnemonicSeed (1 for a seed-phrase wallet, 2 when
    //    the flag is clear). Exercises the value-reading path inside the real walk, not just the decoder.
    {
        auto buildHdchainWallet = [](const std::string& chdValue) {
            std::string w(1024, '\0');
            auto put16 = [&](std::size_t o, unsigned v){ w[o]=(char)(v&0xff); w[o+1]=(char)((v>>8)&0xff); };
            auto put32 = [&](std::size_t o, unsigned v){ for(int k=0;k<4;k++) w[o+k]=(char)((v>>(8*k))&0xff); };
            put32(12, 0x00053162u); put32(20, 512); w[25]=(char)9; put32(88, 1);   // metapage → root = page 1
            const std::size_t P = 512;                                             // page 1: one (key,data) pair
            put16(P+20, 2); w[P+24]=(char)1; w[P+25]=(char)5;                       // entries=2, level 1, P_LBTREE
            put16(P+26, 490); put16(P+28, 430);                                    // item offsets: key@490, data@430
            std::string kd; kd += (char)7; kd += "hdchain";                        // key BKEYDATA: CompactSize(7)+name
            put16(P+490, (unsigned)kd.size()); w[P+490+2]=(char)1;
            for (std::size_t k=0;k<kd.size();++k) w[P+490+3+k]=kd[k];
            put16(P+430, (unsigned)chdValue.size()); w[P+430+2]=(char)1;           // data BKEYDATA: the CHDChain value
            for (std::size_t k=0;k<chdValue.size();++k) w[P+430+3+k]=chdValue[k];
            return w;
        };
        std::string v3seed;                                                        // v3, fMnemonicSeed=1
        for (int k=0;k<4;k++) v3seed += (char)((3u>>(8*k))&0xff);
        v3seed.append(48, '\0'); v3seed += (char)1;
        writef(dir / "hdchain_seed.dat", buildHdchainWallet(v3seed));
        auto s1 = dragonx::util::parseWalletBtree((dir / "hdchain_seed.dat").string());
        EXPECT_TRUE(s1.parsed); EXPECT_TRUE(s1.hdSeed); EXPECT_EQ(s1.mnemonicSeed, 1);

        std::string v3nophrase = v3seed; v3nophrase.back() = (char)0;              // fMnemonicSeed=0
        writef(dir / "hdchain_legacy.dat", buildHdchainWallet(v3nophrase));
        auto s2 = dragonx::util::parseWalletBtree((dir / "hdchain_legacy.dat").string());
        EXPECT_TRUE(s2.parsed); EXPECT_TRUE(s2.hdSeed); EXPECT_EQ(s2.mnemonicSeed, 2);
    }

    fs::remove_all(dir, ec);
}

void testAmountFormatting()
{
    EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
    EXPECT_EQ(dragonx::util::formatAmountFixed(0.1), std::string("0.10000000"));
    EXPECT_EQ(dragonx::util::formatAmountFixed(0.000000019), std::string("0.00000002"));
    EXPECT_EQ(dragonx::util::formatAmountFixed(12.3456, 2), std::string("12.35"));
}

void testSpendableFiltering()
{
    std::vector<dragonx::AddressInfo> addresses;
    addresses.push_back({"zs-view", 50.0, "shielded", false});
    addresses.push_back({"zs-low", 2.0, "shielded", true});
    addresses.push_back({"R-zero", 0.0, "transparent", true});
    addresses.push_back({"R-high", 5.0, "transparent", true});
    for (auto& a : addresses) a.spendableBalance = a.balance;  // selection now ranks by CONFIRMED balance

    EXPECT_EQ(dragonx::bestSpendableAddressIndex(addresses), 3);

    auto indices = dragonx::sortedSpendableAddressIndices(addresses);
    EXPECT_EQ(indices.size(), static_cast<size_t>(2));
    EXPECT_EQ(indices[0], static_cast<size_t>(3));
    EXPECT_EQ(indices[1], static_cast<size_t>(1));

    auto includeZero = dragonx::sortedSpendableAddressIndices(addresses, false);
    EXPECT_EQ(includeZero.size(), static_cast<size_t>(3));
}

void testRefreshScheduler()
{
    using dragonx::services::RefreshScheduler;
    using Timer = RefreshScheduler::Timer;

    RefreshScheduler scheduler;
    scheduler.applyPage(dragonx::ui::NavPage::Overview);
    EXPECT_NEAR(scheduler.intervals().core, 2.0, 0.0001);
    // Peers now polls on every tab (slow cadence) so the status-bar peer count stays current.
    EXPECT_NEAR(scheduler.intervals().peers, 20.0, 0.0001);

    scheduler.tick(1.99f);
    EXPECT_FALSE(scheduler.isDue(Timer::Core));
    scheduler.tick(0.02f);
    EXPECT_TRUE(scheduler.consumeDue(Timer::Core));
    EXPECT_FALSE(scheduler.isDue(Timer::Core));

    scheduler.markWalletMutationRefresh();
    EXPECT_TRUE(scheduler.isDue(Timer::Core));
    EXPECT_TRUE(scheduler.isDue(Timer::Transactions));
    EXPECT_TRUE(scheduler.isDue(Timer::Addresses));
    EXPECT_FALSE(scheduler.isDue(Timer::Peers));

    scheduler.applyPage(dragonx::ui::NavPage::Peers);
    scheduler.markDue(Timer::Peers);
    EXPECT_TRUE(scheduler.consumeDue(Timer::Peers));

    scheduler.applyPage(dragonx::ui::NavPage::Console);
    EXPECT_NEAR(scheduler.intervals().core, 10.0, 0.0001);
    EXPECT_NEAR(scheduler.intervals().transactions, 30.0, 0.0001);
    EXPECT_NEAR(scheduler.intervals().addresses, 30.0, 0.0001);
    EXPECT_NEAR(scheduler.intervals().peers, 30.0, 0.0001);

    EXPECT_FALSE(scheduler.isDue(Timer::Price));
    scheduler.markDue(Timer::Price);
    EXPECT_TRUE(scheduler.consumeDue(Timer::Price));

    EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
    EXPECT_TRUE(scheduler.shouldRefreshTransactions(-1, 100, false));
    EXPECT_TRUE(scheduler.shouldRefreshTransactions(99, 100, false));
    EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, true));
    scheduler.tick(RefreshScheduler::kTxMaxAge);
    EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
    EXPECT_TRUE(scheduler.isDue(Timer::TxAge));
}

void testNetworkRefreshService()
{
    using dragonx::services::NetworkRefreshService;
    using Job = NetworkRefreshService::Job;
    using Timer = NetworkRefreshService::Timer;

    NetworkRefreshService service;
    service.applyPage(dragonx::ui::NavPage::Peers);
    service.markImmediateRefresh();
    EXPECT_TRUE(service.consumeDue(Timer::Core));
    EXPECT_TRUE(service.consumeDue(Timer::Transactions));
    EXPECT_TRUE(service.consumeDue(Timer::Addresses));
    EXPECT_TRUE(service.consumeDue(Timer::Peers));

    EXPECT_TRUE(service.beginJob(Job::Core));
    EXPECT_TRUE(service.jobInProgress(Job::Core));
    EXPECT_FALSE(service.beginJob(Job::Core));
    service.finishJob(Job::Core);
    EXPECT_FALSE(service.jobInProgress(Job::Core));
    EXPECT_TRUE(service.beginJob(Job::Core));
    service.resetJobs();
    EXPECT_FALSE(service.jobInProgress(Job::Core));
    EXPECT_TRUE(service.beginJob(Job::ConnectionInit));
    EXPECT_TRUE(service.jobInProgress(Job::ConnectionInit));
    service.finishJob(Job::ConnectionInit);
    EXPECT_FALSE(service.jobInProgress(Job::ConnectionInit));

    auto cancelOld = service.beginDispatch(Job::Core);
    EXPECT_TRUE(cancelOld.accepted);
    service.resetJobs();
    auto cancelNew = service.beginDispatch(Job::Core);
    EXPECT_TRUE(cancelNew.accepted);
    service.cancelDispatch(cancelOld);
    service.cancelDispatch(cancelNew);
    EXPECT_EQ(service.stats(Job::Core).staleCallbacks, static_cast<uint64_t>(1));

    auto ticket = service.beginDispatch(Job::Addresses, 1, 4);
    EXPECT_TRUE(ticket.accepted);
    auto busyTicket = service.beginDispatch(Job::Addresses, 1, 4);
    EXPECT_FALSE(busyTicket.accepted);
    auto stats = service.stats(Job::Addresses);
    EXPECT_EQ(stats.started, static_cast<uint64_t>(1));
    EXPECT_EQ(stats.skippedInFlight, static_cast<uint64_t>(1));
    EXPECT_EQ(stats.lastQueueDepth, static_cast<size_t>(1));
    EXPECT_TRUE(service.completeDispatch(ticket));
    stats = service.stats(Job::Addresses);
    EXPECT_EQ(stats.finished, static_cast<uint64_t>(1));

    auto pressureTicket = service.beginDispatch(Job::Mining, 3, 3);
    EXPECT_FALSE(pressureTicket.accepted);
    stats = service.stats(Job::Mining);
    EXPECT_EQ(stats.skippedQueuePressure, static_cast<uint64_t>(1));
    EXPECT_EQ(stats.lastQueueDepth, static_cast<size_t>(3));

    auto staleTicket = service.beginDispatch(Job::Peers);
    EXPECT_TRUE(staleTicket.accepted);
    service.resetJobs();
    auto newerTicket = service.beginDispatch(Job::Peers);
    EXPECT_TRUE(newerTicket.accepted);
    EXPECT_FALSE(service.completeDispatch(staleTicket));
    service.cancelDispatch(newerTicket);
    stats = service.stats(Job::Peers);
    EXPECT_EQ(stats.started, static_cast<uint64_t>(2));
    EXPECT_EQ(stats.staleCallbacks, static_cast<uint64_t>(1));

    FakeRefreshWorker worker;
    int workerRuns = 0;
    int mainRuns = 0;
    auto enqueued = service.enqueue(Job::Price, worker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
        ++workerRuns;
        return [&]() { ++mainRuns; };
    }, 3);
    EXPECT_TRUE(enqueued.enqueued);
    EXPECT_TRUE(service.jobInProgress(Job::Price));
    EXPECT_EQ(worker.tasks.size(), static_cast<size_t>(1));
    auto mainCallback = worker.runNext();
    EXPECT_EQ(workerRuns, 1);
    mainCallback();
    EXPECT_EQ(mainRuns, 1);
    EXPECT_FALSE(service.jobInProgress(Job::Price));
    stats = service.stats(Job::Price);
    EXPECT_EQ(stats.finished, static_cast<uint64_t>(1));

    auto nullMainEnqueued = service.enqueue(Job::Price, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
        return nullptr;
    }, 3);
    EXPECT_TRUE(nullMainEnqueued.enqueued);
    EXPECT_TRUE(service.jobInProgress(Job::Price));
    auto nullMainCallback = worker.runNext();
    EXPECT_TRUE(static_cast<bool>(nullMainCallback));
    nullMainCallback();
    EXPECT_FALSE(service.jobInProgress(Job::Price));
    stats = service.stats(Job::Price);
    EXPECT_EQ(stats.finished, static_cast<uint64_t>(2));

    worker.reportedPending = 5;
    auto unboundedPrice = service.enqueue(Job::Price, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
        return []() {};
    }, 0);
    EXPECT_TRUE(unboundedPrice.enqueued);
    EXPECT_EQ(unboundedPrice.queueDepth, static_cast<size_t>(5));
    auto unboundedPriceMain = worker.runNext();
    unboundedPriceMain();
    EXPECT_FALSE(service.jobInProgress(Job::Price));

    worker.reportedPending = 3;
    auto pressured = service.enqueue(Job::Mining, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
        return []() {};
    }, 3);
    EXPECT_FALSE(pressured.enqueued);
    EXPECT_EQ(service.stats(Job::Mining).skippedQueuePressure, static_cast<uint64_t>(2));

    FakeRefreshWorker staleWorker;
    int staleMainRuns = 0;
    auto first = service.enqueue(Job::Encryption, staleWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
        return [&]() { ++staleMainRuns; };
    }, 3);
    EXPECT_TRUE(first.enqueued);
    auto oldCallback = staleWorker.runNext();
    service.resetJobs();
    auto second = service.enqueue(Job::Encryption, staleWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
        return [&]() { ++staleMainRuns; };
    }, 3);
    EXPECT_TRUE(second.enqueued);
    oldCallback();
    EXPECT_EQ(staleMainRuns, 0);
    auto freshCallback = staleWorker.runNext();
    freshCallback();
    EXPECT_EQ(staleMainRuns, 1);
    EXPECT_EQ(service.stats(Job::Encryption).staleCallbacks, static_cast<uint64_t>(1));

    FakeRefreshWorker orderingWorker;
    std::vector<std::string> callbackOrder;
    auto orderedCore = service.enqueue(Job::Core, orderingWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
        callbackOrder.push_back("core-work");
        return [&]() { callbackOrder.push_back("core-main"); };
    }, 3);
    auto orderedPrice = service.enqueue(Job::Price, orderingWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
        callbackOrder.push_back("price-work");
        return [&]() { callbackOrder.push_back("price-main"); };
    }, 3);
    EXPECT_TRUE(orderedCore.enqueued);
    EXPECT_TRUE(orderedPrice.enqueued);
    auto priceMain = orderingWorker.runAt(1);
    auto coreMain = orderingWorker.runAt(0);
    priceMain();
    coreMain();
    EXPECT_TRUE(callbackOrder == std::vector<std::string>({"price-work", "core-work", "price-main", "core-main"}));
    EXPECT_FALSE(service.jobInProgress(Job::Core));
    EXPECT_FALSE(service.jobInProgress(Job::Price));

    FakeRefreshWorker reconnectWorker;
    int reconnectMainRuns = 0;
    auto staleBefore = service.stats(Job::Transactions).staleCallbacks;
    auto oldTransactions = service.enqueue(Job::Transactions, reconnectWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
        return [&]() { ++reconnectMainRuns; };
    }, 3);
    EXPECT_TRUE(oldTransactions.enqueued);
    auto oldTransactionsMain = reconnectWorker.runNext();
    service.resetJobs();
    auto freshTransactions = service.enqueue(Job::Transactions, reconnectWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
        return [&]() { ++reconnectMainRuns; };
    }, 3);
    EXPECT_TRUE(freshTransactions.enqueued);
    auto freshTransactionsMain = reconnectWorker.runNext();
    freshTransactionsMain();
    oldTransactionsMain();
    EXPECT_EQ(reconnectMainRuns, 1);
    EXPECT_EQ(service.stats(Job::Transactions).staleCallbacks, staleBefore + 1);
    EXPECT_FALSE(service.jobInProgress(Job::Transactions));
}

void testNetworkRefreshSnapshotHelpers()
{
    using Refresh = dragonx::services::NetworkRefreshService;
    using nlohmann::json;

    auto shielded = Refresh::buildShieldedAddressInfo("zs-viewonly", json{{"ismine", false}}, true);
    EXPECT_EQ(shielded.address, std::string("zs-viewonly"));
    EXPECT_EQ(shielded.type, std::string("shielded"));
    EXPECT_FALSE(shielded.has_spending_key);

    auto legacyShielded = Refresh::buildShieldedAddressInfo("zs-legacy", json::object(), false);
    EXPECT_TRUE(legacyShielded.has_spending_key);

    auto transparentAddresses = Refresh::parseTransparentAddressList(json::array({"R-one", "R-two"}));
    EXPECT_EQ(transparentAddresses.size(), static_cast<size_t>(2));
    EXPECT_EQ(transparentAddresses[0].type, std::string("transparent"));

    std::vector<dragonx::AddressInfo> balanceAddresses = {shielded, legacyShielded};
    Refresh::applyShieldedBalancesFromUnspent(balanceAddresses, json::array({
        json{{"address", "zs-viewonly"}, {"amount", 1.25}},
        json{{"address", "zs-viewonly"}, {"amount", 0.75}},
        json{{"address", "zs-legacy"}, {"amount", 3.0}}
    }));
    EXPECT_NEAR(balanceAddresses[0].balance, 2.0, 0.00000001);
    EXPECT_NEAR(balanceAddresses[1].balance, 3.0, 0.00000001);

    std::vector<dragonx::TransactionInfo> transactions;
    std::set<std::string> knownTxids;
    Refresh::appendTransparentTransactions(transactions, knownTxids, json::array({
        json{{"txid", "transparent-a"}, {"category", "receive"}, {"amount", 1.5},
             {"timereceived", 100}, {"confirmations", 2}, {"address", "R-one"}},
        json{{"txid", "transparent-b"}, {"category", "send"}, {"amount", -0.25},
             {"time", 300}, {"confirmations", 12}}
    }));
    EXPECT_EQ(transactions.size(), static_cast<size_t>(2));
    EXPECT_EQ(knownTxids.count("transparent-a"), static_cast<size_t>(1));
    EXPECT_EQ(transactions[0].timestamp, static_cast<int64_t>(100));
    EXPECT_EQ(transactions[1].timestamp, static_cast<int64_t>(300));

    Refresh::appendShieldedReceivedTransactions(transactions, knownTxids, "zs-viewonly", json::array({
        json{{"txid", "transparent-a"}, {"amount", 9.0}, {"confirmations", 1}},
        json{{"txid", "shielded-change"}, {"change", true}, {"amount", 2.0}},
        json{{"txid", "shielded-receive"}, {"amount", 4.0}, {"confirmations", 5},
             {"time", 200}, {"memoStr", "hello"}}
    }));
    EXPECT_EQ(transactions.size(), static_cast<size_t>(3));
    EXPECT_EQ(knownTxids.count("shielded-receive"), static_cast<size_t>(1));
    EXPECT_EQ(transactions.back().address, std::string("zs-viewonly"));
    EXPECT_EQ(transactions.back().memo, std::string("hello"));

    Refresh::sortTransactionsNewestFirst(transactions);
    EXPECT_EQ(transactions.front().txid, std::string("transparent-b"));
    EXPECT_EQ(transactions.back().txid, std::string("transparent-a"));

    dragonx::WalletState state;
    state.z_addresses.push_back(shielded);
    dragonx::AddressInfo emptyShielded;
    emptyShielded.type = "shielded";
    state.z_addresses.push_back(emptyShielded);
    dragonx::TransactionInfo oldTransaction;
    oldTransaction.txid = "old-complete";
    oldTransaction.confirmations = 7;
    oldTransaction.timestamp = 1000;
    state.transactions.push_back(oldTransaction);

    Refresh::TransactionViewCache viewCache;
    viewCache["cached-view"].from_address = "zs-from";
    std::unordered_set<std::string> sendTxids{"pending-send"};
    auto snapshot = Refresh::buildTransactionRefreshSnapshot(state, viewCache, sendTxids);
    EXPECT_EQ(snapshot.shieldedAddresses.size(), static_cast<size_t>(1));
    EXPECT_EQ(snapshot.shieldedAddresses[0], std::string("zs-viewonly"));
    EXPECT_EQ(snapshot.fullyEnrichedTxids.count("cached-view"), static_cast<size_t>(1));
    EXPECT_EQ(snapshot.fullyEnrichedTxids.count("old-complete"), static_cast<size_t>(1));
    EXPECT_EQ(snapshot.viewTxCache.size(), static_cast<size_t>(1));
    EXPECT_EQ(snapshot.sendTxids.count("pending-send"), static_cast<size_t>(1));
}

void testParseDaemonRescanOutput()
{
    using dragonx::services::NetworkRefreshService;

    // "Still rescanning ... Progress=<frac>" — fraction scaled to a percentage.
    {
        auto scan = NetworkRefreshService::parseDaemonRescanOutput(
            "2026-07-01 Still rescanning at height 500000. Progress=0.42 tip=600000\n");
        EXPECT_TRUE(scan.foundRescan);
        EXPECT_NEAR(scan.rescanPct, 42.0f, 0.01f);
    }

    // "Rescanning... <n>%" — percentage read directly (already 0..100).
    {
        auto scan = NetworkRefreshService::parseDaemonRescanOutput(
            "Rescanning... 37% done\n");
        EXPECT_TRUE(scan.foundRescan);
        EXPECT_NEAR(scan.rescanPct, 37.0f, 0.01f);
    }

    // Explicit completion line marks finished.
    {
        auto scan = NetworkRefreshService::parseDaemonRescanOutput("Done rescanning\n");
        EXPECT_TRUE(scan.finished);
    }

    // The daemon that prints no "Done rescanning" logs a rescan benchmark ending in "<digits>ms".
    {
        auto scan = NetworkRefreshService::parseDaemonRescanOutput(
            "  rescan             16760577ms\n");
        EXPECT_TRUE(scan.finished);
    }

    // "Building Witnesses for block <h> <frac> complete, <n> remaining".
    {
        auto scan = NetworkRefreshService::parseDaemonRescanOutput(
            "Building Witnesses for block 123 0.5 complete, 40 remaining\n");
        EXPECT_TRUE(scan.foundWitness);
        EXPECT_NEAR(scan.witnessPct, 50.0f, 0.01f);
        EXPECT_EQ(scan.witnessRemaining, 40);
    }

    // "Reading blocks for witness rebuild: <done> / <total>" — captures total and remaining.
    {
        auto scan = NetworkRefreshService::parseDaemonRescanOutput(
            "Reading blocks for witness rebuild: 10 / 200\n");
        EXPECT_TRUE(scan.foundWitness);
        EXPECT_EQ(scan.witnessReadTotal, 200);
        EXPECT_EQ(scan.witnessRemaining, 190);  // total - done
    }

    // Parallel-rebuild completion line snaps witnessRebuilt=true.
    {
        auto scan = NetworkRefreshService::parseDaemonRescanOutput(
            "rebuilt 3 note witness cache(s) to height 99 in 12ms using 4 thread(s)\n");
        EXPECT_TRUE(scan.foundWitness);
        EXPECT_TRUE(scan.witnessRebuilt);
    }

    // "Setting Initial Sapling Witness for tx <hash>, <i> of <N>" — distinct hashes, max N.
    {
        auto scan = NetworkRefreshService::parseDaemonRescanOutput(
            "Setting Initial Sapling Witness for tx abc123, 2 of 7\n"
            "Setting Initial Sapling Witness for tx def456, 3 of 7\n"
            "Setting Initial Sapling Witness for tx abc123, 5 of 7\n");
        EXPECT_TRUE(scan.foundWitness);
        // The parser collects every occurrence (dedup happens in the apply step's set), so the
        // vector has all three keys but only two distinct values.
        EXPECT_EQ(scan.witnessTxids.size(), static_cast<size_t>(3));
        std::set<std::string> distinct(scan.witnessTxids.begin(), scan.witnessTxids.end());
        EXPECT_EQ(distinct.size(), static_cast<size_t>(2));
        EXPECT_EQ(distinct.count("abc123"), static_cast<size_t>(1));
        EXPECT_EQ(distinct.count("def456"), static_cast<size_t>(1));
        EXPECT_EQ(scan.witnessTotalTxs, 7);
    }

    // Empty input yields all defaults.
    {
        auto scan = NetworkRefreshService::parseDaemonRescanOutput("");
        EXPECT_FALSE(scan.foundRescan);
        EXPECT_FALSE(scan.finished);
        EXPECT_NEAR(scan.rescanPct, 0.0f, 0.0001f);
        EXPECT_TRUE(scan.lastStatus.empty());
        EXPECT_FALSE(scan.foundWitness);
        EXPECT_NEAR(scan.witnessPct, -1.0f, 0.0001f);
        EXPECT_EQ(scan.witnessRemaining, -1);
        EXPECT_EQ(scan.witnessReadTotal, -1);
        EXPECT_FALSE(scan.witnessRebuilt);
        EXPECT_TRUE(scan.witnessTxids.empty());
        EXPECT_EQ(scan.witnessTotalTxs, -1);
    }
}

void testNetworkRefreshRpcCollectors()
{
    using Refresh = dragonx::services::NetworkRefreshService;
    using nlohmann::json;

    MockRefreshRpc warmupRpc;
    warmupRpc.addResponse("getinfo", json{
        {"version", 120000},
        {"protocolversion", 170002},
        {"blocks", 12},
        {"longestchain", 15},
        {"notarized", 9}
    });
    auto warmup = Refresh::collectWarmupPollResult(warmupRpc);
    EXPECT_TRUE(warmupRpc.methodNames() == std::vector<std::string>({"getinfo"}));
    EXPECT_EQ(warmupRpc.calls[0].params, json::array());
    EXPECT_TRUE(warmup.ready);
    EXPECT_TRUE(warmup.info.ok);
    EXPECT_EQ(*warmup.info.blocks, 12);
    EXPECT_EQ(*warmup.info.longestChain, 15);
    EXPECT_EQ(warmup.errorMessage, std::string());

    MockRefreshRpc warmupFailureRpc;
    warmupFailureRpc.addFailure("getinfo", "Loading block index");
    auto warmupFailure = Refresh::collectWarmupPollResult(warmupFailureRpc);
    EXPECT_TRUE(warmupFailureRpc.methodNames() == std::vector<std::string>({"getinfo"}));
    EXPECT_EQ(warmupFailureRpc.calls[0].params, json::array());
    EXPECT_FALSE(warmupFailure.ready);
    EXPECT_FALSE(warmupFailure.info.ok);
    EXPECT_EQ(warmupFailure.errorMessage, std::string("Loading block index"));

    MockRefreshRpc connectionRpc;
    connectionRpc.addResponse("getinfo", json{
        {"version", 120001},
        {"protocolversion", 170003},
        {"p2pport", 21769},
        {"longestchain", 250},
        {"blocks", 240},
        {"notarized", 230}
    });
    connectionRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
    auto connection = Refresh::collectConnectionInitResult(connectionRpc);
    EXPECT_TRUE(connectionRpc.methodNames() == std::vector<std::string>({
        "getinfo", "getwalletinfo"
    }));
    EXPECT_EQ(connectionRpc.calls[0].params, json::array());
    EXPECT_EQ(connectionRpc.calls[1].params, json::array());
    EXPECT_TRUE(connection.info.ok);
    EXPECT_EQ(*connection.info.daemonVersion, 120001);
    EXPECT_TRUE(connection.encryption.ok);
    EXPECT_TRUE(connection.encryption.encrypted);
    EXPECT_EQ(connection.encryption.unlockedUntil, static_cast<int64_t>(0));

    MockRefreshRpc connectionPrefetchRpc;
    connectionPrefetchRpc.addFailure("getinfo", "daemon info unavailable");
    connectionPrefetchRpc.addResponse("getwalletinfo", json{{"unlocked_until", 99}});
    auto prefetch = Refresh::collectConnectionInitResult(connectionPrefetchRpc);
    EXPECT_TRUE(connectionPrefetchRpc.methodNames() == std::vector<std::string>({
        "getinfo", "getwalletinfo"
    }));
    EXPECT_FALSE(prefetch.info.ok);
    EXPECT_TRUE(prefetch.encryption.ok);
    EXPECT_TRUE(prefetch.encryption.encrypted);
    EXPECT_EQ(prefetch.encryption.unlockedUntil, static_cast<int64_t>(99));

    MockRefreshRpc connectionReuseRpc;
    connectionReuseRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
    auto reusedInfo = Refresh::parseConnectionInfoResult(json{{"version", 120002}, {"blocks", 241}});
    auto reusedConnection = Refresh::collectConnectionInitResult(connectionReuseRpc, reusedInfo);
    EXPECT_TRUE(connectionReuseRpc.methodNames() == std::vector<std::string>({"getwalletinfo"}));
    EXPECT_TRUE(reusedConnection.info.ok);
    EXPECT_EQ(*reusedConnection.info.daemonVersion, 120002);
    EXPECT_TRUE(reusedConnection.encryption.ok);

    MockRefreshRpc coreRpc;
    coreRpc.addResponse("z_gettotalbalance", json{
        {"private", "3.00000000"},
        {"transparent", "1.25000000"},
        {"total", "4.25000000"}
    });
    coreRpc.addResponse("getblockchaininfo", json{
        {"blocks", 150},
        {"headers", 150},
        {"bestblockhash", "core-best-150"},
        {"verificationprogress", 1.0},
        {"longestchain", 150},        // caught up → balance scan runs
        {"notarized", 145}
    });
    auto core = Refresh::collectCoreRefreshResult(coreRpc);
    // getblockchaininfo is issued FIRST — sync detection gates (and must not be delayed behind) the
    // O(mapWallet) balance scan. When caught up, the balance scan follows: display (minconf=0) +
    // spendable (minconf=1).
    EXPECT_TRUE(coreRpc.methodNames() == std::vector<std::string>({
        "getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance"
    }));
    EXPECT_EQ(coreRpc.calls[1].params, json::array({0}));
    EXPECT_EQ(coreRpc.calls[2].params, json::array({1}));
    EXPECT_TRUE(core.balanceOk);
    EXPECT_TRUE(core.blockchainOk);
    EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001);
    EXPECT_EQ(*core.blocks, 150);
    EXPECT_EQ(*core.bestBlockHash, std::string("core-best-150"));
    EXPECT_EQ(*core.longestChain, 150);

    // When getblockchaininfo shows the node is behind (blocks < longestchain - 2), the balance scan is
    // skipped this cycle regardless of includeBalance — so sync-state (and hence kSyncProfile) updates
    // promptly instead of after the multi-second scan. Only getblockchaininfo is issued.
    MockRefreshRpc coreBehindRpc;
    coreBehindRpc.addResponse("z_gettotalbalance", json{{"total", "4.25000000"}});
    coreBehindRpc.addResponse("getblockchaininfo", json{
        {"blocks", 150}, {"headers", 160}, {"longestchain", 160}
    });
    auto coreBehind = Refresh::collectCoreRefreshResult(coreBehindRpc);
    EXPECT_TRUE(coreBehindRpc.methodNames() == std::vector<std::string>({"getblockchaininfo"}));
    EXPECT_FALSE(coreBehind.balanceOk);
    EXPECT_TRUE(coreBehind.blockchainOk);
    EXPECT_EQ(*coreBehind.blocks, 150);
    EXPECT_EQ(*coreBehind.longestChain, 160);

    MockRefreshRpc coreFallbackRpc;
    coreFallbackRpc.addFailure("z_gettotalbalance", "wallet warming up");
    coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}});
    auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc);
    // No longestchain in the response → not classified as "behind" → balance is still attempted (here it
    // fails). getblockchaininfo is still issued first.
    EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector<std::string>({
        "getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance"
    }));
    EXPECT_FALSE(partialCore.balanceOk);
    EXPECT_TRUE(partialCore.blockchainOk);
    EXPECT_EQ(*partialCore.blocks, 8);

    MockRefreshRpc peerRpc;
    peerRpc.addResponse("getpeerinfo", json::array({
        json{{"id", 42}, {"addr", "203.0.113.7:21769"}, {"tls_verified", true}}
    }));
    peerRpc.addResponse("listbanned", json::array({
        json{{"address", "198.51.100.9"}, {"banned_until", 4444}}
    }));
    auto peers = Refresh::collectPeerRefreshResult(peerRpc);
    EXPECT_TRUE(peerRpc.methodNames() == std::vector<std::string>({
        "getpeerinfo", "listbanned"
    }));
    EXPECT_EQ(peerRpc.calls[0].params, json::array());
    EXPECT_EQ(peerRpc.calls[1].params, json::array());
    EXPECT_EQ(peers.peers.size(), static_cast<size_t>(1));
    EXPECT_EQ(peers.peers[0].id, 42);
    EXPECT_TRUE(peers.peers[0].tls_verified);
    EXPECT_EQ(peers.bannedPeers.size(), static_cast<size_t>(1));
    EXPECT_EQ(peers.bannedPeers[0].address, std::string("198.51.100.9"));

    MockRefreshRpc peerPartialRpc;
    peerPartialRpc.addFailure("getpeerinfo", "peer table unavailable");
    peerPartialRpc.addResponse("listbanned", json::array({
        json{{"address", "203.0.113.8"}, {"banned_until", 5555}}
    }));
    auto partialPeers = Refresh::collectPeerRefreshResult(peerPartialRpc);
    EXPECT_TRUE(peerPartialRpc.methodNames() == std::vector<std::string>({
        "getpeerinfo", "listbanned"
    }));
    EXPECT_EQ(partialPeers.peers.size(), static_cast<size_t>(0));
    EXPECT_EQ(partialPeers.bannedPeers.size(), static_cast<size_t>(1));

    MockRefreshRpc miningSlowRpc;
    miningSlowRpc.addResponse("getlocalsolps", json(72.5));
    miningSlowRpc.addResponse("getmininginfo", json{
        {"generate", true},
        {"genproclimit", 6},
        {"blocks", 321},
        {"difficulty", 19.75},
        {"networkhashps", 1250.0},
        {"chain", "main"}
    });
    auto miningSlow = Refresh::collectMiningRefreshResult(miningSlowRpc, 128.0, true);
    EXPECT_TRUE(miningSlowRpc.methodNames() == std::vector<std::string>({
        "getlocalsolps", "getmininginfo"
    }));
    EXPECT_EQ(miningSlowRpc.calls[0].params, json::array());
    EXPECT_EQ(miningSlowRpc.calls[1].params, json::array());
    EXPECT_NEAR(*miningSlow.localHashrate, 72.5, 0.00000001);
    EXPECT_TRUE(miningSlow.miningOk);
    EXPECT_TRUE(*miningSlow.generate);
    EXPECT_EQ(*miningSlow.genproclimit, 6);
    EXPECT_NEAR(miningSlow.daemonMemoryMb, 128.0, 0.00000001);

    MockRefreshRpc miningFastRpc;
    miningFastRpc.addResponse("getlocalsolps", json(80.25));
    auto miningFast = Refresh::collectMiningRefreshResult(miningFastRpc, 64.0, false);
    EXPECT_TRUE(miningFastRpc.methodNames() == std::vector<std::string>({
        "getlocalsolps"
    }));
    EXPECT_NEAR(*miningFast.localHashrate, 80.25, 0.00000001);
    EXPECT_FALSE(miningFast.miningOk);
    EXPECT_NEAR(miningFast.daemonMemoryMb, 64.0, 0.00000001);

    MockRefreshRpc miningPartialRpc;
    miningPartialRpc.addFailure("getlocalsolps", "local solps unavailable");
    miningPartialRpc.addResponse("getmininginfo", json{{"generate", false}, {"blocks", 7}});
    auto miningPartial = Refresh::collectMiningRefreshResult(miningPartialRpc, 12.0, true);
    EXPECT_TRUE(miningPartialRpc.methodNames() == std::vector<std::string>({
        "getlocalsolps", "getmininginfo"
    }));
    EXPECT_FALSE(miningPartial.localHashrate.has_value());
    EXPECT_TRUE(miningPartial.miningOk);
    EXPECT_FALSE(*miningPartial.generate);

    MockRefreshRpc miningSlowOnlyRpc;
    miningSlowOnlyRpc.addResponse("getmininginfo", json{{"generate", false}, {"networkhashps", 222.0}});
    auto miningSlowOnly = Refresh::collectMiningRefreshResult(miningSlowOnlyRpc, 7.0, true, false);
    EXPECT_TRUE(miningSlowOnlyRpc.methodNames() == std::vector<std::string>({"getmininginfo"}));
    EXPECT_FALSE(miningSlowOnly.localHashrate.has_value());
    EXPECT_TRUE(miningSlowOnly.miningOk);
    EXPECT_NEAR(*miningSlowOnly.networkHashrate, 222.0, 0.00000001);

    MockRefreshRpc addressRpc;
    addressRpc.addResponse("z_listaddresses", json::array({"zs-one", "zs-two"}));
    addressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
    addressRpc.addResponse("z_validateaddress", json{{"ismine", false}});
    addressRpc.addResponse("z_listunspent", json::array({
        json{{"address", "zs-one"}, {"amount", 1.0}},
        json{{"address", "zs-one"}, {"amount", 0.5}}
    }));
    addressRpc.addResponse("getaddressesbyaccount", json::array({"R-one"}));
    addressRpc.addResponse("listunspent", json::array({
        json{{"address", "R-one"}, {"amount", 2.25}}
    }));

    auto addresses = Refresh::collectAddressRefreshResult(addressRpc);
    EXPECT_TRUE(addressRpc.methodNames() == std::vector<std::string>({
        "z_listaddresses", "z_validateaddress", "z_validateaddress",
        "z_listunspent", "getaddressesbyaccount", "listunspent"
    }));
    EXPECT_EQ(addressRpc.calls[1].params, json::array({"zs-one"}));
    EXPECT_EQ(addressRpc.calls[2].params, json::array({"zs-two"}));
    EXPECT_EQ(addressRpc.calls[4].params, json::array({""}));
    EXPECT_EQ(addresses.shieldedAddresses.size(), static_cast<size_t>(2));
    EXPECT_TRUE(addresses.shieldedAddresses[0].has_spending_key);
    EXPECT_FALSE(addresses.shieldedAddresses[1].has_spending_key);
    EXPECT_NEAR(addresses.shieldedAddresses[0].balance, 1.5, 0.00000001);
    EXPECT_EQ(addresses.transparentAddresses.size(), static_cast<size_t>(1));
    EXPECT_NEAR(addresses.transparentAddresses[0].balance, 2.25, 0.00000001);

    MockRefreshRpc fallbackRpc;
    fallbackRpc.addResponse("z_listaddresses", json::array({"zs-fallback"}));
    fallbackRpc.addFailure("z_validateaddress", "legacy daemon");
    fallbackRpc.addFailure("z_listunspent", "method not found");
    fallbackRpc.addResponse("z_getbalance", json(4.75));
    fallbackRpc.addResponse("getaddressesbyaccount", json::array());
    fallbackRpc.addResponse("listunspent", json::array());
    auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc);
    EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({
        "z_listaddresses", "z_validateaddress", "z_listunspent",
        "z_getbalance", "z_getbalance", "getaddressesbyaccount", "listunspent"   // display (minconf=0) + spendable (minconf=1)
    }));
    EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
    EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);

    dragonx::WalletState cachedAddressState;
    cachedAddressState.z_addresses.push_back({"zs-cached", 0.0, "shielded", false});
    auto addressSnapshot = Refresh::buildAddressRefreshSnapshot(cachedAddressState);
    MockRefreshRpc cachedAddressRpc;
    cachedAddressRpc.addResponse("z_listaddresses", json::array({"zs-cached", "zs-new"}));
    cachedAddressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
    cachedAddressRpc.addResponse("z_listunspent", json::array());
    cachedAddressRpc.addResponse("getaddressesbyaccount", json::array());
    cachedAddressRpc.addResponse("listunspent", json::array());
    auto cachedAddresses = Refresh::collectAddressRefreshResult(cachedAddressRpc, addressSnapshot);
    EXPECT_TRUE(cachedAddressRpc.methodNames() == std::vector<std::string>({
        "z_listaddresses", "z_validateaddress", "z_listunspent",
        "getaddressesbyaccount", "listunspent"
    }));
    EXPECT_FALSE(cachedAddresses.shieldedAddresses[0].has_spending_key);
    EXPECT_TRUE(cachedAddresses.shieldedAddresses[1].has_spending_key);

    Refresh::TransactionRefreshSnapshot snapshot;
    snapshot.shieldedAddresses = {"zs-one", "zs-two"};
    snapshot.sendTxids = {"cached-send", "pending-send"};
    snapshot.fullyEnrichedTxids = {"shielded-receive", "transparent-a"};
    Refresh::TransactionViewCacheEntry cachedEntry;
    cachedEntry.from_address = "zs-cache-from";
    cachedEntry.outgoing_outputs.push_back({"zs-cache-dest", 0.20, "cached memo"});
    snapshot.viewTxCache["cached-send"] = cachedEntry;

    MockRefreshRpc transactionRpc;
    transactionRpc.addResponse("listtransactions", json::array({
        json{{"txid", "transparent-a"}, {"category", "receive"}, {"amount", 1.0},
             {"time", 100}, {"confirmations", 3}, {"address", "R-one"}}
    }));
    transactionRpc.addResponse("z_listreceivedbyaddress", json::array({
        json{{"txid", "shielded-receive"}, {"amount", 2.0}, {"confirmations", 4},
             {"time", 50}, {"memoStr", "shielded memo"}}
    }));
    transactionRpc.addResponse("z_listreceivedbyaddress", json::array());
    transactionRpc.addResponse("z_listsentbyaddress", json::array());
    transactionRpc.addResponse("z_viewtransaction", json{
        {"spends", json::array({json{{"address", "zs-from"}}})},
        {"outputs", json::array({
            json{{"outgoing", true}, {"address", "zs-dest"}, {"value", 0.40}, {"memoStr", "fresh memo"}}
        })}
    });
    transactionRpc.addResponse("gettransaction", json{{"time", 500}, {"confirmations", 1}});

    auto transactionResult = Refresh::collectTransactionRefreshResult(transactionRpc, snapshot, 321, 4);
    EXPECT_TRUE(transactionRpc.methodNames() == std::vector<std::string>({
        "listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress",
        "z_listsentbyaddress", "z_viewtransaction", "gettransaction"
    }));
    EXPECT_EQ(transactionRpc.calls[1].params, json::array({"zs-one", 0}));
    EXPECT_EQ(transactionRpc.calls[2].params, json::array({"zs-two", 0}));
    EXPECT_EQ(transactionRpc.calls[3].params, json::array({"zs-one", 0}));
    EXPECT_EQ(transactionRpc.calls[4].params, json::array({"pending-send"}));
    EXPECT_EQ(transactionRpc.calls[5].params, json::array({"pending-send"}));
    EXPECT_EQ(transactionResult.blockHeight, 321);
    EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast<size_t>(1));
    EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast<size_t>(1));
    EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").timestamp, static_cast<int64_t>(500));
    EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").confirmations, 1);
    EXPECT_EQ(transactionResult.transactions.size(), static_cast<size_t>(4));
    EXPECT_EQ(transactionResult.transactions.front().txid, std::string("pending-send"));
    EXPECT_EQ(transactionResult.transactions.front().type, std::string("send"));
    EXPECT_NEAR(transactionResult.transactions.front().amount, -0.40, 0.00000001);
    EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast<int64_t>(500));
    EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a"));

    // Outgoing shielded (z->z) send discovery. A pure z->z send has valueBalance == 0, so it is
    // absent from listtransactions AND (being a send, not a receive) from z_listreceivedbyaddress —
    // it is surfaced only via the whole-wallet z_listsentbyaddress sweep, whose txids are folded
    // into the z_viewtransaction enrichment to build the send row. Regression test for the
    // "outgoing transactions missing from History" bug.
    {
        Refresh::TransactionRefreshSnapshot zzSnapshot;
        zzSnapshot.shieldedAddresses = {"zs-mine"};
        MockRefreshRpc zzRpc;
        zzRpc.addResponse("listtransactions", json::array());         // no transparent movement
        zzRpc.addResponse("z_listreceivedbyaddress", json::array());  // it's a send, not a receive
        zzRpc.addResponse("z_listsentbyaddress", json::array({
            json{{"txid", "zz-send"}}                                 // discovered only here
        }));
        zzRpc.addResponse("z_viewtransaction", json{
            {"spends", json::array({json{{"address", "zs-mine"}}})},
            {"outputs", json::array({
                json{{"outgoing", true}, {"address", "zs-recipient"}, {"value", 1.25},
                     {"memoStr", "z2z memo"}}
            })}
        });
        zzRpc.addResponse("gettransaction", json{{"time", 800}, {"confirmations", 3}});
        auto zzResult = Refresh::collectTransactionRefreshResult(zzRpc, zzSnapshot, 400, 4);
        EXPECT_TRUE(zzRpc.methodNames() == std::vector<std::string>({
            "listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress",
            "z_viewtransaction", "gettransaction"
        }));
        EXPECT_EQ(zzResult.transactions.size(), static_cast<size_t>(1));
        EXPECT_EQ(zzResult.transactions[0].txid, std::string("zz-send"));
        EXPECT_EQ(zzResult.transactions[0].type, std::string("send"));
        EXPECT_NEAR(zzResult.transactions[0].amount, -1.25, 0.00000001);
        EXPECT_EQ(zzResult.transactions[0].address, std::string("zs-recipient"));
        EXPECT_EQ(zzResult.transactions[0].memo, std::string("z2z memo"));
        EXPECT_EQ(zzResult.transactions[0].timestamp, static_cast<int64_t>(800));
        EXPECT_EQ(zzResult.newViewTxEntries.count("zz-send"), static_cast<size_t>(1));

        // Guard: with no shielded addresses the sweep is skipped entirely (transparent sends are
        // already covered by listtransactions), so no z_listsentbyaddress call is made.
        Refresh::TransactionRefreshSnapshot noZSnapshot;
        MockRefreshRpc noZRpc;
        noZRpc.addResponse("listtransactions", json::array());
        auto noZResult = Refresh::collectTransactionRefreshResult(noZRpc, noZSnapshot, 401, 4);
        EXPECT_TRUE(noZRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
        EXPECT_EQ(noZResult.transactions.size(), static_cast<size_t>(0));
    }

    // z->t deshield: the external transparent recipient is reported ONLY in z_listsentbyaddress's
    // sends.transparentSends (z_viewtransaction sees only the shielded change note, isOutgoing=false;
    // listtransactions omits the t-vout because inputs are shielded). The row must be synthesized
    // directly from that payload — a direct sibling of the z->z gap.
    {
        Refresh::TransactionRefreshSnapshot ztSnapshot;
        ztSnapshot.shieldedAddresses = {"zs-mine"};
        MockRefreshRpc ztRpc;
        ztRpc.addResponse("listtransactions", json::array());         // z-originated: no t-input row
        ztRpc.addResponse("z_listreceivedbyaddress", json::array());
        ztRpc.addResponse("z_listsentbyaddress", json::array({
            json{{"txid", "zt-send"}, {"confirmations", 5}, {"blocktime", 900},
                 {"sends", json{
                     {"transparentSends", json::array({
                         json{{"address", "R-recipient"}, {"amount", -2.5}, {"vout", 0}}
                     })},
                     {"saplingSends", json::array()}
                 }}}
        }));
        // z->t viewtransaction: only a change note back to self -> no outgoing outputs.
        ztRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
        auto ztResult = Refresh::collectTransactionRefreshResult(ztRpc, ztSnapshot, 500, 4);
        EXPECT_EQ(ztResult.transactions.size(), static_cast<size_t>(1));
        EXPECT_EQ(ztResult.transactions[0].txid, std::string("zt-send"));
        EXPECT_EQ(ztResult.transactions[0].type, std::string("send"));
        EXPECT_NEAR(ztResult.transactions[0].amount, -2.5, 0.00000001);
        EXPECT_EQ(ztResult.transactions[0].address, std::string("R-recipient"));
        EXPECT_EQ(ztResult.transactions[0].confirmations, 5);
    }

    // t->t send is reported by BOTH listtransactions AND z_listsentbyaddress's transparentSends —
    // it must appear ONCE (deduped by txid+address+amount), not doubled.
    {
        Refresh::TransactionRefreshSnapshot ttSnapshot;
        ttSnapshot.shieldedAddresses = {"zs-mine"};
        MockRefreshRpc ttRpc;
        ttRpc.addResponse("listtransactions", json::array({
            json{{"txid", "tt-send"}, {"category", "send"}, {"amount", -1.0},
                 {"time", 800}, {"confirmations", 3}, {"address", "R-dest"}}
        }));
        ttRpc.addResponse("z_listreceivedbyaddress", json::array());
        ttRpc.addResponse("z_listsentbyaddress", json::array({
            json{{"txid", "tt-send"}, {"confirmations", 3}, {"blocktime", 800},
                 {"sends", json{{"transparentSends", json::array({
                     json{{"address", "R-dest"}, {"amount", -1.0}, {"vout", 0}}
                 })}}}}
        }));
        ttRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
        auto ttResult = Refresh::collectTransactionRefreshResult(ttRpc, ttSnapshot, 500, 4);
        int ttSends = 0;
        for (const auto& t : ttResult.transactions)
            if (t.txid == std::string("tt-send") && t.type == std::string("send")) ++ttSends;
        EXPECT_EQ(ttSends, 1);  // not double-counted
    }

    // Multi-output send: two outgoing outputs of EQUAL value to DIFFERENT recipients in one tx must
    // both render (address-keyed dedup) — an amount-only dedup dropped the second.
    {
        Refresh::TransactionRefreshSnapshot multiSnapshot;
        multiSnapshot.sendTxids = {"multi-send"};
        MockRefreshRpc multiRpc;
        multiRpc.addResponse("listtransactions", json::array());
        multiRpc.addResponse("z_viewtransaction", json{
            {"spends", json::array({json{{"address", "zs-from"}}})},
            {"outputs", json::array({
                json{{"outgoing", true}, {"address", "zs-a"}, {"value", 0.5}, {"memoStr", "a"}},
                json{{"outgoing", true}, {"address", "zs-b"}, {"value", 0.5}, {"memoStr", "b"}}
            })}
        });
        multiRpc.addResponse("gettransaction", json{{"time", 700}, {"confirmations", 2}});
        auto multiResult = Refresh::collectTransactionRefreshResult(multiRpc, multiSnapshot, 500, 4);
        int multiSends = 0;
        for (const auto& t : multiResult.transactions)
            if (t.txid == std::string("multi-send") && t.type == std::string("send")) ++multiSends;
        EXPECT_EQ(multiSends, 2);  // both equal-value outputs kept
    }

    Refresh::TransactionRefreshSnapshot cachedOnlySnapshot;
    auto cachedOnlyEntry = cachedEntry;
    cachedOnlyEntry.timestamp = 450;
    cachedOnlyEntry.confirmations = 6;
    cachedOnlySnapshot.viewTxCache["cached-only-send"] = cachedOnlyEntry;
    cachedOnlySnapshot.fullyEnrichedTxids = {"cached-only-send"};
    MockRefreshRpc cachedOnlyRpc;
    cachedOnlyRpc.addResponse("listtransactions", json::array());
    auto cachedOnlyResult = Refresh::collectTransactionRefreshResult(cachedOnlyRpc, cachedOnlySnapshot, 322, 4);
    EXPECT_TRUE(cachedOnlyRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
    EXPECT_EQ(cachedOnlyResult.transactions.size(), static_cast<size_t>(1));
    EXPECT_EQ(cachedOnlyResult.transactions[0].txid, std::string("cached-only-send"));
    EXPECT_EQ(cachedOnlyResult.transactions[0].type, std::string("send"));
    EXPECT_NEAR(cachedOnlyResult.transactions[0].amount, -0.20, 0.00000001);
    EXPECT_EQ(cachedOnlyResult.transactions[0].timestamp, static_cast<int64_t>(450));
    EXPECT_EQ(cachedOnlyResult.transactions[0].confirmations, 6);

    Refresh::TransactionRefreshSnapshot retrySnapshot;
    retrySnapshot.sendTxids = {"retry-send"};
    retrySnapshot.viewTxCache["retry-send"] = Refresh::TransactionViewCacheEntry{};
    retrySnapshot.fullyEnrichedTxids = {"retry-send"};
    MockRefreshRpc retryRpc;
    retryRpc.addResponse("listtransactions", json::array());
    retryRpc.addResponse("z_viewtransaction", json{
        {"spends", json::array({json{{"address", "zs-retry-from"}}})},
        {"outputs", json::array({
            json{{"outgoing", true}, {"address", "zs-retry-dest"}, {"value", 0.55}}
        })}
    });
    retryRpc.addResponse("gettransaction", json{{"time", 650}, {"confirmations", 2}});
    auto retryResult = Refresh::collectTransactionRefreshResult(retryRpc, retrySnapshot, 323, 4);
    EXPECT_TRUE(retryRpc.methodNames() == std::vector<std::string>({
        "listtransactions", "z_viewtransaction", "gettransaction"
    }));
    EXPECT_EQ(retryResult.transactions.size(), static_cast<size_t>(1));
    EXPECT_EQ(retryResult.transactions[0].txid, std::string("retry-send"));
    EXPECT_NEAR(retryResult.transactions[0].amount, -0.55, 0.00000001);
    EXPECT_EQ(retryResult.newViewTxEntries.count("retry-send"), static_cast<size_t>(1));
    EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").timestamp, static_cast<int64_t>(650));
    EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").confirmations, 2);

    Refresh::TransactionRefreshSnapshot placeholderSnapshot;
    placeholderSnapshot.sendTxids = {"placeholder-send"};
    MockRefreshRpc placeholderRpc;
    placeholderRpc.addResponse("listtransactions", json::array());
    placeholderRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
    placeholderRpc.addResponse("gettransaction", json{
        {"time", 700},
        {"confirmations", 0},
        {"amount", -0.33},
        {"details", json::array({
            json{{"category", "send"}, {"address", "zs-placeholder-dest"}, {"amount", -0.33}}
        })}
    });
    auto placeholderResult = Refresh::collectTransactionRefreshResult(placeholderRpc, placeholderSnapshot, 324, 4);
    EXPECT_TRUE(placeholderRpc.methodNames() == std::vector<std::string>({
        "listtransactions", "z_viewtransaction", "gettransaction"
    }));
    EXPECT_EQ(placeholderResult.transactions.size(), static_cast<size_t>(1));
    EXPECT_EQ(placeholderResult.transactions[0].txid, std::string("placeholder-send"));
    EXPECT_EQ(placeholderResult.transactions[0].type, std::string("send"));
    EXPECT_NEAR(placeholderResult.transactions[0].amount, -0.33, 0.00000001);
    EXPECT_EQ(placeholderResult.transactions[0].timestamp, static_cast<int64_t>(700));
    EXPECT_EQ(placeholderResult.transactions[0].confirmations, 0);
    EXPECT_EQ(placeholderResult.transactions[0].address, std::string("zs-placeholder-dest"));
    EXPECT_EQ(placeholderResult.newViewTxEntries.count("placeholder-send"), static_cast<size_t>(0));

    Refresh::TransactionRefreshSnapshot missingAddressesSnapshot;
    missingAddressesSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
        "shielded-fallback", "receive", 1.25, 150, 2, "zs-one", "", "memo"
    });
    MockRefreshRpc missingAddressesRpc;
    missingAddressesRpc.addResponse("listtransactions", json::array());
    auto missingAddressesResult = Refresh::collectTransactionRefreshResult(
        missingAddressesRpc, missingAddressesSnapshot, 325, 4);
    EXPECT_TRUE(missingAddressesRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
    EXPECT_EQ(missingAddressesResult.transactions.size(), static_cast<size_t>(1));
    EXPECT_EQ(missingAddressesResult.transactions[0].txid, std::string("shielded-fallback"));
    EXPECT_EQ(missingAddressesResult.transactions[0].type, std::string("receive"));

    Refresh::TransactionRefreshSnapshot pendingOpidSnapshot;
    pendingOpidSnapshot.pendingOpids = {"opid-visible-send"};
    pendingOpidSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
        "opid-visible-send", "send", -4.25, 170, 0, "R-destination", "R-source", ""
    });
    MockRefreshRpc pendingOpidRpc;
    pendingOpidRpc.addResponse("listtransactions", json::array());
    auto pendingOpidResult = Refresh::collectTransactionRefreshResult(
        pendingOpidRpc, pendingOpidSnapshot, 326, 4);
    EXPECT_TRUE(pendingOpidRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
    EXPECT_EQ(pendingOpidResult.transactions.size(), static_cast<size_t>(1));
    EXPECT_EQ(pendingOpidResult.transactions[0].txid, std::string("opid-visible-send"));
    EXPECT_EQ(pendingOpidResult.transactions[0].type, std::string("send"));
    EXPECT_NEAR(pendingOpidResult.transactions[0].amount, -4.25, 0.00000001);

    Refresh::TransactionRefreshSnapshot partialFailureSnapshot;
    partialFailureSnapshot.shieldedAddresses = {"zs-one"};
    partialFailureSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
        "old-receive", "receive", 2.50, 140, 8, "zs-one", "", "old memo"
    });
    MockRefreshRpc partialFailureRpc;
    partialFailureRpc.addResponse("listtransactions", json::array({
        json{{"txid", "transparent-b"}, {"category", "receive"}, {"amount", 0.75},
             {"time", 160}, {"confirmations", 2}, {"address", "R-two"}}
    }));
    partialFailureRpc.addFailure("z_listreceivedbyaddress", "temporary receive failure");
    auto partialFailureResult = Refresh::collectTransactionRefreshResult(
        partialFailureRpc, partialFailureSnapshot, 326, 4);
    EXPECT_EQ(partialFailureResult.transactions.size(), static_cast<size_t>(2));
    EXPECT_EQ(partialFailureResult.transactions[0].txid, std::string("transparent-b"));
    EXPECT_EQ(partialFailureResult.transactions[1].txid, std::string("old-receive"));

    Refresh::TransactionRefreshSnapshot pagedSnapshot;
    MockRefreshRpc pagedRpc;
    json firstPage = json::array();
    for (int i = 0; i < 1000; ++i) {
        firstPage.push_back(json{{"txid", "paged-" + std::to_string(i)}, {"category", "receive"},
                                  {"amount", 0.01}, {"time", i}, {"confirmations", 10},
                                  {"address", "R-page"}});
    }
    pagedRpc.addResponse("listtransactions", firstPage);
    pagedRpc.addResponse("listtransactions", json::array({
        json{{"txid", "paged-1000"}, {"category", "receive"}, {"amount", 0.02},
             {"time", 2000}, {"confirmations", 11}, {"address", "R-page"}}
    }));
    auto pagedResult = Refresh::collectTransactionRefreshResult(pagedRpc, pagedSnapshot, 327, 0);
    EXPECT_TRUE(pagedRpc.methodNames() == std::vector<std::string>({"listtransactions", "listtransactions"}));
    EXPECT_EQ(pagedRpc.calls[0].params, json::array({"", 1000, 0}));
    EXPECT_EQ(pagedRpc.calls[1].params, json::array({"", 1000, 1000}));
    EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
    EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));

    // shieldedScanTipTolerance: an address scanned a few blocks below the tip is re-scanned when
    // strict (tolerance 0) but SKIPPED within tolerance — this is what lets a multi-block pass
    // complete (and unsticks the perpetual "refreshing history" state).
    {
        Refresh::TransactionRefreshSnapshot strictSnapshot;
        strictSnapshot.shieldedAddresses = {"zs-a", "zs-b"};
        strictSnapshot.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}};
        strictSnapshot.maxShieldedReceiveScans = 4;
        strictSnapshot.shieldedScanTipTolerance = 0;  // strict: heights 100 are stale at tip 102
        MockRefreshRpc strictRpc;
        strictRpc.addResponse("listtransactions", json::array());
        strictRpc.addResponse("z_listreceivedbyaddress", json::array());
        strictRpc.addResponse("z_listreceivedbyaddress", json::array());
        strictRpc.addResponse("z_listsentbyaddress", json::array());
        auto strictResult = Refresh::collectTransactionRefreshResult(strictRpc, strictSnapshot, 102, 4);
        EXPECT_EQ(strictResult.shieldedAddressesScanned, static_cast<size_t>(2));  // both re-scanned

        Refresh::TransactionRefreshSnapshot tolerantSnapshot;
        tolerantSnapshot.shieldedAddresses = {"zs-a", "zs-b"};
        tolerantSnapshot.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}};
        tolerantSnapshot.maxShieldedReceiveScans = 4;
        tolerantSnapshot.shieldedScanTipTolerance = 5;  // 100 >= 102-5 -> still fresh
        MockRefreshRpc tolerantRpc;
        tolerantRpc.addResponse("listtransactions", json::array());
        // No z_listreceivedbyaddress responses: the addresses must be skipped (else call() throws).
        // The shielded scan still completes, so the whole-wallet z_listsentbyaddress sweep runs once.
        tolerantRpc.addResponse("z_listsentbyaddress", json::array());
        auto tolerantResult = Refresh::collectTransactionRefreshResult(tolerantRpc, tolerantSnapshot, 102, 4);
        EXPECT_EQ(tolerantResult.shieldedAddressesScanned, static_cast<size_t>(0));  // both skipped
        EXPECT_TRUE(tolerantResult.shieldedScanComplete);
        EXPECT_TRUE(tolerantRpc.methodNames() == std::vector<std::string>({"listtransactions", "z_listsentbyaddress"}));
    }

    Refresh::TransactionRefreshSnapshot recentSnapshot;
    dragonx::TransactionInfo previousShielded;
    previousShielded.txid = "shielded-old";
    previousShielded.type = "receive";
    previousShielded.address = "zs-one";
    previousShielded.amount = 3.0;
    previousShielded.timestamp = 10;
    dragonx::TransactionInfo previousTransparent;
    previousTransparent.txid = "recent-one";
    previousTransparent.type = "receive";
    previousTransparent.address = "R-one";
    previousTransparent.amount = 1.0;
    previousTransparent.timestamp = 20;
    recentSnapshot.previousTransactions = {previousShielded, previousTransparent};
    MockRefreshRpc recentRpc;
    recentRpc.addResponse("listtransactions", json::array({
        json{{"txid", "recent-one"}, {"category", "receive"}, {"address", "R-one"}, {"amount", 2.0}, {"time", 30}, {"confirmations", 0}},
        json{{"txid", "recent-two"}, {"category", "send"}, {"address", "R-two"}, {"amount", -0.5}, {"time", 40}, {"confirmations", 0}}
    }));
    auto recent = Refresh::collectRecentTransactionRefreshResult(recentRpc, recentSnapshot, 123);
    EXPECT_TRUE(recentRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
    EXPECT_EQ(recentRpc.calls[0].params, json::array({"", 100, 0}));
    EXPECT_EQ(recent.blockHeight, 123);
    EXPECT_EQ(recent.transactions.size(), static_cast<size_t>(3));
    EXPECT_EQ(recent.transactions[0].txid, std::string("recent-two"));
    EXPECT_EQ(recent.transactions[1].txid, std::string("recent-one"));
    EXPECT_NEAR(recent.transactions[1].amount, 2.0, 0.00000001);
    EXPECT_EQ(recent.transactions[2].txid, std::string("shielded-old"));

    Refresh::TransactionRefreshSnapshot recentShieldedProbeSnapshot;
    recentShieldedProbeSnapshot.shieldedAddresses = {"zs-probe-a", "zs-probe-b"};
    recentShieldedProbeSnapshot.shieldedScanStartIndex = 1;
    recentShieldedProbeSnapshot.maxShieldedReceiveScans = 1;
    recentShieldedProbeSnapshot.shieldedScanHeights = {{"zs-probe-a", 600}, {"zs-probe-b", 600}};
    MockRefreshRpc recentShieldedProbeRpc;
    recentShieldedProbeRpc.addResponse("listtransactions", json::array());
    recentShieldedProbeRpc.addResponse("z_listreceivedbyaddress", json::array({
        json{{"txid", "same-tip-shielded"}, {"amount", 1.75}, {"confirmations", 0}, {"time", 170}}
    }));
    auto recentShieldedProbe = Refresh::collectRecentTransactionRefreshResult(
        recentShieldedProbeRpc, recentShieldedProbeSnapshot, 600);
    EXPECT_TRUE(recentShieldedProbeRpc.methodNames() == std::vector<std::string>({
        "listtransactions", "z_listreceivedbyaddress"
    }));
    EXPECT_EQ(recentShieldedProbeRpc.calls[1].params, json::array({"zs-probe-b", 0}));
    EXPECT_EQ(recentShieldedProbe.nextShieldedScanStartIndex, static_cast<size_t>(0));
    EXPECT_EQ(recentShieldedProbe.shieldedAddressesScanned, static_cast<size_t>(1));
    EXPECT_EQ(recentShieldedProbe.shieldedScanHeights.at("zs-probe-b"), 600);
    EXPECT_EQ(recentShieldedProbe.transactions.size(), static_cast<size_t>(1));
    EXPECT_EQ(recentShieldedProbe.transactions[0].txid, std::string("same-tip-shielded"));
    EXPECT_EQ(recentShieldedProbe.transactions[0].confirmations, 0);

    Refresh::TransactionRefreshSnapshot partialShieldedSnapshot;
    partialShieldedSnapshot.shieldedAddresses = {"zs-zero", "zs-one", "zs-two"};
    partialShieldedSnapshot.maxShieldedReceiveScans = 2;
    partialShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
        "old-zs-two", "receive", 1.0, 90, 10, "zs-two", "", "memo"
    });
    MockRefreshRpc partialShieldedRpc;
    partialShieldedRpc.addResponse("listtransactions", json::array());
    partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array({
        json{{"txid", "new-zs-zero"}, {"amount", 0.5}, {"confirmations", 1}, {"time", 100}}
    }));
    partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
    auto partialShielded = Refresh::collectTransactionRefreshResult(
        partialShieldedRpc, partialShieldedSnapshot, 400, 0);
    EXPECT_TRUE(partialShieldedRpc.methodNames() == std::vector<std::string>({
        "listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress"
    }));
    EXPECT_EQ(partialShieldedRpc.calls[1].params, json::array({"zs-zero", 0}));
    EXPECT_EQ(partialShieldedRpc.calls[2].params, json::array({"zs-one", 0}));
    EXPECT_FALSE(partialShielded.shieldedScanComplete);
    EXPECT_EQ(partialShielded.nextShieldedScanStartIndex, static_cast<size_t>(2));
    EXPECT_EQ(partialShielded.shieldedAddressesScanned, static_cast<size_t>(2));
    EXPECT_EQ(partialShielded.transactions.size(), static_cast<size_t>(2));
    EXPECT_EQ(partialShielded.transactions[0].txid, std::string("new-zs-zero"));
    EXPECT_EQ(partialShielded.transactions[1].txid, std::string("old-zs-two"));

    partialShieldedSnapshot.shieldedScanStartIndex = partialShielded.nextShieldedScanStartIndex;
    partialShieldedSnapshot.shieldedScanHeights = partialShielded.shieldedScanHeights;
    MockRefreshRpc finalShieldedRpc;
    finalShieldedRpc.addResponse("listtransactions", json::array());
    finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
    finalShieldedRpc.addResponse("z_listsentbyaddress", json::array());
    auto finalShielded = Refresh::collectTransactionRefreshResult(
        finalShieldedRpc, partialShieldedSnapshot, 400, 0);
    EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
        "listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress"
    }));
    EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
    EXPECT_TRUE(finalShielded.shieldedScanComplete);
    EXPECT_EQ(finalShielded.nextShieldedScanStartIndex, static_cast<size_t>(0));
    EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-zero"), 400);
    EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-one"), 400);
    EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-two"), 400);

    Refresh::TransactionRefreshSnapshot cachedShieldedSnapshot;
    cachedShieldedSnapshot.shieldedAddresses = {"zs-cached"};
    cachedShieldedSnapshot.shieldedScanHeights = {{"zs-cached", 500}};
    cachedShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
        "cached-shielded", "receive", 1.5, 120, 20, "zs-cached", "", "cached memo"
    });
    MockRefreshRpc cachedShieldedRpc;
    cachedShieldedRpc.addResponse("listtransactions", json::array());
    cachedShieldedRpc.addResponse("z_listsentbyaddress", json::array());
    auto cachedShielded = Refresh::collectTransactionRefreshResult(
        cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
    EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions", "z_listsentbyaddress"}));
    EXPECT_TRUE(cachedShielded.shieldedScanComplete);
    EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
    EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
    EXPECT_EQ(cachedShielded.transactions[0].txid, std::string("cached-shielded"));

    Refresh::TransactionRefreshSnapshot staleProgressSnapshot;
    staleProgressSnapshot.shieldedAddresses = {"zs-current", "zs-stale", "zs-missing"};
    staleProgressSnapshot.shieldedScanHeights = {{"zs-current", 500}, {"zs-stale", 499}};
    staleProgressSnapshot.maxShieldedReceiveScans = 1;
    staleProgressSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
        "current-shielded", "receive", 0.75, 110, 12, "zs-current", "", ""
    });
    MockRefreshRpc staleProgressRpc;
    staleProgressRpc.addResponse("listtransactions", json::array());
    staleProgressRpc.addResponse("z_listreceivedbyaddress", json::array({
        json{{"txid", "stale-shielded"}, {"amount", 2.0}, {"confirmations", 1}, {"time", 130}}
    }));
    auto staleProgress = Refresh::collectTransactionRefreshResult(
        staleProgressRpc, staleProgressSnapshot, 500, 0);
    EXPECT_TRUE(staleProgressRpc.methodNames() == std::vector<std::string>({
        "listtransactions", "z_listreceivedbyaddress"
    }));
    EXPECT_EQ(staleProgressRpc.calls[1].params, json::array({"zs-stale", 0}));
    EXPECT_FALSE(staleProgress.shieldedScanComplete);
    EXPECT_EQ(staleProgress.nextShieldedScanStartIndex, static_cast<size_t>(2));
    EXPECT_EQ(staleProgress.shieldedAddressesScanned, static_cast<size_t>(1));
    EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-current"), 500);
    EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-stale"), 500);
    EXPECT_TRUE(staleProgress.shieldedScanHeights.find("zs-missing") == staleProgress.shieldedScanHeights.end());
    EXPECT_EQ(staleProgress.transactions.size(), static_cast<size_t>(2));
    EXPECT_EQ(staleProgress.transactions[0].txid, std::string("stale-shielded"));
    EXPECT_EQ(staleProgress.transactions[1].txid, std::string("current-shielded"));

    Refresh::TransactionRefreshSnapshot miningSnapshot;
    miningSnapshot.shieldedAddresses = {"zs-mine"};
    miningSnapshot.miningAddresses = {"R-mine", "zs-mine"};
    MockRefreshRpc miningTxRpc;
    miningTxRpc.addResponse("listtransactions", json::array({
        json{{"txid", "transparent-mined"}, {"category", "receive"}, {"amount", 3.0},
             {"time", 220}, {"confirmations", 101}, {"address", "R-mine"}}
    }));
    miningTxRpc.addResponse("z_listreceivedbyaddress", json::array({
        json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
             {"time", 210}, {"memoStr", "pool"}}
    }));
    miningTxRpc.addResponse("z_listsentbyaddress", json::array());
    auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
    EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
    EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
    EXPECT_EQ(miningTxResult.transactions[1].type, std::string("mined"));
}

void testNetworkRefreshResultModels()
{
    using Refresh = dragonx::services::NetworkRefreshService;
    using nlohmann::json;

    dragonx::WalletState state;

    auto core = Refresh::parseCoreRefreshResult(
        json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}},   // display (minconf=0)
        json{{"private", "1.00000000"}, {"transparent", "0.50000000"}, {"total", "1.50000000"}},   // spendable (minconf=1)
        true,
        json{{"blocks", 100}, {"headers", 105}, {"bestblockhash", "apply-best-100"}, {"verificationprogress", 0.75},
             {"longestchain", 110}, {"notarized", 90}},
        true);
    Refresh::applyCoreRefreshResult(state, core, 1234);
    EXPECT_NEAR(state.shielded_balance, 1.25, 0.00000001);
    EXPECT_NEAR(state.transparent_balance, 0.5, 0.00000001);
    EXPECT_NEAR(state.total_balance, 1.75, 0.00000001);
    // Confirmed/spendable stays at minconf=1; unconfirmed = display total - spendable total (the pending change).
    EXPECT_NEAR(state.spendablePrivateBalance, 1.0, 0.00000001);
    EXPECT_NEAR(state.spendableTotalBalance, 1.5, 0.00000001);
    EXPECT_NEAR(state.unconfirmedBalance, 0.25, 0.00000001);
    EXPECT_EQ(state.sync.blocks, 100);
    EXPECT_EQ(state.sync.headers, 105);
    EXPECT_EQ(state.sync.best_blockhash, std::string("apply-best-100"));
    EXPECT_TRUE(state.sync.syncing);
    EXPECT_EQ(state.longestchain, 110);
    EXPECT_EQ(state.notarized, 90);
    EXPECT_EQ(state.last_balance_update, static_cast<int64_t>(1234));
    EXPECT_TRUE(state.sync.tip_known);      // longestchain>0 -> peer-derived tip is known
    EXPECT_FALSE(state.sync.isSynced());    // still catching up (blocks 100 < tip 110)

    // Peerless node: the daemon reports longestchain == 0. Even at the local best height (blocks ==
    // headers) it must NOT be reported synced (the tip is unknown) — otherwise Send would be offered
    // against a stale balance on a node that has silently lost all peers. (audit: peerless-synced)
    {
        dragonx::WalletState peerless;
        auto pc = Refresh::parseCoreRefreshResult(
            json{{"private", "5.00000000"}, {"transparent", "0.00000000"}, {"total", "5.00000000"}},
            json{{"private", "5.00000000"}, {"transparent", "0.00000000"}, {"total", "5.00000000"}},
            true,
            json{{"blocks", 200}, {"headers", 200}, {"bestblockhash", "peerless-200"},
                 {"verificationprogress", 1.0}, {"longestchain", 0}, {"notarized", 0}},
            true);
        Refresh::applyCoreRefreshResult(peerless, pc, 1235);
        EXPECT_FALSE(peerless.sync.tip_known);
        EXPECT_FALSE(peerless.sync.isSynced());  // peerless -> not synced despite blocks == headers
    }

    auto connectionInfo = Refresh::parseConnectionInfoResult(
        json{{"version", 120000}, {"protocolversion", 170002}, {"p2pport", 8233},
             {"longestchain", 0}, {"blocks", 120}, {"notarized", 118}});
    Refresh::applyConnectionInfoResult(state, connectionInfo);
    EXPECT_EQ(state.daemon_version, 120000);
    EXPECT_EQ(state.protocol_version, 170002);
    EXPECT_EQ(state.p2p_port, 8233);
    EXPECT_EQ(state.sync.blocks, 120);
    EXPECT_EQ(state.longestchain, 120);
    EXPECT_EQ(state.notarized, 118);

    auto encrypted = Refresh::parseWalletEncryptionResult(json{{"unlocked_until", 0}});
    Refresh::applyWalletEncryptionResult(state, encrypted);
    EXPECT_TRUE(state.encrypted);
    EXPECT_TRUE(state.locked);
    EXPECT_TRUE(state.encryption_state_known);
    auto unencrypted = Refresh::parseWalletEncryptionResult(json::object());
    Refresh::applyWalletEncryptionResult(state, unencrypted);
    EXPECT_FALSE(state.encrypted);
    EXPECT_FALSE(state.locked);
    EXPECT_EQ(state.unlocked_until, static_cast<int64_t>(0));

    auto mining = Refresh::parseMiningRefreshResult(
        json{{"generate", true}, {"genproclimit", 4}, {"blocks", 101},
             {"difficulty", 12.5}, {"networkhashps", 900.0}, {"chain", "main"}},
        true,
        json(45.0),
        true,
        64.0);
    Refresh::applyMiningRefreshResult(state, mining, 2345);
    EXPECT_TRUE(state.mining.generate);
    EXPECT_EQ(state.mining.genproclimit, 4);
    EXPECT_NEAR(state.mining.localHashrate, 45.0, 0.0001);
    EXPECT_EQ(state.mining.hashrate_history.size(), static_cast<size_t>(1));
    EXPECT_NEAR(state.mining.networkHashrate, 900.0, 0.0001);
    EXPECT_NEAR(state.mining.daemon_memory_mb, 64.0, 0.0001);
    EXPECT_EQ(state.last_mining_update, static_cast<int64_t>(2345));

    auto peers = Refresh::parsePeerRefreshResult(
        json::array({json{{"id", 7}, {"addr", "127.0.0.1:8233"}, {"tls_verified", true}}}),
        json::array({json{{"address", "192.0.2.1"}, {"banned_until", 3456}}}));
    Refresh::applyPeerRefreshResult(state, std::move(peers), 3456);
    EXPECT_EQ(state.peers.size(), static_cast<size_t>(1));
    EXPECT_EQ(state.peers[0].id, 7);
    EXPECT_TRUE(state.peers[0].tls_verified);
    EXPECT_EQ(state.bannedPeers.size(), static_cast<size_t>(1));
    EXPECT_EQ(state.bannedPeers[0].address, std::string("192.0.2.1"));
    EXPECT_EQ(state.last_peer_update, static_cast<int64_t>(3456));

    auto price = Refresh::parseCoinGeckoPriceResponse(
        R"({"dragonx-2":{"usd":0.5,"btc":0.00001,"usd_24h_change":2.5,"usd_24h_vol":1000,"usd_market_cap":50000}})",
        0);
    EXPECT_TRUE(price.has_value());
    if (price) {
        Refresh::markPriceRefreshStarted(state);
        EXPECT_TRUE(state.market.price_loading);
        Refresh::applyPriceRefreshResult(state, *price, std::chrono::steady_clock::now());
        EXPECT_FALSE(state.market.price_loading);
        EXPECT_EQ(state.market.price_error, std::string());
        EXPECT_NEAR(state.market.price_usd, 0.5, 0.00000001);
        EXPECT_NEAR(state.market.price_btc, 0.00001, 0.00000001);
        EXPECT_NEAR(state.market.change_24h, 2.5, 0.0001);
        EXPECT_EQ(state.market.price_history.size(), static_cast<size_t>(1));
    }

    // Regression: CoinGecko emits JSON null (not an omitted key) for fields it can't compute —
    // commonly usd_24h_change on illiquid tokens like DRGX — while still returning a valid spot
    // price. The parser must keep the valid usd/btc, not discard the whole update on the null.
    auto priceNull = Refresh::parseCoinGeckoPriceResponse(
        R"({"dragonx-2":{"usd":0.42,"btc":0.000009,"usd_24h_change":null,"usd_24h_vol":null,"usd_market_cap":50000}})",
        0);
    EXPECT_TRUE(priceNull.has_value());
    if (priceNull) {
        EXPECT_NEAR(priceNull->market.price_usd, 0.42, 0.00000001);
        EXPECT_NEAR(priceNull->market.price_btc, 0.000009, 0.00000001);
        EXPECT_NEAR(priceNull->market.change_24h, 0.0, 0.0001);   // null -> default, not a throw
        EXPECT_NEAR(priceNull->market.market_cap, 50000.0, 0.0001);
    }

    Refresh::markPriceRefreshStarted(state);
    Refresh::applyPriceRefreshFailure(state, "timeout");
    EXPECT_FALSE(state.market.price_loading);
    EXPECT_EQ(state.market.price_error, std::string("timeout"));
    EXPECT_NEAR(state.market.price_usd, 0.5, 0.00000001);

    Refresh::PriceHttpResponse priceHttpOk;
    priceHttpOk.transportOk = true;
    priceHttpOk.httpStatus = 200;
    priceHttpOk.body = R"({"dragonx-2":{"usd":0.75,"btc":0.00002,"usd_24h_change":1.5,"usd_24h_vol":2000,"usd_market_cap":75000}})";
    auto priceHttp = Refresh::parsePriceHttpResponse(priceHttpOk, 0);
    EXPECT_TRUE(priceHttp.price.has_value());
    if (priceHttp.price) {
        EXPECT_NEAR(priceHttp.price->market.price_usd, 0.75, 0.00000001);
        EXPECT_NEAR(priceHttp.price->market.price_btc, 0.00002, 0.00000001);
    }
    EXPECT_EQ(priceHttp.errorMessage, std::string());

    Refresh::PriceHttpResponse priceHttpStatus;
    priceHttpStatus.transportOk = true;
    priceHttpStatus.httpStatus = 429;
    auto priceHttpStatusResult = Refresh::parsePriceHttpResponse(priceHttpStatus, 0);
    EXPECT_FALSE(priceHttpStatusResult.price.has_value());
    EXPECT_EQ(priceHttpStatusResult.errorMessage, std::string("Price fetch failed: OK (HTTP 429)"));

    Refresh::PriceHttpResponse priceHttpTransport;
    priceHttpTransport.transportOk = false;
    priceHttpTransport.httpStatus = 0;
    priceHttpTransport.transportError = "timeout";
    auto priceHttpTransportResult = Refresh::parsePriceHttpResponse(priceHttpTransport, 0);
    EXPECT_FALSE(priceHttpTransportResult.price.has_value());
    EXPECT_EQ(priceHttpTransportResult.errorMessage, std::string("Price fetch failed: timeout (HTTP 0)"));

    Refresh::PriceHttpResponse priceHttpParse;
    priceHttpParse.transportOk = true;
    priceHttpParse.httpStatus = 200;
    priceHttpParse.body = R"({"other-coin":{"usd":1.0}})";
    auto priceHttpParseResult = Refresh::parsePriceHttpResponse(priceHttpParse, 0);
    EXPECT_FALSE(priceHttpParseResult.price.has_value());
    EXPECT_EQ(priceHttpParseResult.errorMessage, std::string("Price fetch returned an unrecognized response"));

    // CoinGecko market_chart parser: [ms, price] rows -> (seconds, price), oldest->newest.
    auto chart = Refresh::parseCoinGeckoMarketChart(
        R"({"prices":[[1700000000000,1.5],[1700003600000,1.6],[1700007200000,1.55]],)"
        R"("market_caps":[[1700000000000,100]],"total_volumes":[[1700000000000,10]]})");
    EXPECT_EQ(chart.size(), static_cast<size_t>(3));
    if (chart.size() == 3) {
        EXPECT_EQ(chart[0].first, static_cast<std::time_t>(1700000000));
        EXPECT_NEAR(chart[0].second, 1.5, 0.00000001);
        EXPECT_EQ(chart[2].first, static_cast<std::time_t>(1700007200));
        EXPECT_NEAR(chart[2].second, 1.55, 0.00000001);
    }
    // Malformed rows are skipped; a good row still comes through.
    auto chartSkip = Refresh::parseCoinGeckoMarketChart(
        R"({"prices":[[1700000000000],["x",1.0],[1700003600000,2.0]]})");
    EXPECT_EQ(chartSkip.size(), static_cast<size_t>(1));
    if (chartSkip.size() == 1) EXPECT_NEAR(chartSkip[0].second, 2.0, 0.00000001);
    // Missing/!array "prices" and invalid JSON both yield an empty series.
    EXPECT_EQ(Refresh::parseCoinGeckoMarketChart(R"({"nope":[]})").size(), static_cast<size_t>(0));
    EXPECT_EQ(Refresh::parseCoinGeckoMarketChart("not json").size(), static_cast<size_t>(0));

    Refresh::AddressRefreshResult addresses;
    dragonx::AddressInfo zAddr;
    zAddr.address = "zs-refresh";
    zAddr.type = "shielded";
    zAddr.balance = 2.0;
    zAddr.has_spending_key = false;
    dragonx::AddressInfo tAddr;
    tAddr.address = "R-refresh";
    tAddr.type = "transparent";
    tAddr.balance = 3.0;
    addresses.shieldedAddresses.push_back(zAddr);
    addresses.transparentAddresses.push_back(tAddr);
    Refresh::applyAddressRefreshResult(state, std::move(addresses));
    EXPECT_EQ(state.z_addresses.size(), static_cast<size_t>(1));
    EXPECT_EQ(state.t_addresses.size(), static_cast<size_t>(1));
    EXPECT_EQ(state.z_addresses[0].address, std::string("zs-refresh"));
    EXPECT_FALSE(state.z_addresses[0].has_spending_key);

    auto viewEntry = Refresh::parseViewTransactionCacheEntry(json{
        {"spends", json::array({json{{"address", "zs-from"}}})},
        {"outputs", json::array({
            json{{"outgoing", true}, {"address", "zs-destination"}, {"value", 0.75}, {"memoStr", "memo"}},
            json{{"outgoing", false}, {"address", "zs-change"}, {"value", 0.25}}
        })}
    });
    EXPECT_EQ(viewEntry.from_address, std::string("zs-from"));
    EXPECT_EQ(viewEntry.outgoing_outputs.size(), static_cast<size_t>(1));
    EXPECT_EQ(viewEntry.outgoing_outputs[0].address, std::string("zs-destination"));

    std::vector<dragonx::TransactionInfo> enrichedTxs;
    dragonx::TransactionInfo baseTx;
    baseTx.txid = "shielded-send";
    baseTx.type = "receive";
    baseTx.confirmations = 7;
    baseTx.timestamp = 111;
    enrichedTxs.push_back(baseTx);
    Refresh::appendViewTransactionOutputs(enrichedTxs, "shielded-send", viewEntry);
    Refresh::appendViewTransactionOutputs(enrichedTxs, "shielded-send", viewEntry);
    EXPECT_EQ(enrichedTxs.size(), static_cast<size_t>(2));
    EXPECT_EQ(enrichedTxs[1].type, std::string("send"));
    EXPECT_EQ(enrichedTxs[1].from_address, std::string("zs-from"));
    EXPECT_NEAR(enrichedTxs[1].amount, -0.75, 0.00000001);
    EXPECT_EQ(enrichedTxs[1].confirmations, 7);
    EXPECT_EQ(enrichedTxs[1].timestamp, static_cast<int64_t>(111));

    Refresh::TransactionRefreshResult transactionResult;
    transactionResult.blockHeight = 222;
    dragonx::TransactionInfo confirmedTx;
    confirmedTx.txid = "confirmed";
    confirmedTx.type = "receive";
    confirmedTx.confirmations = 12;
    confirmedTx.timestamp = 1000;
    dragonx::TransactionInfo pendingTx;
    pendingTx.txid = "pending";
    pendingTx.type = "receive";
    pendingTx.confirmations = 2;
    pendingTx.timestamp = 1001;
    transactionResult.transactions = {pendingTx, confirmedTx};
    transactionResult.newViewTxEntries["shielded-send"] = viewEntry;
    transactionResult.newViewTxEntries["pending-empty"] = Refresh::TransactionViewCacheEntry{};

    Refresh::TransactionViewCache viewCache;
    std::unordered_set<std::string> sendTxids{"shielded-send", "pending", "pending-empty"};
    std::vector<dragonx::TransactionInfo> confirmedCache;
    std::unordered_set<std::string> confirmedIds;
    int confirmedBlock = -1;
    int lastTxBlock = -1;
    Refresh::TransactionCacheUpdate cacheUpdate{
        viewCache,
        sendTxids,
        confirmedCache,
        confirmedIds,
        confirmedBlock,
        lastTxBlock
    };
    Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(transactionResult), 4567);
    EXPECT_EQ(state.transactions.size(), static_cast<size_t>(2));
    EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(4567));
    EXPECT_EQ(lastTxBlock, 222);
    EXPECT_EQ(viewCache.size(), static_cast<size_t>(2));
    EXPECT_EQ(sendTxids.count("shielded-send"), static_cast<size_t>(0));
    EXPECT_EQ(sendTxids.count("pending"), static_cast<size_t>(1));
    EXPECT_EQ(sendTxids.count("pending-empty"), static_cast<size_t>(1));
    EXPECT_EQ(confirmedCache.size(), static_cast<size_t>(1));
    EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
    EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
    EXPECT_EQ(confirmedBlock, 222);
}

void testOperationStatusPollParsing()
{
    using Refresh = dragonx::services::NetworkRefreshService;
    using nlohmann::json;

    auto parsed = Refresh::parseOperationStatusPoll(json::array({
        json{{"id", "op-success"}, {"status", "success"}, {"result", json{{"txid", "tx-success"}}}},
        json{{"id", "op-failed"}, {"status", "failed"}, {"error", json{{"message", "bad memo"}}}},
        json{{"id", "op-running"}, {"status", "executing"}}
    }), {"op-success", "op-failed", "op-running", "op-stale"});

    EXPECT_TRUE(parsed.anySuccess);
    EXPECT_EQ(parsed.doneOpids.size(), static_cast<size_t>(2));
    EXPECT_EQ(parsed.doneOpids[0], std::string("op-success"));
    EXPECT_EQ(parsed.doneOpids[1], std::string("op-failed"));
    EXPECT_EQ(parsed.successTxids.size(), static_cast<size_t>(1));
    EXPECT_EQ(parsed.successTxids[0], std::string("tx-success"));
    EXPECT_EQ(parsed.successTxidsByOpid.size(), static_cast<size_t>(1));
    EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success"));
    EXPECT_EQ(parsed.failureMessages.size(), static_cast<size_t>(1));
    EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo"));
    // Per-opid failure map drives terminal-status routing to the originating send UI.
    EXPECT_EQ(parsed.failureByOpid.size(), static_cast<size_t>(1));
    EXPECT_EQ(parsed.failureByOpid.at("op-failed"), std::string("bad memo"));
    EXPECT_EQ(parsed.staleOpids.size(), static_cast<size_t>(1));
    EXPECT_EQ(parsed.staleOpids[0], std::string("op-stale"));

    auto malformed = Refresh::parseOperationStatusPoll(json{{"status", "success"}}, {"op-keep"});
    EXPECT_FALSE(malformed.anySuccess);
    EXPECT_TRUE(malformed.doneOpids.empty());
    EXPECT_TRUE(malformed.staleOpids.empty());

    // Regression: a type-anomalous element (non-string "id"/"status") must be skipped, not throw —
    // a throw here would escape the parser and permanently wedge opid polling for the session. A
    // valid tracked opid alongside the anomaly must still be processed.
    auto typeSafe = Refresh::parseOperationStatusPoll(json::array({
        json{{"id", 12345}, {"status", "failed"}},                    // non-string id -> skipped, no throw
        json{{"id", "op-ok"}, {"status", 7}},                         // non-string status -> "" (not done)
        json{{"id", "op-good"}, {"status", "success"}, {"result", json{{"txid", "tx-good"}}}}
    }), {"op-ok", "op-good", "op-x"});
    EXPECT_TRUE(typeSafe.anySuccess);
    EXPECT_EQ(typeSafe.successTxidsByOpid.at("op-good"), std::string("tx-good"));
    EXPECT_TRUE(typeSafe.failureMessages.empty());                    // the non-string-id "failed" was skipped
    EXPECT_EQ(typeSafe.staleOpids.size(), static_cast<size_t>(1));    // op-x absent; op-ok was seen (not stale)
    EXPECT_EQ(typeSafe.staleOpids[0], std::string("op-x"));
}

void testSecureVaultScope()
{
    using dragonx::util::SecureVault;
    auto endsWith = [](const std::string& s, const std::string& suf) {
        return s.size() >= suf.size() && s.compare(s.size() - suf.size(), suf.size(), suf) == 0;
    };

    // Default wallet (empty or "wallet.dat") keeps the legacy global vault.dat for back-compat.
    EXPECT_TRUE(endsWith(SecureVault("").getVaultPath(), "vault.dat"));
    EXPECT_TRUE(endsWith(SecureVault("wallet.dat").getVaultPath(), "vault.dat"));

    // A non-default wallet gets its own, distinct vault (not the legacy path).
    SecureVault vb("wallet-savings.dat");
    const std::string pb = vb.getVaultPath();
    EXPECT_TRUE(pb.find("vault-") != std::string::npos);
    EXPECT_TRUE(pb != SecureVault("").getVaultPath());
    EXPECT_TRUE(pb != SecureVault("wallet-cold.dat").getVaultPath());

    // The FNV suffix disambiguates sanitize-colliding names, so distinct files never share a vault.
    EXPECT_TRUE(SecureVault("wallet foo.dat").getVaultPath() != SecureVault("wallet_foo.dat").getVaultPath());

    // Re-scoping moves the path; scoping back to the default returns to vault.dat.
    vb.setWalletScope("wallet-cold.dat");
    EXPECT_TRUE(vb.getVaultPath() == SecureVault("wallet-cold.dat").getVaultPath());
    vb.setWalletScope("wallet.dat");
    EXPECT_TRUE(endsWith(vb.getVaultPath(), "vault.dat"));
}

void testWalletSecurityController()
{
    using dragonx::services::WalletSecurityController;
    using PinError = WalletSecurityController::PinValidationError;

    WalletSecurityController controller;
    EXPECT_FALSE(controller.hasDeferredEncryption());
    controller.beginDeferredEncryption("secret-passphrase", "1234");
    EXPECT_TRUE(controller.hasDeferredEncryption());

    auto snapshot = controller.deferredEncryption();
    EXPECT_EQ(snapshot.passphrase, std::string("secret-passphrase"));
    EXPECT_EQ(snapshot.pin, std::string("1234"));

    EXPECT_TRUE(controller.shouldAttemptDeferredConnect(0.0));
    EXPECT_FALSE(controller.shouldAttemptDeferredConnect(1.0));
    EXPECT_TRUE(controller.shouldAttemptDeferredConnect(3.1));

    controller.clearDeferredEncryption();
    EXPECT_FALSE(controller.hasDeferredEncryption());
    EXPECT_FALSE(controller.shouldAttemptDeferredConnect(10.0));

    auto valid = WalletSecurityController::validatePinSetup("1234", "1234");
    EXPECT_TRUE(valid.ok);
    auto emptyAllowed = WalletSecurityController::validatePinSetup("", "", true);
    EXPECT_TRUE(emptyAllowed.ok);
    auto mismatch = WalletSecurityController::validatePinSetup("1234", "4321");
    EXPECT_EQ(mismatch.error, PinError::Mismatch);
    auto shortPin = WalletSecurityController::validatePinSetup("123", "123");
    EXPECT_EQ(shortPin.error, PinError::TooShort);
    auto nondigit = WalletSecurityController::validatePinSetup("12a4", "12a4");
    EXPECT_EQ(nondigit.error, PinError::NonDigit);

    EXPECT_EQ(WalletSecurityController::classifyAddress("zs123"), WalletSecurityController::KeyKind::Shielded);
    EXPECT_EQ(WalletSecurityController::classifyAddress("R123"), WalletSecurityController::KeyKind::Transparent);
    EXPECT_EQ(WalletSecurityController::classifyPrivateKey("secret-key"), WalletSecurityController::KeyKind::Shielded);
    EXPECT_EQ(WalletSecurityController::classifyPrivateKey("Kx123"), WalletSecurityController::KeyKind::Transparent);
    EXPECT_EQ(std::string(WalletSecurityController::importSuccessMessage(WalletSecurityController::KeyKind::Shielded)),
              std::string("Z-address key imported successfully. Wallet is rescanning."));
    EXPECT_EQ(WalletSecurityController::decryptExportFileName(42), std::string("obsidiandecryptexport42"));

    MockWalletSecurityRpc rpc;
    MockWalletSecurityVault vault;
    auto encrypted = controller.runDeferredEncryption({"passphrase", "9876"}, rpc, &vault);
    EXPECT_TRUE(encrypted.encrypted);
    EXPECT_TRUE(encrypted.pinProvided);
    EXPECT_TRUE(encrypted.pinStored);
    EXPECT_TRUE(encrypted.restartRequired);
    EXPECT_EQ(rpc.encryptCalls, 1);
    EXPECT_EQ(rpc.lastPassphrase, std::string("passphrase"));
    EXPECT_EQ(vault.storeCalls, 1);
    EXPECT_EQ(vault.lastPin, std::string("9876"));
    EXPECT_EQ(vault.lastPassphrase, std::string("passphrase"));

    MockWalletSecurityRpc failingRpc;
    MockWalletSecurityVault unusedVault;
    failingRpc.encryptResult = false;
    failingRpc.encryptError = "rpc unavailable";
    auto failed = controller.runDeferredEncryption({"bad-passphrase", "1111"}, failingRpc, &unusedVault);
    EXPECT_FALSE(failed.encrypted);
    EXPECT_TRUE(failed.pinProvided);
    EXPECT_FALSE(failed.pinStored);
    EXPECT_FALSE(failed.restartRequired);
    EXPECT_EQ(failed.error, std::string("rpc unavailable"));
    EXPECT_EQ(unusedVault.storeCalls, 0);
}

void testWalletSecurityWorkflow()
{
    using dragonx::services::WalletSecurityWorkflow;
    using DecryptPhase = WalletSecurityWorkflow::DecryptPhase;
    using DecryptStep = WalletSecurityWorkflow::DecryptStep;

    WalletSecurityWorkflow workflow;
    auto start = std::chrono::steady_clock::now();
    EXPECT_EQ(workflow.phase(), DecryptPhase::PassphraseEntry);
    EXPECT_TRUE(workflow.canClose());

    workflow.start(start);
    EXPECT_EQ(workflow.phase(), DecryptPhase::Working);
    EXPECT_EQ(workflow.step(), DecryptStep::Unlock);
    EXPECT_FALSE(workflow.canClose());
    EXPECT_TRUE(workflow.inProgress());

    workflow.advanceTo(DecryptStep::BackupWallet,
                       WalletSecurityWorkflow::stepStatus(DecryptStep::BackupWallet),
                       start + std::chrono::seconds(2));
    EXPECT_EQ(workflow.step(), DecryptStep::BackupWallet);
    EXPECT_EQ(workflow.status(), std::string("Backing up encrypted wallet..."));
    EXPECT_TRUE(WalletSecurityWorkflow::stepIsComplete(workflow.step(), DecryptStep::StopDaemon));
    EXPECT_FALSE(WalletSecurityWorkflow::stepIsComplete(workflow.step(), DecryptStep::RestartDaemon));

    workflow.closeDialogForImport();
    EXPECT_FALSE(workflow.inProgress());
    EXPECT_TRUE(workflow.importActive());
    workflow.finishImport();
    EXPECT_FALSE(workflow.importActive());

    workflow.fail("Daemon failed to restart");
    EXPECT_EQ(workflow.phase(), DecryptPhase::Error);
    EXPECT_TRUE(workflow.canClose());
    EXPECT_EQ(workflow.status(), std::string("Daemon failed to restart"));

    workflow.failEntry("Incorrect passphrase");
    EXPECT_EQ(workflow.phase(), DecryptPhase::PassphraseEntry);
    EXPECT_FALSE(workflow.inProgress());
    EXPECT_EQ(workflow.status(), std::string("Incorrect passphrase"));

    auto plan = WalletSecurityWorkflow::planWalletFiles("/tmp/dragonx/", 1234);
    EXPECT_EQ(plan.exportFile, std::string("obsidiandecryptexport1234"));
    EXPECT_EQ(plan.exportPath, std::string("/tmp/dragonx/obsidiandecryptexport1234"));
    EXPECT_EQ(plan.walletPath, std::string("/tmp/dragonx/wallet.dat"));
    EXPECT_EQ(plan.backupPath, std::string("/tmp/dragonx/wallet.dat.encrypted.bak"));
}

void testWalletSecurityWorkflowExecutor()
{
    using Executor = dragonx::services::WalletSecurityWorkflowExecutor;

    MockWorkflowRpc rpc;
    auto unlock = Executor::unlockWallet("passphrase", rpc);
    EXPECT_TRUE(unlock.ok);
    EXPECT_EQ(rpc.unlockCalls, 1);
    EXPECT_EQ(rpc.lastPassphrase, std::string("passphrase"));

    rpc.unlockResult = false;
    rpc.unlockError = "bad passphrase";
    auto failedUnlock = Executor::unlockWallet("bad", rpc);
    EXPECT_FALSE(failedUnlock.ok);
    EXPECT_TRUE(failedUnlock.passphraseRejected);
    EXPECT_EQ(failedUnlock.error, std::string("bad passphrase"));

    MockWorkflowFiles files;
    auto exported = Executor::exportWallet(rpc, files, 77);
    EXPECT_TRUE(exported.ok);
    EXPECT_EQ(exported.filePlan.exportFile, std::string("obsidiandecryptexport77"));
    EXPECT_EQ(exported.filePlan.exportPath, std::string("/tmp/dragonx/obsidiandecryptexport77"));
    EXPECT_EQ(rpc.lastExportFile, std::string("obsidiandecryptexport77"));

    rpc.exportResult = false;
    rpc.exportError = "disk full";
    auto failedExport = Executor::exportWallet(rpc, files, 78);
    EXPECT_FALSE(failedExport.ok);
    EXPECT_EQ(failedExport.error, std::string("Export failed: disk full"));

    rpc.stopResult = false;
    auto stop = Executor::stopDaemon(rpc);
    EXPECT_TRUE(stop.ok);
    EXPECT_EQ(rpc.stopCalls, 1);

    auto backup = Executor::backupEncryptedWallet(files, exported.filePlan);
    EXPECT_TRUE(backup.ok);
    EXPECT_EQ(files.backupCalls, 1);
    files.backupResult = false;
    files.backupError = "permission denied";
    auto failedBackup = Executor::backupEncryptedWallet(files, exported.filePlan);
    EXPECT_FALSE(failedBackup.ok);
    EXPECT_EQ(failedBackup.error, std::string("Failed to rename wallet.dat: permission denied"));

    MockWorkflowDaemon daemon;
    rpc.probeCalls = 0;
    rpc.probeSuccessOnCall = 3;
    auto restart = Executor::restartDaemonAndWait(daemon, rpc, 200, 100, 5);
    EXPECT_TRUE(restart.ok);
    EXPECT_EQ(daemon.stopCalls, 1);
    EXPECT_EQ(daemon.startCalls, 1);
    EXPECT_EQ(rpc.probeCalls, 3);

    MockWorkflowDaemon cancelledDaemon;
    cancelledDaemon.cancelAfterSleeps = 1;
    rpc.probeCalls = 0;
    auto cancelled = Executor::restartDaemonAndWait(cancelledDaemon, rpc, 200, 100, 5);
    EXPECT_FALSE(cancelled.ok);
    EXPECT_TRUE(cancelled.error.empty());

    MockWorkflowDaemon failedDaemon;
    rpc.probeCalls = 0;
    rpc.probeSuccessOnCall = 99;
    auto failedRestart = Executor::restartDaemonAndWait(failedDaemon, rpc, 0, 0, 2);
    EXPECT_FALSE(failedRestart.ok);
    EXPECT_EQ(failedRestart.error, std::string("Daemon failed to restart"));

    MockWorkflowImporter importer;
    auto imported = Executor::importWallet(importer, exported.filePlan.exportPath);
    EXPECT_TRUE(imported.ok);
    EXPECT_EQ(importer.importCalls, 1);
    EXPECT_EQ(importer.lastExportPath, exported.filePlan.exportPath);
    importer.result = false;
    importer.importError = "rescan failed";
    auto failedImport = Executor::importWallet(importer, exported.filePlan.exportPath);
    EXPECT_FALSE(failedImport.ok);
    EXPECT_EQ(failedImport.error, std::string("Key import failed: rescan failed"));

    int cleanupCalls = 0;
    Executor::cleanupVaultAndPin([&]() { ++cleanupCalls; });
    EXPECT_EQ(cleanupCalls, 1);
}

void testDaemonShutdownPolicy()
{
    using dragonx::daemon::DaemonController;
    using ShutdownAction = DaemonController::ShutdownAction;

    auto noDaemon = DaemonController::evaluateShutdownPolicy(false, false, false, false);
    EXPECT_EQ(noDaemon.action, ShutdownAction::DisconnectOnly);

    auto keepRunning = DaemonController::evaluateShutdownPolicy(true, false, true, false);
    EXPECT_EQ(keepRunning.action, ShutdownAction::DisconnectOnly);
    EXPECT_EQ(std::string(keepRunning.logReason), std::string("keep_daemon_running enabled"));

    auto externalPreserved = DaemonController::evaluateShutdownPolicy(true, true, false, false);
    EXPECT_EQ(externalPreserved.action, ShutdownAction::DisconnectOnly);
    EXPECT_EQ(std::string(externalPreserved.logReason), std::string("external daemon (not ours to stop)"));

    auto externalStopAllowed = DaemonController::evaluateShutdownPolicy(true, true, false, true);
    EXPECT_EQ(externalStopAllowed.action, ShutdownAction::StopDaemon);

    auto ownedDaemon = DaemonController::evaluateShutdownPolicy(true, false, false, false);
    EXPECT_EQ(ownedDaemon.action, ShutdownAction::StopDaemon);

    auto restart = DaemonController::evaluateLifecycleOperation(
        DaemonController::LifecycleOperation::ManualRestart, true, true, true, false);
    EXPECT_TRUE(restart.allowed);
    EXPECT_TRUE(restart.wasRunning);
    EXPECT_TRUE(restart.resetCrashCount);
    EXPECT_TRUE(restart.disconnectRpc);
    EXPECT_EQ(restart.restartDelayMs, 500);
    EXPECT_EQ(std::string(restart.taskName), std::string("daemon-restart"));

    auto blockedRestart = DaemonController::evaluateLifecycleOperation(
        DaemonController::LifecycleOperation::ManualRestart, true, true, true, true);
    EXPECT_FALSE(blockedRestart.allowed);

    auto externalRescan = DaemonController::evaluateLifecycleOperation(
        DaemonController::LifecycleOperation::Rescan, false, false, false, false);
    EXPECT_FALSE(externalRescan.allowed);
    EXPECT_EQ(std::string(externalRescan.warning),
              std::string("Rescan requires embedded daemon. Restart your daemon with -rescan manually."));

    auto deleteData = DaemonController::evaluateLifecycleOperation(
        DaemonController::LifecycleOperation::DeleteBlockchainData, true, true, false, false);
    EXPECT_TRUE(deleteData.allowed);
    EXPECT_FALSE(deleteData.wasRunning);
    EXPECT_EQ(deleteData.restartDelayMs, 3000);

    auto bootstrap = DaemonController::evaluateLifecycleOperation(
        DaemonController::LifecycleOperation::BootstrapStop, false, false, true, false);
    EXPECT_TRUE(bootstrap.allowed);
    EXPECT_TRUE(bootstrap.wasRunning);
    EXPECT_TRUE(bootstrap.disconnectRpc);
}

void testIsLocalHost()
{
    using dragonx::rpc::Connection;
    // Genuine loopback / local hosts.
    EXPECT_TRUE(Connection::isLocalHost("127.0.0.1"));
    EXPECT_TRUE(Connection::isLocalHost("127.1.2.3"));
    EXPECT_TRUE(Connection::isLocalHost("localhost"));
    EXPECT_TRUE(Connection::isLocalHost("LocalHost"));
    EXPECT_TRUE(Connection::isLocalHost("::1"));
    EXPECT_TRUE(Connection::isLocalHost("[::1]"));
    // The regression this fix targets: a hostname merely starting "127." is NOT loopback.
    EXPECT_TRUE(!Connection::isLocalHost("127.evil.com"));
    EXPECT_TRUE(!Connection::isLocalHost("127.0.0.1.attacker.example"));
    EXPECT_TRUE(!Connection::isLocalHost("127.300.0.1"));
    EXPECT_TRUE(!Connection::isLocalHost("1270.0.0.1"));
    EXPECT_TRUE(!Connection::isLocalHost("10.0.0.5"));
    EXPECT_TRUE(!Connection::isLocalHost("example.com"));
}

void testAllowsPlaintextRemote()
{
    using dragonx::rpc::Connection;
    using dragonx::rpc::ConnectionConfig;

    ConnectionConfig local;
    local.host = "127.0.0.1";
    local.use_tls = false;
    EXPECT_TRUE(!Connection::usesPlaintextRemote(local));    // local is never "plaintext remote"

    ConnectionConfig remote;
    remote.host = "10.0.0.5";
    remote.use_tls = false;
    EXPECT_TRUE(Connection::usesPlaintextRemote(remote));    // remote + no TLS
    EXPECT_TRUE(!Connection::allowsPlaintextRemote(remote)); // blocked by default → connect refused

    remote.allow_plaintext_remote = true;
    EXPECT_TRUE(Connection::allowsPlaintextRemote(remote));  // explicit opt-in

    ConnectionConfig remoteTls;
    remoteTls.host = "10.0.0.5";
    remoteTls.use_tls = true;
    EXPECT_TRUE(!Connection::usesPlaintextRemote(remoteTls)); // TLS → not plaintext, never refused
}

void testConsoleSecretRedaction()
{
    using dragonx::ui::RedactConsoleCommand;
    using dragonx::ui::ConsoleCommandCarriesSecret;

    // Secret-bearing commands are recognized (case- and whitespace-insensitive on the name).
    EXPECT_TRUE(ConsoleCommandCarriesSecret("walletpassphrase myPass 60"));
    EXPECT_TRUE(ConsoleCommandCarriesSecret("z_importkey SK-secret"));
    EXPECT_TRUE(ConsoleCommandCarriesSecret("  ENCRYPTWALLET topsecret"));
    EXPECT_TRUE(!ConsoleCommandCarriesSecret("getinfo"));
    EXPECT_TRUE(!ConsoleCommandCarriesSecret("getwalletinfo"));   // not a false-positive substring match

    // Redaction replaces the arguments with **** but preserves the (original-case) command name.
    EXPECT_EQ(RedactConsoleCommand("walletpassphrase myPass 60"), std::string("walletpassphrase ****"));
    EXPECT_EQ(RedactConsoleCommand("z_importkey SK-secret-key"),  std::string("z_importkey ****"));
    EXPECT_EQ(RedactConsoleCommand("ENCRYPTWALLET topsecret"),    std::string("ENCRYPTWALLET ****"));
    // A bare secret command with no argument is left unchanged (nothing to hide).
    EXPECT_EQ(RedactConsoleCommand("walletpassphrase"),           std::string("walletpassphrase"));
    // Non-secret commands pass through untouched.
    EXPECT_EQ(RedactConsoleCommand("sendtoaddress addr 1.0"),     std::string("sendtoaddress addr 1.0"));
    EXPECT_EQ(RedactConsoleCommand("getwalletinfo"),              std::string("getwalletinfo"));
}

void testNodeStatusBanner()
{
    using namespace dragonx::ui;

    // Connected full node → no banner.
    {
        NodeBannerInputs in; in.connected = true;
        EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
    }
    // Connected but no peer-derived tip (0 peers) once chain info is in → amber "no peers" banner.
    {
        NodeBannerInputs in;
        in.connected = true; in.tip_known = false; in.blocks = 3262059;
        in.connection_status = "Peers: 0";
        NodeBannerState s = evaluateNodeStatusBanner(in);
        EXPECT_TRUE(s.show);
        EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
        EXPECT_TRUE(s.reason == NodeBannerReason::NoPeers);
        EXPECT_TRUE(s.action == NodeBannerAction::None);
        EXPECT_EQ(s.detail, std::string("Peers: 0"));
    }
    // Connected, tip unknown, but no chain info yet (blocks==0) → no banner (avoid startup flicker).
    {
        NodeBannerInputs in; in.connected = true; in.tip_known = false; in.blocks = 0;
        EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
    }
    // Connected with a known tip → no banner.
    {
        NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100;
        EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
    }
    // Expected startup phases own the screen (loading/warmup overlay) → no banner.
    {
        NodeBannerInputs in; in.warming_up = true;
        EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
        NodeBannerInputs in2; in2.daemon_initializing = true;
        EXPECT_TRUE(!evaluateNodeStatusBanner(in2).show);
        NodeBannerInputs in3; in3.connection_in_progress = true;
        EXPECT_TRUE(!evaluateNodeStatusBanner(in3).show);
    }
    // Genuinely offline full node → amber, reconnect offered, detail passed through.
    {
        NodeBannerInputs in;
        in.connection_status = "Lost connection to daemon";
        NodeBannerState s = evaluateNodeStatusBanner(in);
        EXPECT_TRUE(s.show);
        EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
        EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline);
        EXPECT_TRUE(s.action == NodeBannerAction::Reconnect);
        EXPECT_EQ(s.detail, std::string("Lost connection to daemon"));
    }
    // Embedded daemon crashed and auto-restart gave up → red, restart offered, lastError preferred.
    {
        NodeBannerInputs in;
        in.using_embedded_daemon = true;
        in.has_daemon_controller = true;
        in.daemon_running = false;
        in.daemon_crash_count = kNodeBannerCrashGiveUpCount;
        in.daemon_last_error = "exit code 134";
        in.connection_status = "Daemon crashed 3 times";
        NodeBannerState s = evaluateNodeStatusBanner(in);
        EXPECT_TRUE(s.show);
        EXPECT_TRUE(s.severity == NodeBannerSeverity::Error);
        EXPECT_TRUE(s.reason == NodeBannerReason::DaemonCrashed);
        EXPECT_TRUE(s.action == NodeBannerAction::RestartNode);
        EXPECT_EQ(s.detail, std::string("exit code 134"));
    }
    // Below the give-up threshold it's still just an offline/reconnect banner, not the crash one.
    {
        NodeBannerInputs in;
        in.using_embedded_daemon = true;
        in.has_daemon_controller = true;
        in.daemon_running = false;
        in.daemon_crash_count = kNodeBannerCrashGiveUpCount - 1;
        NodeBannerState s = evaluateNodeStatusBanner(in);
        EXPECT_TRUE(s.show);
        EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline);
        EXPECT_TRUE(s.action == NodeBannerAction::Reconnect);
    }
    // Lite: an open failure shows a red, action-less banner; no failure → nothing.
    {
        NodeBannerInputs in; in.lite = true; in.connected = false;
        in.lite_open_error = "wallet.dat is corrupt";
        NodeBannerState s = evaluateNodeStatusBanner(in);
        EXPECT_TRUE(s.show);
        EXPECT_TRUE(s.severity == NodeBannerSeverity::Error);
        EXPECT_TRUE(s.reason == NodeBannerReason::LiteOpenFailed);
        EXPECT_TRUE(s.action == NodeBannerAction::None);
        EXPECT_EQ(s.detail, std::string("wallet.dat is corrupt"));

        NodeBannerInputs clean; clean.lite = true; clean.connected = false;  // no error yet
        EXPECT_TRUE(!evaluateNodeStatusBanner(clean).show);
        NodeBannerInputs open; open.lite = true; open.connected = true;
        EXPECT_TRUE(!evaluateNodeStatusBanner(open).show);
    }
}

void testStalenessBadge()
{
    using namespace dragonx::ui;
    const int64_t now = 1'000'000;

    // Disconnected → banner's job, never a badge.
    EXPECT_TRUE(!evaluateStalenessBadge(now - 999, now, /*connected=*/false).show);
    // Never updated this session (0 stamp, e.g. fresh start / reset on disconnect) → nothing.
    EXPECT_TRUE(!evaluateStalenessBadge(0, now, true).show);
    // Fresh (just under the threshold) → no badge.
    EXPECT_TRUE(!evaluateStalenessBadge(now - (kStaleAfterSeconds - 1), now, true).show);
    // At the threshold → amber badge, age reported.
    {
        StalenessBadge b = evaluateStalenessBadge(now - kStaleAfterSeconds, now, true);
        EXPECT_TRUE(b.show);
        EXPECT_TRUE(b.severity == StalenessSeverity::Warning);
        EXPECT_EQ(b.seconds_old, (int64_t)kStaleAfterSeconds);
    }
    // Past the very-stale threshold → red.
    {
        StalenessBadge b = evaluateStalenessBadge(now - kVeryStaleAfterSeconds, now, true);
        EXPECT_TRUE(b.show);
        EXPECT_TRUE(b.severity == StalenessSeverity::Error);
    }
    // Clock skew (future timestamp) is clamped to age 0 → no badge, no negative age.
    {
        StalenessBadge b = evaluateStalenessBadge(now + 100, now, true);
        EXPECT_TRUE(!b.show);
    }
}

void testNotificationHistory()
{
    using dragonx::ui::Notifications;
    using dragonx::ui::NotificationType;
    auto& n = Notifications::instance();
    n.clearHistory();
    auto base = n.totalPushed();  // monotonic counter is NOT reset by clearHistory()

    n.push("first",  NotificationType::Info,  5.0f);
    n.push("second", NotificationType::Error, 5.0f);
    EXPECT_EQ((int)n.history().size(), 2);
    EXPECT_TRUE(n.hasHistory());
    // Oldest first, newest last; type + wall-clock stamp retained.
    EXPECT_EQ(n.history().front().message, std::string("first"));
    EXPECT_EQ(n.history().back().message,  std::string("second"));
    EXPECT_TRUE(n.history().back().type == NotificationType::Error);
    EXPECT_TRUE(n.history().back().epoch > 0);
    EXPECT_EQ((int)(n.totalPushed() - base), 2);

    // Cap at 100: push past it → size caps, oldest entries drop, totalPushed keeps counting.
    for (int i = 0; i < 150; ++i) n.push("bulk", NotificationType::Info, 5.0f);
    EXPECT_EQ((int)n.history().size(), 100);
    EXPECT_EQ((int)(n.totalPushed() - base), 152);
    EXPECT_EQ(n.history().front().message, std::string("bulk"));  // the two originals fell off

    n.clearHistory();
    EXPECT_TRUE(!n.hasHistory());
    EXPECT_EQ((int)n.history().size(), 0);
    EXPECT_EQ((int)(n.totalPushed() - base), 152);  // clearing doesn't rewind the counter
}

void testSeedMigrationResume()
{
    using dragonx::decideSeedMigrationResume;
    using dragonx::MigrationResume;

    // No pending migration (or missing dest) → start fresh at the intro.
    EXPECT_TRUE(decideSeedMigrationResume(false, false, "", "", true)  == MigrationResume::Intro);
    EXPECT_TRUE(decideSeedMigrationResume(false, true,  "tx", "op", true) == MigrationResume::Intro);
    EXPECT_TRUE(decideSeedMigrationResume(true,  false, "tx", "op", true) == MigrationResume::Intro);

    // A persisted txid outranks everything → resume at the confirm/adopt gate (txid-first invariant).
    EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "",   true)  == MigrationResume::Confirming);
    EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", true)  == MigrationResume::Confirming);
    EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", false) == MigrationResume::Confirming);

    // Opid but no txid, AND connected → re-track the opid (recover the txid / detect stale).
    EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", true) == MigrationResume::RetrackOpid);

    // W3-3 connectivity gate: opid but NOT connected → the dismissable Sweep gate, NOT the buttonless
    // Sweeping spinner (whose only exit is the opid poller, which needs a connection). This is the
    // trap the review caught.
    EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", false) == MigrationResume::SweepGate);

    // No txid and no opid → the Sweep gate (whether or not connected).
    EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", true)  == MigrationResume::SweepGate);
    EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", false) == MigrationResume::SweepGate);

    // fund-safety: the Sweep step may only short-circuit a ~0 balance straight to adopt when NO sweep
    // was ever submitted (a genuinely empty wallet). Once a sweep has been broadcast, a ~0 balance is
    // ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked.
    EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false));   // empty wallet -> ok
    EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true));   // in-flight -> blocked
}

void testLoggerFileSink()
{
    using dragonx::util::Logger;
    namespace fsn = std::filesystem;
    fsn::path logPath = fsn::temp_directory_path() / "od_logger_test.log";
    std::error_code ec;
    fsn::remove(logPath, ec);
    fsn::remove(logPath.string() + ".1", ec);

    // W7-2: init() opens the file sink and must NOT deadlock — it writes the banner under the same
    // non-recursive lock it holds (this test would hang if that regressed).
    Logger& lg = Logger::instance();
    EXPECT_TRUE(lg.init(logPath.string()));
    lg.write("hello-w7-2-sink");
    EXPECT_TRUE(fsn::exists(logPath));

    std::ifstream f(logPath.string());
    std::string all, line;
    while (std::getline(f, line)) all += line + "\n";
    f.close();
    EXPECT_TRUE(all.find("hello-w7-2-sink") != std::string::npos);
    EXPECT_TRUE(all.find("Logger initialized") != std::string::npos);

    fsn::remove(logPath, ec);
    fsn::remove(logPath.string() + ".1", ec);
}

void testConnectHasStalled()
{
    using dragonx::util::connectHasStalled;
    EXPECT_TRUE(connectHasStalled(100.0, 145.0, 45.0f));    // exactly at threshold
    EXPECT_TRUE(connectHasStalled(100.0, 300.0, 45.0f));    // well over
    EXPECT_TRUE(!connectHasStalled(100.0, 144.0, 45.0f));   // just under
    EXPECT_TRUE(!connectHasStalled(0.0, 1000.0, 45.0f));    // sentinel: not stalling
    EXPECT_TRUE(!connectHasStalled(-1.0, 1000.0, 45.0f));   // sentinel: not stalling
    EXPECT_TRUE(!connectHasStalled(10.0, 20.0, 0.0f));      // disabled: threshold 0
    EXPECT_TRUE(!connectHasStalled(10.0, 20.0, -5.0f));     // disabled: negative threshold
}

void testVerifySaplingParams()
{
    using dragonx::rpc::Connection;
    namespace fsn = std::filesystem;

    fsn::path dir = fsn::temp_directory_path() / "od_sapling_test";
    std::error_code rmec;
    fsn::remove_all(dir, rmec);
    fsn::create_directories(dir);

    auto writeFile = [](const fsn::path& p, const std::string& content) {
        std::ofstream(p.string(), std::ios::binary) << content;
    };
    const std::string spendContent  = "fake-spend-params-contents";
    const std::string outputContent = "fake-output-params-contents";
    writeFile(dir / "sapling-spend.params",  spendContent);
    writeFile(dir / "sapling-output.params", outputContent);

    const std::string spendHash  = dragonx::util::sha256Hex(spendContent.data(),  spendContent.size());
    const std::string outputHash = dragonx::util::sha256Hex(outputContent.data(), outputContent.size());
    const std::vector<std::pair<std::string, std::string>> good = {
        { "sapling-spend.params",  spendHash },
        { "sapling-output.params", outputHash },
    };

    // Valid params → pass, and a verification marker is written.
    EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));
    EXPECT_TRUE(fsn::exists(dir / ".sapling_verified"));

    // Second call → marker fast-path, still true (round-trips the cache).
    EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));

    // Wrong expected hash → integrity failure (fresh dir so no marker can short-circuit it).
    fsn::path dir2 = fsn::temp_directory_path() / "od_sapling_test2";
    fsn::remove_all(dir2, rmec);
    fsn::create_directories(dir2);
    writeFile(dir2 / "sapling-spend.params",  spendContent);
    writeFile(dir2 / "sapling-output.params", outputContent);
    const std::vector<std::pair<std::string, std::string>> wrong = {
        { "sapling-spend.params",  std::string(64, 'a') },
        { "sapling-output.params", outputHash },
    };
    EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir2.string(), wrong));

    // Truncated content (size change) invalidates the marker AND fails the hash.
    writeFile(dir / "sapling-spend.params", std::string("x"));
    EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));

    // A missing param → fail.
    fsn::remove(dir / "sapling-output.params", rmec);
    EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));

    fsn::remove_all(dir, rmec);
    fsn::remove_all(dir2, rmec);
}

void testPlatformEnsureDirectory()
{
    using dragonx::util::Platform;

    // An existing directory → true (temp_directory_path always exists).
    {
        std::string err = "sentinel";
        EXPECT_TRUE(Platform::ensureDirectory(std::filesystem::temp_directory_path().string(), &err));
    }

    // A fresh nested path → created, no error.
    {
        std::filesystem::path base = std::filesystem::temp_directory_path() / "od_ensuredir_test";
        std::error_code rmec; std::filesystem::remove_all(base, rmec);
        std::filesystem::path nested = base / "a" / "b" / "c";
        std::string err;
        EXPECT_TRUE(Platform::ensureDirectory(nested.string(), &err));
        EXPECT_TRUE(std::filesystem::is_directory(nested));
        EXPECT_TRUE(err.empty());
        std::filesystem::remove_all(base, rmec);
    }

    // Empty path → false with a message.
    {
        std::string err;
        EXPECT_TRUE(!Platform::ensureDirectory("", &err));
        EXPECT_TRUE(!err.empty());
    }

    // A path whose parent component is a regular file cannot be created. This fails the
    // same way for root and non-root, so it's a stable negative case across environments.
    {
        std::filesystem::path f = std::filesystem::temp_directory_path() / "od_ensuredir_file";
        std::error_code rmec; std::filesystem::remove_all(f, rmec);
        { std::ofstream(f.string()) << "x"; }
        std::string err;
        bool ok = Platform::ensureDirectory((f / "child").string(), &err);
        std::filesystem::remove_all(f, rmec);
        EXPECT_TRUE(!ok);
        EXPECT_TRUE(err.find("Cannot create") != std::string::npos);
    }
}

#ifndef _WIN32
// Integration tests that drive the REAL EmbeddedDaemon fork/exec/waitpid paths (POSIX only).
void testExecFailureReported()
{
    using dragonx::daemon::EmbeddedDaemon;
    namespace fsn = std::filesystem;

    // A present-but-non-executable file: execv() must fail, and the F2 self-pipe handshake
    // must report it as a start FAILURE with a precise reason — not a transient "Running".
    fsn::path bin = fsn::temp_directory_path() / "od_fake_daemon_bin";
    { std::ofstream(bin.string(), std::ios::binary) << "this is not an executable"; }
    fsn::permissions(bin, fsn::perms::owner_read, fsn::perm_options::replace);  // 0400, no +x

    EmbeddedDaemon d;
    d.setSkipPortCheck(true);   // bypass the port + datadir-lock gates so we reach startProcess()
    EXPECT_TRUE(!d.start(bin.string()));
    EXPECT_TRUE(d.getLastError().find("not executable or wrong architecture") != std::string::npos);
    EXPECT_TRUE(!d.isRunning());

    std::error_code ec; fsn::remove(bin, ec);
}

void testDaemonCrashDetected()
{
    using dragonx::daemon::EmbeddedDaemon;
    namespace fsn = std::filesystem;

    // A tiny script that ignores the injected daemon args, lives briefly, then exits abnormally
    // — standing in for a daemon that crashes. is_script detection runs it via /bin/bash.
    fsn::path script = fsn::temp_directory_path() / "od_fake_daemon.sh";
    { std::ofstream(script.string()) << "#!/bin/bash\nsleep 0.2\nexit 7\n"; }
    fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace);  // +x

    EmbeddedDaemon d;
    d.setSkipPortCheck(true);
    EXPECT_TRUE(d.start(script.string()));
    EXPECT_TRUE(d.isRunning());              // reads the atomic state_, not a racy waitpid()

    // Hammer isRunning() the way the UI thread does while the child exits and monitorProcess()
    // reaps it. Pre-fix (F1), isRunning()'s own waitpid() could steal the reap and hide the
    // crash; with the fix the monitor is the sole reaper and always sees it.
    for (int i = 0; i < 400 && d.getCrashCount() == 0; ++i) {
        (void)d.isRunning();
        std::this_thread::sleep_for(std::chrono::milliseconds(5));
    }
    EXPECT_TRUE(d.getCrashCount() >= 1);     // the unexpected exit was detected and counted
    EXPECT_TRUE(!d.isRunning());             // state_ flipped to Error

    d.stop();                                // join the monitor thread cleanly
    std::error_code ec; fsn::remove(script, ec);
}
#endif  // !_WIN32

void testDatadirLockGate()
{
    using dragonx::daemon::EmbeddedDaemon;

    // Normal start, no lingering daemon after the bounded wait → proceed.
    auto clear = EmbeddedDaemon::evaluateDatadirLockGate(false, false, false);
    EXPECT_TRUE(clear.proceed);

    // A previous dragonxd still alive after the wait → bail with a distinct, non-crash msg.
    auto locked = EmbeddedDaemon::evaluateDatadirLockGate(false, false, true);
    EXPECT_TRUE(!locked.proceed);
    EXPECT_TRUE(std::string(locked.errorMessage).find("data directory lock") != std::string::npos);

    // Isolated instance via skip_port_check_ is exempt even if a sibling dragonxd is running.
    auto skipPort = EmbeddedDaemon::evaluateDatadirLockGate(true, false, true);
    EXPECT_TRUE(skipPort.proceed);

    // Isolated instance via -datadir override is exempt even if a sibling is running.
    auto isolated = EmbeddedDaemon::evaluateDatadirLockGate(false, true, true);
    EXPECT_TRUE(isolated.proceed);
}

void testDaemonLifecycleExecution()
{
    using dragonx::daemon::DaemonController;

    auto restart = DaemonController::evaluateLifecycleOperation(
        DaemonController::LifecycleOperation::ManualRestart, true, true, true, false);
    MockDaemonLifecycleRuntime restartRuntime;
    MockDaemonLifecycleTask restartTask;
    auto restartResult = DaemonController::executeLifecycleOperation(restart, restartRuntime, restartTask);
    EXPECT_TRUE(restartResult.completed);
    EXPECT_TRUE(restartResult.stopped);
    EXPECT_TRUE(restartResult.started);
    EXPECT_EQ(restartTask.sleeps, 5);
    EXPECT_TRUE(restartRuntime.calls == std::vector<std::string>({"stop", "start"}));

    auto rescan = DaemonController::evaluateLifecycleOperation(
        DaemonController::LifecycleOperation::Rescan, true, true, true, false);
    MockDaemonLifecycleRuntime rescanRuntime;
    MockDaemonLifecycleTask rescanTask;
    auto rescanResult = DaemonController::executeLifecycleOperation(rescan, rescanRuntime, rescanTask);
    EXPECT_TRUE(rescanResult.completed);
    EXPECT_EQ(rescanTask.sleeps, 30);
    EXPECT_TRUE(rescanRuntime.calls == std::vector<std::string>({"stop", "reset-output", "start"}));

    auto deleteData = DaemonController::evaluateLifecycleOperation(
        DaemonController::LifecycleOperation::DeleteBlockchainData, true, true, false, false);
    MockDaemonLifecycleRuntime deleteRuntime;
    MockDaemonLifecycleTask deleteTask;
    auto deleteResult = DaemonController::executeLifecycleOperation(deleteData, deleteRuntime, deleteTask);
    EXPECT_TRUE(deleteResult.completed);
    EXPECT_EQ(deleteResult.deletedItems, 7);
    EXPECT_TRUE(deleteRuntime.calls == std::vector<std::string>({"stop", "delete", "reset-output", "start"}));

    auto bootstrap = DaemonController::evaluateLifecycleOperation(
        DaemonController::LifecycleOperation::BootstrapStop, true, true, true, false);
    MockDaemonLifecycleRuntime bootstrapRuntime;
    MockDaemonLifecycleTask bootstrapTask;
    auto bootstrapResult = DaemonController::executeLifecycleOperation(bootstrap, bootstrapRuntime, bootstrapTask);
    EXPECT_TRUE(bootstrapResult.completed);
    EXPECT_TRUE(bootstrapResult.stopped);
    EXPECT_TRUE(bootstrapRuntime.calls == std::vector<std::string>({"rpc-stop-disconnect"}));
    EXPECT_EQ(bootstrapRuntime.rpcStopContext, std::string("Bootstrap daemon stop"));
    EXPECT_EQ(bootstrapRuntime.disconnectReason, std::string("Bootstrap"));

    MockDaemonLifecycleRuntime cancelRuntime;
    MockDaemonLifecycleTask cancelTask;
    cancelTask.cancelAfterSleeps = 1;
    auto cancelResult = DaemonController::executeLifecycleOperation(rescan, cancelRuntime, cancelTask);
    EXPECT_TRUE(cancelResult.cancelled);
    EXPECT_FALSE(cancelResult.started);
    EXPECT_TRUE(cancelRuntime.calls == std::vector<std::string>({"stop"}));

    MockDaemonLifecycleRuntime shutdownRuntime;
    MockDaemonLifecycleTask shutdownTask;
    shutdownTask.isShuttingDown = true;
    auto shutdownResult = DaemonController::executeLifecycleOperation(restart, shutdownRuntime, shutdownTask);
    EXPECT_TRUE(shutdownResult.cancelled);
    EXPECT_TRUE(shutdownResult.stopped);
    EXPECT_FALSE(shutdownResult.started);
    EXPECT_TRUE(shutdownRuntime.calls == std::vector<std::string>({"stop"}));
}

void testDaemonLifecycleAdapters()
{
    using dragonx::daemon::AsyncLifecycleTaskContext;
    using dragonx::daemon::BlockchainDataCleaner;
    using dragonx::daemon::ImmediateLifecycleTaskContext;
    using dragonx::util::AsyncTaskManager;

    auto cancelled = std::make_shared<std::atomic<bool>>(false);
    std::atomic<bool> shuttingDown{false};
    AsyncTaskManager::Token token(cancelled);
    AsyncLifecycleTaskContext asyncContext(token, shuttingDown);
    EXPECT_FALSE(asyncContext.cancelled());
    EXPECT_FALSE(asyncContext.shuttingDown());
    cancelled->store(true);
    EXPECT_TRUE(asyncContext.cancelled());
    shuttingDown.store(true);
    EXPECT_TRUE(asyncContext.shuttingDown());

    ImmediateLifecycleTaskContext immediateContext;
    EXPECT_FALSE(immediateContext.cancelled());
    EXPECT_FALSE(immediateContext.shuttingDown());
    immediateContext.sleepForMs(50);

    fs::path dataDir = makeTempDir();
    fs::create_directories(dataDir / "blocks" / "nested");
    fs::create_directories(dataDir / "chainstate");
    fs::create_directories(dataDir / "database");
    fs::create_directories(dataDir / "notarizations");
    std::ofstream(dataDir / "blocks" / "nested" / "blk00000.dat") << "block";
    std::ofstream(dataDir / "chainstate" / "state.dat") << "state";
    std::ofstream(dataDir / "database" / "db.dat") << "db";
    std::ofstream(dataDir / "notarizations" / "notary.dat") << "notary";
    std::ofstream(dataDir / "peers.dat") << "peers";
    std::ofstream(dataDir / "fee_estimates.dat") << "fees";
    std::ofstream(dataDir / "wallet.dat") << "wallet";

    int removed = BlockchainDataCleaner::removeBlockchainData(dataDir);
    EXPECT_TRUE(removed >= 6);
    EXPECT_FALSE(fs::exists(dataDir / "blocks"));
    EXPECT_FALSE(fs::exists(dataDir / "chainstate"));
    EXPECT_FALSE(fs::exists(dataDir / "database"));
    EXPECT_FALSE(fs::exists(dataDir / "notarizations"));
    EXPECT_FALSE(fs::exists(dataDir / "peers.dat"));
    EXPECT_FALSE(fs::exists(dataDir / "fee_estimates.dat"));
    EXPECT_TRUE(fs::exists(dataDir / "wallet.dat"));
    fs::remove_all(dataDir);
}

// Pure console wrap layout / hit-test / selection (console_text_layout.h), driven by a
// fixed-width measurement stub so the byte-offset selection math is unit-testable.
void testConsoleTextLayout()
{
    using namespace dragonx::ui;
    struct StubMeasure : ConsoleTextMeasure {
        float charW = 10.0f;
        float width(const char* b, const char* e) const override {
            return static_cast<float>(e - b) * charW;
        }
        const char* wrapPosition(const char* b, const char* e, float w) const override {
            int maxChars = static_cast<int>(w / charW);
            if (maxChars < 1) maxChars = 1;
            return (e - b) <= maxChars ? e : b + maxChars;
        }
    } stub;

    std::vector<std::string> lines = { "", "abc", "abcdefghij" };  // 0, 3, 10 chars
    auto get = [&lines](int i) -> const std::string& { return lines[i]; };
    const float lineH = 12.0f, gap = 2.0f;

    // wrapWidth 50 (charW 10 -> 5 chars/row)
    ConsoleLayout L = BuildConsoleLayout(static_cast<int>(lines.size()), get, 50.0f, lineH, gap, stub);
    EXPECT_EQ(L.segments.size(), static_cast<size_t>(3));
    EXPECT_EQ(L.segments[0].size(), static_cast<size_t>(1));     // empty -> one empty seg
    EXPECT_EQ(L.segments[0][0].byteEnd, 0);
    EXPECT_NEAR(L.heights[0], lineH + gap, 0.01);
    EXPECT_EQ(L.segments[1].size(), static_cast<size_t>(1));     // "abc" fits
    EXPECT_EQ(L.segments[1][0].byteEnd, 3);
    EXPECT_EQ(L.segments[2].size(), static_cast<size_t>(2));     // 10 chars, 5/row -> 2 rows
    EXPECT_EQ(L.segments[2][0].byteEnd, 5);
    EXPECT_EQ(L.segments[2][1].byteStart, 5);
    EXPECT_EQ(L.segments[2][1].byteEnd, 10);
    EXPECT_NEAR(L.heights[2], 2 * lineH + gap, 0.01);
    EXPECT_NEAR(L.cumulativeY[2], 2 * (lineH + gap), 0.01);
    EXPECT_NEAR(L.totalHeight, 3 * (lineH + gap) + lineH, 0.01);

    // Hit-test: second wrap row of line 2, column 7.
    ConsoleHit hit = HitTestConsoleLayout(L, 3, get, /*relX*/ 20.0f, /*relY*/ 45.0f, stub);
    EXPECT_EQ(hit.visibleRow, 2);
    EXPECT_EQ(hit.col, 7);
    // Past the bottom clamps to the last line's end.
    ConsoleHit last = HitTestConsoleLayout(L, 3, get, 1000.0f, 9999.0f, stub);
    EXPECT_EQ(last.visibleRow, 2);
    EXPECT_EQ(last.col, 10);

    // Selection extraction (multi-line, no filter, then filtered to line 0 only).
    std::vector<std::string> lines2 = { "hello", "world" };
    auto get2 = [&lines2](int i) -> const std::string& { return lines2[i]; };
    EXPECT_EQ(ExtractConsoleSelection(2, get2, {}, {0, 1}, {1, 3}), std::string("ello\nwor"));
    EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector<int>{0}, {0, 1}, {1, 3}), std::string("ello"));
}

// ConsoleScrollController — auto-scroll state machine (pin, wheel-up cooldown, backlog).
void testConsoleScrollController()
{
    using dragonx::ui::ConsoleScrollController;

    // Starts pinned to the bottom; new lines while pinned are not counted as a backlog.
    {
        ConsoleScrollController s;
        EXPECT_TRUE(s.autoScroll());
        s.onLinesAdded(5);
        EXPECT_EQ(s.newLines(), 0);
    }

    // Wheel-up detaches, starts the cooldown, and begins counting backlog lines.
    {
        ConsoleScrollController s;
        s.onUserScrolledUp();
        EXPECT_FALSE(s.autoScroll());
        s.onLinesAdded(3);
        s.onLinesAdded(2);
        EXPECT_EQ(s.newLines(), 5);

        // At-bottom is ignored until the cooldown elapses.
        s.considerReenable(true);
        EXPECT_FALSE(s.autoScroll());
        s.tickCooldown(ConsoleScrollController::kScrollUpCooldownSeconds + 0.01f);
        // Elapsed, but only re-pins when actually at the bottom.
        s.considerReenable(false);
        EXPECT_FALSE(s.autoScroll());
        s.considerReenable(true);
        EXPECT_TRUE(s.autoScroll());
        EXPECT_EQ(s.newLines(), 0);   // re-pinning clears the backlog
    }

    // Checkbox toggle: enabling clears the backlog, disabling preserves state.
    {
        ConsoleScrollController s;
        s.onUserScrolledUp();
        s.onLinesAdded(4);
        EXPECT_EQ(s.newLines(), 4);
        s.setAutoScroll(true);
        EXPECT_TRUE(s.autoScroll());
        EXPECT_EQ(s.newLines(), 0);
        s.setAutoScroll(false);
        EXPECT_FALSE(s.autoScroll());
    }

    // Jump-to-bottom pill re-pins and clears the backlog.
    {
        ConsoleScrollController s;
        s.onUserScrolledUp();
        s.onLinesAdded(9);
        s.jumpToBottom();
        EXPECT_TRUE(s.autoScroll());
        EXPECT_EQ(s.newLines(), 0);
    }
}

// ConsoleSelectionController — anchor/caret state, ordering, select-all, eviction shift.
void testConsoleSelectionController()
{
    using dragonx::ui::ConsoleSelectionController;
    using dragonx::ui::ConsoleTextPos;

    // Ordering helper.
    EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 2}, {1, 5}));
    EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 5}, {2, 0}));
    EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({3, 4}, {3, 4}));
    EXPECT_FALSE(ConsoleSelectionController::isBeforeOrEqual({2, 0}, {1, 9}));

    // Drag lifecycle: press starts a drag but is not yet a real selection.
    {
        ConsoleSelectionController sel;
        sel.beginDrag({2, 3});
        EXPECT_TRUE(sel.dragging());
        EXPECT_FALSE(sel.active());
        sel.updateDrag({2, 3});               // no movement -> still not active
        EXPECT_FALSE(sel.active());
        sel.updateDrag({4, 1});               // moved -> real selection
        EXPECT_TRUE(sel.active());
        sel.endDrag({4, 1});
        EXPECT_FALSE(sel.dragging());
        EXPECT_TRUE(sel.active());
        // Ordered range is anchor..caret regardless of drag direction.
        EXPECT_EQ(sel.rangeStart().line, 2);
        EXPECT_EQ(sel.rangeStart().col, 3);
        EXPECT_EQ(sel.rangeEnd().line, 4);
        EXPECT_EQ(sel.rangeEnd().col, 1);
    }

    // Upward drag: caret before anchor -> range is still ordered low..high.
    {
        ConsoleSelectionController sel;
        sel.beginDrag({5, 2});
        sel.updateDrag({1, 0});
        EXPECT_EQ(sel.rangeStart().line, 1);
        EXPECT_EQ(sel.rangeEnd().line, 5);
    }

    // Select-all + clear.
    {
        ConsoleSelectionController sel;
        sel.selectAll(0, 0);                  // empty buffer -> no-op
        EXPECT_FALSE(sel.active());
        sel.selectAll(3, 7);                  // lines 0..2, last line 7 bytes
        EXPECT_TRUE(sel.active());
        EXPECT_EQ(sel.rangeStart().line, 0);
        EXPECT_EQ(sel.rangeStart().col, 0);
        EXPECT_EQ(sel.rangeEnd().line, 2);
        EXPECT_EQ(sel.rangeEnd().col, 7);
        sel.clear();
        EXPECT_FALSE(sel.active());
        EXPECT_FALSE(sel.dragging());
    }

    // Eviction shift: partial (top of selection clamped), full (dropped), and no-op.
    {
        ConsoleSelectionController sel;
        sel.selectAll(10, 4);                 // [0,0]..[9,4]
        sel.shiftForEviction(3);              // 3 lines fell off the top
        EXPECT_TRUE(sel.active());
        EXPECT_EQ(sel.rangeStart().line, 0);  // was line 0 -> clamped to 0
        EXPECT_EQ(sel.rangeStart().col, 0);
        EXPECT_EQ(sel.rangeEnd().line, 6);    // 9 - 3
        EXPECT_EQ(sel.rangeEnd().col, 4);
        sel.shiftForEviction(0);              // no-op
        EXPECT_EQ(sel.rangeEnd().line, 6);
        sel.shiftForEviction(100);            // whole selection evicted -> dropped
        EXPECT_FALSE(sel.active());
    }

    // extract(): inactive -> "", active -> the joined selected text.
    {
        std::vector<std::string> lines = {"hello", "world"};
        auto get = [&lines](int i) -> const std::string& { return lines[i]; };
        ConsoleSelectionController sel;
        EXPECT_EQ(sel.extract(2, get, {}), std::string(""));
        sel.beginDrag({0, 1});
        sel.updateDrag({1, 3});
        EXPECT_EQ(sel.extract(2, get, {}), std::string("ello\nwor"));
        // Filtered so only line 0 is visible -> selection clips to it.
        EXPECT_EQ(sel.extract(2, get, std::vector<int>{0}), std::string("ello"));
    }
}

// ConsoleModel — thread-safe ingest queue drained on the main thread, with a line cap.
void testConsoleModel()
{
    using dragonx::ui::ConsoleModel;
    using dragonx::ui::ConsoleChannel;

    // Basic ingest -> drain: order + channels preserved, counts reported.
    {
        ConsoleModel m;
        m.ingest("one", ConsoleChannel::Command);
        m.ingest("two", ConsoleChannel::Info);
        m.ingest("three", ConsoleChannel::Error);
        auto dr = m.drain();
        EXPECT_EQ(dr.added, static_cast<size_t>(3));
        EXPECT_EQ(dr.popped, static_cast<size_t>(0));
        EXPECT_EQ(m.size(), static_cast<size_t>(3));
        EXPECT_EQ(m[0].text, std::string("one"));
        EXPECT_TRUE(m[0].channel == ConsoleChannel::Command);
        EXPECT_TRUE(m[2].channel == ConsoleChannel::Error);
        EXPECT_EQ(m.back().text, std::string("three"));

        // Draining again with nothing pending is a no-op.
        auto empty = m.drain();
        EXPECT_EQ(empty.added, static_cast<size_t>(0));
        EXPECT_EQ(empty.popped, static_cast<size_t>(0));
        EXPECT_EQ(m.size(), static_cast<size_t>(3));
    }

    // Line cap: front lines are evicted, popped is reported for selection adjustment.
    {
        ConsoleModel m(3);  // cap = 3
        for (int i = 0; i < 5; ++i) m.ingest(std::to_string(i), ConsoleChannel::None);
        auto dr = m.drain();
        EXPECT_EQ(dr.added, static_cast<size_t>(5));
        EXPECT_EQ(dr.popped, static_cast<size_t>(2));
        EXPECT_EQ(m.size(), static_cast<size_t>(3));
        EXPECT_EQ(m[0].text, std::string("2"));  // "0" and "1" evicted
        EXPECT_EQ(m[2].text, std::string("4"));
    }

    // clear() drops the visible lines but not lines still queued for the next drain.
    {
        ConsoleModel m;
        m.ingest("a", ConsoleChannel::None);
        m.drain();
        EXPECT_EQ(m.size(), static_cast<size_t>(1));
        m.ingest("b", ConsoleChannel::None);  // queued, not yet visible
        m.clear();
        EXPECT_EQ(m.size(), static_cast<size_t>(0));
        m.drain();
        EXPECT_EQ(m.size(), static_cast<size_t>(1));  // "b" survived the clear
        EXPECT_EQ(m[0].text, std::string("b"));
    }

    // Concurrency: many producer threads ingest while the main thread drains; no line lost.
    {
        ConsoleModel m(1000000);  // large cap so nothing is evicted
        const int kThreads = 8, kPerThread = 2000;
        std::atomic<bool> go{false};
        std::vector<std::thread> producers;
        for (int t = 0; t < kThreads; ++t) {
            producers.emplace_back([&m, &go, t]() {
                while (!go.load()) { /* spin until start */ }
                for (int i = 0; i < kPerThread; ++i)
                    m.ingest("x", ConsoleChannel::None);
            });
        }
        go.store(true);
        // Interleave drains on this thread while producers run.
        size_t drained = 0;
        while (drained < static_cast<size_t>(kThreads * kPerThread)) {
            drained += m.drain().added;
        }
        for (auto& th : producers) th.join();
        drained += m.drain().added;  // final sweep for anything queued after the last drain
        EXPECT_EQ(drained, static_cast<size_t>(kThreads * kPerThread));
        EXPECT_EQ(m.size(), static_cast<size_t>(kThreads * kPerThread));
    }

    // JSON fold state: ingest carries a fold span; toggleCollapsed flips only openers.
    {
        ConsoleModel m;
        m.ingest("{", ConsoleChannel::JsonBrace, 3);
        m.ingest("  \"a\": 1", ConsoleChannel::JsonKey, 0);
        m.ingest("}", ConsoleChannel::JsonBrace, 0);
        m.drain();
        EXPECT_EQ(m[0].foldSpan, 3);
        EXPECT_FALSE(m[0].collapsed);
        EXPECT_TRUE(m.toggleCollapsed(0));    // opener -> collapsed
        EXPECT_TRUE(m[0].collapsed);
        EXPECT_FALSE(m.toggleCollapsed(0));   // -> expanded
        EXPECT_FALSE(m[0].collapsed);
        EXPECT_FALSE(m.toggleCollapsed(1));   // not an opener -> no-op
        EXPECT_FALSE(m.toggleCollapsed(99));  // out of range -> no-op
    }
}

// Portfolio helpers — sum/query a labeled address group against per-address balances.
void testPortfolioHelpers()
{
    using dragonx::AddressInfo;
    using dragonx::data::SumPortfolioBalance;
    using dragonx::data::PortfolioEntryContains;
    using dragonx::data::PortfolioEntryAdd;
    using dragonx::data::PortfolioEntryRemove;

    std::vector<AddressInfo> wallet;
    auto mk = [](const char* addr, double bal, const char* type) {
        AddressInfo a; a.address = addr; a.balance = bal; a.type = type; return a;
    };
    wallet.push_back(mk("R-t1", 1.5, "transparent"));
    wallet.push_back(mk("R-t2", 2.0, "transparent"));
    wallet.push_back(mk("zs-s1", 10.0, "shielded"));
    wallet.push_back(mk("zs-s2", 0.25, "shielded"));

    // Empty group sums to 0.
    EXPECT_NEAR(SumPortfolioBalance({}, wallet), 0.0, 1e-9);
    // A group of two known addresses.
    EXPECT_NEAR(SumPortfolioBalance({"R-t1", "zs-s1"}, wallet), 11.5, 1e-9);
    // Unknown addresses contribute 0 (not present in the wallet).
    EXPECT_NEAR(SumPortfolioBalance({"R-t2", "does-not-exist"}, wallet), 2.0, 1e-9);
    // All shielded.
    EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "zs-s2"}, wallet), 10.25, 1e-9);

    // Membership + add/remove semantics.
    std::vector<std::string> group = {"R-t1"};
    EXPECT_TRUE(PortfolioEntryContains(group, "R-t1"));
    EXPECT_FALSE(PortfolioEntryContains(group, "R-t2"));
    EXPECT_TRUE(PortfolioEntryAdd(group, "R-t2"));    // added
    EXPECT_FALSE(PortfolioEntryAdd(group, "R-t2"));   // already present -> no-op
    EXPECT_EQ(group.size(), static_cast<size_t>(2));
    EXPECT_TRUE(PortfolioEntryRemove(group, "R-t1")); // removed
    EXPECT_FALSE(PortfolioEntryRemove(group, "R-t1"));// gone -> no-op
    EXPECT_EQ(group.size(), static_cast<size_t>(1));
    EXPECT_EQ(group[0], std::string("R-t2"));
}

// Market price-series math (data/market_series.h) — minute-resampling + time-window bucketing
// that feed the price chart and the portfolio-group sparklines.
void testMarketSeries()
{
    using dragonx::MarketInfo;
    using dragonx::data::resampleHistory;
    using dragonx::data::bucketBySeconds;
    using dragonx::data::sparklineSeries;
    using dragonx::data::chartSeries;

    // resampleHistory: interval 0 (minute) returns the input untouched.
    std::vector<double> minute = {1.0, 2.0, 3.0, 4.0};
    EXPECT_EQ(resampleHistory(minute, 0).size(), static_cast<size_t>(4));
    EXPECT_NEAR(resampleHistory(minute, 0)[3], 4.0, 1e-9);
    // interval 1 (hour = 60 minute-samples): 120 samples -> 2 averaged points.
    std::vector<double> longHist(120, 0.0);
    for (int i = 0; i < 120; i++) longHist[i] = (i < 60) ? 10.0 : 20.0;
    auto hourly = resampleHistory(longHist, 1);
    EXPECT_EQ(hourly.size(), static_cast<size_t>(2));
    EXPECT_NEAR(hourly[0], 10.0, 1e-9);
    EXPECT_NEAR(hourly[1], 20.0, 1e-9);
    // A trailing partial block still averages (61 samples -> 2 points).
    EXPECT_EQ(resampleHistory(std::vector<double>(61, 5.0), 1).size(), static_cast<size_t>(2));
    // Empty input -> empty output.
    EXPECT_TRUE(resampleHistory({}, 2).empty());

    // bucketBySeconds: guards + averaging within fixed windows.
    EXPECT_TRUE(bucketBySeconds({}, 3600).empty());
    EXPECT_TRUE(bucketBySeconds({{0, 1.0}}, 0).empty());
    std::vector<std::pair<std::time_t, double>> s = {
        {0, 10.0}, {1000, 20.0},     // bucket 0 (< 3600s) -> avg 15
        {3600, 40.0}, {7000, 60.0},  // bucket 1 -> avg 50
    };
    auto b = bucketBySeconds(s, 3600);
    EXPECT_EQ(b.size(), static_cast<size_t>(2));
    EXPECT_NEAR(b[0], 15.0, 1e-9);
    EXPECT_NEAR(b[1], 50.0, 1e-9);

    // sparklineSeries: hour interval buckets the intraday series...
    MarketInfo m;
    m.price_history = {1.0, 2.0, 3.0};
    m.price_chart_intraday = { {0, 10.0}, {100, 12.0}, {3600, 20.0}, {3700, 22.0} };  // 2 hourly buckets
    EXPECT_EQ(sparklineSeries(m, 1).size(), static_cast<size_t>(2));
    // ...interval 0 (minute) always uses the live buffer...
    EXPECT_EQ(sparklineSeries(m, 0).size(), static_cast<size_t>(3));
    // ...and with no historical series, an interval falls back to the resampled live buffer.
    MarketInfo bare; bare.price_history = std::vector<double>(120, 7.0);
    EXPECT_EQ(sparklineSeries(bare, 1).size(), static_cast<size_t>(2));

    // chartSeries: the 1H window keeps only samples within the last hour of `now`.
    std::time_t now = 100000;
    MarketInfo cm;
    cm.price_chart_intraday = {
        {now - 7200, 1.0},  // 2h ago  -> excluded from 1H
        {now - 1800, 2.0},  // 30m ago -> included
        {now - 60,   3.0},  // 1m ago  -> included
    };
    auto oneH = chartSeries(cm, 1, now);
    EXPECT_EQ(oneH.size(), static_cast<size_t>(2));
    EXPECT_NEAR(oneH[0].second, 2.0, 1e-9);
    // Live (interval 0) synthesizes timestamps from the minute buffer, newest at `now`.
    MarketInfo lm; lm.price_history = {1.0, 2.0, 3.0};
    auto live = chartSeries(lm, 0, now);
    EXPECT_EQ(live.size(), static_cast<size_t>(3));
    EXPECT_EQ(static_cast<long>(live.back().first), static_cast<long>(now));
    EXPECT_NEAR(live.back().second, 3.0, 1e-9);
}

// Per-exchange candle adapters (data/exchange_candles.h) — the URL builder + the Ourbit (array) /
// NonKYC (UDF bars) parsers, verified against real captured API responses, plus the chartSeries
// switch that draws the per-exchange series when it's active.
void testExchangeCandles()
{
    using dragonx::data::buildExchangeCandleUrl;
    using dragonx::data::parseExchangeCandles;
    using dragonx::data::hasExchangeCandleAdapter;
    using dragonx::data::CandleRange;

    // --- URL builder (deterministic given `now`) ---
    EXPECT_TRUE(buildExchangeCandleUrl("ourbit", "DRGX", "USDT", CandleRange::Intraday, 1000)
                == "https://api.ourbit.com/api/v3/klines?symbol=DRGXUSDT&interval=5m&limit=576");
    EXPECT_TRUE(buildExchangeCandleUrl("ourbit", "DRGX", "USDT", CandleRange::Daily, 1000)
                == "https://api.ourbit.com/api/v3/klines?symbol=DRGXUSDT&interval=1d&limit=365");
    EXPECT_TRUE(buildExchangeCandleUrl("nonkyc_io", "DRGX", "USDT", CandleRange::Daily, 1000000000)
                == "https://api.nonkyc.io/api/v2/market/candles?symbol=DRGX_USDT&resolution=1440&from=968464000&to=1000000000");
    EXPECT_TRUE(buildExchangeCandleUrl("binance", "DRGX", "USDT", CandleRange::Daily, 1000).empty());
    EXPECT_TRUE(hasExchangeCandleAdapter("ourbit") && hasExchangeCandleAdapter("nonkyc_io"));
    EXPECT_TRUE(!hasExchangeCandleAdapter("kraken"));

    // --- Ourbit parse: array of arrays, close at index 4, prices as strings, time in ms ---
    auto o = parseExchangeCandles("ourbit",
        R"([[1783728000000,"0.01298","0.01406","0.01286","0.01395","1203855.69",1783814400000,"16132.16809"],)"
        R"([1783814400000,"0.01395","0.01418","0.01364","0.01371","584276.8",1783900800000,"8126.07742"]])");
    EXPECT_EQ(o.size(), static_cast<size_t>(2));
    EXPECT_NEAR(o[0].second, 0.01395, 1e-9);                              // close = idx 4
    EXPECT_EQ(static_cast<long>(o[0].first), 1783728000L);               // ms -> seconds
    EXPECT_TRUE(o[0].first < o[1].first);                                // ascending

    // --- NonKYC parse: {"bars":[{time,close,...}]}, close numeric, time in ms ---
    auto n = parseExchangeCandles("nonkyc_io",
        R"({"bars":[{"time":1781308800000,"close":0.031354,"open":0.032559,"high":0.034999,"low":0.02804,"volume":23550.357},)"
        R"({"time":1781395200000,"close":0.02555,"open":0.031091,"high":0.03296,"low":0.025035,"volume":17657.726}]})");
    EXPECT_EQ(n.size(), static_cast<size_t>(2));
    EXPECT_NEAR(n[0].second, 0.031354, 1e-9);
    EXPECT_EQ(static_cast<long>(n[0].first), 1781308800L);

    // --- robustness: empty / garbage / no-data / unmapped -> empty ---
    EXPECT_TRUE(parseExchangeCandles("ourbit", "").empty());
    EXPECT_TRUE(parseExchangeCandles("ourbit", "not json").empty());
    EXPECT_TRUE(parseExchangeCandles("nonkyc_io", R"({"bars":[],"meta":{"noData":true}})").empty());
    EXPECT_TRUE(parseExchangeCandles("binance", "[[1,2,3,4,5]]").empty());

    // --- chartSeries prefers the per-exchange series when active, else the aggregate ---
    using dragonx::MarketInfo;
    using dragonx::data::chartSeries;
    std::time_t now = 2000000000;
    MarketInfo m;
    // Aggregate daily (two points in the last 30d) vs a DISTINCT exchange daily series.
    m.price_chart_daily    = { {now - 10 * 86400, 1.0}, {now - 1 * 86400, 1.1} };
    m.exchange_chart_daily = { {now - 10 * 86400, 5.0}, {now - 1 * 86400, 5.5} };
    m.exchange_chart_active = false;
    auto agg = chartSeries(m, 4, now);                                   // 1M
    EXPECT_TRUE(!agg.empty() && agg.back().second < 2.0);                // aggregate values
    m.exchange_chart_active = true;
    auto exch = chartSeries(m, 4, now);
    EXPECT_TRUE(!exch.empty() && exch.back().second > 4.0);              // exchange values

    // --- OHLC parse: full open/high/low/close, both formats ---
    using dragonx::data::parseExchangeOHLC;
    auto oc = parseExchangeOHLC("ourbit",
        R"([[1783728000000,"0.01298","0.01406","0.01286","0.01395","1203855.69",1783814400000,"16132.16809"]])");
    EXPECT_EQ(oc.size(), static_cast<size_t>(1));
    EXPECT_NEAR(oc[0].open, 0.01298, 1e-9);
    EXPECT_NEAR(oc[0].high, 0.01406, 1e-9);
    EXPECT_NEAR(oc[0].low,  0.01286, 1e-9);
    EXPECT_NEAR(oc[0].close, 0.01395, 1e-9);
    auto nc = parseExchangeOHLC("nonkyc_io",
        R"({"bars":[{"time":1781308800000,"close":0.031354,"open":0.032559,"high":0.034999,"low":0.02804,"volume":1}]})");
    EXPECT_EQ(nc.size(), static_cast<size_t>(1));
    EXPECT_NEAR(nc[0].high, 0.034999, 1e-9);
    EXPECT_NEAR(nc[0].low,  0.02804, 1e-9);

    // --- bucketOHLC: two 5-min candles in the same hour -> one hourly candle (o=first, h=max, l=min, c=last) ---
    using dragonx::data::bucketOHLC;
    using dragonx::data::Candle;
    std::vector<Candle> fine = {
        {3600, 10.0, 12.0, 9.0, 11.0},    // hour bucket 1
        {3900, 11.0, 15.0, 8.0, 14.0},    // same hour bucket 1
        {7200, 20.0, 21.0, 19.0, 20.5},   // hour bucket 2
    };
    auto hourly = bucketOHLC(fine, 3600);
    EXPECT_EQ(hourly.size(), static_cast<size_t>(2));
    EXPECT_NEAR(hourly[0].open, 10.0, 1e-9);   // first open
    EXPECT_NEAR(hourly[0].high, 15.0, 1e-9);   // max high
    EXPECT_NEAR(hourly[0].low,  8.0, 1e-9);    // min low
    EXPECT_NEAR(hourly[0].close, 14.0, 1e-9);  // last close
    EXPECT_TRUE(bucketOHLC({}, 3600).empty());

    // --- chartCandles: candles only when the per-exchange series is active ---
    using dragonx::data::chartCandles;
    MarketInfo cm;
    cm.exchange_ohlc_daily = { {now - 3 * 86400, 1, 1.2, 0.9, 1.1}, {now - 1 * 86400, 1.1, 1.3, 1.0, 1.25} };
    cm.exchange_chart_active = false;
    EXPECT_TRUE(chartCandles(cm, 4, now).empty());       // inactive -> line (no candles)
    cm.exchange_chart_active = true;
    EXPECT_EQ(chartCandles(cm, 4, now).size(), static_cast<size_t>(2));  // 1M daily
    EXPECT_TRUE(chartCandles(cm, 0, now).empty());       // Live -> line
}

// Regression: the Market-tab portfolio (and other consumers) read the combined
// WalletState::addresses view, which the full-node refresh path builds from the
// authoritative z/t lists via rebuildAddressList(). Before the fix that rebuild
// was never called, so `addresses` stayed empty and every portfolio group summed
// to 0 and showed no addresses to pick. Guard that the rebuilt view carries the
// z/t union with balances, so SumPortfolioBalance resolves right-click-added
// addresses.
void testWalletStateAddressListRebuild()
{
    using dragonx::AddressInfo;
    using dragonx::data::SumPortfolioBalance;

    dragonx::WalletState state;
    auto mk = [](const char* addr, double bal, const char* type) {
        AddressInfo a; a.address = addr; a.balance = bal; a.type = type; return a;
    };
    state.z_addresses.push_back(mk("zs-s1", 10.0, "shielded"));
    state.z_addresses.push_back(mk("zs-s2", 0.25, "shielded"));
    state.t_addresses.push_back(mk("R-t1", 1.5, "transparent"));

    // Before the rebuild the combined view is empty -> the reported bug: a portfolio
    // group of real addresses sums to 0 because none are found in state.addresses.
    EXPECT_EQ(state.addresses.size(), static_cast<size_t>(0));
    EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 0.0, 1e-9);

    // After the rebuild the combined view is the z/t union with balances.
    state.rebuildAddressList();
    EXPECT_EQ(state.addresses.size(), static_cast<size_t>(3));
    // z entries come first, then t entries (rebuild order).
    EXPECT_EQ(state.addresses.front().address, std::string("zs-s1"));
    EXPECT_EQ(state.addresses.back().address, std::string("R-t1"));
    // A group spanning both pools now resolves its balances.
    EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 11.5, 1e-9);

    // In-place z/t balance mutation (pending-send delta) is reflected on the next rebuild.
    state.z_addresses[0].balance = 4.0;
    state.rebuildAddressList();
    EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 5.5, 1e-9);
}

// ComputeConsoleFoldSpans — brace/bracket matching over pretty-printed JSON lines.
void testConsoleFoldSpans()
{
    using dragonx::ui::ComputeConsoleFoldSpans;

    // Nested object: outer '{' spans the whole block; inner "obj": { spans its two-line body.
    std::vector<std::string> obj = {
        "{",              // 0 -> closer at 6, span 6
        "  \"a\": 1,",    // 1
        "  \"obj\": {",   // 2 -> closer at 4, span 2
        "    \"b\": 2",   // 3
        "  },",           // 4 (closer for 2)
        "  \"c\": 3",     // 5
        "}"               // 6 (closer for 0)
    };
    auto spans = ComputeConsoleFoldSpans(obj);
    EXPECT_EQ(spans.size(), static_cast<size_t>(7));
    EXPECT_EQ(spans[0], 6);
    EXPECT_EQ(spans[1], 0);
    EXPECT_EQ(spans[2], 2);
    EXPECT_EQ(spans[3], 0);
    EXPECT_EQ(spans[4], 0);  // a closer is not an opener
    EXPECT_EQ(spans[5], 0);
    EXPECT_EQ(spans[6], 0);

    // Array opener '['.
    std::vector<std::string> arr = { "\"outputs\": [", "  1,", "  2", "]" };
    EXPECT_EQ(ComputeConsoleFoldSpans(arr)[0], 3);

    // Empty block (opener immediately followed by closer) is not worth folding (span < 2).
    std::vector<std::string> empty = { "{", "}" };
    EXPECT_EQ(ComputeConsoleFoldSpans(empty)[0], 0);

    // Plain non-JSON lines: no openers.
    std::vector<std::string> plain = { "hello", "world" };
    EXPECT_EQ(ComputeConsoleFoldSpans(plain)[0], 0);
    EXPECT_EQ(ComputeConsoleFoldSpans(plain)[1], 0);

    // A string value merely containing a brace does not open a block (last char isn't '{').
    std::vector<std::string> str = { "\"note\": \"a { brace\",", "\"x\": 1" };
    EXPECT_EQ(ComputeConsoleFoldSpans(str)[0], 0);
}

void testRendererHelpers()
{
    EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f),
                43.0, 0.0001);
    EXPECT_NEAR(dragonx::ui::ComputeConsoleOutputHeight(200.0f, 50.0f, 120.0f, 0.75f),
                150.0, 0.0001);
    EXPECT_NEAR(dragonx::ui::ComputeConsoleOutputHeight(120.0f, 80.0f, 70.0f, 0.5f),
                70.0, 0.0001);
    EXPECT_NEAR(dragonx::ui::ClampConsoleWrapWidth(40.0f, 8.0f), 50.0, 0.0001);
    EXPECT_NEAR(dragonx::ui::ClampConsoleWrapWidth(200.0f, 12.0f), 176.0, 0.0001);

    EXPECT_EQ(dragonx::ui::ClampMiningThreads(0, 8), 1);
    EXPECT_EQ(dragonx::ui::ClampMiningThreads(12, 8), 8);
    EXPECT_EQ(dragonx::ui::ClampMiningThreads(4, 8), 4);
    EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(true, true, false));
    EXPECT_FALSE(dragonx::ui::IsPoolMiningActive(true, false, true));
    EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(false, false, true));

    // The output filter keys off the line's semantic ConsoleChannel (not a color / prefix).
    using CC = dragonx::ui::ConsoleChannel;
    // {text, daemonMessagesEnabled, errorsOnly, rpcTraceEnabled, appMessagesEnabled}
    dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, true};
    EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", CC::Error, filter));    // substring "error"
    EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("Daemon line", CC::Daemon, filter)); // no "error" substring
    filter.text.clear();
    // Daemon/Xmrig channels hidden while daemonMessagesEnabled=false.
    EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("node log", CC::Daemon, filter));
    EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("hashrate 1000", CC::Xmrig, filter));
    // Rpc channel hidden while rpcTraceEnabled=false, shown once enabled.
    EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("History -> listtransactions", CC::Rpc, filter));
    filter.rpcTraceEnabled = true;
    EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("History -> listtransactions", CC::Rpc, filter));
    filter.daemonMessagesEnabled = true;
    // Errors-only keeps only the Error channel.
    filter.errorsOnly = true;
    EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("error line", CC::Error, filter));
    EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("info line", CC::Info, filter));
    EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("node log", CC::Daemon, filter));

    // App channel is toggled by appMessagesEnabled; other channels are unaffected by it.
    filter.errorsOnly = false;
    filter.appMessagesEnabled = false;
    EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("Price updated", CC::App, filter));
    EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("trace", CC::Rpc, filter));
    filter.appMessagesEnabled = true;
    EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("Price updated", CC::App, filter));

    std::vector<dragonx::AddressInfo> poolAddresses;
    poolAddresses.push_back({"R-transparent", 0.0, "transparent", true});
    poolAddresses.push_back({"zs-default-worker", 0.0, "shielded", true});
    EXPECT_TRUE(dragonx::ui::shouldDefaultPoolWorker("", false));
    EXPECT_TRUE(dragonx::ui::shouldDefaultPoolWorker("x", false));
    EXPECT_FALSE(dragonx::ui::shouldDefaultPoolWorker("zs-manual", false));
    EXPECT_FALSE(dragonx::ui::shouldDefaultPoolWorker("", true));
    EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
    EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
    EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, ""));

    // resolveMiningUserAddress: the configured payout address is the xmrig "user"
    // (where rewards go) and must win over the wallet's own addresses.
    EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("zs-payout", "zs-own", "R-own"),
              std::string("zs-payout"));               // explicit payout wins
    EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "zs-own", "R-own"),
              std::string("zs-own"));                  // unset -> own shielded
    EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "zs-own", "R-own"),
              std::string("zs-own"));                  // "x" placeholder counts as unset
    EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "", "R-own"),
              std::string("R-own"));                   // no shielded -> transparent
    EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "", ""),
              std::string(""));                        // nothing anywhere -> caller errors
    EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433"));

    dragonx::TransactionInfo tx;
    tx.type = "send";
    tx.amount = -1.25;
    tx.address = "zsabcdefghijklmnopqrstuvwxyz";
    auto txDisplay = dragonx::ui::buildRecentTxDisplay(tx, 12);
    EXPECT_EQ(txDisplay.typeText, std::string("Sent"));
    EXPECT_EQ(txDisplay.addressText, std::string("zsab...wxyz"));
    EXPECT_EQ(txDisplay.amountText, std::string("-1.2500 DRGX"));
    EXPECT_EQ(txDisplay.timeText, std::string(""));
}


void testConsoleInputModel()
{
    std::vector<std::string> history;
    dragonx::ui::AppendConsoleHistory(history, "getinfo", 3);
    dragonx::ui::AppendConsoleHistory(history, "getinfo", 3);
    dragonx::ui::AppendConsoleHistory(history, "getpeerinfo", 3);
    dragonx::ui::AppendConsoleHistory(history, "getblockcount", 3);
    dragonx::ui::AppendConsoleHistory(history, "help", 3);
    EXPECT_EQ(history.size(), static_cast<size_t>(3));
    EXPECT_EQ(history.front(), std::string("getpeerinfo"));

    int index = dragonx::ui::NavigateConsoleHistoryIndex(-1, history.size(), true);
    EXPECT_EQ(index, 2);
    EXPECT_EQ(dragonx::ui::ConsoleHistoryEntry(history, index), std::string("help"));
    index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), true);
    EXPECT_EQ(index, 1);
    index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), false);
    EXPECT_EQ(index, 2);
    index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), false);
    EXPECT_EQ(index, -1);

    auto single = dragonx::ui::CompleteConsoleCommand("stop");
    EXPECT_EQ(single.matches.size(), static_cast<size_t>(1));
    EXPECT_EQ(single.commonPrefix, std::string("stop"));

    auto multiple = dragonx::ui::CompleteConsoleCommand("getblock");
    EXPECT_TRUE(multiple.matches.size() > 1);
    EXPECT_EQ(multiple.commonPrefix, std::string("getblock"));
    auto lines = dragonx::ui::FormatConsoleCompletionLines(multiple.matches, 40);
    EXPECT_FALSE(lines.empty());

    auto args = dragonx::ui::ParseConsoleCommandArgs("z_sendmany \"from addr\" [{\"address\":\"zs\",\"amount\":1.25}] true");
    EXPECT_EQ(args.size(), static_cast<size_t>(4));
    EXPECT_EQ(args[1], std::string("from addr"));
    auto call = dragonx::ui::BuildConsoleRpcCall("setgenerate true 4");
    EXPECT_TRUE(call.valid);
    EXPECT_EQ(call.method, std::string("setgenerate"));
    EXPECT_EQ(call.params.size(), static_cast<size_t>(2));
    EXPECT_TRUE(call.params[0].get<bool>());
    EXPECT_EQ(call.params[1].get<long long>(), 4LL);

    // Quoted numeric-looking args must stay strings (not coerced to numbers).
    auto qcall = dragonx::ui::BuildConsoleRpcCall("setaccount \"123\" \"456.7\"");
    EXPECT_TRUE(qcall.valid);
    EXPECT_EQ(qcall.params.size(), static_cast<size_t>(2));
    EXPECT_TRUE(qcall.params[0].is_string());
    EXPECT_EQ(qcall.params[0].get<std::string>(), std::string("123"));
    EXPECT_TRUE(qcall.params[1].is_string());
    // Bare finite numbers become numbers; non-finite / non-whole-token tokens fall back to strings
    // (no std::stoll truncation of "1e999" to 1, no inf).
    auto ncall = dragonx::ui::BuildConsoleRpcCall("foo 42 3.5 1e999 123abc");
    EXPECT_TRUE(ncall.params[0].is_number_integer());
    EXPECT_EQ(ncall.params[0].get<long long>(), 42LL);
    EXPECT_TRUE(ncall.params[1].is_number_float());
    EXPECT_TRUE(ncall.params[2].is_string());
    EXPECT_EQ(ncall.params[2].get<std::string>(), std::string("1e999"));
    EXPECT_TRUE(ncall.params[3].is_string());

    auto resultLines = dragonx::ui::FormatConsoleRpcResultLines("{\n  \"balance\": 12,\n  \"ok\": true\n}", false);
    EXPECT_EQ(resultLines.size(), static_cast<size_t>(4));
    EXPECT_EQ(resultLines[0].role, dragonx::ui::ConsoleResultLineRole::JsonBrace);
    EXPECT_EQ(resultLines[1].role, dragonx::ui::ConsoleResultLineRole::JsonKey);
    auto nullLines = dragonx::ui::FormatConsoleRpcResultLines("null", false);
    EXPECT_EQ(nullLines[0].text, std::string("(no result)"));
    auto errorLines = dragonx::ui::FormatConsoleRpcResultLines("bad rpc", true);
    EXPECT_EQ(errorLines[0].role, dragonx::ui::ConsoleResultLineRole::Error);
    EXPECT_EQ(errorLines[0].text, std::string("Error: bad rpc"));
}

void testMiningBenchmarkModel()
{
    dragonx::ui::ThreadBenchmark benchmark;
    EXPECT_FALSE(benchmark.active());
    benchmark.buildCandidates(8);
    EXPECT_EQ(benchmark.candidates.size(), static_cast<size_t>(5));
    EXPECT_EQ(benchmark.candidates.front(), 4);
    EXPECT_EQ(benchmark.candidates.back(), 8);
    EXPECT_NEAR(benchmark.avgWarmupSecs(), 195.0, 0.0001);

    benchmark.phase = dragonx::ui::ThreadBenchmark::Phase::WarmingUp;
    EXPECT_TRUE(benchmark.active());
    benchmark.current_index = 2;
    benchmark.total_warmup_secs = 210.0f;
    EXPECT_NEAR(benchmark.avgWarmupSecs(), 105.0, 0.0001);
    EXPECT_NEAR(benchmark.perTestSecs(), 135.0, 0.0001);
    benchmark.phase_timer = 15.0f;
    EXPECT_TRUE(benchmark.progress() > 0.0f);

    benchmark.resetStabilityTracking();
    EXPECT_EQ(benchmark.window_samples, 0);
    EXPECT_EQ(benchmark.consecutive_stable, 0);

    benchmark.phase = dragonx::ui::ThreadBenchmark::Phase::Starting;
    benchmark.candidates = {2};
    benchmark.current_index = 0;
    auto update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 0.1f, 0.0);
    EXPECT_TRUE(update.stopPoolMining);
    EXPECT_TRUE(update.startPoolMining);
    EXPECT_EQ(update.startThreads, 2);
    EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::WarmingUp);

    benchmark.phase_timer = dragonx::ui::ThreadBenchmark::MAX_WARMUP_SECS - 0.5f;
    update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 1.0f, 100.0);
    EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::Measuring);
    benchmark.phase_timer = dragonx::ui::ThreadBenchmark::MEASURE_SECS - 0.5f;
    update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 1.0f, 250.0);
    EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::Advancing);
    EXPECT_EQ(benchmark.optimal_threads, 2);
    benchmark.was_pool_running = true;
    update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 0.1f, 0.0);
    EXPECT_TRUE(update.stopPoolMining);
    EXPECT_TRUE(update.saveOptimalThreads);
    EXPECT_TRUE(update.startPoolMining);
    EXPECT_EQ(update.optimalThreads, 2);

    benchmark.reset();
    EXPECT_FALSE(benchmark.active());
    EXPECT_TRUE(benchmark.candidates.empty());
}

void testBalanceAddressListModel()
{
    std::vector<dragonx::AddressInfo> addresses;
    dragonx::AddressInfo zHigh;
    zHigh.address = "zs-high";
    zHigh.label = "Vault";
    zHigh.balance = 10.0;
    zHigh.type = "shielded";
    addresses.push_back(zHigh);

    dragonx::AddressInfo tZero;
    tZero.address = "R-zero";
    tZero.label = "Empty";
    tZero.balance = 0.0;
    tZero.type = "transparent";
    addresses.push_back(tZero);

    dragonx::AddressInfo tFav;
    tFav.address = "R-favorite";
    tFav.label = "Favorite";
    tFav.balance = 0.0;
    tFav.type = "transparent";
    addresses.push_back(tFav);

    std::vector<dragonx::ui::AddressListInput> inputs = {
        {&addresses[0], true, false, false, false, "Main Vault", "", -1},
        {&addresses[1], false, false, false, false, "Empty", "", -1},
        {&addresses[2], false, false, true, false, "Favorite", "", -1}
    };

    auto rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
    EXPECT_EQ(rows.size(), static_cast<size_t>(2));
    EXPECT_EQ(rows[0].info->address, std::string("R-favorite"));
    EXPECT_EQ(rows[1].info->address, std::string("zs-high"));

    inputs[2].sortOrder = 0;
    rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
    EXPECT_EQ(rows[0].info->address, std::string("R-favorite"));

    rows = dragonx::ui::BuildAddressListRows(inputs, "vault", false, false);
    EXPECT_EQ(rows.size(), static_cast<size_t>(1));
    EXPECT_EQ(rows[0].info->address, std::string("zs-high"));

    // Mining-flagged addresses stay visible at 0 balance even with hide-zero on.
    dragonx::AddressInfo tMine;
    tMine.address = "R-miner"; tMine.label = "Payout"; tMine.balance = 0.0; tMine.type = "transparent";
    std::vector<dragonx::ui::AddressListInput> mineInputs = {
        {&addresses[0], true, false, false, false, "Vault", "", -1},        // funded
        {&tMine, false, false, false, /*mining=*/true, "Payout", "", -1},   // 0-balance mining
    };
    auto mineRows = dragonx::ui::BuildAddressListRows(mineInputs, "", /*hideZero=*/true, false);
    EXPECT_EQ(mineRows.size(), static_cast<size_t>(2));
    bool sawMiner = false;
    for (const auto& r : mineRows) if (r.info->address == std::string("R-miner")) sawMiner = true;
    EXPECT_TRUE(sawMiner);

    // Explicit sort order overrides starred: a non-favorite ordered row outranks a favorite.
    std::vector<dragonx::ui::AddressListInput> ordInputs = {
        {&addresses[0], true, false, false, false, "Vault", "", 0},         // ordered (0)
        {&addresses[2], false, false, true, false, "Favorite", "", -1},     // favorite, unordered
    };
    auto ordRows = dragonx::ui::BuildAddressListRows(ordInputs, "", false, false);
    EXPECT_EQ(ordRows[0].info->address, std::string("zs-high"));

    auto layout = dragonx::ui::ComputeAddressRowLayout(10.0f, 20.0f, 300.0f, 50.0f,
                                                       12.0f, 16.0f, 6.0f, 4.0f, 2.0f);
    EXPECT_NEAR(layout.contentStartX, 22.0, 0.0001);
    EXPECT_NEAR(layout.contentStartY, 26.0, 0.0001);
    EXPECT_NEAR(layout.buttonSize, 38.0, 0.0001);
    // Trailing (favorite) button is inset by rowPadLeft (12) so it mirrors the left margin
    // instead of hugging the card edge: 310 - 38 - 12 = 260.
    EXPECT_NEAR(layout.favoriteButton.x, 260.0, 0.0001);
    EXPECT_NEAR(layout.visibilityButton.x, 218.0, 0.0001);
    EXPECT_NEAR(layout.contentRight, 214.0, 0.0001);
    EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(2.0, 3.5), std::string("$7.00"));
    EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.001, 2.0), std::string("$0.002000"));
    EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.0, 2.0), std::string(""));
}

void testExplorerBlockCache()
{
    using dragonx::ui::ExplorerBlockCache;
    using dragonx::ui::ExplorerBlockSummary;
    using nlohmann::json;

    fs::path dir = makeTempDir();
    fs::path databasePath = dir / "explorer_blocks.sqlite";
    fs::path legacyPath = dir / "explorer_blocks_cache.json";

    json legacy = {
        {"version", 1},
        {"tip_height", 10},
        {"tip_hash", "hash-10"},
        {"blocks", json::array({
            json{{"height", 10}, {"hash", "hash-10"}, {"tx_count", 3}, {"size", 1000}, {"time", 5000}, {"difficulty", 1.25}},
            json{{"height", 9}, {"hash", "hash-9"}, {"tx_count", 2}, {"size", 900}, {"time", 4900}, {"difficulty", 1.20}}
        })}
    };
    {
        std::ofstream file(legacyPath);
        file << legacy.dump(2);
    }

    ExplorerBlockCache cache(databasePath.string(), legacyPath.string());
    EXPECT_TRUE(cache.ensureOpen());
    EXPECT_EQ(cache.cachedBlockCount(), 2);

    auto range = cache.loadRange(9, 10);
    EXPECT_EQ(range.size(), static_cast<size_t>(2));
    EXPECT_EQ(range[10].hash, std::string("hash-10"));
    EXPECT_EQ(range[9].tx_count, 2);

    auto sameTipValidation = cache.prepareValidation(10, "hash-10");
    EXPECT_FALSE(sameTipValidation.needed);

    auto advancedValidation = cache.prepareValidation(12, "hash-12");
    EXPECT_TRUE(advancedValidation.needed);
    EXPECT_EQ(advancedValidation.height, 10);
    EXPECT_EQ(advancedValidation.expectedHash, std::string("hash-10"));
    cache.applySavedTipValidation(advancedValidation, "hash-10", 12, "hash-12");

    ExplorerBlockSummary block12;
    block12.height = 12;
    block12.hash = "hash-12";
    block12.tx_count = 5;
    block12.size = 1200;
    block12.time = 5200;
    block12.difficulty = 1.40;
    EXPECT_TRUE(cache.storeBlock(block12));
    EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);

    {
        ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
        EXPECT_TRUE(reopened.ensureOpen());
        EXPECT_EQ(reopened.cachedBlockCount(), 3);
        auto noRepeatValidation = reopened.prepareValidation(12, "hash-12");
        EXPECT_FALSE(noRepeatValidation.needed);
    }

    cache.prepareValidation(12, "different-tip");
    EXPECT_EQ(cache.cachedBlockCount(), 0);

    {
        ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
        EXPECT_TRUE(reopened.ensureOpen());
        EXPECT_EQ(reopened.cachedBlockCount(), 0);
    }

    fs::remove_all(dir);
}

void testTransactionHistoryCache()
{
    using dragonx::TransactionInfo;
    using dragonx::data::TransactionHistoryCache;

    std::string identityA = TransactionHistoryCache::walletIdentityFromAddresses(
        {"zs-beta", "zs-alpha"}, {"R-one"});
    std::string identityB = TransactionHistoryCache::walletIdentityFromAddresses(
        {"zs-alpha", "zs-beta"}, {"R-one"});
    std::string identityWithSameText = TransactionHistoryCache::walletIdentityFromAddresses(
        {"same-address-text"}, {"same-address-text"});
    EXPECT_EQ(identityA, identityB);
    EXPECT_TRUE(identityA.find("protocol=") == std::string::npos);
    EXPECT_TRUE(identityA.find("p2p_port=") == std::string::npos);
    EXPECT_TRUE(identityA.find("z:zs-alpha") != std::string::npos);
    EXPECT_TRUE(identityA.find("t:R-one") != std::string::npos);
    EXPECT_TRUE(identityWithSameText.find("z:same-address-text") != std::string::npos);
    EXPECT_TRUE(identityWithSameText.find("t:same-address-text") != std::string::npos);

    fs::path dir = makeTempDir();
    fs::path databasePath = dir / "transaction_history.sqlite";
    std::string walletIdentity = "mainnet|R-alpha|zs-beta";
    std::string passphrase = "correct horse battery staple";

    std::vector<TransactionInfo> transactions;
    transactions.push_back(TransactionInfo{
        "tx-sensitive-send", "send", -1.25, 1700000100, 12,
        "zs-destination-sensitive", "R-source-sensitive", "private memo"
    });
    transactions.push_back(TransactionInfo{
        "tx-mined", "mined", 3.5, 1700000000, 104,
        "R-mining-address", "", ""
    });
    std::unordered_map<std::string, int> shieldedScanHeights{
        {"zs-beta", 120},
        {"zs-archive", 118}
    };

    {
        TransactionHistoryCache cache(databasePath.string());
        EXPECT_TRUE(cache.ensureOpen());
        EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
        EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000200, shieldedScanHeights));
        EXPECT_EQ(cache.snapshotCount(), 1);
    }

    {
        std::ifstream database(databasePath, std::ios::binary);
        std::string bytes((std::istreambuf_iterator<char>(database)), std::istreambuf_iterator<char>());
        EXPECT_TRUE(bytes.find("tx-sensitive-send") == std::string::npos);
        EXPECT_TRUE(bytes.find("zs-destination-sensitive") == std::string::npos);
        EXPECT_TRUE(bytes.find("zs-archive") == std::string::npos);
        EXPECT_TRUE(bytes.find("private memo") == std::string::npos);
    }

    {
        TransactionHistoryCache wrongKey(databasePath.string());
        EXPECT_TRUE(wrongKey.unlockWithPassphrase(walletIdentity, "wrong passphrase"));
        auto wrongLoad = wrongKey.load(walletIdentity, 120, "tip-120");
        EXPECT_FALSE(wrongLoad.loaded);
    }

    {
        TransactionHistoryCache reopened(databasePath.string());
        EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
        auto loaded = reopened.load(walletIdentity, 120, "tip-120");
        EXPECT_TRUE(loaded.loaded);
        EXPECT_FALSE(loaded.invalidated);
        EXPECT_EQ(loaded.tipHeight, 120);
        EXPECT_EQ(loaded.tipHash, std::string("tip-120"));
        EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000200));
        EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(2));
        EXPECT_EQ(loaded.transactions[0].txid, std::string("tx-sensitive-send"));
        EXPECT_EQ(loaded.transactions[0].memo, std::string("private memo"));
        EXPECT_EQ(loaded.transactions[1].type, std::string("mined"));
        EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(2));
        EXPECT_EQ(loaded.shieldedScanHeights.at("zs-beta"), 120);
        EXPECT_EQ(loaded.shieldedScanHeights.at("zs-archive"), 118);
    }

    {
        TransactionHistoryCache cache(databasePath.string());
        EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
        EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000250));
        auto loaded = cache.load(walletIdentity, 120, "tip-120");
        EXPECT_TRUE(loaded.loaded);
        EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(0));
    }

    {
        TransactionHistoryCache staleTip(databasePath.string());
        EXPECT_TRUE(staleTip.unlockWithPassphrase(walletIdentity, passphrase));
        auto invalidated = staleTip.load(walletIdentity, 119, "tip-119");
        EXPECT_FALSE(invalidated.loaded);
        EXPECT_TRUE(invalidated.invalidated);
        EXPECT_EQ(staleTip.snapshotCount(), 0);
    }

    {
        TransactionHistoryCache cache(databasePath.string());
        EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
        EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000300));
        auto invalidated = cache.load(walletIdentity, 120, "different-tip");
        EXPECT_FALSE(invalidated.loaded);
        EXPECT_TRUE(invalidated.invalidated);
        EXPECT_EQ(cache.snapshotCount(), 0);
    }

    fs::remove_all(dir);
}

// GC: generating a new address changes the wallet-identity hash; the prior hash's snapshot
// + salt must not accumulate forever. A save under a new identity prunes the old rows.
void testTransactionHistoryCachePrunesOldWallets()
{
    using dragonx::TransactionInfo;
    using dragonx::data::TransactionHistoryCache;

    fs::path dir = makeTempDir();
    fs::path dbPath = dir / "transaction_history.sqlite";
    const std::string idA = "mainnet|R-a|zs-a";
    const std::string idB = "mainnet|R-a|zs-a|zs-newly-generated";  // new address -> new identity
    const std::string pass = "correct horse battery staple";

    std::vector<TransactionInfo> txs;
    txs.push_back(TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""});

    TransactionHistoryCache cache(dbPath.string());
    EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
    EXPECT_TRUE(cache.replace(idA, 100, "tip-a", txs, 1700000000));
    EXPECT_EQ(cache.snapshotCount(), 1);

    EXPECT_TRUE(cache.unlockWithPassphrase(idB, pass));
    EXPECT_TRUE(cache.replace(idB, 100, "tip-b", txs, 1700000001));
    // Old wallet A's snapshot was pruned — only the live wallet's row remains.
    EXPECT_EQ(cache.snapshotCount(), 1);

    auto loadedB = cache.load(idB, 100, "tip-b");
    EXPECT_TRUE(loadedB.loaded);

    fs::remove_all(dir);
}

void testTransactionHistoryCacheRefreshApply()
{
    using Refresh = dragonx::services::NetworkRefreshService;
    using dragonx::TransactionInfo;
    using dragonx::data::TransactionHistoryCache;

    fs::path dir = makeTempDir();
    fs::path databasePath = dir / "transaction_history_refresh.sqlite";
    std::string walletIdentity = "mainnet|R-refresh-cache|zs-refresh-cache";
    std::string passphrase = "refresh cache passphrase";

    dragonx::WalletState state;
    state.sync.blocks = 333;
    state.sync.best_blockhash = "tip-333";

    Refresh::TransactionRefreshResult refreshResult;
    refreshResult.blockHeight = 333;
    TransactionInfo refreshedTx;
    refreshedTx.txid = "rpc-refreshed-send";
    refreshedTx.type = "send";
    refreshedTx.amount = -2.75;
    refreshedTx.timestamp = 1700000400;
    refreshedTx.confirmations = 14;
    refreshedTx.address = "zs-rpc-destination";
    refreshedTx.from_address = "R-refresh-cache";
    refreshedTx.memo = "rpc refreshed memo";
    refreshResult.transactions = {refreshedTx};

    Refresh::TransactionViewCache viewCache;
    std::unordered_set<std::string> sendTxids;
    std::vector<TransactionInfo> confirmedCache;
    std::unordered_set<std::string> confirmedIds;
    int confirmedBlock = -1;
    int lastTxBlock = -1;
    Refresh::TransactionCacheUpdate cacheUpdate{
        viewCache,
        sendTxids,
        confirmedCache,
        confirmedIds,
        confirmedBlock,
        lastTxBlock
    };
    Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(refreshResult), 1700000500);

    TransactionHistoryCache cache(databasePath.string());
    EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
    EXPECT_TRUE(cache.replace(walletIdentity,
                              state.sync.blocks,
                              state.sync.best_blockhash,
                              state.transactions,
                              state.last_tx_update));

    TransactionHistoryCache reopened(databasePath.string());
    EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
    auto loaded = reopened.load(walletIdentity, 333, "tip-333");
    EXPECT_TRUE(loaded.loaded);
    EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(1));
    EXPECT_EQ(loaded.transactions[0].txid, std::string("rpc-refreshed-send"));
    EXPECT_EQ(loaded.transactions[0].memo, std::string("rpc refreshed memo"));
    EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000500));
    EXPECT_EQ(lastTxBlock, 333);
    EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast<size_t>(1));

    fs::remove_all(dir);
}




































































































































































































void testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess()
{
    resetLiteBridgeRuntimeTrackedFree();
    dragonx::wallet::LiteBridgeOwnedString ownedString(
        makeLiteBridgeRuntimeOwnedCString("phase2-ok"),
        &fakeLiteBridgeRuntimeTrackedFreeString);

    auto result = ownedString.intoResult();
    EXPECT_TRUE(result.ok);
    EXPECT_EQ(result.value, std::string("phase2-ok"));
    EXPECT_TRUE(result.error.empty());
    EXPECT_TRUE(ownedString.rawPointerReceived());
    EXPECT_TRUE(ownedString.copiedBeforeFree());
    EXPECT_TRUE(ownedString.freed());
    EXPECT_FALSE(ownedString.rawPointerEscaped());
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("phase2-ok"));

    auto secondResult = ownedString.intoResult();
    EXPECT_FALSE(secondResult.ok);
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
}

void testLiteBridgeOwnedStringClassifiesNullWithoutFree()
{
    resetLiteBridgeRuntimeTrackedFree();
    dragonx::wallet::LiteBridgeOwnedString ownedString(
        nullptr,
        &fakeLiteBridgeRuntimeTrackedFreeString);

    auto result = ownedString.intoResult();
    EXPECT_FALSE(result.ok);
    EXPECT_TRUE(result.value.empty());
    EXPECT_TRUE(result.error.find("null string") != std::string::npos);
    EXPECT_FALSE(ownedString.rawPointerReceived());
    EXPECT_FALSE(ownedString.copiedBeforeFree());
    EXPECT_FALSE(ownedString.freed());
    EXPECT_FALSE(ownedString.rawPointerEscaped());
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 0);
}

void testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce()
{
    resetLiteBridgeRuntimeTrackedFree();
    dragonx::wallet::LiteBridgeOwnedString ownedString(
        makeLiteBridgeRuntimeOwnedCString("Error: phase2 denied"),
        &fakeLiteBridgeRuntimeTrackedFreeString);

    auto result = ownedString.intoResult();
    EXPECT_FALSE(result.ok);
    EXPECT_TRUE(result.value.empty());
    EXPECT_EQ(result.error, std::string("Error: phase2 denied"));
    EXPECT_TRUE(ownedString.copiedBeforeFree());
    EXPECT_TRUE(ownedString.freed());
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("Error: phase2 denied"));
}

void testLiteBridgeOwnedStringMovePreventsDoubleFree()
{
    resetLiteBridgeRuntimeTrackedFree();
    {
        dragonx::wallet::LiteBridgeOwnedString original(
            makeLiteBridgeRuntimeOwnedCString("phase2-move"),
            &fakeLiteBridgeRuntimeTrackedFreeString);
        dragonx::wallet::LiteBridgeOwnedString moved(std::move(original));

        EXPECT_FALSE(original.rawPointerReceived());
        EXPECT_TRUE(moved.rawPointerReceived());
        EXPECT_FALSE(moved.rawPointerEscaped());
    }

    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("phase2-move"));
}

void testLiteClientBridgeUsesRuntimeOwnedStringCleanup()
{
    resetLiteBridgeRuntimeTrackedFree();
    g_liteBridgeRuntimeFakeCallCount = 0;
    auto bridgeApi = makeCompleteFakeLiteBridgeRuntimeApi();
    bridgeApi.execute = &fakeLiteBridgeRuntimeOwnedExecute;
    bridgeApi.freeString = &fakeLiteBridgeRuntimeTrackedFreeString;

    auto bridge = dragonx::wallet::LiteClientBridge::fromApi(bridgeApi);
    auto result = bridge.execute("phase2", "{}");

    EXPECT_TRUE(result.ok);
    EXPECT_EQ(result.value, std::string("bridge-runtime-ok"));
    EXPECT_TRUE(result.error.empty());
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
    EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("bridge-runtime-ok"));
    EXPECT_EQ(g_liteBridgeRuntimeFakeCallCount, 1);
}







void testGeneratedResourceBehavior()
{
    const auto* themes = dragonx::resources::getEmbeddedThemes();
    EXPECT_TRUE(themes != nullptr);
    EXPECT_TRUE(dragonx::resources::getEmbeddedResource("__missing_resource__") == nullptr);
    if (!dragonx::resources::hasEmbeddedResources()) {
        EXPECT_TRUE(themes[0].data == nullptr);
        EXPECT_EQ(dragonx::resources::extractBundledThemes("/tmp/obsidian-dragon-empty-themes"), 0);
    }
}

// M0: deterministic injectable fake backend driving the real LiteClientBridge
// (ungated fromApi path). This is the harness M1 service tests reuse.
void testLiteBackendInjectableFakeBridge()
{
    using dragonx::wallet::LiteClientBridge;

    // available() + bool round-trips
    {
        dragonx::test::resetLiteFakeCounters();
        auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
        EXPECT_TRUE(bridge.available());
        EXPECT_TRUE(bridge.walletExists("DRGX"));
        EXPECT_TRUE(bridge.checkServerOnline("https://lite.example"));
    }

    // execute() round-trips the canned value and frees the owned string exactly once
    {
        dragonx::test::resetLiteFakeCounters();
        auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
        const auto result = bridge.execute("ping", "");  // unknown command -> default fake response
        EXPECT_TRUE(result.ok);
        EXPECT_EQ(result.value, std::string("{\"version\":\"sdxl-fake\"}"));
        EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 1L);
        EXPECT_EQ(dragonx::test::g_liteFakeFreed, 1L);
    }

    // lifecycle calls round-trip
    {
        dragonx::test::resetLiteFakeCounters();
        auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
        EXPECT_TRUE(bridge.initializeNew(false, "https://lite.example").ok);
        EXPECT_TRUE(bridge.initializeExisting(false, "https://lite.example").ok);
        EXPECT_TRUE(bridge.initializeNewFromPhrase(false, "https://lite.example", "seed words", 0, 0, false).ok);
        EXPECT_EQ(dragonx::test::g_liteFakeAlloc, dragonx::test::g_liteFakeFreed);
    }

    // an "Error:"-prefixed backend response maps to ok=false with the message propagated
    {
        dragonx::test::resetLiteFakeCounters();
        auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
        const auto result = bridge.execute("boom", "");
        EXPECT_FALSE(result.ok);
        EXPECT_TRUE(result.error.rfind("Error:", 0) == 0);
        EXPECT_EQ(dragonx::test::g_liteFakeAlloc, dragonx::test::g_liteFakeFreed);
    }

    // empty command is rejected before reaching the backend (no allocation)
    {
        dragonx::test::resetLiteFakeCounters();
        auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
        EXPECT_FALSE(bridge.execute("", "").ok);
        EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
    }

    // an unavailable bridge fails closed (never fakes success)
    {
        dragonx::test::resetLiteFakeCounters();
        auto bridge = LiteClientBridge::unavailable("lite backend is not linked");
        EXPECT_FALSE(bridge.available());
        EXPECT_FALSE(bridge.execute("info", "").ok);
        EXPECT_FALSE(bridge.walletExists("DRGX"));
        EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
    }

    // shutdown is invoked (via destructor) and no owned string leaks or double-frees
    {
        dragonx::test::resetLiteFakeCounters();
        {
            auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
            (void)bridge.execute("info", "");
        }
        EXPECT_TRUE(dragonx::test::g_liteFakeShutdownCalled);
        EXPECT_EQ(dragonx::test::g_liteFakeAlloc, dragonx::test::g_liteFakeFreed);
    }
}

// M1: the App-owned LiteWalletController drives real create/open/restore through an
// (injected fake) bridge, reports wallet-ready, persists, wipes secrets, and fails closed.
void testLiteWalletControllerLifecycle()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
    const LiteConnectionSettings conn = defaultLiteConnectionSettings();

    // create -> wallet ready, walletOpen() true, persist callback fires once.
    // (No alloc==freed leak assert here: a ready wallet launches the detached sync thread +
    //  refresh worker, which are still in flight at end-of-scope and legitimately hold owned
    //  strings. The leak/double-free invariant is checked in the thread-free bridge test.)
    {
        dragonx::test::resetLiteFakeCounters();
        int persistCount = 0;
        LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        controller.setPersistCallback([&persistCount]() { ++persistCount; });
        EXPECT_FALSE(controller.walletOpen());

        LiteWalletCreateRequest req;
        const auto result = controller.createWallet(req);
        EXPECT_TRUE(result.ok);
        EXPECT_TRUE(result.walletReady);
        EXPECT_TRUE(result.operation == LiteWalletLifecycleOperation::CreateNew);
        EXPECT_TRUE(controller.walletOpen());
        EXPECT_EQ(persistCount, 1);
    }

    // open existing -> ready. Regression guard: the real backend's open path
    // (litelib_initialize_existing) returns the bare string "OK", which is NOT valid JSON, so
    // walletReady must key off a non-empty success response, not JSON validity.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = true;
        LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletOpenRequest req;
        const auto result = controller.openWallet(req);
        EXPECT_TRUE(result.ok);
        EXPECT_TRUE(result.walletReady);
        EXPECT_TRUE(result.operation == LiteWalletLifecycleOperation::OpenExisting);
        EXPECT_TRUE(controller.walletOpen());
    }

    // restore-from-seed round-trips to ready
    {
        dragonx::test::resetLiteFakeCounters();
        LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletRestoreRequest req;
        req.seedPhrase = "abandon abandon abandon ... art";
        const auto result = controller.restoreWallet(req);
        EXPECT_TRUE(result.walletReady);
        EXPECT_TRUE(result.operation == LiteWalletLifecycleOperation::RestoreFromSeed);
        EXPECT_TRUE(controller.walletOpen());
    }

    // empty restore seed is rejected before the backend is touched
    {
        dragonx::test::resetLiteFakeCounters();
        LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        const auto result = controller.restoreWallet(LiteWalletRestoreRequest{});
        EXPECT_FALSE(result.ok);
        EXPECT_FALSE(result.walletReady);
        EXPECT_FALSE(controller.walletOpen());
        EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
    }

    // bridge calls disabled -> blocked, backend never reached
    {
        dragonx::test::resetLiteFakeCounters();
        LiteWalletController controller(liteCaps, conn,
                                       LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
                                       LiteWalletControllerOptions{/*allowBridgeCalls*/ false});
        const auto result = controller.createWallet(LiteWalletCreateRequest{});
        EXPECT_FALSE(result.walletReady);
        EXPECT_FALSE(controller.walletOpen());
        EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
    }

    // full-node capabilities -> unsupported, fails closed
    {
        dragonx::test::resetLiteFakeCounters();
        const auto fullNodeCaps = makeWalletCapabilities(WalletBuildKind::FullNode, true, false);
        LiteWalletController controller(fullNodeCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        const auto result = controller.createWallet(LiteWalletCreateRequest{});
        EXPECT_FALSE(result.walletReady);
        EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
    }

    // secret-wipe helper zeroes and clears
    {
        std::string secret = "super-secret-seed-phrase";
        secureWipeLiteSecret(secret);
        EXPECT_TRUE(secret.empty());
    }
}

// M4: spend & backup. The controller drives send/shield/import/export/seed through the
// (injected fake) bridge with the real backend's arg/response contracts: send uses the
// JSON-array form (litelib_execute takes one arg), failures arrive as {"error":..} in the
// body, and the async send/shield path delivers a txid via takeBroadcastResult().
void testLiteWalletControllerM4()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
    const LiteConnectionSettings conn = defaultLiteConnectionSettings();

    auto openController = [&]() {
        auto c = std::make_unique<LiteWalletController>(
            liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletCreateRequest req;
        (void)c->createWallet(req);
        return c;
    };

    // send (blocking core): JSON-array payload -> {"txid":..}
    {
        dragonx::test::g_liteFakeSendFails = false;
        auto c = openController();
        LiteSendRequest req;
        req.recipients.push_back({"zs1dest", 150000000ULL, "hello memo with spaces"});
        const auto r = c->sendTransactionBlocking(req);
        EXPECT_TRUE(r.ok);
        EXPECT_EQ(r.txid, std::string("faketxid123"));
        EXPECT_TRUE(r.error.empty());
    }

    // send with no recipients -> error, backend never reached
    {
        auto c = openController();
        const auto r = c->sendTransactionBlocking(LiteSendRequest{});
        EXPECT_FALSE(r.ok);
        EXPECT_FALSE(r.error.empty());
    }

    // send failure surfaces the backend's {"error":..} body (not an "Error:" prefix)
    {
        dragonx::test::g_liteFakeSendFails = true;
        auto c = openController();
        LiteSendRequest req;
        req.recipients.push_back({"zs1dest", 1ULL, ""});
        const auto r = c->sendTransactionBlocking(req);
        EXPECT_FALSE(r.ok);
        EXPECT_EQ(r.error, std::string("insufficient funds"));
        dragonx::test::g_liteFakeSendFails = false;
    }

    // shield (blocking core) -> txid
    {
        dragonx::test::g_liteFakeSendFails = false;
        auto c = openController();
        const auto r = c->shieldFundsBlocking();
        EXPECT_TRUE(r.ok);
        EXPECT_EQ(r.txid, std::string("fakeshieldtxid"));
    }

    // async send: delivers the result to the main-thread slot
    {
        dragonx::test::g_liteFakeSendFails = false;
        auto c = openController();
        LiteSendRequest req;
        req.recipients.push_back({"zs1dest", 42ULL, ""});
        EXPECT_TRUE(c->sendTransaction(req));
        LiteBroadcastResult r;
        bool got = false;
        for (int i = 0; i < 400 && !got; ++i) {  // bounded wait (<= ~2s)
            got = c->takeBroadcastResult(r);
            if (!got) std::this_thread::sleep_for(std::chrono::milliseconds(5));
        }
        EXPECT_TRUE(got);
        EXPECT_TRUE(r.ok);
        EXPECT_EQ(r.txid, std::string("faketxid123"));
    }

    // send before a wallet is open -> rejected
    {
        LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteSendRequest req;
        req.recipients.push_back({"zs1dest", 1ULL, ""});
        EXPECT_FALSE(c.sendTransaction(req));
        const auto r = c.sendTransactionBlocking(req);
        EXPECT_FALSE(r.ok);
    }

    // export private keys (SECRET) -> parsed, non-empty
    {
        auto c = openController();
        const auto r = c->exportPrivateKeys();
        EXPECT_TRUE(r.ok);
        EXPECT_TRUE(r.privateKeysJson.find("private_key") != std::string::npos);
    }

    // export seed (SECRET) -> seed phrase + birthday
    {
        auto c = openController();
        const auto r = c->exportSeed();
        EXPECT_TRUE(r.ok);
        EXPECT_EQ(r.seedPhrase, std::string("fake seed phrase words"));
        EXPECT_EQ(r.birthday, 0ULL);
    }

    // import shielded key -> "import"; the key argument is wiped on return
    {
        auto c = openController();
        std::string key = "secret-extended-key-main1fakeshieldedkey";
        const auto r = c->importKey(std::move(key));
        EXPECT_TRUE(r.ok);
        EXPECT_TRUE(r.detail.find("zs1imported") != std::string::npos);
    }

    // import transparent WIF (begins with K) -> "timport"
    {
        auto c = openController();
        const auto r = c->importKey("Kx1faketransparentwif");
        EXPECT_TRUE(r.ok);
    }

    // import with no wallet open -> rejected (and key still wiped)
    {
        LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        const auto r = c.importKey("secret-extended-key-main1abc");
        EXPECT_FALSE(r.ok);
    }
}

// M5: persistence. The backend auto-saves on new-address/import but NOT after sync/send/shield,
// so the controller must trigger `save` at those points (otherwise a ~30-min scan and sent txs
// are lost on restart). Drives the fake's save counter to prove saves happen exactly when needed.
void testLiteWalletControllerM5Persistence()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
    const LiteConnectionSettings conn = defaultLiteConnectionSettings();

    auto openController = [&]() {
        auto c = std::make_unique<LiteWalletController>(
            liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletCreateRequest req;
        (void)c->createWallet(req);
        return c;
    };
    // Open and wait until the background sync (and its persist) has completed, so the save count
    // is stable before a sub-test captures its baseline.
    auto openSynced = [&]() {
        auto c = openController();
        for (int i = 0; i < 400 && !c->syncComplete(); ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(5));
        return c;
    };

    // sync persists: completing a sync saves the freshly-scanned wallet.
    {
        dragonx::test::g_liteFakeSyncBlock = false;
        dragonx::test::g_liteFakeSaveCount = 0;
        auto c = openSynced();
        EXPECT_TRUE(c->syncComplete());
        EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() >= 1);
    }

    // send persists the new transaction (backend does not auto-save sends)
    {
        dragonx::test::g_liteFakeSendFails = false;
        auto c = openSynced();
        const long before = dragonx::test::g_liteFakeSaveCount.load();
        LiteSendRequest req;
        req.recipients.push_back({"zs1dest", 1ULL, ""});
        EXPECT_TRUE(c->sendTransactionBlocking(req).ok);
        EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
    }

    // a FAILED send does NOT save
    {
        dragonx::test::g_liteFakeSendFails = true;
        auto c = openSynced();
        const long before = dragonx::test::g_liteFakeSaveCount.load();
        LiteSendRequest req;
        req.recipients.push_back({"zs1dest", 1ULL, ""});
        EXPECT_FALSE(c->sendTransactionBlocking(req).ok);
        EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
        dragonx::test::g_liteFakeSendFails = false;
    }

    // shield persists
    {
        auto c = openSynced();
        const long before = dragonx::test::g_liteFakeSaveCount.load();
        EXPECT_TRUE(c->shieldFundsBlocking().ok);
        EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
    }

    // explicit saveWallet() persists; with no wallet open it is a no-op
    {
        auto c = openSynced();
        const long before = dragonx::test::g_liteFakeSaveCount.load();
        EXPECT_TRUE(c->saveWallet());
        EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
    }
    {
        LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        const long before = dragonx::test::g_liteFakeSaveCount.load();
        EXPECT_FALSE(c.saveWallet());
        EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
    }
}

// Encryption: the controller drives encrypt/unlock/lock/decrypt + encryptionstatus through the
// (injected fake) backend, wipes passphrases, and folds status into WalletState (encrypted/locked).
void testLiteWalletControllerEncryption()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
    const LiteConnectionSettings conn = defaultLiteConnectionSettings();

    auto open = [&]() {
        dragonx::test::g_liteFakeEncrypted = false;
        dragonx::test::g_liteFakeLocked = false;
        auto c = std::make_unique<LiteWalletController>(
            liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletCreateRequest req;
        (void)c->createWallet(req);
        return c;
    };

    // encrypt -> unlock -> lock -> decrypt, observed via encryptionStatus(). NOTE: encrypt LOCKS
    // immediately (the real backend removes keys after encrypting — verified via lite_smoke).
    {
        auto c = open();
        const auto s0 = c->encryptionStatus();
        EXPECT_TRUE(s0.ok);
        EXPECT_FALSE(s0.encrypted);
        EXPECT_FALSE(s0.locked);

        EXPECT_TRUE(c->encryptWallet("walletpass").ok);
        const auto s1 = c->encryptionStatus();
        EXPECT_TRUE(s1.encrypted);
        EXPECT_TRUE(s1.locked);    // encrypt locks immediately

        EXPECT_TRUE(c->unlockWallet("walletpass"));
        const auto s2 = c->encryptionStatus();
        EXPECT_TRUE(s2.encrypted);
        EXPECT_FALSE(s2.locked);

        EXPECT_TRUE(c->lockWallet());
        const auto s3 = c->encryptionStatus();
        EXPECT_TRUE(s3.encrypted);
        EXPECT_TRUE(s3.locked);

        EXPECT_TRUE(c->decryptWallet("walletpass").ok);
        const auto s4 = c->encryptionStatus();
        EXPECT_FALSE(s4.encrypted);
        EXPECT_FALSE(s4.locked);
    }

    // empty passphrase rejected; operations on a closed wallet fail closed
    {
        auto c = open();
        EXPECT_FALSE(c->encryptWallet("").ok);

        LiteWalletController closed(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        EXPECT_FALSE(closed.unlockWallet("x"));
        EXPECT_FALSE(closed.lockWallet());
        EXPECT_FALSE(closed.encryptionStatus().ok);
    }

    // encryption status folds into WalletState (gates the locked UI)
    {
        LiteWalletAppRefreshModel model;
        model.hasEncryptionStatus = true;
        model.encrypted = true;
        model.locked = true;
        dragonx::WalletState state;
        applyLiteRefreshModelToWalletState(model, state);
        EXPECT_TRUE(state.isEncrypted());
        EXPECT_TRUE(state.isLocked());
    }
}

// Migration: a saved lite chain_name outside {main,test,regtest} (e.g. the legacy
// "DRAGONX" ticker) is rewritten to "main" on load, since the backend hard-panics
// on unknown chains. Valid values are preserved.
void testLiteChainNameMigration()
{
    const fs::path dir = fs::temp_directory_path() / "dragonx_lite_chain_migration_test";
    fs::create_directories(dir);

    {
        const fs::path p = dir / "legacy.json";
        writeTestFile(p, "{\"lite_wallet\":{\"chain_name\":\"DRAGONX\"}}");
        dragonx::config::Settings settings;
        EXPECT_TRUE(settings.load(p.string()));
        EXPECT_EQ(settings.getLiteChainName(), std::string("main"));
    }
    {
        const fs::path p = dir / "valid.json";
        writeTestFile(p, "{\"lite_wallet\":{\"chain_name\":\"test\"}}");
        dragonx::config::Settings settings;
        EXPECT_TRUE(settings.load(p.string()));
        EXPECT_EQ(settings.getLiteChainName(), std::string("test"));
    }

    std::error_code ec;
    fs::remove_all(dir, ec);
}

// Async open with server failover: when the preferred lite server is unreachable, the controller
// transparently opens against the next usable one; only an all-servers-down case fails (and then
// surfaces a reason). Open runs off the UI thread, finalized by pumpAsyncOpen() on the main thread.
void testLiteWalletControllerOpenFailover()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);

    LiteConnectionSettings conn;
    conn.chainName = "main";
    conn.servers = {
        LiteServerEndpoint{"https://dead.example", "Dead", true},
        LiteServerEndpoint{"https://good.example", "Good", true},
    };
    conn.selectionMode = LiteServerSelectionMode::Sticky;
    conn.stickyServerUrl = "https://dead.example";  // the PREFERRED server is the dead one

    // Preferred server dead, fallback good -> the wallet opens via the fallback.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = true;
        dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
        LiteDiagnostics::instance().clear();  // verify the open populates the console log
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        EXPECT_TRUE(controller.beginOpenExisting());
        for (int i = 0; i < 400 && controller.openInProgress(); ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(5));
        EXPECT_FALSE(controller.openInProgress());
        controller.pumpAsyncOpen();
        EXPECT_TRUE(controller.walletOpen());
        EXPECT_TRUE(controller.lastOpenError().empty());

        // The Console tab reads this log: the failover attempt + success must be recorded.
        const auto log = LiteDiagnostics::instance().snapshot();
        EXPECT_TRUE(!log.empty());
        bool sawConnecting = false, sawOpened = false;
        for (const auto& l : log) {
            if (l.find("connecting to") != std::string::npos) sawConnecting = true;
            if (l.find("Wallet ready via") != std::string::npos) sawOpened = true;
        }
        EXPECT_TRUE(sawConnecting);
        EXPECT_TRUE(sawOpened);
    }

    // All servers dead -> open fails, wallet stays closed, reason surfaced.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = true;
        dragonx::test::g_liteFakeDeadServerSubstr = "example";  // both servers fail
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        EXPECT_TRUE(controller.beginOpenExisting());
        for (int i = 0; i < 400 && controller.openInProgress(); ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(5));
        controller.pumpAsyncOpen();
        EXPECT_FALSE(controller.walletOpen());
        EXPECT_TRUE(!controller.lastOpenError().empty());
    }

    // A warming-up (-28) server is flagged so the caller can retry sooner. Preferred server is
    // warming, the fallback is dead -> open fails but lastOpenWasWarmup() is set.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = true;
        dragonx::test::g_liteFakeWarmupServerSubstr = "dead.example";   // preferred (sticky) is warming
        dragonx::test::g_liteFakeDeadServerSubstr = "good.example";     // fallback unreachable
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        EXPECT_TRUE(controller.beginOpenExisting());
        for (int i = 0; i < 400 && controller.openInProgress(); ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(5));
        controller.pumpAsyncOpen();
        EXPECT_FALSE(controller.walletOpen());
        EXPECT_TRUE(controller.lastOpenWasWarmup());
    }

    // Create also fails over: preferred dead, fallback good -> a new wallet is created via the
    // fallback (no existing wallet beforehand).
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = false;  // creating fresh
        dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        EXPECT_FALSE(controller.walletOpen());
        EXPECT_TRUE(controller.beginCreateWallet());
        for (int i = 0; i < 400 && controller.openInProgress(); ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(5));
        controller.pumpAsyncOpen();
        EXPECT_TRUE(controller.walletOpen());
        EXPECT_TRUE(controller.lastOpenError().empty());
    }

    dragonx::test::g_liteFakeWalletExists = false;
    dragonx::test::g_liteFakeDeadServerSubstr.clear();
    dragonx::test::g_liteFakeWarmupServerSubstr.clear();
}

// Interactive console: runConsoleCommand() forwards an arbitrary backend command on a background
// thread and delivers the raw response to a main-thread slot drained by takeConsoleResult().
void testLiteWalletControllerConsoleCommand()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
    LiteConnectionSettings conn;
    conn.chainName = "main";
    conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} };

    dragonx::test::resetLiteFakeCounters();
    LiteWalletController controller(liteCaps, conn,
                                    LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));

    const auto drainConsole = [&](LiteConsoleResult& res) {
        for (int i = 0; i < 400 && !controller.takeConsoleResult(res); ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(5));
    };

    // A single-token command (no args) returns the backend's raw response.
    {
        EXPECT_TRUE(controller.runConsoleCommand("info"));
        LiteConsoleResult res;
        drainConsole(res);
        EXPECT_TRUE(res.ok);
        EXPECT_EQ(res.command, std::string("info"));
        EXPECT_TRUE(res.response.find("sdxl-fake") != std::string::npos);
    }

    // "<command> <args>" splits into the command token + the remainder as the single arg string
    // (new zs -> the shielded-address path of the fake backend).
    {
        EXPECT_TRUE(controller.runConsoleCommand("new zs"));
        LiteConsoleResult res;
        drainConsole(res);
        EXPECT_TRUE(res.ok);
        EXPECT_TRUE(res.response.find("zs1fakenew") != std::string::npos);
    }

    // A blank line is rejected (nothing to run).
    EXPECT_FALSE(controller.runConsoleCommand("   "));
}

// Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also
// fails over: the request runs off the UI thread against the preferred server, then the other
// usable defaults, finalized by pumpLifecycleResult() on the main thread.
// W5-3: a create-time passphrase now actually encrypts (and locks) the new lite wallet, and it
// unlocks with the same passphrase — previously the field was collected but ignored.
void testLiteWalletControllerCreateEncryptsWithPassphrase()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
    const LiteConnectionSettings conn = defaultLiteConnectionSettings();

    dragonx::test::g_liteFakeEncrypted = false;
    dragonx::test::g_liteFakeLocked = false;
    auto c = std::make_unique<LiteWalletController>(
        liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));

    LiteWalletCreateRequest req;
    req.passphrase = "hunter2";
    (void)c->createWallet(req);

    const auto s = c->encryptionStatus();
    EXPECT_TRUE(s.ok);
    EXPECT_TRUE(s.encrypted);   // the create-time passphrase encrypted the new wallet
    EXPECT_TRUE(s.locked);      // encrypt locks immediately

    EXPECT_TRUE(c->unlockWallet("hunter2"));
    const auto s2 = c->encryptionStatus();
    EXPECT_FALSE(s2.locked);
}

void testLiteWalletControllerAsyncLifecycleFailover()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);

    LiteConnectionSettings conn;
    conn.chainName = "main";
    conn.servers = {
        LiteServerEndpoint{"https://dead.example", "Dead", true},
        LiteServerEndpoint{"https://good.example", "Good", true},
    };
    conn.selectionMode = LiteServerSelectionMode::Sticky;
    conn.stickyServerUrl = "https://dead.example";  // preferred server is the dead one

    const auto drain = [](LiteWalletController& c) {
        for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(5));
        c.pumpLifecycleResult();
    };

    // Async create with a passphrase: preferred dead, fallback good -> wallet created via fallback.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = false;
        dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletCreateRequest req;
        EXPECT_TRUE(controller.beginCreateWalletAsync(req));
        drain(controller);
        EXPECT_TRUE(controller.walletOpen());
        EXPECT_TRUE(controller.lastLifecycleResult().walletReady);
    }

    // Async restore from seed: preferred dead, fallback good -> wallet restored via fallback.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = false;
        dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletRestoreRequest req;
        req.seedPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon "
                         "abandon abandon abandon abandon abandon abandon abandon abandon "
                         "abandon abandon abandon abandon abandon abandon abandon art";
        req.birthday = 0;
        EXPECT_TRUE(controller.beginRestoreWalletAsync(req));
        drain(controller);
        EXPECT_TRUE(controller.walletOpen());
        EXPECT_TRUE(controller.lastLifecycleResult().walletReady);
    }

    // All servers dead -> async lifecycle fails, wallet stays closed, reason surfaced.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = false;
        dragonx::test::g_liteFakeDeadServerSubstr = "example";  // both servers fail
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        EXPECT_TRUE(controller.beginCreateWalletAsync(LiteWalletCreateRequest{}));
        drain(controller);
        EXPECT_FALSE(controller.walletOpen());
        EXPECT_FALSE(controller.lastLifecycleResult().walletReady);
        EXPECT_TRUE(!controller.lastOpenError().empty());
    }

    dragonx::test::g_liteFakeWalletExists = false;
    dragonx::test::g_liteFakeDeadServerSubstr.clear();
    dragonx::test::g_liteFakeWarmupServerSubstr.clear();
}

// C1/M1: the ASYNC lifecycle path (the one the Settings UI actually calls) must apply the
// passphrase — encrypt a freshly created/restored wallet, unlock an existing encrypted one.
// Previously the async path discarded the passphrase (encrypt/unlock lived only in the sync
// wrappers), so a UI-created wallet stored its seed in PLAINTEXT and an encrypted wallet opened
// but stayed locked.
void testLiteWalletControllerAsyncAppliesPassphrase()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
    LiteConnectionSettings conn;
    conn.chainName = "main";
    conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} };
    conn.selectionMode = LiteServerSelectionMode::Sticky;
    conn.stickyServerUrl = "https://good.example";

    const auto drain = [](LiteWalletController& c) {
        for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i)
            std::this_thread::sleep_for(std::chrono::milliseconds(5));
        c.pumpLifecycleResult();
    };

    // C1: async create with a passphrase encrypts (and locks) the new wallet; same passphrase unlocks.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = false;
        dragonx::test::g_liteFakeEncrypted = false;
        dragonx::test::g_liteFakeLocked = false;
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletCreateRequest req;
        req.passphrase = "hunter2";
        EXPECT_TRUE(controller.beginCreateWalletAsync(req));
        drain(controller);
        EXPECT_TRUE(controller.walletOpen());
        const auto s = controller.encryptionStatus();
        EXPECT_TRUE(s.encrypted);   // async create ENCRYPTED the wallet (C1 fixed)
        EXPECT_TRUE(s.locked);      // encrypt locks immediately
        EXPECT_TRUE(controller.unlockWallet("hunter2"));
        EXPECT_FALSE(controller.encryptionStatus().locked);
    }

    // C1: async restore with a passphrase likewise encrypts the restored wallet.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = false;
        dragonx::test::g_liteFakeEncrypted = false;
        dragonx::test::g_liteFakeLocked = false;
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletRestoreRequest req;
        req.seedPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon "
                         "abandon abandon abandon abandon abandon abandon abandon abandon "
                         "abandon abandon abandon abandon abandon abandon abandon art";
        req.birthday = 0;
        req.passphrase = "hunter2";
        EXPECT_TRUE(controller.beginRestoreWalletAsync(req));
        drain(controller);
        EXPECT_TRUE(controller.walletOpen());
        EXPECT_TRUE(controller.encryptionStatus().encrypted);  // C1 fixed for restore
    }

    // M1: async open of an already-encrypted+locked wallet unlocks it with the supplied passphrase.
    {
        dragonx::test::resetLiteFakeCounters();
        dragonx::test::g_liteFakeWalletExists = true;
        dragonx::test::g_liteFakeEncrypted = true;   // existing wallet is encrypted...
        dragonx::test::g_liteFakeLocked = true;      // ...and locked at open time
        LiteWalletController controller(liteCaps, conn,
                                        LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        LiteWalletOpenRequest req;
        req.passphrase = "hunter2";
        EXPECT_TRUE(controller.beginOpenWalletAsync(req));
        drain(controller);
        EXPECT_TRUE(controller.walletOpen());
        const auto s = controller.encryptionStatus();
        EXPECT_TRUE(s.encrypted);
        EXPECT_FALSE(s.locked);   // async open UNLOCKED it (M1 fixed)
    }

    dragonx::test::g_liteFakeWalletExists = false;
    dragonx::test::g_liteFakeEncrypted = false;
    dragonx::test::g_liteFakeLocked = false;
    dragonx::test::g_liteFakeDeadServerSubstr.clear();
    dragonx::test::g_liteFakeWarmupServerSubstr.clear();
}

// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
// split, transaction typing, confirmations, and sync progress.
void testLiteRefreshModelAppliesToWalletState()
{
    using namespace dragonx::wallet;

    LiteWalletRefreshBundle bundle;
    bundle.complete = true;
    bundle.successfulCommandCount = 4;

    bundle.hasBalance = true;
    bundle.balance.shieldedBalance = 200000000;     // 2 DRGX
    bundle.balance.transparentBalance = 100000000;  // 1 DRGX
    bundle.balance.unconfirmedBalance = 50000000;   // 0.5 DRGX

    bundle.hasAddresses = true;
    bundle.addresses.zAddresses = {"zs1shielded"};
    bundle.addresses.tAddresses = {"t1transparent"};

    bundle.hasTransactions = true;
    LiteTransactionRecord rec;
    rec.txid = "deadbeef";
    rec.datetime = 1700000000;
    rec.blockHeight = 990;
    rec.unconfirmed = false;
    rec.direction = LiteTransactionDirection::Receive;
    rec.address = "zs1shielded";
    rec.amount = 150000000;  // 1.5 DRGX
    bundle.transactions.transactions.push_back(rec);

    bundle.hasSyncStatus = true;
    bundle.syncStatus.syncedBlocks = 1000;
    bundle.syncStatus.totalBlocks = 1000;
    bundle.syncStatus.progress = 1.0;
    bundle.syncStatus.complete = true;

    const auto mapped = mapLiteWalletRefreshBundle(bundle);
    EXPECT_TRUE(mapped.ok);

    dragonx::WalletState state;
    applyLiteRefreshModelToWalletState(mapped.model, state);

    EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
    EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
    EXPECT_NEAR(state.totalBalance, 3.0, 1e-9);
    EXPECT_NEAR(state.unconfirmedBalance, 0.5, 1e-9);

    EXPECT_EQ(static_cast<int>(state.addresses.size()), 2);
    EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
    EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
    EXPECT_EQ(state.z_addresses[0].type, std::string("shielded"));
    EXPECT_EQ(state.t_addresses[0].type, std::string("transparent"));

    EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
    EXPECT_EQ(state.transactions[0].type, std::string("receive"));
    EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
    EXPECT_EQ(state.transactions[0].confirmations, 11);  // chain 1000 - block 990 + 1

    EXPECT_EQ(state.sync.blocks, 1000);
    EXPECT_EQ(state.sync.headers, 1000);
    EXPECT_FALSE(state.sync.syncing);
}

// A Send record carries its recipient in outgoing_metadata, not the top-level address/memo —
// the transactions list must surface the destination + memo instead of blanks.
void testLiteSendShowsRecipientFromOutgoing()
{
    using namespace dragonx::wallet;
    LiteWalletRefreshBundle bundle;
    bundle.complete = true;
    bundle.hasTransactions = true;
    LiteTransactionRecord rec;
    rec.txid = "sendtx";
    rec.datetime = 1700000000;
    rec.unconfirmed = true;
    rec.direction = LiteTransactionDirection::Send;
    rec.address = "";   // sends have no top-level address
    rec.memo = "";
    LiteTransactionOutput out;
    out.address = "zs1recipient";
    out.value = 75000000;  // 0.75 DRGX
    out.memo = "thanks";
    rec.outgoingMetadata.push_back(out);
    bundle.transactions.transactions.push_back(rec);

    const auto mapped = mapLiteWalletRefreshBundle(bundle);
    dragonx::WalletState state;
    applyLiteRefreshModelToWalletState(mapped.model, state);

    EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
    EXPECT_EQ(state.transactions[0].type, std::string("send"));
    EXPECT_EQ(state.transactions[0].address, std::string("zs1recipient"));
    EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
}

// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
void testLitePartialRefreshKeepsPriorAddressBalances()
{
    using namespace dragonx::wallet;

    // First, a full refresh establishes a per-address balance.
    LiteWalletRefreshBundle full;
    full.complete = true;
    full.hasAddresses = true;
    full.addresses.zAddresses = {"zs1keep"};
    full.hasNotes = true;
    LiteSpendableOutput note;
    note.address = "zs1keep";
    note.value = 120000000;  // 1.2 DRGX
    note.spent = false;
    full.notes.unspentNotes.push_back(note);

    dragonx::WalletState state;
    applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(full).model, state);
    EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
    EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9);

    // Now a partial refresh: addresses present, notes command failed (hasNotes=false).
    LiteWalletRefreshBundle partial;
    partial.complete = true;
    partial.hasAddresses = true;
    partial.addresses.zAddresses = {"zs1keep"};
    partial.hasNotes = false;  // notes/utxo failed this cycle

    applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
    EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
    EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9);  // preserved, not zeroed
}

// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
void testLiteWalletControllerRefreshPopulatesState()
{
    using namespace dragonx::wallet;
    const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
    const auto conn = defaultLiteConnectionSettings();

    LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
    EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
    EXPECT_TRUE(controller.walletOpen());
    EXPECT_TRUE(controller.syncStarted());  // auto-started when the wallet became ready

    // Sync runs on a detached thread; the full refresh (balance/addresses) only runs once it
    // completes. Wait for it (instant with the fake) so the refresh is deterministic.
    for (int i = 0; i < 500 && !controller.syncComplete(); ++i)
        std::this_thread::sleep_for(std::chrono::milliseconds(5));
    EXPECT_TRUE(controller.syncComplete());

    dragonx::WalletState state;
    EXPECT_TRUE(controller.refreshWalletState(state));
    EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
    EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
    EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
    EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
    EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
    EXPECT_EQ(state.transactions[0].type, std::string("receive"));
    EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
    EXPECT_EQ(state.sync.headers, 1000);

    // No wallet open -> refresh is a safe no-op
    LiteWalletController fresh(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
    dragonx::WalletState empty;
    EXPECT_FALSE(fresh.refreshWalletState(empty));
}

// syncstatus parser must accept both real backend shapes: idle {"syncing":"false"} and
// in-progress {"syncing":"true","synced_blocks":N,"total_blocks":M} ("syncing" is a string).
void testLiteSyncStatusParserRealShapes()
{
    using namespace dragonx::wallet;

    // Idle: no block counts, syncing is the string "false".
    {
        const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}"));
        EXPECT_TRUE(p.ok);
        EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 0LL);
        EXPECT_TRUE(p.syncStatus.complete);
    }
    // In-progress: block counts present.
    {
        const auto p = parseLiteSyncStatusResponse(
            std::string("{\"syncing\":\"true\",\"synced_blocks\":900,\"total_blocks\":1000}"));
        EXPECT_TRUE(p.ok);
        EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 900LL);
        EXPECT_EQ(static_cast<long long>(p.syncStatus.totalBlocks), 1000LL);
        EXPECT_FALSE(p.syncStatus.complete);
        EXPECT_NEAR(p.syncStatus.progress, 0.9, 1e-9);
    }
    // While syncing, missing block counts is still an error.
    {
        const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
        EXPECT_FALSE(p.ok);
    }
}

// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
// into the WalletState address list, instead of aggregate-only zeros.
void testLitePerAddressBalances()
{
    using namespace dragonx::wallet;

    LiteWalletRefreshBundle bundle;
    bundle.complete = true;
    bundle.hasAddresses = true;
    bundle.addresses.zAddresses = {"zs1aaa"};
    bundle.addresses.tAddresses = {"t1bbb"};
    bundle.hasNotes = true;

    LiteSpendableOutput note;       note.address = "zs1aaa"; note.value = 150000000; note.createdInTxid = "n1";
    LiteSpendableOutput utxo;       utxo.address = "t1bbb";  utxo.value = 50000000;  utxo.createdInTxid = "u1";
    LiteSpendableOutput spentNote;  spentNote.address = "zs1aaa"; spentNote.value = 999; spentNote.spent = true; spentNote.createdInTxid = "n2";
    bundle.notes.unspentNotes.push_back(note);
    bundle.notes.unspentNotes.push_back(spentNote);  // excluded: spent
    bundle.notes.utxos.push_back(utxo);

    const auto mapped = mapLiteWalletRefreshBundle(bundle);
    EXPECT_TRUE(mapped.ok);

    dragonx::WalletState state;
    applyLiteRefreshModelToWalletState(mapped.model, state);
    EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
    EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
    EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9);  // 150000000; the spent note is excluded
    EXPECT_NEAR(state.t_addresses[0].balance, 0.5, 1e-9);  // 50000000
}

// M3: new-address generation via the controller (backend "new" zs/R) returns the address.
void testLiteWalletControllerNewAddress()
{
    using namespace dragonx::wallet;
    const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
    const auto conn = defaultLiteConnectionSettings();

    LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
    EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);

    const auto z = controller.newAddress(/*shielded*/ true);
    EXPECT_TRUE(z.ok);
    EXPECT_TRUE(z.address.rfind("zs1", 0) == 0);

    const auto t = controller.newAddress(/*shielded*/ false);
    EXPECT_TRUE(t.ok);
    EXPECT_TRUE(t.address.rfind("R1", 0) == 0);

    // No wallet open -> error, no address.
    LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
    const auto none = idle.newAddress(true);
    EXPECT_FALSE(none.ok);
}

// Gateway hardening: one command's parse failure must not abort the whole refresh — the
// other commands still populate the bundle (graceful degradation against real-shape drift).
void testLiteWalletGatewayRefreshSkipsFailedCommand()
{
    using namespace dragonx::wallet;
    const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
    const auto conn = defaultLiteConnectionSettings();

    auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
    LiteWalletGateway gateway(caps, conn, &bridge, LiteWalletGatewayOptions{/*allowBridgeCalls*/ true});

    dragonx::test::g_liteFakeBadBalance.store(true);  // make only the balance command fail to parse
    const auto result = gateway.refresh(LiteWalletRefreshRequest{});
    dragonx::test::g_liteFakeBadBalance.store(false);

    EXPECT_TRUE(result.ok);                       // partial success, not a total failure
    EXPECT_FALSE(result.bundle.complete);         // not all commands succeeded
    EXPECT_FALSE(result.bundle.hasBalance);       // the failed command is skipped
    EXPECT_TRUE(result.bundle.hasAddresses);      // the others still populate
    EXPECT_TRUE(result.bundle.hasTransactions);
    EXPECT_TRUE(result.bundle.hasInfo);
}

// M2b-3: opening a wallet auto-starts the background worker, which produces a refresh model
// the main thread can pick up via takeRefreshedModel() and apply to WalletState.
void testLiteWalletControllerWorkerProducesModel()
{
    using namespace dragonx::wallet;
    const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
    const auto conn = defaultLiteConnectionSettings();

    LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
    EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);  // auto-starts the worker

    // The worker publishes progress-only models while syncing, then full models once synced.
    // Poll until a full (balance-bearing) model arrives (sync is instant with the fake).
    LiteWalletAppRefreshModel model;
    bool gotFull = false;
    for (int i = 0; i < 500 && !gotFull; ++i) {
        LiteWalletAppRefreshModel m;
        if (controller.takeRefreshedModel(m) && m.hasBalance) {
            model = m;
            gotFull = true;
            break;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
    EXPECT_TRUE(gotFull);
    EXPECT_TRUE(model.hasBalance);
    EXPECT_TRUE(model.hasAddresses);

    dragonx::WalletState state;
    applyLiteRefreshModelToWalletState(model, state);
    EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);

    // Idle controller (no wallet -> no worker) has nothing pending.
    LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
    LiteWalletAppRefreshModel none;
    EXPECT_FALSE(idle.takeRefreshedModel(none));
}

// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
{
    using namespace dragonx::wallet;
    const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
    const auto conn = defaultLiteConnectionSettings();

    dragonx::test::g_liteFakeSyncBlock.store(true);  // make the backend "sync" block indefinitely
    const auto start = std::chrono::steady_clock::now();
    {
        LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
        controller.createWallet(LiteWalletCreateRequest{});  // launches the (now-blocked) sync thread
        EXPECT_TRUE(controller.syncStarted());
        EXPECT_FALSE(controller.syncComplete());
        // controller destructs here with the sync thread still blocked -> must return promptly.
    }
    const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now() - start).count();
    EXPECT_TRUE(elapsedMs < 1500);  // did not wait for the (blocked) sync to finish

    dragonx::test::g_liteFakeSyncBlock.store(false);  // release the detached sync thread
    std::this_thread::sleep_for(std::chrono::milliseconds(50));  // let it unwind cleanly
}

// M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h).
void testLiteRolloutVersionCompare()
{
    using namespace dragonx::wallet;
    EXPECT_EQ(compareLiteVersions("1.2.0", "1.2.0"), 0);
    EXPECT_EQ(compareLiteVersions("1.2.0", "1.2"), 0);        // missing components treated as 0
    EXPECT_EQ(compareLiteVersions("1.2.0-rc1", "1.2.0"), 0);  // pre-release suffix ignored
    EXPECT_EQ(compareLiteVersions("1.2.0", "1.3.0"), -1);
    EXPECT_EQ(compareLiteVersions("2.0.0", "1.9.9"), 1);
    EXPECT_EQ(compareLiteVersions("1.2.10", "1.2.9"), 1);     // numeric, not lexical
}

void testLiteRolloutBucketAndOverrideHelpers()
{
    using namespace dragonx::wallet;
    const int a = liteRolloutBucketFromInstallId("install-abc");
    EXPECT_EQ(a, liteRolloutBucketFromInstallId("install-abc"));  // deterministic
    EXPECT_TRUE(a >= 0 && a < 1000);                              // in range
    EXPECT_EQ(liteRolloutBucketFromInstallId(""), 0);            // empty -> 0
    EXPECT_TRUE(liteRolloutBucketFromInstallId("install-abc")
                != liteRolloutBucketFromInstallId("install-xyz"));

    EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_on"))),
              std::string("force_on"));
    EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_off"))),
              std::string("force_off"));
    EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("auto"))),
              std::string("auto"));
    EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("garbage"))),
              std::string("auto"));  // unknown -> auto
}

void testLiteRolloutPolicyOverridesAndKillSwitch()
{
    using namespace dragonx::wallet;
    LiteRolloutInputs in;
    in.appVersion = "1.2.0";

    // No manifest, auto -> allowed (fail-open).
    EXPECT_TRUE(evaluateLiteRollout(in).allowed);

    // force_off -> disabled by local override.
    in.override = LiteRolloutOverride::ForceOff;
    auto off = evaluateLiteRollout(in);
    EXPECT_FALSE(off.allowed);
    EXPECT_TRUE(off.status == LiteRolloutStatus::DisabledByLocalOverride);

    // force_on -> allowed, even with a disabling manifest.
    in.override = LiteRolloutOverride::ForceOn;
    in.manifest.present = true; in.manifest.valid = true; in.manifest.globalEnabled = false;
    EXPECT_TRUE(evaluateLiteRollout(in).allowed);

    // env kill-switch is absolute: disables even with force_on.
    in.killSwitchEnv = true;
    auto killed = evaluateLiteRollout(in);
    EXPECT_FALSE(killed.allowed);
    EXPECT_TRUE(killed.status == LiteRolloutStatus::DisabledByKillSwitchEnv);
}

void testLiteRolloutPolicyManifestGates()
{
    using namespace dragonx::wallet;
    LiteRolloutInputs in;
    in.appVersion = "1.2.0";
    in.manifest.present = true;
    in.manifest.valid = true;

    // Global disable carries the manifest message through to the decision.
    in.manifest.globalEnabled = false;
    in.manifest.message = "paused for maintenance";
    {
        auto d = evaluateLiteRollout(in);
        EXPECT_FALSE(d.allowed);
        EXPECT_TRUE(d.status == LiteRolloutStatus::DisabledByManifest);
        EXPECT_EQ(d.message, std::string("paused for maintenance"));
    }
    in.manifest.globalEnabled = true;
    in.manifest.message.clear();

    // Version floor / ceiling / blocklist.
    in.manifest.minVersion = "1.3.0";
    EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
    in.manifest.minVersion.clear();
    in.manifest.maxVersion = "1.1.0";
    EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
    in.manifest.maxVersion.clear();
    in.manifest.blockedVersions = {"1.2.0"};
    EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledBlockedVersion);
    in.manifest.blockedVersions.clear();

    // Within range and not blocked -> allowed.
    in.manifest.minVersion = "1.0.0";
    in.manifest.maxVersion = "2.0.0";
    EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}

void testLiteRolloutPolicyStagedRollout()
{
    using namespace dragonx::wallet;
    LiteRolloutInputs in;
    in.appVersion = "1.2.0";
    in.manifest.present = true;
    in.manifest.valid = true;
    in.manifest.globalEnabled = true;

    in.manifest.rolloutPermille = 0;     // nobody (bucket 0 >= 0)
    in.installBucket = 0;
    EXPECT_FALSE(evaluateLiteRollout(in).allowed);

    in.manifest.rolloutPermille = 500;   // bucket < permille is in
    in.installBucket = 499;
    EXPECT_TRUE(evaluateLiteRollout(in).allowed);
    in.installBucket = 500;
    auto out = evaluateLiteRollout(in);
    EXPECT_FALSE(out.allowed);
    EXPECT_TRUE(out.status == LiteRolloutStatus::DisabledByStagedRollout);

    in.manifest.rolloutPermille = 1000;  // everyone (bucket 999 < 1000)
    in.installBucket = 999;
    EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}

void testLiteRolloutManifestLoader()
{
    using namespace dragonx::wallet;
    // Missing file -> not present -> fail-open allowed.
    auto missing = loadLiteRolloutManifestFromFile("/tmp/obsidian-rollout-missing-xyz.json");
    EXPECT_FALSE(missing.present);
    {
        LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = missing;
        EXPECT_TRUE(evaluateLiteRollout(in).allowed);
    }

    // A valid manifest file parses into the expected fields.
    const std::string path = "/tmp/obsidian-rollout-test.json";
    {
        std::ofstream f(path);
        f << R"({"global_enabled":false,"min_version":"1.0.0","rollout_permille":250,"message":"hold"})";
    }
    auto m = loadLiteRolloutManifestFromFile(path);
    EXPECT_TRUE(m.present);
    EXPECT_TRUE(m.valid);
    EXPECT_FALSE(m.globalEnabled);
    EXPECT_EQ(m.minVersion, std::string("1.0.0"));
    EXPECT_EQ(m.rolloutPermille, 250);
    EXPECT_EQ(m.message, std::string("hold"));
    std::remove(path.c_str());

    // Malformed JSON -> present but invalid -> fail-open allowed.
    const std::string badPath = "/tmp/obsidian-rollout-bad.json";
    { std::ofstream f(badPath); f << "{ this is not json"; }
    auto bad = loadLiteRolloutManifestFromFile(badPath);
    EXPECT_TRUE(bad.present);
    EXPECT_FALSE(bad.valid);
    {
        LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = bad;
        EXPECT_TRUE(evaluateLiteRollout(in).allowed);
    }
    std::remove(badPath.c_str());
}

// M5b: the rollout gate threads through the controller -> lifecycle availability() and blocks
// lifecycle execution with the gate's user-facing message.
void testLiteWalletControllerRolloutGate()
{
    using namespace dragonx::wallet;
    const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
    const auto conn = defaultLiteConnectionSettings();

    // Gated OFF: availability() reports RolloutDisabled, create is blocked, wallet stays closed.
    {
        LiteWalletController controller(
            liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
            LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ true,
                                        "lite paused for maintenance"});
        EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::RolloutDisabled);
        const auto result = controller.createWallet(LiteWalletCreateRequest{});
        EXPECT_FALSE(result.walletReady);
        EXPECT_TRUE(result.status.message.find("maintenance") != std::string::npos);
        EXPECT_FALSE(controller.walletOpen());
    }

    // Not gated: availability() is Ready (matches the other lifecycle tests).
    {
        LiteWalletController controller(
            liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
            LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ false, ""});
        EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::Ready);
    }
}

// ── xmrig updater pure core (util/xmrig_updater.h) — driven by the real release fixture ──
static std::string readXmrigFixture()
{
    const std::string path = std::string(DRAGONX_TEST_FIXTURE_DIR) + "/xmrig/release_latest.json";
    std::ifstream f(path, std::ios::binary);
    return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
}

void testXmrigReleaseParsing()
{
    using namespace dragonx::util;
    const auto rel = parseXmrigRelease(readXmrigFixture());
    EXPECT_TRUE(rel.ok);
    EXPECT_EQ(rel.tag, std::string("v1.0.0"));
    EXPECT_EQ(rel.assets.size(), static_cast<std::size_t>(2));
    // Both assets carry a name + a usable download URL.
    for (const auto& a : rel.assets) {
        EXPECT_TRUE(!a.name.empty());
        EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0);
        EXPECT_TRUE(a.size > 0);
    }
    // Garbage input fails closed.
    EXPECT_FALSE(parseXmrigRelease("not json at all").ok);
    EXPECT_FALSE(parseXmrigRelease("{}").ok);  // no tag_name
}

void testXmrigAssetSelection()
{
    using namespace dragonx::util;
    const auto rel = parseXmrigRelease(readXmrigFixture());
    const int linux = selectXmrigAsset(rel, "linux-x64");
    const int win = selectXmrigAsset(rel, "win-x64");
    EXPECT_TRUE(linux >= 0);
    EXPECT_TRUE(win >= 0);
    EXPECT_TRUE(linux != win);
    // Guard the index reads: select*Asset returns -1 when nothing matches, and EXPECT_TRUE doesn't
    // abort this harness, so an unguarded rel.assets[-1] on a fixture/parser regression would SIGSEGV
    // the whole suite instead of reporting the failed EXPECT above.
    if (linux >= 0) EXPECT_TRUE(rel.assets[linux].name.find("linux-x64.zip") != std::string::npos);
    if (win >= 0)   EXPECT_TRUE(rel.assets[win].name.find("win-x64.zip") != std::string::npos);
    // No macOS build in this fixture -> graceful "not found".
    EXPECT_EQ(selectXmrigAsset(rel, "macos-x86_64"), -1);
    EXPECT_EQ(selectXmrigAsset(rel, "macos-arm64"), -1);
    EXPECT_EQ(selectXmrigAsset(rel, ""), -1);

    // The matcher handles the macOS asset naming ("macos-x86_64", not "macos-x64").
    XmrigRelease mac; mac.ok = true; mac.tag = "v6.25.3";
    mac.assets.push_back({"drg-xmrig-6.25.3-macos-x86_64.zip", "https://x/m", 100});
    EXPECT_TRUE(selectXmrigAsset(mac, "macos-x86_64") >= 0);
    EXPECT_EQ(selectXmrigAsset(mac, "macos-x64"), -1);   // the wrong token must NOT match
}

void testXmrigChecksumParsing()
{
    using namespace dragonx::util;
    const auto rel = parseXmrigRelease(readXmrigFixture());
    const auto sums = parseXmrigChecksums(rel.body);
    // Inner-binary checksums (what we verify before making the binary executable).
    EXPECT_EQ(sums.at("xmrig"),
              std::string("37c178f743c269c1d9e18302cead0ed117ded2b5fe30910e836896b4abc20e57"));
    EXPECT_EQ(sums.at("xmrig.exe"),
              std::string("01223711eddea347eee394c4b6d265b9a3e5c13fe93204bc041c4399c9c758f8"));
    // Archive checksum, keyed by the asset filename.
    EXPECT_EQ(sums.at("drg-xmrig-6.25.1-drg1-linux-x64.zip"),
              std::string("c121a078ee46943584aa6148cac26583409c832d28668cf38ba908e10214c9a6"));
    // Non-checksum prose lines are ignored.
    EXPECT_TRUE(parseXmrigChecksums("just some text\nno hashes here").empty());
}

void testXmrigSha256AndBasenames()
{
    using namespace dragonx::util;
    // Known-answer: SHA-256("abc").
    const std::string abc = "abc";
    EXPECT_EQ(sha256Hex(abc.data(), abc.size()),
              std::string("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"));
    // The selected asset's published archive checksum matches what the verifier will compute over
    // the bytes (closes the loop between parse + hash). Empty input is the SHA-256 of "".
    EXPECT_EQ(sha256Hex("", 0),
              std::string("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));

    const auto linuxNames = xmrigExtractBasenames("linux-x64");
    EXPECT_EQ(linuxNames.size(), static_cast<std::size_t>(1));
    EXPECT_EQ(linuxNames.front(), std::string("xmrig"));
    const auto winNames = xmrigExtractBasenames("win-x64");
    EXPECT_EQ(winNames.size(), static_cast<std::size_t>(2));
    EXPECT_EQ(winNames.front(), std::string("xmrig.exe"));   // miner binary is always first
    EXPECT_TRUE(std::find(winNames.begin(), winNames.end(), std::string("WinRing0x64.sys"))
                != winNames.end());
}

void testXmrigSignatureAssetSelection()
{
    using namespace dragonx::util;
    XmrigRelease rel; rel.ok = true; rel.tag = "v1";
    rel.assets.push_back({"drg-xmrig-linux-x64.zip", "https://x/zip", 100});
    EXPECT_EQ(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip"), -1);  // none published
    rel.assets.push_back({"drg-xmrig-linux-x64.zip.sig", "https://x/sig", 64});
    EXPECT_TRUE(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip") >= 0);
    EXPECT_EQ(selectXmrigSignatureAsset(rel, "other.zip"), -1);
}

void testXmrigPinnedKeyValidity()
{
    using namespace dragonx::util;
    const std::string pin = kXmrigSignaturePublicKeyBase64;
    if (pin.empty()) return;  // signing disabled -> nothing to validate
    // A non-empty pinned key MUST decode to exactly a 32-byte ed25519 public key, or signature
    // verification would silently never succeed. Guards against a malformed/truncated paste.
    EXPECT_TRUE(sodium_init() >= 0);
    unsigned char pk[crypto_sign_PUBLICKEYBYTES];
    std::size_t n = 0; const char* end = nullptr;
    const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n",
                                     &n, &end, sodium_base64_VARIANT_ORIGINAL);
    EXPECT_EQ(rc, 0);
    EXPECT_EQ(n, static_cast<std::size_t>(crypto_sign_PUBLICKEYBYTES));
}

// Lite Network tab pure helpers.
void testLiteServerHostParsing()
{
    using dragonx::util::liteServerHost;
    EXPECT_EQ(liteServerHost("https://lite.dragonx.is"), std::string("lite.dragonx.is"));
    EXPECT_EQ(liteServerHost("https://lite.dragonx.is:443/path"), std::string("lite.dragonx.is:443"));
    EXPECT_EQ(liteServerHost("http://1.2.3.4:9067"), std::string("1.2.3.4:9067"));
    EXPECT_EQ(liteServerHost("https://user@host.example/x?y"), std::string("host.example"));
    EXPECT_EQ(liteServerHost("lite.dragonx.is"), std::string("lite.dragonx.is"));  // no scheme
}

void testLiteOfficialServerDetection()
{
    using dragonx::wallet::isOfficialLiteServer;
    EXPECT_TRUE(isOfficialLiteServer("https://lite.dragonx.is"));
    EXPECT_TRUE(isOfficialLiteServer("https://lite3.dragonx.is"));
    EXPECT_TRUE(isOfficialLiteServer("  https://lite.dragonx.is  "));  // trimmed
    EXPECT_FALSE(isOfficialLiteServer("https://my-custom-server.example"));
    EXPECT_FALSE(isOfficialLiteServer(""));
}

// H7: atomic + durable file writes (settings.json / addressbook.json persistence).
void testAtomicFileWrite()
{
    namespace fs = std::filesystem;
    using dragonx::util::Platform;

    fs::path dir = fs::temp_directory_path() / "obsidian_atomic_test";
    fs::remove_all(dir);
    const std::string target = (dir / "nested" / "settings.json").string();

    // Creates parent dirs and writes content.
    EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":1}"));
    EXPECT_TRUE(fs::exists(target));
    {
        std::ifstream in(target);
        std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
        EXPECT_EQ(body, std::string("{\"a\":1}"));
    }

    // Overwrites in place (atomic replace), and leaves no stray .tmp behind.
    EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":2,\"b\":3}"));
    {
        std::ifstream in(target);
        std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
        EXPECT_EQ(body, std::string("{\"a\":2,\"b\":3}"));
    }
    EXPECT_FALSE(fs::exists(target + ".tmp"));

#ifndef _WIN32
    // restrictPermissions -> owner-only (0600).
    const std::string secret = (dir / "vault.bin").string();
    EXPECT_TRUE(Platform::writeFileAtomically(secret, "shh", /*restrictPermissions=*/true));
    auto perms = fs::status(secret).permissions();
    EXPECT_TRUE((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none);
#endif

    fs::remove_all(dir);
}

// Address checksum validation. Uses standard Base58Check / BIP173 Bech32 vectors — the
// algorithms are chain-agnostic, so passing these means real DRGX addresses validate too.
void testAddressChecksumValidation()
{
    using dragonx::util::isValidBase58Check;
    using dragonx::util::isValidBech32;

    // Base58Check: a valid P2PKH address verifies; a one-char transcription error fails.
    EXPECT_TRUE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"));
    EXPECT_FALSE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna"));  // last char flipped
    EXPECT_FALSE(isValidBase58Check("not-base58-0OIl"));                     // invalid alphabet
    EXPECT_FALSE(isValidBase58Check(""));

    // Bech32 (BIP173 valid vectors) verify; corrupted checksums fail.
    EXPECT_TRUE(isValidBech32("A12UEL5L"));
    EXPECT_TRUE(isValidBech32("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"));
    EXPECT_TRUE(isValidBech32("split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w"));
    EXPECT_FALSE(isValidBech32("A12UEL5M"));        // checksum corrupted
    EXPECT_FALSE(isValidBech32("abc1rzg"));         // too short / bad checksum
    EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"));  // mixed case
    EXPECT_FALSE(isValidBech32("nosalt"));          // no separator

    // decodeBase58Check exposes the checksum-stripped payload so callers can inspect version/length.
    using dragonx::util::decodeBase58Check;
    std::vector<std::uint8_t> payload;
    EXPECT_TRUE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", payload));
    EXPECT_EQ(payload.size(), (size_t)21);   // version(1) + 20-byte hash160, checksum stripped
    EXPECT_EQ((int)payload[0], 0);           // mainnet P2PKH version byte
    EXPECT_FALSE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna", payload));  // bad checksum

    // bech32Hrp returns the (lower-cased) HRP of a valid string, or "" when invalid.
    using dragonx::util::bech32Hrp;
    EXPECT_EQ(bech32Hrp("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"), std::string("abcdef"));
    EXPECT_EQ(bech32Hrp("A12UEL5L"), std::string("a"));    // lower-cased
    EXPECT_EQ(bech32Hrp("A12UEL5M"), std::string(""));     // invalid → empty

    // Address type recognizers: accept every real DragonX recipient form, reject non-addresses.
    using dragonx::util::isTransparentAddress;
    using dragonx::util::isShieldedAddress;
    using dragonx::util::isValidRecipientAddress;
    const std::string p2pkh = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti";   // v60
    const std::string p2sh  = "bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C";   // v85 multisig — the regression
    const std::string zaddr = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5";
    EXPECT_TRUE(isTransparentAddress(p2pkh));
    EXPECT_TRUE(isTransparentAddress(p2sh));      // was silently dropped by the old 'R'-only filter
    EXPECT_FALSE(isShieldedAddress(p2pkh));
    EXPECT_TRUE(isShieldedAddress(zaddr));
    EXPECT_FALSE(isTransparentAddress(zaddr));
    EXPECT_TRUE(isValidRecipientAddress(p2pkh));
    EXPECT_TRUE(isValidRecipientAddress(p2sh));
    EXPECT_TRUE(isValidRecipientAddress(zaddr));
    // A WIF spending key is NOT a recipient (33/34-byte payload, not 21); nor is a typo'd address.
    EXPECT_FALSE(isTransparentAddress("Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C"));
    EXPECT_FALSE(isValidRecipientAddress("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX"));  // flipped checksum char
    EXPECT_FALSE(isValidRecipientAddress(""));
}

// Import-key recognition: the client gate must accept every real DragonX key form and reject
// non-keys, so it never blocks a valid import with "Unrecognized key format" (audit F1/F2/F3).
void testPrivateKeyImportRecognition()
{
    using dragonx::services::WalletSecurityController;
    using KeyKind = WalletSecurityController::KeyKind;

    // --- Transparent WIF (DragonX SECRET_KEY version 188; testnet 128) ---
    const std::string wifCompressed   = "Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C"; // v188 compressed 'U'
    const std::string wifUncompressed = "7JTPumX2kofLQdHKANy8MMLRkmXCmuJcosiv9f4RFqW9oCJXBHD";  // v188 uncompressed '7'
    const std::string wifTestnet      = "KwFfpDsaF7yxCELuyrH9gP5XL7TAt5b9HPWC1xCQbmrxvhJgMQHb"; // v128 compressed

    EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifCompressed));
    EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifUncompressed));  // F2 regression: '7' was rejected
    EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifTestnet));
    EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(wifUncompressed));
    EXPECT_EQ(WalletSecurityController::classifyPrivateKey(wifUncompressed), KeyKind::Transparent);
    EXPECT_FALSE(WalletSecurityController::isViewingKey(wifCompressed));

    // A corrupted WIF (flipped last char) fails the checksum → caught locally, not at the daemon.
    std::string wifBad = wifCompressed;
    wifBad.back() = (wifBad.back() == 'C' ? 'D' : 'C');
    EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(wifBad));

    // A transparent R-address is Base58Check-valid but NOT a key (21-byte payload, not 33/34).
    EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"));

    // --- Sapling incoming viewing key (mainnet HRP "zivks") — the F1 regression ---
    const std::string ivk = "zivks1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7rusq45amw7";
    EXPECT_TRUE(WalletSecurityController::isViewingKey(ivk));
    EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(ivk));
    EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(ivk));  // a viewing key can't spend
    // The stale Zcash "zxview…" prefix is NOT a DragonX viewing key (and not valid Bech32 here).
    EXPECT_FALSE(WalletSecurityController::isViewingKey("zxviews1abcdef"));

    // --- Sapling z spending key (recognized by HRP prefix; daemon vets the long payload) ---
    const std::string zspend = "secret-extended-key-main1qxxxxxxxxxxxxxxxxxxxx";
    EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(zspend));
    EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(zspend));
    EXPECT_EQ(WalletSecurityController::classifyPrivateKey(zspend), KeyKind::Shielded);

    // --- Garbage / empty ---
    EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey(""));
    EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey("hello world"));
}

// Seed-phrase normalization + word count + the 24-word completeness gate. Guards the lite-restore
// crash fix (only 24-word/32-byte-entropy seeds are safe for the SDXL backend) and the NBSP-paste
// recovery fix. Both restore gates (first-run wizard + Settings) route through these.
void testSeedPhraseHelpers()
{
    using dragonx::util::normalizeSeedPhrase;
    using dragonx::util::seedPhraseWordCount;
    using dragonx::util::isCompleteRecoveryPhrase;

    // --- completeness gate: 24 words only (12/15/18/21 valid-BIP39 lengths crash the backend) ---
    EXPECT_TRUE(isCompleteRecoveryPhrase(24));
    EXPECT_FALSE(isCompleteRecoveryPhrase(12));
    EXPECT_FALSE(isCompleteRecoveryPhrase(15));
    EXPECT_FALSE(isCompleteRecoveryPhrase(21));
    EXPECT_FALSE(isCompleteRecoveryPhrase(23));
    EXPECT_FALSE(isCompleteRecoveryPhrase(25));
    EXPECT_FALSE(isCompleteRecoveryPhrase(0));

    // --- plain ASCII: trim, collapse runs, count exactly; the common case must be untouched otherwise ---
    EXPECT_EQ(normalizeSeedPhrase("  alpha   beta\tgamma\ndelta  "), std::string("alpha beta gamma delta"));
    EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha beta gamma")), 3);
    EXPECT_EQ(seedPhraseWordCount(""), 0);
    EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("   \t \n ")), 0);  // whitespace-only

    // --- NBSP (U+00A0 = 0xC2 0xA0) between words must fold to a real space, not glue the words ---
    EXPECT_EQ(normalizeSeedPhrase("alpha\xC2\xA0" "beta"), std::string("alpha beta"));
    EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha\xC2\xA0" "beta")), 2);
    // Other Unicode spaces: en space U+2002, ideographic U+3000, narrow NBSP U+202F.
    EXPECT_EQ(normalizeSeedPhrase("a\xE2\x80\x82" "b\xE3\x80\x80" "c\xE2\x80\xAF" "d"), std::string("a b c d"));
    // Zero-width chars (U+200B, U+FEFF BOM) are stripped, not treated as separators.
    EXPECT_EQ(normalizeSeedPhrase("\xEF\xBB\xBF" "alpha\xE2\x80\x8B beta"), std::string("alpha beta"));

    // --- a full 24-word phrase pasted with NBSP separators counts as 24 (regression for the fix) ---
    std::string words24;
    for (int i = 0; i < 24; ++i) { if (i) words24 += "\xC2\xA0"; words24 += "word"; }
    EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(words24)), 24);
    EXPECT_TRUE(isCompleteRecoveryPhrase(seedPhraseWordCount(normalizeSeedPhrase(words24))));
    // The normalized form is plain single-space separated (what the backend's split(" ") needs).
    EXPECT_EQ(normalizeSeedPhrase(words24).find("\xC2\xA0"), std::string::npos);
}

// Block-DB abort detection: classify a crashed daemon's console output so the app can offer a
// one-click reindex instead of silently showing a zero balance (a daemon-vs-chaindata format break).
void testBlockDbOutputDiagnosis()
{
    using dragonx::daemon::blockDbOutputLooksBroken;

    // The exact abort sequence we observed on a format-mismatched datadir.
    EXPECT_TRUE(blockDbOutputLooksBroken(
        "Opened LevelDB successfully\n"
        "GetValue: CDataStream error - non-canonical optional discriminant: iostream error\n"
        "ERROR: LoadBlockIndex() : failed to read value\n"
        ": Error loading block database.\n"
        "Aborted block database rebuild. Exiting.\n"));
    // Each individual fatal marker also trips it (partial capture / different phrasing).
    EXPECT_TRUE(blockDbOutputLooksBroken("... : Error loading block database."));
    EXPECT_TRUE(blockDbOutputLooksBroken("Aborted block database rebuild. Exiting."));
    EXPECT_TRUE(blockDbOutputLooksBroken("ERROR: LoadBlockIndex() : failed to read value"));

    // Normal startup / other failures must NOT be misread as a block-DB problem (no false reindex offer).
    EXPECT_FALSE(blockDbOutputLooksBroken(
        "Loading block index...\nVerifying blocks...\nLoading wallet...\nRescanning...\nDone loading\n"));
    EXPECT_FALSE(blockDbOutputLooksBroken("Error loading wallet"));      // wallet corruption → salvage, not reindex
    EXPECT_FALSE(blockDbOutputLooksBroken("Error: Could not find any asmap file!"));
    EXPECT_FALSE(blockDbOutputLooksBroken(""));

    // Wallet auto-recovery detection: the node salvaged wallet.dat (moved the original to a .bak).
    using dragonx::daemon::walletAutoRecovered;
    EXPECT_TRUE(walletAutoRecovered(
        "Warning: wallet.dat corrupt, data salvaged! Original wallet.dat saved as wallet.1786300000.bak in ..."));
    EXPECT_TRUE(walletAutoRecovered("wallet.dat corrupt, salvage failed"));
    // The FAILED-salvage sequence a BDB-inconsistent wallet actually produces (must also be detected —
    // this is the case that previously slipped through and silently emptied the wallet).
    EXPECT_TRUE(walletAutoRecovered(
        "Renamed wallet.dat to wallet.1786375505.bak\n"
        "CDBEnv::Salvage: Database salvage found errors, all data may not be recoverable.\n"
        "Salvage(aggressive) found no records in wallet.1786375505.bak.\n"));
    EXPECT_FALSE(walletAutoRecovered("Loading wallet...\nWallet completed loading\n"));  // normal load
    EXPECT_FALSE(walletAutoRecovered(": Error loading block database."));                // block-DB abort != salvage
    EXPECT_FALSE(walletAutoRecovered(""));

    // Newest salvage backup picker (the "wallet.<unixtime>.bak" the recovery just made).
    using dragonx::daemon::newestWalletSalvageBak;
    EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.1786200000.bak", "wallet.1786300000.bak", "peers.dat"}),
              std::string("wallet.1786300000.bak"));   // highest timestamp wins
    EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.dat.encrypted.bak", "notes.txt"}),
              std::string(""));                          // no wallet.<digits>.bak present
    EXPECT_EQ(newestWalletSalvageBak({}), std::string(""));

    // Restore must pick the LARGEST (least-salvaged) backup, NOT the newest — a salvage cascade shrinks
    // the wallet each round, so the newest .bak can be an emptied 40KB copy while the original is huge.
    using dragonx::daemon::largestWalletSalvageBak;
    EXPECT_EQ(largestWalletSalvageBak({
                  {"wallet.dat", 40000ull},                         // current (salvaged, tiny) — not a .bak
                  {"wallet.1786200000.bak", 194174976ull},          // ORIGINAL — oldest ts, biggest
                  {"wallet.1786340620.bak", 40960ull},              // latest salvage — newest ts, empty
                  {"peers.dat", 999ull}}),
              std::string("wallet.1786200000.bak"));   // largest wins over newest
    EXPECT_EQ(largestWalletSalvageBak({{"wallet.dat", 100ull}, {"notes.txt", 5ull}}), std::string(""));
    EXPECT_EQ(largestWalletSalvageBak({}), std::string(""));
}

// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
void testLiteServerProbeLive()
{
    if (!std::getenv("DRAGONX_TEST_NETWORK")) {
        std::printf("[skip] testLiteServerProbeLive (set DRAGONX_TEST_NETWORK=1 to run)\n");
        return;
    }
    using namespace dragonx::util;
    LiteServerProbe probe;
    probe.start({"https://lite.dragonx.is"});
    for (int i = 0; i < 150 && probe.busy(); ++i)
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    const auto res = probe.results();
    const auto it = res.find("https://lite.dragonx.is");
    EXPECT_TRUE(it != res.end());
    if (it != res.end()) {
        EXPECT_TRUE(it->second.probed);
        if (it->second.online) {  // reachable -> latency + IP captured
            EXPECT_TRUE(!it->second.ip.empty());
            std::printf("[live] lite.dragonx.is online %dms ip=%s\n",
                        it->second.latencyMs, it->second.ip.c_str());
        }
    }
}

void testXmrigSignatureVerify()
{
    using namespace dragonx::util;
    EXPECT_TRUE(sodium_init() >= 0);
    unsigned char pk[crypto_sign_PUBLICKEYBYTES], sk[crypto_sign_SECRETKEYBYTES];
    crypto_sign_keypair(pk, sk);
    const std::string data = "drg-xmrig archive payload bytes for signing test";
    unsigned char sig[crypto_sign_BYTES];
    crypto_sign_detached(sig, nullptr,
                         reinterpret_cast<const unsigned char*>(data.data()), data.size(), sk);
    auto b64 = [](const unsigned char* b, std::size_t n) {
        std::vector<char> buf(sodium_base64_encoded_len(n, sodium_base64_VARIANT_ORIGINAL));
        sodium_bin2base64(buf.data(), buf.size(), b, n, sodium_base64_VARIANT_ORIGINAL);
        return std::string(buf.data());
    };
    const std::string pkB64 = b64(pk, sizeof(pk));
    const std::string sigB64 = b64(sig, sizeof(sig));

    // Valid base64 signature verifies; a raw 64-byte signature is also accepted.
    EXPECT_TRUE(verifyXmrigSignature(data, sigB64, pkB64));
    EXPECT_TRUE(verifyXmrigSignature(data, std::string(reinterpret_cast<const char*>(sig),
                                                       crypto_sign_BYTES), pkB64));
    // Whitespace around the base64 signature is tolerated.
    EXPECT_TRUE(verifyXmrigSignature(data, "  " + sigB64 + "\n", pkB64));

    // Regression: a RAW 64-byte signature whose final byte equals a whitespace value (~1.6% of
    // signatures) must still verify — it must not be whitespace-trimmed. Force that case.
    {
        unsigned char rpk[crypto_sign_PUBLICKEYBYTES], rsk[crypto_sign_SECRETKEYBYTES],
                      rsig[crypto_sign_BYTES];
        bool found = false;
        for (int i = 0; i < 20000 && !found; ++i) {
            crypto_sign_keypair(rpk, rsk);
            const std::string m = "raw-ws-regression-" + std::to_string(i);
            crypto_sign_detached(rsig, nullptr,
                                 reinterpret_cast<const unsigned char*>(m.data()), m.size(), rsk);
            const unsigned char last = rsig[crypto_sign_BYTES - 1];
            if (last == '\n' || last == '\r' || last == ' ' || last == '\t') {
                found = true;
                const std::string rawSig(reinterpret_cast<const char*>(rsig), crypto_sign_BYTES);
                EXPECT_TRUE(verifyXmrigSignature(m, rawSig, b64(rpk, sizeof(rpk))));
            }
        }
        EXPECT_TRUE(found);
    }

    // Fails closed: tampered data, wrong key, malformed/empty inputs.
    EXPECT_FALSE(verifyXmrigSignature(data + "x", sigB64, pkB64));
    unsigned char pk2[crypto_sign_PUBLICKEYBYTES], sk2[crypto_sign_SECRETKEYBYTES];
    crypto_sign_keypair(pk2, sk2);
    EXPECT_FALSE(verifyXmrigSignature(data, sigB64, b64(pk2, sizeof(pk2))));
    EXPECT_FALSE(verifyXmrigSignature(data, "not valid base64 !!!", pkB64));
    EXPECT_FALSE(verifyXmrigSignature(data, sigB64, ""));
    EXPECT_FALSE(verifyXmrigSignature(data, "", pkB64));
}

// Reads any fixture file by repo-relative path under tests/fixtures/.
static std::string readFixtureFile(const std::string& rel)
{
    std::ifstream f(std::string(DRAGONX_TEST_FIXTURE_DIR) + "/" + rel, std::ios::binary);
    return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
}

void testXmrigReleaseListParsing()
{
    using namespace dragonx::util;
    const auto list = parseXmrigReleaseList(readFixtureFile("xmrig/releases_list.json"));
    EXPECT_TRUE(list.size() >= 2);
    EXPECT_EQ(list.front().tag, std::string("v6.25.3"));   // newest first (Gitea order preserved)
    for (const auto& r : list) {
        EXPECT_TRUE(r.ok);
        EXPECT_TRUE(!r.tag.empty());
        EXPECT_TRUE(!r.assets.empty());
    }
    // Fail closed on non-array / garbage.
    EXPECT_TRUE(parseXmrigReleaseList("not json").empty());
    EXPECT_TRUE(parseXmrigReleaseList("{}").empty());
    // Drafts are skipped; the pre-release flag is captured.
    const std::string inj = R"([
      {"tag_name":"v9.9.9","prerelease":true,"assets":[{"name":"a-linux-x64.zip","browser_download_url":"https://x/a","size":1}]},
      {"tag_name":"v9.9.8","draft":true,"assets":[]}
    ])";
    const auto injList = parseXmrigReleaseList(inj);
    EXPECT_EQ(injList.size(), static_cast<std::size_t>(1));   // draft dropped
    EXPECT_EQ(injList.front().tag, std::string("v9.9.9"));
    EXPECT_TRUE(injList.front().prerelease);
}

// ── daemon updater pure core (util/daemon_updater.h) — driven by the real v1.0.2 release fixture ──
static std::string readDaemonFixture()
{
    return readFixtureFile("daemon/release_latest.json");
}

void testDaemonReleaseParsing()
{
    using namespace dragonx::util;
    const auto rel = parseDaemonRelease(readDaemonFixture());
    EXPECT_TRUE(rel.ok);
    EXPECT_EQ(rel.tag, std::string("v1.0.2"));
    EXPECT_EQ(rel.assets.size(), static_cast<std::size_t>(3));  // linux-amd64 / macos / win64
    for (const auto& a : rel.assets) {
        EXPECT_TRUE(!a.name.empty());
        EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0);
        EXPECT_TRUE(a.size > 0);
    }
    EXPECT_FALSE(parseDaemonRelease("not json at all").ok);
    EXPECT_FALSE(parseDaemonRelease("{}").ok);  // no tag_name
}

void testDaemonAssetSelection()
{
    using namespace dragonx::util;
    const auto rel = parseDaemonRelease(readDaemonFixture());
    const int lin = selectDaemonAsset(rel, "linux-amd64");
    const int mac = selectDaemonAsset(rel, "macos");
    const int win = selectDaemonAsset(rel, "win64");
    EXPECT_TRUE(lin >= 0 && mac >= 0 && win >= 0);
    EXPECT_TRUE(lin != mac && mac != win && lin != win);
    // Guard the index reads (select*Asset returns -1 on no match; EXPECT_TRUE doesn't abort here) so a
    // fixture/parser regression reports the failed EXPECT above instead of an OOB rel.assets[-1] crash.
    if (lin >= 0) EXPECT_TRUE(rel.assets[lin].name.find("linux-amd64.zip") != std::string::npos);
    if (mac >= 0) EXPECT_TRUE(rel.assets[mac].name.find("macos.zip") != std::string::npos);
    if (win >= 0) EXPECT_TRUE(rel.assets[win].name.find("win64.zip") != std::string::npos);
    // Wrong/foreign tokens (e.g. the miner's naming) must NOT match the daemon archives.
    EXPECT_EQ(selectDaemonAsset(rel, "linux-x64"), -1);
    EXPECT_EQ(selectDaemonAsset(rel, "linux-arm64"), -1);
    EXPECT_EQ(selectDaemonAsset(rel, ""), -1);
}

void testDaemonChecksumParsing()
{
    using namespace dragonx::util;
    const auto rel = parseDaemonRelease(readDaemonFixture());
    const auto sums = parseDaemonChecksums(rel.body);
    // The markdown checksum table ( "| <archive>.zip | `<sha256>` |" ) is parsed by archive name.
    EXPECT_EQ(sums.at("dragonx-1.0.2-linux-amd64.zip"),
              std::string("85f1dd908bfbdee6aaebabdc74848dc8963d45e7510172d84d0230c0fad6cc16"));
    EXPECT_EQ(sums.at("dragonx-1.0.2-macos.zip"),
              std::string("102e1f1ecab05def25465ad4867b19d46c7cc00a9fbb018ffb405bcb82cc6432"));
    EXPECT_EQ(sums.at("dragonx-1.0.2-win64.zip"),
              std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
    // Header/separator/prose rows are ignored.
    EXPECT_TRUE(parseDaemonChecksums("| File | SHA-256 |\n|---|---|\njust prose, no hashes").empty());
    // Keys are lowercased so the (also-lowercased) lookup is case-insensitive vs the JSON names.
    const auto mixed = parseDaemonChecksums(
        "| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
    EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"),
              std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
    // Regression: a markdown-bolded filename (**archive.zip**) must still parse — otherwise a valid,
    // correctly-signed release whose body bolds the name would fail checksum lookup and be refused.
    const auto bold = parseDaemonChecksums(
        "| **dragonx-1.0.2-win64.zip** | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
    EXPECT_EQ(bold.at("dragonx-1.0.2-win64.zip"),
              std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
}

void testDaemonBasenamesAndVersionCore()
{
    using namespace dragonx::util;
    const auto posix = daemonExtractBasenames("linux-amd64");
    EXPECT_EQ(posix.size(), static_cast<std::size_t>(3));
    EXPECT_EQ(posix.front(), std::string("dragonxd"));  // daemon binary is always first
    EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-cli")) != posix.end());
    EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-tx")) != posix.end());
    const auto win = daemonExtractBasenames("win64");
    EXPECT_EQ(win.front(), std::string("dragonxd.exe"));
    EXPECT_TRUE(std::find(win.begin(), win.end(), std::string("dragonx-cli.exe")) != win.end());

    // Version-core normalization: a scanned "vX.Y.Z-<commit>" must compare equal to a release tag.
    EXPECT_EQ(daemonVersionCore("v1.0.2-ddd851dc1"), std::string("v1.0.2"));
    EXPECT_EQ(daemonVersionCore("v1.0.2"), std::string("v1.0.2"));
    EXPECT_EQ(daemonVersionCore("1.0.2"), std::string("1.0.2"));
    EXPECT_EQ(daemonVersionCore("DragonX v1.0.2 (abc)"), std::string("v1.0.2"));
    EXPECT_EQ(daemonVersionCore("v1.2"), std::string("v1.2"));  // not full N.N.N -> unchanged
    EXPECT_EQ(daemonVersionCore(""), std::string(""));
    EXPECT_EQ(daemonVersionCore("nightly"), std::string("nightly"));
}

void testDaemonSignatureAssetSelection()
{
    using namespace dragonx::util;
    DaemonRelease rel; rel.ok = true; rel.tag = "v1.0.2";
    rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip", "https://x/zip", 100});
    EXPECT_EQ(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip"), -1);  // none published
    rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip.sig", "https://x/sig", 64});
    EXPECT_TRUE(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip") >= 0);
    EXPECT_EQ(selectDaemonSignatureAsset(rel, "other.zip"), -1);
}

void testDaemonPinnedKeyValidity()
{
    using namespace dragonx::util;
    // The daemon updater REQUIRES signatures, so the pinned key must be a valid 32-byte ed25519 key.
    const std::string pin = kDaemonSignaturePublicKeyBase64;
    EXPECT_TRUE(kDaemonRequireSignature);
    EXPECT_TRUE(!pin.empty());
    EXPECT_TRUE(sodium_init() >= 0);
    unsigned char pk[crypto_sign_PUBLICKEYBYTES];
    std::size_t n = 0; const char* end = nullptr;
    const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n",
                                     &n, &end, sodium_base64_VARIANT_ORIGINAL);
    EXPECT_EQ(rc, 0);
    EXPECT_EQ(n, static_cast<std::size_t>(crypto_sign_PUBLICKEYBYTES));
}

void testDaemonSignatureInterop()
{
    using namespace dragonx::util;
    // Known answer produced by scripts/sign-daemon-release.sh (OpenSSL ed25519) over the message
    // below with the pinned key's secret half. Proves the pinned key + the release-signing flow
    // produce signatures the wallet's libsodium verifier accepts (closes the OpenSSL<->libsodium
    // interop loop), and that the pinned public key actually matches the signing key.
    const std::string msg = "dragonx daemon archive payload bytes for signing test";
    const std::string sigB64 =
        "rmQ5qOw+W8vu56GeZrooD7Wh1N/WHRP4siD19Mxq/8WXQQuNrFY3DPCNU9C7jHB2jg/VfKrLVna57K/lkSDBDA==";
    const std::string pin = kDaemonSignaturePublicKeyBase64;
    EXPECT_TRUE(verifyXmrigSignature(msg, sigB64, pin));
    EXPECT_TRUE(verifyXmrigSignature(msg, "  " + sigB64 + "\n", pin));  // trailing newline tolerated
    // Fails closed on tampered payload or wrong key.
    EXPECT_FALSE(verifyXmrigSignature(msg + "x", sigB64, pin));
    EXPECT_FALSE(verifyXmrigSignature(msg, sigB64, std::string(kXmrigSignaturePublicKeyBase64)));
}

void testDaemonReleaseListParsing()
{
    using namespace dragonx::util;
    const auto list = parseDaemonReleaseList(readFixtureFile("daemon/releases_list.json"));
    EXPECT_TRUE(list.size() >= 2);
    EXPECT_EQ(list.front().tag, std::string("v1.0.2"));   // newest first
    for (const auto& r : list) {
        EXPECT_TRUE(r.ok);
        EXPECT_TRUE(!r.assets.empty());
    }
    EXPECT_TRUE(parseDaemonReleaseList("not json").empty());
    EXPECT_TRUE(parseDaemonReleaseList("{}").empty());
    // Draft skipped; pre-release + name + published_at captured.
    const std::string inj = R"([
      {"tag_name":"v2.0.0","prerelease":true,"name":"RC","published_at":"2026-09-01T00:00:00Z","assets":[{"name":"dragonx-2.0.0-linux-amd64.zip","browser_download_url":"https://x/a","size":1}]},
      {"tag_name":"v1.9.9","draft":true,"assets":[]}
    ])";
    const auto injList = parseDaemonReleaseList(inj);
    EXPECT_EQ(injList.size(), static_cast<std::size_t>(1));
    EXPECT_TRUE(injList.front().prerelease);
    EXPECT_EQ(injList.front().name, std::string("RC"));
    EXPECT_EQ(injList.front().publishedAt.substr(0, 10), std::string("2026-09-01"));
}

// Live end-to-end exercise of the XmrigUpdater WORKER (real network + curl + miniz). Env-gated so
// CI / offline runs skip it; run with DRAGONX_TEST_NETWORK=1 to hit git.dragonx.is. Verifies the
// full download -> archive-checksum -> extract/flatten -> inner-binary-checksum -> install path.
void testXmrigLiveInstall()
{
    if (!std::getenv("DRAGONX_TEST_NETWORK")) {
        std::printf("[skip] testXmrigLiveInstall (set DRAGONX_TEST_NETWORK=1 to run)\n");
        return;
    }
    using namespace dragonx::util;
    const std::string dir = "/tmp/obsidian-xmrig-live-test";
    std::error_code ec; std::filesystem::remove_all(dir, ec);

    XmrigUpdater up;
    up.startInstall(dir);
    for (int i = 0; i < 1200 && !up.isDone(); ++i)         // up to ~120s for a ~4 MiB download
        std::this_thread::sleep_for(std::chrono::milliseconds(100));

    const auto p = up.getProgress();
    EXPECT_TRUE(up.isDone());
    EXPECT_TRUE(p.state == XmrigUpdater::State::Done);
    if (p.state != XmrigUpdater::State::Done) {
        std::printf("[testXmrigLiveInstall] failed: %s\n", p.error.c_str());
        return;
    }
    // The miner binary was flattened out of the versioned subdir into the target dir. We don't pin
    // a release-specific hash here (releases change) — reaching State::Done already means the worker
    // verified the binary against the release's own published SHA-256. Just assert a real binary landed.
    const std::string bin = dir + "/" + xmrigExtractBasenames(currentXmrigPlatformToken()).front();
    EXPECT_TRUE(std::filesystem::exists(bin));
    if (std::filesystem::exists(bin)) {
        std::error_code szEc;
        EXPECT_TRUE(std::filesystem::file_size(bin, szEc) > 100000);  // a real miner binary is MBs
    }
    std::filesystem::remove_all(dir, ec);
}

// Mining pool registry: URL matching + per-pool algo resolution.
void testPoolRegistryLookup()
{
    using namespace dragonx::util;
    // pool.dragonx.cc was removed from the built-in defaults; pool.dragonx.is is the sole entry.
    EXPECT_EQ(knownPools().size(), static_cast<std::size_t>(1));

    // The algo follows the pool: pool.dragonx.is resolves to rx/hush regardless of the caller default.
    EXPECT_EQ(resolvePoolAlgo("pool.dragonx.is:3433", "rx/dragonx"), std::string("rx/hush"));
    // The former us.dragonx.cc (pool.dragonx.cc) host is now unknown -> caller's fallback algo.
    EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc:3333", "rx/hush"), std::string("rx/hush"));
    EXPECT_EQ(resolvePoolAlgo("my.pool.example:1234", "rx/hush"), std::string("rx/hush"));

    EXPECT_TRUE(findKnownPoolByUrl("pool.dragonx.is:3433") != nullptr);
    EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:3333") == nullptr);  // removed built-in default
    EXPECT_TRUE(findKnownPoolByUrl("unknown.host:1") == nullptr);
    // A mismatched explicit port must NOT match a known pool.
    EXPECT_TRUE(findKnownPoolByUrl("pool.dragonx.is:9999") == nullptr);
}

// Schema-aware pool hashrate parsing (the two pools speak different APIs).
void testPoolHashrateParsing()
{
    using namespace dragonx::util;
    bool ok = false;

    // pool.dragonx.is custom schema: pools.dragonx.hashrate
    const std::string isJson =
        R"({"pools":{"dragonx":{"hashrate":27670.14,"workerCount":12}},"global":{"hashrate":30000.0}})";
    double hr = parsePoolHashrate(PoolStatsSchema::DragonXIs, isJson, "", ok);
    EXPECT_TRUE(ok);
    EXPECT_NEAR(hr, 27670.14, 0.01);

    // Miningcore schema: the requested pool id selects the entry (not just the first).
    const std::string ccJson =
        R"({"pools":[)"
        R"({"id":"dragonx-solo","poolStats":{"poolHashrate":88780.0}},)"
        R"({"id":"dragonx-pplns","poolStats":{"poolHashrate":1585.9}}]})";
    hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok);
    EXPECT_TRUE(ok);
    EXPECT_NEAR(hr, 1585.9, 0.01);
    hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-solo", ok);
    EXPECT_TRUE(ok);
    EXPECT_NEAR(hr, 88780.0, 0.01);

    // Missing pool id / malformed / wrong-schema input all fail closed (no silent 0).
    parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok);
    EXPECT_FALSE(ok);
    parsePoolHashrate(PoolStatsSchema::DragonXIs, "not json", "", ok);
    EXPECT_FALSE(ok);
    parsePoolHashrate(PoolStatsSchema::Miningcore, "{}", "dragonx-pplns", ok);
    EXPECT_FALSE(ok);
    parsePoolHashrate(PoolStatsSchema::DragonXIs, ccJson, "", ok);  // .cc JSON, .is parser
    EXPECT_FALSE(ok);
}

// Schema-aware pool fee parsing (fed to the mining-tab "N% fee" display).
void testPoolFeeParsing()
{
    using namespace dragonx::util;
    bool ok = false;

    // pool.dragonx.is custom schema: pools.dragonx.poolFee (a whole-percent number).
    const std::string isJson =
        R"({"pools":{"dragonx":{"hashrate":27670.14,"poolFee":1,"soloFee":3}}})";
    double fee = parsePoolFee(PoolStatsSchema::DragonXIs, isJson, "", ok);
    EXPECT_TRUE(ok);
    EXPECT_NEAR(fee, 1.0, 0.001);

    // Fractional fees survive (display rounds, but the parse must not).
    const std::string isFrac = R"({"pools":{"dragonx":{"poolFee":1.5}}})";
    fee = parsePoolFee(PoolStatsSchema::DragonXIs, isFrac, "", ok);
    EXPECT_TRUE(ok);
    EXPECT_NEAR(fee, 1.5, 0.001);

    // Miningcore schema: the requested pool id's poolFeePercent.
    const std::string ccJson =
        R"({"pools":[)"
        R"({"id":"dragonx-solo","poolFeePercent":2.0,"poolStats":{"poolHashrate":88780.0}},)"
        R"({"id":"dragonx-pplns","poolFeePercent":0.9,"poolStats":{"poolHashrate":1585.9}}]})";
    fee = parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok);
    EXPECT_TRUE(ok);
    EXPECT_NEAR(fee, 0.9, 0.001);

    // Missing field / malformed / wrong-schema input all fail closed (caller keeps
    // the compile-time fallback rather than showing a bogus 0%).
    parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"hashrate":1.0}}})", "", ok);
    EXPECT_FALSE(ok);   // no poolFee key
    parsePoolFee(PoolStatsSchema::DragonXIs, "not json", "", ok);
    EXPECT_FALSE(ok);
    parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok);
    EXPECT_FALSE(ok);
    parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"poolFee":"1"}}})", "", ok);
    EXPECT_FALSE(ok);   // string, not number
}

// The effective pool list = official pools ∪ saved favorites ∪ current custom pool,
// endpoint-deduped, with synthetic user rows flagged official=false.
void testEffectivePools()
{
    using namespace dragonx::util;
    const int base = (int)knownPools().size();

    // Current pool is the official one, nothing saved -> just the official pools.
    auto a = effectivePools("pool.dragonx.is:3433", {});
    EXPECT_EQ((int)a.size(), base);

    // A custom current pool (neither official nor saved) appears as an extra row.
    auto b = effectivePools("my.pool.example:3333", {});
    EXPECT_EQ((int)b.size(), base + 1);
    const KnownPool* custom = findPoolByUrl(b, "my.pool.example:3333");
    EXPECT_TRUE(custom != nullptr);
    EXPECT_FALSE(custom->official);
    EXPECT_TRUE(custom->feePercent < 0.0);   // unknown fee

    // Saved pools are appended; an official one among them and a duplicate collapse.
    auto c = effectivePools("pool.dragonx.is:3433",
                            {"pool.dragonx.is:3433", "alt.pool:1", "alt.pool:1"});
    EXPECT_EQ((int)c.size(), base + 1);
    EXPECT_TRUE(findPoolByUrl(c, "alt.pool:1") != nullptr);

    // Current pool equal to a saved one is not listed twice.
    auto d = effectivePools("alt.pool:1", {"alt.pool:1"});
    EXPECT_EQ((int)d.size(), base + 1);

    // Blank/whitespace URLs are ignored (no phantom rows).
    auto e = effectivePools("   ", {"", "  "});
    EXPECT_EQ((int)e.size(), base);
}

// Fee formatting: whole numbers stay clean, fractional fees keep their decimals.
void testFormatFeePercent()
{
    using dragonx::ui::FormatFeePercent;
    EXPECT_TRUE(FormatFeePercent(1.0)  == "1");
    EXPECT_TRUE(FormatFeePercent(0.0)  == "0");
    EXPECT_TRUE(FormatFeePercent(3.0)  == "3");
    EXPECT_TRUE(FormatFeePercent(1.5)  == "1.5");
    EXPECT_TRUE(FormatFeePercent(0.9)  == "0.9");
    EXPECT_TRUE(FormatFeePercent(1.25) == "1.25");
    EXPECT_TRUE(FormatFeePercent(2.50) == "2.5");   // trailing zero trimmed
    EXPECT_TRUE(FormatFeePercent(100.0) == "100");
}

// Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe.
void testPoolWeightedSelection()
{
    using namespace dragonx::util;
    std::mt19937 rng(12345u);  // fixed seed -> deterministic

    std::vector<PoolHashrate> pools = {
        {"big",   100000.0, true},  // large -> low weight
        {"small",   1000.0, true},  // small -> high weight (favored)
    };
    int smallCount = 0, bigCount = 0;
    for (int i = 0; i < 2000; ++i) {
        const std::string pick = chooseWeightedPool(pools, /*currentId=*/"", rng);
        if (pick == "small") ++smallCount;
        else if (pick == "big") ++bigCount;
    }
    EXPECT_TRUE(smallCount > bigCount * 5);  // ~100:1 by weight

    // Only one usable pool -> always chosen; no usable pool -> keep current.
    std::vector<PoolHashrate> one = {{"a", 5.0, true}, {"b", 1.0, false}};
    EXPECT_EQ(chooseWeightedPool(one, "", rng), std::string("a"));
    std::vector<PoolHashrate> none = {{"a", 5.0, false}, {"b", 1.0, false}};
    EXPECT_EQ(chooseWeightedPool(none, "keepme", rng), std::string("keepme"));

    // Incumbent stickiness: with equal hashrates the current pool wins the majority.
    std::vector<PoolHashrate> equal = {{"x", 1000.0, true}, {"y", 1000.0, true}};
    int stay = 0;
    for (int i = 0; i < 2000; ++i)
        if (chooseWeightedPool(equal, "x", rng) == "x") ++stay;
    EXPECT_TRUE(stay > 1000);  // >50% thanks to kIncumbentStayBias
}

// HushChat crypto core: seed-derived identity + secretstream encrypt/decrypt round-trip.
// Pass featureEnabled=true so the crypto is exercised even in the default (chat-OFF) build.
void testHushChatCrypto()
{
    using namespace dragonx::chat;

    ChatKeyPair alice, bob;
    ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-wallet-secret", alice, /*featureEnabled=*/true);
    ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-wallet-secret", bob, /*featureEnabled=*/true);
    EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready);
    EXPECT_TRUE(rb.status == ChatIdentityStatus::Ready);
    EXPECT_EQ((int)ra.public_key_hex.size(), 64);
    EXPECT_TRUE(ra.public_key_hex != rb.public_key_hex);

    // Deterministic: the same secret always yields the same identity.
    ChatKeyPair alice2;
    ChatIdentityResult ra2 = deriveChatIdentityFromSecret("alice-wallet-secret", alice2, true);
    EXPECT_EQ(ra.public_key_hex, ra2.public_key_hex);

    // Feature gate: featureEnabled=false yields FeatureDisabled (and error_name matches).
    ChatKeyPair off;
    ChatIdentityResult roff = deriveChatIdentityFromSecret("x", off, false);
    EXPECT_TRUE(roff.status == ChatIdentityStatus::FeatureDisabled);
    EXPECT_EQ(std::string(roff.error_name), std::string("FeatureDisabled"));
    // Empty secret is rejected.
    ChatKeyPair none;
    EXPECT_TRUE(deriveChatIdentityFromSecret("", none, true).status == ChatIdentityStatus::SecretUnavailable);

    // Round-trip: Alice (server role) encrypts to Bob; Bob (client role) decrypts from Alice.
    std::string headerHex, cipherHex;
    ChatCryptoStatus es = encryptOutgoing(alice, rb.public_key_hex, "hello bob \xF0\x9F\x90\x89", headerHex, cipherHex);
    EXPECT_TRUE(es == ChatCryptoStatus::Ok);
    EXPECT_EQ((int)headerHex.size(), 48);  // 24-byte secretstream header as hex

    std::string plain;
    ChatCryptoStatus ds = decryptIncoming(bob, ra.public_key_hex, headerHex, cipherHex, plain);
    EXPECT_TRUE(ds == ChatCryptoStatus::Ok);
    EXPECT_EQ(plain, std::string("hello bob \xF0\x9F\x90\x89"));

    // Empty plaintext round-trips symmetrically (the crypto layer imposes no content policy).
    std::string emptyHeader, emptyCipher, emptyPlain;
    EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "", emptyHeader, emptyCipher) == ChatCryptoStatus::Ok);
    EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, emptyHeader, emptyCipher, emptyPlain) == ChatCryptoStatus::Ok);
    EXPECT_EQ(emptyPlain, std::string(""));

    // Tampered ciphertext fails the auth tag (neutral DecryptFailed, not a crash).
    std::string tampered = cipherHex;
    tampered.back() = (tampered.back() == '0') ? '1' : '0';
    std::string plain2;
    EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, tampered, plain2) != ChatCryptoStatus::Ok);

    // Wrong recipient key can't derive the session and/or fails auth.
    std::string plain3;
    ChatKeyPair mallory;
    deriveChatIdentityFromSecret("mallory-secret", mallory, true);
    EXPECT_TRUE(decryptIncoming(mallory, ra.public_key_hex, headerHex, cipherHex, plain3) != ChatCryptoStatus::Ok);

    // Malformed inputs are rejected without touching the tag path.
    std::string plain4;
    EXPECT_TRUE(decryptIncoming(bob, "not-hex", headerHex, cipherHex, plain4) == ChatCryptoStatus::BadPeerKey);
    EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, "00", plain4) == ChatCryptoStatus::CiphertextTooShort);
}

// End-to-end receive path: identity -> encrypt -> HushChat memo pair -> parser harvest
// (extractHushChatTransactionMetadata now carries e/p/ciphertext, Phase 1 Step 1) -> decrypt.
void testHushChatReceivePath()
{
    using namespace dragonx::chat;

    ChatKeyPair alice, bob;
    ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-recv", alice, true);
    ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-recv", bob, true);
    EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready);
    EXPECT_TRUE(rb.status == ChatIdentityStatus::Ready);

    // Alice encrypts to Bob, wrapped as a HushChat header memo + payload memo.
    std::string e, ct;
    EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "gm bob", e, ct) == ChatCryptoStatus::Ok);
    std::string headerMemo = std::string("{\"cid\":\"conv-42\",\"e\":\"") + e +
        "\",\"h\":1,\"p\":\"" + ra.public_key_hex +
        "\",\"t\":\"Memo\",\"v\":0,\"z\":\"zs-alice-reply\"}";

    HushChatTransactionInput tx;
    tx.txid = "txid-recv-1";
    tx.outputs.push_back(HushChatMemoOutput{0, headerMemo});
    tx.outputs.push_back(HushChatMemoOutput{1, ct});

    HushChatTransactionExtractionResult ext = extractHushChatTransactionMetadata(tx, true);
    EXPECT_TRUE(ext.feature_enabled);
    EXPECT_EQ((int)ext.metadata.size(), 1);
    const HushChatTransactionMetadata& m = ext.metadata[0];
    EXPECT_TRUE(m.type == HushChatHeaderType::Message);
    EXPECT_EQ(m.sender_public_key_hex, ra.public_key_hex);
    EXPECT_EQ(m.secretstream_header_hex, e);
    EXPECT_EQ(m.payload_memo, ct);

    // Bob decrypts straight from the carried metadata — the whole receive path.
    std::string plain;
    EXPECT_TRUE(decryptIncoming(bob, m.sender_public_key_hex, m.secretstream_header_hex,
                                m.payload_memo, plain) == ChatCryptoStatus::Ok);
    EXPECT_EQ(plain, std::string("gm bob"));

    // Feature disabled -> the harvest yields nothing.
    HushChatTransactionExtractionResult off = extractHushChatTransactionMetadata(tx, false);
    EXPECT_TRUE(!off.feature_enabled);
    EXPECT_EQ((int)off.metadata.size(), 0);
}

// ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store.
void testHushChatService()
{
    using namespace dragonx::chat;

    ChatKeyPair alice, bob;
    ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-svc", alice, true);
    ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-svc", bob, true);

    // Two Alice->Bob messages in one conversation, as harvested metadata.
    std::vector<HushChatTransactionMetadata> batch;
    for (int i = 0; i < 2; ++i) {
        std::string e, ct;
        EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, i == 0 ? "first" : "second", e, ct) == ChatCryptoStatus::Ok);
        HushChatTransactionMetadata m;
        m.txid = std::string("tx") + std::to_string(i);
        m.type = HushChatHeaderType::Message;
        m.conversation_id = "conv-x";
        m.reply_zaddr = "zs-alice";
        m.sender_public_key_hex = ra.public_key_hex;
        m.secretstream_header_hex = e;
        m.payload_memo = ct;
        m.payload_position = 1;
        batch.push_back(m);
    }

    ChatService svc;
    EXPECT_TRUE(!svc.hasIdentity());
    EXPECT_EQ(svc.ingest(batch, {}), 0);  // no identity -> nothing ingested

    svc.setIdentity(bob);
    EXPECT_TRUE(svc.hasIdentity());
    EXPECT_EQ(svc.ingest(batch, {}, 1000), 2);
    EXPECT_EQ((int)svc.store().size(), 2);
    EXPECT_EQ(svc.ingest(batch, {}, 1000), 0);  // re-scan dedups
    EXPECT_EQ((int)svc.store().size(), 2);

    std::vector<ChatMessage> conv = svc.store().conversation("conv-x");
    EXPECT_EQ((int)conv.size(), 2);
    EXPECT_EQ(conv[0].body, std::string("first"));
    EXPECT_EQ(conv[1].body, std::string("second"));
    EXPECT_TRUE(conv[0].direction == ChatDirection::Incoming);
    EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex);
    EXPECT_EQ(conv[0].timestamp, (std::int64_t)1000);

    // A contact request carries plaintext through without decryption.
    std::vector<HushChatTransactionMetadata> creq(1);
    creq[0].txid = "txc";
    creq[0].type = HushChatHeaderType::ContactRequest;
    creq[0].conversation_id = "conv-y";
    creq[0].sender_public_key_hex = ra.public_key_hex;
    creq[0].payload_memo = "hi, add me";
    creq[0].payload_position = 1;
    EXPECT_EQ(svc.ingest(creq, {}), 1);
    std::vector<ChatMessage> cy = svc.store().conversation("conv-y");
    EXPECT_EQ((int)cy.size(), 1);
    EXPECT_TRUE(cy[0].kind == ChatMessageKind::ContactRequest);
    EXPECT_EQ(cy[0].body, std::string("hi, add me"));

    // Wrong identity can't decrypt -> messages dropped silently.
    ChatService svc2;
    ChatKeyPair mallory;
    deriveChatIdentityFromSecret("mallory-svc", mallory, true);
    svc2.setIdentity(mallory);
    EXPECT_EQ(svc2.ingest(batch, {}, 1000), 0);
    EXPECT_TRUE(svc2.store().empty());
}

// Phase 2: persistent, seed-encrypted store — round-trip, dedup, per-wallet isolation, and
// ChatService write-through + reload (reload needs only the storage key, not the chat identity).
void testHushChatDatabase()
{
    using namespace dragonx::chat;
    namespace fs = std::filesystem;

    const std::string dbPath  = (fs::temp_directory_path() / "drgx_chat_db_test.sqlite").string();
    const std::string dbPath2 = (fs::temp_directory_path() / "drgx_chat_db_test2.sqlite").string();
    auto scrub = [](const std::string& p) {
        fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm");
    };
    scrub(dbPath); scrub(dbPath2);

    const std::string seedA = "wallet A seed phrase words here";
    const std::string seedB = "a completely different wallet B seed";

    auto makeMsg = [](const std::string& txid, std::size_t pos, const std::string& cid,
                      const std::string& body, ChatMessageKind kind, std::int64_t ts) {
        ChatMessage m;
        m.direction = ChatDirection::Incoming;
        m.kind = kind;
        m.txid = txid;
        m.conversation_id = cid;
        m.peer_zaddr = "zs-peer";
        m.peer_public_key_hex = "deadbeef";
        m.body = body;
        m.timestamp = ts;
        m.payload_position = pos;
        return m;
    };

    // Locked DB is inert; unlock, then write three (with a duplicate that is ignored).
    {
        ChatDatabase db(dbPath);
        EXPECT_TRUE(!db.hasKey());
        EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false);
        EXPECT_TRUE(db.unlockWithSecret(seedA));
        EXPECT_TRUE(db.hasKey());
        EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)));
        EXPECT_TRUE(db.append(makeMsg("t2", 1, "c", "world", ChatMessageKind::Message, 222)));
        EXPECT_TRUE(db.append(makeMsg("t3", 0, "c", "add me", ChatMessageKind::ContactRequest, 333)));
        EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false); // dup
    }

    // Reopen with the SAME seed → messages persist, fields + order intact.
    {
        ChatDatabase db(dbPath);
        EXPECT_TRUE(db.unlockWithSecret(seedA));
        std::vector<ChatMessage> all = db.load();
        EXPECT_EQ((int)all.size(), 3);
        EXPECT_EQ(all[0].body, std::string("hello"));
        EXPECT_EQ(all[0].txid, std::string("t1"));
        EXPECT_EQ(all[0].timestamp, (std::int64_t)111);
        EXPECT_EQ(all[1].body, std::string("world"));
        EXPECT_TRUE(all[2].kind == ChatMessageKind::ContactRequest);
        EXPECT_EQ(all[2].body, std::string("add me"));
        EXPECT_EQ(all[2].peer_public_key_hex, std::string("deadbeef"));
    }

    // A DIFFERENT seed is partitioned + can't decrypt → sees nothing, writes in isolation.
    {
        ChatDatabase db(dbPath);
        EXPECT_TRUE(db.unlockWithSecret(seedB));
        EXPECT_TRUE(db.load().empty());
        EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "B-secret", ChatMessageKind::Message, 999)));
        EXPECT_EQ((int)db.load().size(), 1);
    }
    // ...and wallet A still sees exactly its three.
    {
        ChatDatabase db(dbPath);
        EXPECT_TRUE(db.unlockWithSecret(seedA));
        EXPECT_EQ((int)db.load().size(), 3);
        db.lock();
        EXPECT_TRUE(!db.hasKey());
        EXPECT_TRUE(db.load().empty());  // inert once locked
    }

    // End-to-end: ChatService write-through on ingest, then reload into a fresh service WITHOUT an
    // identity (proves stored messages are decryptable with the storage key alone).
    {
        ChatKeyPair alice, bob;
        ChatIdentityResult ra = deriveChatIdentityFromSecret("db-alice", alice, true);
        ChatIdentityResult rb = deriveChatIdentityFromSecret("db-bob", bob, true);
        std::string e, ct;
        EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "persisted!", e, ct) == ChatCryptoStatus::Ok);
        HushChatTransactionMetadata m;
        m.txid = "txp"; m.type = HushChatHeaderType::Message; m.conversation_id = "cp";
        m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex;
        m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1;
        std::vector<HushChatTransactionMetadata> batch{m};
        std::unordered_map<std::string, std::int64_t> times{{"txp", 4242}};

        {
            ChatDatabase db(dbPath2);
            EXPECT_TRUE(db.unlockWithSecret("e2e-seed"));
            ChatService svc;
            svc.setPersistence(&db);
            svc.setIdentity(bob);
            EXPECT_EQ(svc.ingest(batch, times), 1);
        }
        {
            ChatDatabase db(dbPath2);
            EXPECT_TRUE(db.unlockWithSecret("e2e-seed"));
            ChatService svc;
            svc.setPersistence(&db);
            svc.loadFromDatabase();  // no setIdentity() — reload doesn't need it
            std::vector<ChatMessage> conv = svc.store().conversation("cp");
            EXPECT_EQ((int)conv.size(), 1);
            EXPECT_EQ(conv[0].body, std::string("persisted!"));
            EXPECT_EQ(conv[0].timestamp, (std::int64_t)4242);
        }
    }

    // Delivery status persists (a failed outgoing echo round-trips as Failed).
    {
        ChatDatabase db(dbPath);
        EXPECT_TRUE(db.unlockWithSecret("delivery-seed"));
        ChatMessage m = makeMsg("tD", 1, "cD", "not-sent one", ChatMessageKind::Message, 555);
        m.direction = ChatDirection::Outgoing;
        m.delivery = ChatDelivery::Failed;
        EXPECT_TRUE(db.append(m));
        std::vector<ChatMessage> loaded = db.load();
        EXPECT_EQ((int)loaded.size(), 1);
        EXPECT_TRUE(loaded[0].delivery == ChatDelivery::Failed);
    }

    scrub(dbPath); scrub(dbPath2);
}

// Per-conversation local delete: (1) "delete, revive on new message" tombstones the removed messages so
// a chain re-scan can't re-import them — but a NEW txid in the same conversation flows through; the
// tombstone survives a DB reload. (2) "delete & block" removes the rows WITHOUT a tombstone and relies on
// a blocked-cid predicate to drop messages while blocked; unblocking re-imports the conversation.
void testHushChatDeleteConversation()
{
    using namespace dragonx::chat;
    namespace fs = std::filesystem;

    const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_del_test.sqlite").string();
    auto scrub = [](const std::string& p) { fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm"); };
    scrub(dbPath);

    ChatKeyPair alice, bob;
    ChatIdentityResult ra = deriveChatIdentityFromSecret("del-alice", alice, true);
    ChatIdentityResult rb = deriveChatIdentityFromSecret("del-bob", bob, true);

    // Build one incoming (alice->bob) metadata entry. encryptOutgoing produces a fresh ciphertext each
    // call, but dedup/tombstone key off (txid, position) only, so re-using the same txids re-scans them.
    auto mkMeta = [&](const std::string& txid, const std::string& cid, const std::string& body) {
        std::string e, ct;
        EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, body, e, ct) == ChatCryptoStatus::Ok);
        HushChatTransactionMetadata m;
        m.txid = txid; m.type = HushChatHeaderType::Message; m.conversation_id = cid;
        m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex;
        m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1;
        return m;
    };
    std::vector<HushChatTransactionMetadata> convX{ mkMeta("dx1", "conv-x", "x-one"),
                                                    mkMeta("dx2", "conv-x", "x-two") };
    std::vector<HushChatTransactionMetadata> convY{ mkMeta("dy1", "conv-y", "y-one") };

    // (1) Delete with revive-on-new-message.
    {
        ChatDatabase db(dbPath);
        EXPECT_TRUE(db.unlockWithSecret("del-seed"));
        ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db);
        EXPECT_EQ(svc.ingest(convX, {}, 100), 2);
        EXPECT_EQ(svc.ingest(convY, {}, 100), 1);
        EXPECT_EQ((int)svc.store().size(), 3);

        EXPECT_TRUE(svc.deleteConversation("conv-x", /*block=*/false));
        EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0);
        EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1);   // untouched

        EXPECT_EQ(svc.ingest(convX, {}, 100), 0);                       // re-scan: tombstoned, not re-imported
        EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0);

        std::vector<HushChatTransactionMetadata> convXnew{ mkMeta("dx3", "conv-x", "x-three") };
        EXPECT_EQ(svc.ingest(convXnew, {}, 200), 1);                    // NEW txid revives the thread
        EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1);
        EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three"));
    }

    // (2) Tombstone survives a DB reload into a fresh service.
    {
        ChatDatabase db(dbPath);
        EXPECT_TRUE(db.unlockWithSecret("del-seed"));
        ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db);
        svc.loadFromDatabase();
        EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1);   // only the revived dx3 persisted
        EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three"));
        EXPECT_EQ(svc.ingest(convX, {}, 100), 0);                       // dx1/dx2 still tombstoned after reload
        EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1);
    }

    // (3) Delete & block, then unblock.
    {
        ChatDatabase db(dbPath);
        EXPECT_TRUE(db.unlockWithSecret("del-seed"));
        ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db);
        svc.loadFromDatabase();
        bool blocked = false;
        svc.setBlockedPredicate([&](const std::string& cid) { return blocked && cid == "conv-y"; });

        EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1);
        blocked = true;
        EXPECT_TRUE(svc.deleteConversation("conv-y", /*block=*/true));
        EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0);
        EXPECT_EQ(svc.ingest(convY, {}, 100), 0);                       // suppressed by the predicate (no tombstone)
        EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0);

        blocked = false;                                               // unblock
        EXPECT_EQ(svc.ingest(convY, {}, 100), 1);                       // re-imported from chain
        EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1);
        EXPECT_EQ(svc.store().conversation("conv-y")[0].body, std::string("y-one"));
    }

    scrub(dbPath);
}

// Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and
// ChatService compose/recordOutgoing echoes into the store.
void testHushChatOutgoing()
{
    using namespace dragonx::chat;

    ChatKeyPair alice, bob;
    ChatIdentityResult ra = deriveChatIdentityFromSecret("out-alice", alice, true);
    ChatIdentityResult rb = deriveChatIdentityFromSecret("out-bob", bob, true);
    const std::string aliceZ = "zs-alice-reply";
    const std::string bobZ = "zs-bob";
    const std::string cid = "conv-out";

    // Encrypted message Alice -> Bob, then round-trip through the receive path.
    OutgoingChatMemos memos;
    EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid,
                                     "hello bob", memos) == ChatComposeStatus::Ok);
    EXPECT_EQ(memos.recipientZaddr, bobZ);
    EXPECT_TRUE(!memos.headerMemo.empty() && memos.headerMemo.front() == '{');
    // Header keys serialize alphabetically (cid before e).
    EXPECT_TRUE(memos.headerMemo.find("\"cid\"") < memos.headerMemo.find("\"e\""));

    HushChatTransactionInput tx;
    tx.txid = "txout1";
    tx.outputs.push_back({0, memos.headerMemo});   // header at the lower position
    tx.outputs.push_back({1, memos.payloadMemo});
    auto extracted = extractHushChatTransactionMetadata(tx, true);
    EXPECT_EQ((int)extracted.metadata.size(), 1);

    ChatService bobSvc;
    bobSvc.setIdentity(bob);
    // Realistic receive-time fallback (the header "ts" is a real std::time stamp; an artificially-tiny
    // fallback would trip the future-plausibility clamp in ingest and reject the real ts).
    EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, static_cast<std::int64_t>(std::time(nullptr))), 1);
    std::vector<ChatMessage> conv = bobSvc.store().conversation(cid);
    EXPECT_EQ((int)conv.size(), 1);
    EXPECT_EQ(conv[0].body, std::string("hello bob"));
    EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex);  // Bob learns Alice's key
    EXPECT_EQ(conv[0].peer_zaddr, aliceZ);
    // The header now carries the sender's compose time (header "ts"); ingest prefers it over the fallback
    // (5) so both ends show the same send time. A real std::time() stamp is well past year-2001.
    EXPECT_TRUE(conv[0].timestamp > 1000000000);

    // Plaintext contact request Alice -> Bob.
    OutgoingChatMemos creq;
    EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "conv-cr", "add me?",
                                            creq) == ChatComposeStatus::Ok);
    HushChatTransactionInput tx2;
    tx2.txid = "txout2";
    tx2.outputs.push_back({0, creq.headerMemo});
    tx2.outputs.push_back({1, creq.payloadMemo});
    auto ex2 = extractHushChatTransactionMetadata(tx2, true);
    EXPECT_EQ((int)ex2.metadata.size(), 1);
    EXPECT_TRUE(ex2.metadata[0].type == HushChatHeaderType::ContactRequest);
    bobSvc.ingest(ex2.metadata, {}, 6);
    EXPECT_EQ((int)bobSvc.store().conversation("conv-cr").size(), 1);
    EXPECT_EQ(bobSvc.store().conversation("conv-cr")[0].body, std::string("add me?"));

    // Validation guards.
    OutgoingChatMemos dummy;
    EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, "tooshort", bobZ, cid, "x", dummy)
                == ChatComposeStatus::BadPeerKey);
    EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid, "", dummy)
                == ChatComposeStatus::EmptyBody);
    EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "c", "{bad", dummy)
                == ChatComposeStatus::BadRequestText);

    // ChatService compose + recordOutgoing echo.
    OutgoingChatMemos svcMemos;
    EXPECT_TRUE(bobSvc.composeMessage(bobZ, ra.public_key_hex, aliceZ, cid, "reply!", svcMemos)
                == ChatComposeStatus::Ok);
    ChatMessage echo;
    echo.direction = ChatDirection::Outgoing;
    echo.kind = ChatMessageKind::Message;
    echo.conversation_id = cid;
    echo.peer_zaddr = aliceZ;
    echo.peer_public_key_hex = ra.public_key_hex;
    echo.body = "reply!";
    // A reply is sent AFTER the received message; the incoming "hello bob" now carries a real compose-time
    // "ts" (~now), so stamp the echo just after it (not the old artificial 7, which would sort before it).
    echo.timestamp = static_cast<std::int64_t>(std::time(nullptr)) + 10;
    echo.txid = "out:local1";
    echo.payload_position = 0;
    EXPECT_TRUE(bobSvc.recordOutgoing(echo));
    std::vector<ChatMessage> conv2 = bobSvc.store().conversation(cid);
    EXPECT_EQ((int)conv2.size(), 2);
    EXPECT_TRUE(conv2.back().direction == ChatDirection::Outgoing);
    EXPECT_EQ(conv2.back().body, std::string("reply!"));

    // Future-clock clamp: a header "ts" implausibly ahead of the receive time is rejected (falls back to
    // the receive time), so a wrong/ahead-clocked peer can't pin their messages to the bottom of a thread.
    {
        const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
        const std::int64_t futureTs = nowSec + 10 * 24 * 3600;  // 10 days ahead — well past the 1h tolerance
        nlohmann::json h;
        h["h"] = 1; h["v"] = 0; h["z"] = aliceZ; h["cid"] = "conv-clamp";
        h["t"] = "Cont"; h["e"] = ""; h["p"] = ra.public_key_hex; h["ts"] = futureTs;
        HushChatTransactionInput ctx;
        ctx.txid = "txclamp";
        ctx.outputs.push_back({0, h.dump()});      // header at the lower position
        ctx.outputs.push_back({1, "please add"});  // contact-request plaintext payload (must not start with '{')
        auto exc = extractHushChatTransactionMetadata(ctx, true);
        EXPECT_EQ((int)exc.metadata.size(), 1);
        EXPECT_EQ(exc.metadata[0].sent_at, futureTs);          // the future ts parses through
        bobSvc.ingest(exc.metadata, {}, nowSec);               // realistic receive time
        auto cc = bobSvc.store().conversation("conv-clamp");
        EXPECT_EQ((int)cc.size(), 1);
        EXPECT_EQ(cc[0].timestamp, nowSec);                    // clamped to receive time, NOT the future ts
    }
}

// Phase 5: the transport encoding (chatSendOutputs) — header first, "utf8:" for full-node / raw for
// lite — and that the on-chain form (after the daemon strips "utf8:") round-trips through receive.
void testHushChatTransport()
{
    using namespace dragonx::chat;

    ChatKeyPair alice, bob;
    ChatIdentityResult ra = deriveChatIdentityFromSecret("tx-alice", alice, true);
    ChatIdentityResult rb = deriveChatIdentityFromSecret("tx-bob", bob, true);
    OutgoingChatMemos memos;
    EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex,
                                     "zs-bob", "cid-tx", "over the wire", memos) == ChatComposeStatus::Ok);

    // Full-node: header at index 0, both to the peer, each "utf8:"-prefixed.
    std::array<ChatSendOutput, 2> fn = chatSendOutputs(memos, /*utf8Prefix=*/true);
    EXPECT_EQ(fn[0].address, std::string("zs-bob"));
    EXPECT_EQ(fn[1].address, std::string("zs-bob"));
    EXPECT_TRUE(fn[0].memo.rfind("utf8:", 0) == 0);
    EXPECT_TRUE(fn[1].memo.rfind("utf8:", 0) == 0);
    EXPECT_EQ(fn[0].memo, std::string("utf8:") + memos.headerMemo);
    EXPECT_EQ(fn[1].memo, std::string("utf8:") + memos.payloadMemo);

    // Lite: raw, no prefix.
    std::array<ChatSendOutput, 2> lt = chatSendOutputs(memos, /*utf8Prefix=*/false);
    EXPECT_EQ(lt[0].memo, memos.headerMemo);
    EXPECT_EQ(lt[1].memo, memos.payloadMemo);

    // On-chain form round-trips: the daemon stores the UTF-8 text (the string after "utf8:"), which
    // is exactly what the harvest parser reads back (header first / lower position).
    auto strip = [](const std::string& m) { return m.rfind("utf8:", 0) == 0 ? m.substr(5) : m; };
    HushChatTransactionInput tx;
    tx.txid = "txwire";
    tx.outputs.push_back({0, strip(fn[0].memo)});
    tx.outputs.push_back({1, strip(fn[1].memo)});
    auto extracted = extractHushChatTransactionMetadata(tx, true);
    EXPECT_EQ((int)extracted.metadata.size(), 1);
    ChatService bobSvc;
    bobSvc.setIdentity(bob);
    EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 9), 1);
    EXPECT_EQ(bobSvc.store().conversation("cid-tx")[0].body, std::string("over the wire"));
}

// Phase 5: the full-node daemon SHUFFLES the two memo outputs, so the header may land at a HIGHER
// note position than its payload. The receive parser must pair them regardless of on-chain order.
void testHushChatShuffledReceive()
{
    using namespace dragonx::chat;

    ChatKeyPair alice, bob;
    ChatIdentityResult ra = deriveChatIdentityFromSecret("shuf-alice", alice, true);
    ChatIdentityResult rb = deriveChatIdentityFromSecret("shuf-bob", bob, true);
    ChatService bobSvc;
    bobSvc.setIdentity(bob);

    // Payload BEFORE header (positions swapped, as a shuffle may produce).
    OutgoingChatMemos memos;
    EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex,
                                     "zs-bob", "cid-shuf", "shuffled hi", memos) == ChatComposeStatus::Ok);
    HushChatTransactionInput tx;
    tx.txid = "txshuf";
    tx.outputs.push_back({0, memos.payloadMemo});   // payload at the LOWER position
    tx.outputs.push_back({1, memos.headerMemo});    // header at the HIGHER position
    auto extracted = extractHushChatTransactionMetadata(tx, true);
    EXPECT_EQ((int)extracted.metadata.size(), 1);
    EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 3), 1);
    EXPECT_EQ(bobSvc.store().conversation("cid-shuf")[0].body, std::string("shuffled hi"));

    // With a change output (empty memo) interspersed and the header last, still pairs.
    OutgoingChatMemos memos2;
    EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex,
                                     "zs-bob", "cid-shuf2", "with change", memos2) == ChatComposeStatus::Ok);
    HushChatTransactionInput tx2;
    tx2.txid = "txshuf2";
    tx2.outputs.push_back({0, std::string()});       // change output: empty memo (ignored)
    tx2.outputs.push_back({1, memos2.payloadMemo});  // payload
    tx2.outputs.push_back({2, memos2.headerMemo});   // header last
    auto ex2 = extractHushChatTransactionMetadata(tx2, true);
    EXPECT_EQ((int)ex2.metadata.size(), 1);
    EXPECT_EQ(bobSvc.ingest(ex2.metadata, {}, 4), 1);
    EXPECT_EQ(bobSvc.store().conversation("cid-shuf2")[0].body, std::string("with change"));
}

} // namespace

// Per-wallet address-book scoping (multi-wallet P1): global vs wallet-scoped contacts, the
// legacy-entry migration, and scope-aware de-duplication.
void testAddressBookScope()
{
    using dragonx::data::AddressBook;
    using dragonx::data::AddressBookEntry;

    // --- Pure visibility logic (drives the contacts-tab filter) ---
    AddressBookEntry g("Alice", "zs1alice", "", "global");
    AddressBookEntry wA("Bob", "zs1bob", "", "hashA");
    AddressBookEntry legacy; legacy.scope = "";        // legacy entry (no scope) => global
    EXPECT_TRUE(g.isGlobal());
    EXPECT_TRUE(!wA.isGlobal());
    EXPECT_TRUE(legacy.isGlobal());
    EXPECT_TRUE(g.visibleInWallet("hashA"));           // global shows everywhere
    EXPECT_TRUE(g.visibleInWallet("hashB"));
    EXPECT_TRUE(wA.visibleInWallet("hashA"));          // wallet-scoped shows only in its wallet
    EXPECT_TRUE(!wA.visibleInWallet("hashB"));
    EXPECT_TRUE(legacy.visibleInWallet("anything"));

    // --- Round-trip + migration + scope-aware dedup, isolated under a temp $HOME ---
    const char* oldHome = std::getenv("HOME");
    std::string savedHome = oldHome ? oldHome : "";
    fs::path tmp = fs::temp_directory_path() /
                   ("obsidian_ab_test_" + std::to_string((long long)::time(nullptr)));
    fs::create_directories(tmp);
    setenv("HOME", tmp.string().c_str(), 1);

    // A pre-scoping addressbook.json (no "scope" field) migrates to global on load. Write to the
    // SAME per-variant config dir AddressBook::load() reads from (Lite -> ObsidianDragonLite/),
    // resolved under the temp HOME set above. Hardcoding ".config/ObsidianDragon" made the lite-build
    // ctest write where load() never looks -> 0 entries -> the entries()[0] below segfaulted.
    fs::path cfg = dragonx::util::Platform::getConfigDir();
    fs::create_directories(cfg);
    std::ofstream(cfg / "addressbook.json")
        << R"({"entries":[{"label":"Legacy","address":"zs1legacy","notes":""}]})";
    {
        AddressBook book;
        EXPECT_TRUE(book.load());
        EXPECT_EQ(book.size(), (size_t)1);
        // Guard the [0] access — EXPECT_EQ doesn't abort this harness, so an empty book here must not
        // SIGSEGV the whole suite (it would take every later test down with it).
        EXPECT_TRUE(!book.entries().empty() && book.entries()[0].isGlobal());  // migrated -> global
    }

    // Same address may be a contact in two DIFFERENT wallets, but not twice in one; and a global
    // entry collides with any wallet-scoped one for the same address (they'd show together).
    {
        AddressBook book;
        book.load();  // has the migrated global "Legacy"
        EXPECT_TRUE(book.addEntry(AddressBookEntry("BobA", "zs1bob", "", "walletA")));
        EXPECT_TRUE(book.addEntry(AddressBookEntry("BobB", "zs1bob", "", "walletB")));   // diff wallet: ok
        EXPECT_TRUE(!book.addEntry(AddressBookEntry("BobA2", "zs1bob", "", "walletA")));  // same wallet: dup
        EXPECT_TRUE(!book.addEntry(AddressBookEntry("BobG", "zs1bob", "", "global")));    // global collides
        EXPECT_TRUE(!book.hasVisibleDuplicate("zs1novel", "walletA"));                    // novel address: ok
    }

    // Scope survives save + reload.
    {
        AddressBook reopened;
        reopened.load();
        int ia = -1, ib = -1;
        for (size_t i = 0; i < reopened.size(); ++i) {
            const auto& e = reopened.entries()[i];
            if (e.address == "zs1bob" && e.scope == "walletA") ia = (int)i;
            if (e.address == "zs1bob" && e.scope == "walletB") ib = (int)i;
        }
        EXPECT_TRUE(ia >= 0);   // both wallet-scoped Bobs round-tripped
        EXPECT_TRUE(ib >= 0);
    }

    if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME");
    std::error_code ec; fs::remove_all(tmp, ec);
}

// Wallet-files metadata index (multi-wallet P1b): upsert change-detection, find, extra folders,
// and save/reload round-trip.
void testWalletIndex()
{
    using dragonx::data::WalletIndex;
    using dragonx::data::WalletIndexEntry;

    // upsert change-detection + find + folders (in-memory)
    {
        WalletIndex idx;
        WalletIndexEntry a; a.fileName = "wallet.dat"; a.cachedBalance = 1.5; a.cachedAddressCount = 3;
        EXPECT_TRUE(idx.upsert(a));                       // new -> changed
        EXPECT_TRUE(!idx.upsert(a));                      // identical -> no change (skips save)
        a.cachedBalance = 2.0;
        EXPECT_TRUE(idx.upsert(a));                       // value changed -> changed
        EXPECT_TRUE(idx.find("wallet.dat") != nullptr);
        EXPECT_NEAR(idx.find("wallet.dat")->cachedBalance, 2.0, 1e-9);
        EXPECT_TRUE(idx.find("missing.dat") == nullptr);
        WalletIndexEntry b; b.fileName = "wallet-2.dat";
        idx.upsert(b);
        EXPECT_EQ(idx.entries().size(), (size_t)2);
        EXPECT_TRUE(idx.addExtraFolder("/some/dir"));
        EXPECT_TRUE(!idx.addExtraFolder("/some/dir"));    // duplicate rejected
        EXPECT_TRUE(idx.removeExtraFolder("/some/dir"));
        EXPECT_TRUE(!idx.removeExtraFolder("/some/dir"));  // already gone
    }

    // save + reload round-trip under a temp $HOME
    {
        const char* oldHome = std::getenv("HOME");
        std::string savedHome = oldHome ? oldHome : "";
        fs::path tmp = fs::temp_directory_path() /
                       ("obsidian_wi_test_" + std::to_string((long long)::time(nullptr)));
        fs::create_directories(tmp);
        setenv("HOME", tmp.string().c_str(), 1);

        {
            WalletIndex idx;
            idx.load();
            WalletIndexEntry a; a.fileName = "wallet.dat"; a.displayName = "Main";
            a.cachedBalance = 12.34; a.cachedAddressCount = 7; a.walletIdentityHash = "hashX";
            a.lastOpenedEpoch = 1720000000; a.sizeBytesAtLastOpen = 98304; a.syncedHere = true;
            idx.upsert(a);
            idx.addExtraFolder("/other/wallets");
            EXPECT_TRUE(idx.save());
        }
        {
            WalletIndex idx;
            EXPECT_TRUE(idx.load());
            const auto* w = idx.find("wallet.dat");
            EXPECT_TRUE(w != nullptr);
            EXPECT_EQ(w->displayName, std::string("Main"));
            EXPECT_EQ(w->cachedAddressCount, (long long)7);
            EXPECT_EQ(w->walletIdentityHash, std::string("hashX"));
            EXPECT_TRUE(w->syncedHere);
            EXPECT_NEAR(w->cachedBalance, 12.34, 1e-9);
            EXPECT_EQ(idx.extraFolders().size(), (size_t)1);
        }

        if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME");
        std::error_code ec2; fs::remove_all(tmp, ec2);
    }
}

int main()
{
    testConnectionConfig();
    testPaymentUri();
    testAmountFormatting();
    testSpendableFiltering();
    testRefreshScheduler();
    testNetworkRefreshService();
    testNetworkRefreshSnapshotHelpers();
    testParseDaemonRescanOutput();
    testNetworkRefreshRpcCollectors();
    testNetworkRefreshResultModels();
    testOperationStatusPollParsing();
    testSecureVaultScope();
    testWalletSecurityController();
    testWalletSecurityWorkflow();
    testWalletSecurityWorkflowExecutor();
    testDaemonShutdownPolicy();
    testDatadirLockGate();
#ifndef _WIN32
    testExecFailureReported();
    testDaemonCrashDetected();
#endif
    testPlatformEnsureDirectory();
    testVerifySaplingParams();
    testConnectHasStalled();
    testIsLocalHost();
    testAllowsPlaintextRemote();
    testConsoleSecretRedaction();
    testNodeStatusBanner();
    testStalenessBadge();
    testNotificationHistory();
    testSeedMigrationResume();
    testLoggerFileSink();
    testDaemonLifecycleExecution();
    testDaemonLifecycleAdapters();
    testConsoleTextLayout();
    testConsoleModel();
    testPortfolioHelpers();
    testMarketSeries();
    testExchangeCandles();
    testWalletStateAddressListRebuild();
    testConsoleFoldSpans();
    testConsoleScrollController();
    testConsoleSelectionController();
    testRendererHelpers();
    testConsoleInputModel();
    testMiningBenchmarkModel();
    testBalanceAddressListModel();
    testExplorerBlockCache();
    testTransactionHistoryCache();
    testTransactionHistoryCachePrunesOldWallets();
    testAddressBookScope();
    testWalletIndex();
    testTransactionHistoryCacheRefreshApply();
    testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
    testLiteBridgeOwnedStringClassifiesNullWithoutFree();
    testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce();
    testLiteBridgeOwnedStringMovePreventsDoubleFree();
    testLiteClientBridgeUsesRuntimeOwnedStringCleanup();
    testLiteBackendInjectableFakeBridge();
    testLiteWalletControllerLifecycle();
    testLiteWalletControllerOpenFailover();
    testLiteWalletControllerAsyncLifecycleFailover();
    testLiteWalletControllerConsoleCommand();
    testLiteWalletControllerM4();
    testLiteWalletControllerM5Persistence();
    testLiteWalletControllerEncryption();
    testLiteWalletControllerCreateEncryptsWithPassphrase();
    testLiteWalletControllerAsyncAppliesPassphrase();
    testLiteChainNameMigration();
    testLiteRefreshModelAppliesToWalletState();
    testLiteSendShowsRecipientFromOutgoing();
    testLitePartialRefreshKeepsPriorAddressBalances();
    testLitePerAddressBalances();
    testLiteWalletControllerNewAddress();
    testLiteSyncStatusParserRealShapes();
    testLiteWalletControllerRefreshPopulatesState();
    testLiteWalletGatewayRefreshSkipsFailedCommand();
    testLiteWalletControllerWorkerProducesModel();
    testLiteWalletControllerShutdownDoesNotHangDuringSync();
    testLiteRolloutVersionCompare();
    testLiteRolloutBucketAndOverrideHelpers();
    testLiteRolloutPolicyOverridesAndKillSwitch();
    testLiteRolloutPolicyManifestGates();
    testLiteRolloutPolicyStagedRollout();
    testLiteRolloutManifestLoader();
    testLiteWalletControllerRolloutGate();
    testXmrigReleaseParsing();
    testXmrigAssetSelection();
    testXmrigChecksumParsing();
    testXmrigSha256AndBasenames();
    testXmrigSignatureAssetSelection();
    testXmrigPinnedKeyValidity();
    testXmrigSignatureVerify();
    testXmrigReleaseListParsing();
    testDaemonReleaseParsing();
    testDaemonAssetSelection();
    testDaemonChecksumParsing();
    testDaemonBasenamesAndVersionCore();
    testDaemonSignatureAssetSelection();
    testDaemonPinnedKeyValidity();
    testDaemonSignatureInterop();
    testDaemonReleaseListParsing();
    testLiteServerHostParsing();
    testLiteOfficialServerDetection();
    testPoolRegistryLookup();
    testPoolHashrateParsing();
    testPoolFeeParsing();
    testEffectivePools();
    testFormatFeePercent();
    testPoolWeightedSelection();
    testAtomicFileWrite();
    testHushChatCrypto();
    testHushChatReceivePath();
    testHushChatService();
    testHushChatDatabase();
    testHushChatDeleteConversation();
    testHushChatOutgoing();
    testHushChatTransport();
    testHushChatShuffledReceive();
    testWalletFileProbe();
    testAddressChecksumValidation();
    testPrivateKeyImportRecognition();
    testSeedPhraseHelpers();
    testBlockDbOutputDiagnosis();
    testLiteServerProbeLive();
    testXmrigLiveInstall();
    testGeneratedResourceBehavior();

    if (g_failures != 0) {
        std::cerr << g_failures << " test assertion(s) failed\n";
        return 1;
    }
    std::cout << "Focused service tests passed\n";
    return 0;
}
