//! A tap in the car, with the phone in a pocket.
//!
//! Android Auto can start this app's media service with no Activity, no
//! Tauri and no WebView anywhere in the process — the browse tree is served
//! by Kotlin over a `MediaLibraryService`, and a tap on a row is a play
//! request that arrives before anything the app normally is has been built.
//! This module is what answers it.
//!
//! Three things make that possible, and all three were already true:
//!
//!   * `JNI_OnLoad` publishes the JavaVM and the app Context to `ndk-context`
//!     at `System.loadLibrary` time — before any Activity exists — so cpal's
//!     AAudio backend can open an output stream in a service-started process;
//!   * `crates/library` is plain rusqlite with no Tauri dependency, so the
//!     database can be opened and read without an `AppHandle`;
//!   * `crates/player` is plain rodio with no Tauri dependency, so the audio
//!     thread can be driven the same way.
//!
//! What is NOT here, deliberately:
//!
//!   * this is not "the queue moved into Rust". Shuffle, repeat, the alts
//!     failover, provenance and the three-ahead preload all stay in
//!     `src/mobile/player.svelte.js`, which is the ONLY queue while a WebView
//!     is alive. The fallback below is a `Vec` and an index that stops at the
//!     end of the record — no shuffle, no repeat, no preload;
//!   * catalog and Soulseek items cannot play from cold. The resolver is
//!     eight hundred lines of shared JS and Soulseek wants a live session;
//!     an id this module cannot turn into a local file resolves to nothing
//!     and the browse side says so. Round fifty owns that label;
//!   * loudness normalisation. `commands::play_local` wraps every start in
//!     `loudness::on_track_started`, which needs an `AppHandle`. A headless
//!     track plays at unity gain until the WebView adopts it. Harmless —
//!     every `Cmd::Play` resets the normalisation gain to 0 dB anyway — but
//!     it is a real difference and it is written down in ANDROID.md.
//!
//! THE DATABASE IS OPENED READ-ONLY, and that is not a nicety.
//! `Library::open` runs `PRAGMA journal_mode = WAL` and a batch of
//! `CREATE TABLE IF NOT EXISTS` — writes, both of them, from a process that
//! may be racing the real app. `Library::open_read_only` does neither.
//! Reading a WAL database read-only is legal from SQLite 3.22 (this tree
//! bundles 3.46) as long as the `-shm`/`-wal` files exist and are readable,
//! OR there is write permission on the DIRECTORY holding the database.
//! The second always holds here: the headless process is the app's own uid
//! and owns `…/Library/.soulstream/`.
//!
//! THE LOG GOES THROUGH KOTLIN. `tauri_plugin_log` is registered inside
//! Tauri's `.setup()`, so in a service-started process `log::info!` reaches
//! nowhere `adb logcat` can be pointed at. Every line this module wants read
//! back on the device goes out through `NativeAuto.log`, which prints it
//! under the tag `SoulStream` with an `auto ` prefix. None of those lines
//! may contain the substring `media window `: `mediaWindow()` in
//! tests/mobile/smoke.mjs filters on exactly that and would swallow ours
//! into the `queue-window` assertion.

use std::path::{Path, PathBuf};
use std::sync::Mutex;

use base64::Engine as _;
use tauri_plugin_media_session::{NowPlaying, QueueItem};

/// The current item and ten either side — the same cap `media.svelte.js`
/// uses, so the card and Auto's queue list are the same shape whichever side
/// is driving.
const WINDOW: usize = 10;
/// A cover bigger than this is not a cover. The bytes cross two hops as
/// base64 and are decoded by media3; the phone's own art is well under it.
const MAX_ART_BYTES: u64 = 3 * 1024 * 1024;

/// One playable row of the fallback queue: a local file and the little that
/// a media3 `MediaItem` and the phone's own queue entry are built from.
///
/// Serialized camelCase because the SAME rows go to the WebView twice — as
/// the `soulseek:auto-play` event when the app is already up, and as
/// `headless_state()` when it has just come up and is adopting what the car
/// started.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Row {
    pub id: i64,
    pub album_id: i64,
    pub path: String,
    pub title: String,
    pub artist: String,
    pub album: String,
    pub duration: Option<f64>,
    /// The album's cover, as a local path — `NowPlaying::art_key`'s kind of
    /// key, and what the phone's `album.art_path` is.
    pub art_key: Option<String>,
}

/// The handover, in one answer. `live` says whether the fallback was still
/// driving when the WebView asked; `rows`/`index` are what it was playing.
#[derive(Debug, Clone, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HeadlessState {
    pub live: bool,
    pub index: usize,
    pub rows: Vec<Row>,
}

struct Fallback {
    rows: Vec<Row>,
    index: usize,
    /// Is this the thing driving the engine RIGHT NOW? Exactly one driver
    /// exists at a time: while this is true the WebView has no queue, and
    /// `playback::init` clears it before the WebView can arm a preload.
    live: bool,
}

static FALLBACK: Mutex<Fallback> = Mutex::new(Fallback {
    rows: Vec::new(),
    index: 0,
    live: false,
});
/// The cover last sent across, so the bytes ride along once per record
/// rather than once per transport change — `media.svelte.js` keeps the same
/// mirror for the same reason.
static LAST_ART: Mutex<Option<String>> = Mutex::new(None);

/// A poisoned lock here is not worth losing the music over: every field is
/// plain data and the worst a panic mid-update leaves is a stale index.
fn fb() -> std::sync::MutexGuard<'static, Fallback> {
    FALLBACK.lock().unwrap_or_else(|p| p.into_inner())
}

/* ── media ids ─────────────────────────────────────────────────────────
   ROUND FIFTY OWNS THE GRAMMAR, and this parser is deliberately loose
   about it. What it needs is the last integer of the id and enough of the
   prefix to say whether that integer names an album row or a track row, so
   `alb:12`, `album/12`, `soulstream://album/12` and `trk:9` all land, and a
   bare `12` is read as a track. Anything else — `cat:5`, `pl3`, a search
   token — resolves to nothing, which is the correct answer: a catalog or
   Soulseek item cannot play from cold and the browse side labels it.
   If round fifty's grammar turns out to be something else entirely, this
   function is the only thing that changes. */
enum MediaId {
    Album(i64),
    Track(i64),
    Path(String),
}

fn parse_media_id(raw: &str) -> Option<MediaId> {
    let s = raw.trim();
    if s.is_empty() {
        return None;
    }
    // An absolute path is its own id — Android's own are absolute, and a
    // Windows one is only ever seen by a desktop test.
    let bytes = s.as_bytes();
    if bytes[0] == b'/' || s.starts_with("\\\\") || (bytes.len() > 2 && bytes[1] == b':' && bytes[2] == b'\\') {
        return Some(MediaId::Path(s.to_string()));
    }
    let tail = s.rsplit(|c| c == ':' || c == '/').next()?;
    let n: i64 = tail.parse().ok()?;
    let head = s[..s.len() - tail.len()].to_ascii_lowercase();
    if head.contains("alb") {
        Some(MediaId::Album(n))
    } else if head.is_empty() || head.contains("tr") {
        // "tr" rather than "trk" so `track/`, `trk:` and
        // `soulstream://track/` all land. The album test runs FIRST because
        // the scheme `soulstream` happens to contain "tr" as well.
        Some(MediaId::Track(n))
    } else {
        None
    }
}

/* ── the library, read-only ────────────────────────────────────────────── */

/// Where the database is, from the app's data directory alone.
///
/// `dataDir`, NOT `filesDir`: Tauri's `app_data_dir()` on Android resolves to
/// `PathPlugin.getDataDir` → `activity.dataDir`, so the library root is
/// `/data/user/0/com.soulstream.app/Library` and `filesDir` would be one
/// level too deep. The failure is silent — the open answers "unable to open
/// database file" and browse and play both come back empty with nothing
/// obviously wrong — which is why it is spelled out here and in ANDROID.md.
fn library_db(data_dir: &Path) -> Option<PathBuf> {
    let cfg = crate::config::load_at(data_dir);
    let root = crate::config::download_dir_at(data_dir, &cfg)?;
    Some(root
        .join(crate::config::LIBRARY_DATA_DIRNAME)
        .join("library.db"))
}

fn rows_for_album(lib: &library::Library, album_id: i64) -> Vec<Row> {
    let Ok(Some((album, _mbid))) = lib.get_album(album_id) else {
        return Vec::new();
    };
    let Ok(tracks) = lib.album_tracks(album_id) else {
        return Vec::new();
    };
    tracks
        .into_iter()
        .map(|t| Row {
            id: t.id,
            album_id,
            path: t.path,
            title: t.title,
            artist: if t.artist.is_empty() {
                album.artist.clone()
            } else {
                t.artist
            },
            album: album.title.clone(),
            duration: t.duration_secs.map(|s| s as f64),
            art_key: album.art_path.clone(),
        })
        .collect()
}

/// Turn media ids into playable rows, and say where to start.
///
/// ONE id is the car's case and is expanded: an album plays whole, a track
/// plays its record FROM that track — which is the phone's own behaviour
/// (`playIndexRow` in player.svelte.js), and the reason a tap in a car does
/// not leave you with a one-song queue. Several ids are taken literally, one
/// row each, and `start_index` picks among them.
pub fn resolve(data_dir: &Path, ids: &[String], start_index: i32) -> (Vec<Row>, usize) {
    let empty = (Vec::new(), 0usize);
    let Some(db) = library_db(data_dir) else {
        return empty;
    };
    let Ok(lib) = library::Library::open_read_only(&db) else {
        log(&format!("library would not open read-only at {}", db.display()));
        return empty;
    };
    let start = start_index.max(0) as usize;

    if ids.len() == 1 {
        return match parse_media_id(&ids[0]) {
            Some(MediaId::Album(id)) => {
                let rows = rows_for_album(&lib, id);
                let at = if start < rows.len() { start } else { 0 };
                (rows, at)
            }
            Some(MediaId::Track(id)) => {
                let Ok(Some(album_id)) = lib.track_album(id) else {
                    return empty;
                };
                let rows = rows_for_album(&lib, album_id);
                let at = rows.iter().position(|r| r.id == id).unwrap_or(0);
                (rows, at)
            }
            Some(MediaId::Path(p)) => (loose_row(&p).into_iter().collect(), 0),
            None => empty,
        };
    }

    let mut rows = Vec::new();
    for id in ids {
        match parse_media_id(id) {
            Some(MediaId::Album(a)) => {
                if let Some(first) = rows_for_album(&lib, a).into_iter().next() {
                    rows.push(first);
                }
            }
            Some(MediaId::Track(t)) => {
                if let Ok(Some(album_id)) = lib.track_album(t) {
                    if let Some(row) = rows_for_album(&lib, album_id)
                        .into_iter()
                        .find(|r| r.id == t)
                    {
                        rows.push(row);
                    }
                }
            }
            Some(MediaId::Path(p)) => {
                if let Some(row) = loose_row(&p) {
                    rows.push(row);
                }
            }
            None => {}
        }
    }
    let at = if start < rows.len() { start } else { 0 };
    (rows, at)
}

/// A file with no library row behind it. Everything a tag would have given
/// is missing, so the card shows the file's own name — which is still better
/// than refusing to play a file that plainly exists.
fn loose_row(path: &str) -> Option<Row> {
    if !std::fs::metadata(path).map(|m| m.is_file()).unwrap_or(false) {
        return None;
    }
    let title = Path::new(path)
        .file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.to_string());
    Some(Row {
        id: 0,
        album_id: 0,
        path: path.to_string(),
        title,
        artist: String::new(),
        album: String::new(),
        duration: None,
        art_key: None,
    })
}

/* ── the fallback queue ────────────────────────────────────────────────── */

/// A play request off the media session. Returns what happened, which
/// Kotlin logs: `headless` (we are driving), `webview` (the app is up and
/// was handed the rows), `empty` (nothing resolved).
///
/// THE TWO DRIVERS NEVER OVERLAP. `has_app()` is the switch, and it is true
/// from `playback::init` onward for the life of the process — so once Tauri
/// has run, a tap in the car is an EVENT to the WebView and never a second
/// driver, and the JS queue keeps shuffle, repeat, alts failover, provenance
/// and its three-ahead preload.
pub fn start(data_dir: &Path, ids: &[String], start_index: i32, start_at_ms: i64) -> String {
    let (rows, at) = resolve(data_dir, ids, start_index);
    if rows.is_empty() {
        return "empty".to_string();
    }
    let start_at = if start_at_ms > 0 {
        Some(start_at_ms as f64 / 1000.0)
    } else {
        None
    };
    if crate::playback::has_app() {
        let payload = serde_json::json!({ "rows": rows, "index": at, "startAt": start_at });
        if crate::playback::emit_to_app("soulseek:auto-play", payload) {
            return "webview".to_string();
        }
        // An app handle that will not emit is not an app any more. Fall
        // through rather than answer silence to somebody sitting in a car.
    }
    {
        let mut f = fb();
        f.rows = rows;
        f.index = at;
        f.live = true;
    }
    play_current(start_at);
    "headless".to_string()
}

/// Start whatever the index points at, and tell the card about it.
fn play_current(start_at: Option<f64>) {
    let started = {
        let f = fb();
        f.rows
            .get(f.index)
            .map(|r| (r.path.clone(), r.duration, r.title.clone(), f.rows.len(), f.index))
    };
    let Some((path, duration, title, n, index)) = started else {
        return;
    };
    crate::playback::process_player(None)
        .player
        .play_local_from(path, duration, start_at);
    // `at=` LAST, because a title may hold anything — spaces and `=`
    // included — and the prober reads i= off this line to prove the queue
    // advanced with nobody pressing anything.
    log(&format!("headless n={n} i={index} at={title}"));
    push_state(true, start_at.unwrap_or(0.0));
}

/// The engine finished a track. A no-op unless the fallback is the driver —
/// when the WebView is, `ended()` in player.svelte.js owns this and knows
/// about repeat, shuffle and the armed preload; this one deliberately does
/// not, and stops at the end of the record.
pub fn on_track_ended() {
    let advanced = {
        let mut f = fb();
        if !f.live {
            return;
        }
        if f.index + 1 >= f.rows.len() {
            f.live = false;
            false
        } else {
            f.index += 1;
            true
        }
    };
    if advanced {
        play_current(None);
    } else {
        log("headless queue ended");
    }
}

/// Buffering / playing / paused / idle off the engine, mirrored to the card.
/// Position ticks do NOT come through here: media3 extrapolates position
/// from the last `setContentPositionMs` while it believes we are playing,
/// exactly as the WebView's own card does.
pub fn on_transport(status: &str) {
    {
        let mut f = fb();
        if !f.live {
            return;
        }
        if status == "idle" {
            // A `Stop` or an error. `Stopped` is emitted by `Cmd::Stop`
            // alone — a new `Play` does not pass through it — so this really
            // is the end and the card comes down with it.
            f.live = false;
            f.rows.clear();
            f.index = 0;
            drop(f);
            clear_card();
            return;
        }
    }
    let snap = crate::playback::snapshot();
    push_state(
        snap.status == "playing" || snap.status == "buffering",
        snap.position,
    );
}

/// A button, when there is no WebView to give it to. `false` means "not
/// mine" and Kotlin should use `MediaHub.onAction` instead — which is what
/// it tried first anyway.
pub fn transport(action: &str, position: f64) -> bool {
    if crate::playback::has_app() {
        return false;
    }
    if !fb().live {
        return false;
    }
    let player = crate::playback::process_player(None).player.clone();
    match action {
        "play" => player.resume(),
        "pause" => player.pause(),
        "toggle" => {
            if crate::playback::snapshot().status == "playing" {
                player.pause()
            } else {
                player.resume()
            }
        }
        "stop" => player.stop(),
        "next" => step(1),
        "prev" => {
            // The app's own five-second restart rule, kept: past five
            // seconds previous is a seek to zero, not a step back.
            if crate::playback::snapshot().position > 5.0 {
                player.seek(0.0);
            } else {
                step(-1);
            }
        }
        "seek" => {
            if position >= 0.0 {
                player.seek(position);
            }
        }
        "queue-at" => {
            if position >= 0.0 {
                jump(position.round() as usize);
            }
        }
        _ => return false,
    }
    true
}

fn step(delta: i64) {
    let moved = {
        let mut f = fb();
        let n = f.rows.len() as i64;
        let i = f.index as i64 + delta;
        if !f.live || i < 0 || i >= n {
            false
        } else {
            f.index = i as usize;
            true
        }
    };
    if moved {
        play_current(None);
    }
}

fn jump(i: usize) {
    let moved = {
        let mut f = fb();
        if f.live && i < f.rows.len() {
            f.index = i;
            true
        } else {
            false
        }
    };
    if moved {
        play_current(None);
    }
}

/// The WebView has come up. Stop DRIVING, do not stop the MUSIC: the whole
/// point of the handover is that the album keeps going while the app builds
/// itself around it.
///
/// The rows deliberately SURVIVE this. `headless_state()` runs a second or
/// two later, from `restoreSession()`, and it is the only thing that can
/// tell the WebView what is actually sounding — `PlaybackSnapshot` carries
/// transport and nothing else, on purpose.
pub fn stand_down() {
    let was = {
        let mut f = fb();
        let was = f.live;
        f.live = false;
        was
    };
    if was {
        log("headless standing down");
    }
}

/// What the car left sounding, handed over ONCE. A second ask answers
/// nothing: the WebView owns the queue from the first answer on, and a later
/// reload must restore what the USER has played since, not what the car
/// started an hour ago.
pub fn take_state() -> HeadlessState {
    let mut f = fb();
    let out = HeadlessState {
        live: f.live,
        index: f.index,
        rows: std::mem::take(&mut f.rows),
    };
    f.index = 0;
    f.live = false;
    out
}

/* ── the card ──────────────────────────────────────────────────────────── */

/// Fill the SAME `NowPlayingArgs` the WebView fills, through the same
/// `MediaHub.update`. That is the one idea that keeps this round small:
/// `SoulStreamPlayer.getState()`, the card, the notification, audio focus
/// and the walk's `one-card` / `one-session` checks all keep working by
/// construction, because there is exactly one writer at a time and nothing
/// in round forty-eight's file needs a second reading path.
fn push_state(playing: bool, position: f64) {
    let json = {
        let f = fb();
        let Some(cur) = f.rows.get(f.index) else {
            return;
        };
        let lo = f.index.saturating_sub(WINDOW);
        let hi = (f.index + WINDOW + 1).min(f.rows.len());
        let queue: Vec<QueueItem> = f.rows[lo..hi]
            .iter()
            .map(|r| QueueItem {
                id: Some(r.id.to_string()),
                title: Some(r.title.clone()),
                artist: Some(r.artist.clone()),
                album: Some(r.album.clone()),
                duration: r.duration,
                art_key: r.art_key.clone(),
            })
            .collect();
        let np = NowPlaying {
            title: Some(cur.title.clone()),
            artist: Some(cur.artist.clone()),
            album: Some(cur.album.clone()),
            playing,
            position,
            duration: cur.duration,
            art_key: cur.art_key.clone(),
            art: cover_once(cur.art_key.as_deref()),
            has_next: f.index + 1 < f.rows.len(),
            has_prev: true,
            queue,
            queue_index: Some((f.index - lo) as u32),
            queue_base: Some(lo as u32),
            // No repeat and no shuffle in the fallback, and saying so is
            // what keeps media3's view of "is there a next item" honest.
            repeat: 0,
        };
        serde_json::to_string(&np).ok()
    };
    if let Some(json) = json {
        bridge::push_state(&json);
    }
}

/// The bar emptied. Kotlin reads an empty string as `MediaHub.clear`.
fn clear_card() {
    if let Ok(mut last) = LAST_ART.lock() {
        *last = None;
    }
    bridge::push_state("");
}

/// The cover's bytes, once per record. `MediaHub` keeps them until `artKey`
/// changes, so re-reading a two-megabyte JPEG on every transport change
/// would be pure waste — `media.svelte.js` keeps the same mirror.
fn cover_once(key: Option<&str>) -> Option<String> {
    let key = key.filter(|k| !k.is_empty() && !k.starts_with("http"))?;
    {
        let mut last = LAST_ART.lock().ok()?;
        if last.as_deref() == Some(key) {
            return None;
        }
        *last = Some(key.to_string());
    }
    let md = std::fs::metadata(key).ok()?;
    if !md.is_file() || md.len() > MAX_ART_BYTES {
        return None;
    }
    let bytes = std::fs::read(key).ok()?;
    Some(base64::engine::general_purpose::STANDARD.encode(bytes))
}

/// One line, under the tag `SoulStream`, prefixed `auto ` by Kotlin. Never
/// containing `media window `; see the module header.
pub fn log(msg: &str) {
    bridge::log(msg);
}

/* ── JNI ───────────────────────────────────────────────────────────────── */

/// Cache what the callbacks need, from `JNI_OnLoad` and nowhere else.
///
/// Android's JNI Tips: a thread attached with `AttachCurrentThread` starts in
/// the SYSTEM class loader, so `FindClass` from it cannot see app classes at
/// all. The remedy the platform documents is to do the lookup once in
/// `JNI_OnLoad`, which runs on the class loader of whoever called
/// `System.loadLibrary`, and to hold the result with `NewGlobalRef`. This is
/// not theoretical here: the `player-events` pump is a `std::thread`, so the
/// track-ended callback runs on exactly such a thread.
#[cfg(target_os = "android")]
pub fn cache_jni(vm: *mut jni::sys::JavaVM) {
    bridge::cache(vm);
}

#[cfg(target_os = "android")]
mod bridge {
    use jni::objects::{GlobalRef, JClass, JObjectArray, JString, JValue};
    use jni::sys::{jboolean, jdouble, jint, jlong, jstring, JNI_FALSE, JNI_TRUE};
    use jni::{JNIEnv, JavaVM};
    use std::sync::OnceLock;

    /// Reached by `FindClass` from native code, which is why it is named in
    /// the plugin's consumer-rules.pro: R8 renaming it breaks this at
    /// RUNTIME, in a release build, with nothing in any log until somebody
    /// taps something in a car.
    const CLASS: &str = "com/soulstream/mediasession/NativeAuto";

    struct Hooks {
        vm: JavaVM,
        class: GlobalRef,
    }
    static HOOKS: OnceLock<Hooks> = OnceLock::new();

    pub(super) fn cache(vm: *mut jni::sys::JavaVM) {
        if HOOKS.get().is_some() {
            return;
        }
        let Ok(jvm) = (unsafe { JavaVM::from_raw(vm) }) else {
            return;
        };
        let class = {
            let Ok(mut env) = jvm.get_env() else {
                return;
            };
            let found = match env.find_class(CLASS) {
                Ok(c) => c,
                Err(e) => {
                    let _ = env.exception_clear();
                    eprintln!("android: {CLASS} not found ({e}); the car cannot reach us");
                    return;
                }
            };
            match env.new_global_ref(&found) {
                Ok(g) => g,
                Err(_) => return,
            }
        };
        let _ = HOOKS.set(Hooks { vm: jvm, class });
    }

    /// Every call from Rust into Kotlin, on whatever thread we happen to be.
    /// The pump thread is not a Java thread, so it is attached AS A DAEMON —
    /// a plain attach would keep the VM from ever shutting down, and an
    /// `AttachGuard` would attach and detach on every track change.
    fn call(name: &str, arg: &str) {
        let Some(hooks) = HOOKS.get() else {
            return;
        };
        let Ok(mut env) = hooks.vm.attach_current_thread_as_daemon() else {
            return;
        };
        let Ok(text) = env.new_string(arg) else {
            return;
        };
        let result = env.call_static_method(
            &hooks.class,
            name,
            "(Ljava/lang/String;)V",
            &[JValue::from(&text)],
        );
        if result.is_err() {
            let _ = env.exception_describe();
            let _ = env.exception_clear();
        }
    }

    pub(super) fn push_state(json: &str) {
        call("onHeadlessState", json);
    }

    pub(super) fn log(msg: &str) {
        call("log", msg);
    }

    fn text(env: &mut JNIEnv, s: &JString) -> String {
        env.get_string(s).map(String::from).unwrap_or_default()
    }

    /// `NativeAuto.play(dataDir, ids, startIndex, startAtMs)`.
    ///
    /// `dataDir` is `ctx.dataDir`, NOT `ctx.filesDir` — see `library_db`.
    #[no_mangle]
    pub extern "system" fn Java_com_soulstream_mediasession_NativeAuto_play<'local>(
        mut env: JNIEnv<'local>,
        _class: JClass<'local>,
        data_dir: JString<'local>,
        ids: JObjectArray<'local>,
        start_index: jint,
        start_at_ms: jlong,
    ) -> jstring {
        let dir = text(&mut env, &data_dir);
        let mut list: Vec<String> = Vec::new();
        if let Ok(n) = env.get_array_length(&ids) {
            for i in 0..n {
                let Ok(object) = env.get_object_array_element(&ids, i) else {
                    continue;
                };
                if object.is_null() {
                    continue;
                }
                let one = JString::from(object);
                let s = text(&mut env, &one);
                if !s.is_empty() {
                    list.push(s);
                }
            }
        }
        let answer = super::start(
            std::path::Path::new(&dir),
            &list,
            start_index,
            start_at_ms,
        );
        env.new_string(answer)
            .map(|s| s.into_raw())
            .unwrap_or(std::ptr::null_mut())
    }

    /// `NativeAuto.transport(action, positionSecs)` — true when the headless
    /// side took it, false when Kotlin should use `MediaHub.onAction`.
    #[no_mangle]
    pub extern "system" fn Java_com_soulstream_mediasession_NativeAuto_transport<'local>(
        mut env: JNIEnv<'local>,
        _class: JClass<'local>,
        action: JString<'local>,
        position_secs: jdouble,
    ) -> jboolean {
        let name = text(&mut env, &action);
        if super::transport(&name, position_secs) {
            JNI_TRUE
        } else {
            JNI_FALSE
        }
    }
}

/// Off the phone there is no Kotlin to call and no car to call it from. The
/// rest of this module still compiles — and type-checks on the desktop,
/// which is most of why it is one file rather than two.
#[cfg(not(target_os = "android"))]
mod bridge {
    pub(super) fn push_state(_json: &str) {}
    pub(super) fn log(_msg: &str) {}
}
