package com.foxx.androidcast.receiver;

/*********************************************************************
 * AudioDecoder.java
 * Created at: Fri 15 May 2026 16:49:02 +0200
 * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
 * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
 * class AudioDecoder
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (6 commits, 419 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	5a735f2a711c2676e71bdb7b3541c08055fee63c37d5f81be669402dfeca1167
**********************************************************************/
import android.content.Context;
import android.media.AudioAttributes;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioTrack;
import android.media.MediaCodec;
import android.media.MediaFormat;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;

import com.foxx.androidcast.media.AacAdtsHelper;
import com.foxx.androidcast.media.PcmLevelAnalyzer;
import com.foxx.androidcast.receiver.av.AudioFilterChain;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

/** AAC decoder → AudioTrack; low-latency live playback (no sender-PTS sleep). */
public class AudioDecoder {
    private static final String TAG = "AudioDecoder";
    private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
    private static final int MAX_INPUT_QUEUE = 48;

    public interface RenderCallback {
        void onPcmRendered(int bytes, PcmLevelAnalyzer.Levels levels);
    }

    public static final class Stats {
        public long framesQueued;
        public long framesFed;
        public long pcmBytesWritten;
        public long pcmSilentBlocks;
        public long pcmAudibleBlocks;
        public long decodeErrors;
        public long queueDrops;
        public long writeShortfalls;
        public float lastPeak;
        public float lastRms;
    }

    private final Context appContext;
    private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
    private Handler worker;
    private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(MAX_INPUT_QUEUE);
    private final Stats stats = new Stats();

    private MediaCodec codec;
    private AudioTrack track;
    private volatile boolean configured;
    private volatile boolean draining;
    private Thread drainThread;
    private int sampleRate = 44100;
    private int channels = 2;
    private boolean useAdtsMode;
    private RenderCallback renderCallback;
    private PendingFrame retryFrame;
    private long lastDropLogMs;

    private static final class PendingFrame {
        final long ptsUs;
        final byte[] data;

        PendingFrame(long ptsUs, byte[] data) {
            this.ptsUs = ptsUs;
            this.data = data;
        }
    }

    public AudioDecoder(Context context) {
        this.appContext = context.getApplicationContext();
        workerThread.start();
        worker = new Handler(workerThread.getLooper());
    }

    public void setRenderCallback(RenderCallback callback) {
        this.renderCallback = callback;
    }

    public Stats getStats() {
        return stats;
    }

    public void configure(int sampleRate, int channels, byte[] csd0) throws IOException {
        final IOException[] error = new IOException[1];
        final Object lock = new Object();
        worker.post(() -> {
            try {
                releaseOnWorker();
                AudioDecoder.this.sampleRate = sampleRate;
                AudioDecoder.this.channels = channels;
                useAdtsMode = csd0 == null || csd0.length < 2;

                MediaFormat format = MediaFormat.createAudioFormat(MIME, sampleRate, channels);
                if (useAdtsMode) {
                    format.setInteger(MediaFormat.KEY_IS_ADTS, 1);
                } else {
                    format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0));
                }
                codec = MediaCodec.createDecoderByType(MIME);
                codec.configure(format, null, null, 0);
                codec.start();
                createAudioTrack(sampleRate, channels);
                ReceiverAvRuntime.audio().reset(sampleRate, channels);
                configured = true;
                draining = true;
                retryFrame = null;
                startDrainThread();
                Log.i(TAG, "Audio ready " + sampleRate + "Hz ch=" + channels
                        + " mode=" + (useAdtsMode ? "ADTS" : "raw+csd")
                        + " csdLen=" + (csd0 != null ? csd0.length : 0)
                        + " qMax=" + MAX_INPUT_QUEUE);
            } catch (IOException e) {
                error[0] = e;
            } catch (Exception e) {
                error[0] = new IOException(e);
            } finally {
                synchronized (lock) {
                    lock.notifyAll();
                }
            }
        });
        synchronized (lock) {
            try {
                lock.wait(5000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        if (error[0] != null) {
            throw error[0];
        }
    }

    private void startDrainThread() {
        drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
        drainThread.setPriority(Thread.MAX_PRIORITY - 1);
        drainThread.start();
    }

    private void createAudioTrack(int sampleRate, int channels) {
        int channelMask = channels >= 2
                ? AudioFormat.CHANNEL_OUT_STEREO
                : AudioFormat.CHANNEL_OUT_MONO;
        int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
        int bufSize = Math.max(minBuf * 4, 16384);
        track = new AudioTrack.Builder()
                .setAudioAttributes(new AudioAttributes.Builder()
                        .setUsage(AudioAttributes.USAGE_MEDIA)
                        .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                        .build())
                .setAudioFormat(new AudioFormat.Builder()
                        .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
                        .setSampleRate(sampleRate)
                        .setChannelMask(channelMask)
                        .build())
                .setBufferSizeInBytes(bufSize)
                .setTransferMode(AudioTrack.MODE_STREAM)
                .build();
        track.setVolume(1.0f);
        requestAudioFocus();
        track.play();
        Log.i(TAG, "AudioTrack buf=" + bufSize + " playState=" + track.getPlayState());
    }

    /** Drops queued AAC input without tearing down the decoder (e.g. after video keyframe wait). */
    public void clearInputQueue() {
        inputQueue.clear();
        retryFrame = null;
    }

    public void queueFrame(long ptsUs, byte[] data) {
        if (data == null || data.length == 0) {
            return;
        }
        stats.framesQueued++;
        PendingFrame frame = new PendingFrame(ptsUs, data.clone());
        if (inputQueue.offer(frame)) {
            return;
        }
        int dropped = inputQueue.size() + (retryFrame != null ? 1 : 0);
        inputQueue.clear();
        retryFrame = null;
        if (dropped > 0) {
            stats.queueDrops += dropped;
            logDropBurst();
        }
        if (!inputQueue.offer(frame)) {
            stats.queueDrops++;
            logDropBurst();
        }
    }

    private void logDropBurst() {
        long now = System.currentTimeMillis();
        if (now - lastDropLogMs > 2000) {
            lastDropLogMs = now;
            Log.w(TAG, "AAC input drops=" + stats.queueDrops + " q=" + inputQueue.size());
        }
    }

    private void drainLoop() {
        MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
        while (draining) {
            try {
                if (retryFrame != null) {
                    feedFrameSync(retryFrame, info);
                }
                PendingFrame frame = inputQueue.poll(5, TimeUnit.MILLISECONDS);
                if (frame != null && codec != null) {
                    feedFrameSync(frame, info);
                }
                drainOutputSync(info);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            } catch (Exception e) {
                stats.decodeErrors++;
                if (draining) {
                    Log.w(TAG, "drainLoop: " + e.getMessage());
                }
            }
        }
    }

    private void feedFrameSync(PendingFrame frame, MediaCodec.BufferInfo info) {
        retryFrame = null;
        byte[] data = frame.data;
        if (useAdtsMode) {
            data = AacAdtsHelper.wrapIfNeeded(data, sampleRate, channels);
        }
        int inIndex = codec.dequeueInputBuffer(5_000);
        if (inIndex < 0) {
            retryFrame = frame;
            return;
        }
        ByteBuffer buffer = codec.getInputBuffer(inIndex);
        if (buffer == null) {
            retryFrame = frame;
            return;
        }
        buffer.clear();
        buffer.put(data);
        codec.queueInputBuffer(inIndex, 0, data.length, frame.ptsUs, 0);
        stats.framesFed++;
        drainOutputSync(info);
    }

    private void drainOutputSync(MediaCodec.BufferInfo info) {
        if (codec == null) {
            return;
        }
        for (;;) {
            int outIndex = codec.dequeueOutputBuffer(info, 0);
            if (outIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
                break;
            }
            if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                Log.i(TAG, "Decoder output: " + codec.getOutputFormat());
                continue;
            }
            if (outIndex < 0) {
                continue;
            }
            ByteBuffer outBuf = codec.getOutputBuffer(outIndex);
            if (outBuf != null && info.size > 0 && track != null
                    && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
                byte[] pcm = new byte[info.size];
                outBuf.position(info.offset);
                outBuf.limit(info.offset + info.size);
                outBuf.get(pcm);
                PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(pcm, pcm.length);
                stats.lastPeak = levels.peak;
                stats.lastRms = levels.rms;
                if (levels.silent) {
                    stats.pcmSilentBlocks++;
                } else {
                    stats.pcmAudibleBlocks++;
                }
                ReceiverAvRuntime.audio().process(pcm, pcm.length, sampleRate, channels);
                int written = writePcm(pcm);
                if (written > 0) {
                    stats.pcmBytesWritten += written;
                    if (renderCallback != null) {
                        renderCallback.onPcmRendered(written, levels);
                    }
                } else if (written < pcm.length) {
                    stats.writeShortfalls++;
                }
            }
            codec.releaseOutputBuffer(outIndex, false);
        }
    }

    private int writePcm(byte[] pcm) {
        if (track == null || pcm.length == 0) {
            return 0;
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            int w = track.write(pcm, 0, pcm.length, AudioTrack.WRITE_BLOCKING);
            return Math.max(0, w);
        }
        return writePcmLegacy(pcm);
    }

    private int writePcmLegacy(byte[] pcm) {
        int total = 0;
        int offset = 0;
        int idle = 0;
        while (offset < pcm.length && idle < 40) {
            int w = track.write(pcm, offset, pcm.length - offset);
            if (w > 0) {
                total += w;
                offset += w;
                idle = 0;
                continue;
            }
            idle++;
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
        return total;
    }

    private void requestAudioFocus() {
        AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
        if (am != null) {
            am.requestAudioFocus(focusChange -> {
            }, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
        }
    }

    public void flushAndStop() {
        draining = false;
        configured = false;
        inputQueue.clear();
        retryFrame = null;
        final Object lock = new Object();
        worker.post(() -> {
            releaseOnWorker();
            synchronized (lock) {
                lock.notifyAll();
            }
        });
        synchronized (lock) {
            try {
                lock.wait(500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    public void release() {
        flushAndStop();
    }

    private void releaseOnWorker() {
        draining = false;
        configured = false;
        retryFrame = null;
        inputQueue.clear();
        if (drainThread != null) {
            drainThread.interrupt();
            try {
                drainThread.join(400);
            } catch (InterruptedException ignored) {
                Thread.currentThread().interrupt();
            }
            drainThread = null;
        }
        if (codec != null) {
            try {
                codec.stop();
            } catch (Exception ignored) {
            }
            codec.release();
            codec = null;
        }
        if (track != null) {
            try {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                    track.pause();
                    track.flush();
                }
                track.stop();
            } catch (Exception ignored) {
            }
            track.release();
            track = null;
        }
        abandonAudioFocus();
    }

    private void abandonAudioFocus() {
        AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
        if (am != null) {
            am.abandonAudioFocus(null);
        }
    }

    public void releaseAndQuit() {
        release();
        workerThread.quitSafely();
    }
}
