//! The library, read through a keyhole, for a browser with no app behind it.
//!
//! Android Auto browses through a `MediaLibraryService`, and the system
//! starts that service in a process with no Activity, no Tauri and no
//! WebView — the whole frontend, every `#[tauri::command]` in `commands.rs`
//! and the entire resolver are simply not there. What IS there is this
//! crate's own `.so`: `JNI_OnLoad` (see `android_glue.rs`) runs the moment
//! `System.loadLibrary("app_lib")` loads us and publishes the JavaVM and the
//! app Context before any component exists, and `crates/library` is plain
//! rusqlite with no Tauri anywhere in it. So the tree the car reads is
//! answered from here, straight off the database, and browse works cold.
//!
//! Four rules hold this module together, and each of them is a bug that has
//! already been paid for somewhere in this project:
//!
//! * **The connection is READ-ONLY.** Never `library::Library::open` — it
//!   runs `PRAGMA journal_mode = WAL` and eleven `CREATE TABLE IF NOT
//!   EXISTS` before it returns, which are writes, into a database whose real
//!   owner may be running in another process at the same moment.
//!   `SQLITE_OPEN_READ_ONLY`, a short busy timeout, and this module's own
//!   SQL — copied from `crates/library`, never invented.
//!
//! * **Nothing ever fails loudly.** No config, no database, a storage
//!   permission revoked since the last launch, a corrupt page: every one of
//!   them answers an EMPTY BUT VALID list with the reason in `note`. A head
//!   unit that spins forever is worse than one that says the library is
//!   empty, and a driver cannot dismiss a dialog. `ss:root` in particular is
//!   answered without opening anything at all, so the four tabs are there
//!   even on a phone that has never had a library — that is what makes
//!   "empty but valid" structural rather than a fallback in Kotlin.
//!
//! * **It agrees with the phone.** Artists are not a Rust concept. They are
//!   derived in `src/mobile/lib.svelte.js` by grouping `list_albums()` in
//!   its own order, so they are grouped exactly that way here — first-seen
//!   casing, insertion-ordered off the same query. A SQL `GROUP BY artist`
//!   would pick a different display name and the car would disagree with the
//!   phone in Kyle's hand. Same for the recents list, which is the library
//!   arm of `recent_albums` at the limit the phone asks for.
//!
//! * **Art crosses as a PATH, and Kotlin turns it into a `content://`.**
//!   Minting a URI needs a Context and a FileProvider; deciding WHICH file
//!   is art needs the thumbnail cache key, which lives in `commands.rs`. So
//!   the decision is here and the minting is there. A `http(s)` cover (a
//!   catalog row) is dropped rather than handed over: Auto refuses every
//!   scheme but `content://` and `android.resource://`, so a remote URL is
//!   not a cover, it is a grey square with extra steps.
//!
//! ## The node grammar (rounds 51 and 52 read this, not this code)
//!
//! ```text
//! ss:root[?limit=N]                        browsable   the four tabs
//! ss:tab:recent|albums|artists|playlists   browsable
//! ss:album:<albums.id>                     browsable
//! ss:artist:<pct-encoded lowercase name>   browsable
//! ss:playlist:<playlists.id>               browsable
//! ss:track:<tracks.id>                     playable    tracks.path
//! ss:plitem:<playlist_items.id>            playable    playlist_items.path, WHICH MAY BE NULL
//! ```
//!
//! `ss:plitem:` rows of kind `stream` or `unresolved` have no path at all
//! and are listed and marked playable ON PURPOSE — hiding them would make
//! the car disagree with the phone, and the design owes them `STATE_ERROR`
//! plus "open SoulStream on your phone" rather than silence. A caller that
//! assumes a path is always there will NPE on a real playlist of Kyle's.
//!
//! `ss:artist:` is a percent-encoded `to_lowercase()` of the tag artist: it
//! is stable while the tags are, and not across a retag.

use rusqlite::{Connection, OpenFlags};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

/// The root node id. Kotlin appends `?limit=N` to it from the browser's own
/// root hints; nothing else in the grammar carries a query.
pub const ROOT: &str = "ss:root";

/// The most rows one node ever answers with.
///
/// Auto does not paginate: `MediaLibraryServiceLegacyStub.onLoadChildren`
/// calls `onGetChildrenOnHandler(..., page = 0, pageSize = Integer.MAX_VALUE,
/// ...)` when the browser sends no pagination option, which a
/// `MediaBrowserCompat.subscribe()` never does. So a whole tab crosses ONE
/// Binder transaction, under a ~1 MB ceiling, and the cap is what keeps a
/// large library from failing that transaction instead of showing 500 rows.
/// Kyle's library is ~114 covers and never reaches it; a five-thousand-album
/// one would, and would be told so through `capped`.
const CAP: usize = 500;

/// The recents list, exactly as the phone asks for it: `recentAlbums(14)` in
/// `src/mobile/lib.svelte.js`, over the command's own 45-second play floor.
/// Not the command's default 20 — the number the PHONE shows is the number
/// the car should show.
const RECENT_LIMIT: i64 = 14;
const RECENT_MIN_SECS: i64 = 45;

/// The thumbnail edge the phone prewarms (`Mobile.svelte` →
/// `library_art_prewarm([360])`), so a browsed cover is a disk read of a
/// 360 px JPEG rather than a 3000 px master decoded inside Auto's process.
const ART_EDGE: u32 = 360;

/// The four tabs, in the order the root serves them. A root-children limit
/// below four truncates from the END, so Recent is the one tab a two-slot
/// head unit is guaranteed to keep.
const TABS: [(&str, &str, &str); 4] = [
    ("ss:tab:recent", "Recent", "What you played last"),
    ("ss:tab:albums", "Albums", "Everything you have saved"),
    ("ss:tab:artists", "Artists", "Grouped the way the phone groups them"),
    ("ss:tab:playlists", "Playlists", "Your own lists"),
];

/// One row on its way to a `MediaItem`. `art` is an absolute FILE path —
/// Kotlin mints the `content://` — and `duration_ms` is milliseconds because
/// that is what `MediaMetadata.setDurationMs` wants.
struct Item {
    id: String,
    kind: &'static str,
    title: String,
    subtitle: String,
    browsable: bool,
    playable: bool,
    art: Option<String>,
    duration_ms: Option<i64>,
}

/// The whole answer, as one JSON string. The only entry point; the JNI shim
/// below is a thin wrapper and every other platform reaches it not at all.
///
/// `data_dir` is Android's `Context.getDataDir()` — `/data/user/0/<pkg>` —
/// which is what `paths::app_data` resolves to on the phone (tauri's
/// `PathPlugin.getDataDir` answers `activity.dataDir`). It is NOT
/// `getFilesDir()`: nothing this module reads lives under `files/`.
/// `cache_dir` is `Context.getCacheDir()`, which is where
/// `library_art_prewarm` put the thumbnails.
pub fn browse_json(node: &str, data_dir: &str, cache_dir: &str) -> String {
    let started = Instant::now();
    let (path, limit) = split_query(node);
    let mut note = String::new();
    let mut items: Vec<Item> = if path == ROOT {
        /* Answered with the database untouched, deliberately: see the module
           note. Four tabs on a phone with no config, no library and no
           storage permission is the difference between "your library is
           empty" and a car that never finishes loading. */
        root_tabs(limit)
    } else {
        match children(path, data_dir, cache_dir) {
            Ok(v) => v,
            Err(e) => {
                note = e;
                Vec::new()
            }
        }
    };
    let capped = items.len() > CAP;
    if capped {
        items.truncate(CAP);
    }
    envelope(node, note.is_empty(), &note, capped, started.elapsed(), &items)
}

/// The empty-but-valid answer, for a caller that has nothing else to say —
/// the JNI shim's panic guard uses it.
pub fn empty_json(node: &str, note: &str) -> String {
    envelope(node, false, note, false, Duration::from_millis(0), &[])
}

fn envelope(node: &str, ok: bool, note: &str, capped: bool, took: Duration, items: &[Item]) -> String {
    serde_json::json!({
        "node": node,
        "ok": ok,
        "note": note,
        "capped": capped,
        "ms": took.as_millis() as u64,
        "items": items.iter().map(|i| serde_json::json!({
            "id": i.id,
            "kind": i.kind,
            "title": i.title,
            "subtitle": i.subtitle,
            "browsable": i.browsable,
            "playable": i.playable,
            "art": i.art,
            "duration": i.duration_ms,
        })).collect::<Vec<_>>(),
    })
    .to_string()
}

/// `ss:root?limit=3` → (`ss:root`, Some(3)). Nothing else in the grammar
/// carries a query, and an unparseable one reads as absent.
fn split_query(node: &str) -> (&str, Option<i64>) {
    match node.split_once('?') {
        None => (node, None),
        Some((head, query)) => {
            let mut limit = None;
            for part in query.split('&') {
                if let Some(v) = part.strip_prefix("limit=") {
                    limit = v.parse::<i64>().ok();
                }
            }
            (head, limit)
        }
    }
}

fn root_tabs(limit: Option<i64>) -> Vec<Item> {
    /* A limit of zero, a negative one or none at all is the browser saying
       nothing, which Google's own docs put at four — and four is what we
       have. Anything larger is the browser being generous. */
    let keep = match limit {
        Some(n) if n >= 1 => (n as usize).min(TABS.len()),
        _ => TABS.len(),
    };
    TABS.iter()
        .take(keep)
        .map(|(id, title, subtitle)| Item {
            id: (*id).to_string(),
            kind: "tab",
            title: (*title).to_string(),
            subtitle: (*subtitle).to_string(),
            browsable: true,
            playable: false,
            art: None,
            duration_ms: None,
        })
        .collect()
}

fn children(node: &str, data_dir: &str, cache_dir: &str) -> Result<Vec<Item>, String> {
    let rest = node
        .strip_prefix("ss:")
        .ok_or_else(|| format!("not one of ours: {node}"))?;
    let (kind, arg) = rest.split_once(':').unwrap_or((rest, ""));
    /* A leaf has no children and needs no database to say so. Round 51 turns
       these two into a file path; here they are simply the bottom. */
    if kind == "track" || kind == "plitem" {
        return Ok(Vec::new());
    }

    let db = library_db(data_dir);
    if !db.is_file() {
        /* The commonest honest answer there is: a fresh install, or a
           library folder in shared storage with the all-files grant gone.
           Said out loud so the empty tree explains itself on the first real
           run rather than being mute. */
        return Err(format!("no library database at {}", db.display()));
    }
    let conn = open_read_only(&db).map_err(|e| format!("sqlite open: {e} ({})", db.display()))?;
    let thumbs = Path::new(cache_dir).join("art-thumbs-v1");

    match (kind, arg) {
        ("tab", "recent") => recent(&conn, &thumbs),
        ("tab", "albums") => albums_tab(&conn, &thumbs),
        ("tab", "artists") => artists_tab(&conn, &thumbs),
        ("tab", "playlists") => playlists_tab(&conn, &thumbs),
        ("album", id) => {
            let id: i64 = id.parse().map_err(|_| format!("not an album id: {id}"))?;
            album_tracks(&conn, &thumbs, id)
        }
        ("artist", key) => artist_albums(&conn, &thumbs, &pct_decode(key)),
        ("playlist", id) => {
            let id: i64 = id.parse().map_err(|_| format!("not a playlist id: {id}"))?;
            playlist_rows(&conn, &thumbs, id)
        }
        _ => Err(format!("unknown node {node}")),
    }
}

/* ── where the library is ──────────────────────────────────────────────
 *
 *  Mirrors `config.rs` exactly, and writes nothing on the way: the config is
 *  READ, never parsed-and-saved, because a service process has no business
 *  rewriting the file the app owns.
 */

/// `<download_dir>/.soulstream/library.db` — `config::library_data_dir` plus
/// `pipeline::init`'s own filename, with `LIBRARY_DATA_DIRNAME` taken from
/// the one place that spells it.
fn library_db(data_dir: &str) -> PathBuf {
    download_dir(data_dir)
        .join(crate::config::LIBRARY_DATA_DIRNAME)
        .join("library.db")
}

/// `cfg.download_dir` when it is set and non-empty, else `<data_dir>/Library`
/// — the `#[cfg(target_os = "android")]` arm of `config::download_dir`, which
/// returns before the desktop's `audio_dir()` fallback is ever reached.
///
/// The BOM strip is not decoration. serde refuses a leading U+FEFF outright,
/// so a config written by PowerShell 5.1's `Out-File utf8` would parse as
/// nothing at all and the car would quietly browse the wrong (private)
/// library while the phone browsed the right one — the 2026 `config.json`
/// BOM trap, in a process with no log anyone reads.
fn download_dir(data_dir: &str) -> PathBuf {
    let cfg = Path::new(data_dir).join("config.json");
    if let Ok(text) = std::fs::read_to_string(&cfg) {
        let text = text.strip_prefix('\u{feff}').unwrap_or(text.as_str());
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
            if let Some(d) = v.get("download_dir").and_then(|d| d.as_str()) {
                if !d.is_empty() {
                    return PathBuf::from(d);
                }
            }
        }
    }
    Path::new(data_dir).join("Library")
}

/// Read-only, URI-capable, unshared, and with a SHORT busy timeout.
///
/// The timeout is the interesting one: the app's own process may be
/// mid-checkpoint when the car asks, and the head unit must not be made to
/// wait on it. A second and a half, then the node comes back empty with the
/// reason in `note`, which is exactly the contract the rest of this module
/// keeps.
///
/// On a WAL database a read-only CONNECTION still creates the `-shm` when it
/// can: SQLite's shared-memory open is governed by filesystem permission on
/// the directory, not by the connection's flags, and it is our own UID.
/// Measured on the Win32 VFS (3.49.1) against a database with an
/// uncheckpointed WAL and no `-shm`: the file was created and the WAL's
/// content read. If the unix VFS ever disagrees it fails as
/// SQLITE_CANTOPEN / SQLITE_READONLY_CANTINIT, which lands in `note` rather
/// than anywhere a driver can see.
fn open_read_only(db: &Path) -> rusqlite::Result<Connection> {
    let conn = Connection::open_with_flags(
        db,
        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
    )?;
    conn.busy_timeout(Duration::from_millis(1500))?;
    Ok(conn)
}

fn sql_err(e: rusqlite::Error) -> String {
    format!("sqlite: {e}")
}

/* ── art ───────────────────────────────────────────────────────────── */

/// The best FILE on disk for one stored art path: the warmed 360 px
/// thumbnail when it is there, the master otherwise, and nothing when
/// neither is.
///
/// `art_thumb_target` is borrowed from `commands.rs` rather than
/// reimplemented, and the comment over it says why: the key is
/// (path, mtime, len, edge), `album_art` reads through it and
/// `library_art_prewarm` writes through it, and a second implementation
/// drifting would make every warmed thumbnail invisible to this module.
fn art_for(thumbs: &Path, src: Option<&str>) -> Option<String> {
    let s = src.map(str::trim).filter(|s| !s.is_empty())?;
    /* a catalog row's cover. Auto supports content:// and
       android.resource:// and nothing else, so this is not a cover. */
    if s.starts_with("http://") || s.starts_with("https://") {
        return None;
    }
    if let Some(t) = crate::commands::art_thumb_target(thumbs, s, ART_EDGE) {
        if t.is_file() {
            return Some(t.to_string_lossy().into_owned());
        }
    }
    if Path::new(s).is_file() {
        return Some(s.to_string());
    }
    None
}

/* ── the albums, once ──────────────────────────────────────────────── */

struct AlbumRow {
    id: i64,
    artist: String,
    title: String,
    year: Option<i64>,
    art: Option<String>,
    artist_art: Option<String>,
    added_at: i64,
    tracks: i64,
}

/// `Library::list_albums`'s statement, verbatim — including its
/// `ORDER BY a.added_at DESC, a.artist, a.title`, which IS `newest()` in
/// `lib.svelte.js` and is also the order the artist grouping depends on.
fn all_albums(conn: &Connection) -> rusqlite::Result<Vec<AlbumRow>> {
    let mut stmt = conn.prepare(
        "SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at,
                (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id)
         FROM albums a
         ORDER BY a.added_at DESC, a.artist, a.title",
    )?;
    let rows = stmt.query_map([], |r| {
        Ok(AlbumRow {
            id: r.get(0)?,
            artist: r.get(1)?,
            title: r.get(2)?,
            year: r.get(3)?,
            art: r.get(4)?,
            artist_art: r.get(5)?,
            added_at: r.get(6)?,
            tracks: r.get(7)?,
        })
    })?;
    Ok(rows.filter_map(|r| r.ok()).collect())
}

fn album_item(thumbs: &Path, a: &AlbumRow) -> Item {
    let subtitle = match a.year {
        Some(y) if y > 0 => format!("{} \u{b7} {y}", a.artist),
        _ => a.artist.clone(),
    };
    Item {
        id: format!("ss:album:{}", a.id),
        kind: "album",
        title: a.title.clone(),
        subtitle,
        browsable: true,
        playable: false,
        art: art_for(thumbs, a.art.as_deref()),
        duration_ms: None,
    }
}

fn albums_tab(conn: &Connection, thumbs: &Path) -> Result<Vec<Item>, String> {
    let albums = all_albums(conn).map_err(sql_err)?;
    Ok(albums.iter().map(|a| album_item(thumbs, a)).collect())
}

/// `albumsBy()` in `lib.svelte.js`: the same album list, this artist only,
/// newest release year first and newest added within a year.
fn artist_albums(conn: &Connection, thumbs: &Path, key: &str) -> Result<Vec<Item>, String> {
    let mut mine: Vec<AlbumRow> = all_albums(conn)
        .map_err(sql_err)?
        .into_iter()
        .filter(|a| a.artist.to_lowercase() == key)
        .collect();
    mine.sort_by(|x, y| {
        y.year
            .unwrap_or(0)
            .cmp(&x.year.unwrap_or(0))
            .then(y.added_at.cmp(&x.added_at))
    });
    Ok(mine.iter().map(|a| album_item(thumbs, a)).collect())
}

/// `artists()` in `lib.svelte.js`, grouped in Rust because there is no
/// artists table and never has been.
///
/// Every detail here is a copy rather than an improvement: the key is
/// `to_lowercase()`, the DISPLAY name is the first casing seen in
/// `list_albums` order (not `MIN(artist)`, which a `GROUP BY` would pick and
/// which would differ), the portrait is the first non-null
/// `artist_art_path` with the first non-null `art_path` behind it, and the
/// sort is albums desc → tracks desc → name. The one knowing departure is
/// the tiebreak: JS sorts the last step with `localeCompare` and this sorts
/// by bytes, which only reorders two artists who already have identical
/// album and track counts.
fn artists_tab(conn: &Connection, thumbs: &Path) -> Result<Vec<Item>, String> {
    struct Group {
        name: String,
        key: String,
        albums: i64,
        tracks: i64,
        art: Option<String>,
        cover: Option<String>,
    }
    let albums = all_albums(conn).map_err(sql_err)?;
    let mut groups: Vec<Group> = Vec::new();
    let mut at: HashMap<String, usize> = HashMap::new();
    for a in &albums {
        let key = a.artist.to_lowercase();
        let i = match at.get(&key) {
            Some(i) => *i,
            None => {
                groups.push(Group {
                    name: a.artist.clone(),
                    key: key.clone(),
                    albums: 0,
                    tracks: 0,
                    art: None,
                    cover: None,
                });
                at.insert(key, groups.len() - 1);
                groups.len() - 1
            }
        };
        let g = &mut groups[i];
        g.albums += 1;
        g.tracks += a.tracks;
        if g.art.is_none() {
            g.art = a.artist_art.clone();
        }
        if g.cover.is_none() {
            g.cover = a.art.clone();
        }
    }
    groups.sort_by(|x, y| {
        y.albums
            .cmp(&x.albums)
            .then(y.tracks.cmp(&x.tracks))
            .then(x.name.cmp(&y.name))
    });
    Ok(groups
        .iter()
        .map(|g| Item {
            id: format!("ss:artist:{}", pct_encode(&g.key)),
            kind: "artist",
            title: g.name.clone(),
            subtitle: format!("{} \u{b7} {}", plural(g.albums, "album"), plural(g.tracks, "track")),
            browsable: true,
            playable: false,
            art: art_for(thumbs, g.art.as_deref()).or_else(|| art_for(thumbs, g.cover.as_deref())),
            duration_ms: None,
        })
        .collect())
}

fn plural(n: i64, word: &str) -> String {
    if n == 1 {
        format!("{n} {word}")
    } else {
        format!("{n} {word}s")
    }
}

/* ── recent ────────────────────────────────────────────────────────── */

/// The LIBRARY arm of `Library::recent_albums`, `link` CTE and all.
///
/// The catalog arm is dropped at the source rather than filtered after,
/// for two reasons that agree: `lib.svelte.js` filters it out of the
/// phone's rail anyway (`r.kind === "library" && r.album_id != null`), and a
/// catalog row's cover is an `http` URL, which is not a cover Auto can show.
/// The `link` CTE stays because it is what keeps ONE record one row when it
/// was played both ways — without it, streaming a saved album pushes a
/// duplicate to the top.
fn recent(conn: &Connection, thumbs: &Path) -> Result<Vec<Item>, String> {
    let mut stmt = conn
        .prepare(
            "WITH link AS (
                 SELECT DISTINCT p.album_id, p.catalog_album_id
                 FROM plays p
                 JOIN albums a ON a.id = p.album_id
                 WHERE p.catalog_album_id IS NOT NULL
             )
             SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at,
                    (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id),
                    MAX(p.started_at) AS last_at
             FROM plays p
             JOIN albums a
               ON a.id = COALESCE(
                      p.album_id,
                      (SELECT l.album_id FROM link l
                       WHERE l.catalog_album_id = p.catalog_album_id
                       LIMIT 1))
             WHERE p.seconds >= ?2
             GROUP BY a.id
             ORDER BY last_at DESC
             LIMIT ?1",
        )
        .map_err(sql_err)?;
    let rows = stmt
        .query_map(rusqlite::params![RECENT_LIMIT, RECENT_MIN_SECS], |r| {
            Ok(AlbumRow {
                id: r.get(0)?,
                artist: r.get(1)?,
                title: r.get(2)?,
                year: r.get(3)?,
                art: r.get(4)?,
                artist_art: r.get(5)?,
                added_at: r.get(6)?,
                tracks: r.get(7)?,
            })
        })
        .map_err(sql_err)?;
    Ok(rows
        .filter_map(|r| r.ok())
        .map(|a| album_item(thumbs, &a))
        .collect())
}

/* ── tracks ────────────────────────────────────────────────────────── */

/// `Library::album_tracks`'s statement and its ORDER BY, verbatim — the one
/// that keeps eleven volumes of Analord from interleaving eleven track-1s.
/// The cover is the ALBUM's, read once, so a whole record's worth of rows
/// costs one extra query and not one per track.
fn album_tracks(conn: &Connection, thumbs: &Path, album_id: i64) -> Result<Vec<Item>, String> {
    let cover: Option<String> = conn
        .query_row(
            "SELECT art_path FROM albums WHERE id = ?1",
            [album_id],
            |r| r.get(0),
        )
        .map_err(sql_err)?;
    let art = art_for(thumbs, cover.as_deref());
    let mut stmt = conn
        .prepare(
            "SELECT id, title, artist, duration_secs
             FROM tracks WHERE album_id = ?1
             ORDER BY disc_no IS NOT NULL, disc_no, track_no IS NULL, track_no, title",
        )
        .map_err(sql_err)?;
    let rows = stmt
        .query_map([album_id], |r| {
            Ok((
                r.get::<_, i64>(0)?,
                r.get::<_, String>(1)?,
                r.get::<_, String>(2)?,
                r.get::<_, Option<i64>>(3)?,
            ))
        })
        .map_err(sql_err)?;
    Ok(rows
        .filter_map(|r| r.ok())
        .map(|(id, title, artist, secs)| Item {
            id: format!("ss:track:{id}"),
            kind: "track",
            title,
            subtitle: artist,
            browsable: false,
            playable: true,
            art: art.clone(),
            duration_ms: secs.filter(|s| *s > 0).map(|s| s * 1000),
        })
        .collect())
}

/* ── playlists ─────────────────────────────────────────────────────── */

/// `Library::list_playlists`'s ordering (pinned first, then newest) with its
/// aggregates cut down to the count the car shows, plus the same `art_seeds`
/// pass — one query over all rows, not one per playlist — so a list with no
/// chosen cover still gets the first cover its rows carry.
fn playlists_tab(conn: &Connection, thumbs: &Path) -> Result<Vec<Item>, String> {
    let mut seeds: HashMap<i64, String> = HashMap::new();
    {
        let mut stmt = conn
            .prepare(
                "SELECT playlist_id, art_path FROM playlist_items
                 WHERE art_path IS NOT NULL ORDER BY playlist_id, position",
            )
            .map_err(sql_err)?;
        let rows = stmt
            .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
            .map_err(sql_err)?;
        for (pid, art) in rows.flatten() {
            seeds.entry(pid).or_insert(art);
        }
    }
    let mut stmt = conn
        .prepare(
            "SELECT p.id, p.name, p.cover_path,
                    (SELECT COUNT(*) FROM playlist_items i WHERE i.playlist_id = p.id)
             FROM playlists p
             ORDER BY p.pinned DESC, p.created_at DESC",
        )
        .map_err(sql_err)?;
    let rows = stmt
        .query_map([], |r| {
            Ok((
                r.get::<_, i64>(0)?,
                r.get::<_, String>(1)?,
                r.get::<_, Option<String>>(2)?,
                r.get::<_, i64>(3)?,
            ))
        })
        .map_err(sql_err)?;
    Ok(rows
        .filter_map(|r| r.ok())
        .map(|(id, name, cover, n)| {
            let art = art_for(thumbs, cover.as_deref())
                .or_else(|| art_for(thumbs, seeds.get(&id).map(String::as_str)));
            Item {
                id: format!("ss:playlist:{id}"),
                kind: "playlist",
                title: name,
                subtitle: plural(n, "track"),
                browsable: true,
                playable: false,
                art,
                duration_ms: None,
            }
        })
        .collect())
}

/// `Library::playlist_items`'s statement, cut to what a row in a car shows.
///
/// Rows with no `path` — kind `stream` or `unresolved` — are listed and
/// marked PLAYABLE on purpose: the phone shows them, so the car shows them,
/// and the round that plays them owes them an error state with a resolution
/// rather than a gap in the list.
fn playlist_rows(conn: &Connection, thumbs: &Path, playlist_id: i64) -> Result<Vec<Item>, String> {
    let mut stmt = conn
        .prepare(
            "SELECT id, title, artist, duration_secs, art_path
             FROM playlist_items WHERE playlist_id = ?1 ORDER BY position",
        )
        .map_err(sql_err)?;
    let rows = stmt
        .query_map([playlist_id], |r| {
            Ok((
                r.get::<_, i64>(0)?,
                r.get::<_, String>(1)?,
                r.get::<_, String>(2)?,
                r.get::<_, Option<i64>>(3)?,
                r.get::<_, Option<String>>(4)?,
            ))
        })
        .map_err(sql_err)?;
    Ok(rows
        .filter_map(|r| r.ok())
        .map(|(id, title, artist, secs, art)| Item {
            id: format!("ss:plitem:{id}"),
            kind: "plitem",
            title,
            subtitle: artist,
            browsable: false,
            playable: true,
            art: art_for(thumbs, art.as_deref()),
            duration_ms: secs.filter(|s| *s > 0).map(|s| s * 1000),
        })
        .collect())
}

/* ── percent coding, by hand ───────────────────────────────────────── */

/// RFC 3986 unreserved survives; everything else becomes %XX. A media id
/// may hold anything, but it also travels through logs, a regex in the phone
/// walk and (in round 52) a search query, so the artist key is kept to
/// bytes that cannot be mistaken for the grammar's own punctuation.
fn pct_encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.as_bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(*b as char),
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

/// Byte-wise on purpose: slicing a `&str` at `i + 1 .. i + 3` would panic on
/// a char boundary the moment a node id carried a stray `%` in front of
/// multibyte text.
fn pct_decode(s: &str) -> String {
    fn hex(c: u8) -> Option<u8> {
        match c {
            b'0'..=b'9' => Some(c - b'0'),
            b'a'..=b'f' => Some(c - b'a' + 10),
            b'A'..=b'F' => Some(c - b'A' + 10),
            _ => None,
        }
    }
    let bytes = s.as_bytes();
    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let (Some(hi), Some(lo)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
                out.push(hi * 16 + lo);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/* ── the JNI door ──────────────────────────────────────────────────── */

/// `com.soulstream.mediasession.LibraryBridge.browse`.
///
/// The symbol is spelled from the package, the class and the method, which
/// is why `consumer-rules.pro` keeps all three by name: R8 renaming any of
/// them is an `UnsatisfiedLinkError` at the first browse in the car, with
/// nothing at all wrong at build time.
///
/// The Kotlin side declares this on an `object`, so the second argument is
/// the singleton and not a class — `JObject` either way, and ignored.
///
/// Nothing crosses this boundary but a string, and nothing unwinds across
/// it: a panic in the query layer would be undefined behaviour through JNI,
/// so the whole call is inside `catch_unwind` and a caught panic answers the
/// same empty-but-valid envelope every other failure does.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_LibraryBridge_browse<'local>(
    mut env: jni::JNIEnv<'local>,
    _this: jni::objects::JObject<'local>,
    node: jni::objects::JString<'local>,
    data_dir: jni::objects::JString<'local>,
    cache_dir: jni::objects::JString<'local>,
) -> jni::sys::jstring {
    fn read<'a>(env: &mut jni::JNIEnv<'a>, s: &jni::objects::JString<'a>) -> String {
        env.get_string(s).map(Into::into).unwrap_or_default()
    }
    let node = read(&mut env, &node);
    let data = read(&mut env, &data_dir);
    let cache = read(&mut env, &cache_dir);
    let json = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        browse_json(&node, &data, &cache)
    }))
    .unwrap_or_else(|_| empty_json(&node, "panicked"));
    match env.new_string(json) {
        Ok(s) => s.into_raw(),
        /* a null answer is the one thing Kotlin is told to expect from a
           bridge that could not speak at all */
        Err(_) => std::ptr::null_mut(),
    }
}
