//! ExoPlayer backend for Android via JNI.
//!
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
//! through JNI calls to Kotlin code.

use crate::utils::lock::MutexSafe;
use log::debug;
use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Mutex as TokioMutex;

use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
use jni::sys::{jboolean, jdouble, jfloat, jint};
use jni::{JNIEnv, JavaVM};

use super::autoplay::AutoplayDecision;
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType};
use super::state::PlayerState;
use super::stream_end;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::{audio_settings_jni_payload, AudioSettings};
use crate::utils::conversions::seconds_to_ticks;

/// Global reference to the JavaVM for JNI callbacks
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();

/// Global reference to the Android Context for accessing app classes
static APP_CONTEXT: OnceLock<GlobalRef> = OnceLock::new();

/// Global event emitter for callbacks from Kotlin
static EVENT_EMITTER: OnceLock<SharedEventEmitter> = OnceLock::new();

/// Shared state updated by JNI callbacks
static SHARED_STATE: OnceLock<Arc<Mutex<ExoPlayerState>>> = OnceLock::new();

/// Global handler for media session commands from Android lockscreen/notification
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> =
    OnceLock::new();

/// Global handler for remote volume changes from Android volume buttons
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> =
    OnceLock::new();

/// Global player controller for autoplay decisions
static PLAYER_CONTROLLER: OnceLock<Arc<TokioMutex<super::PlayerController>>> = OnceLock::new();

/// Global storage for detected device codecs
static DETECTED_CODECS: OnceLock<DetectedCodecs> = OnceLock::new();

/// Global playback reporter for dual sync (local DB + server)
static PLAYBACK_REPORTER: OnceLock<Arc<TokioMutex<Option<PlaybackReporter>>>> = OnceLock::new();

/// Global position throttler for progress reporting
static POSITION_THROTTLER: OnceLock<Arc<EventThrottler>> = OnceLock::new();

/// Detected codec capabilities from Android MediaCodecList
#[derive(Debug, Clone)]
struct DetectedCodecs {
    video_codecs: Vec<String>,
    audio_codecs: Vec<String>,
    /// Channels the *current audio output route* accepts, as reported by
    /// media3's `AudioCapabilities`. Distinct from the codec lists: a device
    /// decodes 5.1 happily and still has only two channels to play it out of.
    /// `None` when the platform had no answer.
    max_audio_channels: Option<u32>,
}

impl DetectedCodecs {
    /// Create from comma-separated codec strings (from JNI)
    fn from_jni_strings(
        video_codecs: &str,
        audio_codecs: &str,
        max_audio_channels: Option<u32>,
    ) -> Self {
        Self {
            video_codecs: video_codecs
                .split(',')
                .filter(|s| !s.is_empty())
                .map(|s| s.to_string())
                .collect(),
            audio_codecs: audio_codecs
                .split(',')
                .filter(|s| !s.is_empty())
                .map(|s| s.to_string())
                .collect(),
            max_audio_channels,
        }
    }

    /// Get video codecs as comma-separated string (for DeviceProfile)
    fn video_codecs_string(&self) -> String {
        self.video_codecs.join(",")
    }

    /// Get audio codecs as comma-separated string (for DeviceProfile)
    fn audio_codecs_string(&self) -> String {
        self.audio_codecs.join(",")
    }
}

/// Public function to get detected codecs (for use in repository layer).
///
/// Returns `(video, audio, max_audio_channels)` — the third element is how many
/// channels the current audio output can actually voice, which bounds what the
/// server may direct-play.
pub fn get_detected_codecs() -> Option<(String, String, Option<u32>)> {
    DETECTED_CODECS.get().map(|codecs| {
        (
            codecs.video_codecs_string(),
            codecs.audio_codecs_string(),
            codecs.max_audio_channels,
        )
    })
}

/// Trait for handling media commands from Android MediaSession.
///
/// This is implemented by the application to receive commands from
/// lockscreen controls, notification buttons, and Bluetooth devices.
pub trait MediaCommandHandler: Send + Sync {
    /// Handle a media command.
    ///
    /// Commands: "play", "pause", "next", "previous", "stop", "seek:123.45"
    fn on_command(&self, command: &str);
}

/// Trait for handling remote volume changes when in remote playback mode.
///
/// This is implemented by the application to forward volume button presses
/// to the remote Jellyfin session.
pub trait RemoteVolumeHandler: Send + Sync {
    /// Handle a remote volume change command.
    ///
    /// Commands: "SetVolume", "VolumeUp", "VolumeDown"
    /// @param command The volume command
    /// @param volume The volume level (0-100)
    fn on_remote_volume_change(&self, command: &str, volume: i32);
}

/// Set the global media command handler.
///
/// This should be called during app initialization after creating the PlayerController.
pub fn set_media_command_handler(handler: Arc<dyn MediaCommandHandler + Send + Sync>) {
    let _ = MEDIA_COMMAND_HANDLER.set(handler);
}

/// Set the global player controller.
///
/// This should be called during app initialization after creating the PlayerController.
pub fn set_player_controller(controller: Arc<TokioMutex<super::PlayerController>>) {
    let _ = PLAYER_CONTROLLER.set(controller);
}

/// Set the global remote volume handler.
///
/// This should be called during app initialization to handle remote volume changes.
pub fn set_remote_volume_handler(handler: Arc<dyn RemoteVolumeHandler + Send + Sync>) {
    let _ = REMOTE_VOLUME_HANDLER.set(handler);
}

/// State tracking for ExoPlayer
#[derive(Default)]
struct ExoPlayerState {
    state: PlayerState,
    position: f64,
    duration: Option<f64>,
    volume: f32,
    is_loaded: bool,
    current_media: Option<MediaItem>,
    /// Last applied audio settings. Unlike the fields above (which JNI callbacks
    /// push *in*), this is commanded *out* — audio settings are never reported
    /// by the player, so this is the authoritative copy for `audio_settings()`.
    audio_settings: AudioSettings,
}

impl ExoPlayerState {
    fn new() -> Self {
        Self {
            state: PlayerState::Idle,
            position: 0.0,
            duration: None,
            volume: 1.0,
            is_loaded: false,
            current_media: None,
            audio_settings: AudioSettings::default(),
        }
    }
}

/// ExoPlayer-based player backend for Android.
pub struct ExoPlayerBackend {
    /// Reference to the JellyTauPlayer Kotlin object
    player_ref: GlobalRef,
    /// Shared state
    shared_state: Arc<Mutex<ExoPlayerState>>,
}

impl ExoPlayerBackend {
    /// Initialize the ExoPlayer backend.
    ///
    /// This must be called from the Android main thread with the Activity context.
    ///
    /// # Arguments
    /// * `env` - JNI environment
    /// * `context` - Android Context (Activity or Application)
    /// * `event_emitter` - Event emitter for frontend notifications
    /// * `playback_reporter` - Playback reporter for dual sync
    /// * `position_throttler` - Event throttler for position updates
    pub fn new(
        env: &mut JNIEnv,
        context: &JObject,
        event_emitter: SharedEventEmitter,
        playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
        position_throttler: Arc<EventThrottler>,
    ) -> Result<Self, PlayerError> {
        // Store the JavaVM for callbacks
        let vm = env
            .get_java_vm()
            .map_err(|e| PlayerError::playback_failed(format!("Failed to get JavaVM: {}", e)))?;
        let _ = JAVA_VM.set(vm);

        // Store the Context as a global reference for later use
        let context_global = env.new_global_ref(context).map_err(|e| {
            PlayerError::playback_failed(format!("Failed to create global context ref: {}", e))
        })?;
        let _ = APP_CONTEXT.set(context_global);

        // Store the event emitter
        let _ = EVENT_EMITTER.set(event_emitter);

        // Store the playback reporter and throttler
        let _ = PLAYBACK_REPORTER.set(playback_reporter);
        let _ = POSITION_THROTTLER.set(position_throttler);

        // Create shared state
        let shared_state = Arc::new(Mutex::new(ExoPlayerState::new()));
        let _ = SHARED_STATE.set(shared_state.clone());

        // Get the ClassLoader from the Context to load app classes
        // This is necessary because find_class() uses the system class loader
        // which doesn't have access to app classes when called from native threads
        let class_loader = env
            .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
            .map_err(|e| PlayerError::playback_failed(format!("Failed to get ClassLoader: {}", e)))?
            .l()
            .map_err(|e| {
                PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e))
            })?;

        // Load the JellyTauPlayer class using the app's class loader
        let player_class_name = env
            .new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
            .map_err(|e| {
                PlayerError::playback_failed(format!("Failed to create class name string: {}", e))
            })?;

        let player_class_obj = env
            .call_method(
                &class_loader,
                "loadClass",
                "(Ljava/lang/String;)Ljava/lang/Class;",
                &[JValue::Object(&player_class_name.into())],
            )
            .map_err(|e| {
                PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e))
            })?
            .l()
            .map_err(|e| {
                PlayerError::playback_failed(format!("Failed to convert to Class: {}", e))
            })?;

        // Cast to JClass for static method calls
        let player_class = JClass::from(player_class_obj);

        // Call JellyTauPlayer.initialize(context)
        env.call_static_method(
            &player_class,
            "initialize",
            "(Landroid/content/Context;)V",
            &[JValue::Object(context)],
        )
        .map_err(|e| {
            PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e))
        })?;

        // Get the singleton instance
        let player_obj = env
            .call_static_method(
                &player_class,
                "getInstance",
                "()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
                &[],
            )
            .map_err(|e| {
                PlayerError::playback_failed(format!(
                    "Failed to get JellyTauPlayer instance: {}",
                    e
                ))
            })?
            .l()
            .map_err(|e| {
                PlayerError::playback_failed(format!("Failed to convert to object: {}", e))
            })?;

        // Create a global reference to keep the player alive
        let player_ref = env.new_global_ref(player_obj).map_err(|e| {
            PlayerError::playback_failed(format!("Failed to create global ref: {}", e))
        })?;

        Ok(Self {
            player_ref,
            shared_state,
        })
    }

    /// Call a void method on the player with no arguments
    fn call_player_method(&self, method: &str) -> Result<(), PlayerError> {
        let vm = JAVA_VM
            .get()
            .ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;

        let mut env = vm
            .attach_current_thread()
            .map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;

        env.call_method(&self.player_ref, method, "()V", &[])
            .map_err(|e| {
                PlayerError::playback_failed(format!("Failed to call {}: {}", method, e))
            })?;

        Ok(())
    }
}

impl Drop for ExoPlayerBackend {
    fn drop(&mut self) {
        let _ = self.call_player_method("release");
    }
}

impl PlayerBackend for ExoPlayerBackend {
    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
        let url = media.playback_url();
        let media_id = media.id.clone();
        let title = media.title.clone();
        let artist = media.artist.clone();
        let album = media.album.clone();
        let artwork_url = media.artwork_url.clone();
        // Convert duration from seconds to milliseconds
        let duration_ms = media.duration.map(|d| (d * 1000.0) as i64).unwrap_or(0);
        // A stream the player could only "retry" by restarting it must not be
        // retried by the player at all — recovery is ours. (DR-203)
        let player_retry_restarts_stream = stream_end::player_retry_restarts_stream(media);

        // Update local state
        {
            let mut state = self.shared_state.lock_safe();
            state.current_media = Some(media.clone());
            state.state = PlayerState::Loading {
                media: media.clone(),
            };
            state.position = 0.0;
            state.is_loaded = false;
        }

        let vm = JAVA_VM
            .get()
            .ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;

        let mut env = vm
            .attach_current_thread()
            .map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;

        // Create JNI strings for required parameters
        let url_jstring = env.new_string(&url).map_err(|e| {
            PlayerError::playback_failed(format!("Failed to create URL string: {}", e))
        })?;

        let media_id_jstring = env.new_string(&media_id).map_err(|e| {
            PlayerError::playback_failed(format!("Failed to create media ID string: {}", e))
        })?;

        let title_jstring = env.new_string(&title).map_err(|e| {
            PlayerError::playback_failed(format!("Failed to create title string: {}", e))
        })?;

        // Create JNI strings for optional parameters (null if None)
        let artist_jstring = match &artist {
            Some(a) => Some(env.new_string(a).map_err(|e| {
                PlayerError::playback_failed(format!("Failed to create artist string: {}", e))
            })?),
            None => None,
        };

        let album_jstring = match &album {
            Some(a) => Some(env.new_string(a).map_err(|e| {
                PlayerError::playback_failed(format!("Failed to create album string: {}", e))
            })?),
            None => None,
        };

        let artwork_jstring = match &artwork_url {
            Some(a) => Some(env.new_string(a).map_err(|e| {
                PlayerError::playback_failed(format!("Failed to create artwork string: {}", e))
            })?),
            None => None,
        };

        // Create null object binding for use in match arms
        let null_obj = JObject::null();

        // Get JValue for nullable strings - convert Option<JString> to JValue::Object
        let artist_value = match &artist_jstring {
            Some(s) => JValue::Object(s.as_ref()),
            None => JValue::Object(&null_obj),
        };
        let album_value = match &album_jstring {
            Some(s) => JValue::Object(s.as_ref()),
            None => JValue::Object(&null_obj),
        };
        let artwork_value = match &artwork_jstring {
            Some(s) => JValue::Object(s.as_ref()),
            None => JValue::Object(&null_obj),
        };

        // Determine media type string for JNI.
        //
        // This is not cosmetic: the string decides *which audio-focus mechanism*
        // runs on the Kotlin side. `JellyTauPlayer.load()` re-applies
        // `setAudioAttributes(attrs, handleAudioFocus = mediaType == AUDIO)`, so
        // "audio" leaves focus to ExoPlayer (request on play, duck on transient
        // loss, pause on a call) while "video" switches it to the manual
        // `AudioFocusRequest` path, which needs delayed-focus handling. Either
        // way the resulting pause comes back through `nativeOnStateChanged`, so
        // the Rust controller — not the focus listener — stays authoritative.
        //
        // TRACES: UR-004, UR-006 | IR-008
        let media_type_str = match media.media_type {
            MediaType::Video => "video",
            MediaType::Audio => "audio",
        };
        let media_type_jstring = env.new_string(media_type_str).map_err(|e| {
            PlayerError::playback_failed(format!("Failed to create media type string: {}", e))
        })?;

        // Serialize subtitles to JSON for passing to Kotlin
        let subtitles_json =
            serde_json::to_string(&media.subtitles).unwrap_or_else(|_| "[]".to_string());
        let subtitles_jstring = env.new_string(&subtitles_json).map_err(|e| {
            PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e))
        })?;

        // Call loadWithMetadata for MediaSession support (lockscreen controls)
        debug!("[Android] Loading media: url={}, id={}, title={}, artist={:?}, album={:?}, duration_ms={}, type={}, subtitles={}",
                 &url, &media_id, &title, &artist, &album, duration_ms, media_type_str, &subtitles_json);

        let result = env.call_method(
            &self.player_ref,
            "loadWithMetadata",
            "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Z)V",
            &[
                JValue::Object(&url_jstring),
                JValue::Object(&media_id_jstring),
                JValue::Object(&title_jstring),
                artist_value,
                album_value,
                artwork_value,
                JValue::Long(duration_ms),
                JValue::Object(&media_type_jstring),
                JValue::Object(&subtitles_jstring),
                JValue::Bool(player_retry_restarts_stream as u8),
            ],
        );

        if let Err(e) = result {
            // Check for pending exceptions
            if let Ok(true) = env.exception_check() {
                env.exception_describe().ok();
                env.exception_clear().ok();
            }
            return Err(PlayerError::playback_failed(format!(
                "Failed to call loadWithMetadata: {}",
                e
            )));
        }

        debug!("[Android] Successfully called loadWithMetadata");

        Ok(())
    }

    fn play(&mut self) -> Result<(), PlayerError> {
        self.call_player_method("play")
    }

    fn pause(&mut self) -> Result<(), PlayerError> {
        self.call_player_method("pause")
    }

    fn stop(&mut self) -> Result<(), PlayerError> {
        {
            let mut state = self.shared_state.lock_safe();
            state.state = PlayerState::Idle;
            state.is_loaded = false;
            state.current_media = None;
            state.position = 0.0;
            state.duration = None;
        }
        self.call_player_method("stop")
    }

    fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
        let vm = JAVA_VM
            .get()
            .ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;

        let mut env = vm
            .attach_current_thread()
            .map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;

        env.call_method(
            &self.player_ref,
            "seek",
            "(D)V",
            &[JValue::Double(position)],
        )
        .map_err(|e| PlayerError::playback_failed(format!("Failed to call seek: {}", e)))?;

        Ok(())
    }

    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
        let clamped = volume.clamp(0.0, 1.0);

        let vm = JAVA_VM
            .get()
            .ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;

        let mut env = vm
            .attach_current_thread()
            .map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;

        env.call_method(
            &self.player_ref,
            "setVolume",
            "(FZ)V",
            &[JValue::Float(clamped), JValue::Bool(0)],
        )
        .map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;

        self.shared_state.lock_safe().volume = clamped;
        Ok(())
    }

    fn position(&self) -> f64 {
        self.shared_state.lock_safe().position
    }

    fn duration(&self) -> Option<f64> {
        self.shared_state.lock_safe().duration
    }

    fn state(&self) -> PlayerState {
        self.shared_state.lock_safe().state.clone()
    }

    fn volume(&self) -> f32 {
        self.shared_state.lock_safe().volume
    }

    /// Apply audio settings to ExoPlayer (equalizer, normalization, gapless).
    ///
    /// Sent as JSON rather than a wide JNI signature so new fields do not change
    /// the method signature — the same approach `load()` uses for subtitles. The
    /// Kotlin side owns the *mechanics* (attaching AudioEffects to the audio
    /// session); the canonical band layout and preset curves stay in Rust.
    ///
    /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
    fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
        let json = audio_settings_jni_payload(settings).map_err(|e| {
            PlayerError::playback_failed(format!("Failed to serialize audio settings: {}", e))
        })?;

        let vm = JAVA_VM
            .get()
            .ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;

        let mut env = vm
            .attach_current_thread()
            .map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;

        let json_jstring = env.new_string(&json).map_err(|e| {
            PlayerError::playback_failed(format!("Failed to create settings string: {}", e))
        })?;

        env.call_method(
            &self.player_ref,
            "setAudioSettings",
            "(Ljava/lang/String;)V",
            &[JValue::Object(&json_jstring)],
        )
        .map_err(|e| {
            PlayerError::playback_failed(format!("Failed to call setAudioSettings: {}", e))
        })?;

        // Store the sanitised form so audio_settings() reflects what was applied,
        // not what was requested.
        self.shared_state.lock_safe().audio_settings = settings
            .clone()
            .with_crossfade_clamped()
            .with_equalizer_normalised();

        Ok(())
    }

    /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
    fn audio_settings(&self) -> AudioSettings {
        self.shared_state.lock_safe().audio_settings.clone()
    }

    fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
        let vm = JAVA_VM
            .get()
            .ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;

        let mut env = vm
            .attach_current_thread()
            .map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;

        env.call_method(
            &self.player_ref,
            "setAudioTrack",
            "(I)V",
            &[JValue::Int(stream_index)],
        )
        .map_err(|e| {
            PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e))
        })?;

        Ok(())
    }

    fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
        let vm = JAVA_VM
            .get()
            .ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;

        let mut env = vm
            .attach_current_thread()
            .map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;

        // Use -1 to disable subtitles if stream_index is None
        let index = stream_index.unwrap_or(-1);

        env.call_method(
            &self.player_ref,
            "setSubtitleTrack",
            "(I)V",
            &[JValue::Int(index)],
        )
        .map_err(|e| {
            PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e))
        })?;

        Ok(())
    }
}

// JNI callback functions called from Kotlin

/// Called when position updates during playback.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
    _env: JNIEnv,
    _class: JClass,
    position: jdouble,
    duration: jdouble,
) {
    // Debug: Log every 10th update to avoid spam
    static mut UPDATE_COUNTER: u32 = 0;
    unsafe {
        UPDATE_COUNTER += 1;
        if UPDATE_COUNTER % 10 == 0 {
            log::debug!("[Android] Position update {} / {}", position, duration);
        }
    }

    // Update state and get the preserved duration to emit
    let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
        let mut state = state.lock_safe();
        state.position = position;
        if duration > 0.0 {
            state.duration = Some(duration);
        }
        // Use preserved duration from state, or fall back to the received value
        state.duration.unwrap_or(duration)
    } else {
        duration
    };

    if let Some(emitter) = EVENT_EMITTER.get() {
        emitter.emit(PlayerStatusEvent::PositionUpdate {
            position,
            duration: duration_to_emit,
        });
    } else {
        log::error!("[Android] WARNING: No event emitter for position update!");
    }

    // Throttled progress reporting to Jellyfin so playback position syncs and can
    // be resumed on another device. ExoPlayer only fires position updates while
    // playing, but guard on the stored state anyway. Mirrors the MPV backend's
    // progress loop; both share the same EventThrottler (every 30s per item).
    report_android_progress(position);
}

/// Report throttled playback progress to Jellyfin from the Android position
/// callback. No-op until the reporter/throttler are wired (post-login) or when
/// not actively playing.
fn report_android_progress(position: f64) {
    let item_id = match SHARED_STATE.get() {
        Some(state) => {
            let state = state.lock_safe();
            if !state.state.is_playing() {
                return;
            }
            match state
                .current_media
                .as_ref()
                .and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
            {
                Some(id) => id,
                None => return,
            }
        }
        None => return,
    };

    let throttler = match POSITION_THROTTLER.get() {
        Some(t) => t,
        None => return,
    };
    if !throttler.should_report(&item_id) {
        return;
    }

    let reporter_arc = match PLAYBACK_REPORTER.get() {
        Some(r) => r.clone(),
        None => return,
    };

    let position_ticks = seconds_to_ticks(position);
    let item_id_for_task = item_id.clone();

    // The reporter is async; the JNI callback is sync. Spawn onto the Tokio
    // runtime when present, otherwise a throwaway runtime on a new thread.
    let spawn_report = move || async move {
        let reporter_guard = reporter_arc.lock().await;
        if let Some(reporter) = reporter_guard.as_ref() {
            let operation = PlaybackOperation::Progress {
                item_id: item_id_for_task.clone(),
                position_ticks,
                is_paused: false,
            };
            match reporter.report(operation, true).await {
                Ok(_) => debug!("[Android] Reported progress for {}", item_id_for_task),
                Err(e) => log::warn!("[Android] Failed to report progress: {}", e),
            }
        }
    };

    if let Ok(handle) = tokio::runtime::Handle::try_current() {
        handle.spawn(spawn_report());
    } else {
        std::thread::spawn(move || {
            let rt = tokio::runtime::Runtime::new().unwrap();
            rt.block_on(spawn_report());
        });
    }

    throttler.mark_reported(&item_id);
}

/// Called when player state changes.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnStateChanged(
    mut env: JNIEnv,
    _class: JClass,
    state: JString,
    media_id: JString,
) {
    let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();

    let media_id_opt: Option<String> = if media_id.is_null() {
        None
    } else {
        env.get_string(&media_id).map(|s| s.into()).ok()
    };

    // Update shared state
    if let Some(shared) = SHARED_STATE.get() {
        let mut shared = shared.lock_safe();
        if let Some(media) = shared.current_media.clone() {
            let duration = shared.duration.unwrap_or(0.0);
            let position = shared.position;
            match state_str.as_str() {
                "playing" => {
                    shared.state = PlayerState::Playing {
                        media,
                        position,
                        duration,
                    };
                    shared.is_loaded = true;
                }
                "paused" => {
                    shared.state = PlayerState::Paused {
                        media,
                        position,
                        duration,
                    };
                }
                "idle" => {
                    shared.state = PlayerState::Idle;
                    shared.is_loaded = false;
                }
                _ => {}
            }
        }
    }

    if let Some(emitter) = EVENT_EMITTER.get() {
        emitter.emit(PlayerStatusEvent::StateChanged {
            state: state_str,
            media_id: media_id_opt,
        });
    }
}

/// Called when media has finished loading.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded(
    _env: JNIEnv,
    _class: JClass,
    duration: jdouble,
) {
    if let Some(state) = SHARED_STATE.get() {
        let mut state = state.lock_safe();
        state.duration = Some(duration);
        state.is_loaded = true;
    }

    if let Some(emitter) = EVENT_EMITTER.get() {
        emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
    }
}

/// Called when playback reaches the end.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPlaybackEnded(
    _env: JNIEnv,
    _class: JClass,
) {
    log::info!("[ExoPlayer] Playback ended - processing autoplay decision");

    // Get player controller and handle autoplay decision
    if let Some(controller) = PLAYER_CONTROLLER.get() {
        let controller = controller.clone();

        // Spawn async task to handle autoplay decision
        // Use tauri::async_runtime::spawn instead of tokio::spawn
        // JNI callbacks happen on arbitrary threads without a Tokio runtime
        tauri::async_runtime::spawn(async move {
            // Compute the autoplay decision and release the lock before matching.
            // Holding the guard across the match would deadlock the AdvanceToNext
            // arm, which re-locks the controller to call next() — leaving playback
            // stopped (paused at position 0) instead of advancing.
            let decision = controller.lock().await.on_playback_ended().await;
            match decision {
                Ok(AutoplayDecision::Stop) => {
                    log::debug!("[Autoplay] Decision: Stop playback");
                    // Emit PlaybackEnded event to frontend
                    if let Some(emitter) = EVENT_EMITTER.get() {
                        emitter.emit(PlayerStatusEvent::PlaybackEnded);
                    }
                }
                Ok(AutoplayDecision::AdvanceToNext) => {
                    log::debug!("[Autoplay] Decision: Advance to next track");
                    // Advance to next track in queue
                    let ctrl = controller.lock().await;

                    // Log queue state before advancing
                    let queue_info = {
                        let queue = ctrl.queue.lock_safe();
                        format!(
                            "current_index={:?}, len={}",
                            queue.current_index(),
                            queue.items().len()
                        )
                    };
                    log::debug!("[Autoplay] Queue state before next(): {}", queue_info);

                    match ctrl.next() {
                        Ok(_) => {
                            log::info!("[Autoplay] Successfully advanced to next track");
                            // Log queue state after advancing
                            let queue_info = {
                                let queue = ctrl.queue.lock_safe();
                                format!(
                                    "current_index={:?}, len={}",
                                    queue.current_index(),
                                    queue.items().len()
                                )
                            };
                            log::debug!("[Autoplay] Queue state after next(): {}", queue_info);

                            // Emit queue changed event so frontend updates UI with new current track
                            ctrl.emit_queue_changed();
                        }
                        Err(e) => {
                            log::error!("[Autoplay] Failed to advance to next track: {}", e);
                            // Emit PlaybackEnded event on error
                            if let Some(emitter) = EVENT_EMITTER.get() {
                                emitter.emit(PlayerStatusEvent::PlaybackEnded);
                            }
                        }
                    }
                }
                Ok(AutoplayDecision::ShowNextEpisodePopup {
                    current_episode,
                    next_episode,
                    countdown_seconds,
                    auto_advance,
                }) => {
                    log::info!(
                        "[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
                        countdown_seconds,
                        auto_advance
                    );

                    // Emit popup event to frontend
                    if let Some(emitter) = EVENT_EMITTER.get() {
                        emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
                            current_episode: current_episode.clone(),
                            next_episode: next_episode.clone(),
                            countdown_seconds,
                            auto_advance,
                        });
                    }

                    if auto_advance {
                        // Shared with the frontend-invoked command path
                        // (player_on_playback_ended) so the two dispatchers cannot
                        // disagree about how a background audio-only episode
                        // advances — they did, and the command's copy was missing
                        // the case entirely. That copy is the one that actually
                        // decides here: the end reason set at load makes this
                        // callback's own decision Stop, and the frontend echoes the
                        // resulting PlaybackEnded back into the command.
                        controller
                            .lock()
                            .await
                            .auto_advance_to_next_episode(next_episode, countdown_seconds)
                            .await;
                    }
                }
                Ok(AutoplayDecision::ResumeStream { position }) => {
                    // ExoPlayer reported ENDED because the progressive transcode's
                    // connection dropped, not because the episode finished. This
                    // is the arm that matters while backgrounded: it needs no
                    // frontend echo, so the stream re-opens even with the webview
                    // suspended — and playback never parks in STATE_ENDED, where
                    // the next lockscreen/Bluetooth play restarts the item at 0:00.
                    log::info!(
                        "[Autoplay] Decision: Resume truncated stream at {:.1}s",
                        position
                    );
                    let ctrl = controller.lock().await;
                    if let Err(e) = ctrl.resume_stream_at(position).await {
                        log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
                        if let Some(emitter) = EVENT_EMITTER.get() {
                            emitter.emit(PlayerStatusEvent::PlaybackEnded);
                        }
                    }
                }
                Err(e) => {
                    log::error!("[Autoplay] Decision failed: {}", e);
                    // Emit PlaybackEnded event on error
                    if let Some(emitter) = EVENT_EMITTER.get() {
                        emitter.emit(PlayerStatusEvent::PlaybackEnded);
                    }
                }
            }
        });
    } else {
        log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
        // Fallback: just emit PlaybackEnded event
        if let Some(emitter) = EVENT_EMITTER.get() {
            emitter.emit(PlayerStatusEvent::PlaybackEnded);
        }
    }
}

/// Called when buffering state changes.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering(
    _env: JNIEnv,
    _class: JClass,
    percent: jint,
) {
    if let Some(emitter) = EVENT_EMITTER.get() {
        emitter.emit(PlayerStatusEvent::Buffering {
            percent: percent as u8,
        });
    }
}

/// Called when a playback error occurs.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError(
    mut env: JNIEnv,
    _class: JClass,
    message: JString,
    recoverable: jboolean,
) {
    let message_str: String = env
        .get_string(&message)
        .map(|s| s.into())
        .unwrap_or_else(|_| "Unknown error".to_string());
    let recoverable = recoverable != 0;

    // A background audio-only handoff is an mp3 the device was already decoding,
    // so a recoverable failure part-way through is the network. Surfacing it as a
    // player error stops playback for good (the frontend's handler calls
    // player_stop); re-opening the stream where it died is the "buffer and
    // resume" this actually is. Everything else keeps reporting the error.
    if recoverable {
        if let Some(controller) = PLAYER_CONTROLLER.get() {
            let controller = controller.clone();
            let message_str = message_str.clone();
            tauri::async_runtime::spawn(async move {
                let resume = controller.lock().await.recoverable_error_resume();
                let Some((position, delay_secs)) = resume else {
                    // Declined here, so report it as NOT recoverable: the frontend
                    // would otherwise echo it into player_recover_stream and ask
                    // the same question a second time.
                    if let Some(emitter) = EVENT_EMITTER.get() {
                        emitter.emit(PlayerStatusEvent::Error {
                            message: message_str,
                            recoverable: false,
                        });
                    }
                    return;
                };

                log::warn!(
                    "[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
                    message_str,
                    position,
                    delay_secs
                );
                // Give a brief outage time to clear before asking the server for
                // the stream again; retrying instantly just burns the budget.
                tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;

                let ctrl = controller.lock().await;
                if let Err(e) = ctrl.resume_stream_at(position).await {
                    log::error!("[ExoPlayer] Failed to resume after error: {}", e);
                    if let Some(emitter) = EVENT_EMITTER.get() {
                        emitter.emit(PlayerStatusEvent::Error {
                            message: message_str,
                            recoverable: false,
                        });
                    }
                }
            });
            return;
        }
    }

    if let Some(emitter) = EVENT_EMITTER.get() {
        emitter.emit(PlayerStatusEvent::Error {
            message: message_str,
            recoverable,
        });
    }
}

/// Called when volume changes.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnVolumeChanged(
    _env: JNIEnv,
    _class: JClass,
    volume: jfloat,
    muted: jboolean,
) {
    if let Some(state) = SHARED_STATE.get() {
        state.lock_safe().volume = volume;
    }

    if let Some(emitter) = EVENT_EMITTER.get() {
        emitter.emit(PlayerStatusEvent::VolumeChanged {
            volume,
            muted: muted != 0,
        });
    }
}

// JNI callback for MediaSession commands from JellyTauPlaybackService

/// Called when a media command is received from the Android MediaSession.
///
/// Commands from lockscreen controls, notification buttons, and Bluetooth
/// devices are routed through here to the Rust PlayerController.
///
/// This is the inbound half of UR-006: `MediaSessionCompat` is flagged
/// `FLAG_HANDLES_MEDIA_BUTTONS`, so an AVRCP play/pause/skip from a headset
/// arrives at the service's transport callback and lands here as a command
/// string. The player stays authoritative — the session is a consumer that
/// *requests*, and the resulting state comes back out through
/// [`update_lockscreen_metadata`].
///
/// TRACES: UR-006 | IR-006
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand(
    mut env: JNIEnv,
    _class: JClass,
    command: JString,
) {
    let command_str: String = env
        .get_string(&command)
        .map(|s| s.into())
        .unwrap_or_default();

    if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
        handler.on_command(&command_str);
    }
}

/// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode.
///
/// This is called when the user presses volume up/down buttons or adjusts volume
/// while in remote playback mode (casting to a Jellyfin session).
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange(
    mut env: JNIEnv,
    _class: JClass,
    command: JString,
    volume: jint,
) {
    let command_str: String = env
        .get_string(&command)
        .map(|s| s.into())
        .unwrap_or_default();

    if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
        handler.on_remote_volume_change(&command_str, volume as i32);
    }
}

/// JNI callback from Kotlin when codec detection completes.
///
/// Called once during JellyTauPlayer initialization to store the device's
/// hardware codec capabilities for use in DeviceProfile generation.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected(
    mut env: JNIEnv,
    _class: JClass,
    video_codecs: JString,
    audio_codecs: JString,
    max_audio_channels: jint,
) {
    let video_str: String = env
        .get_string(&video_codecs)
        .map(|s| s.into())
        .unwrap_or_default();

    let audio_str: String = env
        .get_string(&audio_codecs)
        .map(|s| s.into())
        .unwrap_or_default();

    // Kotlin sends 0 when AudioCapabilities had no answer for the current route.
    let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);

    let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);

    log::info!(
        "[CodecDetection] Detected {} video codecs: {}",
        codecs.video_codecs.len(),
        codecs.video_codecs_string()
    );
    log::info!(
        "[CodecDetection] Detected {} audio codecs: {}",
        codecs.audio_codecs.len(),
        codecs.audio_codecs_string()
    );
    log::info!(
        "[CodecDetection] Audio route max channels: {:?}",
        codecs.max_audio_channels
    );

    // Store in global state
    if DETECTED_CODECS.set(codecs).is_err() {
        log::error!("[CodecDetection] Failed to store codecs - already initialized");
    }
}

/// Start the JellyTauPlaybackService if not already running.
///
/// This service is required for remote volume control to work.
#[cfg(target_os = "android")]
fn start_playback_service() -> Result<(), String> {
    log::info!("[Android] Starting JellyTauPlaybackService");

    let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
    let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;

    // Get the stored Context
    let context = APP_CONTEXT.get().ok_or("Context not initialized")?;

    // Get the ClassLoader from the Context to load app classes
    let class_loader = env
        .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
        .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;

    // Load the JellyTauPlayer class using the app's class loader
    let player_class_name = env
        .new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
        .map_err(|e| format!("Failed to create class name string: {}", e))?;

    let player_class_obj = env
        .call_method(
            &class_loader,
            "loadClass",
            "(Ljava/lang/String;)Ljava/lang/Class;",
            &[JValue::Object(&player_class_name.into())],
        )
        .map_err(|e| format!("Failed to load JellyTauPlayer class: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to Class: {}", e))?;

    // Cast to JClass for static method calls
    let player_class = JClass::from(player_class_obj);

    // Get the singleton instance
    let player_obj = env
        .call_static_method(
            &player_class,
            "getInstance",
            "()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
            &[],
        )
        .map_err(|e| format!("Failed to get JellyTauPlayer instance: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to object: {}", e))?;

    if player_obj.is_null() {
        return Err("JellyTauPlayer not initialized".to_string());
    }

    // Call startPlaybackService() on the player instance
    env.call_method(&player_obj, "startPlaybackService", "()V", &[])
        .map_err(|e| format!("Failed to start playback service: {}", e))?;

    log::info!("[Android] JellyTauPlaybackService start requested");
    Ok(())
}

/// Enable remote volume control on Android.
///
/// This switches the MediaSession to remote playback mode, intercepting
/// volume button presses and routing them to the RemoteVolumeHandler.
///
/// @param initial_volume Initial volume level (0-100)
#[cfg(target_os = "android")]
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
    log::info!(
        "[Android] Enabling remote volume control (volume={})",
        initial_volume
    );

    // Ensure the playback service is started first
    start_playback_service()?;

    // Give the service a moment to initialize
    std::thread::sleep(std::time::Duration::from_millis(100));

    let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
    let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;

    // Get the stored Context for ClassLoader access
    let context = APP_CONTEXT.get().ok_or("Context not initialized")?;

    // Get the ClassLoader from the Context to load app classes
    let class_loader = env
        .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
        .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;

    // Load the JellyTauPlaybackService class using the app's class loader
    let service_class_name = env
        .new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
        .map_err(|e| format!("Failed to create class name string: {}", e))?;

    let service_class_obj = env
        .call_method(
            &class_loader,
            "loadClass",
            "(Ljava/lang/String;)Ljava/lang/Class;",
            &[JValue::Object(&service_class_name.into())],
        )
        .map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to Class: {}", e))?;

    // Cast to JClass for static method calls
    let service_class = JClass::from(service_class_obj);

    // Get the JellyTauPlaybackService instance
    let service_obj = env
        .call_static_method(
            &service_class,
            "getInstance",
            "()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
            &[],
        )
        .map_err(|e| format!("Failed to get service instance: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to object: {}", e))?;

    if service_obj.is_null() {
        return Err("JellyTauPlaybackService not running after start attempt".to_string());
    }

    // Call enableRemoteVolume(int)
    env.call_method(
        &service_obj,
        "enableRemoteVolume",
        "(I)V",
        &[JValue::Int(initial_volume)],
    )
    .map_err(|e| format!("Failed to enable remote volume: {}", e))?;

    log::info!("[Android] Remote volume control enabled");
    Ok(())
}

/// Disable remote volume control on Android.
///
/// This switches the MediaSession back to local playback mode,
/// allowing system volume buttons to control ExoPlayer volume.
#[cfg(target_os = "android")]
pub fn disable_remote_volume() -> Result<(), String> {
    log::info!("[Android] Disabling remote volume control");

    let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
    let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;

    // Get the stored Context for ClassLoader access
    let context = APP_CONTEXT.get().ok_or("Context not initialized")?;

    // Get the ClassLoader from the Context to load app classes
    let class_loader = env
        .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
        .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;

    // Load the JellyTauPlaybackService class using the app's class loader
    let service_class_name = env
        .new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
        .map_err(|e| format!("Failed to create class name string: {}", e))?;

    let service_class_obj = env
        .call_method(
            &class_loader,
            "loadClass",
            "(Ljava/lang/String;)Ljava/lang/Class;",
            &[JValue::Object(&service_class_name.into())],
        )
        .map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to Class: {}", e))?;

    // Cast to JClass for static method calls
    let service_class = JClass::from(service_class_obj);

    // Get the JellyTauPlaybackService instance
    let service_obj = env
        .call_static_method(
            &service_class,
            "getInstance",
            "()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
            &[],
        )
        .map_err(|e| format!("Failed to get service instance: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to object: {}", e))?;

    if service_obj.is_null() {
        return Err("JellyTauPlaybackService not running".to_string());
    }

    // Call disableRemoteVolume()
    env.call_method(&service_obj, "disableRemoteVolume", "()V", &[])
        .map_err(|e| format!("Failed to disable remote volume: {}", e))?;

    log::info!("[Android] Remote volume control disabled");
    Ok(())
}

use crate::player::LockscreenMetadata;

/// Push now-playing metadata and playback state to the Android lockscreen.
///
/// Calls `JellyTauPlaybackService.updateMediaMetadata(...)`. The service must be
/// running (in remote mode it is started via [`enable_remote_volume`]); if it
/// isn't, this is a no-op rather than an error so it can be called freely on
/// every poll tick.
///
/// TRACES: UR-006 | IR-006
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
    let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
    let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;

    let context = APP_CONTEXT.get().ok_or("Context not initialized")?;

    let class_loader = env
        .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
        .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;

    let service_class_name = env
        .new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
        .map_err(|e| format!("Failed to create class name string: {}", e))?;

    let service_class_obj = env
        .call_method(
            &class_loader,
            "loadClass",
            "(Ljava/lang/String;)Ljava/lang/Class;",
            &[JValue::Object(&service_class_name.into())],
        )
        .map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to Class: {}", e))?;

    let service_class = JClass::from(service_class_obj);

    let service_obj = env
        .call_static_method(
            &service_class,
            "getInstance",
            "()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
            &[],
        )
        .map_err(|e| format!("Failed to get service instance: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to object: {}", e))?;

    // Service not running yet (e.g. nothing has played) - nothing to update.
    if service_obj.is_null() {
        return Ok(());
    }

    let title = env
        .new_string(&meta.title)
        .map_err(|e| format!("Failed to create title string: {}", e))?;
    let artist = env
        .new_string(&meta.artist)
        .map_err(|e| format!("Failed to create artist string: {}", e))?;
    // album is nullable on the Kotlin side; pass a real String or JObject::null().
    let album_obj = match &meta.album {
        Some(a) => env
            .new_string(a)
            .map_err(|e| format!("Failed to create album string: {}", e))?
            .into(),
        None => jni::objects::JObject::null(),
    };

    env.call_method(
        &service_obj,
        "updateMediaMetadata",
        "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZ)V",
        &[
            JValue::Object(&title.into()),
            JValue::Object(&artist.into()),
            JValue::Object(&album_obj),
            JValue::Long(meta.duration_ms),
            JValue::Long(meta.position_ms),
            JValue::Bool(meta.is_playing as u8),
        ],
    )
    .map_err(|e| format!("Failed to update lockscreen metadata: {}", e))?;

    Ok(())
}

/// Set the background-audio handoff base (seconds) on the playback service.
///
/// The service holds it for `JellyTauPlayer`'s position tick, which is the one
/// place the relative handoff timeline is converted to the episode's own — see
/// DR-159. Calls `JellyTauPlaybackService.setHandoffBase(double)`. No-op if the
/// service isn't running yet, so it's safe to call unconditionally.
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
    let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
    let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;

    let context = APP_CONTEXT.get().ok_or("Context not initialized")?;

    let class_loader = env
        .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
        .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;

    let service_class_name = env
        .new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
        .map_err(|e| format!("Failed to create class name string: {}", e))?;

    let service_class_obj = env
        .call_method(
            &class_loader,
            "loadClass",
            "(Ljava/lang/String;)Ljava/lang/Class;",
            &[JValue::Object(&service_class_name.into())],
        )
        .map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to Class: {}", e))?;

    let service_class = JClass::from(service_class_obj);

    let service_obj = env
        .call_static_method(
            &service_class,
            "getInstance",
            "()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
            &[],
        )
        .map_err(|e| format!("Failed to get service instance: {}", e))?
        .l()
        .map_err(|e| format!("Failed to convert to object: {}", e))?;

    // Service not running yet - nothing to offset.
    if service_obj.is_null() {
        return Ok(());
    }

    env.call_method(
        &service_obj,
        "setHandoffBase",
        "(D)V",
        &[JValue::Double(offset_seconds)],
    )
    .map_err(|e| format!("Failed to set handoff base: {}", e))?;

    Ok(())
}

/// Stub implementations for non-Android platforms
#[cfg(not(target_os = "android"))]
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
    Ok(())
}

#[cfg(not(target_os = "android"))]
pub fn disable_remote_volume() -> Result<(), String> {
    Ok(())
}
