/// Export a playlist to a file the user picks. `format` ∈ m3u8 | xspf |
/// pls | csv | json. Returns where it went — the path on the desktop, the
/// document's name on the phone — or None when the dialog was cancelled.
///
/// The file is built ABOVE the fork, by `playlist_formats`, so both doors
/// write the same bytes and neither platform owns a format. The doors
/// differ because the answers do: the desktop's save dialog hands back a
/// path and `std::fs` writes it; Android's create-document sheet hands back
/// a `content://` URI, which no path can open, so the bytes go to the
/// storage plugin and its ContentResolver writes them where the user
/// pointed. A row with no local file is the writers' business, not this
/// one's — each has its own answer, and both platforms get it.
#[tauri::command]
pub async fn playlist_export(
    app: tauri::AppHandle,
    lib: State<'_, LibraryState>,
    id: i64,
    format: String,
) -> Result<Option<String>, String> {
    let l = lib.library.clone();
    let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id))
        .await
        .map_err(|e| e.to_string())?
        .map_err(|e| e.to_string())?
        .ok_or("Playlist not found")?;
    let l = lib.library.clone();
    let items = tauri::async_runtime::spawn_blocking(move || l.playlist_items(id))
        .await
        .map_err(|e| e.to_string())?
        .map_err(|e| e.to_string())?;

    // The MIME is the phone's half of the pair: Android's Documents UI files
    // the document by it, and staples an extension of its own on anything it
    // does not recognise. All five are in its bundled table.
    let (ext, filter_name, mime): (&str, &str, &str) = match format.as_str() {
        "m3u8" => ("m3u8", "M3U8 playlist", "audio/x-mpegurl"),
        "xspf" => ("xspf", "XSPF playlist", "application/xspf+xml"),
        "pls" => ("pls", "PLS playlist", "audio/x-scpls"),
        "csv" => ("csv", "CSV", "text/csv"),
        "json" => ("json", "SoulStream playlist JSON", "application/json"),
        other => return Err(format!("Unknown export format {other}")),
    };
    let safe_name: String = meta
        .name
        .chars()
        .map(|c| match c {
            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
            c if (c as u32) < 0x20 => '_',
            c => c,
        })
        .collect();

    // Its own block: `export_items` borrows `items` and `meta`, and the
    // desktop branch below awaits a dialog — nothing borrowed may be held
    // across that await. The block ends the borrows with the file in hand.
    let body = {
        let export_items: Vec<ExportItem<'_>> = items
            .iter()
            .map(|i| ExportItem {
                title: &i.title,
                artist: &i.artist,
                album: i.album.as_deref(),
                duration_secs: i.duration_secs,
                path: i.path.as_deref(),
                start_ms: i.start_ms,
                source_line: i.source_line.as_deref(),
                kind: &i.kind,
                note: i.note.as_deref(),
            })
            .collect();
        match format.as_str() {
            "m3u8" => playlist_formats::write_m3u8(&export_items),
            "xspf" => {
                playlist_formats::write_xspf(&meta.name, meta.description.as_deref(), &export_items)
            }
            "pls" => playlist_formats::write_pls(&export_items),
            "csv" => playlist_formats::write_csv(&meta.name, &export_items),
            _ => playlist_formats::write_soulstream_json(
                &meta.name,
                meta.description.as_deref(),
                &meta.kind,
                &export_items,
            ),
        }
    };

    #[cfg(mobile)]
    {
        // No filter and no temp file here: the sheet does the choosing, and
        // the ContentResolver's "wt" truncates in place, so there is no torn
        // file to move out of the way of.
        let _ = filter_name;
        let doc = tauri_plugin_storage::create_document(
            &app,
            &format!("{safe_name}.{ext}"),
            mime,
            &body,
        )?;
        // Nothing picked is both fields empty — a dismissed sheet, which is
        // this command's None on either platform. The name is what a person
        // can find again; the URI is the fallback when the provider is quiet.
        Ok(doc.name.or(doc.uri))
    }
    #[cfg(desktop)]
    {
        let _ = mime;
        use tauri_plugin_dialog::DialogExt;
        let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
        app.dialog()
            .file()
            .set_file_name(format!("{safe_name}.{ext}"))
            .add_filter(filter_name, &[ext])
            .save_file(move |path| {
                let _ = tx.send(path.map(|p| p.to_string()));
            });
        let Some(dest) = rx.await.unwrap_or(None) else {
            return Ok(None);
        };
        // Write beside the destination, then move into place — a failed write
        // never leaves a torn file under the chosen name. rename on Windows
        // replaces an existing destination, so there is no unlink window.
        let dest_path = PathBuf::from(&dest);
        tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
            let temp = dest_path.with_extension(format!("{ext}.writing"));
            std::fs::write(&temp, body.as_bytes()).map_err(|e| e.to_string())?;
            std::fs::rename(&temp, &dest_path).map_err(|e| e.to_string())
        })
        .await
        .map_err(|e| e.to_string())??;
        Ok(Some(dest))
    }
}
