// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3

#include "lite_wallet_controller.h"

#include "lite_diagnostics.h"
#include "lite_result_parsers.h"
#include "../data/wallet_state.h"

#include <algorithm>
#include <chrono>
#include <cstdint>
#include <unordered_map>
#include <utility>

#include <nlohmann/json.hpp>
#include <sodium.h>

namespace dragonx {
namespace wallet {

namespace {
constexpr double kZatoshisPerCoin = 100000000.0;  // DRGX has 1e8 zatoshis per coin

// A lightwalletd open error that means "server is up but still warming up" (JSON-RPC -28 /
// "Activating best chain", "Loading"/"Verifying"/"Rescanning" phases) rather than a dead server.
// Such a server will be ready shortly, so the caller should retry it soon.
bool liteOpenErrorIsWarmup(const std::string& error)
{
    const auto has = [&error](const char* s) { return error.find(s) != std::string::npos; };
    return has("-28") || has("Activating best chain") || has("warming up") ||
           has("Loading block") || has("Verifying blocks") || has("Rescanning");
}

// Extract a backend {"error":..} message (string or arbitrary JSON) into a plain string.
std::string extractJsonError(const nlohmann::json& value)
{
    const auto& e = value.at("error");
    return e.is_string() ? e.get<std::string>() : e.dump();
}

// Parse a send/shield response: success is {"txid":".."}, failure is {"error":".."} (NOT an
// "Error:"-prefixed string), and malformed args make the backend return plain-text help.
LiteBroadcastResult parseBroadcastResponse(const LiteBridgeStringResult& bridgeCall)
{
    LiteBroadcastResult out;
    if (!bridgeCall.ok) {
        out.error = bridgeCall.error.empty() ? "backend call failed" : bridgeCall.error;
        return out;
    }
    try {
        const auto j = nlohmann::json::parse(bridgeCall.value);
        if (j.is_object()) {
            if (j.contains("error")) {
                out.error = extractJsonError(j);
                return out;
            }
            if (j.contains("txid") && j.at("txid").is_string()) {
                out.txid = j.at("txid").get<std::string>();
                out.ok = !out.txid.empty();
                if (!out.ok) out.error = "backend returned an empty txid";
                return out;
            }
        }
    } catch (...) {
        // Non-JSON (e.g. the command's plain-text help on bad args) -> ambiguous error below.
    }
    // The bridge call itself succeeded but the response is unrecognizable, so we genuinely
    // can't tell whether the tx was broadcast. Use cautious wording (don't claim a hard
    // failure) so the user verifies in Transactions before retrying — avoiding a double-spend.
    out.error = "Transaction status could not be confirmed — check Transactions before retrying";
    return out;
}

// The backend does not auto-save after send/shield. Persist the new transaction now so it
// survives a restart; retry once on a transient failure (disk lock, etc.).
bool persistAfterBroadcast(LiteClientBridge& bridge)
{
    for (int attempt = 0; attempt < 2; ++attempt) {
        if (bridge.execute("save", "").ok) return true;
    }
    // Persistent failure: the spent note will be re-derived from the chain on the next sync, so this
    // is a robustness gap, not fund loss. Log it (W5-1) — both callers discard this return, so the
    // failure was previously completely silent.
    liteLog("save failed after send/shield — the wallet will re-derive it on the next sync");
    return false;
}

// Build the JSON-array send payload and broadcast it. litelib_execute passes the whole args
// string as ONE argument (no whitespace splitting), so send MUST use the JSON-array form
// ([{address,amount,memo},..]); the space-separated CLI form would never parse.
LiteBroadcastResult doSend(LiteClientBridge& bridge, const LiteSendRequest& request)
{
    LiteBroadcastResult out;
    if (request.recipients.empty()) {
        out.error = "no recipients";
        return out;
    }
    nlohmann::json arr = nlohmann::json::array();
    for (const auto& r : request.recipients) {
        if (r.address.empty()) {
            out.error = "recipient address is empty";
            return out;
        }
        nlohmann::json o;
        o["address"] = r.address;
        o["amount"] = r.amountZatoshis;  // zatoshis (puposhis)
        if (!r.memo.empty()) o["memo"] = r.memo;
        arr.push_back(std::move(o));
    }
    auto result = parseBroadcastResponse(bridge.execute("send", arr.dump()));
    if (result.ok) persistAfterBroadcast(bridge);
    return result;
}

LiteBroadcastResult doShield(LiteClientBridge& bridge, const std::string& optionalAddress)
{
    // Empty address -> shield all transparent funds; otherwise shield to the given address.
    auto result = parseBroadcastResponse(bridge.execute("shield", optionalAddress));
    if (result.ok) persistAfterBroadcast(bridge);  // shield does not auto-save either
    return result;
}
}  // namespace

void secureWipeLiteSecret(std::string& secret)
{
    if (!secret.empty()) {
        sodium_memzero(&secret[0], secret.size());
    }
    secret.clear();
}

void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
                                        dragonx::WalletState& state)
{
    if (model.hasBalance) {
        state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
        state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
        state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
        state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin;
        // Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed
        // (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend
        // validation isn't zeroed on lite (the actual lite spend gate is per-address, set below).
        state.spendablePrivateBalance = state.privateBalance;
        state.spendableTransparentBalance = state.transparentBalance;
        state.spendableTotalBalance = state.totalBalance;
    }

    if (model.hasAddresses) {
        // Per-address balances from unspent notes/utxos (when the notes command succeeded).
        // Sum the value of confirmed, unspent outputs at each address: skip anything spent,
        // unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
        // the per-address figures match the confirmed balance the wallet treats as available.
        std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
        if (model.hasSpendableOutputs) {
            for (const auto& output : model.spendableOutputs) {
                if (output.spent || output.unconfirmedSpent || output.pending) continue;
                perAddressZatoshis[output.address] += output.valueZatoshis;
            }
        }

        // If the notes/utxo command failed this cycle (a tolerated partial refresh), we don't
        // actually know per-address balances. Preserve the previously displayed ones instead
        // of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
        // like fund loss and breaks "send from this address".
        std::unordered_map<std::string, double> priorBalances;
        if (!model.hasSpendableOutputs) {
            for (const auto& a : state.addresses) priorBalances[a.address] = a.balance;
        }

        state.addresses.clear();
        state.z_addresses.clear();
        state.t_addresses.clear();
        for (const auto& addr : model.addresses) {
            AddressInfo info;
            info.address = addr.address;
            const auto it = perAddressZatoshis.find(addr.address);
            if (it != perAddressZatoshis.end()) {
                info.balance = static_cast<double>(it->second) / kZatoshisPerCoin;
            } else if (!model.hasSpendableOutputs) {
                const auto pit = priorBalances.find(addr.address);  // keep last-known on notes failure
                info.balance = pit != priorBalances.end() ? pit->second : 0.0;
            } else {
                info.balance = 0.0;  // notes succeeded and address has no spendable outputs
            }
            info.spendableBalance = info.balance;  // lite per-address balance is already confirmed/spendable
            info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
            info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
            if (addr.kind == LiteWalletAppAddressKind::Shielded) {
                state.z_addresses.push_back(info);
            } else {
                state.t_addresses.push_back(info);
            }
            state.addresses.push_back(std::move(info));
        }
    }

    if (model.hasTransactions) {
        state.transactions.clear();
        const int64_t chainHeight =
            model.hasSyncStatus ? static_cast<int64_t>(model.sync.chainHeight) : 0;
        for (const auto& record : model.transactions) {
            TransactionInfo tx;
            tx.txid = record.txid;
            if (record.kind == LiteWalletAppTransactionKind::Send) {
                tx.type = "send";
            } else if (record.kind == LiteWalletAppTransactionKind::Receive) {
                tx.type = "receive";
            } else {
                tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
            }
            tx.amount = static_cast<double>(record.amountZatoshis) / kZatoshisPerCoin;
            tx.timestamp = record.timestamp;
            tx.address = record.address;
            tx.memo = record.memo;
            // For a Send the recipient address/memo live in outgoingOutputs — the top-level
            // address/memo are only filled for Receives. Surface the first recipient so the
            // list shows the destination + memo instead of blanks (single-recipient case).
            if (tx.type == "send" && tx.address.empty() && !record.outgoingOutputs.empty()) {
                tx.address = record.outgoingOutputs.front().address;
                if (tx.memo.empty()) tx.memo = record.outgoingOutputs.front().memo;
            }
            if (record.unconfirmed || !record.blockHeight.has_value() || chainHeight == 0) {
                tx.confirmations = record.unconfirmed ? 0 : 1;
            } else {
                const int64_t confs = chainHeight - *record.blockHeight + 1;
                tx.confirmations = confs > 0 ? static_cast<int>(confs) : 0;
            }
            state.transactions.push_back(std::move(tx));
        }
    }

    if (model.hasSyncStatus) {
        state.sync.blocks = static_cast<int>(model.sync.walletHeight);
        state.sync.headers = static_cast<int>(model.sync.chainHeight);
        state.sync.verification_progress = model.sync.progress;
        state.sync.syncing = !model.sync.complete;
    }

    if (model.hasEncryptionStatus) {
        // Reflect backend encryption state so isLocked()/isEncrypted() gate the UI correctly.
        state.encrypted = model.encrypted;
        state.locked = model.locked;
    }
}

LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
                                          LiteConnectionSettings connectionSettings,
                                          LiteClientBridge bridge,
                                          LiteWalletControllerOptions options)
    : bridge_(std::make_shared<LiteClientBridge>(std::move(bridge))),
      chainName_(connectionSettings.chainName),
      connectionSettings_(connectionSettings),
      capabilities_(capabilities),
      lifecycleOptions_{options.allowBridgeCalls, options.rolloutBlocked, options.rolloutMessage},
      lifecycle_(capabilities, connectionSettings, bridge_.get(),
                 LiteWalletLifecycleOptions{options.allowBridgeCalls,
                                            options.rolloutBlocked, options.rolloutMessage}),
      gateway_(capabilities, connectionSettings, bridge_.get(),
               LiteWalletGatewayOptions{options.allowBridgeCalls}),
      sync_(capabilities, connectionSettings, bridge_.get(),
            LiteSyncServiceOptions{options.allowBridgeCalls})
{
    status_ = lifecycle_.status();
}

LiteWalletController::~LiteWalletController()
{
    stopWorker();  // joins the fast poll worker (short iterations)
    // Best-effort flush on shutdown: the mempool monitor's unconfirmed updates aren't persisted
    // by the backend (sync/send/shield already save inline). Guarded by syncDone_ and no in-flight
    // broadcast so we never block shutdown waiting on the wallet lock held by an uninterruptible
    // scan or an in-progress send proving.
    if (walletOpen_.load() && syncDone_->load() && !broadcastInProgress() && bridge_) {
        bridge_->execute("save", "");
    }
    // The sync thread may be blocked in an uninterruptible full scan; detach it. It holds
    // shared refs (bridge_ + syncDone_), so it stays safe and the bridge survives until it
    // finishes — the process is exiting, so a late litelib_shutdown is harmless.
    if (syncThread_.joinable()) syncThread_.detach();
    // Likewise the broadcast thread (send/shield proving): it captures shared refs (bridge +
    // running flag + result slot), never `this`, so detaching is safe.
    if (broadcastThread_.joinable()) broadcastThread_.detach();
    // The interactive-console command thread follows the same shared-lifetime pattern.
    if (consoleThread_.joinable()) consoleThread_.detach();
    // The async-open failover thread captures only shared refs (bridge + running flag + result
    // slot), never `this`, so detaching is safe if it's still trying servers at shutdown.
    if (openThread_.joinable()) openThread_.detach();
    // The async full-lifecycle thread builds its own local lifecycle service from captured value
    // copies + the shared bridge (never `this`), so detaching is likewise safe.
    if (lifecycleThread_.joinable()) lifecycleThread_.detach();
}

std::unique_ptr<LiteWalletController> LiteWalletController::createLinked(
    WalletCapabilities capabilities,
    LiteConnectionSettings connectionSettings,
    LiteRolloutDecision rollout)
{
    return std::make_unique<LiteWalletController>(
        capabilities,
        std::move(connectionSettings),
        LiteClientBridge::linkedSdxl(),
        LiteWalletControllerOptions{true, !rollout.allowed, rollout.message});
}

void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result)
{
    status_ = result.status;
    const std::string op = liteWalletLifecycleOperationName(result.operation);
    if (result.walletReady) {
        liteLog(op + ": wallet ready");
        walletOpen_ = true;
        if (persist_) persist_();
        startSync();    // begin background sync on the backend
        startWorker();  // begin periodic refresh -> WalletState (via takeRefreshedModel)
    } else if (result.attempted) {
        liteLog(op + " failed: " +
                (result.error.empty() ? result.status.message : result.error));
    }
}

std::vector<std::string> LiteWalletController::failoverServerUrls() const
{
    std::vector<std::string> urls;
    const auto add = [&urls](const std::string& url) {
        if (!isLiteServerUrlUsable(url)) return;
        if (std::find(urls.begin(), urls.end(), url) == urls.end()) urls.push_back(url);
    };
    // Preferred server first (honours the user's sticky/random selection), then every other
    // usable configured server as a fallback so one dead server can't strand the wallet.
    const auto selected = selectLiteServer(connectionSettings_);
    if (selected.ok) add(selected.server.url);
    for (const auto& endpoint : connectionSettings_.servers) {
        if (endpoint.enabled) add(endpoint.url);
    }
    return urls;
}

bool LiteWalletController::beginOpenExisting() { return beginAsyncLifecycle(/*create=*/false); }
bool LiteWalletController::beginCreateWallet() { return beginAsyncLifecycle(/*create=*/true); }

bool LiteWalletController::beginAsyncLifecycle(bool create)
{
    // Don't race a Settings-page async lifecycle request (create/open/restore) on the same bridge.
    if (walletOpen_.load() || openRunning_->load() || lifecycleRunning_->load()) return false;
    const char* verb = create ? "Create" : "Open";
    if (lifecycle_.availability() != LiteWalletLifecycleAvailability::Ready) {
        const std::string& reason = lifecycle_.status().message;
        status_ = WalletBackendStatus{WalletBackendState::Error,
            reason.empty() ? "lite wallet is not available" : reason, {}, {}, 0.0};
        lastOpenError_ = status_.message;
        liteLog(std::string(verb) + " blocked: " + lastOpenError_);
        return false;
    }
    auto servers = failoverServerUrls();
    if (servers.empty()) {
        status_ = WalletBackendStatus{WalletBackendState::Error,
            "no usable lite servers are configured", {}, {}, 0.0};
        lastOpenError_ = status_.message;
        liteLog(std::string(verb) + " blocked: " + lastOpenError_);
        return false;
    }

    if (openThread_.joinable()) openThread_.join();  // a prior attempt has fully finished
    {
        std::lock_guard<std::mutex> lk(*openResultMutex_);
        openResult_->reset();
    }
    openRunning_->store(true);
    status_ = WalletBackendStatus{WalletBackendState::Connecting,
                                  create ? "creating wallet" : "opening wallet", {}, {}, 0.0};
    liteLog(std::string(create ? "Creating wallet — trying " : "Opening wallet — trying ") +
            std::to_string(servers.size()) + " server(s)");

    // Capture only shared refs + value copies (never `this`) so the thread can safely outlive us.
    auto bridge = bridge_;
    auto running = openRunning_;
    auto resultMutex = openResultMutex_;
    auto resultSlot = openResult_;
    openThread_ = std::thread([bridge, servers, running, resultMutex, resultSlot, create]() {
        OpenOutcome outcome;
        std::string preferredError;  // error from the FIRST (preferred) server — the actionable one
        for (const auto& url : servers) {
            if (!bridge) break;
            liteLog("  connecting to " + url + " ...");
            // create: initialize_new generates a NEW seed+wallet, but contacts the server FIRST,
            // so an unreachable server fails before writing any file — safe to try the next.
            // open: initialize_existing just loads the file. ok && non-empty value == ready.
            auto call = create ? bridge->initializeNew(/*dangerous=*/false, url)
                               : bridge->initializeExisting(/*dangerous=*/false, url);
            const bool ready = call.ok && !call.value.empty();
            // create's response IS the secret seed — wipe our copy; it's read back via exportSeed().
            if (create) secureWipeLiteSecret(call.value);
            if (ready) {
                liteLog("  " + url + ": connected");
                outcome.ok = true;
                outcome.serverUrl = url;
                break;
            }
            const std::string why = call.error.empty() ? "unreachable" : call.error;
            liteLog("  " + url + ": " + why);
            if (!call.error.empty()) {
                if (preferredError.empty()) preferredError = call.error;  // keep the preferred one
                if (liteOpenErrorIsWarmup(call.error)) outcome.warming = true;  // healthy, just starting
            }
        }
        // On total failure, report the preferred server's error (not whichever broken fallback
        // happened to be tried last), since that's the server the user expects to use.
        if (!outcome.ok)
            outcome.error = preferredError.empty() ? "could not reach any lite server" : preferredError;
        {
            std::lock_guard<std::mutex> lk(*resultMutex);
            *resultSlot = outcome;
        }
        running->store(false);
    });
    return true;
}

void LiteWalletController::pumpAsyncOpen()
{
    OpenOutcome outcome;
    {
        std::lock_guard<std::mutex> lk(*openResultMutex_);
        if (!openResult_->has_value()) return;  // nothing finished since last pump
        outcome = std::move(openResult_->value());
        openResult_->reset();
    }
    if (openThread_.joinable()) openThread_.join();  // producer set its result, then exits

    if (outcome.ok) {
        walletOpen_ = true;
        lastOpenError_.clear();
        lastOpenWarming_ = false;
        status_ = WalletBackendStatus{WalletBackendState::Ready, "wallet open", {}, {}, 0.0};
        liteLog("Wallet ready via " + outcome.serverUrl);
        if (persist_) persist_();
        startSync();    // begin background sync on the backend
        startWorker();  // begin periodic refresh -> WalletState
    } else {
        lastOpenError_ = outcome.error;
        lastOpenWarming_ = outcome.warming;  // a healthy server was warming up -> retry sooner
        status_ = WalletBackendStatus{WalletBackendState::Error, outcome.error, {}, {}, 0.0};
        liteLog("Open failed: " + outcome.error +
                (outcome.warming ? "  (a server is warming up — will retry shortly)" : ""));
    }
}

bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request)
{
    auto req = std::make_shared<LiteWalletCreateRequest>(std::move(request));
    // Encrypt the brand-new wallet with the user's passphrase once it opens. The async worker
    // can't (walletOpen_ isn't set until pumpLifecycleResult runs), so stash it and apply on the
    // main-thread finalize — otherwise the passphrase is discarded and the seed stored in
    // plaintext (audit C1). Mirrors the synchronous createWallet() wrapper.
    stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt);
    const bool started = beginLifecycleRequestAsync(
        "Create",
        [req](LiteWalletLifecycleService& svc, const std::string& url) {
            req->serverUrl = url;
            return svc.createWallet(*req);
        },
        [req]() { secureWipeLiteSecret(req->passphrase); });
    if (!started) clearPendingLifecycleSecurity();
    return started;
}

bool LiteWalletController::beginOpenWalletAsync(LiteWalletOpenRequest request)
{
    auto req = std::make_shared<LiteWalletOpenRequest>(std::move(request));
    // An existing wallet may be encrypted+locked — unlock it with the supplied passphrase on the
    // main-thread finalize (the async worker can't; walletOpen_ isn't set yet). Without this an
    // encrypted wallet opens but stays locked and the passphrase is never verified (audit M1).
    stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Unlock);
    const bool started = beginLifecycleRequestAsync(
        "Open",
        [req](LiteWalletLifecycleService& svc, const std::string& url) {
            req->serverUrl = url;
            return svc.openWallet(*req);
        },
        [req]() { secureWipeLiteSecret(req->passphrase); });
    if (!started) clearPendingLifecycleSecurity();
    return started;
}

bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest request)
{
    auto req = std::make_shared<LiteWalletRestoreRequest>(std::move(request));
    // Encrypt the restored wallet with the user's passphrase once it opens (same rationale and
    // main-thread finalize as create — audit C1).
    stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt);
    const bool started = beginLifecycleRequestAsync(
        "Restore",
        [req](LiteWalletLifecycleService& svc, const std::string& url) {
            req->serverUrl = url;
            return svc.restoreWallet(*req);
        },
        [req]() {
            secureWipeLiteSecret(req->seedPhrase);
            secureWipeLiteSecret(req->passphrase);
        });
    if (!started) clearPendingLifecycleSecurity();
    return started;
}

bool LiteWalletController::beginLifecycleRequestAsync(
    const char* verb,
    std::function<LiteWalletLifecycleResult(LiteWalletLifecycleService&, const std::string&)> exec,
    std::function<void()> wipeSecrets)
{
    // Reject (and still wipe the caller's secrets) if a wallet is already open or any async
    // open/lifecycle attempt is in flight.
    if (walletOpen_.load() || lifecycleRunning_->load() || openRunning_->load()) {
        if (wipeSecrets) wipeSecrets();
        return false;
    }
    auto servers = failoverServerUrls();
    if (servers.empty()) {
        if (wipeSecrets) wipeSecrets();
        status_ = WalletBackendStatus{WalletBackendState::Error,
            "no usable lite servers are configured", {}, {}, 0.0};
        lastOpenError_ = status_.message;
        liteLog(std::string(verb) + " blocked: " + lastOpenError_);
        return false;
    }

    if (lifecycleThread_.joinable()) lifecycleThread_.join();  // a prior attempt has fully finished
    {
        std::lock_guard<std::mutex> lk(*lifecycleResultMutex_);
        lifecycleResult_->reset();
    }
    lifecycleRunning_->store(true);
    status_ = WalletBackendStatus{WalletBackendState::Connecting,
                                  std::string(verb) + " wallet…", {}, {}, 0.0};
    liteLog(std::string(verb) + " wallet — trying " + std::to_string(servers.size()) + " server(s)");

    // Capture only value copies + the shared bridge (never `this`): the thread builds its own
    // local lifecycle service, so it can safely outlive the controller (mirrors the open thread).
    auto bridge = bridge_;
    auto caps = capabilities_;
    auto conn = connectionSettings_;
    auto opts = lifecycleOptions_;
    auto running = lifecycleRunning_;
    auto resultMutex = lifecycleResultMutex_;
    auto resultSlot = lifecycleResult_;
    lifecycleThread_ = std::thread(
        [bridge, caps, conn, opts, servers, exec, wipeSecrets, running, resultMutex, resultSlot]() {
            LiteWalletLifecycleService localLifecycle(caps, conn, bridge.get(), opts);
            LiteWalletLifecycleResult chosen;
            bool have = false;
            for (const auto& url : servers) {
                if (!bridge) break;
                liteLog("  connecting to " + url + " ...");
                LiteWalletLifecycleResult r = exec(localLifecycle, url);
                if (r.walletReady) {
                    liteLog("  " + url + ": ready");
                    chosen = std::move(r);
                    have = true;
                    break;
                }
                // A non-attempted result is a structural block (availability / validation): the same
                // for every server, so stop and surface it rather than retrying pointlessly.
                if (!r.attempted) {
                    chosen = std::move(r);
                    have = true;
                    break;
                }
                liteLog("  " + url + ": " + (r.error.empty() ? r.status.message : r.error));
                // Keep the PREFERRED (first) server's failure — the actionable one for the user —
                // rather than whichever fallback happened to be tried last.
                if (!have) {
                    chosen = std::move(r);
                    have = true;
                }
            }
            if (!have) {
                chosen.error = "could not reach any lite server";
                chosen.status = WalletBackendStatus{WalletBackendState::Error, chosen.error, {}, {}, 0.0};
            }
            if (wipeSecrets) wipeSecrets();  // wipe the request's secrets once the attempt is done
            {
                std::lock_guard<std::mutex> lk(*resultMutex);
                *resultSlot = std::move(chosen);
            }
            running->store(false);
        });
    return true;
}

void LiteWalletController::pumpLifecycleResult()
{
    LiteWalletLifecycleResult out;
    {
        std::lock_guard<std::mutex> lk(*lifecycleResultMutex_);
        if (!lifecycleResult_->has_value()) return;  // nothing finished since last pump
        out = std::move(lifecycleResult_->value());
        lifecycleResult_->reset();
    }
    if (lifecycleThread_.joinable()) lifecycleThread_.join();  // producer set its result, then exits

    // Finalize on the main thread: flip walletOpen()/status, persist, start sync/worker on success;
    // log the failure otherwise (shared with the synchronous lifecycle path).
    onLifecycleResult(out);
    if (out.walletReady) {
        // Wallet is now open (walletOpen_ set by onLifecycleResult) — apply the stashed passphrase:
        // encrypt a freshly created/restored wallet, or unlock an existing encrypted one (C1/M1).
        applyPendingLifecycleSecurity();
        lastOpenError_.clear();
        lastOpenWarming_ = false;
    } else {
        clearPendingLifecycleSecurity();  // wallet didn't open — drop the stashed passphrase
        lastOpenError_ = out.error.empty() ? out.status.message : out.error;
        lastOpenWarming_ = liteOpenErrorIsWarmup(lastOpenError_);
    }
    lastLifecycleResult_ = std::move(out);
}

void LiteWalletController::stashPendingLifecycleSecurity(const std::string& passphrase,
                                                         PendingSecurityOp op)
{
    clearPendingLifecycleSecurity();            // wipe any stale prior stash first
    pendingLifecyclePassphrase_ = passphrase;   // independent copy; wiped in apply/clear
    pendingLifecycleSecurityOp_ = op;
}

void LiteWalletController::applyPendingLifecycleSecurity()
{
    const PendingSecurityOp op = pendingLifecycleSecurityOp_;
    if (op == PendingSecurityOp::None || pendingLifecyclePassphrase_.empty() ||
        !walletOpen_.load() || !bridge_) {
        clearPendingLifecycleSecurity();
        return;
    }
    if (op == PendingSecurityOp::Encrypt) {
        // encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
        const auto enc = encryptWallet(pendingLifecyclePassphrase_);
        if (!enc.ok)
            liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
                    enc.error);
    } else if (op == PendingSecurityOp::Unlock) {
        if (!unlockWallet(pendingLifecyclePassphrase_))
            liteLog("wallet opened but unlock failed (wrong passphrase?)");
    }
    clearPendingLifecycleSecurity();
}

void LiteWalletController::clearPendingLifecycleSecurity()
{
    secureWipeLiteSecret(pendingLifecyclePassphrase_);  // no-op if already empty
    pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
}

void LiteWalletController::startSync()
{
    if (syncLaunched_.exchange(true)) return;
    syncStarted_ = true;
    liteLog("Background sync started");
    // The backend `sync` command is a blocking, uninterruptible full chain scan, so run it on
    // a detached thread. Capture shared refs (not the controller) so it is safe to outlive us.
    auto bridge = bridge_;
    auto done = syncDone_;
    syncThread_ = std::thread([bridge, done] {
        if (bridge) {
            bridge->execute("sync", "");  // blocks until synced (or errors out)
            // The backend does NOT auto-save after a sync, so persist the freshly-scanned wallet;
            // otherwise the next launch re-scans from the checkpoint (~30 min). Set `done` only
            // after the save so a syncComplete() observer sees a fully-persisted wallet.
            if (!bridge->execute("save", "").ok)   // W5-2: don't leave a failed post-sync save silent
                liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
        }
        done->store(true);
    });
}

bool LiteWalletController::startRescan()
{
    if (!walletOpen_.load()) return false;
    // Refuse if a sync/rescan is already running (would race two scans on the backend wallet lock).
    if (!syncDone_ || !syncDone_->load()) return false;

    // Reset the done flag so refreshModel() re-enters its "scanning, publish progress only" path
    // and the UI shows progress again; clearing it BEFORE launching the thread avoids a window
    // where the worker would query balances mid-rescan.
    syncDone_->store(false);
    syncStarted_ = true;
    liteLog("Block re-download (rescan) started");

    // The previous sync/rescan thread has finished (syncDone_ was true above); detach it before
    // reassigning syncThread_. Like startSync's thread it captures shared refs (bridge_ + syncDone_),
    // never `this`, so detaching is safe.
    if (syncThread_.joinable()) syncThread_.detach();

    auto bridge = bridge_;
    auto done = syncDone_;
    syncThread_ = std::thread([bridge, done] {
        if (bridge) {
            // `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the
            // birthday height — a blocking, uninterruptible full scan, same as `sync`.
            bridge->execute("rescan", "");
            if (!bridge->execute("save", "").ok)   // W5-2: don't leave a failed post-rescan save silent
                liteLog("save failed after rescan — the next launch will re-scan from the checkpoint");
        }
        done->store(true);
    });
    return true;
}

std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
{
    if (!walletOpen_.load()) return std::nullopt;

    // syncstatus is fast (reads shared state the sync thread updates). Poll it every time.
    const auto syncResult = sync_.pollSyncStatus(LiteSyncStatusRequest{});

    // Encryption status reads local wallet state (available even mid-sync); fold it into every
    // model so WalletState.isLocked()/isEncrypted() track the backend.
    const auto encStatus = encryptionStatus();
    auto applyEncryption = [&encStatus](LiteWalletAppRefreshModel& m) {
        if (encStatus.ok) {
            m.hasEncryptionStatus = true;
            m.encrypted = encStatus.encrypted;
            m.locked = encStatus.locked;
        }
    };

    if (!syncDone_->load()) {
        // Sync still running: publish progress only. Data queries (balance/list) would block
        // until the chain is synced, so don't issue them yet.
        if (!syncResult.ok) return std::nullopt;
        LiteWalletAppRefreshModel model;
        applyEncryption(model);
        model.hasSyncStatus = true;
        model.sync.walletHeight = syncResult.syncStatus.syncedBlocks;
        model.sync.chainHeight = syncResult.syncStatus.totalBlocks;
        // syncDone_ is authoritative: the detached sync thread is still running, so we are NOT
        // complete regardless of what syncstatus reports. The backend briefly returns the idle
        // shape ({"syncing":"false"} -> parser progress=1.0, complete=true) before the scan
        // starts publishing in-progress status; don't surface that as a misleading 100%/done.
        model.sync.complete = false;
        model.sync.progress = syncResult.syncStatus.complete ? 0.0 : syncResult.syncStatus.progress;
        return model;
    }

    // Synced: full refresh (balance/addresses/transactions are fast now).
    LiteWalletRefreshRequest request;
    if (syncResult.ok) {
        request.haveSyncStatus = true;
        request.syncStatus = syncResult.syncStatus;
    }
    const auto refreshResult = gateway_.refresh(request);
    if (refreshResult.bundle.successfulCommandCount == 0 && !request.haveSyncStatus) {
        return std::nullopt;
    }
    const auto mapped = mapLiteWalletRefreshResult(refreshResult);
    if (!mapped.ok) return std::nullopt;
    auto model = mapped.model;
    applyEncryption(model);

    // `syncstatus` only reports synced_blocks/total_blocks WHILE actively scanning; once idle it
    // returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
    // "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
    // surface it as the synced height/tip so the block count is correct at rest.
    if (bridge_) {
        const auto h = bridge_->execute("height", "");
        if (h.ok) {
            const auto parsed = parseLiteHeightResponse(h.value);
            if (parsed.ok && parsed.height.height > 0) {
                model.hasSyncStatus = true;
                model.sync.walletHeight = parsed.height.height;
                model.sync.chainHeight  = parsed.height.height;  // synced: wallet height == chain tip
                model.sync.complete     = true;
                model.sync.progress     = 1.0;
            }
        }
    }
    return model;
}

LiteNewAddressResult LiteWalletController::newAddress(bool shielded)
{
    LiteNewAddressResult out;
    if (!walletOpen_.load() || !bridge_) {
        out.error = "no wallet is open";
        return out;
    }
    // Backend address-type tokens: "zs" (shielded) / "R" (transparent) (do_new_address).
    const auto result = bridge_->execute("new", shielded ? "zs" : "R");
    if (!result.ok) {
        out.error = result.error.empty() ? "new address generation failed" : result.error;
        return out;
    }
    // Response is a JSON array with the new address, e.g. ["zs1..."].
    try {
        const auto parsed = nlohmann::json::parse(result.value);
        if (parsed.is_array() && !parsed.empty() && parsed[0].is_string()) {
            out.address = parsed[0].get<std::string>();
        } else if (parsed.is_string()) {
            out.address = parsed.get<std::string>();
        }
    } catch (...) {
        // fall through to the error below
    }
    out.ok = !out.address.empty();
    if (!out.ok) out.error = "could not parse new address response";
    return out;
}

LiteBroadcastResult LiteWalletController::sendTransactionBlocking(const LiteSendRequest& request)
{
    LiteBroadcastResult out;
    if (!walletOpen_.load() || !bridge_) {
        out.error = "no wallet is open";
        return out;
    }
    return doSend(*bridge_, request);
}

LiteBroadcastResult LiteWalletController::shieldFundsBlocking(const std::string& optionalAddress)
{
    LiteBroadcastResult out;
    if (!walletOpen_.load() || !bridge_) {
        out.error = "no wallet is open";
        return out;
    }
    return doShield(*bridge_, optionalAddress);
}

bool LiteWalletController::startBroadcast(std::function<LiteBroadcastResult()> op)
{
    bool expected = false;
    if (!broadcastRunning_->compare_exchange_strong(expected, true)) return false;  // one at a time
    {
        std::lock_guard<std::mutex> lock(*broadcastResultMutex_);
        broadcastResult_->reset();  // drop any already-consumed prior result
    }
    // A previous broadcast thread (already finished) may still be joinable; detach before reuse.
    if (broadcastThread_.joinable()) broadcastThread_.detach();

    auto running = broadcastRunning_;
    auto mutex = broadcastResultMutex_;
    auto slot = broadcastResult_;
    broadcastThread_ = std::thread([op = std::move(op), running, mutex, slot] {
        LiteBroadcastResult result = op();
        {
            std::lock_guard<std::mutex> lock(*mutex);
            *slot = std::move(result);
        }
        running->store(false);
    });
    return true;
}

bool LiteWalletController::sendTransaction(const LiteSendRequest& request)
{
    if (!walletOpen_.load() || !bridge_) return false;
    auto bridge = bridge_;  // shared copy; the op must not capture `this`
    return startBroadcast([bridge, request]() { return doSend(*bridge, request); });
}

bool LiteWalletController::shieldFunds(const std::string& optionalAddress)
{
    if (!walletOpen_.load() || !bridge_) return false;
    auto bridge = bridge_;
    return startBroadcast([bridge, optionalAddress]() { return doShield(*bridge, optionalAddress); });
}

bool LiteWalletController::takeBroadcastResult(LiteBroadcastResult& out)
{
    std::lock_guard<std::mutex> lock(*broadcastResultMutex_);
    if (!broadcastResult_->has_value()) return false;
    out = std::move(**broadcastResult_);
    broadcastResult_->reset();
    return true;
}

bool LiteWalletController::runConsoleCommand(std::string commandLine)
{
    if (!bridge_ || consoleRunning_->load()) return false;

    // Split into command (first token) + the remainder as a single arg string: litelib_execute
    // passes args through as ONE element (it does not whitespace-split), matching how send uses
    // the JSON-array form.
    const size_t cmdBegin = commandLine.find_first_not_of(" \t");
    if (cmdBegin == std::string::npos) return false;  // blank line
    const size_t cmdEnd = commandLine.find_first_of(" \t", cmdBegin);
    std::string command = commandLine.substr(
        cmdBegin, cmdEnd == std::string::npos ? std::string::npos : cmdEnd - cmdBegin);
    std::string args;
    if (cmdEnd != std::string::npos) {
        const size_t argBegin = commandLine.find_first_not_of(" \t", cmdEnd);
        if (argBegin != std::string::npos) args = commandLine.substr(argBegin);
    }

    if (consoleThread_.joinable()) consoleThread_.join();  // a prior command has fully finished
    {
        std::lock_guard<std::mutex> lk(*consoleResultMutex_);
        consoleResult_->reset();
    }
    consoleRunning_->store(true);

    // Capture only the shared bridge + flags/slot (never `this`) so the thread can outlive us.
    auto bridge = bridge_;
    auto running = consoleRunning_;
    auto mutex = consoleResultMutex_;
    auto slot = consoleResult_;
    consoleThread_ = std::thread(
        [bridge, command, args, echo = std::move(commandLine), running, mutex, slot]() {
            LiteConsoleResult r;
            r.command = echo;
            if (bridge) {
                const auto call = bridge->execute(command, args);
                r.ok = call.ok;
                r.response = call.ok ? call.value
                                     : (call.error.empty() ? "command failed" : call.error);
                // send/shield/import mutate wallet state and the backend does NOT auto-save (same reason
                // doSend/doShield call persistAfterBroadcast). Persist so a console-driven tx survives a
                // restart instead of only being re-derived on the next full sync. (M-02)
                if (call.ok && (command == "send" || command == "shield" ||
                                command == "import" || command == "timport")) {
                    persistAfterBroadcast(*bridge);
                }
            } else {
                r.response = "lite backend unavailable";
            }
            {
                std::lock_guard<std::mutex> lk(*mutex);
                *slot = std::move(r);
            }
            running->store(false);
        });
    return true;
}

bool LiteWalletController::takeConsoleResult(LiteConsoleResult& out)
{
    {
        std::lock_guard<std::mutex> lk(*consoleResultMutex_);
        if (!consoleResult_->has_value()) return false;
        out = std::move(**consoleResult_);
        consoleResult_->reset();
    }
    if (consoleThread_.joinable()) consoleThread_.join();  // producer set its result, then exits
    return true;
}

LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKey)
{
    LiteImportResult out;
    if (!walletOpen_.load() || !bridge_) {
        secureWipeLiteSecret(spendingOrViewingKey);
        out.error = "no wallet is open";
        return out;
    }
    if (spendingOrViewingKey.empty()) {
        out.error = "no key provided";
        return out;
    }
    // Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with
    // "secret-..." / viewing keys "zxview...", so this prefix check usually won't collide.
    // NB: the lite/SDXL backend's viewing key is an *extended full* viewing key ("zxviews…",
    // hrp_sapling_viewing_key), which is genuinely correct here — do NOT "harmonize" this with the
    // full node, whose z_importviewingkey takes an *incoming* viewing key ("zivks…") instead. The
    // two variants accept different viewing-key forms; a VK is not portable between them. Regardless,
    // the two-command fallback below means a mis-guessed prefix never rejects an otherwise-valid key.
    const char first = spendingOrViewingKey[0];
    const bool transparentFirst = (first == 'U' || first == '5' || first == 'K' || first == 'L');

    auto runImport = [&](const char* command) -> LiteImportResult {
        LiteImportResult r;
        const auto result = bridge_->execute(command, spendingOrViewingKey);
        if (!result.ok) {  // do_import_* failures come back "Error:"-prefixed (bridge -> ok=false)
            r.error = result.error.empty() ? "key import failed" : result.error;
            return r;
        }
        try {
            const auto j = nlohmann::json::parse(result.value);
            if (j.is_object() && j.contains("error")) {
                r.error = extractJsonError(j);
                return r;
            }
        } catch (...) {
            // A non-JSON success payload is acceptable; fall through.
        }
        r.detail = result.value;
        r.ok = true;
        return r;
    };

    out = runImport(transparentFirst ? "timport" : "import");
    if (!out.ok) {
        // The single-char heuristic can mis-route (e.g. testnet/regtest WIFs). Try the other
        // command before giving up — the wrong command rejects the key (each validates the
        // encoding), it never imports it as the wrong type.
        LiteImportResult alt = runImport(transparentFirst ? "import" : "timport");
        if (alt.ok) out = alt;
    }
    if (!out.ok) liteLog("Key import failed: " + out.error);  // error text only — never the key
    secureWipeLiteSecret(spendingOrViewingKey);  // wipe our copy after both attempts
    return out;
}

LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
{
    LiteExportResult out;
    if (!walletOpen_.load() || !bridge_) {
        out.error = "no wallet is open";
        return out;
    }
    // Empty address -> export keys for all addresses; otherwise just the given address.
    const auto result = bridge_->execute("export", optionalAddress);
    if (!result.ok) {
        out.error = result.error.empty() ? "export failed" : result.error;
        return out;
    }
    try {
        const auto j = nlohmann::json::parse(result.value);
        if (j.is_object() && j.contains("error")) {
            out.error = extractJsonError(j);
            return out;
        }
    } catch (...) {
        out.error = "could not parse export response";
        return out;
    }
    out.privateKeysJson = result.value;  // SECRET — caller must not log; wipe after use
    out.ok = true;
    return out;
}

LiteSeedResult LiteWalletController::exportSeed()
{
    LiteSeedResult out;
    if (!walletOpen_.load() || !bridge_) {
        out.error = "no wallet is open";
        return out;
    }
    const auto result = bridge_->execute("seed", "");
    if (!result.ok) {
        out.error = result.error.empty() ? "seed export failed" : result.error;
        return out;
    }
    try {
        const auto j = nlohmann::json::parse(result.value);
        if (j.is_object()) {
            if (j.contains("error")) {
                out.error = extractJsonError(j);
                return out;
            }
            if (j.contains("seed") && j.at("seed").is_string()) {
                out.seedPhrase = j.at("seed").get<std::string>();  // SECRET
                if (j.contains("birthday") && j.at("birthday").is_number_unsigned()) {
                    out.birthday = j.at("birthday").get<std::uint64_t>();
                }
                out.ok = !out.seedPhrase.empty();
                if (!out.ok) out.error = "backend returned an empty seed";
                return out;
            }
        }
    } catch (...) {
        // fall through to the generic parse error
    }
    out.error = "could not parse seed response";
    return out;
}

bool LiteWalletController::saveWallet()
{
    if (!walletOpen_.load() || !bridge_) return false;
    return bridge_->execute("save", "").ok;
}

bool LiteWalletController::walletExists() const
{
    // chainName_ is always a backend-accepted value ("main"/"test"/"regtest"); litelib_wallet_
    // exists panics on unknown chains, but settings migration guarantees a valid one.
    return bridge_ && bridge_->walletExists(chainName_);
}

namespace {
// encrypt/unlock/lock/decrypt return {"result":"success"} or {"error":..} (or an "Error:"-prefixed
// string the bridge maps to ok=false). Success = bridge ok and no error field.
LiteEncryptionResult parseEncryptionOpResponse(const LiteBridgeStringResult& bridgeCall)
{
    LiteEncryptionResult out;
    if (!bridgeCall.ok) {
        out.error = bridgeCall.error.empty() ? "operation failed" : bridgeCall.error;
        return out;
    }
    try {
        const auto j = nlohmann::json::parse(bridgeCall.value);
        if (j.is_object() && j.contains("error")) {
            out.error = extractJsonError(j);
            return out;
        }
    } catch (...) {
        // a non-JSON success payload is acceptable
    }
    out.ok = true;
    return out;
}
}  // namespace

LiteEncryptionStatus LiteWalletController::encryptionStatus()
{
    LiteEncryptionStatus out;
    if (!walletOpen_.load() || !bridge_) {
        out.error = "no wallet is open";
        return out;
    }
    const auto result = bridge_->execute("encryptionstatus", "");
    if (!result.ok) {
        out.error = result.error.empty() ? "encryptionstatus failed" : result.error;
        return out;
    }
    try {
        const auto j = nlohmann::json::parse(result.value);
        if (j.is_object()) {
            if (j.contains("error")) {
                out.error = extractJsonError(j);
                return out;
            }
            out.encrypted = j.value("encrypted", false);
            out.locked = j.value("locked", false);
            out.ok = true;
            return out;
        }
    } catch (...) {
        // fall through
    }
    out.error = "could not parse encryptionstatus response";
    return out;
}

LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase)
{
    LiteEncryptionResult out;
    if (!walletOpen_.load() || !bridge_) {
        secureWipeLiteSecret(passphrase);
        out.error = "no wallet is open";
        return out;
    }
    if (passphrase.empty()) {
        out.error = "passphrase required";
        return out;
    }
    out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase));
    secureWipeLiteSecret(passphrase);
    if (out.ok) {
        // Persist the now-encrypted wallet. If the save fails, do NOT report success — the
        // on-disk wallet would still be unencrypted, contradicting what the user was told.
        const auto saved = bridge_->execute("save", "");
        if (!saved.ok) {
            out.ok = false;
            out.error = "wallet encrypted in memory but saving to disk failed" +
                        (saved.error.empty() ? std::string() : (": " + saved.error));
        }
    }
    return out;
}

LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase)
{
    LiteEncryptionResult out;
    if (!walletOpen_.load() || !bridge_) {
        secureWipeLiteSecret(passphrase);
        out.error = "no wallet is open";
        return out;
    }
    if (passphrase.empty()) {
        out.error = "passphrase required";
        return out;
    }
    out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase));
    secureWipeLiteSecret(passphrase);
    if (out.ok) {
        // Persist the now-unencrypted wallet. If the save fails, do NOT report success — the
        // on-disk wallet would still be encrypted, contradicting what the user was told.
        const auto saved = bridge_->execute("save", "");
        if (!saved.ok) {
            out.ok = false;
            out.error = "wallet decrypted in memory but saving to disk failed" +
                        (saved.error.empty() ? std::string() : (": " + saved.error));
        }
    }
    return out;
}

bool LiteWalletController::unlockWallet(std::string passphrase)
{
    if (!walletOpen_.load() || !bridge_ || passphrase.empty()) {
        secureWipeLiteSecret(passphrase);
        return false;
    }
    const bool ok = parseEncryptionOpResponse(bridge_->execute("unlock", passphrase)).ok;
    secureWipeLiteSecret(passphrase);
    return ok;
}

bool LiteWalletController::lockWallet()
{
    if (!walletOpen_.load() || !bridge_) return false;
    return parseEncryptionOpResponse(bridge_->execute("lock", "")).ok;
}

bool LiteWalletController::refreshWalletState(dragonx::WalletState& state)
{
    auto model = refreshModel();
    if (!model) return false;
    applyLiteRefreshModelToWalletState(*model, state);
    return true;
}

bool LiteWalletController::takeRefreshedModel(LiteWalletAppRefreshModel& out)
{
    std::lock_guard<std::mutex> lock(modelMutex_);
    if (!pendingModel_) return false;
    out = std::move(*pendingModel_);
    pendingModel_.reset();
    return true;
}

void LiteWalletController::startWorker()
{
    if (running_.exchange(true)) return;  // already running
    worker_ = std::thread([this] { workerLoop(); });
}

void LiteWalletController::stopWorker()
{
    if (!running_.exchange(false)) return;  // not running
    wakeCv_.notify_all();
    if (worker_.joinable()) worker_.join();
}

void LiteWalletController::workerLoop()
{
    while (running_.load()) {
        if (walletOpen_.load()) {
            auto model = refreshModel();
            if (model) {
                std::lock_guard<std::mutex> lock(modelMutex_);
                pendingModel_ = std::move(model);
            }
        }
        std::unique_lock<std::mutex> lock(wakeMutex_);
        wakeCv_.wait_for(lock, std::chrono::milliseconds(kRefreshIntervalMs),
                         [this] { return !running_.load(); });
    }
}

LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
{
    auto result = lifecycle_.createWallet(request);
    onLifecycleResult(result);
    // If the user supplied a passphrase, encrypt the brand-new wallet with it now that it's open
    // (the backend encrypts + locks + saves). Previously this passphrase was collected but never
    // used (W5-3) — a passphrase field that silently did nothing. encryptWallet() takes its own
    // copy and wipes it.
    if (walletOpen_.load() && !request.passphrase.empty()) {
        const auto enc = encryptWallet(request.passphrase);
        if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error);
    }
    secureWipeLiteSecret(request.passphrase);
    return result;
}

LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request)
{
    auto result = lifecycle_.openWallet(request);
    onLifecycleResult(result);
    // An existing wallet may be encrypted + locked — use the supplied passphrase to unlock it so it
    // opens ready to use. Only meaningful when the wallet is actually locked (W5-3).
    if (walletOpen_.load() && !request.passphrase.empty()) {
        const auto encStatus = encryptionStatus();
        if (encStatus.ok && encStatus.encrypted && encStatus.locked) {
            if (!unlockWallet(request.passphrase))
                liteLog("wallet opened but unlock failed (wrong passphrase?)");
        }
    }
    secureWipeLiteSecret(request.passphrase);
    return result;
}

LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request)
{
    auto result = lifecycle_.restoreWallet(request);
    onLifecycleResult(result);
    // If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
    if (walletOpen_.load() && !request.passphrase.empty()) {
        const auto enc = encryptWallet(request.passphrase);
        if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error);
    }
    secureWipeLiteSecret(request.seedPhrase);
    secureWipeLiteSecret(request.passphrase);
    return result;
}

} // namespace wallet
} // namespace dragonx
