#ifdef VOICECAT_HAS_AUDIO
#define MINIAUDIO_IMPLEMENTATION
#include <miniaudio.h>
#endif

#include "audio/audio_engine.h"

#include <algorithm>
#include <chrono>
#include <cstring>

namespace voicecat::audio {

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

// Bounded-depth playout targeting (samples @ 48 kHz). The playout clock advances every callback
// via the PLC path in on_playback, while the sender's frame timestamps only advance while it is
// actually transmitting (silence is omitted) — so the clock drifts away from the stream timeline
// across VAD/PTT/DTX gaps and late joins. Rather than snap the clock to the *oldest* buffered
// frame (which could only add standing latency and never trim it — the cause of the "latency keeps
// drifting backward, fixed by rejoin" bug), on_playback keeps the clock a bounded `target` behind
// the *newest* arrival and frame-skips to catch up when the backlog grows. These are the floor and
// hysteresis for that target; the steady-state target itself comes from JitterBuffer's adaptive
// estimate (target_depth_samples()).
constexpr int32_t kMinDepthSamples  = 48000 * 40 / 1000;   // floor for the catch-up target depth
constexpr int32_t kCatchupSamples   = 48000 * 60 / 1000;   // skip when depth > target + 60 ms
constexpr int32_t kStarveSamples    = 48000 * 120 / 1000;  // reseed when clock 120 ms past newest

// PLC cap: after this many consecutive samples of pure packet-loss concealment (no real
// packet decoded), stop calling opus_decode(nullptr,0,...) and emit silence instead. Opus
// PLC synthesizes soft comfort noise that never "exhausts" (opus_decode always returns
// frame_samples > 0 for PLC), so without a cap a stale stream left in the mixer after its
// source disconnects would hiss forever. 2 s bounds the hiss to a brief gap while still
// bridging normal network jitter/PTT silences. The primary fix for stale streams is the
// server's UserEvent::LEFT broadcast (which triggers remove_stream); this is
// defense-in-depth against any future regression that skips remove_stream.
constexpr int32_t kPlcCapSamples = 48000 * 2;  // 2 s @ 48 kHz

#ifdef VOICECAT_HAS_AUDIO
// device_id encoding (DeviceInfo::id / AudioParams::*_device_id): a hex string of the raw
// ma_device_id bytes. Opaque on purpose — names aren't guaranteed unique, and this is the only
// stable handle miniaudio accepts back for device selection. Internal contract only; never
// exposed as anything other than an opaque round-tripped string at the C ABI boundary.
std::string hex_encode_device_id(const ma_device_id& id) {
    static constexpr char kHex[] = "0123456789abcdef";
    const auto* bytes = reinterpret_cast<const uint8_t*>(&id);
    std::string out;
    out.reserve(sizeof(ma_device_id) * 2);
    for (size_t i = 0; i < sizeof(ma_device_id); ++i) {
        out.push_back(kHex[bytes[i] >> 4]);
        out.push_back(kHex[bytes[i] & 0xF]);
    }
    return out;
}

bool hex_decode_device_id(const std::string& hex, ma_device_id* out) {
    if (hex.size() != sizeof(ma_device_id) * 2) return false;
    std::memset(out, 0, sizeof(ma_device_id));
    auto* bytes = reinterpret_cast<uint8_t*>(out);
    auto nibble = [](char c) -> int {
        if (c >= '0' && c <= '9') return c - '0';
        if (c >= 'a' && c <= 'f') return c - 'a' + 10;
        if (c >= 'A' && c <= 'F') return c - 'A' + 10;
        return -1;
    };
    for (size_t i = 0; i < sizeof(ma_device_id); ++i) {
        int hi = nibble(hex[i * 2]);
        int lo = nibble(hex[i * 2 + 1]);
        if (hi < 0 || lo < 0) return false;
        bytes[i] = static_cast<uint8_t>((hi << 4) | lo);
    }
    return true;
}
#endif  // VOICECAT_HAS_AUDIO
}  // namespace

// ── JitterBuffer ─────────────────────────────────────────────────────────────

void JitterBuffer::push(Frame f) {
    std::lock_guard lk(mu_);

    uint32_t ts     = f.timestamp;
    bool     marker = f.marker;

    // Jitter estimation (EWMA of inter-arrival gap vs the expected per-frame gap). Skip
    // silence-gap outliers — a talkspurt restart (marker) or any gap far larger than a frame
    // (DTX/VAD/PTT silence) is not jitter; counting it would spike the estimate and inflate the
    // target depth for the rest of the call. The sender omits silence from its timestamp, so a
    // restart can also arrive "behind" (negative gap) — also an outlier.
    if (!first_push_) {
        int32_t gap = static_cast<int32_t>(ts - last_push_ts_);
        bool outlier = marker || gap <= 0 ||
                       gap > static_cast<int32_t>(expected_gap_samples_ * 8);
        if (!outlier) {
            uint32_t diff = (static_cast<uint32_t>(gap) > expected_gap_samples_)
                                ? (static_cast<uint32_t>(gap) - expected_gap_samples_)
                                : (expected_gap_samples_ - static_cast<uint32_t>(gap));
            jitter_est_ = (jitter_est_ * 7 + diff) / 8;
            uint32_t depth = std::clamp(jitter_est_ * 2 + expected_gap_samples_,
                                        expected_gap_samples_, 48000u * 200u / 1000u);
            target_depth_ms_.store(depth * 1000u / 48000u, std::memory_order_relaxed);
        }
    }
    last_push_ts_ = ts;
    first_push_   = false;

    // Track the leading edge (newest timestamp), wrap-safe.
    if (!have_newest_ || static_cast<int32_t>(ts - newest_ts_) > 0) newest_ts_ = ts;
    have_newest_ = true;

    auto res = buf_.emplace(ts, std::move(f));
    if (!res.second) dup_.fetch_add(1, std::memory_order_relaxed);  // duplicate timestamp
}

std::optional<JitterBuffer::Frame> JitterBuffer::pop(uint32_t playout_ts) {
    std::unique_lock lk(mu_, std::try_to_lock);
    if (!lk) return std::nullopt;  // contended — caller does PLC

    if (buf_.empty()) return std::nullopt;

    auto it = buf_.begin();
    uint32_t ts = it->first;

    // Drop frames that are too old to play. The window tracks the (adaptive) target depth plus a
    // margin so it never undercuts DRED's next-packet lookahead, floored/capped at the fixed
    // 500 ms bound. Without this the only downward force on latency was a snap that *added* it.
    constexpr uint32_t kLateMarginSamples = 48000u * 200u / 1000u;  // +200 ms over target depth
    constexpr uint32_t kLateDropFloor     = 48000u * 200u / 1000u;  // never drop earlier than 200 ms
    uint32_t late_window = std::clamp(target_depth_samples() + kLateMarginSamples,
                                      kLateDropFloor, kLateDropSamples);
    if (static_cast<int32_t>(playout_ts - ts) > static_cast<int32_t>(late_window)) {
        lost_.fetch_add(1, std::memory_order_relaxed);
        buf_.erase(it);
        return std::nullopt;
    }

    // Return frame only when it's due.
    if (static_cast<int32_t>(ts - playout_ts) > 0) return std::nullopt;

    Frame f = std::move(it->second);
    buf_.erase(it);
    return f;
}

std::optional<uint32_t> JitterBuffer::peek_front_ts() const {
    std::unique_lock lk(mu_, std::try_to_lock);
    if (!lk || buf_.empty()) return std::nullopt;
    return buf_.begin()->first;
}

std::optional<uint32_t> JitterBuffer::peek_back_ts() const {
    std::unique_lock lk(mu_, std::try_to_lock);
    if (!lk || !have_newest_) return std::nullopt;
    return newest_ts_;
}

void JitterBuffer::drop_before(uint32_t ts) {
    std::lock_guard lk(mu_);
    while (!buf_.empty()) {
        auto it = buf_.begin();  // oldest
        if (static_cast<int32_t>(ts - it->first) > 0)
            buf_.erase(it);  // strictly before the new playout point — stale
        else
            break;
    }
}

size_t JitterBuffer::try_copy_front_payload(uint32_t expected_ts, uint8_t* out, size_t max_sz) {
    std::unique_lock lk(mu_, std::try_to_lock);
    if (!lk || buf_.empty()) return 0;
    auto it = buf_.begin();
    if (it->first != expected_ts) return 0;
    const auto& payload = it->second.payload;
    size_t n = std::min(payload.size(), max_sz);
    std::memcpy(out, payload.data(), n);
    return n;
}

void JitterBuffer::reset() {
    std::lock_guard lk(mu_);
    buf_.clear();
    lost_.store(0);
    dup_.store(0);
    first_push_  = true;
    have_newest_ = false;
    jitter_est_  = 0;
}

// ── AudioEngine ──────────────────────────────────────────────────────────────

AudioEngine::AudioEngine() = default;

AudioEngine::~AudioEngine() {
    stop();
#ifdef VOICECAT_HAS_AUDIO
    stop_loopback_capture();  // loopback has an independent lifecycle — close it before the context
    if (context_inited_) {
        ma_context_uninit(&context_);
        context_inited_ = false;
    }
#endif
#ifdef VOICECAT_HAS_OPUS
    if (dred_dec_) { opus_dred_decoder_destroy(dred_dec_); dred_dec_ = nullptr; }
#endif
}

#ifdef VOICECAT_HAS_AUDIO
ma_context_config AudioEngine::make_context_config() {
    ma_context_config cfg = ma_context_config_init();
    // iOS: leave AVAudioSession entirely to the Swift layer (IOSAudioRouter). See the
    // make_context_config() declaration in audio_engine.h for the full rationale.
    cfg.coreaudio.sessionCategory          = ma_ios_session_category_none;  // don't call setCategory
    cfg.coreaudio.noAudioSessionActivate   = MA_TRUE;  // don't setActive(true) on device init
    cfg.coreaudio.noAudioSessionDeactivate = MA_TRUE;  // don't setActive(false) on device uninit
    return cfg;
}
#endif

bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
    if (running_.load()) return false;
    params_        = p;
    capture_cb_    = std::move(capture_cb);
    frame_samples_ = static_cast<int>(p.sample_rate / 1000 * p.frame_ms);
    running_.store(true, std::memory_order_release);
#ifdef VOICECAT_HAS_OPUS
    if (!dred_dec_) {
        int err = 0;
        dred_dec_ = opus_dred_decoder_create(&err);  // null on failure — DRED silently disabled
    }
#endif

#ifdef VOICECAT_HAS_AUDIO
    // Pre-allocate capture accumulators before the devices start so on_capture / on_loopback
    // never allocate on the RT thread. count=0 means "empty"; the buf is sized to exactly one
    // encoder frame so a memcpy into it can never overrun. The mic accumulator is sized to
    // frame_samples_ * capture_channels (1 = mono, 2 = stereo interleaved — set via
    // vc_set_capture_channels, e.g. iOS stereo built-in mic). The loopback accumulator is
    // sized mono here as a safe default and re-sized to frame_samples_*channels in
    // start_loopback_capture() once the screen stream's channel mode is known (off the RT
    // thread, before the loopback device is started).
    capture_accum_.buf.assign(static_cast<size_t>(frame_samples_) * p.capture_channels, 0);
    capture_accum_.count = 0;
    loopback_accum_.buf.assign(static_cast<size_t>(frame_samples_), 0);
    loopback_accum_.count = 0;
#endif

#ifdef VOICECAT_HAS_AUDIO
    // Own the ma_context (lazily, reused across restarts) so miniaudio does not touch
    // AVAudioSession on iOS — the Swift IOSAudioRouter is the sole session owner. Without
    // this, ma_device_init(nullptr, ...) below would reset the session category to Record/
    // Playback with no options on every open, killing headphone/A2DP output. See
    // make_context_config() in audio_engine.h. If context init fails we fall back to a NULL
    // context (degraded: miniaudio manages the session) rather than leaving audio dead.
    if (!context_inited_) {
        ma_context_config ctx_cfg = make_context_config();
        context_inited_ = (ma_context_init(nullptr, 0, &ctx_cfg, &context_) == MA_SUCCESS);
    }
    ma_context* ctx = context_inited_ ? &context_ : nullptr;

    // External playback (iOS VPIO): skip the hardware playback device entirely — a timer
    // thread drives the mixer and the final mix goes to the Swift VPIO renderer (launched
    // after the capture block below).
    if (!external_playback_) {
    // ── Playback device (opened first on iOS: commits the output route — e.g. A2DP —
    // before the capture device starts. Starting stereo capture can trigger an iOS audio
    // route reconfiguration; opening playback first ensures A2DP is already committed
    // and is less likely to be dropped when the capture AudioUnit activates.) ──────────
    ma_device_id pb_id{};
    bool have_pb_id = !p.playback_device_id.empty() &&
                      hex_decode_device_id(p.playback_device_id, &pb_id);

    ma_device_config pb_cfg = ma_device_config_init(ma_device_type_playback);
    pb_cfg.playback.format   = ma_format_s16;
    pb_cfg.playback.channels = p.playback_channels;
    pb_cfg.sampleRate        = p.sample_rate;
    pb_cfg.dataCallback      = playback_data_cb;
    pb_cfg.pUserData         = this;
    pb_cfg.playback.pDeviceID = have_pb_id ? &pb_id : nullptr;

    if (ma_device_init(ctx, &pb_cfg, &playback_device_) == MA_SUCCESS) {
        if (ma_device_start(&playback_device_) == MA_SUCCESS) {
            playback_started_ = true;
        } else {
            ma_device_uninit(&playback_device_);
        }
    } else if (p.playback_channels != 1) {
        // Fallback: some unusual hardware may not accept the requested channel count even
        // though WASAPI shared mode normally remixes transparently. Retry once at mono rather
        // than leaving playback dead.
        pb_cfg.playback.channels = 1;
        if (ma_device_init(ctx, &pb_cfg, &playback_device_) == MA_SUCCESS) {
            if (ma_device_start(&playback_device_) == MA_SUCCESS) {
                playback_started_ = true;
                params_.playback_channels = 1;
            } else {
                ma_device_uninit(&playback_device_);
            }
        }
    }
    }  // end if (!external_playback_)

    // External capture (iOS VPIO / external feed): skip the hardware mic device — PCM is fed
    // via inject_capture / vc_stream_feed_pcm. capture_cb_ (set above) still fires for fed frames.
    if (!params_.external_capture) {
    // ── Capture device (opened after playback so the output route is already committed) ──
    ma_device_id cap_id{};
    bool have_cap_id = !p.capture_device_id.empty() &&
                       hex_decode_device_id(p.capture_device_id, &cap_id);

    ma_device_config cap_cfg = ma_device_config_init(ma_device_type_capture);
    cap_cfg.capture.format     = ma_format_s16;
    cap_cfg.capture.channels   = p.capture_channels;
    cap_cfg.sampleRate         = p.sample_rate;
    cap_cfg.dataCallback       = capture_data_cb;
    cap_cfg.pUserData          = this;
    cap_cfg.capture.pDeviceID  = have_cap_id ? &cap_id : nullptr;  // null = default device
    // Hint: request the encoder's frame size as the callback period. WASAPI shared mode may
    // not honor this (the hardware period is fixed), but when it is honored the accumulator
    // below becomes a zero-copy passthrough rather than a copy every two callbacks.
    cap_cfg.periodSizeInFrames = static_cast<ma_uint32>(frame_samples_);

    if (ma_device_init(ctx, &cap_cfg, &capture_device_) == MA_SUCCESS) {
        if (ma_device_start(&capture_device_) == MA_SUCCESS) {
            capture_started_ = true;
        } else {
            ma_device_uninit(&capture_device_);
        }
    }
    }  // end if (!params_.external_capture)

    // External playback: launch the mixer-timer thread now that the engine is configured. It
    // drives on_playback() (decode + mix all remote streams) every frame_ms and ships the final
    // mix to mixed_sink_ for the Swift VPIO renderer. No hardware playback device exists.
    if (external_playback_) {
        mixer_scratch_.assign(
            static_cast<size_t>(frame_samples_) * params_.playback_channels, 0);
        mixer_timer_stop_.store(false, std::memory_order_release);
        mixer_timer_thread_ = std::thread([this] { run_mixer_timer(); });
    }
#endif  // VOICECAT_HAS_AUDIO

    return true;
}

std::vector<DeviceInfo> AudioEngine::enumerate_devices(bool capture) {
    std::vector<DeviceInfo> result;
#ifdef VOICECAT_HAS_AUDIO
    // Use the no-AVAudioSession-management config here too: device enumeration runs at
    // SessionState init (refreshDevices) and on settings views, and a default-config context
    // would call setCategory()/setActive() on iOS, disrupting the session the Swift layer owns.
    ma_context ctx;
    ma_context_config ctx_cfg = make_context_config();
    if (ma_context_init(nullptr, 0, &ctx_cfg, &ctx) != MA_SUCCESS) return result;

    ma_device_info* playback_infos  = nullptr;
    ma_uint32        playback_count = 0;
    ma_device_info* capture_infos   = nullptr;
    ma_uint32        capture_count  = 0;

    if (ma_context_get_devices(&ctx, &playback_infos, &playback_count, &capture_infos,
                                &capture_count) == MA_SUCCESS) {
        ma_device_info* infos = capture ? capture_infos : playback_infos;
        ma_uint32        count = capture ? capture_count : playback_count;
        result.reserve(count);
        for (ma_uint32 i = 0; i < count; ++i) {
            DeviceInfo d;
            d.id         = hex_encode_device_id(infos[i].id);
            d.name       = infos[i].name;
            d.is_default = infos[i].isDefault != 0;
            result.push_back(std::move(d));
        }
    }

    ma_context_uninit(&ctx);
#else
    (void)capture;
#endif  // VOICECAT_HAS_AUDIO
    return result;
}

void AudioEngine::stop() {
    if (!running_.exchange(false)) return;

#ifdef VOICECAT_HAS_AUDIO
    // External playback: stop + join the mixer-timer thread before tearing down state it reads.
    mixer_timer_stop_.store(true, std::memory_order_release);
    if (mixer_timer_thread_.joinable()) mixer_timer_thread_.join();
    if (capture_started_) {
        ma_device_stop(&capture_device_);
        ma_device_uninit(&capture_device_);
        capture_started_ = false;
    }
    if (playback_started_) {
        ma_device_stop(&playback_device_);
        ma_device_uninit(&playback_device_);
        playback_started_ = false;
    }
#endif
}

bool AudioEngine::suspend() {
#ifdef VOICECAT_HAS_AUDIO
    if (!running_.load(std::memory_order_acquire)) return true;
    // External playback: pause the mixer timer (there is no playback device to stop). This
    // matches the VPIO renderer going down during an AVAudioSession interruption.
    if (external_playback_) {
        mixer_timer_stop_.store(true, std::memory_order_release);
        if (mixer_timer_thread_.joinable()) mixer_timer_thread_.join();
    }
    bool ok = true;
    if (capture_started_)  ok &= (ma_device_stop(&capture_device_)  == MA_SUCCESS);
    if (playback_started_) ok &= (ma_device_stop(&playback_device_) == MA_SUCCESS);
    return ok;
#else
    return true;
#endif
}

bool AudioEngine::resume() {
#ifdef VOICECAT_HAS_AUDIO
    if (!running_.load(std::memory_order_acquire)) return true;
    // External playback: relaunch the mixer timer (mixer_scratch_ is still sized from start()).
    if (external_playback_ && !mixer_timer_thread_.joinable()) {
        mixer_timer_stop_.store(false, std::memory_order_release);
        mixer_timer_thread_ = std::thread([this] { run_mixer_timer(); });
    }
    bool ok = true;
    if (capture_started_)  ok &= (ma_device_start(&capture_device_)  == MA_SUCCESS);
    if (playback_started_) ok &= (ma_device_start(&playback_device_) == MA_SUCCESS);
    return ok;
#else
    return true;
#endif
}

void AudioEngine::inject_capture(int kind, const int16_t* pcm, size_t samples_per_channel,
                                 int channels) {
    const int ch = std::max(1, channels);
    const size_t n = samples_per_channel * static_cast<size_t>(ch);

    InjectTap* tap;
    {
        std::lock_guard lk(inject_mu_);
        auto& slot = inject_taps_[kind];
        if (!slot) {
            slot = std::make_unique<InjectTap>();
            slot->ring.resize(kInjectCapSamples, 0);
        }
        tap = slot.get();
    }

    // If the channel count changed, reset the ring to avoid mixing mono and stereo samples.
    if (tap->channels != ch) {
        tap->write.store(0, std::memory_order_relaxed);
        tap->read.store(0, std::memory_order_relaxed);
        tap->channels = ch;
    }

    size_t w = tap->write.load(std::memory_order_relaxed);
    for (size_t i = 0; i < n; ++i)
        tap->ring[(w + i) % kInjectCapSamples] = pcm[i];
    tap->write.store(w + n, std::memory_order_release);

    // Fire capture_cb_ for each complete frame (frame_samples_ * ch flat samples).
    const size_t frame_flat = static_cast<size_t>(frame_samples_) * static_cast<size_t>(ch);
    while (true) {
        size_t r     = tap->read.load(std::memory_order_relaxed);
        size_t avail = tap->write.load(std::memory_order_acquire) - r;
        if (avail < frame_flat) break;

        std::vector<int16_t> frame(frame_flat);
        for (size_t i = 0; i < frame_flat; ++i)
            frame[i] = tap->ring[(r + i) % kInjectCapSamples];
        tap->read.store(r + frame_flat, std::memory_order_release);

        if (capture_cb_) capture_cb_(kind, frame.data(), frame_samples_, ch);
    }
}

void AudioEngine::inject_capture(int kind, const int16_t* pcm, size_t n) {
    inject_capture(kind, pcm, n, 1);
}

void AudioEngine::push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f) {
    std::lock_guard lk(streams_mu_);
    auto& s = streams_[ssrc];
    s.last_voice_ms.store(now_ms(), std::memory_order_relaxed);
    if (f.marker) s.pending_marker = true;  // talkspurt start → force playout reseed
    s.jitter.push(std::move(f));
}

void AudioEngine::set_stream_gain(uint32_t ssrc, float gain) {
    std::lock_guard lk(streams_mu_);
    streams_[ssrc].gain = gain;
}

void AudioEngine::set_stream_mute(uint32_t ssrc, bool mute) {
    std::lock_guard lk(streams_mu_);
    streams_[ssrc].mute = mute;
}

void AudioEngine::set_stream_noise_reduction(uint32_t ssrc, bool enable) {
    std::lock_guard lk(streams_mu_);
    auto& s = streams_[ssrc];
    s.noise_reduction_enabled = enable;
    if (enable) {
        if (!s.recv_ns) s.recv_ns = ApmProcessor::create();
    } else {
        s.recv_ns.reset();
    }
}

bool AudioEngine::get_stream_state(uint32_t ssrc, float& gain, bool& mute, bool& noise_reduction) {
    std::lock_guard lk(streams_mu_);
    auto it = streams_.find(ssrc);
    if (it == streams_.end()) return false;
    const auto& s = it->second;
    gain             = s.gain;
    mute             = s.mute;
    noise_reduction  = s.noise_reduction_enabled;
    return true;
}

void AudioEngine::remove_stream(uint32_t ssrc) {
    std::lock_guard lk(streams_mu_);
    auto it = streams_.find(ssrc);
    if (it != streams_.end()) {
#ifdef VOICECAT_HAS_OPUS
        if (it->second.dred_state_) {
            opus_dred_free(it->second.dred_state_);
            it->second.dred_state_ = nullptr;
        }
#endif
        streams_.erase(it);
    }
}

std::vector<std::pair<uint32_t, bool>> AudioEngine::poll_talk_transitions() {
    std::vector<std::pair<uint32_t, bool>> edges;
    std::lock_guard lk(streams_mu_);
    int64_t now = now_ms();
    for (auto& [ssrc, stream] : streams_) {
        bool now_talking = (now - stream.last_voice_ms.load(std::memory_order_relaxed)) <
                            kTalkHangoverMs;
        if (now_talking != stream.talking) {
            stream.talking = now_talking;
            edges.emplace_back(ssrc, now_talking);
        }
    }
    return edges;
}

uint32_t AudioEngine::stream_packets_lost(uint32_t ssrc) const {
    std::lock_guard lk(streams_mu_);
    auto it = streams_.find(ssrc);
    return (it != streams_.end()) ? it->second.jitter.packets_lost() : 0;
}

uint32_t AudioEngine::stream_target_depth_ms(uint32_t ssrc) const {
    std::lock_guard lk(streams_mu_);
    auto it = streams_.find(ssrc);
    return (it != streams_.end()) ? it->second.jitter.target_depth_ms() : 40;
}

uint32_t AudioEngine::stream_duplicates(uint32_t ssrc) const {
    std::lock_guard lk(streams_mu_);
    auto it = streams_.find(ssrc);
    return (it != streams_.end()) ? it->second.jitter.duplicates() : 0;
}

uint64_t AudioEngine::stream_underruns(uint32_t ssrc) const {
    std::lock_guard lk(streams_mu_);
    auto it = streams_.find(ssrc);
    return (it != streams_.end())
               ? it->second.underruns.load(std::memory_order_relaxed)
               : 0;
}

int32_t AudioEngine::stream_playout_depth_samples(uint32_t ssrc) const {
    std::lock_guard lk(streams_mu_);
    auto it = streams_.find(ssrc);
    if (it == streams_.end() || !it->second.playout_started) return 0;
    auto newest = it->second.jitter.peek_back_ts();
    if (!newest) return 0;
    return static_cast<int32_t>(*newest - it->second.playout_ts);
}

#ifdef VOICECAT_HAS_OPUS
void AudioEngine::init_recv_stream(uint32_t ssrc, const codec::OpusParams& p,
                                   uint32_t user_id, uint32_t stream_id, bool is_voice) {
    std::lock_guard lk(streams_mu_);
    auto& stream = streams_[ssrc];
    stream.user_id    = user_id;
    stream.stream_id  = stream_id;
    stream.is_voice   = is_voice;
    stream.fec_enabled_ = p.fec;
    stream.decoder.init(p);
    // Ring must be sized for this decoder's actual channel/frame-size — see RemoteStream::ring
    // comment in audio_engine.h for why this can't just be the playback callback's frame count.
    int channels      = std::max(1, stream.decoder.channels());
    int frame_samples = stream.decoder.frame_samples();
    if (frame_samples <= 0) frame_samples = static_cast<int>(p.sample_rate / 1000 * p.frame_ms);
    stream.init_ring(channels, frame_samples);
    // The expected inter-arrival gap = the sender's frame size in samples @48 kHz; the jitter
    // EWMA and silence-gap outlier rejection key off it (defaults to 20 ms otherwise).
    stream.jitter.set_expected_gap(static_cast<uint32_t>(frame_samples));
    // DRED: pre-allocate per-stream scratch (no RT-thread allocation). 4000 bytes > max Opus pkt.
    stream.dred_payload_scratch_.assign(4000, 0);
    if (!stream.dred_state_) {
        int err = 0;
        stream.dred_state_ = opus_dred_alloc(&err);  // null on failure — falls back to PLC
    }
}
#endif

void AudioEngine::set_pcm_sink(PcmSink cb, void* user) {
    pcm_sink_user_.store(user, std::memory_order_relaxed);
    pcm_sink_.store(cb, std::memory_order_release);
}

void AudioEngine::set_mixed_output_sink(MixedSink cb, void* user) {
    mixed_sink_user_.store(user, std::memory_order_relaxed);
    mixed_sink_.store(cb, std::memory_order_release);
}

#ifdef VOICECAT_HAS_AUDIO

void AudioEngine::capture_data_cb(ma_device* dev, void* /*out*/,
                                   const void* in, ma_uint32 frame_count) {
    auto* self = static_cast<AudioEngine*>(dev->pUserData);
    self->on_capture(static_cast<const int16_t*>(in), frame_count);
}

void AudioEngine::on_capture(const int16_t* pcm, ma_uint32 frames) {
    // Accumulate samples until we have exactly frame_samples_ (e.g. 960 for 20 ms @ 48 kHz),
    // then fire capture_cb_. WASAPI shared mode commonly delivers 480-sample (10 ms) callbacks
    // regardless of the periodSizeInFrames hint above; passing a sub-frame chunk directly to
    // opus_encode() returns OPUS_BAD_ARG (negative), silently dropping every mic frame.
    // PCM here is interleaved across params_.capture_channels (1 = mono, 2 = stereo L/R —
    // e.g. iOS stereo built-in mic via vc_set_capture_channels) — the accumulator was sized to
    // frame_samples_*capture_channels in start(), so a memcpy into it can never overrun.
    // capture_cb_ receives samples-per-channel (frame_samples_) and the channel count explicitly.
    if (!capture_cb_ || frame_samples_ <= 0) return;
    const int ch = std::max(1u, params_.capture_channels);
    const int16_t* src = pcm;
    auto remaining = static_cast<int>(frames) * ch;
    const int full = frame_samples_ * ch;
    while (remaining > 0) {
        int space = full - capture_accum_.count;
        int copy  = std::min(remaining, space);
        std::memcpy(capture_accum_.buf.data() + capture_accum_.count, src,
                    static_cast<size_t>(copy) * sizeof(int16_t));
        capture_accum_.count += copy;
        src       += copy;
        remaining -= copy;
        if (capture_accum_.count == full) {
            capture_cb_(0, capture_accum_.buf.data(), frame_samples_, ch);
            capture_accum_.count = 0;
        }
    }
}

void AudioEngine::playback_data_cb(ma_device* dev, void* out,
                                    const void* /*in*/, ma_uint32 frame_count) {
    auto* self = static_cast<AudioEngine*>(dev->pUserData);
    self->on_playback(static_cast<int16_t*>(out), frame_count);
}

void AudioEngine::on_playback(int16_t* out, ma_uint32 frames) {
    const uint32_t pb_channels = params_.playback_channels;
    std::memset(out, 0, frames * pb_channels * sizeof(int16_t));

    std::unique_lock lk(streams_mu_, std::try_to_lock);
    if (!lk) return;  // contended: emit silence this period

#ifdef VOICECAT_HAS_OPUS
    std::vector<int32_t> mix(frames * pb_channels, 0);

    for (auto& [ssrc, stream] : streams_) {
        if (stream.mute || !stream.decoder.valid()) continue;

        // A stream's Opus channel count (mono/stereo, per-channel AudioConfig) may differ from
        // the engine-wide playback channel count (now genuinely stereo — see docs/voice.md §8).
        // dec_channels/frame_samples are bitstream properties (fixed at decoder init); `frames`
        // below is the *hardware* playback callback's period, an independent value miniaudio
        // picks on its own — opus_decode's max_samples must be frame_samples, never `frames`
        // (see RemoteStream::ring in audio_engine.h for what went wrong when it was). The ring
        // decouples the two: top it up by decoding whole Opus frames, then drain exactly
        // `frames` samples-per-channel from it below (silence-padding on underrun = PLC).
        const int dec_channels   = std::max(1, stream.decoder.channels());
        const int frame_samples  = stream.decoder.frame_samples();

        // Bound the playout latency against the stream's leading edge (newest buffered frame).
        // playout_ts free-runs every callback (PLC path below) while the sender omits silence from
        // its timestamps, so the two diverge across late joins and VAD/PTT/DTX gaps. Two cases,
        // both measured against the *newest* frame — never the oldest, whose snap could only ever
        // *add* standing latency (the ratchet that caused the growing-latency bug):
        //   • (re)seed to the leading edge when starting, on a talkspurt marker, or when the clock
        //     has run past the newest frame (starved after silence). depth becomes ~0 — no
        //     artificial prebuffer, so latency stays as low as the old path; buffered frames still
        //     play in order (pop() returns oldest-first), they just stop being held back.
        //   • frame-skip catch-up: if the backlog has grown beyond target+hysteresis (clock drift,
        //     bursty arrival, reordering), fast-forward to leave exactly `target` buffered and drop
        //     the now-stale frames. This is the missing downward force that bounds latency.
        // u32 subtraction via int32_t handles timestamp wrap.
        const int32_t target =
            std::max<int32_t>(static_cast<int32_t>(stream.jitter.target_depth_samples()),
                              kMinDepthSamples);
        if (auto newest = stream.jitter.peek_back_ts()) {
            int32_t depth = static_cast<int32_t>(*newest - stream.playout_ts);  // wrap-safe
            if (!stream.playout_started || stream.pending_marker || depth < -kStarveSamples) {
                stream.playout_ts      = *newest;
                stream.playout_started = true;
                stream.pending_marker  = false;
            } else if (depth > target + kCatchupSamples) {
                stream.playout_ts = *newest - static_cast<uint32_t>(target);
                stream.jitter.drop_before(stream.playout_ts);
            }
        }

        while (stream.ring_count < frames && frame_samples > 0) {
            auto maybe_frame = stream.jitter.pop(stream.playout_ts);
            int n;

            if (maybe_frame) {
                n = stream.decoder.decode(
                    maybe_frame->payload.data(),
                    static_cast<int>(maybe_frame->payload.size()),
                    stream.decode_scratch.data(), frame_samples);
                stream.plc_samples_since_real = 0;  // real packet — reset PLC streak
            } else if (stream.plc_samples_since_real >= kPlcCapSamples) {
                // PLC cap exhausted: emit silence instead of more comfort noise. Keeps the
                // ring fed and the playout clock advancing so timing is correct if the
                // source resumes, but bounds the hiss to ~2 s (kPlcCapSamples).
                std::memset(stream.decode_scratch.data(), 0,
                            static_cast<size_t>(frame_samples) * stream.ring_channels *
                                sizeof(int16_t));
                n = frame_samples;
            } else {
                // Loss recovery for a missing frame, best-quality first: DRED (Opus 1.6 ML
                // reconstruction) → in-band FEC (the low-bitrate copy of this frame the encoder
                // embeds in the next packet) → PLC comfort noise. DRED and FEC both need the
                // *next* packet already buffered, so copy it once and try each in turn.
                n = -1;
#ifdef VOICECAT_HAS_OPUS
                uint32_t next_ts = stream.playout_ts + static_cast<uint32_t>(frame_samples);
                size_t psz = stream.jitter.try_copy_front_payload(
                    next_ts, stream.dred_payload_scratch_.data(),
                    stream.dred_payload_scratch_.size());

                // 1. DRED: parse the next packet's deep-redundancy extension and reconstruct.
                if (psz > 0 && dred_dec_ && stream.dred_state_) {
                    int dred_end = 0;
                    int ret = opus_dred_parse(
                        dred_dec_, stream.dred_state_,
                        stream.dred_payload_scratch_.data(),
                        static_cast<opus_int32>(psz),
                        frame_samples, static_cast<opus_int32>(params_.sample_rate),
                        &dred_end, 0);
                    if (ret > 0)
                        n = stream.decoder.decode_dred(stream.dred_state_, 0,
                                                       stream.decode_scratch.data(),
                                                       frame_samples);
                }

                // 2. In-band FEC: reconstruct the lost frame from the redundant copy carried in
                //    the next packet (decode_fec=1). Only when FEC is negotiated and the next
                //    packet is present; if it carries no LBRR data libopus falls back to PLC,
                //    so this is at worst a no-op relative to the PLC path below.
                if (n <= 0 && psz > 0 && stream.fec_enabled_) {
                    n = stream.decoder.decode(
                        stream.dred_payload_scratch_.data(), static_cast<int>(psz),
                        stream.decode_scratch.data(), frame_samples, /*fec=*/true);
                }
#endif
                // 3. PLC: synthesize a continuation when no redundancy is available.
                if (n <= 0) {
                    n = stream.decoder.decode(nullptr, 0, stream.decode_scratch.data(),
                                             frame_samples);
                }
                if (n > 0) stream.plc_samples_since_real += n;  // track concealment streak
            }
            if (n <= 0) break;  // decoder error/exhausted PLC; rest of this period stays silent

            // `n` is samples-per-channel (matches the frame_samples convention used by
            // OpusEncoder::encode elsewhere in the codebase).
            // Receive-side NR runs only on VOICE (MIC) streams — a screen-audio share is music/
            // video, never voice, so it's left untouched (gating on dec_channels would silently
            // skip a now-stereo mic, see docs/voice.md §10). RNNoise is mono-only, so a stereo mic
            // is folded to mono in place (symmetric with the send-side downmix), denoised, then
            // duplicated back across both channels — no allocation on this RT path.
            if (stream.recv_ns && stream.is_voice) {
                int16_t* s = stream.decode_scratch.data();
                if (dec_channels == 2) {
                    for (int i = 0; i < n; ++i)  // L/R -> mono, packed into [0..n)
                        s[i] = static_cast<int16_t>(
                            (static_cast<int32_t>(s[2 * i]) + static_cast<int32_t>(s[2 * i + 1])) /
                            2);
                    stream.recv_ns->process_capture(s, n, static_cast<int>(params_.sample_rate));
                    for (int i = n - 1; i >= 0; --i)  // mono -> both channels (back-to-front)
                        s[2 * i] = s[2 * i + 1] = s[i];
                } else if (dec_channels == 1) {
                    stream.recv_ns->process_capture(s, n, static_cast<int>(params_.sample_rate));
                }
            }

            // PCM sink: deliver decoded per-stream audio to external consumer (bots,
            // transcription, recording) before it enters the hardware mix. Atomic relaxed-
            // load is safe on the RT thread — the fn-ptr and user-ptr are independent
            // pointer-sized values written together by set_pcm_sink (release store).
            if (auto sink = pcm_sink_.load(std::memory_order_relaxed)) {
                sink(pcm_sink_user_.load(std::memory_order_relaxed),
                     stream.user_id, stream.stream_id,
                     stream.decode_scratch.data(),
                     static_cast<size_t>(n),
                     static_cast<uint32_t>(dec_channels),
                     params_.sample_rate);
            }

            stream.push_ring(stream.decode_scratch.data(), static_cast<size_t>(n));
            stream.playout_ts += static_cast<uint32_t>(n);
        }

        // Diagnostic: the decode loop couldn't keep the ring fed for this hardware period while
        // the stream was actively playing out — a genuine underrun (decoder error / exhausted
        // PLC), distinct from ordinary single-packet loss the loop conceals in place.
        if (stream.playout_started && stream.ring_count < frames)
            stream.underruns.fetch_add(1, std::memory_order_relaxed);

        const float g = stream.gain;
        int16_t frame_buf[2];
        for (ma_uint32 i = 0; i < frames; ++i) {
            stream.pop_ring(frame_buf, 2);  // zero-filled if the ring underran (PLC silence)
            if (dec_channels == 2) {
                int32_t l = static_cast<int32_t>(static_cast<float>(frame_buf[0]) * g);
                int32_t r = static_cast<int32_t>(static_cast<float>(frame_buf[1]) * g);
                if (pb_channels == 2) {
                    mix[i * 2]     += l;
                    mix[i * 2 + 1] += r;
                } else {
                    mix[i] += (l + r) / 2;  // playback device fell back to mono
                }
            } else {
                int32_t sample = static_cast<int32_t>(static_cast<float>(frame_buf[0]) * g);
                for (uint32_t c = 0; c < pb_channels; ++c)
                    mix[i * pb_channels + c] += sample;  // upmix mono -> all playback channels
            }
        }
    }

    const float ovol = output_volume_.load(std::memory_order_relaxed);
    for (ma_uint32 i = 0; i < frames * pb_channels; ++i) {
        int32_t s = static_cast<int32_t>(static_cast<float>(mix[i]) * ovol);
        out[i] = static_cast<int16_t>(std::clamp(s, -32768, 32767));
    }
#else
    (void)out;
    (void)frames;
#endif
}

// External-playback timer (iOS VPIO): with no hardware playback device to "pull" frames, this
// dedicated thread drives the mixer on a steady cadence. It is NOT a real-time audio thread (a
// plain timed worker, same class as vc_client::run_talk_timer), but it must still not allocate
// in the loop because on_playback() takes streams_mu_ via try_lock and runs the RT-safe decode
// path — so mixer_scratch_ is pre-sized in start(). Uses a deadline-based sleep to bound drift.
void AudioEngine::run_mixer_timer() {
    using clock = std::chrono::steady_clock;
    const uint32_t ch     = params_.playback_channels;
    const uint32_t spc    = static_cast<uint32_t>(frame_samples_);
    const auto     period = std::chrono::milliseconds(params_.frame_ms);
    auto next = clock::now();
    while (!mixer_timer_stop_.load(std::memory_order_acquire)) {
        on_playback(mixer_scratch_.data(), spc);
        if (auto sink = mixed_sink_.load(std::memory_order_relaxed)) {
            sink(mixed_sink_user_.load(std::memory_order_relaxed), mixer_scratch_.data(), spc, ch,
                 params_.sample_rate);
        }
        next += period;
        // If we fell badly behind (e.g. the thread was descheduled), reset the deadline rather
        // than spin to catch up — the VPIO renderer rides its own clock + jitter ring.
        auto now = clock::now();
        if (next < now) next = now + period;
        std::this_thread::sleep_until(next);
    }
}

#ifdef VOICECAT_HAS_LOOPBACK
void AudioEngine::loopback_data_cb(ma_device* dev, void* /*out*/, const void* in,
                                   ma_uint32 frame_count) {
    auto* self = static_cast<AudioEngine*>(dev->pUserData);
    self->on_loopback(static_cast<const int16_t*>(in), frame_count);
}

void AudioEngine::on_loopback(const int16_t* pcm, ma_uint32 frames) {
    // Same accumulation as on_capture — the WASAPI loopback render endpoint's callback
    // period is also hardware-driven and may not match frame_samples_. PCM here is
    // interleaved across loopback_channels_ (1 = mono downmix, 2 = stereo L/R) — the
    // accumulator was sized to frame_samples_*loopback_channels_ in start_loopback_capture,
    // so a memcpy into it can never overrun. capture_cb_ receives samples-per-channel
    // (frame_samples_) and the channel count explicitly.
    if (!capture_cb_ || frame_samples_ <= 0) return;
    const int ch = std::max(1, loopback_channels_);
    const int16_t* src = pcm;
    auto remaining = static_cast<int>(frames) * ch;
    const int full = frame_samples_ * ch;
    while (remaining > 0) {
        int space = full - loopback_accum_.count;
        int copy  = std::min(remaining, space);
        std::memcpy(loopback_accum_.buf.data() + loopback_accum_.count, src,
                    static_cast<size_t>(copy) * sizeof(int16_t));
        loopback_accum_.count += copy;
        src       += copy;
        remaining -= copy;
        if (loopback_accum_.count == full) {
            capture_cb_(loopback_kind_, loopback_accum_.buf.data(), frame_samples_, ch);
            loopback_accum_.count = 0;
        }
    }
}

bool AudioEngine::start_loopback_capture(int kind, int channels) {
    if (loopback_started_) return false;  // already running; stop_loopback_capture() first
    int ch = std::max(1, channels);
    loopback_accum_.count = 0;  // discard any partial frame from a previous loopback session

    ma_device_config cfg = ma_device_config_init(ma_device_type_loopback);
    cfg.capture.format    = ma_format_s16;
    cfg.capture.channels  = static_cast<ma_uint32>(ch);
    cfg.sampleRate        = params_.sample_rate;
    cfg.dataCallback      = loopback_data_cb;
    cfg.pUserData         = this;
    // pDeviceID left null: captures the default render endpoint (docs/voice.md §9). Windows
    // 10 2004+ supports process-specific loopback via AUDIOCLIENT_ACTIVATION_PARAMS, but
    // miniaudio's loopback mode only exposes whole-device capture — a future enhancement, not
    // this pass. Whole-device loopback inherently captures this app's own incoming voice mix
    // along with everything else playing — an accepted self-echo-loop characteristic of
    // desktop-audio capture, not a bug.

    // Use the engine's owned context (shared with playback/capture) so loopback honors the
    // same no-AVAudioSession-management config. Loopback is Windows/WASAPI-only today, where
    // the coreaudio fields are irrelevant, but keep it consistent. start() inits the context
    // before any loopback can be requested; fall back to NULL if it somehow isn't.
    ma_context* ctx = context_inited_ ? &context_ : nullptr;
    if (ma_device_init(ctx, &cfg, &loopback_device_) != MA_SUCCESS) {
        // Fallback: some unusual render endpoints may reject channels=2 even though WASAPI
        // shared mode normally remixes transparently. Retry once at mono (mirror the playback
        // device's fallback in start()) rather than leaving screen-audio capture dead.
        if (ch != 1) {
            ch = 1;
            cfg.capture.channels = 1;
            if (ma_device_init(ctx, &cfg, &loopback_device_) != MA_SUCCESS) return false;
        } else {
            return false;
        }
    }
    if (ma_device_start(&loopback_device_) != MA_SUCCESS) {
        ma_device_uninit(&loopback_device_);
        return false;
    }

    // Size the accumulator to one full encoder frame at the actual opened channel count.
    // Done here (off the RT thread) before any callback can fire — satisfies architecture.md
    // §3 (no allocation on RT threads). ma_device_start() above is what arms the callback.
    loopback_channels_    = ch;
    loopback_accum_.buf.assign(static_cast<size_t>(frame_samples_ * ch), 0);
    loopback_accum_.count = 0;
    loopback_kind_        = kind;
    loopback_started_     = true;
    return true;
}

void AudioEngine::stop_loopback_capture() {
    if (!loopback_started_) return;
    ma_device_stop(&loopback_device_);
    ma_device_uninit(&loopback_device_);
    loopback_started_ = false;
}
#else   // !VOICECAT_HAS_LOOPBACK
bool AudioEngine::start_loopback_capture(int /*kind*/, int /*channels*/) { return false; }
void AudioEngine::stop_loopback_capture() {}
#endif  // VOICECAT_HAS_LOOPBACK

#endif  // VOICECAT_HAS_AUDIO

#ifndef VOICECAT_HAS_AUDIO
bool AudioEngine::start_loopback_capture(int /*kind*/, int /*channels*/) { return false; }
void AudioEngine::stop_loopback_capture() {}
#endif

}  // namespace voicecat::audio
