#include "core/client.h"

#ifdef VOICECAT_HAS_NET

#ifdef _WIN32
#  ifndef WIN32_LEAN_AND_MEAN
#    define WIN32_LEAN_AND_MEAN
#  endif
#  include <winsock2.h>
#  include <ws2tcpip.h>
   using sock_t = SOCKET;
   static constexpr sock_t kBadSock = INVALID_SOCKET;
   static void close_sock(sock_t s) { ::closesocket(s); }
#else
#  include <arpa/inet.h>
#  include <netdb.h>
#  include <netinet/in.h>
#  include <sys/socket.h>
#  include <unistd.h>
   using sock_t = int;
   static constexpr sock_t kBadSock = -1;
   static void close_sock(sock_t s) { ::close(s); }
#endif

#include <algorithm>
#include <chrono>
#include <cmath>
#include <csignal>
#include <cstring>
#include <filesystem>

#include "protocol/protocol.h"

namespace {

// Build a length-prefixed frame from an Envelope and return the raw bytes.
std::vector<uint8_t> make_frame(const voicecat::v1::Envelope& env) {
    std::vector<uint8_t> out;
    voicecat::protocol::encode_envelope(env, out);
    return out;
}

}  // namespace

// ── vc_client M1 implementation ───────────────────────────────────────────────

vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {
    // M4 TOFU: NULL/empty tofu_store_path falls back to a relative default so existing tests
    // (which never set this field) keep working without real per-user persistence.
    std::filesystem::path tofu_path = (cfg.tofu_store_path && cfg.tofu_store_path[0])
                                           ? std::filesystem::path(cfg.tofu_store_path)
                                           : std::filesystem::path("voicecat_tofu_pins.txt");
    tofu_store_ = std::make_unique<voicecat::crypto::TofuStore>(std::move(tofu_path));
}

vc_client::~vc_client() { disconnect(); }

void vc_client::emit(const vc_event& ev) const {
    if (cb_.on_event) cb_.on_event(cb_.user, &ev);
}

void vc_client::set_state(vc_connection_state s) {
    state_net_.store(s, std::memory_order_release);
    vc_event ev{};
    ev.type = VC_EVENT_CONNECTION_STATE;
    ev.connection_state = s;
    emit(ev);
}

void vc_client::emit_error(vc_result r, const char* text) {
    vc_event ev{};
    ev.type   = VC_EVENT_ERROR;
    ev.result = static_cast<int32_t>(r);
    ev.text   = text;
    emit(ev);
}

void vc_client::emit_disconnected(vc_result r, const char* reason) {
    vc_event ev{};
    ev.type   = VC_EVENT_DISCONNECTED;
    ev.result = static_cast<int32_t>(r);
    ev.text   = reason;
    emit(ev);
    set_state(VC_STATE_DISCONNECTED);
}

// ── Connection ────────────────────────────────────────────────────────────────

vc_result vc_client::connect(const char* host, uint16_t port) {
    auto cur = state_net_.load(std::memory_order_acquire);
    if (cur != VC_STATE_DISCONNECTED) return VC_ERR_ALREADY;

    io_stop_.store(false, std::memory_order_release);
    io_fd_.store(-1, std::memory_order_release);
    // Pre-set so authenticate_*() called right after connect() doesn't see DISCONNECTED.
    state_net_.store(VC_STATE_CONNECTING, std::memory_order_release);

    std::string h = host;
    io_thread_ = std::thread([this, h, port] { run_io(h, port); });
    return VC_OK;
}

vc_result vc_client::disconnect() {
    auto cur = state_net_.load(std::memory_order_acquire);
    if (cur == VC_STATE_DISCONNECTED && !io_thread_.joinable()) return VC_ERR_NOT_CONNECTED;

    // Graceful disconnect (Tier 5): queue Disconnect{code=0}, set a flag, and let the io
    // thread drain the queue + send it + exit naturally. The io thread's cleanup handles
    // teardown_voice() and socket close — the main thread just joins. This avoids the
    // double-close race that a send_cv_ wait + main-thread socket close would create
    // (the server closes the connection on receipt of Disconnect, so the io thread exits
    // while the main thread is still waiting, and both try to close the same socket).
    // Only when fully authenticated: the server gates Disconnect handling on Authenticated,
    // and in earlier states (TLS handshake, TOFU gate, auth pending) the io thread may be
    // blocked outside the read loop (e.g. tofu_cv_) where the flag would never be checked.
    if (cur == VC_STATE_CONNECTED && io_thread_.joinable()) {
        voicecat::v1::Envelope env;
        env.set_request_id(next_req_id_++);
        auto* d = env.mutable_disconnect();
        d->set_code(0);  // 0 = graceful client-initiated
        d->set_reason("client disconnect");
        queue_envelope(env);
        graceful_disconnect_pending_.store(true, std::memory_order_release);

        // Wait for the io thread to drain the queue, send the Disconnect, and exit.
        // Its cleanup handles teardown_voice() + socket close. No main-thread socket
        // close needed. If the io thread is stuck (unlikely), the caller can force-close
        // by calling disconnect() again — but the second call hits the non-graceful path
        // below since io_thread_ is no longer joinable after the join returns.
        if (io_thread_.joinable()) io_thread_.join();
        return VC_OK;
    }

    // Non-graceful path: force-close (original logic). Used when the io thread is already
    // gone, the connection is in an early state, or as a fallback.
    io_stop_.store(true, std::memory_order_release);

    // Unblock a run_io() thread that's currently waiting on vc_confirm_server_identity() —
    // without this, disconnecting mid-dialog would strand io_thread_ until the 120s timeout.
    {
        std::lock_guard lk(tofu_mu_);
        if (tofu_decision_pending_) {
            tofu_decision_pending_ = false;
            tofu_cv_.notify_all();
        }
    }

    // Close the socket to unblock blocking TLS reads/writes.
    int fd = io_fd_.load(std::memory_order_acquire);
    if (fd != -1) {
#ifdef _WIN32
        ::shutdown(static_cast<SOCKET>(fd), SD_BOTH);
        ::closesocket(static_cast<SOCKET>(fd));
#else
        ::shutdown(fd, SHUT_RDWR);
        ::close(fd);
#endif
        io_fd_.store(-1, std::memory_order_release);
    }

    teardown_voice();

    if (io_thread_.joinable()) io_thread_.join();
    return VC_OK;
}

// ── io_thread_ entry point ────────────────────────────────────────────────────

void vc_client::run_io(std::string host, uint16_t port) {
    udp_host_ = host;
    set_state(VC_STATE_CONNECTING);

    // ── TCP connect ──────────────────────────────────────────────────────────
#ifdef _WIN32
    WSADATA wsa{};
    WSAStartup(MAKEWORD(2, 2), &wsa);
#else
    // POSIX/macOS: ignore SIGPIPE — a write to a closed socket returns EPIPE instead of
    // terminating the process. On macOS SIGPIPE is delivered by default (unlike Windows
    // where it doesn't exist); without this, a peer dropping mid-TLS-write kills us.
    // Process-global and idempotent (safe to call per run_io).
    std::signal(SIGPIPE, SIG_IGN);
#endif

    struct addrinfo hints{};
    hints.ai_family   = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    struct addrinfo* res = nullptr;
    std::string port_str = std::to_string(port);

    if (io_stop_.load()) goto cleanup;

    if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0 || !res) {
        emit_disconnected(VC_ERR_IO, "hostname resolution failed");
        goto cleanup;
    }

    {
        sock_t sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
        if (sock == kBadSock) {
            freeaddrinfo(res);
            emit_disconnected(VC_ERR_IO, "socket() failed");
            goto cleanup;
        }

        if (::connect(sock, res->ai_addr, static_cast<int>(res->ai_addrlen)) != 0) {
            close_sock(sock);
            freeaddrinfo(res);
            emit_disconnected(VC_ERR_IO, "TCP connect failed");
            goto cleanup;
        }
        freeaddrinfo(res);
        res = nullptr;
        io_fd_.store(static_cast<int>(sock), std::memory_order_release);

        // ── TLS handshake ────────────────────────────────────────────────────
        set_state(VC_STATE_TLS_HANDSHAKE);

        tls_ = std::make_unique<voicecat::crypto::TlsContext>(
            voicecat::crypto::TlsContext::Role::Client, nullptr);

        {
            std::string tls_err;
            if (!tls_->handshake(static_cast<int>(sock), tls_err)) {
                tls_.reset();
                emit_disconnected(VC_ERR_CRYPTO, tls_err.c_str());
                close_sock(sock);
                io_fd_.store(-1);
                goto cleanup;
            }
        }

        // ── TOFU server-identity gate (M4) ──────────────────────────────────────
        // Pins the TLS leaf cert's own fingerprint (real, verifiable right here from the
        // handshake) — NOT the declared Ed25519 server_identity_fingerprint from ServerHello,
        // which hasn't even arrived yet at this point (it's sent *inside* this now-established
        // tunnel) and isn't cryptographically bound to this cert anyway (docs/security.md
        // §1.1). See voicecat.h's vc_tofu_status doc comment.
        set_state(VC_STATE_VERIFYING_IDENTITY);
        {
            std::array<uint8_t, 32> peer_fp{};
            vc_tofu_status status = VC_TOFU_MISMATCH;
            if (tls_->peer_cert_fingerprint(peer_fp) && tofu_store_) {
                auto r = tofu_store_->peek(host, port, peer_fp);
                status = (r == voicecat::crypto::TofuResult::FirstConnect) ? VC_TOFU_FIRST_CONNECT
                          : (r == voicecat::crypto::TofuResult::Matched)   ? VC_TOFU_MATCHED
                                                                             : VC_TOFU_MISMATCH;
            }

            std::string fp_hex;
            {
                static const char* hex = "0123456789abcdef";
                fp_hex.reserve(peer_fp.size() * 2);
                for (auto b : peer_fp) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; }
            }

            {
                std::lock_guard<std::mutex> set_lk(tofu_mu_);
                tofu_decision_pending_ = true;
                tofu_accept_ = false;
            }

            vc_event ev{};
            ev.type = VC_EVENT_SERVER_IDENTITY;
            ev.u32a = static_cast<uint32_t>(status);
            ev.text = fp_hex.c_str();
            emit(ev);

            bool accepted;
            {
                std::unique_lock lk(tofu_mu_);
                tofu_cv_.wait_for(lk, std::chrono::seconds(120), [&] {
                    return !tofu_decision_pending_ || io_stop_.load(std::memory_order_acquire);
                });
                // Timeout or an external stop (disconnect() during the wait) both leave
                // tofu_decision_pending_ true here — treated as a reject, per voicecat.h.
                accepted = tofu_decision_pending_ ? false : tofu_accept_;
                tofu_decision_pending_ = false;
            }

            if (!accepted) {
                tls_.reset();
                emit_disconnected(VC_ERR_CRYPTO, "server identity rejected");
                close_sock(sock);
                io_fd_.store(-1);
                goto cleanup;
            }
            if (status != VC_TOFU_MATCHED && tofu_store_) {
                tofu_store_->pin(host, port, peer_fp);
            }
        }

        // 50 ms timeout so we can drain sends between reads.
        tls_->set_read_timeout(50);

        // ── Send ClientHello ─────────────────────────────────────────────────
        set_state(VC_STATE_AUTHENTICATING);
        {
            voicecat::v1::Envelope env;
            env.set_request_id(next_req_id_++);
            auto* hello = env.mutable_client_hello();
            hello->set_proto_version(2);
            hello->set_client_name(cfg_.client_name ? cfg_.client_name : "vccli");
            hello->set_client_version(cfg_.client_version ? cfg_.client_version : "0.1.0");
            auto frame = make_frame(env);
            size_t off = 0;
            while (off < frame.size()) {
                int n = tls_->write(frame.data() + off, frame.size() - off);
                if (n <= 0) {
                    tls_.reset();
                    emit_disconnected(VC_ERR_IO, "write ClientHello failed");
                    close_sock(sock);
                    io_fd_.store(-1);
                    goto cleanup;
                }
                off += static_cast<size_t>(n);
            }
        }

        // ── Read loop ────────────────────────────────────────────────────────
        {
            voicecat::protocol::FrameCodec codec;
            std::vector<uint8_t> buf(16384);

            // Seed the keepalive clock so the first Ping goes out ~15s after connect,
            // not immediately.
            last_ping_ms_.store(std::chrono::duration_cast<std::chrono::milliseconds>(
                std::chrono::steady_clock::now().time_since_epoch()).count(),
                std::memory_order_release);

            while (!io_stop_.load(std::memory_order_acquire)) {
                drain_sends();

                // Graceful disconnect: if disconnect() queued a Disconnect{code=0} and set
                // the flag, drain_sends() just sent it. Exit the read loop now — the server
                // will close the connection on receipt, but we don't need to wait for that.
                // Setting io_stop_ prevents the emit_disconnected() call below the loop
                // (this is a user-initiated exit, not an error).
                if (graceful_disconnect_pending_.load(std::memory_order_acquire)) {
                    io_stop_.store(true, std::memory_order_release);
                    break;
                }

                // Keepalive: send a Ping every ~15s so the server's reaper doesn't drop us
                // (docs/protocol.md §7). The 50ms read timeout means this loop spins ~20×/s,
                // plenty of resolution for a 15s interval.
                {
                    auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
                        std::chrono::steady_clock::now().time_since_epoch()).count();
                    if (now_ms - last_ping_ms_.load(std::memory_order_acquire) >= kPingIntervalMs) {
                        send_ping();
                        drain_sends();  // flush the ping immediately
                    }
                }

                int n = tls_->read(buf.data(), buf.size());
                if (voicecat::crypto::TlsContext::is_timeout_error(n)) continue;
                if (n <= 0) break;

                std::vector<std::vector<uint8_t>> frames;
                if (!codec.feed(buf.data(), static_cast<size_t>(n), frames)) break;
                for (auto& frame : frames) {
                    voicecat::v1::Envelope env;
                    if (voicecat::protocol::decode_envelope(frame, env)) {
                        handle_envelope(env);
                    }
                }
            }
        }

        teardown_voice();
        tls_.reset();
        close_sock(sock);
        io_fd_.store(-1);
    }

    if (!io_stop_.load()) emit_disconnected(VC_ERR_IO, "connection closed");

cleanup:
#ifdef _WIN32
    WSACleanup();
#endif
    return;
}

void vc_client::teardown_voice() {
    std::lock_guard teardown_lk(teardown_mu_);

    udp_stop_.store(true, std::memory_order_release);
    int ufd = udp_fd_.load(std::memory_order_acquire);
    if (ufd != -1) {
        close_sock(static_cast<sock_t>(ufd));
        udp_fd_.store(-1, std::memory_order_release);
    }
    if (udp_thread_.joinable()) udp_thread_.join();
    udp_ready_.store(false, std::memory_order_release);

    talk_timer_stop_.store(true, std::memory_order_release);
    if (talk_timer_thread_.joinable()) talk_timer_thread_.join();

    audio_engine_.stop();
    {
        std::lock_guard lk(local_streams_mu_);
        for (auto& [kind, ls] : local_streams_) {
            (void)kind;
            ls.active.store(false, std::memory_order_release);
            ls.pending = false;
            ls.encoder.destroy();
        }
        local_streams_.clear();
        pending_announce_kind_.clear();
    }

    media_send_crypto_.reset();
    media_recv_crypto_.reset();

    std::lock_guard lk(remote_streams_mu_);
    remote_streams_.clear();
}

void vc_client::drain_sends() {
    while (true) {
        std::vector<uint8_t> frame;
        {
            std::lock_guard lk(send_mutex_);
            if (send_queue_.empty()) {
                send_cv_.notify_all();  // unblock disconnect()'s drain wait
                return;
            }
            frame = std::move(send_queue_.front());
            send_queue_.pop_front();
        }
        if (!tls_) { send_cv_.notify_all(); return; }
        size_t off = 0;
        while (off < frame.size()) {
            int n = tls_->write(frame.data() + off, frame.size() - off);
            if (n <= 0) {
                io_stop_.store(true);
                send_cv_.notify_all();  // unblock disconnect() even on write failure
                return;
            }
            off += static_cast<size_t>(n);
        }
    }
}

void vc_client::queue_envelope(const voicecat::v1::Envelope& env) {
    auto frame = make_frame(env);
    if (frame.empty()) return;
    std::lock_guard lk(send_mutex_);
    send_queue_.push_back(std::move(frame));
}

void vc_client::send_ping() {
    auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now().time_since_epoch()).count();
    last_ping_ms_.store(now_ms, std::memory_order_release);
    uint64_t nonce = ping_nonce_.fetch_add(1, std::memory_order_relaxed);
    {
        std::lock_guard lk(ping_mutex_);
        pending_pings_[nonce] = now_ms;
    }
    voicecat::v1::Envelope env;
    env.set_request_id(next_req_id_++);
    env.mutable_ping()->set_nonce(nonce);
    queue_envelope(env);
}

// ── Protocol dispatch ─────────────────────────────────────────────────────────

void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
    switch (env.body_case()) {
        case voicecat::v1::Envelope::kServerHello:
            handle_server_hello(env.server_hello(), env.request_id());
            break;
        case voicecat::v1::Envelope::kAuthResult:
            handle_auth_result(env.auth_result());
            break;
        case voicecat::v1::Envelope::kServerState:
            handle_server_state(env.server_state());
            break;
        case voicecat::v1::Envelope::kUserEvent:
            handle_user_event(env.user_event());
            break;
        case voicecat::v1::Envelope::kChannelEvent:
            handle_channel_event(env.channel_event());
            break;
        case voicecat::v1::Envelope::kJoinChannelResult:
            handle_join_channel_result(env.join_channel_result());
            break;
        case voicecat::v1::Envelope::kTextMessage:
            handle_text_message(env.text_message());
            break;
        case voicecat::v1::Envelope::kDisconnect:
            handle_disconnect(env.disconnect());
            break;
        case voicecat::v1::Envelope::kUdpBinding:
            handle_udp_binding_ack(env.udp_binding());
            break;
        case voicecat::v1::Envelope::kStreamAnnounceResult:
            handle_stream_announce_result(env.request_id(), env.stream_announce_result());
            break;
        case voicecat::v1::Envelope::kPong: {
            // Correlate the echoed nonce to measure RTT (docs/protocol.md §7).
            uint64_t nonce = env.pong().nonce();
            auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
                std::chrono::steady_clock::now().time_since_epoch()).count();
            std::lock_guard lk(ping_mutex_);
            auto it = pending_pings_.find(nonce);
            if (it != pending_pings_.end()) {
                last_rtt_ms_.store(now_ms - it->second, std::memory_order_relaxed);
                pending_pings_.erase(it);
            }
            break;
        }
        case voicecat::v1::Envelope::kGenericResult: {
            vc_event ev{};
            ev.type   = VC_EVENT_GENERIC_RESULT;
            ev.result = env.generic_result().ok() ? VC_OK : VC_ERR_PERMISSION_DENIED;
            ev.u32a   = env.generic_result().code();
            ev.text   = env.generic_result().message().c_str();
            emit(ev);
            break;
        }
        case voicecat::v1::Envelope::kListAccountsResult: {
            {
                std::lock_guard lk(account_list_mu_);
                last_account_list_.assign(env.list_accounts_result().accounts().begin(),
                                          env.list_accounts_result().accounts().end());
            }
            vc_event ev{};
            ev.type = VC_EVENT_ACCOUNT_LIST;
            emit(ev);
            break;
        }
        default:
            break;
    }
}

void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) {
    server_udp_port_ = static_cast<uint16_t>(msg.udp_port());

    // M4: stash the declared Ed25519 fingerprint for vc_get_server_identity_display() —
    // display-only, not the TOFU-pinned value (that's the TLS cert fingerprint, gated before
    // ClientHello was even sent — see the TOFU block above in run_io()).
    {
        const std::string& raw = msg.server_identity_fingerprint();
        static const char* hex = "0123456789abcdef";
        std::string fp_hex;
        fp_hex.reserve(raw.size() * 2);
        for (unsigned char b : raw) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; }
        std::lock_guard<std::mutex> lk(tofu_mu_);
        pending_identity_fp_hex_ = std::move(fp_hex);
    }

    // Server acknowledged our ClientHello. Now send AuthRequest (or queue it).
    std::optional<PendingAuth> auth;
    {
        std::lock_guard lk(pending_auth_mutex_);
        auth = pending_auth_;
    }
    if (!auth) return;  // caller will call authenticate_*() later

    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* ar = req.mutable_auth_request();
    if (auth->is_guest) {
        ar->mutable_guest()->set_nickname(auth->nick_or_user);
    } else {
        ar->mutable_password()->set_username(auth->nick_or_user);
        ar->mutable_password()->set_password(auth->password);
    }
    queue_envelope(req);
    (void)msg;
}

void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) {
    vc_event ev{};
    ev.type   = VC_EVENT_AUTH_RESULT;
    ev.result = msg.ok() ? VC_OK : VC_ERR_AUTH_FAILED;

    if (msg.ok()) {
        self_user_id_     = msg.self().id();
        server_session_id_ = msg.session_id();
        ev.user_id        = self_user_id_;
        set_state(VC_STATE_CONNECTED);

        // Store own permissions.
        const auto& perms = msg.permissions();
        own_permissions_.can_create_temp_channel = perms.can_create_temp_channel() ? 1 : 0;
        own_permissions_.can_kick                = perms.can_kick() ? 1 : 0;
        own_permissions_.can_ban                 = perms.can_ban() ? 1 : 0;
        own_permissions_.can_move_users          = perms.can_move_users() ? 1 : 0;
        own_permissions_.can_admin_accounts      = perms.can_admin_accounts() ? 1 : 0;
        own_permissions_.is_admin                = perms.is_admin() ? 1 : 0;

        const std::string& tok = msg.udp_token();
        if (tok.size() == udp_token_.size()) {
            std::memcpy(udp_token_.data(), tok.data(), udp_token_.size());
            start_udp_binding();
        }
    } else {
        ev.text = msg.error().c_str();
    }
    emit(ev);
}

void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& snap) {
    {
        std::lock_guard<std::mutex> lk(session_model_mu_);
        session_model_.apply_snapshot(snap);
    }
    for (const auto& u : snap.users()) sync_remote_streams(u);
    vc_event ev{};
    ev.type = VC_EVENT_CHANNEL_LIST;
    emit(ev);
}

void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) {
    {
        std::lock_guard<std::mutex> lk(session_model_mu_);
        session_model_.apply_channel_event(ce);
    }
    // Same "go look" signal vc_list_channels' callers already poll on after the initial
    // snapshot — see voicecat.h's VC_EVENT_CHANNEL_LIST doc comment.
    vc_event ev{};
    ev.type = VC_EVENT_CHANNEL_LIST;
    emit(ev);
}

void vc_client::handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg) {
    // This response only acks the request + carries our private audio config. The
    // authoritative channel change reaches us as a UserEvent::UPDATED broadcast (the server
    // no longer excludes the mover), which keeps session_model_ in sync via apply_user_event.
    vc_event ev{};
    ev.type       = VC_EVENT_JOIN_RESULT;
    ev.result     = msg.ok() ? VC_OK : VC_ERR_PROTOCOL;
    ev.channel_id = msg.channel_id();
    if (!msg.ok()) ev.text = msg.error().c_str();
    emit(ev);
}

void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
    {
        std::lock_guard<std::mutex> lk(session_model_mu_);
        session_model_.apply_user_event(ue);
    }

    vc_event ev{};
    const auto& user = ue.user();
    ev.user_id   = user.id();
    ev.channel_id = user.channel_id();

    static const char* nick_buf_ptr = nullptr;
    std::string nick = user.nickname();

    switch (ue.kind()) {
        case voicecat::v1::UserEvent::JOINED:
            ev.type = VC_EVENT_USER_JOINED;
            ev.text = nick.c_str();
            emit(ev);
            sync_remote_streams(user);
            break;
        case voicecat::v1::UserEvent::LEFT: {
            ev.type = VC_EVENT_USER_LEFT;
            emit(ev);
            std::lock_guard lk(remote_streams_mu_);
            for (auto it = remote_streams_.begin(); it != remote_streams_.end();) {
                if (it->second.first == user.id()) {
                    audio_engine_.remove_stream(it->first);
                    it = remote_streams_.erase(it);
                } else {
                    ++it;
                }
            }
            break;
        }
        case voicecat::v1::UserEvent::UPDATED:
            ev.type = VC_EVENT_USER_UPDATED;
            emit(ev);
            // M5: if this is an update to our own user, reflect server-mute/deafen locally.
            if (user.id() == self_user_id_) {
                server_muted_.store(user.server_muted(), std::memory_order_release);
                server_deafened_.store(user.server_deafened(), std::memory_order_release);
                std::lock_guard lk(remote_streams_mu_);
                bool muted = self_deafened_.load(std::memory_order_acquire) ||
                             server_deafened_.load(std::memory_order_acquire);
                for (auto& [ssrc, info] : remote_streams_) {
                    (void)info;
                    audio_engine_.set_stream_mute(ssrc, muted);
                }
            }
            sync_remote_streams(user);
            break;
        default:
            break;
    }
    (void)nick_buf_ptr;
}

void vc_client::handle_text_message(const voicecat::v1::TextMessage& msg) {
    vc_event ev{};
    ev.type       = VC_EVENT_TEXT_MESSAGE;
    ev.text_scope = (msg.scope() == voicecat::v1::TEXT_PRIVATE) ? VC_TEXT_PRIVATE : VC_TEXT_CHANNEL;
    ev.user_id    = msg.sender_id();
    ev.channel_id = msg.target_id();
    ev.text       = msg.body().c_str();
    ev.timestamp_unix_ms = static_cast<uint64_t>(msg.sent_at_unix_ms());
    emit(ev);
}

void vc_client::handle_disconnect(const voicecat::v1::Disconnect& msg) {
    io_stop_.store(true, std::memory_order_release);
    emit_disconnected(VC_ERR_IO, msg.reason().c_str());
}

// ── Auth / channel / text commands ───────────────────────────────────────────

vc_result vc_client::authenticate_guest(const char* nickname) {
    auto cur = state_net_.load(std::memory_order_acquire);
    if (cur == VC_STATE_DISCONNECTED) return VC_ERR_NOT_CONNECTED;

    PendingAuth pa{true, nickname, {}};
    {
        std::lock_guard lk(pending_auth_mutex_);
        pending_auth_ = pa;
    }

    // If already past ServerHello, send AuthRequest immediately.
    if (cur == VC_STATE_CONNECTED || cur == VC_STATE_AUTHENTICATING) {
        voicecat::v1::Envelope req;
        req.set_request_id(next_req_id_++);
        req.mutable_auth_request()->mutable_guest()->set_nickname(nickname);
        queue_envelope(req);
    }
    return VC_OK;
}

vc_result vc_client::authenticate_user(const char* username, const char* password) {
    auto cur = state_net_.load(std::memory_order_acquire);
    if (cur == VC_STATE_DISCONNECTED) return VC_ERR_NOT_CONNECTED;

    PendingAuth pa{false, username, password};
    {
        std::lock_guard lk(pending_auth_mutex_);
        pending_auth_ = pa;
    }

    if (cur == VC_STATE_CONNECTED || cur == VC_STATE_AUTHENTICATING) {
        voicecat::v1::Envelope req;
        req.set_request_id(next_req_id_++);
        auto* pw = req.mutable_auth_request()->mutable_password();
        pw->set_username(username);
        pw->set_password(password);
        queue_envelope(req);
    }
    return VC_OK;
}

vc_result vc_client::join_channel(uint32_t channel_id, const char* password) {
    if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* jc = req.mutable_join_channel();
    jc->set_channel_id(channel_id);
    // See voicecat.h's vc_join_channel doc comment: wired through to the wire message, but no
    // in-tree channel has a server-side password to check yet (no channel-creation feature
    // exists — M5+).
    if (password) jc->set_password(password);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::leave_channel() {
    if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    req.mutable_leave_channel();
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::send_text(vc_text_scope scope, uint32_t target_id, const char* utf8) {
    if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* tm = req.mutable_text_message();
    tm->set_scope(scope == VC_TEXT_PRIVATE ? voicecat::v1::TEXT_PRIVATE : voicecat::v1::TEXT_CHANNEL);
    tm->set_target_id(target_id);
    tm->set_body(utf8);
    queue_envelope(req);
    return VC_OK;
}

// ── M2: UDP binding ───────────────────────────────────────────────────────────

void vc_client::start_udp_binding() {
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    req.mutable_udp_binding()->set_udp_token(
        reinterpret_cast<const char*>(udp_token_.data()), udp_token_.size());
    queue_envelope(req);
}

void vc_client::handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg) {
    if (!msg.ack()) return;
    finish_udp_binding();
}

void vc_client::finish_udp_binding() {
    if (udp_ready_.load(std::memory_order_acquire)) return;
    if (!tls_ || server_udp_port_ == 0) return;

    media_send_crypto_ = voicecat::crypto::SodiumMediaCrypto::derive_send(*tls_, true);
    media_recv_crypto_ = voicecat::crypto::SodiumMediaCrypto::derive_recv(*tls_, true);
    if (!media_send_crypto_ || !media_recv_crypto_) {
        emit_error(VC_ERR_CRYPTO, "failed to derive media keys");
        return;
    }

    sock_t s = ::socket(AF_INET, SOCK_DGRAM, 0);
    if (s == kBadSock) {
        emit_error(VC_ERR_IO, "udp socket() failed");
        return;
    }

    struct addrinfo hints{};
    hints.ai_family   = AF_INET;
    hints.ai_socktype = SOCK_DGRAM;
    struct addrinfo* res = nullptr;
    std::string port_str = std::to_string(server_udp_port_);
    if (getaddrinfo(udp_host_.c_str(), port_str.c_str(), &hints, &res) != 0 || !res) {
        close_sock(s);
        emit_error(VC_ERR_IO, "udp hostname resolution failed");
        return;
    }

    auto* sin = reinterpret_cast<sockaddr_in*>(res->ai_addr);
    udp_dest_addr_ = sin->sin_addr.s_addr;
    udp_dest_port_ = sin->sin_port;

    auto pkt = voicecat::net::make_udp_binding_packet(udp_token_.data(), udp_token_.size());
    ::sendto(s, reinterpret_cast<const char*>(pkt.data()), static_cast<int>(pkt.size()), 0,
             res->ai_addr, static_cast<int>(res->ai_addrlen));
    freeaddrinfo(res);

    udp_fd_.store(static_cast<int>(s), std::memory_order_release);
    udp_stop_.store(false, std::memory_order_release);
    udp_ready_.store(true, std::memory_order_release);
    udp_thread_ = std::thread([this] { run_udp_recv(); });

    talk_timer_stop_.store(false, std::memory_order_release);
    talk_timer_thread_ = std::thread([this] { run_talk_timer(); });
}

void vc_client::run_udp_recv() {
    int fd = udp_fd_.load(std::memory_order_acquire);
    if (fd == -1) return;

#ifdef _WIN32
    DWORD tv = 200;
    setsockopt(static_cast<sock_t>(fd), SOL_SOCKET, SO_RCVTIMEO,
               reinterpret_cast<const char*>(&tv), sizeof(tv));
#else
    struct timeval tv{0, 200000};
    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
#endif

    // Seed the keepalive clock so the first KEEPALIVE goes out ~5s after binding, not
    // immediately (the UdpBinding bootstrap itself is a recent packet).
    last_udp_keepalive_ms_.store(std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now().time_since_epoch()).count(),
        std::memory_order_release);

    std::vector<uint8_t> buf(2048);
    while (!udp_stop_.load(std::memory_order_acquire)) {
        int n = static_cast<int>(::recv(static_cast<sock_t>(fd), reinterpret_cast<char*>(buf.data()),
                                         static_cast<int>(buf.size()), 0));
        if (n < static_cast<int>(voicecat::net::kVoiceHeaderSize)) {
            // Timeout or short packet — send a KEEPALIVE if the interval has elapsed.
            send_udp_keepalive();
            continue;
        }

        voicecat::net::VoiceFrame hdr{};
        if (!voicecat::net::parse_header(buf.data(), static_cast<size_t>(n), hdr)) continue;
        if (hdr.type == voicecat::net::kFrameKeepalive) {
            // Echoed keepalive from the server — media path is alive. (RTT measurement
            // could be added here later by correlating a nonce; not needed for NAT/timeout.)
            continue;
        }
        if (hdr.type != voicecat::net::kFrameVoice) continue;
        if (!media_recv_crypto_) continue;

        size_t sealed_len = static_cast<size_t>(n) - voicecat::net::kVoiceHeaderSize;
        std::vector<uint8_t> plain(sealed_len);
        long plain_len = media_recv_crypto_->open(
            buf.data() + voicecat::net::kVoiceHeaderSize, sealed_len, buf.data(),
            voicecat::net::kVoiceHeaderSize, plain.data(), plain.size());
        if (plain_len < 0) continue;
        plain.resize(static_cast<size_t>(plain_len));

        voicecat::audio::JitterBuffer::Frame jf;
        jf.seq         = hdr.seq;
        jf.timestamp   = hdr.timestamp;
        jf.fec_present = (hdr.flags & voicecat::net::kFlagFecPresent) != 0;
        jf.marker      = (hdr.flags & voicecat::net::kFlagMarker) != 0;
        jf.payload     = std::move(plain);
        audio_engine_.push_recv_frame(hdr.ssrc, std::move(jf));
    }
}

void vc_client::send_udp_keepalive() {
    int fd = udp_fd_.load(std::memory_order_acquire);
    if (fd == -1) return;

    auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now().time_since_epoch()).count();
    if (now_ms - last_udp_keepalive_ms_.load(std::memory_order_acquire) < kUdpKeepaliveIntervalMs)
        return;
    last_udp_keepalive_ms_.store(now_ms, std::memory_order_release);

    // Plaintext KEEPALIVE: 14-byte header, type=2, no payload, no AEAD. The server
    // identifies us by the verified UDP endpoint (set during the UdpBinding handshake).
    uint8_t pkt[voicecat::net::kVoiceHeaderSize] = {0};
    pkt[0] = voicecat::net::kFrameKeepalive;

    sockaddr_in dest{};
    dest.sin_family      = AF_INET;
    dest.sin_addr.s_addr = udp_dest_addr_;
    dest.sin_port        = udp_dest_port_;
    ::sendto(static_cast<sock_t>(fd), reinterpret_cast<const char*>(pkt),
             static_cast<int>(sizeof(pkt)), 0, reinterpret_cast<sockaddr*>(&dest), sizeof(dest));
}

namespace {
int64_t client_now_ms() {
    return std::chrono::duration_cast<std::chrono::milliseconds>(
               std::chrono::steady_clock::now().time_since_epoch())
        .count();
}

// Builds an OpusParams from a wire AudioConfig, applying the same field-by-field mapping on
// both the send (local-stream encoder) and receive (remote-stream decoder) paths — fixes the
// M2 gap where mode/dtx/complexity/application were silently dropped.
voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::AudioConfig& a) {
    voicecat::codec::OpusParams p;
    // Opus always runs at 48 kHz internally (docs/voice.md §3): the whole AudioEngine clock is
    // 48 kHz and external PCM is fed at 48 kHz, so the codec must match regardless of what a
    // channel advertises. Honoring a non-48k effective sample_rate as the codec rate would
    // create an encoder expecting e.g. 16k PCM while being fed 48k frames — wrong pitch/duration.
    // Instead the channel's sample_rate is carried as max_bandwidth_hz and caps the encoder's
    // selected audio bandwidth (narrowband/wideband/…) — a low-bitrate room still benefits.
    p.sample_rate          = 48000;
    p.max_bandwidth_hz     = a.sample_rate();  // 0 = unset → full band
    p.bitrate_bps           = a.bitrate_bps() ? a.bitrate_bps() : 24000;
    p.frame_ms              = a.frame_ms() ? a.frame_ms() : 20;
    p.stereo                = (a.mode() == voicecat::v1::MODE_STEREO);
    p.fec                   = a.fec();
    p.dtx                   = a.dtx();
    p.complexity            = a.complexity() ? a.complexity() : 10;
    p.expected_packet_loss  = a.expected_packet_loss();
    p.application            = static_cast<voicecat::codec::OpusApplication>(a.application());
    p.dred                  = a.dred();
    return p;
}

voicecat::v1::AudioConfig audio_config_from_vc(const vc_audio_config& c) {
    voicecat::v1::AudioConfig a;
    a.set_codec(c.codec);
    a.set_mode(c.mode == 1 ? voicecat::v1::MODE_STEREO : voicecat::v1::MODE_MONO);
    a.set_sample_rate(c.sample_rate);
    a.set_bitrate_bps(c.bitrate_bps);
    a.set_frame_ms(c.frame_ms);
    a.set_application(static_cast<voicecat::v1::OpusApplication>(c.application));
    a.set_fec(c.fec != 0);
    a.set_expected_packet_loss(c.expected_packet_loss);
    a.set_dtx(c.dtx != 0);
    a.set_complexity(c.complexity);
    a.set_dred(c.dred != 0);
    return a;
}

voicecat::v1::Channel channel_from_vc(const vc_channel_info& c) {
    voicecat::v1::Channel ch;
    ch.set_id(c.id);
    ch.set_parent_id(c.parent_id);
    ch.set_name(c.name ? c.name : "");
    ch.set_topic(c.topic ? c.topic : "");
    ch.set_password_protected(c.password_protected != 0);
    ch.set_max_users(c.max_users);
    ch.set_type(voicecat::v1::CHANNEL_PERMANENT);
    *ch.mutable_audio() = audio_config_from_vc(c.audio);
    ch.set_order(static_cast<int32_t>(c.sort_order));
    return ch;
}

}  // namespace

void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples, int channels) {
    std::lock_guard lk(local_streams_mu_);
    auto it = local_streams_.find(kind);
    if (it == local_streams_.end() || !it->second.active.load(std::memory_order_acquire)) return;
    // "Mic muted" only gates the MIC stream — a concurrently-running SCREEN_AUDIO share keeps
    // playing while the user's mic is muted (docs §M3 scope decision).
    // M5: server-mute is also a hard gate on MIC transmission.
    if (kind == static_cast<int>(VC_STREAM_MIC) &&
        (self_mic_muted_.load(std::memory_order_acquire) ||
         server_muted_.load(std::memory_order_acquire))) return;

    // Send-side mic noise suppression (vc_set_input_noise_reduction) — MIC only. Runs first, on
    // the raw mic, so the gain boost and the VAD gate below both see the cleaned signal. mic_ns_
    // exists for the lifetime of the MIC stream; the atomic flip gates it without touching the
    // pointer on this RT callback. RNNoise is a mono 48 kHz denoiser, so a stereo mic is downmixed
    // to mono IN PLACE here — but only when NS is enabled. With NS off this block is skipped
    // entirely, so a stereo mic keeps full stereo: we never collapse mic quality unless asked.
    if (kind == static_cast<int>(VC_STREAM_MIC) && mic_ns_ &&
        input_noise_reduction_.load(std::memory_order_relaxed)) {
        int16_t* w = const_cast<int16_t*>(pcm);
        if (channels == 2) {
            for (int i = 0; i < samples; ++i)
                w[i] = static_cast<int16_t>(
                    (static_cast<int32_t>(w[2 * i]) + static_cast<int32_t>(w[2 * i + 1])) / 2);
            channels = 1;  // rest of the pipeline (gain, gate, encode) now sees a mono frame
        }
        if (channels == 1) mic_ns_->process_capture(w, samples, 48000);
    }

    // Send-side mic input gain (vc_set_input_gain) — MIC only. Applied in place before the gate
    // so a boosted quiet mic also helps cross the VAD threshold. EnergyVadProcessor never writes
    // through its pointer, so the const_cast (same as the VAD path below) is safe; no allocation.
    if (kind == static_cast<int>(VC_STREAM_MIC)) {
        const float gain = input_gain_.load(std::memory_order_relaxed);
        if (gain != 1.0f) {
            int16_t* w = const_cast<int16_t*>(pcm);
            const int n = samples * std::max(1, channels);
            for (int i = 0; i < n; ++i) {
                int32_t v = static_cast<int32_t>(std::lround(w[i] * gain));
                w[i] = static_cast<int16_t>(std::clamp(v, -32768, 32767));
            }
        }
    }

    // Send-side input gate (docs/voice.md §11) — MIC only. SCREEN_AUDIO/AUX_DEVICE always
    // bypass this: gating a screen-share on the user's own voice activity would silently drop
    // shared music/video audio whenever the user isn't talking, which defeats the feature.
    if (kind == static_cast<int>(VC_STREAM_MIC)) {
        auto mode = current_input_mode_.load(std::memory_order_acquire);
        if (mode == VC_INPUT_PUSH_TO_TALK) {
            if (!ptt_active_.load(std::memory_order_acquire)) return;  // gate closed
        } else if (mode == VC_INPUT_VOICE_ACTIVATION && mic_vad_) {
            // EnergyVadProcessor never writes through the pointer (see apm_processor.cpp); the
            // const_cast is safe and avoids splitting ApmProcessor's interface just for this.
            if (!mic_vad_->process_capture(const_cast<int16_t*>(pcm), samples, 48000)) return;
        }
        // VC_INPUT_ALWAYS_ON: no gate — fall through and always send.
    }

    if (!media_send_crypto_) return;
    int fd = udp_fd_.load(std::memory_order_acquire);
    if (fd == -1) return;

    auto& ls = it->second;
    // Updated only after the gate above, so a VAD/PTT-closed frame never shows as "talking".
    ls.last_capture_ms.store(client_now_ms(), std::memory_order_relaxed);

    // The AudioEngine clock is fixed at 48 kHz / 20 ms, so `samples` is always 960. The encoder,
    // however, wants ls.frame_samples per call, which the channel's frame_ms can make smaller
    // (480 @10ms) or larger (1920 @40ms). Reframe to that size before encoding (docs/voice.md §3).
    const int target = ls.frame_samples;
    if (samples == target) {
        // Fast path — the common 20 ms channel: encode the engine frame directly, no buffering.
        encode_and_send_frame(ls, pcm, samples, channels, fd);
        return;
    }

    // Reframe: accumulate engine frames and emit target-sized chunks. encode_accum/upmix_scratch
    // were pre-sized in handle_stream_announce_result, so no allocation happens here.
    const int ch = std::max(1, channels);
    if (ls.accum_channels != ch) {  // mono/stereo source change — never mix the two
        ls.accum_count    = 0;
        ls.accum_channels = ch;
    }
    const size_t add   = static_cast<size_t>(samples) * static_cast<size_t>(ch);
    const size_t chunk = static_cast<size_t>(target) * static_cast<size_t>(ch);
    if (ls.accum_count + add > ls.encode_accum.size()) return;  // capacity guard (shouldn't trip)
    std::memcpy(ls.encode_accum.data() + ls.accum_count, pcm, add * sizeof(int16_t));
    ls.accum_count += add;

    size_t off = 0;
    while (ls.accum_count - off >= chunk) {
        encode_and_send_frame(ls, ls.encode_accum.data() + off, target, ch, fd);
        off += chunk;
    }
    if (off > 0) {  // shift the sub-frame remainder to the front
        const size_t rem = ls.accum_count - off;
        if (rem > 0)
            std::memmove(ls.encode_accum.data(), ls.encode_accum.data() + off,
                         rem * sizeof(int16_t));
        ls.accum_count = rem;
    }
}

void vc_client::encode_and_send_frame(LocalStream& ls, const int16_t* pcm, int samples,
                                      int channels, int fd) {
    uint8_t opus_buf[1500];
    int opus_len;
    if (channels == 2 && ls.effective_params.stereo) {
        // Real interleaved stereo PCM (a stereo mic via vc_set_capture_channels, or SCREEN_AUDIO
        // loopback) on a channel configured for stereo — encode directly, true L/R, no fold.
        // `samples` is samples-per-channel, as OpusEncoder::encode expects.
        opus_len = ls.encoder.encode(pcm, samples, opus_buf, sizeof(opus_buf));
    } else if (channels == 2) {
        // Stereo capture (stereo mic / line-in) on a MONO channel: the encoder is mono, so fold
        // L/R to mono first. Feeding interleaved pairs straight to a mono opus_encode would make
        // it read 2× the samples it should (wrong pitch / garbage). upmix_scratch is pre-sized at
        // announce and easily holds `samples` mono values. This keeps a stereo-mic toggle safe on
        // every channel: real L/R when the channel is stereo, a clean downmix when it isn't.
        int16_t* mono = ls.upmix_scratch.data();
        for (int i = 0; i < samples; ++i)
            mono[i] = static_cast<int16_t>(
                (static_cast<int32_t>(pcm[2 * i]) + static_cast<int32_t>(pcm[2 * i + 1])) / 2);
        opus_len = ls.encoder.encode(mono, samples, opus_buf, sizeof(opus_buf));
    } else if (ls.effective_params.stereo) {
        // Mono capture (mono mic, or loopback on a mono channel, or test injection) on a channel
        // configured for stereo — upmix L=R so the stream is still a spec-correct stereo Opus
        // bitstream (a stereo channel requires a stereo bitstream). upmix_scratch is pre-sized at announce.
        int16_t* st = ls.upmix_scratch.data();
        for (int i = 0; i < samples; ++i) {
            st[i * 2]     = pcm[i];
            st[i * 2 + 1] = pcm[i];
        }
        opus_len = ls.encoder.encode(st, samples, opus_buf, sizeof(opus_buf));
    } else {
        opus_len = ls.encoder.encode(pcm, samples, opus_buf, sizeof(opus_buf));
    }
    if (opus_len <= 0) return;

    voicecat::net::VoiceFrame hdr;
    hdr.ssrc      = ls.ssrc;
    hdr.seq       = media_send_crypto_->peek_send_counter();
    hdr.timestamp = ls.timestamp;
    ls.timestamp += static_cast<uint32_t>(samples);

    // Talkspurt marker: first frame overall, or the first after a transmission gap longer than a
    // few frame intervals (VAD/PTT closed, or DTX silence). The sender omits silence from the
    // timestamp, so this is how the receiver knows to reseed its playout clock (see on_playback).
    const int64_t now_ms      = client_now_ms();
    const int64_t frame_ms    = std::max<int64_t>(1, samples / 48);  // @48 kHz
    if (ls.last_send_ms < 0 || (now_ms - ls.last_send_ms) > frame_ms * 3)
        hdr.flags |= voicecat::net::kFlagMarker;
    ls.last_send_ms = now_ms;
    // DTX: Opus emits a 1–2 byte comfort-noise packet when it gates silence. Flag it so the
    // receiver can treat it as such (informational; the bounded-depth playout handles timing).
    if (opus_len <= 2) hdr.flags |= voicecat::net::kFlagDtx;

    uint8_t header_bytes[voicecat::net::kVoiceHeaderSize];
    voicecat::net::serialize_header(hdr, header_bytes);

    uint8_t sealed[1500];
    long sealed_len = media_send_crypto_->seal(opus_buf, static_cast<size_t>(opus_len),
                                                header_bytes, voicecat::net::kVoiceHeaderSize,
                                                sealed, sizeof(sealed));
    if (sealed_len < 0) return;

    std::vector<uint8_t> pkt(voicecat::net::kVoiceHeaderSize + static_cast<size_t>(sealed_len));
    std::memcpy(pkt.data(), header_bytes, voicecat::net::kVoiceHeaderSize);
    std::memcpy(pkt.data() + voicecat::net::kVoiceHeaderSize, sealed,
                static_cast<size_t>(sealed_len));

    sockaddr_in dest{};
    dest.sin_family      = AF_INET;
    dest.sin_addr.s_addr = udp_dest_addr_;
    dest.sin_port        = udp_dest_port_;
    ::sendto(static_cast<sock_t>(fd), reinterpret_cast<const char*>(pkt.data()),
             static_cast<int>(pkt.size()), 0, reinterpret_cast<sockaddr*>(&dest), sizeof(dest));
}

void vc_client::ensure_audio_running() {
    if (audio_engine_.running()) return;
    voicecat::audio::AudioParams p;
    p.sample_rate       = 48000;
    p.capture_channels  = 1;
    p.playback_channels = 2;  // true stereo output (audio_engine.cpp on_playback)
    p.frame_ms          = 20;
    {
        std::lock_guard lk(local_streams_mu_);
        auto it = local_streams_.find(static_cast<int>(VC_STREAM_MIC));
        if (it != local_streams_.end()) {
            p.capture_device_id = it->second.capture_device_id;
            p.capture_channels  = it->second.capture_channels;
            // iOS VPIO: when the mic is fed externally, skip the hardware capture device — the
            // Swift AVAudioEngine VPIO path feeds processed mic PCM via vc_stream_feed_pcm.
            p.external_capture  = it->second.external_feed;
        }
    }
    // iOS unified mode (external_playback): capture is ALWAYS external — the Swift AVAudioEngine
    // owns the only mic path and feeds via vc_stream_feed_pcm. Force external_capture so start()
    // never opens a hardware mic device, even when ensure_audio_running runs before a MIC stream
    // exists (a remote stream arriving first — e.g. the post-auth ServerStateSnapshot — starts
    // the engine for playback). Without this the core opens a miniaudio capture device that races
    // the AVAudioEngine tap → dual mic capture → duplicated, crackly audio on the remote end.
    // No-op on desktop, where external_playback_ is never set.
    if (external_playback_.load(std::memory_order_acquire)) {
        p.external_capture = true;
    }
    // iOS VPIO: skip the hardware playback device and drive the mixer on a timer, delivering the
    // final mix to the mixed-output sink for the Swift VPIO renderer (vc_set_external_playback).
    audio_engine_.set_external_playback(external_playback_.load(std::memory_order_acquire));
    audio_engine_.start(p, [this](int kind, const int16_t* pcm, int samples, int channels) {
        on_capture_frame(kind, pcm, samples, channels);
    });
}

void vc_client::sync_remote_streams(const voicecat::v1::User& user) {
    if (user.id() == self_user_id_) return;

    std::vector<uint32_t> current_ssrcs;
    for (const auto& si : user.streams()) current_ssrcs.push_back(si.ssrc());

    std::vector<std::pair<uint32_t, uint32_t>> newly_added;  // {ssrc, stream_id}
    {
        std::lock_guard lk(remote_streams_mu_);
        for (const auto& si : user.streams()) {
            uint32_t ssrc = si.ssrc();
            if (remote_streams_.count(ssrc)) continue;

            remote_streams_[ssrc] = {user.id(), si.stream_id()};

            voicecat::codec::OpusParams p = opus_params_from_audio_config(si.audio());
            // Only a MIC stream is voice; receive-side NR denoises voice only (docs/voice.md §10).
            bool is_voice = si.kind() == voicecat::v1::STREAM_MIC;
            audio_engine_.init_recv_stream(ssrc, p, user.id(), si.stream_id(), is_voice);
            bool muted = self_deafened_.load(std::memory_order_acquire) ||
                         server_deafened_.load(std::memory_order_acquire);
            audio_engine_.set_stream_mute(ssrc, muted);

            newly_added.emplace_back(ssrc, si.stream_id());
        }

        for (auto it = remote_streams_.begin(); it != remote_streams_.end();) {
            if (it->second.first == user.id() &&
                std::find(current_ssrcs.begin(), current_ssrcs.end(), it->first) ==
                    current_ssrcs.end()) {
                uint32_t stream_id = it->second.second;
                audio_engine_.remove_stream(it->first);
                it = remote_streams_.erase(it);

                vc_event ev{};
                ev.type      = VC_EVENT_STREAM_STOPPED;
                ev.user_id   = user.id();
                ev.stream_id = stream_id;
                emit(ev);
            } else {
                ++it;
            }
        }
    }

    if (!newly_added.empty()) ensure_audio_running();
    for (auto& [ssrc, stream_id] : newly_added) {
        (void)ssrc;
        vc_event ev{};
        ev.type      = VC_EVENT_STREAM_STARTED;
        ev.user_id   = user.id();
        ev.stream_id = stream_id;
        emit(ev);
    }
}

// ── M2: stream / device control ──────────────────────────────────────────────

vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;

    int kind = static_cast<int>(desc.kind);
    uint64_t req_id;
    {
        std::lock_guard lk(local_streams_mu_);
        auto& ls = local_streams_[kind];  // default-constructs if this kind is new
        if (ls.active.load(std::memory_order_acquire) || ls.pending) return VC_ERR_ALREADY;

        uint32_t sid = next_local_stream_id_++;
        ls.stream_id    = sid;
        ls.pending      = true;
        ls.external_feed = (desc.external_feed != 0);
        if (out_stream_id) *out_stream_id = sid;

        req_id = next_req_id_++;
        ls.pending_request_id = req_id;
        pending_announce_kind_[req_id] = kind;
    }

    voicecat::v1::Envelope req;
    req.set_request_id(req_id);
    auto* ann = req.mutable_stream_announce();
    ann->set_kind(desc.kind == VC_STREAM_SCREEN_AUDIO ? voicecat::v1::STREAM_SCREEN_AUDIO
                  : desc.kind == VC_STREAM_AUX_DEVICE  ? voicecat::v1::STREAM_AUX_DEVICE
                                                        : voicecat::v1::STREAM_MIC);
    if (desc.label) ann->set_label(desc.label);
    auto* audio = ann->mutable_requested_audio();
    audio->set_sample_rate(48000);
    // bitrate_bps intentionally left unset (0 = "no preference"): the channel's AudioConfig
    // is authoritative when the channel has one (docs/voice.md §3) — a client-side default
    // here would otherwise always clamp the channel's bitrate down to this value. frame_ms/
    // fec are likewise channel-controlled when a channel config exists; these are only used
    // as a fallback when it doesn't.
    audio->set_frame_ms(20);
    audio->set_fec(true);
    queue_envelope(req);
    return VC_OK;
}

void vc_client::handle_stream_announce_result(uint64_t req_id,
                                               const voicecat::v1::StreamAnnounceResult& msg) {
    uint32_t self_uid;
    uint32_t emit_stream_id;
    bool     ok_to_emit = false;
    int      kind;
    int      loopback_channels = 1;  // only meaningful for SCREEN_AUDIO; set under the lock
    bool     loopback_external = false;
    {
        std::lock_guard lk(local_streams_mu_);
        auto pit = pending_announce_kind_.find(req_id);
        if (pit == pending_announce_kind_.end()) return;  // stray/duplicate — ignore
        kind = pit->second;
        pending_announce_kind_.erase(pit);

        auto sit = local_streams_.find(kind);
        if (sit == local_streams_.end() || !sit->second.pending) return;
        auto& ls = sit->second;
        ls.pending = false;

        if (!msg.ok()) {
            emit_error(VC_ERR_PROTOCOL, msg.error().c_str());
            return;
        }

        ls.ssrc      = msg.ssrc();
        ls.timestamp = 0;
        ls.effective_params  = opus_params_from_audio_config(msg.effective_audio());
        ls.frame_samples = static_cast<uint16_t>(voicecat::codec::opus_frame_samples(ls.effective_params));

        if (!ls.encoder.init(ls.effective_params)) {
            emit_error(VC_ERR_AUDIO, "opus encoder init failed");
            return;
        }

        // Pre-size the reframe buffers used by on_capture_frame when the channel's frame_ms
        // differs from the engine's 20 ms (frame_samples != 960). Worst case the accumulator
        // holds one sub-frame remainder (< frame_samples) plus one engine block (960 samples),
        // interleaved over up to 2 channels — so (frame_samples + 960) * 2. upmix_scratch sizes
        // one frame_samples chunk as stereo. Allocated here (control thread), never on the RT
        // audio thread (architecture.md §3).
        const size_t fs = ls.frame_samples;
        ls.encode_accum.assign((fs + 960) * 2, 0);
        ls.upmix_scratch.assign(fs * 2, 0);
        ls.accum_count    = 0;
        ls.accum_channels = 0;

        ls.active.store(true, std::memory_order_release);
        self_uid       = self_user_id_;
        emit_stream_id = ls.stream_id;
        ok_to_emit     = true;

        // Construct the MIC VAD once, here on io_thread_ (not the RT capture callback) — see
        // client.h's comment on mic_vad_.
        if (kind == static_cast<int>(VC_STREAM_MIC) && !mic_vad_) {
            mic_vad_ = voicecat::audio::ApmProcessor::create_vad(
                vad_threshold_.load(std::memory_order_relaxed));
        }
        // Mic noise suppressor, built once here (not on the RT capture callback). Always created
        // so the toggle is a pure atomic flip — see client.h's comment on mic_ns_.
        if (kind == static_cast<int>(VC_STREAM_MIC) && !mic_ns_) {
            mic_ns_ = voicecat::audio::ApmProcessor::create();
        }

        // SCREEN_AUDIO loopback opens the WASAPI device in the channel's mode: stereo capture
        // when the channel is stereo (real L/R, no downmix), mono otherwise. Captured under
        // the lock alongside the rest of the LocalStream setup; used below after unlock.
        if (kind == static_cast<int>(VC_STREAM_SCREEN_AUDIO)) {
            loopback_channels  = ls.effective_params.stereo ? 2 : 1;
            loopback_external  = ls.external_feed;
        }
    }

    ensure_audio_running();
    if (kind == static_cast<int>(VC_STREAM_SCREEN_AUDIO) && !loopback_external) {
        audio_engine_.start_loopback_capture(kind, loopback_channels);
    }

    if (ok_to_emit) {
        vc_event ev{};
        ev.type      = VC_EVENT_STREAM_STARTED;
        ev.user_id   = self_uid;
        ev.stream_id = emit_stream_id;
        emit(ev);
    }
}

vc_result vc_client::stream_stop(uint32_t stream_id) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;

    int  stopped_kind      = -1;
    bool stopped_external  = false;
    {
        std::lock_guard lk(local_streams_mu_);
        for (auto& [k, ls] : local_streams_) {
            if (ls.stream_id == stream_id) { stopped_kind = k; break; }
        }
        LocalStream* ls = find_local_stream_by_id(stream_id);
        if (!ls || !ls->active.load(std::memory_order_acquire)) return VC_ERR_INVALID_ARG;
        stopped_external = ls->external_feed;
        ls->active.store(false, std::memory_order_release);
        ls->encoder.destroy();
    }

    if (stopped_kind == static_cast<int>(VC_STREAM_SCREEN_AUDIO) && !stopped_external) {
        audio_engine_.stop_loopback_capture();
    }

    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    req.mutable_stream_stop()->set_stream_id(stream_id);
    queue_envelope(req);

    vc_event ev{};
    ev.type      = VC_EVENT_STREAM_STOPPED;
    ev.user_id   = self_user_id_;
    ev.stream_id = stream_id;
    emit(ev);
    return VC_OK;
}

vc_client::LocalStream* vc_client::find_local_stream_by_id(uint32_t stream_id) {
    for (auto& [kind, ls] : local_streams_) {
        (void)kind;
        if (ls.stream_id == stream_id) return &ls;
    }
    return nullptr;
}

vc_result vc_client::set_input_device(uint32_t stream_id, const char* device_id) {
    int kind = -1;
    {
        std::lock_guard lk(local_streams_mu_);
        LocalStream* ls = find_local_stream_by_id(stream_id);
        if (!ls) return VC_ERR_INVALID_ARG;
        ls->capture_device_id = device_id ? device_id : "";
        for (auto& [k, entry] : local_streams_) {
            if (&entry == ls) { kind = k; break; }
        }
    }
    // Only the real capture device (MIC) is affected by device selection — SCREEN_AUDIO uses
    // loopback capture (no input device to pick) and AUX_DEVICE isn't backed by a real device
    // path yet. Restart the engine unconditionally when it's already running so the new device
    // id takes effect; AudioEngine doesn't expose a getter for "is this the same device" so we
    // don't try to skip the restart when it happens to be a no-op change.
    if (kind == static_cast<int>(VC_STREAM_MIC) && audio_engine_.running()) {
        audio_engine_.stop();
        ensure_audio_running();
    }
    return VC_OK;
}

vc_result vc_client::set_capture_channels(uint32_t stream_id, uint32_t channels) {
    if (channels != 1 && channels != 2) return VC_ERR_INVALID_ARG;
    std::lock_guard lk(local_streams_mu_);
    LocalStream* ls = find_local_stream_by_id(stream_id);
    if (!ls) return VC_ERR_INVALID_ARG;
    ls->capture_channels = channels;
    // Engine restart is the caller's responsibility (via vc_audio_restart), issued AFTER
    // AVAudioSession routing has settled. The stored channel count is picked up by
    // ensure_audio_running() on the next (re)start. On iOS this avoids the race where
    // starting the stereo capture AudioUnit immediately (before the playback device is
    // committed to its route) collapses the A2DP output.
    return VC_OK;
}

vc_result vc_client::set_input_mode(vc_input_mode mode) {
    current_input_mode_.store(mode, std::memory_order_release);
    return VC_OK;
}

vc_result vc_client::set_vad_threshold(float threshold) {
    if (threshold < 0.0f || threshold > 1.0f) return VC_ERR_INVALID_ARG;
    vad_threshold_.store(threshold, std::memory_order_relaxed);
    if (mic_vad_) mic_vad_->set_threshold(threshold);
    return VC_OK;
}

vc_result vc_client::set_push_to_talk(bool active) {
    ptt_active_.store(active, std::memory_order_release);
    return VC_OK;
}

vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    self_mic_muted_.store(mic_muted, std::memory_order_release);
    self_deafened_.store(deafened, std::memory_order_release);

    std::lock_guard lk(remote_streams_mu_);
    bool muted = deafened || server_deafened_.load(std::memory_order_acquire);
    for (auto& [ssrc, info] : remote_streams_) {
        (void)info;
        audio_engine_.set_stream_mute(ssrc, muted);
    }
    return VC_OK;
}

vc_result vc_client::set_output_volume(float gain) {
    audio_engine_.set_output_volume(gain < 0.0f ? 0.0f : gain);
    return VC_OK;
}

vc_result vc_client::set_input_gain(float gain) {
    input_gain_.store(gain < 0.0f ? 0.0f : gain, std::memory_order_relaxed);
    return VC_OK;
}

vc_result vc_client::set_input_noise_reduction(bool enable) {
    // Pure local toggle (like set_input_gain): mic_ns_ is created with the MIC stream, this only
    // flips whether the capture callback runs it. Safe to call before a MIC stream exists.
    input_noise_reduction_.store(enable, std::memory_order_relaxed);
    return VC_OK;
}

vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain,
                                       bool muted, bool noise_reduction) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    uint32_t ssrc  = 0;
    bool     found = false;
    {
        std::lock_guard<std::mutex> lk(session_model_mu_);
        const auto* user = session_model_.find_user(user_id);
        if (user) {
            for (const auto& s : user->streams) {
                if (s.stream_id == stream_id) { ssrc = s.ssrc; found = true; break; }
            }
        }
    }
    if (!found) return VC_ERR_INVALID_ARG;
    audio_engine_.set_stream_gain(ssrc, gain);
    audio_engine_.set_stream_mute(ssrc, muted);
    audio_engine_.set_stream_noise_reduction(ssrc, noise_reduction);
    return VC_OK;
}

vc_result vc_client::get_remote_stream(uint32_t user_id, uint32_t stream_id,
                                       vc_remote_stream_state* out) {
    if (out == nullptr) return VC_ERR_INVALID_ARG;
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    uint32_t ssrc  = 0;
    bool     found = false;
    {
        std::lock_guard<std::mutex> lk(session_model_mu_);
        const auto* user = session_model_.find_user(user_id);
        if (user) {
            for (const auto& s : user->streams) {
                if (s.stream_id == stream_id) { ssrc = s.ssrc; found = true; break; }
            }
        }
    }
    if (!found) return VC_ERR_INVALID_ARG;
    // The (user, stream) is known. The RemoteStream entry may not exist yet if the listener
    // has neither set any control nor received audio for it — in that case report the
    // defaults (docs/voice.md §10) so the UI opens at 100/unmuted/NR-off.
    float gain = 1.0f; bool mute = false; bool nr = false;
    if (audio_engine_.get_stream_state(ssrc, gain, mute, nr)) {
        out->gain             = gain;
        out->muted            = mute ? 1 : 0;
        out->noise_reduction  = nr   ? 1 : 0;
    } else {
        out->gain             = 1.0f;
        out->muted            = 0;
        out->noise_reduction  = 0;
    }
    return VC_OK;
}

vc_result vc_client::audio_suspend() {
    return audio_engine_.suspend() ? VC_OK : VC_ERR_AUDIO;
}

vc_result vc_client::audio_resume() {
    return audio_engine_.resume() ? VC_OK : VC_ERR_AUDIO;
}

vc_result vc_client::audio_restart() {
    // Full uninit + re-init (not just stop/start like suspend/resume) so the miniaudio
    // devices reopen against the current AVAudioSession route.
    // IMPORTANT: only restart if the engine was already running — calling
    // ensure_audio_running() when the engine isn't running would start it prematurely
    // (opening the capture device / mic without an active mic stream), which on iOS
    // triggers a route reconfiguration that can collapse the output route.
    bool was_running = audio_engine_.running();
    audio_engine_.stop();
    if (was_running) {
        ensure_audio_running();
    }
    return VC_OK;
}

vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
                                             vc_audio_config* out) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;

    if (user_id == self_user_id_) {
        std::lock_guard lk(local_streams_mu_);
        LocalStream* ls = find_local_stream_by_id(stream_id);
        if (!ls || !ls->active.load(std::memory_order_acquire)) return VC_ERR_INVALID_ARG;
        const auto& p = ls->effective_params;
        out->codec                = 0;
        out->mode                 = p.stereo ? 1u : 0u;
        // Report the channel's configured sample_rate (carried as max_bandwidth_hz), not the
        // fixed 48 kHz codec clock — matches what the remote-stream path below reports.
        out->sample_rate          = p.max_bandwidth_hz ? p.max_bandwidth_hz : p.sample_rate;
        out->bitrate_bps          = p.bitrate_bps;
        out->frame_ms             = p.frame_ms;
        out->application          = static_cast<uint32_t>(p.application);
        out->fec                  = p.fec ? 1 : 0;
        out->expected_packet_loss = p.expected_packet_loss;
        out->dtx                  = p.dtx ? 1 : 0;
        out->complexity           = p.complexity;
        out->dred                 = p.dred ? 1 : 0;
        return VC_OK;
    }

    std::lock_guard<std::mutex> lk(session_model_mu_);
    const auto* user = session_model_.find_user(user_id);
    if (!user) return VC_ERR_INVALID_ARG;
    for (const auto& s : user->streams) {
        if (s.stream_id != stream_id) continue;
        out->codec                = 0;
        out->mode                 = s.mode;
        out->sample_rate          = s.sample_rate;
        out->bitrate_bps          = s.bitrate_bps;
        out->frame_ms             = s.frame_ms;
        out->application          = s.application;
        out->fec                  = s.fec ? 1 : 0;
        out->expected_packet_loss = s.expected_packet_loss;
        out->dtx                  = s.dtx ? 1 : 0;
        out->complexity           = s.complexity;
        out->dred                 = s.dred ? 1 : 0;
        return VC_OK;
    }
    return VC_ERR_INVALID_ARG;
}

vc_result vc_client::stream_feed_pcm(uint32_t stream_id, const int16_t* pcm,
                                     size_t samples_per_channel, uint32_t channels) {
    int kind = -1;
    {
        std::lock_guard lk(local_streams_mu_);
        for (auto& [k, ls] : local_streams_) {
            if (ls.stream_id == stream_id && ls.active.load(std::memory_order_acquire)) {
                kind = k;
                break;
            }
        }
    }
    if (kind < 0) return VC_ERR_INVALID_ARG;
    audio_engine_.inject_capture(kind, pcm, samples_per_channel, static_cast<int>(channels));
    return VC_OK;
}

vc_result vc_client::set_pcm_sink(vc_pcm_sink_cb cb, void* user) {
    audio_engine_.set_pcm_sink(
        reinterpret_cast<voicecat::audio::AudioEngine::PcmSink>(cb), user);
    return VC_OK;
}

vc_result vc_client::set_mixed_output_sink(vc_mixed_output_cb cb, void* user) {
    audio_engine_.set_mixed_output_sink(
        reinterpret_cast<voicecat::audio::AudioEngine::MixedSink>(cb), user);
    return VC_OK;
}

vc_result vc_client::set_external_playback(bool enable) {
    external_playback_.store(enable, std::memory_order_release);
    // Stored on the engine too; takes effect on the next start()/vc_audio_restart() (matching
    // the vc_set_capture_channels "apply on next restart" contract).
    audio_engine_.set_external_playback(enable);
    return VC_OK;
}

vc_result vc_client::test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples) {
    return stream_feed_pcm(stream_id, pcm, samples, 1);
}

vc_result vc_client::list_devices(vc_device_kind kind, vc_device_list* out) {
#ifdef VOICECAT_HAS_AUDIO
    // Works in any connection state — device pickers need to populate pre-connect.
    auto devices = voicecat::audio::AudioEngine::enumerate_devices(kind == VC_DEVICE_INPUT);

    auto* items = new vc_device[devices.size()];
    for (size_t i = 0; i < devices.size(); ++i) {
        const auto& d = devices[i];
        auto* id   = new char[d.id.size() + 1];
        auto* name = new char[d.name.size() + 1];
        std::memcpy(id, d.id.c_str(), d.id.size() + 1);
        std::memcpy(name, d.name.c_str(), d.name.size() + 1);
        items[i].id         = id;
        items[i].name       = name;
        items[i].is_default = d.is_default ? 1 : 0;
    }
    out->items = items;
    out->count = devices.size();
    return VC_OK;
#else
    (void)kind;
    out->items = nullptr;
    out->count = 0;
    return VC_ERR_NOT_IMPLEMENTED;
#endif
}

// ── M4: channel/user/stream snapshot getters ─────────────────────────────────

vc_result vc_client::list_channels(vc_channel_list* out) {
    std::lock_guard<std::mutex> lk(session_model_mu_);
    const auto& channels = session_model_.channels();
    auto* items = new vc_channel[channels.size()];
    for (size_t i = 0; i < channels.size(); ++i) {
        const auto& ch = channels[i];
        auto* name  = new char[ch.name.size() + 1];
        auto* topic = new char[ch.topic.size() + 1];
        std::memcpy(name, ch.name.c_str(), ch.name.size() + 1);
        std::memcpy(topic, ch.topic.c_str(), ch.topic.size() + 1);
        items[i].id                 = ch.id;
        items[i].parent_id          = ch.parent_id;
        items[i].name               = name;
        items[i].topic              = topic;
        items[i].password_protected = ch.password_protected ? 1 : 0;
        items[i].max_users          = ch.max_users;
    }
    out->items = items;
    out->count = channels.size();
    return VC_OK;
}

vc_result vc_client::list_users(vc_user_list* out) {
    std::lock_guard<std::mutex> lk(session_model_mu_);
    const auto& users = session_model_.users();
    auto* items = new vc_user[users.size()];
    for (size_t i = 0; i < users.size(); ++i) {
        const auto& u = users[i];
        auto* nick = new char[u.nickname.size() + 1];
        std::memcpy(nick, u.nickname.c_str(), u.nickname.size() + 1);
        items[i].id              = u.id;
        items[i].nickname        = nick;
        items[i].is_guest        = u.is_guest ? 1 : 0;
        items[i].channel_id      = u.channel_id;
        items[i].self_mic_muted  = u.self_mic_muted ? 1 : 0;
        items[i].self_deafened   = u.self_deafened ? 1 : 0;
        items[i].server_muted    = u.server_muted ? 1 : 0;
        items[i].server_deafened = u.server_deafened ? 1 : 0;
    }
    out->items = items;
    out->count = users.size();
    return VC_OK;
}

vc_result vc_client::list_user_streams(uint32_t user_id, vc_stream_summary_list* out) {
    std::lock_guard<std::mutex> lk(session_model_mu_);
    const auto* user = session_model_.find_user(user_id);
    if (!user) return VC_ERR_INVALID_ARG;
    auto* items = new vc_stream_summary[user->streams.size()];
    for (size_t i = 0; i < user->streams.size(); ++i) {
        const auto& s = user->streams[i];
        auto* label = new char[s.label.size() + 1];
        std::memcpy(label, s.label.c_str(), s.label.size() + 1);
        items[i].stream_id = s.stream_id;
        items[i].kind      = static_cast<vc_stream_kind>(s.kind);
        items[i].label     = label;
    }
    out->items = items;
    out->count = user->streams.size();
    return VC_OK;
}

// ── M4: TOFU server-identity gate ─────────────────────────────────────────────

vc_result vc_client::confirm_server_identity(bool accept) {
    std::lock_guard<std::mutex> lk(tofu_mu_);
    if (!tofu_decision_pending_) return VC_ERR_INVALID_ARG;
    tofu_accept_ = accept;
    tofu_decision_pending_ = false;
    tofu_cv_.notify_all();
    return VC_OK;
}

vc_result vc_client::get_server_identity_display(char* out_buf, size_t buf_cap,
                                                  size_t* out_len) {
    std::string display;
    {
        std::lock_guard<std::mutex> lk(tofu_mu_);
        display = pending_identity_fp_hex_;
    }
    if (out_len) *out_len = display.size();
    if (!out_buf) return VC_OK;  // size-query mode
    if (buf_cap < display.size() + 1) return VC_ERR_INVALID_ARG;
    std::memcpy(out_buf, display.c_str(), display.size() + 1);
    return VC_OK;
}

// ── M5: Moderation & admin ───────────────────────────────────────────────────

vc_result vc_client::kick_user(uint32_t user_id, const char* reason) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* k = req.mutable_kick();
    k->set_user_id(user_id);
    k->set_reason(reason ? reason : "");
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::ban_user(uint32_t user_id, const char* reason, uint64_t expires_unix_ms) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* b = req.mutable_ban();
    b->set_user_id(user_id);
    b->set_reason(reason ? reason : "");
    b->set_expires_unix_ms(expires_unix_ms);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::set_permission(uint32_t user_id, const vc_permissions* perms) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    if (!perms) return VC_ERR_INVALID_ARG;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* sp = req.mutable_set_permission();
    sp->set_user_id(user_id);
    auto* p = sp->mutable_permissions();
    p->set_can_create_temp_channel(perms->can_create_temp_channel != 0);
    p->set_can_kick(perms->can_kick != 0);
    p->set_can_ban(perms->can_ban != 0);
    p->set_can_move_users(perms->can_move_users != 0);
    p->set_can_admin_accounts(perms->can_admin_accounts != 0);
    p->set_is_admin(perms->is_admin != 0);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::set_server_mute(uint32_t user_id, bool muted, bool deafened) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* sm = req.mutable_server_mute();
    sm->set_user_id(user_id);
    sm->set_muted(muted);
    sm->set_deafened(deafened);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::move_user(uint32_t user_id, uint32_t channel_id) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* m = req.mutable_move_user();
    m->set_user_id(user_id);
    m->set_channel_id(channel_id);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::create_channel(const vc_channel_info* info) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    if (!info || !info->name) return VC_ERR_INVALID_ARG;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* cc = req.mutable_create_channel();
    *cc->mutable_channel() = channel_from_vc(*info);
    if (info->password_protected && info->password) cc->set_password(info->password);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::edit_channel(const vc_channel_info* info) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    if (!info || !info->name) return VC_ERR_INVALID_ARG;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* ec = req.mutable_edit_channel();
    *ec->mutable_channel() = channel_from_vc(*info);
    if (info->password_protected && info->password) ec->set_password(info->password);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::delete_channel(uint32_t channel_id) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    req.mutable_delete_channel()->set_channel_id(channel_id);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::create_account(const char* username, const char* password) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    if (!username || !password) return VC_ERR_INVALID_ARG;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* ca = req.mutable_create_account();
    ca->set_username(username);
    ca->set_password(password);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::reset_password(const char* username, const char* new_password) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    if (!username || !new_password) return VC_ERR_INVALID_ARG;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    auto* rp = req.mutable_reset_password();
    rp->set_username(username);
    rp->set_new_password(new_password);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::delete_account(const char* username) {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    if (!username) return VC_ERR_INVALID_ARG;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    req.mutable_delete_account()->set_username(username);
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::list_accounts() {
    if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
    voicecat::v1::Envelope req;
    req.set_request_id(next_req_id_++);
    req.mutable_list_accounts();
    queue_envelope(req);
    return VC_OK;
}

vc_result vc_client::get_account_list(vc_account_list* out) {
    if (!out) return VC_ERR_INVALID_ARG;
    std::lock_guard lk(account_list_mu_);
    auto* items = new vc_account[last_account_list_.size()];
    for (size_t i = 0; i < last_account_list_.size(); ++i) {
        const auto& a = last_account_list_[i];
        auto* user = new char[a.username().size() + 1];
        std::memcpy(user, a.username().c_str(), a.username().size() + 1);
        items[i].username           = user;
        items[i].is_admin           = a.is_admin() ? 1 : 0;
        items[i].created_at_unix_ms = a.created_at_unix_ms();
        items[i].last_login_unix_ms = a.last_login_unix_ms();
    }
    out->items = items;
    out->count = last_account_list_.size();
    return VC_OK;
}

vc_result vc_client::get_permissions(vc_permissions* out) {
    if (!out) return VC_ERR_INVALID_ARG;
    *out = own_permissions_;
    return state_net_.load(std::memory_order_acquire) == VC_STATE_CONNECTED ? VC_OK : VC_ERR_NOT_CONNECTED;
}

void vc_client::run_talk_timer() {
    while (!talk_timer_stop_.load(std::memory_order_acquire)) {
        // Remote streams: ask the engine for edge-triggered transitions, map ssrc -> (user,
        // stream) via remote_streams_, emit.
        for (auto& [ssrc, talking] : audio_engine_.poll_talk_transitions()) {
            uint32_t uid = 0, sid = 0;
            {
                std::lock_guard lk(remote_streams_mu_);
                auto it = remote_streams_.find(ssrc);
                if (it == remote_streams_.end()) continue;
                uid = it->second.first;
                sid = it->second.second;
            }
            vc_event ev{};
            ev.type      = VC_EVENT_TALK_STATE;
            ev.user_id   = uid;
            ev.stream_id = sid;
            ev.u32a      = talking ? 1u : 0u;
            emit(ev);
        }

        // Local streams: same hangover logic, driven by on_capture_frame's last_capture_ms.
        std::vector<vc_event> local_events;
        {
            std::lock_guard lk(local_streams_mu_);
            int64_t now = client_now_ms();
            for (auto& [kind, ls] : local_streams_) {
                (void)kind;
                if (!ls.active.load(std::memory_order_acquire)) continue;
                bool now_talking =
                    (now - ls.last_capture_ms.load(std::memory_order_relaxed)) < kTalkHangoverMs;
                if (now_talking != ls.talking) {
                    ls.talking = now_talking;
                    vc_event ev{};
                    ev.type      = VC_EVENT_TALK_STATE;
                    ev.user_id   = self_user_id_;
                    ev.stream_id = ls.stream_id;
                    ev.u32a      = now_talking ? 1u : 0u;
                    local_events.push_back(ev);
                }
            }
        }
        for (auto& ev : local_events) emit(ev);

        std::this_thread::sleep_for(std::chrono::milliseconds(kTalkPollMs));
    }
}

#else  // !VOICECAT_HAS_NET

// ── M0 stub implementations ───────────────────────────────────────────────────

vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {}
vc_client::~vc_client() = default;

void vc_client::emit(const vc_event& ev) const {
    if (cb_.on_event) cb_.on_event(cb_.user, &ev);
}

vc_result vc_client::connect(const char*, uint16_t)        { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::disconnect()                           { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::authenticate_guest(const char*)        { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::authenticate_user(const char*, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::join_channel(uint32_t, const char*)   { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::leave_channel()                        { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::stream_stop(uint32_t)                 { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_capture_channels(uint32_t, uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_input_mode(vc_input_mode)         { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_vad_threshold(float)              { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_input_gain(float)                 { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_input_noise_reduction(bool)      { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_push_to_talk(bool)                { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_self_mute(bool, bool)             { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::get_remote_stream(uint32_t, uint32_t, vc_remote_stream_state*) {
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::send_text(vc_text_scope, uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::list_devices(vc_device_kind, vc_device_list* out) {
    out->items = nullptr;
    out->count = 0;
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::get_stream_audio_config(uint32_t, uint32_t, vc_audio_config*) {
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::stream_feed_pcm(uint32_t, const int16_t*, size_t, uint32_t) {
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::set_pcm_sink(vc_pcm_sink_cb, void*) {
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::set_mixed_output_sink(vc_mixed_output_cb, void*) {
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::set_external_playback(bool) {
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::test_inject_capture(uint32_t, const int16_t*, size_t) {
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::list_channels(vc_channel_list* out) {
    out->items = nullptr;
    out->count = 0;
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::list_users(vc_user_list* out) {
    out->items = nullptr;
    out->count = 0;
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::list_user_streams(uint32_t, vc_stream_summary_list* out) {
    out->items = nullptr;
    out->count = 0;
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::confirm_server_identity(bool) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::get_server_identity_display(char*, size_t, size_t* out_len) {
    if (out_len) *out_len = 0;
    return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::kick_user(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::ban_user(uint32_t, const char*, uint64_t) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_permission(uint32_t, const vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_server_mute(uint32_t, bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::move_user(uint32_t, uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::create_channel(const vc_channel_info*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::edit_channel(const vc_channel_info*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::delete_channel(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::create_account(const char*, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::reset_password(const char*, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::delete_account(const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::list_accounts() { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::get_account_list(vc_account_list*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::get_permissions(vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::audio_suspend() { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::audio_resume()  { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::audio_restart() { return VC_ERR_NOT_IMPLEMENTED; }

#endif  // VOICECAT_HAS_NET
