/*
 * vccli — headless test client.
 *
 * This is the primary way the protocol is exercised and verified from M1 onward (see
 * AGENTS.md). Each milestone's exit criterion is demonstrated by driving two vccli
 * instances against a real voicecat-server.
 */
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstring>
#include <functional>
#include <iostream>
#include <string>
#include <thread>

#include "voicecat.h"

namespace {

std::atomic<bool> g_stop{false};
void on_sigint(int) { g_stop.store(true); }

struct Stats {
    std::atomic<bool> auth_done{false};
    std::atomic<bool> auth_ok{false};
    // Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
    // M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below). vccli has no interactive prompt, so it
    // trusts-on-first-connect unconditionally (prints the fingerprint for visibility).
    vc_client* client{nullptr};

    // M5 async result tracking. Generic results are used by every moderation/admin/channel
    // request; account-list is its own event. We count generic results so callers can wait
    // for a new one even if several arrived earlier.
    std::atomic<int>      generic_result_count{0};
    std::atomic<int>      last_result{0};
    std::atomic<uint32_t> last_code{0};
    std::string           last_message;
    std::mutex            last_message_mu;
    std::atomic<bool>     account_list_received{false};
};

void on_event(void* user, const vc_event* ev) {
    auto* st = static_cast<Stats*>(user);
    switch (ev->type) {
        case VC_EVENT_CONNECTION_STATE:
            std::printf("[state] -> %d\n", static_cast<int>(ev->connection_state));
            break;
        case VC_EVENT_SERVER_IDENTITY:
            std::printf("[tofu] status=%u fingerprint=%s (auto-trusting — vccli has no "
                        "interactive prompt)\n", ev->u32a, ev->text ? ev->text : "");
            vc_confirm_server_identity(st->client, 1);
            break;
        case VC_EVENT_AUTH_RESULT:
            st->auth_ok   = (ev->result == VC_OK);
            st->auth_done = true;
            std::printf("[auth] ok=%d user_id=%u %s\n", st->auth_ok.load(), ev->user_id,
                        ev->text ? ev->text : "");
            break;
        case VC_EVENT_CHANNEL_LIST:
            std::printf("[channel] list updated\n");
            break;
        case VC_EVENT_USER_JOINED:
            std::printf("[user] joined: %s (id=%u, channel=%u)\n", ev->text ? ev->text : "?",
                        ev->user_id, ev->channel_id);
            break;
        case VC_EVENT_USER_LEFT:
            std::printf("[user] left: id=%u\n", ev->user_id);
            break;
        case VC_EVENT_USER_UPDATED:
            std::printf("[user] updated: id=%u\n", ev->user_id);
            break;
        case VC_EVENT_STREAM_STARTED:
            std::printf("[voice] stream started: user_id=%u stream_id=%u\n", ev->user_id,
                        ev->stream_id);
            break;
        case VC_EVENT_STREAM_STOPPED:
            std::printf("[voice] stream stopped: user_id=%u stream_id=%u\n", ev->user_id,
                        ev->stream_id);
            break;
        case VC_EVENT_TEXT_MESSAGE:
            std::printf("[text] from=%u: %s\n", ev->user_id, ev->text ? ev->text : "");
            break;
        case VC_EVENT_TALK_STATE:
            std::printf("[voice] talk state: user_id=%u stream_id=%u talking=%u\n", ev->user_id,
                        ev->stream_id, ev->u32a);
            break;
        case VC_EVENT_JOIN_RESULT:
            std::printf("[join] result=%d channel=%u %s\n", ev->result, ev->channel_id,
                        ev->text ? ev->text : "");
            break;
        case VC_EVENT_GENERIC_RESULT: {
            std::lock_guard lk(st->last_message_mu);
            st->last_result.store(ev->result);
            st->last_code.store(ev->u32a);
            st->last_message = ev->text ? ev->text : "";
            st->generic_result_count.fetch_add(1);
            std::printf("[result] rc=%d code=%u: %s\n", ev->result, ev->u32a,
                        ev->text ? ev->text : "");
            break;
        }
        case VC_EVENT_ACCOUNT_LIST:
            st->account_list_received.store(true);
            std::printf("[accounts] list received (use vc_list_accounts in code to inspect)\n");
            break;
        case VC_EVENT_ERROR:
            std::fprintf(stderr, "[error] rc=%d: %s\n", ev->result, ev->text ? ev->text : "");
            break;
        case VC_EVENT_DISCONNECTED:
            std::printf("[disconnected] rc=%d: %s\n", ev->result, ev->text ? ev->text : "");
            g_stop.store(true);
            break;
        default:
            break;
    }
}

bool wait_until(std::atomic<bool>& flag, int timeout_ms) {
    auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
    while (!flag.load()) {
        if (std::chrono::steady_clock::now() >= deadline) return false;
        std::this_thread::sleep_for(std::chrono::milliseconds(20));
    }
    return true;
}

// Wait for a new generic result to arrive. Returns the vc_result from that result.
vc_result wait_generic_result(Stats& st, int baseline_count, int timeout_ms) {
    auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
    while (st.generic_result_count.load() <= baseline_count) {
        if (std::chrono::steady_clock::now() >= deadline) return VC_ERR_TIMEOUT;
        std::this_thread::sleep_for(std::chrono::milliseconds(20));
    }
    return static_cast<vc_result>(st.last_result.load());
}

// Parse "true"/"false"/"1"/"0"/"yes"/"no" (case-insensitive).
bool parse_bool(const char* s, bool* out) {
    if (!s || !*s) return false;
    std::string v = s;
    for (auto& ch : v) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
    if (v == "1" || v == "true" || v == "yes" || v == "on") { *out = true; return true; }
    if (v == "0" || v == "false" || v == "no" || v == "off") { *out = false; return true; }
    return false;
}

bool parse_u32(const char* s, uint32_t* out, const char* label) {
    if (!s || !*s) { std::fprintf(stderr, "missing value for %s\n", label); return false; }
    try {
        *out = static_cast<uint32_t>(std::stoul(s));
        return true;
    } catch (...) {
        std::fprintf(stderr, "invalid value for %s: %s\n", label, s);
        return false;
    }
}

bool parse_u16(const char* s, uint16_t* out, const char* label) {
    if (!s || !*s) { std::fprintf(stderr, "missing value for %s\n", label); return false; }
    try {
        int v = std::stoi(s);
        if (v < 0 || v > 65535) throw std::out_of_range("port");
        *out = static_cast<uint16_t>(v);
        return true;
    } catch (...) {
        std::fprintf(stderr, "invalid value for %s: %s\n", label, s);
        return false;
    }
}

void print_usage() {
    std::printf(
        "usage: vccli [--host H] [--port P] [--nick NAME | --username U --password P]\n"
        "             [--channel ID] [--voice] [--mute] [--text MSG] [--list-devices]\n"
        "             [--input-device ID] [--input-mode vad|ptt] [--share-screen-audio]\n"
        "             [--wait-ms N]\n"
        "             [--kick USER_ID [--reason REASON]]\n"
        "             [--ban USER_ID [--reason REASON] [--ban-expires-ms MS]]\n"
        "             [--move USER_ID --to-channel ID]\n"
        "             [--server-mute USER_ID] [--server-unmute USER_ID]\n"
        "             [--server-deafen USER_ID] [--server-undeafen USER_ID]\n"
        "             [--set-permission USER_ID --perm-admin B --perm-kick B --perm-ban B\n"
        "                              --perm-move B --perm-create-temp B --perm-admin-accounts B]\n"
        "             [--create-channel --new-channel-name NAME [--new-channel-topic TOPIC]\n"
        "                             [--new-channel-parent ID] [--new-channel-password PASS]\n"
        "                             [--new-channel-max-users N]]\n"
        "             [--edit-channel --channel-id ID --new-channel-name NAME\n"
        "                            [--new-channel-topic TOPIC] [--new-channel-parent ID]\n"
        "                            [--new-channel-password PASS] [--new-channel-max-users N]]\n"
        "             [--delete-channel --channel-id ID]\n"
        "             [--create-account USER PASS] [--reset-password USER PASS]\n"
        "             [--delete-account USER] [--list-accounts]\n"
        "             [--self-mute] [--self-deafen]\n"
        "\n"
        "Connection / identity:\n"
        "  --host H              server host (default 127.0.0.1)\n"
        "  --port P              server TCP port (default 8384)\n"
        "  --nick NAME           guest nickname (default vccli-test)\n"
        "  --username U          authenticate as registered user U\n"
        "  --password P          password for --username\n"
        "  --channel ID          channel to join after auth (default 1, Lobby)\n"
        "  --wait-ms N           timeout for M5 async result events (default 5000)\n"
        "\n"
        "Voice / devices:\n"
        "  --voice               start a MIC stream and stay connected until Ctrl+C\n"
        "  --mute                start with the mic muted (only meaningful with --voice)\n"
        "  --list-devices        print input/output devices (vc_list_devices) and exit\n"
        "  --input-device ID     use device ID (from --list-devices) for the MIC stream\n"
        "  --input-mode vad|ptt  send-side input gate mode (default vad)\n"
        "  --share-screen-audio  also start a SCREEN_AUDIO stream (WASAPI loopback on Windows)\n"
        "  --self-mute           mute own mic before/without voice mode\n"
        "  --self-deafen         deafen self before/without voice mode\n"
        "\n"
        "Text:\n"
        "  --text MSG            send MSG to the channel, then exit\n"
        "\n"
        "Moderation (require permission):\n"
        "  --kick USER_ID [--reason REASON]\n"
        "  --ban USER_ID [--reason REASON] [--ban-expires-ms MS] (0 = permanent)\n"
        "  --move USER_ID --to-channel ID\n"
        "  --server-mute USER_ID, --server-unmute USER_ID\n"
        "  --server-deafen USER_ID, --server-undeafen USER_ID\n"
        "\n"
        "Permissions (require permission):\n"
        "  --set-permission USER_ID --perm-admin B --perm-kick B --perm-ban B\n"
        "                           --perm-move B --perm-create-temp B --perm-admin-accounts B\n"
        "                        B = 0|1|true|false|yes|no\n"
        "\n"
        "Channel management (require permission):\n"
        "  --create-channel --new-channel-name NAME ...\n"
        "  --edit-channel --channel-id ID --new-channel-name NAME ...\n"
        "  --delete-channel --channel-id ID\n"
        "\n"
        "Account management (require permission):\n"
        "  --create-account USER PASS\n"
        "  --reset-password USER PASS\n"
        "  --delete-account USER\n"
        "  --list-accounts\n"
        "\n"
        "While --voice is running, stdin accepts: \"ptt on\", \"ptt off\", \"mode vad\",\n"
        "\"mode ptt\" (PTT key state can't be held interactively in a headless CLI, so it's\n"
        "toggled via these commands instead).\n");
}

void print_device_list(vc_client* c, vc_device_kind kind, const char* label) {
    vc_device_list dl{};
    vc_result      r = vc_list_devices(c, kind, &dl);
    std::printf("%s devices: vc_list_devices -> %d (%s), count=%zu\n", label, r,
                vc_result_string(r), dl.count);
    for (size_t i = 0; i < dl.count; ++i) {
        std::printf("  [%s] %s%s\n", dl.items[i].id, dl.items[i].name,
                    dl.items[i].is_default ? "  (default)" : "");
    }
    vc_free_device_list(&dl);
}

// Background stdin reader for --voice mode: the most portable way to drive PTT/mode toggles
// interactively from a headless CLI (no SIGUSR1 equivalent on Windows).
void run_stdin_commands(vc_client* c, std::atomic<bool>& stop) {
    std::string line;
    while (!stop.load() && std::getline(std::cin, line)) {
        if (line == "ptt on") {
            vc_set_push_to_talk(c, 1);
            std::printf("[ptt] on\n");
        } else if (line == "ptt off") {
            vc_set_push_to_talk(c, 0);
            std::printf("[ptt] off\n");
        } else if (line == "mode vad") {
            vc_set_input_mode(c, VC_INPUT_VOICE_ACTIVATION);
            std::printf("[mode] vad\n");
        } else if (line == "mode ptt") {
            vc_set_input_mode(c, VC_INPUT_PUSH_TO_TALK);
            std::printf("[mode] ptt\n");
        } else if (!line.empty()) {
            std::fprintf(stderr, "unknown command: %s\n", line.c_str());
        }
    }
}

// Issue an M5 request that produces VC_EVENT_GENERIC_RESULT and wait for it.
// Returns the result code from the event.
using RequestFn = std::function<vc_result()>;

vc_result run_generic_request(Stats& st, int timeout_ms, RequestFn fn, const char* label) {
    int before = st.generic_result_count.load();
    vc_result r = fn();
    std::printf("%s -> %d (%s)\n", label, r, vc_result_string(r));
    if (r != VC_OK) return r;
    vc_result event_rc = wait_generic_result(st, before, timeout_ms);
    if (event_rc == VC_ERR_TIMEOUT) {
        std::fprintf(stderr, "%s: timed out waiting for result event\n", label);
        return VC_ERR_TIMEOUT;
    }
    return event_rc;
}

}  // namespace

int main(int argc, char** argv) {
    // MSVCRT/MinGW treat _IOLBF as full buffering for non-console streams, so go unbuffered
    // to keep output visible immediately when piped to a file or another process.
    std::setvbuf(stdout, nullptr, _IONBF, 0);

    std::string host       = "127.0.0.1";
    uint16_t    port       = 8384;
    std::string nick       = "vccli-test";
    std::string username;
    std::string password;
    bool        have_username = false;
    bool        have_password = false;
    uint32_t    channel_id = 1;
    bool        voice_mode = false;
    bool        start_muted = false;
    bool        self_mute = false;
    bool        self_deafen = false;
    std::string text_msg;
    bool        have_text = false;
    bool        list_devices = false;
    std::string input_device;
    bool        have_input_device = false;
    vc_input_mode input_mode = VC_INPUT_VOICE_ACTIVATION;
    bool        share_screen_audio = false;
    int         wait_ms = 5000;

    // Moderation
    bool        do_kick = false;
    uint32_t    kick_user_id = 0;
    std::string kick_reason;
    bool        do_ban = false;
    uint32_t    ban_user_id = 0;
    std::string ban_reason;
    uint64_t    ban_expires_ms = 0;
    bool        do_move = false;
    uint32_t    move_user_id = 0;
    uint32_t    move_channel_id = 0;
    bool        do_server_mute = false;
    bool        do_server_unmute = false;
    bool        do_server_deafen = false;
    bool        do_server_undeafen = false;
    uint32_t    server_mute_user_id = 0;
    int         server_mute_muted = 0;
    int         server_mute_deafened = 0;

    // Permissions
    bool        do_set_permission = false;
    uint32_t    perm_user_id = 0;
    vc_permissions perms{};

    // Channel CRUD
    bool        do_create_channel = false;
    bool        do_edit_channel = false;
    bool        do_delete_channel = false;
    uint32_t    delete_channel_id = 0;
    vc_channel_info channel_info{};

    // Account management
    bool        do_create_account = false;
    bool        do_reset_password = false;
    bool        do_delete_account = false;
    bool        do_list_accounts = false;
    std::string acct_user;
    std::string acct_pass;

    for (int i = 1; i < argc; ++i) {
        std::string a = argv[i];
        auto next = [&]() -> std::string { return (i + 1 < argc) ? argv[++i] : std::string(); };
        if (a == "--host") host = next();
        else if (a == "--port") { if (!parse_u16(next().c_str(), &port, "--port")) return 1; }
        else if (a == "--nick") nick = next();
        else if (a == "--username") { username = next(); have_username = true; }
        else if (a == "--password") { password = next(); have_password = true; }
        else if (a == "--channel") { if (!parse_u32(next().c_str(), &channel_id, "--channel")) return 1; }
        else if (a == "--voice") voice_mode = true;
        else if (a == "--mute") start_muted = true;
        else if (a == "--self-mute") self_mute = true;
        else if (a == "--self-deafen") self_deafen = true;
        else if (a == "--text") { text_msg = next(); have_text = true; }
        else if (a == "--list-devices") list_devices = true;
        else if (a == "--input-device") { input_device = next(); have_input_device = true; }
        else if (a == "--input-mode") {
            std::string m = next();
            if (m == "ptt") input_mode = VC_INPUT_PUSH_TO_TALK;
            else if (m != "vad") { std::fprintf(stderr, "--input-mode must be vad|ptt\n"); return 1; }
        }
        else if (a == "--share-screen-audio") share_screen_audio = true;
        else if (a == "--wait-ms") {
            std::string v = next();
            try { wait_ms = std::stoi(v); } catch (...) {
                std::fprintf(stderr, "invalid --wait-ms: %s\n", v.c_str()); return 1;
            }
            if (wait_ms < 0) wait_ms = 0;
        }
        // Moderation
        else if (a == "--kick") { do_kick = true; if (!parse_u32(next().c_str(), &kick_user_id, "--kick")) return 1; }
        else if (a == "--reason") {
            std::string r = next();
            if (do_kick) kick_reason = r;
            else if (do_ban) ban_reason = r;
            else { std::fprintf(stderr, "--reason without --kick or --ban\n"); return 1; }
        }
        else if (a == "--ban") { do_ban = true; if (!parse_u32(next().c_str(), &ban_user_id, "--ban")) return 1; }
        else if (a == "--ban-expires-ms") {
            std::string v = next();
            try { ban_expires_ms = static_cast<uint64_t>(std::stoull(v)); } catch (...) {
                std::fprintf(stderr, "invalid --ban-expires-ms: %s\n", v.c_str()); return 1;
            }
        }
        else if (a == "--move") { do_move = true; if (!parse_u32(next().c_str(), &move_user_id, "--move")) return 1; }
        else if (a == "--to-channel") { if (!parse_u32(next().c_str(), &move_channel_id, "--to-channel")) return 1; }
        else if (a == "--server-mute") { do_server_mute = true; server_mute_muted = 1; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-mute")) return 1; }
        else if (a == "--server-unmute") { do_server_unmute = true; server_mute_muted = 0; server_mute_deafened = 0; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-unmute")) return 1; }
        else if (a == "--server-deafen") { do_server_deafen = true; server_mute_muted = 1; server_mute_deafened = 1; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-deafen")) return 1; }
        else if (a == "--server-undeafen") { do_server_undeafen = true; server_mute_muted = 0; server_mute_deafened = 0; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-undeafen")) return 1; }
        // Permissions
        else if (a == "--set-permission") { do_set_permission = true; if (!parse_u32(next().c_str(), &perm_user_id, "--set-permission")) return 1; }
        else if (a == "--perm-admin") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.is_admin = b; }
        else if (a == "--perm-kick") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_kick = b; }
        else if (a == "--perm-ban") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_ban = b; }
        else if (a == "--perm-move") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_move_users = b; }
        else if (a == "--perm-create-temp") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_create_temp_channel = b; }
        else if (a == "--perm-admin-accounts") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_admin_accounts = b; }
        // Channel CRUD
        else if (a == "--create-channel") do_create_channel = true;
        else if (a == "--edit-channel") do_edit_channel = true;
        else if (a == "--delete-channel") { do_delete_channel = true; if (!parse_u32(next().c_str(), &delete_channel_id, "--delete-channel")) return 1; }
        else if (a == "--channel-id") { if (!parse_u32(next().c_str(), &channel_info.id, "--channel-id")) return 1; }
        else if (a == "--new-channel-name") channel_info.name = next().c_str();
        else if (a == "--new-channel-topic") channel_info.topic = next().c_str();
        else if (a == "--new-channel-parent") { if (!parse_u32(next().c_str(), &channel_info.parent_id, "--new-channel-parent")) return 1; }
        else if (a == "--new-channel-password") {
            channel_info.password = next().c_str();
            channel_info.password_protected = 1;
        }
        else if (a == "--new-channel-max-users") { if (!parse_u32(next().c_str(), &channel_info.max_users, "--new-channel-max-users")) return 1; }
        // Account management
        else if (a == "--create-account") { do_create_account = true; acct_user = next(); acct_pass = next(); }
        else if (a == "--reset-password") { do_reset_password = true; acct_user = next(); acct_pass = next(); }
        else if (a == "--delete-account") { do_delete_account = true; acct_user = next(); }
        else if (a == "--list-accounts") do_list_accounts = true;
        else if (a == "--help" || a == "-h") { print_usage(); return 0; }
        else { std::fprintf(stderr, "unknown flag: %s\n", a.c_str()); print_usage(); return 1; }
    }

    // Validate auth mode.
    if (have_username != have_password) {
        std::fprintf(stderr, "--username and --password must be used together\n");
        return 1;
    }

    // Validate moderation flags that need extra args.
    if (do_move && move_channel_id == 0) {
        std::fprintf(stderr, "--move requires --to-channel\n");
        return 1;
    }
    if ((do_server_mute || do_server_unmute || do_server_deafen || do_server_undeafen) &&
        server_mute_user_id == 0) {
        std::fprintf(stderr, "server mute/deafen requires a user id\n");
        return 1;
    }
    // Normalize server mute/deafen into a single request.
    bool do_server_mute_request = do_server_mute || do_server_unmute || do_server_deafen || do_server_undeafen;

    // Validate channel CRUD.
    if (do_create_channel && (!channel_info.name || !*channel_info.name)) {
        std::fprintf(stderr, "--create-channel requires --new-channel-name\n");
        return 1;
    }
    if (do_edit_channel && (channel_info.id == 0 || !channel_info.name || !*channel_info.name)) {
        std::fprintf(stderr, "--edit-channel requires --channel-id and --new-channel-name\n");
        return 1;
    }

    std::printf("vccli — VoiceCat test client (core %s, protocol v%d)\n", vc_version_string(),
                VOICECAT_PROTOCOL_VERSION);

    std::signal(SIGINT, on_sigint);

    vc_config cfg{};
    cfg.client_name    = "vccli";
    cfg.client_version = vc_version_string();
    cfg.log_level       = VC_LOG_INFO;

    Stats st;
    vc_callbacks cb{};
    cb.on_event = on_event;
    cb.user     = &st;

    vc_client* c = vc_client_create(&cfg, cb);
    if (c == nullptr) {
        std::fprintf(stderr, "failed to create client\n");
        return 1;
    }
    st.client = c;

    if (list_devices) {
        // Device enumeration works pre-connect (no server needed) — see docs/voice.md.
        print_device_list(c, VC_DEVICE_INPUT, "input");
        print_device_list(c, VC_DEVICE_OUTPUT, "output");
        vc_client_destroy(c);
        return 0;
    }

    vc_result r = vc_connect(c, host.c_str(), port);
    std::printf("vc_connect(%s:%u) -> %d (%s)\n", host.c_str(), port, r, vc_result_string(r));
    if (r != VC_OK) {
        vc_client_destroy(c);
        return 1;
    }

    if (have_username) {
        r = vc_authenticate_user(c, username.c_str(), password.c_str());
        std::printf("vc_authenticate_user(%s) -> %d (%s)\n", username.c_str(), r,
                    vc_result_string(r));
    } else {
        r = vc_authenticate_guest(c, nick.c_str());
        std::printf("vc_authenticate_guest(%s) -> %d (%s)\n", nick.c_str(), r,
                    vc_result_string(r));
    }

    if (!wait_until(st.auth_done, 8000) || !st.auth_ok.load()) {
        std::fprintf(stderr, "authentication failed or timed out\n");
        vc_disconnect(c);
        vc_client_destroy(c);
        return 1;
    }

    if (channel_id != 1) {
        r = vc_join_channel(c, channel_id, nullptr);
        std::printf("vc_join_channel(%u) -> %d (%s)\n", channel_id, r, vc_result_string(r));
    }

    if (have_text) {
        r = vc_send_text(c, VC_TEXT_CHANNEL, channel_id, text_msg.c_str());
        std::printf("vc_send_text -> %d (%s)\n", r, vc_result_string(r));
        std::this_thread::sleep_for(std::chrono::milliseconds(300));  // let the relay land
    }

    // M5 moderation / admin requests (executed in a sensible order if multiple are given).
    bool m5_error = false;

    if (self_mute || self_deafen) {
        r = vc_set_self_mute(c, self_mute ? 1 : 0, self_deafen ? 1 : 0);
        std::printf("vc_set_self_mute -> %d (%s)\n", r, vc_result_string(r));
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_kick) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_kick_user(c, kick_user_id, kick_reason.c_str()); },
                                "vc_kick_user");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_ban) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_ban_user(c, ban_user_id, ban_reason.c_str(), ban_expires_ms); },
                                "vc_ban_user");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_move) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_move_user(c, move_user_id, move_channel_id); },
                                "vc_move_user");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_server_mute_request) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_set_server_mute(c, server_mute_user_id, server_mute_muted, server_mute_deafened); },
                                "vc_set_server_mute");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_set_permission) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_set_permission(c, perm_user_id, &perms); },
                                "vc_set_permission");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_create_channel) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_create_channel(c, &channel_info); },
                                "vc_create_channel");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_edit_channel) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_edit_channel(c, &channel_info); },
                                "vc_edit_channel");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_delete_channel) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_delete_channel(c, delete_channel_id); },
                                "vc_delete_channel");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_create_account) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_create_account(c, acct_user.c_str(), acct_pass.c_str()); },
                                "vc_create_account");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_reset_password) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_reset_password(c, acct_user.c_str(), acct_pass.c_str()); },
                                "vc_reset_password");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_delete_account) {
        r = run_generic_request(st, wait_ms,
                                [&]() { return vc_delete_account(c, acct_user.c_str()); },
                                "vc_delete_account");
        if (r != VC_OK) m5_error = true;
    }

    if (!m5_error && do_list_accounts) {
        st.account_list_received.store(false);
        r = vc_list_accounts(c);
        std::printf("vc_list_accounts -> %d (%s)\n", r, vc_result_string(r));
        if (r != VC_OK) {
            m5_error = true;
        } else {
            if (!wait_until(st.account_list_received, wait_ms)) {
                std::fprintf(stderr, "vc_list_accounts: timed out waiting for list event\n");
                m5_error = true;
            }
        }
    }

    if (m5_error && !voice_mode) {
        vc_disconnect(c);
        vc_client_destroy(c);
        return 1;
    }

    if (voice_mode) {
        // Give the async UDP binding handshake (TCP UdpBinding -> ack -> plaintext
        // bootstrap packet) a moment to land before announcing a stream.
        std::this_thread::sleep_for(std::chrono::milliseconds(500));

        if (start_muted) vc_set_self_mute(c, 1, 0);

        r = vc_set_input_mode(c, input_mode);
        std::printf("vc_set_input_mode(%s) -> %d (%s)\n",
                    input_mode == VC_INPUT_PUSH_TO_TALK ? "ptt" : "vad", r, vc_result_string(r));

        vc_stream_desc desc{};
        desc.kind      = VC_STREAM_MIC;
        desc.device_id = have_input_device ? input_device.c_str() : nullptr;
        desc.label     = "Microphone";

        uint32_t stream_id = 0;
        r = vc_stream_start(c, &desc, &stream_id);
        std::printf("vc_stream_start -> %d (%s), stream_id=%u\n", r, vc_result_string(r),
                    stream_id);

        if (have_input_device && r == VC_OK) {
            r = vc_set_input_device(c, stream_id, input_device.c_str());
            std::printf("vc_set_input_device -> %d (%s)\n", r, vc_result_string(r));
        }

        uint32_t screen_stream_id = 0;
        if (share_screen_audio) {
            vc_stream_desc sdesc{};
            sdesc.kind  = VC_STREAM_SCREEN_AUDIO;
            sdesc.label = "Desktop audio";
            r = vc_stream_start(c, &sdesc, &screen_stream_id);
            std::printf("vc_stream_start(SCREEN_AUDIO) -> %d (%s), stream_id=%u\n", r,
                        vc_result_string(r), screen_stream_id);
        }

        std::thread stdin_thread(run_stdin_commands, c, std::ref(g_stop));

        std::printf("voice mode: streaming mic, listening for remote streams. Ctrl+C to stop.\n"
                    "(type \"ptt on\"/\"ptt off\"/\"mode vad\"/\"mode ptt\" to drive the input gate)\n");
        while (!g_stop.load()) {
            std::this_thread::sleep_for(std::chrono::milliseconds(200));
        }

        if (share_screen_audio) vc_stream_stop(c, screen_stream_id);
        vc_stream_stop(c, stream_id);

        // Unblock the stdin reader and let it exit before the client is torn down.
        if (stdin_thread.joinable()) {
#if defined(_WIN32)
            // std::getline on a console stdin blocks indefinitely; on Windows there's no
            // portable way to interrupt it from another thread, so detach rather than join —
            // process exit reclaims the thread.
            stdin_thread.detach();
#else
            stdin_thread.join();
#endif
        }
    }

    vc_disconnect(c);
    vc_client_destroy(c);
    std::printf("ok\n");
    return m5_error ? 1 : 0;
}
