package com.foxx.androidcast.sender;

/*********************************************************************
 * AudioCapture.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 AudioCapture
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (6 commits, 326 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	914af98a311d798bfd6d439943292b0be1dd5d2f23b0900997f6c02f7f7167a6
**********************************************************************/
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.AudioAttributes;
import android.media.AudioFormat;
import android.media.AudioPlaybackCaptureConfiguration;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.media.projection.MediaProjection;
import android.os.Build;
import android.util.Log;

import androidx.core.content.ContextCompat;

import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.media.PcmLevelAnalyzer;

/**
 * Captures device playback via MediaProjection (primary) and optionally mixes microphone
 * when playback is silent. Playback is never replaced by mic-only capture.
 */
public class AudioCapture {
    private static final String TAG = "AudioCapture";

    public interface Callback {
        void onPcm(byte[] pcm, int length, long ptsUs, PcmLevelAnalyzer.Levels levels);

        void onCaptureModeChanged(String mode);
    }

    private final Context appContext;
    private AudioRecord playbackRecord;
    private AudioRecord micRecord;
    private Thread thread;
    private volatile boolean running;
    private volatile String captureMode = "playback";

    public AudioCapture(Context context) {
        this.appContext = context.getApplicationContext();
    }

    public String getCaptureMode() {
        return captureMode;
    }

    public void start(MediaProjection projection, Callback callback) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            Log.w(TAG, "Audio capture requires API 29+");
            return;
        }
        if (projection == null) {
            startMicOnly(callback);
            return;
        }
        if (!startPlayback(projection)) {
            Log.e(TAG, "Playback capture unavailable");
            return;
        }
        micRecord = canUseMic() ? buildMicRecord() : null;
        if (micRecord != null && micRecord.getState() == AudioRecord.STATE_INITIALIZED) {
            micRecord.startRecording();
            Log.i(TAG, "Mic mix enabled (playback primary)");
        } else {
            micRecord = null;
        }
        running = true;
        playbackRecord.startRecording();
        captureMode = micRecord != null ? "playback+mic" : "playback";
        if (callback != null) {
            callback.onCaptureModeChanged(captureMode);
        }
        Log.i(TAG, "Audio capture started: " + captureMode);
        thread = new Thread(() -> mixLoop(callback), "AudioCapture");
        thread.start();
    }

    private void startMicOnly(Callback callback) {
        micRecord = canUseMic() ? buildMicRecord() : null;
        if (micRecord == null || micRecord.getState() != AudioRecord.STATE_INITIALIZED) {
            micRecord = null;
            Log.w(TAG, "Mic-only capture unavailable");
            return;
        }
        running = true;
        micRecord.startRecording();
        captureMode = "mic";
        if (callback != null) {
            callback.onCaptureModeChanged(captureMode);
        }
        Log.i(TAG, "Mic-only capture for calibration VU");
        thread = new Thread(() -> micOnlyLoop(callback), "AudioCaptureMic");
        thread.start();
    }

    private void micOnlyLoop(Callback callback) {
        int frameBytes = pcmFrameBytes();
        byte[] micBuf = new byte[frameBytes];
        byte[] copy = new byte[frameBytes];
        final long frameNs = 20_000_000L;
        long captureEpochNs = -1;
        long nextFrameNs = System.nanoTime();
        while (running) {
            if (!readFully(micRecord, micBuf, frameBytes)) {
                continue;
            }
            long now = System.nanoTime();
            if (captureEpochNs < 0) {
                captureEpochNs = now;
            }
            if (now < nextFrameNs) {
                long sleepMs = (nextFrameNs - now) / 1_000_000L;
                if (sleepMs > 0) {
                    try {
                        Thread.sleep(sleepMs);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        break;
                    }
                }
            } else if (now - nextFrameNs > frameNs * 5) {
                nextFrameNs = now;
                captureEpochNs = now;
            }
            nextFrameNs += frameNs;
            System.arraycopy(micBuf, 0, copy, 0, frameBytes);
            long ptsUs = (now - captureEpochNs) / 1000L;
            if (callback != null) {
                callback.onPcm(copy, frameBytes, ptsUs,
                        com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes));
            }
        }
    }

    private static int pcmFrameBytes() {
        return CastConfig.AUDIO_SAMPLE_RATE * CastConfig.AUDIO_CHANNEL_COUNT * 2 / 50;
    }

    private static boolean readFully(AudioRecord record, byte[] buf, int length) {
        int off = 0;
        while (off < length && record != null) {
            int n = record.read(buf, off, length - off);
            if (n <= 0) {
                return false;
            }
            off += n;
        }
        return true;
    }

    private boolean startPlayback(MediaProjection projection) {
        try {
            playbackRecord = buildPlaybackRecord(projection);
            if (playbackRecord.getState() != AudioRecord.STATE_INITIALIZED) {
                Log.e(TAG, "Playback AudioRecord not initialized");
                releasePlayback();
                return false;
            }
            return true;
        } catch (Exception e) {
            Log.e(TAG, "Playback capture setup failed", e);
            releasePlayback();
            return false;
        }
    }

    private AudioRecord buildPlaybackRecord(MediaProjection projection) {
        int channelMask = AudioFormat.CHANNEL_IN_STEREO;
        int encoding = AudioFormat.ENCODING_PCM_16BIT;
        int minBuf = AudioRecord.getMinBufferSize(CastConfig.AUDIO_SAMPLE_RATE, channelMask, encoding);
        int bufferSize = Math.max(minBuf, 8192) * 2;

        AudioFormat format = new AudioFormat.Builder()
                .setEncoding(encoding)
                .setSampleRate(CastConfig.AUDIO_SAMPLE_RATE)
                .setChannelMask(channelMask)
                .build();

        AudioPlaybackCaptureConfiguration.Builder capBuilder =
                new AudioPlaybackCaptureConfiguration.Builder(projection);
        capBuilder.addMatchingUsage(AudioAttributes.USAGE_MEDIA);
        capBuilder.addMatchingUsage(AudioAttributes.USAGE_GAME);
        capBuilder.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN);
        capBuilder.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION);
        capBuilder.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY);
        capBuilder.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE);
        capBuilder.addMatchingUsage(AudioAttributes.USAGE_NOTIFICATION);
        capBuilder.addMatchingUsage(AudioAttributes.USAGE_ALARM);
        capBuilder.addMatchingUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION);

        return new AudioRecord.Builder()
                .setAudioFormat(format)
                .setBufferSizeInBytes(bufferSize)
                .setAudioPlaybackCaptureConfig(capBuilder.build())
                .build();
    }

    private AudioRecord buildMicRecord() {
        int channelMask = AudioFormat.CHANNEL_IN_STEREO;
        int encoding = AudioFormat.ENCODING_PCM_16BIT;
        int minBuf = AudioRecord.getMinBufferSize(CastConfig.AUDIO_SAMPLE_RATE, channelMask, encoding);
        int bufferSize = Math.max(minBuf, 8192) * 2;
        return new AudioRecord(
                MediaRecorder.AudioSource.VOICE_RECOGNITION,
                CastConfig.AUDIO_SAMPLE_RATE,
                channelMask,
                encoding,
                bufferSize);
    }

    private boolean canUseMic() {
        return ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO)
                == PackageManager.PERMISSION_GRANTED;
    }

    private void mixLoop(Callback callback) {
        int frameBytes = pcmFrameBytes();
        byte[] playBuf = new byte[frameBytes];
        byte[] micBuf = new byte[frameBytes];
        byte[] mixOut = new byte[frameBytes];
        final long frameNs = 20_000_000L;
        long captureEpochNs = -1;
        long nextFrameNs = System.nanoTime();
        while (running) {
            if (!readFully(playbackRecord, playBuf, frameBytes)) {
                continue;
            }
            long now = System.nanoTime();
            if (captureEpochNs < 0) {
                captureEpochNs = now;
            }
            if (now < nextFrameNs) {
                long sleepMs = (nextFrameNs - now) / 1_000_000L;
                if (sleepMs > 0) {
                    try {
                        Thread.sleep(sleepMs);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        break;
                    }
                }
            } else if (now - nextFrameNs > frameNs * 5) {
                nextFrameNs = now;
                captureEpochNs = now;
            }
            nextFrameNs += frameNs;
            int mRead = micRecord != null ? micRecord.read(micBuf, 0, frameBytes) : 0;
            System.arraycopy(playBuf, 0, mixOut, 0, frameBytes);
            if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) {
                for (int i = 0; i < frameBytes; i++) {
                    mixOut[i] = (byte) Math.max(mixOut[i] & 0xff, micBuf[i] & 0xff);
                }
            }
            String mode = hasAudibleSamples(playBuf, frameBytes) ? "playback" : "playback-quiet";
            if (!mode.equals(captureMode)) {
                captureMode = mode;
                notifyMode(callback, captureMode);
            }
            long ptsUs = (now - captureEpochNs) / 1000L;
            if (callback != null) {
                PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes);
                callback.onPcm(mixOut, frameBytes, ptsUs, levels);
            }
        }
    }

    private static void notifyMode(Callback callback, String mode) {
        if (callback != null) {
            callback.onCaptureModeChanged(mode);
        }
    }

    private static boolean hasAudibleSamples(byte[] buf, int len) {
        for (int i = 0; i + 1 < len; i += 2) {
            short s = (short) ((buf[i] & 0xff) | (buf[i + 1] << 8));
            if (Math.abs(s) > 256) {
                return true;
            }
        }
        return false;
    }

    public void stop() {
        running = false;
        if (thread != null) {
            try {
                thread.join(1000);
            } catch (InterruptedException ignored) {
                Thread.currentThread().interrupt();
            }
            thread = null;
        }
        releaseMic();
        releasePlayback();
    }

    private void releasePlayback() {
        if (playbackRecord != null) {
            try {
                playbackRecord.stop();
            } catch (Exception ignored) {
            }
            playbackRecord.release();
            playbackRecord = null;
        }
    }

    private void releaseMic() {
        if (micRecord != null) {
            try {
                micRecord.stop();
            } catch (Exception ignored) {
            }
            micRecord.release();
            micRecord = null;
        }
    }
}
