// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later

// Native Oboe (AAudio) output backend for Sizzletracker.
//
// Opens a low-latency mono float Oboe output stream. On each real-time data
// callback it calls back into Kotlin (NativeAudioBridge.renderAudio) to fill a
// reused Java float[] from the shared AudioEngine.fillBlock, then copies that
// into Oboe's output buffer. This keeps ONE implementation of the synth/mixer
// (in Kotlin) while delivering audio through the lower-latency native path.

#include <jni.h>
#include <android/log.h>
#include <oboe/Oboe.h>
#include <atomic>
#include <memory>
#include <time.h>

// Optional profiling of the real-time render callback (render time, xruns, buffer
// size), logged ~once/second under tag SizzleNative. Off by default; set to 1 to
// diagnose audio glitches.
#define SIZZLE_PROFILE 0

#define LOG_TAG "SizzleNative"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

namespace {

JavaVM *g_vm = nullptr;
jobject g_callback = nullptr;    // global ref to the NativeAudioBridge
jmethodID g_renderMethod = nullptr;
jfloatArray g_buffer = nullptr;  // reused Java float[] of g_frames length
int g_frames = 0;
int32_t g_prevXRuns = 0;         // last-seen underrun count, for adaptive sizing
int32_t g_sampleRate = 48000;    // remembered so the stream can be reopened on disconnect
std::atomic<bool> g_wantRunning{false}; // false once nativeStop is called, so a
                                        // concurrent disconnect doesn't reopen a stream
                                        // the app deliberately shut down

// How glitch-resistant the output buffer starts (in device bursts). The buffer
// then grows adaptively (see onAudioReady) if underruns still occur. Profiling
// showed the JVM render is fast on average (<1 ms) but spikes to 3-7 ms when a GC
// pause freezes the callback thread; 4 bursts (~8 ms on a 96-frame device) absorbs
// those from the first note instead of glitching until adaptive growth catches up.
constexpr int32_t kInitialBursts = 8;
constexpr int32_t kMaxBufferFrames = 8192; // cap for the reused Java buffer

// Fetch a JNIEnv for the (persistent) audio callback thread, attaching once.
JNIEnv *callbackEnv() {
    JNIEnv *env = nullptr;
    if (g_vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) == JNI_EDETACHED) {
        g_vm->AttachCurrentThread(&env, nullptr);
    }
    return env;
}

class SizzleCallback : public oboe::AudioStreamDataCallback {
public:
    oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, void *audioData,
                                          int32_t numFrames) override {
        auto *out = static_cast<float *>(audioData);

        // Always time the render — the adaptive buffer sizing below relies on it
        // (this device reports getXRunCount()==0, so an xrun-only heuristic never
        // fires even under real underruns).
        struct timespec ts0;
        clock_gettime(CLOCK_MONOTONIC, &ts0);

        // Adaptive buffer sizing (xrun path): if AAudio reports an underrun, grow the
        // buffer by one burst (up to capacity). Kept for devices where it works.
        auto xr = stream->getXRunCount();
        if (xr && xr.value() > g_prevXRuns) {
            g_prevXRuns = xr.value();
            stream->setBufferSizeInFrames(
                    stream->getBufferSizeInFrames() + stream->getFramesPerBurst());
        }

        JNIEnv *env = callbackEnv();
        if (!env || !g_callback || !g_renderMethod || !g_buffer) {
            for (int i = 0; i < numFrames * 2; ++i) out[i] = 0.0f; // stereo silence
            return oboe::DataCallbackResult::Continue;
        }
        const int n = numFrames < g_frames ? numFrames : g_frames;
        // Ask Kotlin to render n frames; fillBlock writes 2*n interleaved-stereo
        // samples into the reused Java buffer ...
        env->CallVoidMethod(g_callback, g_renderMethod, g_buffer, n);
        // ... then copy them out to Oboe (2 samples/frame) and pad any remainder with
        // silence (the Java buffer is sized to the stream capacity, so rare).
        env->GetFloatArrayRegion(g_buffer, 0, n * 2, out);
        for (int i = n * 2; i < numFrames * 2; ++i) out[i] = 0.0f;

        // Adaptive buffer sizing (timing path): a callback that renders slower than
        // its deadline will drain the buffer; a couple in a row means we're about to
        // (or did) underrun, so grow the buffer a burst up to capacity. This is what
        // actually absorbs the crackle here, since getXRunCount() stays 0.
        {
            struct timespec ts1;
            clock_gettime(CLOCK_MONOTONIC, &ts1);
            const double renderMs =
                    (ts1.tv_sec - ts0.tv_sec) * 1e3 + (ts1.tv_nsec - ts0.tv_nsec) / 1e6;
            const double budgetMs = numFrames * 1000.0 / (double) stream->getSampleRate();
            static int sConsecutiveOver = 0;
            if (renderMs > budgetMs) {
                if (++sConsecutiveOver >= 2) {
                    const int32_t cap = stream->getBufferCapacityInFrames();
                    const int32_t cur = stream->getBufferSizeInFrames();
                    const int32_t burst = stream->getFramesPerBurst();
                    if (cur + burst <= cap) stream->setBufferSizeInFrames(cur + burst);
                    sConsecutiveOver = 0;
                }
            } else {
                sConsecutiveOver = 0;
            }
        }

#if SIZZLE_PROFILE
        {
            struct timespec ts1;
            clock_gettime(CLOCK_MONOTONIC, &ts1);
            double renderMs = (ts1.tv_sec - ts0.tv_sec) * 1e3 + (ts1.tv_nsec - ts0.tv_nsec) / 1e6;
            static double sMaxMs = 0, sSumMs = 0;
            static int sCount = 0, sOver = 0;
            static int32_t sBaseXRuns = -1;
            sSumMs += renderMs;
            if (renderMs > sMaxMs) sMaxMs = renderMs;
            // Budget = one callback's worth of time; count overruns of the callback.
            double budgetMs = numFrames * 1000.0 / (double) stream->getSampleRate();
            if (renderMs > budgetMs) sOver++;
            if (sBaseXRuns < 0 && xr) sBaseXRuns = xr.value();
            if (++sCount >= 500) {
                int32_t xrNow = xr ? xr.value() : -1;
                LOGI("prof: cbSize=%d budget=%.2fms avg=%.2fms max=%.2fms over=%d xruns=%d(+%d) buf=%d",
                     numFrames, budgetMs, sSumMs / sCount, sMaxMs, sOver,
                     xrNow, (sBaseXRuns >= 0 ? xrNow - sBaseXRuns : -1),
                     stream->getBufferSizeInFrames());
                sMaxMs = 0; sSumMs = 0; sCount = 0; sOver = 0; sBaseXRuns = xrNow;
            }
        }
#endif
        return oboe::DataCallbackResult::Continue;
    }
};

SizzleCallback g_dataCallback;
std::shared_ptr<oboe::AudioStream> g_stream;

bool openAndStartStream(); // fwd

// Recovers from a stream disconnect — e.g. a voice assistant or a phone call grabs
// the audio route, or headphones are (un)plugged. AAudio delivers ErrorDisconnected
// and closes the stream; without reopening it, the data callback never fires again
// and playback freezes (the sequencer only advances inside the callback). Oboe calls
// this on its own error thread AFTER the stream is closed, so it is safe to open a
// fresh one here (a few retries cover a route that is briefly still busy).
class SizzleErrorCallback : public oboe::AudioStreamErrorCallback {
public:
    void onErrorAfterClose(oboe::AudioStream * /*stream*/, oboe::Result error) override {
        LOGE("Oboe stream disconnected (%s) — reopening", oboe::convertToText(error));
        g_prevXRuns = 0;
        for (int attempt = 0; attempt < 10; ++attempt) {
            if (!g_wantRunning) return; // app asked to stop while we were recovering
            if (openAndStartStream()) {
                LOGI("Oboe stream reopened after disconnect (attempt %d)", attempt + 1);
                return;
            }
            struct timespec ts{0, 200 * 1000 * 1000}; // 200 ms
            nanosleep(&ts, nullptr);
        }
        LOGE("Oboe reopen failed after retries; audio will stay silent until restart");
    }
};

SizzleErrorCallback g_errorCallback;

// Opens, buffers and starts the low-latency output stream. Used both at initial
// start and to recover from a disconnect. Reuses the already-allocated g_buffer.
bool openAndStartStream() {
    oboe::AudioStreamBuilder builder;
    builder.setDirection(oboe::Direction::Output)
            ->setPerformanceMode(oboe::PerformanceMode::LowLatency)
            ->setSharingMode(oboe::SharingMode::Shared)
            ->setFormat(oboe::AudioFormat::Float)
            ->setChannelCount(oboe::ChannelCount::Stereo)
            ->setSampleRate(g_sampleRate)
            ->setDataCallback(&g_dataCallback)
            ->setErrorCallback(&g_errorCallback);

    oboe::Result result = builder.openStream(g_stream);
    if (result != oboe::Result::OK) {
        LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result));
        return false;
    }
    const int32_t burst = g_stream->getFramesPerBurst();
    g_stream->setBufferSizeInFrames(burst * kInitialBursts);
    result = g_stream->requestStart();
    if (result != oboe::Result::OK) {
        LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result));
        g_stream->close();
        g_stream.reset();
        return false;
    }
    LOGI("Oboe stream started (%d Hz, burst=%d, bufferSize=%d, capacity=%d, javaBuf=%d)",
         g_sampleRate, burst, g_stream->getBufferSizeInFrames(),
         g_stream->getBufferCapacityInFrames(), g_frames);
    return true;
}

} // namespace

extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
    g_vm = vm;
    return JNI_VERSION_1_6;
}

extern "C" JNIEXPORT jboolean JNICALL
Java_space_rcmd_android_sizzle_audio_NativeAudioBridge_nativeStart(
        JNIEnv *env, jobject /*thiz*/, jobject callback, jint sampleRate, jint framesPerCallback) {
    g_callback = env->NewGlobalRef(callback);
    jclass cls = env->GetObjectClass(callback);
    g_renderMethod = env->GetMethodID(cls, "renderAudio", "([FI)V");
    g_prevXRuns = 0;
    g_sampleRate = sampleRate;
    g_wantRunning = true;

    // Allocate the reused Java render buffer up front, sized to our maximum callback,
    // so the stream can be transparently reopened on a disconnect without touching
    // JNI from Oboe's (non-Java) error thread. The data callback only ever renders
    // min(numFrames, g_frames) frames, and low-latency bursts are far smaller.
    g_frames = kMaxBufferFrames;
    if (g_frames < framesPerCallback) g_frames = framesPerCallback;
    // Interleaved stereo: 2 samples per frame.
    g_buffer = static_cast<jfloatArray>(env->NewGlobalRef(env->NewFloatArray(g_frames * 2)));

    if (!openAndStartStream()) {
        env->DeleteGlobalRef(g_buffer);
        g_buffer = nullptr;
        return JNI_FALSE;
    }
    return JNI_TRUE;
}

extern "C" JNIEXPORT void JNICALL
Java_space_rcmd_android_sizzle_audio_NativeAudioBridge_nativeStop(
        JNIEnv *env, jobject /*thiz*/) {
    g_wantRunning = false; // stop the disconnect-recovery from reopening
    if (g_stream) {
        g_stream->stop();
        g_stream->close();
        g_stream.reset();
    }
    if (g_buffer) { env->DeleteGlobalRef(g_buffer); g_buffer = nullptr; }
    if (g_callback) { env->DeleteGlobalRef(g_callback); g_callback = nullptr; }
    g_renderMethod = nullptr;
}
