//! Android voice audio backend (SDD-111..SDD-115).
//!
//! Implements the `MobileVoiceAudioBackend` trait against
//! `oboe-rs 0.6.x`, which wraps Google's Oboe C++ library on top of
//! AAudio (Android 8.1+) and OpenSL ES (legacy). The backend owns
//! the lifetime of a paired voice input / output stream pair and
//! drives the SDD-113 hardware-effect attach via JNI.
//!
//! ## Manifest contract (SDD-114)
//!
//! This module assumes the Android manifest already declares
//! (landed by Wave 2B-1):
//!
//! - `INTERNET`, `RECORD_AUDIO`, `FOREGROUND_SERVICE`,
//!   `FOREGROUND_SERVICE_MICROPHONE`, `POST_NOTIFICATIONS`,
//!   `MODIFY_AUDIO_SETTINGS`, `BLUETOOTH_CONNECT`
//! - `<service android:name=".AndroidVoiceForegroundService"
//!   android:exported="false"
//!   android:foregroundServiceType="microphone"/>`
//!
//! Regressions against the manifest are SDD-114 violations and are
//! caught by the CI assertion described in SDD-114 item 4. This
//! file does NOT mutate the manifest.
//!
//! ## Callback safety
//!
//! Oboe audio callbacks run on real-time threads. Under `panic=abort`
//! a panic in an audio callback aborts the process. Every callback
//! path in this module:
//!
//! 1. Catches unwinds at the FFI boundary (`std::panic::catch_unwind`).
//! 2. Never blocks (no mutex/RwLock acquisition; only `try_send` on
//!    bounded channels).
//! 3. Marshals events (error/disconnect, focus change, route change)
//!    to a regular tokio task via an `mpsc::UnboundedSender` so all
//!    engine-state mutation happens off the audio thread (SDD-115).

#![cfg(target_os = "android")]

use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};

use audiopus::coder::Encoder as OpusEncoder;
use tracing::{debug, info, warn};

use crate::mobile_voice_backend::{
    clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after,
    next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset,
    AchievedPerformanceMode, AchievedSharingMode, AndroidAudioDiagnostics,
    AndroidVoiceStreamConfig, AudioSessionId, BackendError, BackendEvent, BackendEventRx,
    BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
    SharingModeChoice, VoiceAudioParams,
};
use chanora_protocol::OutPacket;
use tsclientlib::audio::AudioHandler;

use crate::{engine::SessionAudioId, AudioError};

use tokio::sync::mpsc;

use oboe::{
    AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe,
    AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe,
    DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput,
    PerformanceMode, SessionId, SharingMode, Usage,
};

use crate::processor::AudioProcessor;

// `BackendEvent` / `BackendEventRx` / `BackendEventTx` moved to
// `mobile_voice_backend` so the trait can expose `take_event_rx`
// (SDD-111 item 1) cross-platform.

// --- Render-reference buffer for AEC (SDD-111 / SDD-120) ---------
//
// The output (render) callback writes the audio that will be played
// into this ring buffer. The capture callback reads the latest render
// frame and feeds it to WebRTC APM's `process_render` so AEC can
// subtract the speaker output from the microphone input.
//
// 4 slots × 10 ms × 48 kHz mono f32. One slot is always being written
// by the render callback; the capture callback reads the slot that was
// most recently completed.

const RENDER_REF_SLOTS: usize = 4;
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;

struct RenderReferenceBuffer {
    buf: Box<[[f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]>,
    write_idx: std::sync::atomic::AtomicUsize,
}

impl RenderReferenceBuffer {
    fn new() -> Arc<Self> {
        Arc::new(Self {
            buf: Box::new([[0.0_f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]),
            write_idx: std::sync::atomic::AtomicUsize::new(0),
        })
    }

    fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
        let idx = self.write_idx.load(Ordering::Relaxed);
        unsafe {
            let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
                as *mut [f32; RENDER_REF_SAMPLES];
            (*slot).copy_from_slice(frame);
        }
        self.write_idx
            .store((idx + 1) % RENDER_REF_SLOTS, Ordering::Relaxed);
    }

    fn read_latest(&self) -> [f32; RENDER_REF_SAMPLES] {
        let wi = self.write_idx.load(Ordering::Relaxed);
        let ri = (wi + RENDER_REF_SLOTS - 1) % RENDER_REF_SLOTS;
        self.buf[ri]
    }
}

unsafe impl Send for RenderReferenceBuffer {}
unsafe impl Sync for RenderReferenceBuffer {}

// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
//
// Enhanced with WebRTC APM (AEC/NS/AGC) and VAD (voice activity
// detection). Oboe delivers 48 kHz mono i16 PCM in variable-size
// chunks. We accumulate into 10 ms frames, then:
//
//   1. i16 → f32 conversion
//   2. Read render reference (for AEC)
//   3. WebRtcApmProcessor::process_render + process_capture
//   4. VAD → VoiceActivityStateMachine → TransmitModeSelector
//   5. f32 → i16 conversion + mic gain
//   6. Accumulate to 20 ms → Opus encode → send

struct AndroidCaptureState {
    encoder: OpusEncoder,
    pcm_accum: Vec<i16>,
    opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
    voice_out_tx: mpsc::Sender<OutPacket>,
    transmit_active: Arc<AtomicBool>,
    frames_sent: Arc<AtomicU32>,
    mic_gain: f32,
    voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
    vad_detector: crate::vad::WebRtcFallbackVad,
    silero_vad_worker: Option<crate::vad::silero_onnx::SileroOnnxVadWorker>,
    current_vad_backend: crate::VadBackend,
    silero_model_epoch: u64,
    capture_frame_seq: u64,
    vad_state: crate::voice_activity::VoiceActivityStateMachine,
    webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
    audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
    audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
    render_reference: Arc<RenderReferenceBuffer>,
    input_sample_rate_hz: u32,
    resample_pos: f64,
    resample_last: i16,
    resample_scratch: Vec<i16>,
    pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
    pending_10ms_len: usize,
    fallback_warned_backend: Option<crate::VadBackend>,
}

impl AndroidCaptureState {
    fn new(
        voice_out_tx: mpsc::Sender<OutPacket>,
        transmit_active: Arc<AtomicBool>,
        frames_sent: Arc<AtomicU32>,
        mic_gain: f32,
        voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
        audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
        audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
        render_reference: Arc<RenderReferenceBuffer>,
        input_sample_rate_hz: u32,
    ) -> Result<Self, AudioError> {
        let encoder = crate::opus_voice::new_voip_encoder("android")?;
        // Seed the processor from the current shared config snapshot.
        // `open()` may later resolve Platform-owned stages to WebRTC
        // fallback (or keep them hardware-owned) once hardware-effect
        // binding completes; that resolved config is pushed back into
        // the live processor before the streams are started.
        let webrtc_apm_config = audio_processing_config
            .lock()
            .map(|cfg| webrtc_apm_config_from_audio_config(&cfg))
            .unwrap_or_default();
        Ok(Self {
            encoder,
            pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
            opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
            voice_out_tx,
            transmit_active,
            frames_sent,
            mic_gain,
            voice_activity_selector,
            vad_detector: crate::vad::WebRtcFallbackVad::default(),
            silero_vad_worker: None,
            current_vad_backend: crate::VadBackend::WebrtcVad,
            silero_model_epoch: crate::vad::silero_model_epoch(),
            capture_frame_seq: 0,
            vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
            webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
                webrtc_apm_config,
            )?,
            audio_processing_config,
            audio_processing_stats,
            render_reference,
            input_sample_rate_hz: input_sample_rate_hz.max(1),
            resample_pos: 0.0,
            resample_last: 0,
            resample_scratch: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
            pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
            pending_10ms_len: 0,
            fallback_warned_backend: None,
        })
    }

    /// Consume i16 mono frames from Oboe. Accumulate to 10 ms chunks,
    /// process each through WebRTC APM + VAD, then encode 20 ms frames.
    fn ingest_i16(&mut self, samples: &[i16]) {
        self.audio_processing_stats
            .record_callback_frames(samples.len() as u64);
        if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
            let resampled = self.resample_capture_to_48k(samples);
            self.ingest_48k_i16(&resampled);
            return;
        }
        self.ingest_48k_i16(samples);
    }

    fn ingest_48k_i16(&mut self, samples: &[i16]) {
        let mut offset = 0;
        while offset < samples.len() {
            let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
            let take = remaining.min(samples.len() - offset);
            self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
                .copy_from_slice(&samples[offset..offset + take]);
            self.pending_10ms_len += take;
            offset += take;

            if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
                let frame = self.pending_10ms;
                self.process_10ms_capture_frame(&frame);
                self.pending_10ms_len = 0;
            }
        }

        if !self.transmit_active.load(Ordering::Relaxed) {
            self.pcm_accum.clear();
            return;
        }

        while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
            let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
            frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
            self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
            match self.encoder.encode(&frame, &mut self.opus_out[..]) {
                Ok(len) => {
                    crate::opus_voice::send_voip_frame(
                        &self.voice_out_tx,
                        &self.frames_sent,
                        &self.opus_out,
                        len,
                        || {
                            warn!(
                                target: "chanora_audio",
                                "android Oboe: voice_out queue full; dropping frame"
                            );
                        },
                        || {
                            debug!(
                                target: "chanora_audio",
                                "android Oboe: voice_out closed; capture pipeline stopping"
                            );
                        },
                    );
                }
                Err(e) => {
                    warn!(
                        target: "chanora_audio",
                        error = %e,
                        "android Oboe opus encode failed"
                    );
                }
            }
        }
    }

    fn resample_capture_to_48k(&mut self, samples: &[i16]) -> Vec<i16> {
        if samples.is_empty() {
            return Vec::new();
        }
        self.resample_scratch.clear();
        let ratio = self.input_sample_rate_hz as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
        let mut pos = self.resample_pos;
        while pos < samples.len() as f64 {
            let i = pos.floor() as isize;
            let frac = pos - i as f64;
            let a = if i <= 0 {
                self.resample_last as f64
            } else {
                samples[(i - 1) as usize] as f64
            };
            let b = if i < samples.len() as isize {
                samples[i as usize] as f64
            } else {
                a
            };
            let value = (a + frac * (b - a))
                .round()
                .clamp(i16::MIN as f64, i16::MAX as f64) as i16;
            self.resample_scratch.push(value);
            pos += ratio;
        }
        self.resample_pos = pos - samples.len() as f64;
        self.resample_last = *samples.last().unwrap_or(&self.resample_last);
        self.resample_scratch.clone()
    }

    fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
        self.input_sample_rate_hz = sample_rate_hz.max(1);
    }

    fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
        if self.fallback_warned_backend != Some(failed_backend) {
            self.fallback_warned_backend = Some(failed_backend);
            // Warm-up period: ONNX workers need ~100ms to process first frame.
            // Don't flag as a problem if the capture has just started.
            if self.capture_frame_seq < 128 {
                info!(
                    target: "chanora_audio",
                    backend = failed_backend.as_str(),
                    seq = self.capture_frame_seq,
                    "android: VAD backend warming up; using WebRTC fallback"
                );
            } else {
                warn!(
                    target: "chanora_audio",
                    backend = failed_backend.as_str(),
                    "android: VAD backend unavailable; using WebRTC fallback for runtime detection"
                );
            }
        }
    }

    fn process_10ms_capture_frame(&mut self, samples: &[i16; crate::frame::FRAME_10MS_SAMPLES]) {
        let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
        for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
            *dst = crate::frame::i16_to_f32(src);
        }
        let input_dbfs = crate::frame::dbfs(&frame);

        let render_ref = self.render_reference.read_latest();
        self.webrtc_apm_processor.process_render(&render_ref);
        self.webrtc_apm_processor.process_capture(&mut frame);

        let voice_activity_mode = self
            .voice_activity_selector
            .as_ref()
            .map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
            .unwrap_or(false);
        if !voice_activity_mode {
            self.silero_vad_worker = None;
            self.current_vad_backend = crate::VadBackend::Disabled;
            self.fallback_warned_backend = None;
            self.audio_processing_stats.set_vad_fallback_active(false);
        }

        let (vad_hangover, vad_backend) = self
            .audio_processing_config
            .try_lock()
            .map(|cfg| (cfg.vad_hangover_ms, cfg.vad_backend))
            .unwrap_or((
                crate::voice_activity::VAD_HANGOVER_MS,
                crate::VadBackend::WebrtcVad,
            ));
        if voice_activity_mode {
            self.vad_state.configure(
                crate::voice_activity::VAD_OPEN_AFTER_MS,
                vad_hangover,
                crate::voice_activity::VAD_MIN_TX_MS,
            );
        }

        // VAD backend switching only while VoiceActivity mode is active.
        let silero_epoch = crate::vad::silero_model_epoch();
        let silero_changed = voice_activity_mode
            && vad_backend == crate::VadBackend::SileroOnnx
            && silero_epoch != self.silero_model_epoch;
        if voice_activity_mode && (vad_backend != self.current_vad_backend || silero_changed) {
            self.current_vad_backend = vad_backend;
            self.silero_model_epoch = silero_epoch;
            self.fallback_warned_backend = None;
            match vad_backend {
                crate::VadBackend::SileroOnnx => {
                    let path = crate::vad::silero_model_bundle_path();
                    self.silero_vad_worker =
                        crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
                    if self.silero_vad_worker.is_none() {
                        warn!(
                            target: "chanora_audio",
                            "android: Silero VAD model not found at {path}; falling back to WebRTC VAD"
                        );
                    }
                }
                _ => {
                    self.silero_vad_worker = None;
                }
            }
            self.vad_state.reset();
        }

        let (vad_probability, active) = if voice_activity_mode {
            self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
            let capture_seq = self.capture_frame_seq;
            let mut used_fallback_vad = false;
            let vad = if vad_backend == crate::VadBackend::Disabled {
                crate::vad::VadOutput {
                    probability: 1.0,
                    speech: true,
                }
            } else if vad_backend == crate::VadBackend::SileroOnnx {
                if let Some(worker) = self.silero_vad_worker.as_ref() {
                    let enqueued = worker.try_send(capture_seq, &frame);
                    if !worker.is_stale(capture_seq) {
                        let p = worker.latest_probability();
                        crate::vad::VadOutput {
                            probability: p,
                            speech: p >= 0.5,
                        }
                    } else if enqueued {
                        crate::vad::VadOutput {
                            probability: 0.0,
                            speech: false,
                        }
                    } else {
                        used_fallback_vad = true;
                        self.mark_vad_fallback_active(vad_backend);
                        crate::vad::VoiceActivityDetector::process_10ms(
                            &mut self.vad_detector,
                            &frame,
                        )
                    }
                } else {
                    used_fallback_vad = true;
                    self.mark_vad_fallback_active(vad_backend);
                    crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
                }
            } else {
                crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
            };
            self.audio_processing_stats
                .set_vad_fallback_active(used_fallback_vad);
            (vad.probability, self.vad_state.update(vad.speech))
        } else {
            (0.0, false)
        };
        if let Some(sel) = &self.voice_activity_selector {
            sel.set_voice_activity_open(voice_activity_mode && active);
        }
        self.audio_processing_stats.update_capture(
            input_dbfs,
            crate::frame::dbfs(&frame),
            vad_probability,
            voice_activity_mode && active,
            self.transmit_active.load(Ordering::Relaxed),
        );
        self.audio_processing_stats
            .record_capture_frame(frame.iter().all(|sample| sample.abs() < 1.0e-6));

        if !self.transmit_active.load(Ordering::Relaxed) {
            return;
        }

        let gain = self.mic_gain;
        if (gain - 1.0).abs() < f32::EPSILON {
            self.pcm_accum
                .extend(frame.iter().copied().map(crate::frame::f32_to_i16));
        } else {
            self.pcm_accum.extend(frame.iter().copied().map(|s| {
                let scaled = (crate::frame::f32_to_i16(s) as f32) * gain;
                scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
            }));
        }
    }
}

struct InputCallback {
    state: Arc<Mutex<AndroidCaptureState>>,
    event_tx: BackendEventTx,
}

impl AudioInputCallback for InputCallback {
    type FrameType = (i16, Mono);

    fn on_audio_ready(
        &mut self,
        _stream: &mut dyn AudioInputStreamSafe,
        frames: &[i16],
    ) -> DataCallbackResult {
        let _ = catch_unwind(AssertUnwindSafe(|| {
            if let Ok(mut s) = self.state.lock() {
                s.ingest_i16(frames);
            }
        }));
        DataCallbackResult::Continue
    }

    fn on_error_after_close(&mut self, _stream: &mut dyn AudioInputStreamSafe, error: oboe::Error) {
        if matches!(error, oboe::Error::Disconnected) {
            let _ = self.event_tx.send(BackendEvent::Disconnected);
        } else {
            warn!(target: "chanora_audio", error = ?error, "android: input stream error_after_close");
        }
    }
}

// --- Output callback wiring (SDD-111 / SDD-120) ----
//
// Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32
// from `AudioHandler::fill_buffer`, applies output gain + mute, and
// writes mono i16 to the Oboe output buffer.

struct OutputCallback {
    handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
    output_gain: Arc<AtomicU32>,
    output_muted: Arc<AtomicBool>,
    event_tx: BackendEventTx,
    scratch: Arc<Mutex<Vec<f32>>>,
    render_reference: Arc<RenderReferenceBuffer>,
    audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
    pending_render_ref: [f32; crate::frame::FRAME_10MS_SAMPLES],
    pending_render_ref_len: usize,
}

impl AudioOutputCallback for OutputCallback {
    type FrameType = (i16, Mono);

    fn on_audio_ready(
        &mut self,
        _stream: &mut dyn AudioOutputStreamSafe,
        frames: &mut [i16],
    ) -> DataCallbackResult {
        let _ = catch_unwind(AssertUnwindSafe(|| {
            let needed = frames.len() * 2; // stereo
            let scratch = &mut self.scratch.lock().unwrap();
            if scratch.len() < needed {
                scratch.resize(needed, 0.0);
            } else {
                for s in &mut scratch[..needed] {
                    *s = 0.0;
                }
            }
            match self.handler.try_lock() {
                Ok(mut h) => {
                    let _ = h.fill_buffer(&mut scratch[..needed]);
                }
                Err(std::sync::TryLockError::WouldBlock) => {}
                Err(std::sync::TryLockError::Poisoned(e)) => {
                    warn!(
                        target: "chanora_audio",
                        "AudioHandler mutex poisoned: {}",
                        e
                    );
                }
            }
            let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
            let muted = self.output_muted.load(Ordering::Relaxed);
            let _ = crate::voice_render::downmix_stereo_f32_to_mono_i16(
                &scratch[..needed],
                frames,
                gain,
                muted,
            );
            self.audio_processing_stats
                .update_render(crate::frame::dbfs(&scratch[..needed]), frames.len() as u32);

            // Accumulate the full render callback into 10 ms mono chunks so
            // AEC sees consistent reference timing even when output callbacks
            // are shorter or longer than 10 ms.
            for chunk in scratch[..needed].chunks_exact(2) {
                self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
                self.pending_render_ref_len += 1;
                if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
                    self.render_reference.write(&self.pending_render_ref);
                    self.pending_render_ref_len = 0;
                }
            }
        }));
        DataCallbackResult::Continue
    }

    fn on_error_after_close(
        &mut self,
        _stream: &mut dyn AudioOutputStreamSafe,
        error: oboe::Error,
    ) {
        if matches!(error, oboe::Error::Disconnected) {
            let _ = self.event_tx.send(BackendEvent::Disconnected);
        } else {
            warn!(target: "chanora_audio", error = ?error, "android: output stream error_after_close");
        }
    }
}

// --- The backend itself ------------------------------------------

/// Android voice-audio backend (SDD-111). Owns one input + one
/// output Oboe stream and the JNI handles for SDD-113 hardware
/// effects.
pub struct AndroidVoiceUnit {
    input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
    output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,

    // Recorded achieved values (SDD-112).
    input_perf: AchievedPerformanceMode,
    input_share: AchievedSharingMode,
    output_perf: AchievedPerformanceMode,
    output_share: AchievedSharingMode,
    input_sample_rate: i32,
    input_frames_per_burst: i32,

    session_id: Option<AudioSessionId>,

    // SDD-113 hardware effect handles. Each is a JNI `GlobalRef`
    // we keep alive for the lifetime of the input stream.
    hw_effects: HardwareEffectHandles,

    event_tx: BackendEventTx,
    event_rx: Option<BackendEventRx>,
}

#[derive(Default)]
struct HardwareEffectHandles {
    aec: Option<jni::objects::GlobalRef>,
    ns: Option<jni::objects::GlobalRef>,
    agc: Option<jni::objects::GlobalRef>,
}

impl AndroidVoiceUnit {
    /// Open the input + output streams (SDD-111 + SDD-112) and,
    /// once a session id is available, attach SDD-113 hardware
    /// effects. The engine is expected to have already issued
    /// `setMode(MODE_IN_COMMUNICATION)` (SDD-108) per the SDD-115
    /// sequencing rules.
    ///
    /// `params` bundles the engine-owned state shared with the Oboe
    /// audio callbacks (SDD-120 amendment: Android Oboe-only audio
    /// path — capture pipeline, playback pull, and PTT gate).
    pub(crate) fn open(
        cfg: &AndroidVoiceStreamConfig,
        params: VoiceAudioParams,
    ) -> Result<Self, BackendError> {
        let (event_tx, event_rx) = mpsc::unbounded_channel();

        // Shared render-reference buffer (for AEC). The output
        // callback writes; the capture callback reads.
        let render_ref_buf = RenderReferenceBuffer::new();
        let render_ref_for_capture = render_ref_buf.clone();

        // Clone the APM config Arc before params is partially moved
        // into the capture state constructor below.
        let apm_config_clone = params.audio_processing_config.clone();
        let audio_processing_stats = params.audio_processing_stats.clone();

        let capture_state = Arc::new(Mutex::new(
            AndroidCaptureState::new(
                params.voice_out_tx,
                params.transmit_active,
                params.frames_sent,
                params.mic_gain,
                params.voice_activity_selector,
                params.audio_processing_config,
                audio_processing_stats.clone(),
                render_ref_for_capture,
                cfg.sample_rate,
            )
            .map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?,
        ));

        let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192)));

        // --- Open input stream (SDD-112) ---------------------------
        let input_builder = AudioStreamBuilder::default()
            .set_direction::<OboeInput>()
            .set_sample_rate(cfg.sample_rate as i32)
            .set_channel_count::<Mono>()
            .set_format::<i16>()
            .set_performance_mode(if cfg.request_low_latency {
                PerformanceMode::LowLatency
            } else {
                PerformanceMode::None
            })
            .set_sharing_mode(if cfg.request_exclusive {
                SharingMode::Exclusive
            } else {
                SharingMode::Shared
            })
            // SDD-113 item 1 amends SDD-112: input stream MUST be
            // opened with session id allocation so platform effects
            // can attach.
            .set_session_id(SessionId::Allocate)
            .set_input_preset(InputPreset::VoiceCommunication)
            .set_usage(Usage::VoiceCommunication);

        let input_cb = InputCallback {
            state: capture_state.clone(),
            event_tx: event_tx.clone(),
        };
        let input_builder = input_builder.set_callback(input_cb);

        let mut input_stream = match input_builder.open_stream() {
            Ok(s) => Some(s),
            Err(e) => {
                // Input-preset fallback ladder (SDD-112 item 6) X
                // Sharing-mode ladder (SDD-112 item 7), explored
                // independently via the pure helpers so all
                // (preset × sharing) rungs are reachable.
                warn!(
                    target: "chanora_audio",
                    error = ?e,
                    "android: primary input stream open failed; entering fallback ladder"
                );
                match Self::open_input_fallback(cfg, &event_tx, capture_state.clone()) {
                    Ok(s) => Some(s),
                    Err(fallback_err) => {
                        warn!(
                            target: "chanora_audio",
                            error = %fallback_err,
                            "android: input unavailable after all fallbacks; continuing listen-only with output stream"
                        );
                        None
                    }
                }
            }
        };

        let input_perf = input_stream
            .as_ref()
            .map(|s| perf_from_oboe(s.get_performance_mode()))
            .unwrap_or(AchievedPerformanceMode::None);
        let input_share = input_stream
            .as_ref()
            .map(|s| share_from_oboe(s.get_sharing_mode()))
            .unwrap_or(AchievedSharingMode::Shared);
        let input_sample_rate = input_stream
            .as_ref()
            .map(|s| s.get_sample_rate())
            .unwrap_or(0);
        let input_frames_per_burst = input_stream
            .as_mut()
            .map(|s| s.get_frames_per_burst())
            .unwrap_or(0);
        // The oboe-rs fork (edisonjwa/oboe-rs 0.6.2) fixes the
        // get_session_id() panic with unwrap_or_default(), but the
        // SessionId enum still only models builder parameters (None =
        // -1, Allocate = 0). The actual system audio session ID (>0)
        // written by AAudio to mSessionId after stream open cannot be
        // expressed in the current enum; get_raw_session_id() is a
        // follow-up addition to the fork.
        //
        // For now: hardware effects require the system session ID.
        // WebRTC APM software processing handles AEC/NS/AGC/HPF.
        let session_id: Option<i32> = input_stream.as_ref().and_then(|s| s.get_raw_session_id());

        // --- Open output stream (SDD-112) --------------------------
        let output_builder = AudioStreamBuilder::default()
            .set_direction::<OboeOutput>()
            .set_sample_rate(cfg.sample_rate as i32)
            .set_channel_count::<Mono>()
            .set_format::<i16>()
            .set_performance_mode(if cfg.request_low_latency {
                PerformanceMode::LowLatency
            } else {
                PerformanceMode::None
            })
            .set_sharing_mode(if cfg.request_exclusive {
                SharingMode::Exclusive
            } else {
                SharingMode::Shared
            })
            .set_usage(Usage::VoiceCommunication)
            .set_content_type(oboe::ContentType::Speech);

        let render_ref_for_output = render_ref_buf.clone();
        let output_cb = OutputCallback {
            handler: params.handler.clone(),
            output_gain: params.output_gain.clone(),
            output_muted: params.output_muted.clone(),
            event_tx: event_tx.clone(),
            scratch: scratch.clone(),
            render_reference: render_ref_for_output,
            audio_processing_stats: audio_processing_stats.clone(),
            pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
            pending_render_ref_len: 0,
        };
        let output_builder = output_builder.set_callback(output_cb);

        let mut output_stream = match output_builder.open_stream() {
            Ok(s) => s,
            Err(e) => {
                warn!(
                    target: "chanora_audio",
                    error = ?e,
                    "android: primary output stream open failed; retrying with Shared sharing mode"
                );
                Self::open_output_fallback(
                    cfg,
                    &event_tx,
                    params.handler.clone(),
                    params.output_gain.clone(),
                    params.output_muted.clone(),
                    audio_processing_stats.clone(),
                    scratch.clone(),
                    render_ref_buf,
                )?
            }
        };

        let output_perf = perf_from_oboe(output_stream.get_performance_mode());
        let output_share = share_from_oboe(output_stream.get_sharing_mode());
        let output_sample_rate = output_stream.get_sample_rate();
        let output_frames_per_burst = output_stream.get_frames_per_burst();

        // SDD-112 / SRS-210: structured "stream opened" event with
        // achieved values. No PII; only platform-reported scalars.
        info!(
            target: "chanora_audio",
            event = "audio.android.stream_opened",
            input_perf = %input_perf,
            input_share = %input_share,
            input_sample_rate,
            input_frames_per_burst,
            output_perf = %output_perf,
            output_share = %output_share,
            output_sample_rate,
            output_frames_per_burst,
            session_id = ?session_id,
            "android: voice streams opened"
        );

        // --- SDD-113 hardware effects -----------------------------
        let hw_effects = if let Some(sid) = session_id {
            attach_hardware_effects(sid, &cfg.effects)
        } else {
            warn!(
                target: "chanora_audio",
                "android: no session id from input stream; hardware effects not bound — engine software AEC/NS/AGC will engage"
            );
            HardwareEffectHandles::default()
        };

        // --- SDD-113 config resolution: hardware-available? -------
        // Android gives the user a choice between hardware (JNI) and
        // software (WebRTC APM) effects. The AudioProcessingConfig's
        // EffectOwner fields encode that choice:
        //   Platform   → prefer hardware; software fallback if missing
        //   WebrtcApm  → always software WebRTC APM
        //   Off        → disable effect entirely
        //
        // Here we resolve Platform → WebrtcApm for each effect whose
        // hardware binding failed (or wasn't attempted). This is read
        // by the capture callback's WebRtcApmProcessor.
        {
            use crate::audio_processing::EffectOwner;
            let mut apm_cfg = apm_config_clone.lock().unwrap();
            let hw_aec = hw_effects.aec.is_some();
            let hw_ns = hw_effects.ns.is_some();
            let hw_agc = hw_effects.agc.is_some();
            if apm_cfg.aec == EffectOwner::Platform && !hw_aec {
                apm_cfg.aec = EffectOwner::WebrtcApm;
            }
            if apm_cfg.ns == EffectOwner::Platform && !hw_ns {
                apm_cfg.ns = EffectOwner::WebrtcApm;
            }
            if apm_cfg.agc == EffectOwner::Platform && !hw_agc {
                apm_cfg.agc = EffectOwner::WebrtcApm;
            }
            if apm_cfg.processing_backend
                == crate::audio_processing::AudioBackend::PlatformVoiceProcessing
                && (!hw_aec || !hw_ns || !hw_agc)
            {
                apm_cfg.processing_backend = crate::audio_processing::AudioBackend::WebrtcApm;
            }
            info!(
                target: "chanora_audio",
                aec = ?apm_cfg.aec,
                ns = ?apm_cfg.ns,
                agc = ?apm_cfg.agc,
                hpf = apm_cfg.hpf_enabled,
                hw_aec,
                hw_ns,
                hw_agc,
                session_id,
                "android: audio processing config resolved (hardware effects: aec={hw_aec} ns={hw_ns} agc={hw_agc})"
            );
        }
        if let Ok(mut capture) = capture_state.lock() {
            capture.set_input_sample_rate_hz(input_sample_rate.max(1) as u32);
            let resolved_cfg = apm_config_clone
                .lock()
                .map(|cfg| webrtc_apm_config_from_audio_config(&cfg))
                .unwrap_or_default();
            capture.webrtc_apm_processor.apply_config(resolved_cfg);
        }

        // --- SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3 ---
        // Publish the diagnostics snapshot. Per-effect engagement is
        // derived from (a) the JNI handle (`Hardware`) or (b) the
        // requested-effects mask (`Software` fallback) or (c) neither
        // (`None`). The achieved input preset readback is `Unknown`
        // until the oboe-rs wrapper exposes a getter (SWE4-UV-052
        // follow-through). No PII per SDD-090.
        let aec = effect_engagement(cfg.effects.aec, hw_effects.aec.is_some());
        let ns = effect_engagement(cfg.effects.noise_suppression, hw_effects.ns.is_some());
        let agc = effect_engagement(cfg.effects.agc, hw_effects.agc.is_some());
        let diagnostics = AndroidAudioDiagnostics {
            // SDD-112 items 4..7: requested-side pinned values.
            requested_performance_mode: if cfg.request_low_latency {
                "LowLatency"
            } else {
                "None"
            },
            requested_usage: "VoiceCommunication",
            requested_content_type: "Speech",
            requested_input_preset: "VoiceCommunication",
            requested_sharing_mode: if cfg.request_exclusive {
                "Exclusive"
            } else {
                "Shared"
            },
            requested_sample_rate_hz: cfg.sample_rate,
            // SDD-112 item 4 / 7 achieved side.
            achieved_performance_mode: input_perf,
            achieved_sharing_mode: input_share,
            achieved_input_preset: AchievedInputPreset::Unknown,
            achieved_sample_rate_hz: input_sample_rate.max(0) as u32,
            achieved_frames_per_burst: input_frames_per_burst.max(0) as u32,
            // SDD-113 item 7 / SRS-212 per-effect engagement.
            aec,
            ns,
            agc,
            // SDD-116 / SRS-210 latency-tier classification.
            latency_tier: latency_tier_for(input_perf),
        };
        publish_android_audio_diagnostics(diagnostics);
        params
            .audio_processing_stats
            .set_actual_sample_rate_hz(input_sample_rate.max(0) as u32);

        Ok(Self {
            input: input_stream,
            output: Some(output_stream),
            input_perf,
            input_share,
            output_perf,
            output_share,
            input_sample_rate,
            input_frames_per_burst,
            session_id,
            hw_effects,
            event_tx,
            event_rx: Some(event_rx),
        })
    }

    fn open_input_fallback(
        cfg: &AndroidVoiceStreamConfig,
        event_tx: &BackendEventTx,
        capture_state: Arc<Mutex<AndroidCaptureState>>,
    ) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
        // SDD-112 items 6 & 7: explore (preset × sharing) independently
        // via the pure helpers in `mobile_voice_backend`. Primary
        // attempt (VoiceCommunication × Exclusive) was tried by the
        // caller; here we walk the remaining rungs of both ladders.
        let presets = [
            InputPresetChoice::VoiceCommunication,
            InputPresetChoice::VoicePerformance,
            InputPresetChoice::Generic,
        ];
        let sharings = [SharingModeChoice::Exclusive, SharingModeChoice::Shared];
        // Track attempted presets / sharings so the helpers' order
        // statements are honoured in tests and operations.
        let mut attempted_presets: Vec<InputPresetChoice> = Vec::new();
        while let Some(preset_choice) = next_input_preset_after(&attempted_presets) {
            attempted_presets.push(preset_choice);
            let mut attempted_sharings: Vec<SharingModeChoice> = Vec::new();
            while let Some(sharing_choice) = next_sharing_mode_after(&attempted_sharings) {
                attempted_sharings.push(sharing_choice);
                // Skip the rung the primary attempt already used.
                if matches!(preset_choice, InputPresetChoice::VoiceCommunication)
                    && matches!(sharing_choice, SharingModeChoice::Exclusive)
                {
                    continue;
                }
                let preset = match preset_choice {
                    InputPresetChoice::VoiceCommunication => InputPreset::VoiceCommunication,
                    InputPresetChoice::VoicePerformance => InputPreset::VoicePerformance,
                    InputPresetChoice::Generic => InputPreset::Generic,
                };
                let sharing = match sharing_choice {
                    SharingModeChoice::Exclusive => SharingMode::Exclusive,
                    SharingModeChoice::Shared => SharingMode::Shared,
                };
                let cb = InputCallback {
                    state: capture_state.clone(),
                    event_tx: event_tx.clone(),
                };
                let builder = AudioStreamBuilder::default()
                    .set_direction::<OboeInput>()
                    .set_sample_rate(cfg.sample_rate as i32)
                    .set_channel_count::<Mono>()
                    .set_format::<i16>()
                    .set_performance_mode(PerformanceMode::LowLatency)
                    .set_sharing_mode(sharing)
                    .set_session_id(SessionId::Allocate)
                    .set_input_preset(preset)
                    .set_usage(Usage::VoiceCommunication)
                    .set_callback(cb);

                match builder.open_stream() {
                    Ok(s) => return Ok(s),
                    Err(e) => warn!(
                        target: "chanora_audio",
                        error = ?e,
                        ?preset_choice,
                        ?sharing_choice,
                        "android: input fallback rung failed"
                    ),
                }
            }
        }
        let _ = (presets, sharings); // documented coverage source
        Err(BackendError::OpenFailed(
            "all input preset / sharing fallbacks exhausted".to_string(),
        ))
    }

    fn open_output_fallback(
        cfg: &AndroidVoiceStreamConfig,
        event_tx: &BackendEventTx,
        handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
        output_gain: Arc<AtomicU32>,
        output_muted: Arc<AtomicBool>,
        audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
        scratch: Arc<Mutex<Vec<f32>>>,
        render_reference: Arc<RenderReferenceBuffer>,
    ) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
        let cb = OutputCallback {
            handler,
            output_gain,
            output_muted,
            event_tx: event_tx.clone(),
            scratch,
            render_reference,
            audio_processing_stats,
            pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
            pending_render_ref_len: 0,
        };
        let builder = AudioStreamBuilder::default()
            .set_direction::<OboeOutput>()
            .set_sample_rate(cfg.sample_rate as i32)
            .set_channel_count::<Mono>()
            .set_format::<i16>()
            .set_performance_mode(PerformanceMode::LowLatency)
            .set_sharing_mode(SharingMode::Shared)
            .set_usage(Usage::VoiceCommunication)
            .set_content_type(oboe::ContentType::Speech)
            .set_callback(cb);
        builder
            .open_stream()
            .map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
    }

    /// Clone of the event sender, for JNI focus / SCO listeners
    /// registered on the engine's behalf.
    pub fn event_sender(&self) -> BackendEventTx {
        self.event_tx.clone()
    }
}

fn webrtc_apm_config_from_audio_config(
    config: &crate::AudioProcessingConfig,
) -> crate::processor::webrtc_apm::WebRtcApmConfig {
    crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(config)
}

impl MobileVoiceAudioBackend for AndroidVoiceUnit {
    fn start(&mut self) -> Result<(), BackendError> {
        if let Some(s) = self.input.as_mut() {
            if let Err(e) = s.start() {
                warn!(
                    target: "chanora_audio",
                    error = ?e,
                    "android: input start failed; continuing listen-only with output stream"
                );
                self.input = None;
            }
        }
        if let Some(s) = self.output.as_mut() {
            s.start()
                .map_err(|e| BackendError::LifecycleFailed(format!("output start: {e:?}")))?;
        }
        Ok(())
    }

    fn stop(&mut self) -> Result<(), BackendError> {
        if let Some(s) = self.input.as_mut() {
            // best-effort stop — log on failure but continue
            // tearing down the rest of the pair.
            if let Err(e) = s.stop() {
                warn!(target: "chanora_audio", error = ?e, "android: input stop failed");
            }
        }
        if let Some(s) = self.output.as_mut() {
            if let Err(e) = s.stop() {
                warn!(target: "chanora_audio", error = ?e, "android: output stop failed");
            }
        }
        Ok(())
    }

    fn close(&mut self) -> Result<(), BackendError> {
        // SDD-115 reverse order: release hardware effects FIRST,
        // then close streams.
        release_hardware_effects(&mut self.hw_effects);
        self.stop().ok();
        // Dropping the Option drops the underlying AudioStreamAsync
        // which Oboe-safe-closes the stream.
        self.input = None;
        self.output = None;
        // SDD-116: clear the diagnostics slot so a stale snapshot
        // does not survive past the voice session.
        clear_android_audio_diagnostics();
        Ok(())
    }

    fn session_id(&self) -> Option<AudioSessionId> {
        self.session_id
    }

    fn take_event_rx(&mut self) -> Option<BackendEventRx> {
        self.event_rx.take()
    }

    fn achieved_sample_rate(&self) -> u32 {
        self.input_sample_rate.max(0) as u32
    }

    fn achieved_input_preset(&self) -> AchievedInputPreset {
        // The oboe-rs wrapper does not expose a preset-readback as of
        // 0.6.x; record `Unknown` until a readback path lands
        // (SWE4-UV-052 follow-through).
        AchievedInputPreset::Unknown
    }

    fn achieved_frames_per_burst(&self) -> u32 {
        self.input_frames_per_burst.max(0) as u32
    }

    fn achieved_input_performance_mode(&self) -> AchievedPerformanceMode {
        self.input_perf
    }
    fn achieved_input_sharing_mode(&self) -> AchievedSharingMode {
        self.input_share
    }
    fn achieved_output_performance_mode(&self) -> AchievedPerformanceMode {
        self.output_perf
    }
    fn achieved_output_sharing_mode(&self) -> AchievedSharingMode {
        self.output_share
    }
}

impl Drop for AndroidVoiceUnit {
    fn drop(&mut self) {
        // Belt-and-braces: if `close()` was not called explicitly,
        // tear hardware effects down here so the JNI globals are
        // released before the stream's session id evaporates.
        // Wrap in catch_unwind so a panic during Drop cannot unwind
        // into the JVM (SDD-115 callback safety).
        let _ = catch_unwind(AssertUnwindSafe(|| {
            release_hardware_effects(&mut self.hw_effects);
            // SDD-116: clear the diagnostics slot on Drop too.
            clear_android_audio_diagnostics();
        }));
    }
}

// --- helpers -----------------------------------------------------

/// Derive per-effect engagement (SDD-113 item 7) from the request
/// mask and the JNI binding outcome. Hardware: JNI ref retained.
/// Software: requested but hardware binding failed → engine
/// software AEC/NS/AGC carries it. None: not requested.
fn effect_engagement(requested: bool, hardware_bound: bool) -> EffectEngagement {
    match (requested, hardware_bound) {
        (true, true) => EffectEngagement {
            engaged: true,
            engine: EffectEngine::Hardware,
        },
        (true, false) => EffectEngagement {
            engaged: true,
            engine: EffectEngine::Software,
        },
        (false, _) => EffectEngagement {
            engaged: false,
            engine: EffectEngine::None,
        },
    }
}

fn perf_from_oboe(p: PerformanceMode) -> AchievedPerformanceMode {
    match p {
        PerformanceMode::LowLatency => AchievedPerformanceMode::LowLatency,
        PerformanceMode::PowerSaving => AchievedPerformanceMode::PowerSaving,
        PerformanceMode::PowerSavingOffloaded => AchievedPerformanceMode::PowerSaving,
        PerformanceMode::None => AchievedPerformanceMode::None,
    }
}

fn share_from_oboe(s: SharingMode) -> AchievedSharingMode {
    match s {
        SharingMode::Exclusive => AchievedSharingMode::Exclusive,
        SharingMode::Shared => AchievedSharingMode::Shared,
    }
}

// --- SDD-113 hardware-effect binding -----------------------------
//
// We attach `AcousticEchoCanceler`, `NoiseSuppressor`,
// `AutomaticGainControl` against the input stream's session id via
// JNI. Per-effect failure falls back to the engine's software path;
// failure is **never** propagated to the user (SDD-113 item 5).

fn attach_hardware_effects(
    session_id: AudioSessionId,
    effects: &crate::AudioEffects,
) -> HardwareEffectHandles {
    // SDD-115 callback safety: even on the (assumed) non-realtime
    // open/close paths, wrap the JNI body in `catch_unwind` so a
    // panic during teardown cannot unwind into the JVM.
    let result = catch_unwind(AssertUnwindSafe(|| {
        attach_hardware_effects_inner(session_id, effects)
    }));
    match result {
        Ok(h) => h,
        Err(_) => {
            warn!(
                target: "chanora_audio",
                "android: attach_hardware_effects panicked; caught at FFI boundary (software fallback engages)"
            );
            HardwareEffectHandles::default()
        }
    }
}

fn attach_hardware_effects_inner(
    session_id: AudioSessionId,
    effects: &crate::AudioEffects,
) -> HardwareEffectHandles {
    let ctx = ndk_context::android_context();
    if ctx.vm().is_null() {
        warn!(
            target: "chanora_audio",
            "android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
        );
        return HardwareEffectHandles::default();
    }
    let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
        Ok(v) => v,
        Err(e) => {
            warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
            return HardwareEffectHandles::default();
        }
    };
    let mut env = match jvm.attach_current_thread() {
        Ok(e) => e,
        Err(e) => {
            warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
            return HardwareEffectHandles::default();
        }
    };

    let mut handles = HardwareEffectHandles::default();
    if effects.aec {
        handles.aec = create_effect(
            &mut env,
            "android/media/audiofx/AcousticEchoCanceler",
            session_id,
            "AEC",
        );
    }
    if effects.noise_suppression {
        handles.ns = create_effect(
            &mut env,
            "android/media/audiofx/NoiseSuppressor",
            session_id,
            "NS",
        );
    }
    if effects.agc {
        handles.agc = create_effect(
            &mut env,
            "android/media/audiofx/AutomaticGainControl",
            session_id,
            "AGC",
        );
    }
    handles
}

/// SDD-113 item 3: probe the static `isAvailable()` on each effect
/// class before calling `create(int)`. Returns `false` on any JNI
/// failure so the caller engages the software fallback.
fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
    match env.call_static_method(class, "isAvailable", "()Z", &[]) {
        Ok(v) => match v.z() {
            Ok(b) => b,
            Err(e) => {
                let _ = env.exception_clear();
                warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() return cast failed");
                false
            }
        },
        Err(e) => {
            let _ = env.exception_clear();
            warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() threw");
            false
        }
    }
}

fn create_effect(
    env: &mut jni::JNIEnv,
    fqcn: &str,
    session_id: AudioSessionId,
    label: &str,
) -> Option<jni::objects::GlobalRef> {
    use jni::objects::JValue;
    // Class.create(int) -> ClassInstance|null
    let class = match env.find_class(fqcn) {
        Ok(c) => c,
        Err(e) => {
            warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
            return None;
        }
    };
    // SDD-113 item 3: probe isAvailable() before create(int).
    if !effect_is_available(env, &class, label) {
        info!(
            target: "chanora_audio",
            effect = label,
            "android: hardware effect not available on this device — software fallback engages"
        );
        return None;
    }
    let inst = match env.call_static_method(
        &class,
        "create",
        &format!("(I)L{fqcn};"),
        &[JValue::Int(session_id)],
    ) {
        Ok(v) => match v.l() {
            Ok(o) => o,
            Err(e) => {
                warn!(target: "chanora_audio", error = %e, effect = label, "android: create() return cast failed");
                return None;
            }
        },
        Err(e) => {
            // Likely an exception in JNI — clear so the next JNI
            // call doesn't immediately abort.
            let _ = env.exception_clear();
            warn!(target: "chanora_audio", error = %e, effect = label, "android: create() threw — software fallback engages");
            return None;
        }
    };
    if inst.is_null() {
        warn!(target: "chanora_audio", effect = label, "android: create() returned null (unsupported on device) — software fallback engages");
        return None;
    }
    // setEnabled(true) -> int (success code)
    if let Err(e) = env.call_method(
        &inst,
        "setEnabled",
        "(Z)I",
        &[JValue::Bool(jni::sys::JNI_TRUE)],
    ) {
        let _ = env.exception_clear();
        warn!(target: "chanora_audio", error = %e, effect = label, "android: setEnabled(true) failed — software fallback engages");
        return None;
    }
    match env.new_global_ref(&inst) {
        Ok(g) => {
            info!(target: "chanora_audio", effect = label, session_id, "android: hardware effect bound (SDD-113)");
            Some(g)
        }
        Err(e) => {
            warn!(target: "chanora_audio", error = %e, effect = label, "android: new_global_ref failed");
            None
        }
    }
}

fn release_hardware_effects(handles: &mut HardwareEffectHandles) {
    let result = catch_unwind(AssertUnwindSafe(|| release_hardware_effects_inner(handles)));
    if result.is_err() {
        warn!(
            target: "chanora_audio",
            "android: release_hardware_effects panicked; caught at FFI boundary"
        );
    }
}

fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
    let aec = handles.aec.take();
    let ns = handles.ns.take();
    let agc = handles.agc.take();
    if aec.is_none() && ns.is_none() && agc.is_none() {
        return;
    }
    let ctx = ndk_context::android_context();
    if ctx.vm().is_null() {
        return;
    }
    // SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
    let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
        Ok(v) => v,
        Err(_) => return,
    };
    let mut env = match jvm.attach_current_thread() {
        Ok(e) => e,
        Err(_) => return,
    };
    for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
        if let Some(g) = effect {
            let _ = env.call_method(
                g.as_obj(),
                "setEnabled",
                "(Z)I",
                &[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
            );
            let _ = env.exception_clear();
            let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
            let _ = env.exception_clear();
            drop(g);
            info!(target: "chanora_audio", effect = label, "android: hardware effect released");
        }
    }
}

// --- Process-global BackendEvent sender for JNI callbacks --------
//
// Kotlin-side listeners (audio focus, Bluetooth SCO, device route
// changes) need to publish events into the Rust engine's event
// channel. Since the engine's `BackendEventTx` is created at voice
// start, we store it here as a process-global so the JNI callbacks
// can reach it without holding a direct Rust reference.
//
// Cleared on voice stop; the Kotlin listeners are idempotent when
// no sender is registered (they log and continue).

static GLOBAL_BACKEND_EVENT_TX: std::sync::OnceLock<
    std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<BackendEvent>>>,
> = std::sync::OnceLock::new();

fn global_event_tx_slot(
) -> &'static std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<BackendEvent>>> {
    GLOBAL_BACKEND_EVENT_TX.get_or_init(|| std::sync::Mutex::new(None))
}

pub(crate) fn register_global_event_sender(tx: BackendEventTx) {
    if let Ok(mut g) = global_event_tx_slot().lock() {
        *g = Some(tx);
    }
}

pub(crate) fn clear_global_event_sender() {
    if let Ok(mut g) = global_event_tx_slot().lock() {
        *g = None;
    }
}

fn try_send_backend_event(event: BackendEvent) {
    if let Ok(g) = global_event_tx_slot().lock() {
        if let Some(tx) = g.as_ref() {
            if tx.send(event).is_err() {
                warn!(
                    target: "chanora_audio",
                    "android: global BackendEvent channel closed; event dropped"
                );
            }
        }
    }
}

// --- SDD-115 foreground-service JNI helpers ----------------------
//
// The Kotlin class `AndroidVoiceForegroundService` (Wave 2B-2)
// exposes `@JvmStatic fun start(Context)` / `fun stop(Context)`.
// These Rust helpers reach across JNI to invoke those entry points.
// IMPORTANT: never call these from an audio callback thread; route
// invocation through a regular tokio task.

const ANDROID_VOICE_FG_SERVICE_FQCN: &str =
    "app/chanora/chanora_flutter/AndroidVoiceForegroundService";

/// SDD-115 forward step 2: start the voice foreground service.
/// Returns true on a clean JNI invocation (no exception). Callers
/// should treat this as best-effort; `onStartCommand` runs
/// asynchronously on the Android side.
pub fn chanora_android_start_voice_service() -> bool {
    call_voice_service_static("start")
}

/// SDD-115 reverse step 4: stop the voice foreground service.
pub fn chanora_android_stop_voice_service() -> bool {
    call_voice_service_static("stop")
}

fn call_voice_service_static(method: &str) -> bool {
    use jni::objects::{JObject, JValue};
    let ctx = ndk_context::android_context();
    if ctx.vm().is_null() || ctx.context().is_null() {
        warn!(
            target: "chanora_audio",
            method,
            "android: ndk_context not initialised; voice service call skipped"
        );
        return false;
    }
    // SAFETY: vm/context populated by chanora_bridge::android_init at
    // JNI_OnLoad + initChanoraContext; both pointers are valid for
    // the process lifetime.
    let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
        Ok(v) => v,
        Err(e) => {
            warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
            return false;
        }
    };
    let mut env = match jvm.attach_current_thread() {
        Ok(e) => e,
        Err(e) => {
            warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed");
            return false;
        }
    };
    // SAFETY: ndk_context::context() is the application Context
    // jobject; valid global ref for process lifetime.
    let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
    let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
        Some(c) => c,
        None => return false,
    };
    match env.call_static_method(
        &class,
        method,
        "(Landroid/content/Context;)V",
        &[JValue::Object(&context_obj)],
    ) {
        Ok(_) => {
            info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
            true
        }
        Err(e) => {
            let _ = env.exception_clear();
            warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
            false
        }
    }
}

fn load_app_class<'local>(
    env: &mut jni::JNIEnv<'local>,
    context_obj: &jni::objects::JObject<'local>,
    slash_name: &str,
) -> Option<jni::objects::JClass<'local>> {
    match env.find_class(slash_name) {
        Ok(c) => return Some(c),
        Err(e) => {
            let _ = env.exception_clear();
            debug!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader");
        }
    }

    let loader = match env
        .call_method(
            context_obj,
            "getClassLoader",
            "()Ljava/lang/ClassLoader;",
            &[],
        )
        .and_then(|v| v.l())
    {
        Ok(loader) => loader,
        Err(e) => {
            let _ = env.exception_clear();
            warn!(target: "chanora_audio", error = %e, "android: Context.getClassLoader failed");
            return None;
        }
    };
    let dotted_name = slash_name.replace('/', ".");
    let class_name = match env.new_string(&dotted_name) {
        Ok(s) => s,
        Err(e) => {
            let _ = env.exception_clear();
            warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: class-name string allocation failed");
            return None;
        }
    };
    let class_name_obj = jni::objects::JObject::from(class_name);
    match env
        .call_method(
            &loader,
            "loadClass",
            "(Ljava/lang/String;)Ljava/lang/Class;",
            &[jni::objects::JValue::Object(&class_name_obj)],
        )
        .and_then(|v| v.l())
    {
        Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
        Err(e) => {
            let _ = env.exception_clear();
            warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
            None
        }
    }
}

// --- SDD-109 / SDD-110 JNI callbacks: focus + SCO events ----------
//
// Kotlin-side OnAudioFocusChangeListener and BroadcastReceiver for
// ACTION_SCO_AUDIO_STATE_UPDATED call these Rust entry points via
// JNI. Each function marshals the platform event into a BackendEvent
// and posts it through the global event sender registered by the
// engine at voice start.
//
// SDD-115 callback safety: every entry point is wrapped in
// catch_unwind so a panic in the Rust engine can never unwind
// into the JVM.

/// SDD-109: audio focus change published by Kotlin's
/// `AndroidAudioFocusController`. `state` is the `focusChange`
/// value from `OnAudioFocusChangeListener`.
///
/// Symbol naming: JNI function declared in
/// `app.chanora.chanora_flutter.AndroidAudioFocusController`.
#[no_mangle]
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
    'local,
>(
    _env: jni::JNIEnv<'local>,
    _class: jni::objects::JClass<'local>,
    state: jni::sys::jint,
) {
    let _ = catch_unwind(AssertUnwindSafe(|| {
        // AUDIOFOCUS_LOSS = -1, LOSS_TRANSIENT = -2, LOSS_TRANSIENT_CAN_DUCK = -3,
        // GAIN = 1 (AudioManager.AUDIOFOCUS_REQUEST_GRANTED is also 1, but we only
        // call this from the listener callback so the values are well-known).
        let event = match state {
            -1 => BackendEvent::FocusLost,
            -2 => BackendEvent::FocusTransient,
            -3 => BackendEvent::FocusTransientCanDuck,
            1 | 2 | 3 | 4 => BackendEvent::FocusGain,
            _ => {
                warn!(
                    target: "chanora_audio",
                    state,
                    "android: unknown audio focus change value; treating as FocusLost"
                );
                BackendEvent::FocusLost
            }
        };
        try_send_backend_event(event);
    }));
}

/// SDD-110: Bluetooth SCO state change published by Kotlin's
/// `AndroidBluetoothScoController`. `state` is the `STATE`
/// value from `ACTION_SCO_AUDIO_STATE_UPDATED`:
///   - `ACTION_SCO_AUDIO_STATE_UPDATED` is always fired with `EXTRA_SCO_AUDIO_STATE`
///   - `SCO_STATE_CONNECTING = 0`, `SCO_STATE_CONNECTED = 1`, `SCO_STATE_DISCONNECTED = 2`
///
/// Symbol naming: JNI function declared in
/// `app.chanora.chanora_flutter.AndroidBluetoothScoController`.
#[no_mangle]
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
    'local,
>(
    _env: jni::JNIEnv<'local>,
    _class: jni::objects::JClass<'local>,
    state: jni::sys::jint,
) {
    let _ = catch_unwind(AssertUnwindSafe(|| {
        try_send_backend_event(BackendEvent::BluetoothScoStateChanged(state));
    }));
}

/// SDD-115 integration: start the Android audio focus listener.
/// Called by the engine after the voice unit is started. Uses JNI
/// to invoke `AndroidAudioFocusController.start(Context)`.
pub fn chanora_android_request_audio_focus() -> bool {
    call_static_void_context(
        "app/chanora/chanora_flutter/AndroidAudioFocusController",
        "start",
    )
}

/// SDD-115 integration: stop the Android audio focus listener.
/// Called by the engine on voice stop.
pub fn chanora_android_abandon_audio_focus() -> bool {
    call_static_void_context(
        "app/chanora/chanora_flutter/AndroidAudioFocusController",
        "stop",
    )
}

/// SDD-115 integration: start Bluetooth SCO.
/// Called by the engine after the voice unit is started.
pub fn chanora_android_start_bluetooth_sco() -> bool {
    call_static_void_context(
        "app/chanora/chanora_flutter/AndroidBluetoothScoController",
        "start",
    )
}

/// SDD-115 integration: stop Bluetooth SCO.
/// Called by the engine on voice stop.
pub fn chanora_android_stop_bluetooth_sco() -> bool {
    call_static_void_context(
        "app/chanora/chanora_flutter/AndroidBluetoothScoController",
        "stop",
    )
}

fn call_static_void_context(fqcn: &str, method: &str) -> bool {
    use jni::objects::{JObject, JValue};
    let ctx = ndk_context::android_context();
    if ctx.vm().is_null() || ctx.context().is_null() {
        warn!(
            target: "chanora_audio",
            class = fqcn,
            method,
            "android: ndk_context not initialised; call skipped"
        );
        return false;
    }
    let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
        Ok(v) => v,
        Err(e) => {
            warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
            return false;
        }
    };
    let mut env = match jvm.attach_current_thread() {
        Ok(e) => e,
        Err(e) => {
            warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed");
            return false;
        }
    };
    let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
    let class = match load_app_class(&mut env, &context_obj, fqcn) {
        Some(c) => c,
        None => return false,
    };
    match env.call_static_method(
        &class,
        method,
        "(Landroid/content/Context;)V",
        &[JValue::Object(&context_obj)],
    ) {
        Ok(_) => {
            info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
            true
        }
        Err(e) => {
            let _ = env.exception_clear();
            warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
            false
        }
    }
}
