#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod auth;
mod beatport;
mod config;
mod spotify;

use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::fs;
use std::io::{BufRead, Write};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use tokio::sync::{oneshot, Mutex};
use tauri::{
    menu::{Menu, MenuItem},
    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
    Emitter, Manager,
};
use tauri_plugin_global_shortcut::{
    Builder as GlobalShortcutBuilder,
    Code as ShortcutCode,
    Shortcut,
    ShortcutEvent,
    ShortcutState,
};

// ── Public data structs ─────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Track {
    pub id: String,
    pub name: String,
    pub artists: String,
    pub artist_ids: Vec<String>,
    pub album: String,
    pub album_id: String,
    pub duration_ms: u64,
    pub uri: String,
    pub album_art: String,
    pub added_at: Option<String>,
    // Region availability. With `market=from_token` on the list GETs, Spotify
    // relinks region-locked tracks to a playable version and sets this; a `false`
    // means no playable version exists in the user's market. Defaults to `true`
    // for payloads that never carry the field (embed/SSR fallbacks, placeholders).
    #[serde(default = "default_true")]
    pub is_playable: bool,
}

fn default_true() -> bool {
    true
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playlist {
    pub id: String,
    pub name: String,
    pub tracks: u32,
    pub owner: String,
    pub uri: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artist {
    pub id: String,
    pub name: String,
    pub followers: u32,
    pub uri: String,
    pub image: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Album {
    pub id: String,
    pub name: String,
    pub artists: String,
    pub tracks: u32,
    pub uri: String,
    pub image: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackState {
    pub is_playing: bool,
    pub progress_ms: u64,
    pub duration_ms: u64,
    pub shuffle_state: bool,
    pub repeat_state: String,
    pub volume_percent: u32,
    pub device_name: String,
    pub device_id: String,
    pub track: Option<Track>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchAllResult {
    pub tracks: Vec<Track>,
    pub playlists: Vec<Playlist>,
    pub playlist_total: u32,
    pub artists: Vec<Artist>,
    pub albums: Vec<Album>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchPlaylistsPage {
    pub playlists: Vec<Playlist>,
    pub total: u32,
    pub offset: u32,
    pub limit: u32,
    pub next_offset: Option<u32>,
    pub done: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaylistTracksPage {
    pub tracks: Vec<Track>,
    pub total: u32,
    pub offset: u32,
    pub limit: u32,
    pub next_offset: Option<u32>,
    pub done: bool,
    pub cached: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BpCreateResult {
    pub name: String,
    pub tracks_found: u32,
    pub tracks_total: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BpSearchInput {
    pub name: String,
    pub artists: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NativeAudioStatus {
    pub running: bool,
    pub available: bool,
    pub path: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloaderStatus {
    pub spotify_ready: bool,
    pub spotify_login_in_progress: bool,
    pub spotify_login_message: String,
    pub spotify_login_error: String,
    pub spotify_configured_accounts: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadQueueItem {
    pub local_id: String,
    pub available: bool,
    pub item_service: String,
    pub item_type: String,
    pub item_id: String,
    pub item_status: String,
    pub item_name: String,
    pub item_by: String,
    pub parent_category: String,
    pub playlist_name: String,
    pub playlist_by: String,
    pub playlist_number: u32,
    pub item_thumbnail: String,
    pub item_url: String,
    pub file_path: String,
    pub progress: u32,
    pub error: String,
}

#[derive(Debug, Clone)]
struct SpotifyRateLimitEntry {
    family: String,
    method: String,
    path: String,
    status: u16,
    retry_after_secs: u64,
    observed_at_unix: u64,
    blocked_until_unix: u64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpotifySearchFallbackKind {
    Track,
    Playlist,
}

impl SpotifySearchFallbackKind {
    fn as_str(self) -> &'static str {
        match self {
            Self::Track => "track",
            Self::Playlist => "playlist",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct SpotifySearchFallbackEntry {
    id: String,
    name: String,
    subtitle: String,
}

struct PendingSpotifySearchFallback {
    query: String,
    kind: SpotifySearchFallbackKind,
    sender: oneshot::Sender<Result<Vec<SpotifySearchFallbackEntry>, String>>,
}

// ── App state ───────────────────────────────────────────────────────────────

pub struct AppInner {
    pub http: reqwest::Client,
    pub token: Option<config::TokenInfo>,
    pub cfg: config::Config,
    pub data_dir: std::path::PathBuf,
    pub resource_dir: Option<std::path::PathBuf>,
    pub user_id: String,
    pub pending_auth: Option<auth::PkceAuth>,
    pub native_audio: Option<Child>,
    pub native_audio_stdin: Option<ChildStdin>,
    pub onthespot_web: Option<Child>,
    pub onthespot_web_started_at: Option<std::time::Instant>,
    /// Ring buffer for the last 200 lines of lightify-audio stderr.
    pub native_audio_stderr: Arc<std::sync::Mutex<VecDeque<String>>>,
    /// Ring buffer for the last 200 lines of OnTheSpot stdout/stderr.
    pub onthespot_web_stderr: Arc<std::sync::Mutex<VecDeque<String>>>,
    pub onthespot_synced_audio_download_path: Option<String>,
    pub active_device_id: Option<String>,
    last_spotify_search_get_at: Option<std::time::Instant>,
    spotify_write_rate_limits: HashMap<String, SpotifyRateLimitEntry>,
    last_spotify_write_rate_limit: Option<SpotifyRateLimitEntry>,
    /// Shared short cooldown after a read (GET) 429, so concurrent reads back off
    /// together instead of each independently hammering the API.
    spotify_read_cooldown_until: Option<std::time::Instant>,
    pending_spotify_search_fallback: Option<PendingSpotifySearchFallback>,
    /// Rolling structured diagnostics (device/auth/playback events).
    pub diagnostics: Arc<std::sync::Mutex<VecDeque<DiagEntry>>>,
}

pub type AppState = Arc<Mutex<AppInner>>;

// ── Spotify helpers ─────────────────────────────────────────────────────────

const BASE: &str = "https://api.spotify.com/v1";
const SPOTIFY_GET_MAX_ATTEMPTS: usize = 3;
const SPOTIFY_WRITE_MAX_ATTEMPTS: usize = 3;
const SPOTIFY_REQUEST_TIMEOUT_SECS: u64 = 20;
const SPOTIFY_WRITE_AUTORETRY_MAX_SECS: u64 = 3;
const SPOTIFY_WRITE_RATE_LIMIT_MAX_SECS: u64 = 6 * 60 * 60;
const SPOTIFY_SEARCH_GET_MIN_INTERVAL_MS: u64 = 800;
const SPOTIFY_READ_COOLDOWN_MAX_SECS: u64 = 5;
const PLAYLIST_ITEM_PAGE_LIMIT: u32 = 100;
/// Hard ceiling for ANY `search` request. Spotify's docs say 50, but this app's
/// client id is capped at 10 — 11+ returns `400 Invalid limit` (verified live against
/// track/artist/album/playlist searches). Exceeding it is what made per-category tabs
/// fail while "ALL" (which used 10) kept working. Offset paging is NOT capped, so
/// depth comes from paging, never from a bigger limit. Clamp every search limit here.
const SPOTIFY_SEARCH_MAX_LIMIT: u32 = 10;
/// Per-type result count for `cmd_search_all` — the single call every search tab
/// renders from. Must stay within SPOTIFY_SEARCH_MAX_LIMIT.
const SEARCH_ALL_TYPE_LIMIT: u32 = SPOTIFY_SEARCH_MAX_LIMIT;
const SPOTIFY_WEB_PLAYLIST_FALLBACK_LIMIT: u32 = 50;
const ARTIST_TRACK_SEARCH_PAGE_LIMIT: u32 = 50;
const LIKED_TRACKS_PAGE_LIMIT: u32 = 50;
const TRACK_PAGE_CACHE_VERSION: u8 = 3;
const PLAY_URIS_MAX_BATCH: usize = 50;
const DEBUG_LOG_LINE_LIMIT: usize = 200;
const SPOTIFY_SEARCH_FALLBACK_WINDOW_LABEL: &str = "spotify-search-fallback";
const SPOTIFY_SEARCH_FALLBACK_TIMEOUT_SECS: u64 = 18;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedTrackPage {
    #[serde(rename = "v")]
    version: u8,
    #[serde(rename = "s")]
    saved_at: u64,
    #[serde(rename = "o")]
    offset: u32,
    #[serde(rename = "l")]
    limit: u32,
    #[serde(rename = "t")]
    total: u32,
    #[serde(rename = "n")]
    next_offset: Option<u32>,
    #[serde(rename = "d")]
    done: bool,
    #[serde(rename = "x")]
    tracks: Vec<Track>,
}

fn unix_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

const DIAG_CAP: usize = 300;

#[derive(Clone, Serialize)]
struct DiagEntry {
    ts: u64,
    level: String,
    category: String,
    message: String,
}

/// Append an entry to the rolling diagnostics ring buffer (and stderr). Cheap;
/// callers pass short level/category tags plus a human-readable message. Used to
/// turn "playback got stuck" reports into a concrete event trail (device
/// transfers, token refreshes, queue recovery, unavailable tracks, …).
async fn push_diag(state: &AppState, level: &str, category: &str, message: impl Into<String>) {
    let entry = DiagEntry {
        ts: unix_secs(),
        level: level.to_string(),
        category: category.to_string(),
        message: message.into(),
    };
    eprintln!("[diag] {} [{}] {}", entry.level, entry.category, entry.message);
    let buf = { state.lock().await.diagnostics.clone() };
    let mut g = match buf.lock() {
        Ok(g) => g,
        Err(_) => return,
    };
    g.push_back(entry);
    while g.len() > DIAG_CAP {
        g.pop_front();
    }
}

fn spotify_retry_after_secs(retry_after: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
    retry_after
        .and_then(|value| value.to_str().ok())
        .and_then(|value| value.parse::<u64>().ok())
}

fn spotify_write_rate_limit_family(path: &str) -> &'static str {
    let normalized = path.split('?').next().unwrap_or(path);
    if normalized.starts_with("search") {
        "search"
    } else if normalized.starts_with("me/library") || normalized.starts_with("me/tracks") {
        "library"
    } else if normalized.starts_with("me/player/queue") {
        "player-queue"
    } else if normalized.starts_with("me/player") {
        "player"
    } else if normalized.starts_with("playlists") || normalized.starts_with("me/playlists") {
        "playlist"
    } else {
        "spotify"
    }
}

fn spotify_write_rate_limit_label(family: &str) -> &'static str {
    match family {
        "search" => "Spotify search",
        "library" => "library actions",
        "player-queue" => "queue updates",
        "player" => "playback commands",
        "playlist" => "playlist requests",
        _ => "Spotify requests",
    }
}

fn spotify_write_rate_limit_message(entry: &SpotifyRateLimitEntry) -> String {
    let remaining_secs = entry.blocked_until_unix.saturating_sub(unix_secs());
    let label = spotify_write_rate_limit_label(&entry.family);
    if remaining_secs > 0 {
        format!("SPOTIFY_RATE_LIMITED: Spotify asked Lightify to wait {remaining_secs}s before {label}.")
    } else {
        format!("SPOTIFY_RATE_LIMITED: Spotify is temporarily rate limiting {label}.")
    }
}

fn spotify_rate_limit_message_for_path(
    path: &str,
    retry_after: Option<&reqwest::header::HeaderValue>,
) -> String {
    let family = spotify_write_rate_limit_family(path);
    if family == "search" {
        return "SPOTIFY_RATE_LIMITED: Spotify search is temporarily rate limited.".to_string();
    }
    let label = spotify_write_rate_limit_label(family);
    let retry_after_secs = spotify_retry_after_secs(retry_after).unwrap_or(1);
    format!("SPOTIFY_RATE_LIMITED: Spotify asked Lightify to wait {retry_after_secs}s before {label}.")
}

fn should_retry_transient_spotify_status(
    status: u16,
    attempt: usize,
    max_attempts: usize,
    retry_after: Option<&reqwest::header::HeaderValue>,
) -> bool {
    if !is_transient_spotify_status(status) || attempt + 1 >= max_attempts {
        return false;
    }
    if status != 429 {
        return true;
    }
    spotify_retry_after_secs(retry_after)
        .map(|secs| secs <= SPOTIFY_WRITE_AUTORETRY_MAX_SECS)
        .unwrap_or(false)
}

async fn spotify_pace_search_get(state: &AppState, path: &str) {
    if !path.starts_with("search?") {
        return;
    }

    let min_interval = Duration::from_millis(SPOTIFY_SEARCH_GET_MIN_INTERVAL_MS);
    loop {
        let wait_for = {
            let mut guard = state.lock().await;
            match guard.last_spotify_search_get_at {
                Some(last) => {
                    let elapsed = last.elapsed();
                    if elapsed < min_interval {
                        Some(min_interval - elapsed)
                    } else {
                        guard.last_spotify_search_get_at = Some(std::time::Instant::now());
                        None
                    }
                }
                None => {
                    guard.last_spotify_search_get_at = Some(std::time::Instant::now());
                    None
                }
            }
        };

        if let Some(delay) = wait_for {
            tokio::time::sleep(delay).await;
        } else {
            return;
        }
    }
}

/// If a recent GET hit 429, briefly hold other reads so they don't hammer the API
/// during the same window. The wait is bounded (the cooldown is capped when set),
/// so a long Retry-After never stalls the UI — sp_get's own retry still applies.
async fn spotify_preflight_read_cooldown(state: &AppState) {
    let wait = {
        let mut guard = state.lock().await;
        match guard.spotify_read_cooldown_until {
            Some(until) => {
                let now = std::time::Instant::now();
                if until > now {
                    Some(until - now)
                } else {
                    guard.spotify_read_cooldown_until = None;
                    None
                }
            }
            None => None,
        }
    };
    if let Some(delay) = wait {
        tokio::time::sleep(delay).await;
        state.lock().await.spotify_read_cooldown_until = None;
    }
}

/// Record a read-side 429 so other in-flight/subsequent reads back off together.
async fn note_spotify_read_rate_limited(state: &AppState, path: &str) {
    {
        let mut guard = state.lock().await;
        guard.spotify_read_cooldown_until =
            Some(std::time::Instant::now() + Duration::from_secs(SPOTIFY_READ_COOLDOWN_MAX_SECS));
    }
    push_diag(state, "warn", "ratelimit", format!("read 429 on {} — shared read cooldown engaged", path)).await;
}

async fn spotify_preflight_write_rate_limit(state: &AppState, path: &str) -> Result<(), String> {
    let family = spotify_write_rate_limit_family(path).to_string();
    let now = unix_secs();
    let active_limit = {
        let mut guard = state.lock().await;
        match guard.spotify_write_rate_limits.get(&family).cloned() {
            Some(entry) if entry.blocked_until_unix > now => Some(entry),
            Some(_) => {
                guard.spotify_write_rate_limits.remove(&family);
                None
            }
            None => None,
        }
    };

    if let Some(entry) = active_limit {
        return Err(spotify_write_rate_limit_message(&entry));
    }

    Ok(())
}

async fn spotify_clear_write_rate_limit(state: &AppState, path: &str) {
    let family = spotify_write_rate_limit_family(path);
    state.lock().await.spotify_write_rate_limits.remove(family);
}

async fn spotify_record_write_rate_limit(
    state: &AppState,
    method: &str,
    path: &str,
    status: u16,
    retry_after: Option<&reqwest::header::HeaderValue>,
) -> String {
    let observed_at_unix = unix_secs();
    let retry_after_secs = spotify_retry_after_secs(retry_after)
        .unwrap_or(1)
        .clamp(1, SPOTIFY_WRITE_RATE_LIMIT_MAX_SECS);
    let family = spotify_write_rate_limit_family(path).to_string();
    let entry = SpotifyRateLimitEntry {
        family: family.clone(),
        method: method.to_string(),
        path: path.to_string(),
        status,
        retry_after_secs,
        observed_at_unix,
        blocked_until_unix: observed_at_unix.saturating_add(retry_after_secs),
    };
    let message = spotify_write_rate_limit_message(&entry);

    let mut guard = state.lock().await;
    guard.spotify_write_rate_limits.insert(family, entry.clone());
    guard.last_spotify_write_rate_limit = Some(entry);

    message
}

fn is_spotify_rate_limited_error(value: &str) -> bool {
    value.starts_with("Spotify 429:") || value.starts_with("SPOTIFY_RATE_LIMITED:")
}

fn safe_cache_segment(value: &str) -> String {
    let mut out = String::with_capacity(value.len().min(96));
    for c in value.chars() {
        if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
            out.push(c);
            if out.len() >= 96 {
                break;
            }
        }
    }
    if out.is_empty() { "unknown".to_string() } else { out }
}

fn track_cache_dir(data_dir: &Path, user_id: &str, kind: &str, id: &str) -> PathBuf {
    data_dir
        .join("track-cache")
        .join(safe_cache_segment(user_id))
        .join(kind)
        .join(safe_cache_segment(id))
}

fn track_cache_page_path(data_dir: &Path, user_id: &str, kind: &str, id: &str, offset: u32) -> PathBuf {
    track_cache_dir(data_dir, user_id, kind, id).join(format!("{offset}.json"))
}

fn read_cached_track_page(
    data_dir: &Path,
    user_id: &str,
    kind: &str,
    id: &str,
    offset: u32,
    limit: u32,
) -> Option<PlaylistTracksPage> {
    let path = track_cache_page_path(data_dir, user_id, kind, id, offset);
    let text = fs::read_to_string(path).ok()?;
    let cached: CachedTrackPage = serde_json::from_str(&text).ok()?;
    if cached.version != TRACK_PAGE_CACHE_VERSION || cached.offset != offset || cached.limit != limit {
        return None;
    }
    Some(PlaylistTracksPage {
        tracks: cached.tracks,
        total: cached.total,
        offset: cached.offset,
        limit: cached.limit,
        next_offset: cached.next_offset,
        done: cached.done,
        cached: true,
    })
}

fn write_cached_track_page(data_dir: &Path, user_id: &str, kind: &str, id: &str, page: &PlaylistTracksPage) {
    let dir = track_cache_dir(data_dir, user_id, kind, id);
    if fs::create_dir_all(&dir).is_err() {
        return;
    }
    let path = dir.join(format!("{}.json", page.offset));
    let tmp = dir.join(format!("{}.{}.tmp", page.offset, std::process::id()));
    let cached = CachedTrackPage {
        version: TRACK_PAGE_CACHE_VERSION,
        saved_at: unix_secs(),
        offset: page.offset,
        limit: page.limit,
        total: page.total,
        next_offset: page.next_offset,
        done: page.done,
        tracks: page.tracks.clone(),
    };
    let Ok(bytes) = serde_json::to_vec(&cached) else { return; };
    if fs::write(&tmp, bytes).is_ok() {
        let _ = fs::remove_file(&path);
        let _ = fs::rename(tmp, path);
    }
}

fn clear_cached_track_source(data_dir: &Path, user_id: &str, kind: &str, id: &str) {
    let _ = fs::remove_dir_all(track_cache_dir(data_dir, user_id, kind, id));
}

async fn cache_identity(state: &AppState) -> (PathBuf, String) {
    let g = state.lock().await;
    (g.data_dir.clone(), g.user_id.clone())
}

/// Get a valid token, refreshing if expired. Returns (client, token).
async fn auth_token(state: &AppState) -> Result<(reqwest::Client, String), String> {
    let (http, access, is_expired, refresh, client_id, data_dir) = {
        let g = state.lock().await;
        let tok = g.token.as_ref().ok_or("Not authenticated")?;
        (
            g.http.clone(),
            tok.access_token.clone(),
            tok.is_expired(),
            tok.refresh_token.clone(),
            g.cfg.client_id.clone(),
            g.data_dir.clone(),
        )
    };
    if !is_expired {
        return Ok((http, access));
    }
    auth_force_refresh(state, &http, &client_id, &refresh, &data_dir).await
}

/// Force a token refresh regardless of expiry (used after receiving 401).
async fn auth_force_refresh(
    state: &AppState,
    http: &reqwest::Client,
    client_id: &str,
    refresh: &str,
    data_dir: &std::path::Path,
) -> Result<(reqwest::Client, String), String> {
    let mut new_tok = auth::refresh_access_token(http, client_id, refresh).await?;
    let new_access = {
        let mut g = state.lock().await;
        if let Some(existing) = g.token.as_ref() {
            new_tok.cached_user_id = existing.cached_user_id.clone();
            new_tok.cached_display_name = existing.cached_display_name.clone();
        }
        let new_access = new_tok.access_token.clone();
        config::save_token(data_dir, &new_tok);
        g.token = Some(new_tok);
        new_access
    };
    push_diag(state, "info", "auth", "access token refreshed after 401").await;
    Ok((http.clone(), new_access))
}

/// Like sp_get but retries once with a forced token refresh on 401.
async fn sp_get_retrying(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    path: &str,
) -> Result<serde_json::Value, String> {
    spotify_preflight_read_cooldown(state).await;
    spotify_pace_search_get(state, path).await;
    let result = match sp_get(http, token, path).await {
        Err(e) if e.starts_with("SPOTIFY_AUTH_EXPIRED") => {
            let (refresh, client_id, data_dir) = {
                let g = state.lock().await;
                let tok = g.token.as_ref().ok_or("Not authenticated")?;
                (tok.refresh_token.clone(), g.cfg.client_id.clone(), g.data_dir.clone())
            };
            let (_, new_token) = auth_force_refresh(state, http, &client_id, &refresh, &data_dir).await?;
            spotify_pace_search_get(state, path).await;
            sp_get(http, &new_token, path).await
        }
        other => other,
    };
    if let Err(e) = &result {
        if e.starts_with("SPOTIFY_RATE_LIMITED") {
            note_spotify_read_rate_limited(state, path).await;
        }
    }
    result
}

fn is_transient_spotify_status(status: u16) -> bool {
    matches!(status, 429 | 500 | 502 | 503 | 504)
}

fn spotify_retry_delay(attempt: usize, retry_after: Option<&reqwest::header::HeaderValue>) -> Duration {
    if let Some(secs) = spotify_retry_after_secs(retry_after) {
        return Duration::from_secs(secs.clamp(1, 30));
    }
    Duration::from_millis(250 * (attempt as u64 + 1))
}

async fn sp_get(http: &reqwest::Client, token: &str, path: &str) -> Result<serde_json::Value, String> {
    let url = format!("{BASE}/{path}");
    let mut last_error = String::new();

    for attempt in 0..SPOTIFY_GET_MAX_ATTEMPTS {
        let resp = match http
            .get(&url)
            .bearer_auth(token)
            .timeout(Duration::from_secs(SPOTIFY_REQUEST_TIMEOUT_SECS))
            .send()
            .await
        {
            Ok(resp) => resp,
            Err(e) if attempt + 1 < SPOTIFY_GET_MAX_ATTEMPTS && (e.is_timeout() || e.is_connect()) => {
                last_error = e.to_string();
                tokio::time::sleep(spotify_retry_delay(attempt, None)).await;
                continue;
            }
            Err(e) => return Err(e.to_string()),
        };

        let status = resp.status().as_u16();
        if status == 204 {
            return Ok(serde_json::Value::Null);
        }
        if resp.status().is_success() {
            return resp.json().await.map_err(|e| e.to_string());
        }

        let retry_after = resp.headers().get(reqwest::header::RETRY_AFTER).cloned();
        let body = resp.text().await.unwrap_or_default();
        if status == 401 {
            return Err(format!("SPOTIFY_AUTH_EXPIRED: {body}"));
        }
        if status == 403 {
            return Err(format!("SPOTIFY_FORBIDDEN: {body}"));
        }
        if status == 429 && !should_retry_transient_spotify_status(status, attempt, SPOTIFY_GET_MAX_ATTEMPTS, retry_after.as_ref()) {
            return Err(spotify_rate_limit_message_for_path(path, retry_after.as_ref()));
        }
        if is_transient_spotify_status(status) && attempt + 1 < SPOTIFY_GET_MAX_ATTEMPTS {
            last_error = format!("Spotify {status}: {body}");
            tokio::time::sleep(spotify_retry_delay(attempt, retry_after.as_ref())).await;
            continue;
        }
        return Err(format!("Spotify {status}: {body}"));
    }

    Err(format!("Spotify request failed after {SPOTIFY_GET_MAX_ATTEMPTS} attempts: {last_error}"))
}

async fn sp_put(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    path: &str,
    body: Option<serde_json::Value>,
) -> Result<(), String> {
    spotify_preflight_write_rate_limit(state, path).await?;
    let url = format!("{BASE}/{path}");
    let mut last_error = String::new();

    for attempt in 0..SPOTIFY_WRITE_MAX_ATTEMPTS {
        let mut req = http.put(&url).bearer_auth(token);
        req = match &body {
            Some(b) => req.json(b),
            None => req.header("Content-Length", "0"),
        };

        let resp = match req.send().await {
            Ok(resp) => resp,
            Err(e) if attempt + 1 < SPOTIFY_WRITE_MAX_ATTEMPTS && (e.is_timeout() || e.is_connect()) => {
                last_error = e.to_string();
                tokio::time::sleep(spotify_retry_delay(attempt, None)).await;
                continue;
            }
            Err(e) => return Err(e.to_string()),
        };

        let status = resp.status().as_u16();
        if resp.status().is_success() || status == 204 {
            spotify_clear_write_rate_limit(state, path).await;
            return Ok(());
        }

        let retry_after = resp.headers().get(reqwest::header::RETRY_AFTER).cloned();
        let b = resp.text().await.unwrap_or_default();
        if status == 401 {
            return Err(format!("SPOTIFY_AUTH_EXPIRED: {b}"));
        }
        if status == 429 {
            if should_retry_transient_spotify_status(status, attempt, SPOTIFY_WRITE_MAX_ATTEMPTS, retry_after.as_ref()) {
                last_error = format!("Spotify {status}: {b}");
                tokio::time::sleep(spotify_retry_delay(attempt, retry_after.as_ref())).await;
                continue;
            }
            return Err(spotify_record_write_rate_limit(state, "PUT", path, status, retry_after.as_ref()).await);
        }
        if should_retry_transient_spotify_status(status, attempt, SPOTIFY_WRITE_MAX_ATTEMPTS, retry_after.as_ref()) {
            last_error = format!("Spotify {status}: {b}");
            tokio::time::sleep(spotify_retry_delay(attempt, retry_after.as_ref())).await;
            continue;
        }
        return Err(format!("Spotify {status}: {b}"));
    }

    Err(format!("Spotify request failed after {SPOTIFY_WRITE_MAX_ATTEMPTS} attempts: {last_error}"))
}

/// Like sp_put but retries once with a forced token refresh on 401.
async fn sp_put_retrying(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    path: &str,
    body: Option<serde_json::Value>,
) -> Result<(), String> {
    match sp_put(state, http, token, path, body.clone()).await {
        Err(e) if e.starts_with("SPOTIFY_AUTH_EXPIRED") => {
            let (refresh, client_id, data_dir) = {
                let g = state.lock().await;
                let tok = g.token.as_ref().ok_or("Not authenticated")?;
                (tok.refresh_token.clone(), g.cfg.client_id.clone(), g.data_dir.clone())
            };
            let (_, new_token) = auth_force_refresh(state, http, &client_id, &refresh, &data_dir).await?;
            sp_put(state, http, &new_token, path, body).await
        }
        other => other,
    }
}

async fn sp_post(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    path: &str,
    body: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
    spotify_preflight_write_rate_limit(state, path).await?;
    let url = format!("{BASE}/{path}");
    let mut last_error = String::new();

    for attempt in 0..SPOTIFY_WRITE_MAX_ATTEMPTS {
        let mut req = http.post(&url).bearer_auth(token);
        // Spotify's edge returns 411 Length Required for POSTs without a Content-Length
        // header. reqwest omits it for bodyless requests, so we must set it explicitly.
        req = match &body {
            Some(b) => req.json(b),
            None => req.header("Content-Length", "0"),
        };

        let resp = match req.send().await {
            Ok(resp) => resp,
            Err(e) if attempt + 1 < SPOTIFY_WRITE_MAX_ATTEMPTS && (e.is_timeout() || e.is_connect()) => {
                last_error = e.to_string();
                tokio::time::sleep(spotify_retry_delay(attempt, None)).await;
                continue;
            }
            Err(e) => return Err(e.to_string()),
        };

        let status = resp.status().as_u16();
        if !resp.status().is_success() {
            let retry_after = resp.headers().get(reqwest::header::RETRY_AFTER).cloned();
            let b = resp.text().await.unwrap_or_default();
            if status == 401 {
                return Err(format!("SPOTIFY_AUTH_EXPIRED: {b}"));
            }
            if status == 403 {
                return Err(format!("SPOTIFY_FORBIDDEN: {b}"));
            }
            if status == 429 {
                if should_retry_transient_spotify_status(status, attempt, SPOTIFY_WRITE_MAX_ATTEMPTS, retry_after.as_ref()) {
                    last_error = format!("Spotify {status}: {b}");
                    tokio::time::sleep(spotify_retry_delay(attempt, retry_after.as_ref())).await;
                    continue;
                }
                return Err(spotify_record_write_rate_limit(state, "POST", path, status, retry_after.as_ref()).await);
            }
            if should_retry_transient_spotify_status(status, attempt, SPOTIFY_WRITE_MAX_ATTEMPTS, retry_after.as_ref()) {
                last_error = format!("Spotify {status}: {b}");
                tokio::time::sleep(spotify_retry_delay(attempt, retry_after.as_ref())).await;
                continue;
            }
            return Err(format!("Spotify {status}: {b}"));
        }

        spotify_clear_write_rate_limit(state, path).await;
        let content_type = resp
            .headers()
            .get(reqwest::header::CONTENT_TYPE)
            .and_then(|value| value.to_str().ok())
            .unwrap_or("")
            .to_ascii_lowercase();
        if status == 204 {
            return Ok(serde_json::Value::Null);
        }
        let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
        if bytes.is_empty() {
            return Ok(serde_json::Value::Null);
        }
        if content_type.contains("json") {
            return serde_json::from_slice(&bytes).map_err(|e| e.to_string());
        }

        return Ok(serde_json::Value::String(String::from_utf8_lossy(&bytes).trim().to_string()));
    }

    Err(format!("Spotify request failed after {SPOTIFY_WRITE_MAX_ATTEMPTS} attempts: {last_error}"))
}

/// sp_post + token-refresh retry on 401.
async fn sp_post_retrying(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    path: &str,
    body: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
    match sp_post(state, http, token, path, body.clone()).await {
        Err(e) if e.starts_with("SPOTIFY_AUTH_EXPIRED") => {
            let (refresh, client_id, data_dir) = {
                let g = state.lock().await;
                let tok = g.token.as_ref().ok_or("Not authenticated")?;
                (tok.refresh_token.clone(), g.cfg.client_id.clone(), g.data_dir.clone())
            };
            let (_, new_token) = auth_force_refresh(state, http, &client_id, &refresh, &data_dir).await?;
            sp_post(state, http, &new_token, path, body).await
        }
        other => other,
    }
}

async fn sp_no_body(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    method: reqwest::Method,
    path: &str,
) -> Result<(), String> {
    spotify_preflight_write_rate_limit(state, path).await?;
    let url = format!("{BASE}/{path}");
    let mut last_error = String::new();

    for attempt in 0..SPOTIFY_WRITE_MAX_ATTEMPTS {
        let resp = match http
            .request(method.clone(), &url)
            .bearer_auth(token)
            .header("Content-Length", "0")
            .send()
            .await
        {
            Ok(resp) => resp,
            Err(e) if attempt + 1 < SPOTIFY_WRITE_MAX_ATTEMPTS && (e.is_timeout() || e.is_connect()) => {
                last_error = e.to_string();
                tokio::time::sleep(spotify_retry_delay(attempt, None)).await;
                continue;
            }
            Err(e) => return Err(e.to_string()),
        };

        let status = resp.status().as_u16();
        if !resp.status().is_success() {
            let retry_after = resp.headers().get(reqwest::header::RETRY_AFTER).cloned();
            let b = resp.text().await.unwrap_or_default();
            if status == 401 {
                return Err(format!("SPOTIFY_AUTH_EXPIRED: {b}"));
            }
            if status == 403 {
                return Err(format!("SPOTIFY_FORBIDDEN: {b}"));
            }
            if status == 429 {
                if should_retry_transient_spotify_status(status, attempt, SPOTIFY_WRITE_MAX_ATTEMPTS, retry_after.as_ref()) {
                    last_error = format!("Spotify {status}: {b}");
                    tokio::time::sleep(spotify_retry_delay(attempt, retry_after.as_ref())).await;
                    continue;
                }
                return Err(spotify_record_write_rate_limit(state, method.as_str(), path, status, retry_after.as_ref()).await);
            }
            if should_retry_transient_spotify_status(status, attempt, SPOTIFY_WRITE_MAX_ATTEMPTS, retry_after.as_ref()) {
                last_error = format!("Spotify {status}: {b}");
                tokio::time::sleep(spotify_retry_delay(attempt, retry_after.as_ref())).await;
                continue;
            }
            return Err(format!("Spotify {status}: {b}"));
        }

        spotify_clear_write_rate_limit(state, path).await;
        return Ok(());
    }

    Err(format!("Spotify request failed after {SPOTIFY_WRITE_MAX_ATTEMPTS} attempts: {last_error}"))
}

async fn sp_no_body_retrying(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    method: reqwest::Method,
    path: &str,
) -> Result<(), String> {
    match sp_no_body(state, http, token, method.clone(), path).await {
        Err(e) if e.starts_with("SPOTIFY_AUTH_EXPIRED") => {
            let (refresh, client_id, data_dir) = {
                let g = state.lock().await;
                let tok = g.token.as_ref().ok_or("Not authenticated")?;
                (tok.refresh_token.clone(), g.cfg.client_id.clone(), g.data_dir.clone())
            };
            let (_, new_token) = auth_force_refresh(state, http, &client_id, &refresh, &data_dir).await?;
            sp_no_body(state, http, &new_token, method, path).await
        }
        other => other,
    }
}

/// Returns true if the error indicates "no active device" (Spotify 404 on a player endpoint).
fn is_no_device_error(e: &str) -> bool {
    e.starts_with("SPOTIFY_NO_DEVICE")
        || e.contains("Spotify 404")
        || e.contains("NO_ACTIVE_DEVICE")
}

/// Append `?device_id=` (or `&device_id=`) to a player API path.
fn with_device_query(path: &str, device_id: &Option<String>) -> String {
    match device_id {
        Some(id) if !id.is_empty() => {
            let sep = if path.contains('?') { '&' } else { '?' };
            format!("{path}{sep}device_id={id}")
        }
        _ => path.to_string(),
    }
}

/// Drop cached device id and resolve a fresh one.
async fn refresh_device(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
) -> Result<Option<String>, String> {
    state.lock().await.active_device_id = None;
    ensure_active_device_id(state, http, token).await
}

/// PUT against a player endpoint with automatic device-id injection and 404 recovery.
async fn put_player(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    base_path: &str,
    body: Option<serde_json::Value>,
) -> Result<(), String> {
    let dev = ensure_active_device_id(state, http, token).await?;
    let path = with_device_query(base_path, &dev);
    match sp_put_retrying(state, http, token, &path, body.clone()).await {
        Err(e) if is_no_device_error(&e) => {
            let dev2 = refresh_device(state, http, token).await?;
            let path2 = with_device_query(base_path, &dev2);
            sp_put_retrying(state, http, token, &path2, body).await
        }
        other => other,
    }
}

/// POST against a player endpoint with automatic device-id injection and 404 recovery.
async fn post_player(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    base_path: &str,
    body: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
    let dev = ensure_active_device_id(state, http, token).await?;
    let path = with_device_query(base_path, &dev);
    match sp_post_retrying(state, http, token, &path, body.clone()).await {
        Err(e) if is_no_device_error(&e) => {
            let dev2 = refresh_device(state, http, token).await?;
            let path2 = with_device_query(base_path, &dev2);
            sp_post_retrying(state, http, token, &path2, body).await
        }
        other => other,
    }
}

fn url_encode(s: &str) -> String {
    url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}

fn normalize_search_query(query: &str) -> Result<String, String> {
    let normalized = query.split_whitespace().collect::<Vec<_>>().join(" ");
    if normalized.is_empty() {
        return Err("Search query is empty".to_string());
    }
    Ok(normalized)
}

fn normalize_artist_lookup_name(name: &str) -> Result<String, String> {
    let cleaned = name.replace('"', " ");
    let normalized = normalize_search_query(&cleaned)?;
    if normalized.len() > 160 {
        return Err("Artist name is too long".to_string());
    }
    Ok(normalized)
}

fn validate_track_uri(uri: &str) -> Result<(), String> {
    let prefix = "spotify:track:";
    let Some(track_id) = uri.strip_prefix(prefix) else {
        return Err("Invalid track uri".to_string());
    };
    validate_spotify_id(track_id, "track")
}

fn html_entity(entity: &str) -> Option<char> {
    match entity {
        "amp" => Some('&'),
        "lt" => Some('<'),
        "gt" => Some('>'),
        "quot" => Some('"'),
        "apos" | "#39" => Some('\''),
        _ if entity.starts_with("#x") || entity.starts_with("#X") => {
            u32::from_str_radix(&entity[2..], 16).ok().and_then(char::from_u32)
        }
        _ if entity.starts_with('#') => {
            entity[1..].parse::<u32>().ok().and_then(char::from_u32)
        }
        _ => None,
    }
}

fn decode_html_entities(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    let mut rest = input;
    while let Some(amp) = rest.find('&') {
        out.push_str(&rest[..amp]);
        let after_amp = &rest[amp + 1..];
        if let Some(semi) = after_amp.find(';').filter(|idx| *idx <= 16) {
            let entity = &after_amp[..semi];
            if let Some(ch) = html_entity(entity) {
                out.push(ch);
                rest = &after_amp[semi + 1..];
                continue;
            }
        }
        out.push('&');
        rest = after_amp;
    }
    out.push_str(rest);
    rest_normalized(&out)
}

fn rest_normalized(input: &str) -> String {
    input.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn artist_name_key(input: &str) -> String {
    rest_normalized(input).to_ascii_lowercase()
}

fn spotify_track_matches_artist(item: &serde_json::Value, artist_id: &str, artist_name: &str) -> bool {
    item["artists"]
        .as_array()
        .map(|artists| {
            artists.iter().any(|artist| {
                artist["id"].as_str() == Some(artist_id)
                    || artist["name"]
                        .as_str()
                        .map(|name| artist_name_key(name) == artist_name)
                        .unwrap_or(false)
            })
        })
        .unwrap_or(false)
}

async fn get_user(http: &reqwest::Client, token: &str) -> Result<(String, String), String> {
    let resp = http
        .get(format!("{BASE}/me"))
        .bearer_auth(token)
        .timeout(Duration::from_secs(SPOTIFY_REQUEST_TIMEOUT_SECS))
        .send()
        .await
        .map_err(|e| e.to_string())?;
    let status = resp.status().as_u16();
    if !resp.status().is_success() {
        let body = resp.text().await.unwrap_or_default();
        if status == 401 {
            return Err(format!("SPOTIFY_AUTH_EXPIRED: {body}"));
        }
        if status == 403 {
            return Err(format!("SPOTIFY_FORBIDDEN: {body}"));
        }
        return Err(format!("Spotify {status}: {body}"));
    }
    let v = resp.json::<serde_json::Value>().await.map_err(|e| e.to_string())?;
    let user_id = v["id"].as_str().unwrap_or("").to_string();
    let display_name = v["display_name"]
        .as_str()
        .or_else(|| v["id"].as_str())
        .unwrap_or("Unknown")
        .to_string();
    Ok((user_id, display_name))
}

fn remember_token_identity(tok: &mut config::TokenInfo, user_id: &str, display_name: &str) {
    tok.cached_user_id = user_id.trim().to_string();
    tok.cached_display_name = display_name.trim().to_string();
}

fn fallback_token_identity(tok: &config::TokenInfo) -> (String, String) {
    let display_name = if tok.cached_display_name.trim().is_empty() {
        "Spotify".to_string()
    } else {
        tok.cached_display_name.trim().to_string()
    };
    (tok.cached_user_id.trim().to_string(), display_name)
}

async fn resolve_user_identity(tok: &mut config::TokenInfo, http: &reqwest::Client) -> Result<(String, String), String> {
    match get_user(http, &tok.access_token).await {
        Ok((user_id, display_name)) => {
            remember_token_identity(tok, &user_id, &display_name);
            Ok((user_id, display_name))
        }
        Err(err) if is_spotify_rate_limited_error(&err) => Ok(fallback_token_identity(tok)),
        Err(err) => Err(err),
    }
}

fn token_has_required_scopes(scope_str: &str) -> bool {
    let granted: std::collections::HashSet<&str> = scope_str.split_whitespace().collect();
    auth::SCOPES
        .split_whitespace()
        .all(|required| granted.contains(required))
}

async fn fetch_devices(http: &reqwest::Client, token: &str) -> Result<Vec<Device>, String> {
    let v = sp_get(http, token, "me/player/devices").await?;
    Ok(v["devices"]
        .as_array()
        .map(|a| {
            a.iter()
                .filter_map(|d| {
                    let id = d["id"].as_str()?.to_string();
                    if id.is_empty() {
                        return None;
                    }
                    Some(Device {
                        id,
                        name: d["name"].as_str().unwrap_or("Unknown").to_string(),
                        is_active: d["is_active"].as_bool().unwrap_or(false),
                    })
                })
                .collect()
        })
        .unwrap_or_default())
}

fn choose_preferred_device(devices: &[Device]) -> Option<Device> {
    // Lightify native device always takes priority — prevents hijacking phone/other devices
    devices
        .iter()
        .find(|d| d.name.to_ascii_lowercase().contains("lightify"))
        .cloned()
        .or_else(|| devices.iter().find(|d| d.is_active).cloned())
        .or_else(|| devices.first().cloned())
}

async fn ensure_active_device_id(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
) -> Result<Option<String>, String> {
    let cached = state.lock().await.active_device_id.clone();

    // ALWAYS re-fetch the live device list. The previous "cache forever" approach
    // caused a race at startup: if the user clicked Play before lightify-audio
    // finished registering with Spotify Connect (~5-10s), choose_preferred_device
    // fell back to the user's phone or web player and cached that id permanently —
    // playback would then route there silently, with the UI showing "playing" and
    // progress advancing while no audio reached the local machine.
    //
    // Re-fetching costs ~1 round-trip (<100ms) per command and lets a freshly-
    // registered "Lightify" device take over as soon as it appears.
    let devices = fetch_devices(http, token).await?;

    // Honour an explicit user choice (cmd_set_device) only if that device is
    // still in the device list AND it's either the Lightify device or no Lightify
    // device is currently online. If Lightify just came online and the cache
    // points elsewhere, switch to Lightify.
    let lightify = devices.iter().find(|d| d.name.to_ascii_lowercase().contains("lightify")).cloned();
    let cached_in_list = cached.as_ref()
        .and_then(|id| devices.iter().find(|d| &d.id == id).cloned());

    let chosen = match (lightify, cached_in_list) {
        (Some(l), _) => l,                                      // prefer Lightify when present
        (None, Some(c)) => c,                                   // honour cached if still present
        (None, None) => {
            let Some(d) = choose_preferred_device(&devices) else { return Ok(None) };
            d
        }
    };

    // Transfer only when switching to a device we have not already selected.
    // Spotify's `is_active` flag lags reality by several seconds, so transferring
    // every time it reads false — even though our device is actually playing —
    // fires spurious `play:false` transfers that hiccup playback. If the device
    // genuinely went away, put_player's no-device retry path re-resolves and
    // transfers then. The transfer error is logged rather than silently dropped.
    let already_selected = cached.as_deref() == Some(chosen.id.as_str());
    if !chosen.is_active && !already_selected {
        match sp_put(
            state,
            http,
            token,
            "me/player",
            Some(serde_json::json!({ "device_ids": [chosen.id], "play": false })),
        ).await {
            Ok(_) => push_diag(state, "info", "device", format!("transferred playback to {} ({})", chosen.name, chosen.id)).await,
            Err(e) => {
                eprintln!("[lightify] device transfer to {} failed: {}", chosen.id, e);
                push_diag(state, "warn", "device", format!("transfer to {} failed: {}", chosen.id, e)).await;
            }
        }
    }

    {
        let mut g = state.lock().await;
        g.active_device_id = Some(chosen.id.clone());
    }

    Ok(Some(chosen.id))
}

fn native_audio_candidates(data_dir: &std::path::Path, resource_dir: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
    let mut paths = Vec::new();

    // Tauri bundled resource dir (highest priority in release builds)
    if let Some(rd) = resource_dir {
        push_unique_path(&mut paths, rd.join("lightify-audio.exe"));
        push_unique_path(
            &mut paths,
            rd.join("_up_")
                .join("_up_")
                .join("lightify-audio")
                .join("target")
                .join("release")
                .join("lightify-audio.exe"),
        );
        push_unique_path(
            &mut paths,
            rd.join("lightify-audio")
                .join("target")
                .join("release")
                .join("lightify-audio.exe"),
        );
    }

    if let Ok(exe_path) = std::env::current_exe() {
        if let Some(exe_dir) = exe_path.parent() {
            push_unique_path(&mut paths, exe_dir.join("lightify-audio.exe"));
            push_unique_path(&mut paths, exe_dir.join("resources").join("lightify-audio.exe"));
        }
    }

    push_unique_path(&mut paths, data_dir.join("lightify-audio.exe"));

    if let Some(workspace_root) = dev_workspace_root() {
        let release_audio = workspace_root
            .join("lightify-audio")
            .join("target")
            .join("release")
            .join("lightify-audio.exe");
        let debug_audio = workspace_root
            .join("lightify-audio")
            .join("target")
            .join("debug")
            .join("lightify-audio.exe");

        if cfg!(debug_assertions) {
            push_unique_path(&mut paths, debug_audio);
            push_unique_path(&mut paths, release_audio);
        } else {
            push_unique_path(&mut paths, release_audio);
            push_unique_path(&mut paths, debug_audio);
        }
    }

    paths
}

fn find_native_audio_binary(data_dir: &std::path::Path, resource_dir: Option<&std::path::Path>) -> Option<std::path::PathBuf> {
    native_audio_candidates(data_dir, resource_dir)
        .into_iter()
        .find(|p| p.is_file())
}

fn child_running(child: &mut Child) -> bool {
    matches!(child.try_wait(), Ok(None))
}

fn stop_native_audio_child(g: &mut AppInner) {
    if let Some(stdin) = g.native_audio_stdin.as_mut() {
        let _ = writeln!(stdin, "{}", r#"{"cmd":"quit"}"#);
        let _ = stdin.flush();
    }
    g.native_audio_stdin = None;
    if let Some(mut child) = g.native_audio.take() {
        if child_running(&mut child) {
            let _ = child.kill();
        }
        let _ = child.wait();
    }
}

fn stop_onthespot_child(g: &mut AppInner) {
    g.onthespot_web_started_at = None;
    g.onthespot_synced_audio_download_path = None;
    if let Some(mut child) = g.onthespot_web.take() {
        if child_running(&mut child) {
            let _ = child.kill();
        }
        let _ = child.wait();
    }
}

fn stop_managed_children(g: &mut AppInner) {
    stop_native_audio_child(g);
    stop_onthespot_child(g);
}

/// Best-effort: forward a JSON command line to the running native audio engine's
/// stdin. No-ops silently when the engine isn't running. Used for controls that the
/// Web API can't reliably apply to the local librespot device (e.g. repeat, since
/// spirc owns track advancement).
async fn send_native_audio_command(state: &AppState, line: &str) {
    let mut g = state.lock().await;
    if let Some(stdin) = g.native_audio_stdin.as_mut() {
        let _ = writeln!(stdin, "{}", line);
        let _ = stdin.flush();
    }
}

fn prepare_log_file(path: &Path) {
    let _ = std::fs::File::create(path);
}

fn start_line_capture_thread<R: std::io::Read + Send + 'static>(
    reader: R,
    log_path: PathBuf,
    ring_buffer: Arc<std::sync::Mutex<VecDeque<String>>>,
    prefix: &'static str,
) {
    std::thread::spawn(move || {
        use std::io::Write;

        let reader = std::io::BufReader::new(reader);
        let mut sink = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&log_path)
            .ok();

        for line in reader.lines().flatten() {
            let formatted = if prefix.is_empty() {
                line
            } else {
                format!("[{prefix}] {line}")
            };

            if let Some(file) = sink.as_mut() {
                let _ = writeln!(file, "{}", formatted);
                let _ = file.flush();
            }

            let mut recent = ring_buffer.lock().unwrap();
            if recent.len() >= DEBUG_LOG_LINE_LIMIT {
                recent.pop_front();
            }
            recent.push_back(formatted);
        }
    });
}

fn recent_buffer_lines(
    ring_buffer: &Arc<std::sync::Mutex<VecDeque<String>>>,
    max_lines: usize,
) -> Vec<String> {
    let recent = ring_buffer.lock().unwrap();
    recent
        .iter()
        .skip(recent.len().saturating_sub(max_lines))
        .cloned()
        .collect()
}

const ONTHESPOT_PORT: u16 = 57321;
const ONTHESPOT_READY_POLL_STEPS: usize = 40;
const ONTHESPOT_READY_POLL_DELAY_MS: u64 = 250;

#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;

fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
    if !path.as_os_str().is_empty() && !paths.iter().any(|existing| existing == &path) {
        paths.push(path);
    }
}

fn find_path_binary(name: &str) -> Option<PathBuf> {
    std::env::var_os("PATH").and_then(|value| {
        std::env::split_paths(&value)
            .map(|dir| dir.join(name))
            .find(|path| path.is_file())
    })
}

fn dev_workspace_root() -> Option<PathBuf> {
    let mut candidates = Vec::new();

    if let Some(explicit) = std::env::var_os("LIGHTIFY_DEV_WORKSPACE") {
        push_unique_path(&mut candidates, PathBuf::from(explicit));
    }

    if let Ok(exe_path) = std::env::current_exe() {
        for ancestor in exe_path.ancestors().skip(1) {
            push_unique_path(&mut candidates, ancestor.to_path_buf());
        }
    }

    if cfg!(debug_assertions) {
        if let Ok(cwd) = std::env::current_dir() {
            for ancestor in cwd.ancestors() {
                push_unique_path(&mut candidates, ancestor.to_path_buf());
            }
        }
    }

    candidates.into_iter().find(|root| {
        root.join("lightify-tauri").join("src-tauri").join("Cargo.toml").is_file()
            && root.join("lightify-audio").join("Cargo.toml").is_file()
    })
}

fn dev_onthespot_source_dir(workspace_root: &Path) -> PathBuf {
    workspace_root
        .join("lightify-tauri")
        .join("docs")
        .join("onthespot-master")
        .join("onthespot-master")
        .join("src")
}

fn dev_onthespot_python_command(workspace_root: Option<&Path>) -> PathBuf {
    if let Some(root) = workspace_root {
        let venv_python = if cfg!(target_os = "windows") {
            root.join(".venv").join("Scripts").join("python.exe")
        } else {
            root.join(".venv").join("bin").join("python")
        };
        if venv_python.is_file() {
            return venv_python;
        }
    }

    if cfg!(target_os = "windows") {
        if let Some(path) = find_path_binary("python.exe")
            .or_else(|| find_path_binary("python"))
            .or_else(|| find_path_binary("py.exe"))
            .or_else(|| find_path_binary("py"))
        {
            return path;
        }
    } else if let Some(path) = find_path_binary("python3").or_else(|| find_path_binary("python")) {
        return path;
    }

    PathBuf::from("python")
}

fn onthespot_root_url() -> String {
    format!("http://127.0.0.1:{ONTHESPOT_PORT}")
}

fn onthespot_http() -> Result<reqwest::Client, String> {
    reqwest::Client::builder()
        .user_agent("Lightify/2.0")
        .redirect(reqwest::redirect::Policy::none())
        .timeout(Duration::from_secs(10))
        .build()
        .map_err(|e| format!("Failed to build OnTheSpot client: {e}"))
}

async fn onthespot_session_cookie(client: &reqwest::Client) -> Result<String, String> {
    let response = client
        .get(format!("{}/login", onthespot_root_url()))
        .send()
        .await
        .map_err(|e| format!("OnTheSpot login failed: {e}"))?;
    if !(response.status().is_success() || response.status().is_redirection()) {
        return Err(format!("OnTheSpot login failed with status {}", response.status()));
    }
    let cookie = response
        .headers()
        .get_all(reqwest::header::SET_COOKIE)
        .iter()
        .filter_map(|value| value.to_str().ok())
        .filter_map(|raw| raw.split(';').next())
        .map(str::trim)
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join("; ");
    if cookie.is_empty() {
        return Err("OnTheSpot login did not return a session cookie".to_string());
    }
    Ok(cookie)
}

async fn onthespot_ping() -> bool {
    let Ok(client) = onthespot_http() else {
        return false;
    };
    client
        .get(format!("{}/login", onthespot_root_url()))
        .send()
        .await
        .map(|response| response.status().is_success() || response.status().is_redirection())
        .unwrap_or(false)
}

fn default_download_path() -> String {
    dirs::audio_dir()
        .or_else(|| dirs::home_dir().map(|h| h.join("Music")))
        .map(|d| d.join("LightifyDownloads").to_string_lossy().into_owned())
        .unwrap_or_else(|| "LightifyDownloads".to_string())
}

fn onthespot_runtime_dir(lightify_data_dir: &Path) -> PathBuf {
    if let Some(appdata) = std::env::var_os("APPDATA") {
        let path = PathBuf::from(appdata).join("onthespot");
        if !path.as_os_str().is_empty() {
            return path;
        }
    }

    if let Some(config_dir) = dirs::config_dir() {
        let path = config_dir.join("onthespot");
        if !path.as_os_str().is_empty() {
            return path;
        }
    }

    lightify_data_dir.join("onthespot")
}

fn resolved_download_path(configured_path: &str) -> String {
    let trimmed = configured_path.trim();
    if trimmed.is_empty() {
        default_download_path()
    } else {
        trimmed.to_string()
    }
}

async fn configured_download_path(state: &AppState) -> String {
    let configured = state.lock().await.cfg.download_path.clone();
    resolved_download_path(&configured)
}

fn onthespot_download_settings_payload(path: &str) -> serde_json::Value {
    serde_json::json!({
        "audio_download_path": path,
        // Keep the legacy key in sync as an informational field for existing configs.
        "download_location": path,
    })
}

async fn sync_onthespot_download_path(state: &AppState, force: bool) -> Result<String, String> {
    let path = configured_download_path(state).await;

    if !force {
        let already_synced = {
            let g = state.lock().await;
            g.onthespot_synced_audio_download_path.as_deref() == Some(path.as_str())
        };
        if already_synced {
            return Ok(path);
        }
    }

    std::fs::create_dir_all(&path)
        .map_err(|e| format!("Failed to create download directory {}: {e}", path))?;

    let client = onthespot_http()?;
    let cookie = onthespot_session_cookie(&client).await?;
    client
        .post(format!("{}/api/update_settings", onthespot_root_url()))
        .header(reqwest::header::COOKIE, cookie)
        .json(&onthespot_download_settings_payload(&path))
        .send()
        .await
        .map_err(|e| format!("Failed to sync OnTheSpot download path: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Failed to sync OnTheSpot download path: {e}"))?;

    state.lock().await.onthespot_synced_audio_download_path = Some(path.clone());
    Ok(path)
}

fn downloader_status_from_json(value: serde_json::Value) -> DownloaderStatus {
    DownloaderStatus {
        spotify_ready: value["spotify_ready"].as_bool().unwrap_or(false),
        spotify_login_in_progress: value["spotify_login_in_progress"].as_bool().unwrap_or(false),
        spotify_login_message: value["spotify_login_message"].as_str().unwrap_or_default().to_string(),
        spotify_login_error: value["spotify_login_error"].as_str().unwrap_or_default().to_string(),
        spotify_configured_accounts: value["spotify_configured_accounts"].as_u64().unwrap_or(0) as u32,
    }
}

async fn fetch_downloader_status(state: &AppState) -> Result<DownloaderStatus, String> {
    ensure_onthespot_web(state).await?;
    let client = onthespot_http()?;
    let cookie = onthespot_session_cookie(&client).await?;
    let value: serde_json::Value = client
        .get(format!("{}/api/lightify/status", onthespot_root_url()))
        .header(reqwest::header::COOKIE, cookie)
        .send()
        .await
        .map_err(|e| format!("Failed to check downloader status: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Failed to check downloader status: {e}"))?
        .json()
        .await
        .map_err(|e| format!("Failed to parse downloader status: {e}"))?;
    Ok(downloader_status_from_json(value))
}

async fn start_downloader_spotify_login(state: &AppState) -> Result<DownloaderStatus, String> {
    ensure_onthespot_web(state).await?;
    let client = onthespot_http()?;
    let cookie = onthespot_session_cookie(&client).await?;
    let value: serde_json::Value = client
        .post(format!("{}/api/lightify/start_spotify_login", onthespot_root_url()))
        .header(reqwest::header::COOKIE, cookie)
        .send()
        .await
        .map_err(|e| format!("Failed to start downloader login: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Failed to start downloader login: {e}"))?
        .json()
        .await
        .map_err(|e| format!("Failed to parse downloader login status: {e}"))?;
    Ok(downloader_status_from_json(value))
}

fn find_onthespot_bridge(resource_dir: Option<&Path>) -> Option<PathBuf> {
    let exe_name = if cfg!(target_os = "windows") { "lightify-onthespot.exe" } else { "lightify-onthespot" };

    if let Some(rd) = resource_dir {
        let p = rd.join(exe_name);
        if p.is_file() {
            return Some(p);
        }
        let p = rd
            .join("target")
            .join("pyinstaller")
            .join("release")
            .join("dist")
            .join(exe_name);
        if p.is_file() {
            return Some(p);
        }
    }

    if let Ok(exe_path) = std::env::current_exe() {
        if let Some(exe_dir) = exe_path.parent() {
            if let Some(profile_dir) = exe_dir.parent() {
                let p = profile_dir.join("pyinstaller").join("release").join("dist").join(exe_name);
                if p.is_file() { return Some(p); }
            }
            // Next to current exe: installed builds and `cargo build --release` output dir.
            let p = exe_dir.join(exe_name);
            if p.is_file() { return Some(p); }
            // Tauri resources subdir (some packaging configurations).
            let p = exe_dir.join("resources").join(exe_name);
            if p.is_file() { return Some(p); }
        }
    }
    None
}

fn find_ffmpeg_binary(resource_dir: Option<&Path>) -> Option<PathBuf> {
    let name = if cfg!(target_os = "windows") { "ffmpeg.exe" } else { "ffmpeg" };

    if let Some(rd) = resource_dir {
        let p = rd.join(name);
        if p.is_file() {
            return Some(p);
        }
        let p = rd.join("target").join("release").join(name);
        if p.is_file() {
            return Some(p);
        }
    }

    if let Ok(exe_path) = std::env::current_exe() {
        if let Some(exe_dir) = exe_path.parent() {
            let p = exe_dir.join(name);
            if p.is_file() { return Some(p); }
            let p = exe_dir.join("resources").join(name);
            if p.is_file() { return Some(p); }
        }
    }

    find_path_binary(name)
}

async fn ensure_onthespot_web(state: &AppState) -> Result<(), String> {
    if onthespot_ping().await {
        sync_onthespot_download_path(state, false).await?;
        return Ok(());
    }

    let should_spawn = {
        let mut g = state.lock().await;
        if let Some(child) = g.onthespot_web.as_mut() {
            if !child_running(child) {
                stop_onthespot_child(&mut g);
            } else {
                let startup_stale = g
                    .onthespot_web_started_at
                    .map(|started| started.elapsed() >= Duration::from_millis(ONTHESPOT_READY_POLL_STEPS as u64 * ONTHESPOT_READY_POLL_DELAY_MS))
                    .unwrap_or(false);
                if startup_stale {
                    stop_onthespot_child(&mut g);
                }
            }
        }
        g.onthespot_web.is_none()
    };

    if should_spawn {
        let (data_dir, resource_dir, stderr_buf) = {
            let g = state.lock().await;
            (
                g.data_dir.clone(),
                g.resource_dir.clone(),
                Arc::clone(&g.onthespot_web_stderr),
            )
        };
        stderr_buf.lock().unwrap().clear();
        let log_path = data_dir.join("onthespot-web.log");
        prepare_log_file(&log_path);
        let onthespot_dir = onthespot_runtime_dir(&data_dir);
        std::fs::create_dir_all(&onthespot_dir)
            .map_err(|e| format!("Failed to create OnTheSpot data dir {}: {e}", onthespot_dir.display()))?;

        if let Some(bridge_exe) = find_onthespot_bridge(resource_dir.as_deref()) {
            // Prefer the packaged standalone bridge exe (works in installed and dev builds).
            let mut command = Command::new(&bridge_exe);
            command
                .arg("--host")
                .arg("127.0.0.1")
                .arg("--port")
                .arg(ONTHESPOT_PORT.to_string())
                .stdin(Stdio::null())
                .stdout(Stdio::piped())
                .stderr(Stdio::piped());
            command.env("ONTHESPOTDIR", &onthespot_dir);
            if let Some(ffmpeg) = find_ffmpeg_binary(resource_dir.as_deref()) {
                command.env("FFMPEG_PATH", ffmpeg);
            }
            #[cfg(target_os = "windows")]
            command.creation_flags(CREATE_NO_WINDOW);

            let mut child = command
                .spawn()
                .map_err(|e| format!("Failed to start OnTheSpot bridge at {}: {e}", bridge_exe.display()))?;

            if let Some(stdout) = child.stdout.take() {
                start_line_capture_thread(stdout, log_path.clone(), Arc::clone(&stderr_buf), "stdout");
            }
            if let Some(stderr) = child.stderr.take() {
                start_line_capture_thread(stderr, log_path, Arc::clone(&stderr_buf), "stderr");
            }

            let mut g = state.lock().await;
            g.onthespot_web_started_at = Some(std::time::Instant::now());
            g.onthespot_web = Some(child);
        } else {
            // Fallback for local development checkouts when the packaged bridge is absent.
            let dev_workspace = dev_workspace_root();
            let source_dir = dev_workspace
                .as_deref()
                .map(dev_onthespot_source_dir)
                .filter(|path| path.is_dir())
                .ok_or_else(|| {
                    "OnTheSpot bridge not found and no local Lightify dev checkout was discovered."
                        .to_string()
                })?;
            let python = dev_onthespot_python_command(dev_workspace.as_deref());

            let mut command = Command::new(&python);
            command
                .arg("-m")
                .arg("onthespot.web")
                .arg("--host")
                .arg("127.0.0.1")
                .arg("--port")
                .arg(ONTHESPOT_PORT.to_string())
                .env("PYTHONPATH", source_dir.as_os_str())
                .stdin(Stdio::null())
                .stdout(Stdio::piped())
                .stderr(Stdio::piped());
            if let Some(root) = dev_workspace.as_deref() {
                command.current_dir(root);
            }
            command.env("ONTHESPOTDIR", &onthespot_dir);
            if let Some(ffmpeg) = find_ffmpeg_binary(resource_dir.as_deref()) {
                command.env("FFMPEG_PATH", ffmpeg);
            }
            #[cfg(target_os = "windows")]
            command.creation_flags(CREATE_NO_WINDOW);

            let mut child = command
                .spawn()
                .map_err(|e| format!("Failed to start OnTheSpot bridge with {}: {e}", python.display()))?;

            if let Some(stdout) = child.stdout.take() {
                start_line_capture_thread(stdout, log_path.clone(), Arc::clone(&stderr_buf), "stdout");
            }
            if let Some(stderr) = child.stderr.take() {
                start_line_capture_thread(stderr, log_path, Arc::clone(&stderr_buf), "stderr");
            }

            let mut g = state.lock().await;
            g.onthespot_web_started_at = Some(std::time::Instant::now());
            g.onthespot_web = Some(child);
        }
    }

    for _ in 0..ONTHESPOT_READY_POLL_STEPS {
        if onthespot_ping().await {
            sync_onthespot_download_path(state, true).await?;
            return Ok(());
        }
        tokio::time::sleep(Duration::from_millis(ONTHESPOT_READY_POLL_DELAY_MS)).await;
    }

    let (stderr_buf, log_path) = {
        let g = state.lock().await;
        (Arc::clone(&g.onthespot_web_stderr), g.data_dir.join("onthespot-web.log"))
    };
    {
        let mut g = state.lock().await;
        stop_onthespot_child(&mut g);
    }
    let tail = recent_buffer_lines(&stderr_buf, 12);
    let tail_msg = if tail.is_empty() {
        format!(" See {} for bridge logs.", log_path.display())
    } else {
        format!(
            " Last bridge log lines:\n{}\nSee {} for the full bridge log.",
            tail.join("\n"),
            log_path.display()
        )
    };

    Err(format!(
        "OnTheSpot bridge did not become ready at {}.{}",
        onthespot_root_url(),
        tail_msg
    ))
}

fn normalize_download_url(input: &str) -> Result<String, String> {
    let parsed = url::Url::parse(input).map_err(|_| "Invalid download URL".to_string())?;
    if parsed.scheme() != "https" {
        return Err("Download URL must use https".to_string());
    }
    let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
    if host != "open.spotify.com" {
        return Err("Only open.spotify.com download URLs are supported".to_string());
    }
    let segments = parsed
        .path_segments()
        .map(|parts| parts.filter(|part| !part.is_empty()).collect::<Vec<_>>())
        .unwrap_or_default();
    if segments.as_slice() == ["collection", "tracks"] {
        return Ok("https://open.spotify.com/collection/tracks".to_string());
    }
    if segments.len() < 2 {
        return Err("Unsupported Spotify download URL".to_string());
    }
    let kind = segments[0];
    let id = segments[1];
    match kind {
        "track" | "playlist" | "album" | "artist" | "show" | "episode" => {
            validate_spotify_id(id, kind)?;
            Ok(format!("https://open.spotify.com/{kind}/{id}"))
        }
        _ => Err(format!("Unsupported Spotify download URL: {kind}")),
    }
}

fn json_text_field(object: &serde_json::Map<String, serde_json::Value>, key: &str) -> String {
    object
        .get(key)
        .and_then(|value| value.as_str())
        .unwrap_or_default()
        .to_string()
}

fn json_u32_field(object: &serde_json::Map<String, serde_json::Value>, key: &str) -> u32 {
    object
        .get(key)
        .and_then(|value| value.as_u64().or_else(|| value.as_f64().map(|number| number as u64)))
        .unwrap_or(0) as u32
}

fn json_bool_field(object: &serde_json::Map<String, serde_json::Value>, key: &str) -> bool {
    object.get(key).and_then(|value| value.as_bool()).unwrap_or(false)
}

fn download_queue_item_from_json(object: &serde_json::Map<String, serde_json::Value>) -> DownloadQueueItem {
    DownloadQueueItem {
        local_id: json_text_field(object, "local_id"),
        available: json_bool_field(object, "available"),
        item_service: json_text_field(object, "item_service"),
        item_type: json_text_field(object, "item_type"),
        item_id: json_text_field(object, "item_id"),
        item_status: json_text_field(object, "item_status"),
        item_name: json_text_field(object, "item_name"),
        item_by: json_text_field(object, "item_by"),
        parent_category: json_text_field(object, "parent_category"),
        playlist_name: json_text_field(object, "playlist_name"),
        playlist_by: json_text_field(object, "playlist_by"),
        playlist_number: json_u32_field(object, "playlist_number"),
        item_thumbnail: json_text_field(object, "item_thumbnail"),
        item_url: json_text_field(object, "item_url"),
        file_path: json_text_field(object, "file_path"),
        progress: json_u32_field(object, "progress"),
        error: json_text_field(object, "error"),
    }
}

// ── Tauri commands ──────────────────────────────────────────────────────────

#[tauri::command]
async fn get_config(state: tauri::State<'_, AppState>) -> Result<config::Config, String> {
    Ok(state.lock().await.cfg.clone())
}

#[tauri::command]
async fn try_cached_auth(state: tauri::State<'_, AppState>) -> Result<String, String> {
    let (data_dir, http, client_id) = {
        let g = state.lock().await;
        (g.data_dir.clone(), g.http.clone(), g.cfg.client_id.clone())
    };
    let tok = config::load_token(&data_dir).ok_or("No cached token")?;
    if !token_has_required_scopes(&tok.scope) {
        config::delete_token(&data_dir);
        return Err("Cached token missing required permissions. Please log in again.".into());
    }

    let tok = if tok.is_expired() {
        let mut new_tok = auth::refresh_access_token(&http, &client_id, &tok.refresh_token).await?;
        new_tok.cached_user_id = tok.cached_user_id.clone();
        new_tok.cached_display_name = tok.cached_display_name.clone();
        if !token_has_required_scopes(&new_tok.scope) {
            config::delete_token(&data_dir);
            return Err("Refreshed token missing required permissions. Please log in again.".into());
        }
        new_tok
    } else {
        tok
    };
    let mut tok = tok;
    let (user_id, display_name) = resolve_user_identity(&mut tok, &http).await?;
    config::save_token(&data_dir, &tok);
    {
        let mut g = state.lock().await;
        g.token = Some(tok);
        g.user_id = user_id;
    }
    Ok(display_name)
}

#[tauri::command]
async fn start_auth(client_id: String, state: tauri::State<'_, AppState>) -> Result<String, String> {
    {
        let mut g = state.lock().await;
        g.cfg.client_id = client_id.clone();
        config::save_config(&g.data_dir, &g.cfg);
    }
    let pkce = auth::PkceAuth::new(&client_id);
    let url = pkce.auth_url();
    {
        let mut g = state.lock().await;
        g.pending_auth = Some(pkce);
    }
    open::that(&url).map_err(|e| format!("Failed to open browser: {e}"))?;
    let code = auth::wait_for_callback(300).await?;
    let (http, pkce_moved) = {
        let mut g = state.lock().await;
        let p = g.pending_auth.take().ok_or("No pending auth")?;
        (g.http.clone(), p)
    };
    let mut tok = pkce_moved.exchange_code(&http, &code).await?;
    if !token_has_required_scopes(&tok.scope) {
        return Err("Spotify did not grant required permissions. Please approve all scopes and try again.".into());
    }
    let (user_id, display_name) = resolve_user_identity(&mut tok, &http).await?;
    {
        let mut g = state.lock().await;
        config::save_token(&g.data_dir, &tok);
        g.token = Some(tok);
        g.user_id = user_id;
    }
    Ok(display_name)
}

#[tauri::command]
async fn cmd_retry_auth_browser(state: tauri::State<'_, AppState>) -> Result<(), String> {
    let url = {
        let g = state.lock().await;
        let pending = g.pending_auth.as_ref().ok_or("No pending authorisation to retry. Click Authorise first.")?;
        pending.auth_url()
    };
    open::that(&url).map_err(|e| format!("Failed to open browser: {e}"))?;
    Ok(())
}

#[tauri::command]
fn cmd_open_spotify_dashboard() -> Result<(), String> {
    open::that("https://developer.spotify.com/dashboard")
        .map_err(|e| format!("Failed to open browser: {e}"))?;
    Ok(())
}

async fn poll_playback_internal(state: &AppState) -> Result<Option<PlaybackState>, String> {
    let (http, token) = auth_token(&state).await?;
    let v = sp_get_retrying(&state, &http, &token, "me/player").await?;
    if v.is_null() { return Ok(None); }
    let pb = spotify::parse_playback(&v);
    if !pb.device_id.is_empty() {
        let mut g = state.lock().await;
        // Only cache the device if nothing is selected yet, or if Lightify is now playing.
        // Prevents the phone (or any non-Lightify device) from stomping the selected device.
        // Only adopt the Lightify device from polling. Seeding from any active
        // device (e.g. the user's phone before lightify-audio finishes
        // registering) lets a non-Lightify device win the startup race and route
        // playback there; device selection is owned by ensure_active_device_id.
        let is_lightify = pb.device_name.to_ascii_lowercase().contains("lightify");
        if is_lightify {
            g.active_device_id = Some(pb.device_id.clone());
        }
    }
    Ok(Some(pb))
}

#[tauri::command]
async fn poll_playback(state: tauri::State<'_, AppState>) -> Result<Option<PlaybackState>, String> {
    poll_playback_internal(&state).await
}

async fn play_transport(state: &AppState) -> Result<(), String> {
    let (http, token) = auth_token(state).await?;
    put_player(state, &http, &token, "me/player/play", None).await
}

async fn pause_transport(state: &AppState) -> Result<(), String> {
    let (http, token) = auth_token(state).await?;
    put_player(state, &http, &token, "me/player/pause", None).await
}

async fn next_transport(state: &AppState) -> Result<(), String> {
    let (http, token) = auth_token(state).await?;
    post_player(state, &http, &token, "me/player/next", None).await.map(|_| ())
}

async fn prev_transport(state: &AppState) -> Result<(), String> {
    let (http, token) = auth_token(state).await?;
    post_player(state, &http, &token, "me/player/previous", None).await.map(|_| ())
}

async fn toggle_transport(state: &AppState) -> Result<(), String> {
    match poll_playback_internal(state).await? {
        Some(pb) if pb.is_playing => pause_transport(state).await,
        _ => play_transport(state).await,
    }
}

#[tauri::command]
async fn cmd_play(state: tauri::State<'_, AppState>) -> Result<(), String> {
    play_transport(&state).await
}

#[tauri::command]
async fn cmd_pause(state: tauri::State<'_, AppState>) -> Result<(), String> {
    pause_transport(&state).await
}

#[tauri::command]
async fn cmd_next(state: tauri::State<'_, AppState>) -> Result<(), String> {
    next_transport(&state).await
}

#[tauri::command]
async fn cmd_prev(state: tauri::State<'_, AppState>) -> Result<(), String> {
    prev_transport(&state).await
}

#[tauri::command]
async fn cmd_seek(position_ms: u64, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let (http, token) = auth_token(&state).await?;
    put_player(&state, &http, &token, &format!("me/player/seek?position_ms={position_ms}"), None).await
}

#[tauri::command]
async fn cmd_volume(percent: u32, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let (http, token) = auth_token(&state).await?;
    put_player(&state, &http, &token, &format!("me/player/volume?volume_percent={percent}"), None).await
}

#[tauri::command]
async fn cmd_shuffle(enabled: bool, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let (http, token) = auth_token(&state).await?;
    put_player(&state, &http, &token, &format!("me/player/shuffle?state={enabled}"), None).await
}

#[tauri::command]
async fn cmd_repeat(mode: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    // Drive the native Lightify device directly — librespot's spirc owns track
    // advancement, so repeat only actually loops when set on the engine. Accept only
    // the known modes to avoid forwarding junk to the engine / Web API.
    let mode = match mode.as_str() {
        "track" | "context" | "off" => mode,
        _ => "off".to_string(),
    };
    let engine_line = serde_json::json!({ "cmd": "repeat", "mode": mode }).to_string();
    send_native_audio_command(&state, &engine_line).await;
    // The Web-API call keeps any other active device in sync; its failure (e.g. no
    // active non-Lightify device) is non-fatal since the engine is the authority here.
    if let Ok((http, token)) = auth_token(&state).await {
        let _ = put_player(&state, &http, &token, &format!("me/player/repeat?state={mode}"), None).await;
    }
    Ok(())
}

/// Blip gate: hand the native engine the set of sanctioned track ids for the local
/// queue. The engine mutes any track it decodes that isn't in this set (a stale
/// Spotify user-queue interloper). An empty list turns gating off (fail open). Only
/// the frontend knows the arbitrary queue, so it drives this; the host just relays.
#[tauri::command]
async fn cmd_set_expected_tracks(ids: Vec<String>, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let line = serde_json::json!({ "cmd": "expect", "ids": ids }).to_string();
    send_native_audio_command(&state, &line).await;
    Ok(())
}

/// #5 station: toggle spirc autoplay on the native engine. On = Spotify resolves its
/// algorithmic radio continuation when a context runs low (used only while a station
/// is active); off = normal Lightify-owned playback. Relayed to the engine stdin.
#[tauri::command]
async fn cmd_set_autoplay(enabled: bool, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let line = serde_json::json!({ "cmd": "autoplay", "enabled": enabled }).to_string();
    send_native_audio_command(&state, &line).await;
    Ok(())
}

/// Transport straight to the native engine (spirc), bypassing the Web API.
///
/// While a station is active, spirc owns the queue: the station context lives in the
/// engine's connect state, not in anything the Web API player endpoints can address
/// correctly. Routing play/pause/next/prev through `me/player/*` there just fights the
/// engine (device re-resolution, transfers with `play:false`) and leaves the station
/// queue untouched — which is why the controls appeared dead during a station.
#[tauri::command]
async fn cmd_engine_transport(action: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let action = match action.as_str() {
        "play" | "pause" | "next" | "prev" | "stop" => action,
        other => return Err(format!("Unsupported engine transport action: {other}")),
    };
    send_native_audio_command(&state, &serde_json::json!({ "cmd": action }).to_string()).await;
    Ok(())
}

/// Seek on the native engine (see cmd_engine_transport for why).
#[tauri::command]
async fn cmd_engine_seek(position_ms: u32, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let line = serde_json::json!({ "cmd": "seek", "position_ms": position_ms }).to_string();
    send_native_audio_command(&state, &line).await;
    Ok(())
}

/// Set volume on the native engine (see cmd_engine_transport for why).
#[tauri::command]
async fn cmd_engine_volume(percent: u16, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let line = serde_json::json!({ "cmd": "volume", "percent": percent.min(100) }).to_string();
    send_native_audio_command(&state, &line).await;
    Ok(())
}

/// #5 station: ask the native engine to start Spotify's real radio for a seed context
/// (`spotify:track:…` = song radio). The engine clears the stale Connect queue first,
/// so a station never inherits leftover queued tracks (duplicates / skip-back loops).
#[tauri::command]
async fn cmd_start_native_station(context_uri: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let uri = context_uri.trim();
    if !uri.starts_with("spotify:") {
        return Err("Station needs a Spotify context uri".to_string());
    }
    let line = serde_json::json!({ "cmd": "station", "context_uri": uri }).to_string();
    send_native_audio_command(&state, &line).await;
    Ok(())
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
    pub id: String,
    pub name: String,
    pub is_active: bool,
}

#[tauri::command]
async fn cmd_get_devices(state: tauri::State<'_, AppState>) -> Result<Vec<Device>, String> {
    let (http, token) = auth_token(&state).await?;
    fetch_devices(&http, &token).await
}

#[tauri::command]
async fn cmd_set_device(device_id: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let (http, token) = auth_token(&state).await?;
    sp_put_retrying(
        &state,
        &http,
        &token,
        "me/player",
        Some(serde_json::json!({ "device_ids": [device_id] })),
    ).await?;
    state.lock().await.active_device_id = Some(device_id);
    Ok(())
}

#[tauri::command]
async fn cmd_start_native_audio(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<String, String> {
    let (_, token) = auth_token(&state).await?;

    let (data_dir, audio_output) = {
        let mut g = state.lock().await;
        if let Some(child) = g.native_audio.as_mut() {
            if child_running(child) {
                return Ok("Native audio backend already running".to_string());
            }
        }
        g.native_audio = None;
        g.native_audio_stdin = None;
        (g.data_dir.clone(), g.cfg.audio_output.clone())
    };

    let resource_dir = app.path().resource_dir().ok();
    let bin = find_native_audio_binary(&data_dir, resource_dir.as_deref())
        .ok_or("Native audio backend not found. Build lightify-audio and place lightify-audio.exe next to the app.")?;

    if !audio_output.is_empty() && audio_output.to_ascii_lowercase() != "default" {
        let outputs = current_audio_outputs()?;
        if !outputs.iter().any(|name| name == &audio_output) {
            return Err(format!(
                "Configured audio output '{}' is not available. Available outputs: {}",
                audio_output,
                outputs.join(", ")
            ));
        }
    }

    let cache_dir = data_dir.join("lightify-audio-cache");
    std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;

    let mut cmd = Command::new(&bin);
    if !audio_output.is_empty() && audio_output.to_ascii_lowercase() != "default" {
        cmd.arg("--device").arg(&audio_output);
    }
    cmd.arg(&cache_dir);
    if std::env::var_os("RUST_LOG").is_none() {
        cmd.env("RUST_LOG", "lightify_audio=debug,librespot_core::spclient=debug,librespot_core::login5=debug,librespot_connect=info,librespot_playback=debug");
    }
    cmd.stdin(Stdio::piped());
    cmd.stdout(Stdio::piped());
    cmd.stderr(Stdio::piped());
    // Hide the console window on Windows — lightify-audio must not flash a terminal.
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
    }

    let mut child = cmd
        .spawn()
        .map_err(|e| format!("Failed to start native audio backend: {e}"))?;

    // Send token via stdin as the first JSON line — never via CLI argv.
    let mut stdin = child
        .stdin
        .take()
        .ok_or("Failed to open stdin for native audio backend")?;
    let token_json = serde_json::json!({ "cmd": "token", "access_token": token });
    writeln!(stdin, "{}", token_json)
        .map_err(|e| format!("Failed to send token to native audio backend: {e}"))?;
    stdin.flush().ok();

    // Capture stderr into a ring buffer (last 200 lines) on a background thread.
    let stderr_buf = {
        let g = state.lock().await;
        Arc::clone(&g.native_audio_stderr)
    };
    stderr_buf.lock().unwrap().clear(); // Clear stale stderr from previous run.
    if let Some(stderr) = child.stderr.take() {
        let log_path = data_dir.join("lightify-audio.log");
        prepare_log_file(&log_path);
        start_line_capture_thread(stderr, log_path, Arc::clone(&stderr_buf), "");
    }

    // Read stdout from the child and emit Tauri events (FFT, state, track, ready, etc.)
    if let Some(stdout) = child.stdout.take() {
        let app_handle = app.clone();
        std::thread::spawn(move || {
            let reader = std::io::BufReader::new(stdout);
            for line in reader.lines().flatten() {
                let trimmed = line.trim();
                if trimmed.is_empty() { continue; }
                if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
                    let event = v["event"].as_str().unwrap_or("").to_string();
                    if !event.is_empty() {
                        let _ = app_handle.emit(&format!("audio:{}", event), &v);
                    }
                }
            }
        });
    }

    {
        let mut g = state.lock().await;
        g.native_audio = Some(child);
        g.native_audio_stdin = Some(stdin);
    }

    Ok(format!("Native audio backend started: {}", bin.display()))
}

#[tauri::command]
async fn cmd_restart_native_audio(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<String, String> {
    {
        let mut g = state.lock().await;
        stop_native_audio_child(&mut g);
    }
    cmd_start_native_audio(state, app).await
}

#[tauri::command]
async fn cmd_native_audio_status(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<NativeAudioStatus, String> {
    let (data_dir, running) = {
        let mut g = state.lock().await;
        let mut running = false;
        if let Some(child) = g.native_audio.as_mut() {
            if child_running(child) {
                running = true;
            } else {
                g.native_audio = None;
            }
        }
        (g.data_dir.clone(), running)
    };

    let resource_dir = app.path().resource_dir().ok();
    let found = find_native_audio_binary(&data_dir, resource_dir.as_deref());
    Ok(NativeAudioStatus {
        running,
        available: found.is_some(),
        path: found.map(|p| p.display().to_string()).unwrap_or_default(),
    })
}

#[tauri::command]
async fn cmd_search(
    query: String,
    state: tauri::State<'_, AppState>,
) -> Result<Vec<Track>, String> {
    let (http, token) = auth_token(&state).await?;
    let clean_query = normalize_search_query(&query)?;
    let q = url_encode(&clean_query);
    // No scraper-window fallback here: search must never spawn a visible browser
    // window. Rate limits surface as an error the frontend renders from its cache.
    let v = sp_get_retrying(&state, &http, &token, &format!("search?q={q}&type=track&limit={SPOTIFY_SEARCH_MAX_LIMIT}&market=from_token")).await?;
    Ok(v["tracks"]["items"]
        .as_array()
        .map(|a| a.iter().filter_map(spotify::parse_track).collect())
        .unwrap_or_default())
}

#[tauri::command]
async fn cmd_search_all(
    query: String,
    state: tauri::State<'_, AppState>,
) -> Result<SearchAllResult, String> {
    let (http, token) = auth_token(&state).await?;
    let clean_query = normalize_search_query(&query)?;
    let q = url_encode(&clean_query);
    // This single call backs EVERY search tab (the per-category tabs filter its result
    // client-side), so fetch enough of each type for a tab to stand on its own.
    let general_path = format!("search?q={q}&type=track,artist,album&limit={SEARCH_ALL_TYPE_LIMIT}&market=from_token");
    let playlist_path = format!("search?q={q}&type=playlist&limit={SEARCH_ALL_TYPE_LIMIT}");

    let general_result = sp_get_retrying(&state, &http, &token, &general_path).await;
    let playlist_result = sp_get_retrying(&state, &http, &token, &playlist_path).await;

    match general_result {
        // Core (track/artist/album) search succeeded. Playlists are secondary — a
        // playlist-search failure must NOT sink the whole search (Spotify's
        // type=playlist endpoint intermittently 404s, especially for dev-mode apps).
        // This is the fix for "searching in a tab throws 404": every non-playlist tab
        // routes through here, so the playlist sub-request can no longer break them.
        Ok(general_v) => {
            let (playlists, playlist_total) = match &playlist_result {
                Ok(pv) => {
                    let items = pv["playlists"]["items"].as_array()
                        .map(|a| a.iter().filter_map(spotify::parse_playlist).collect::<Vec<_>>())
                        .unwrap_or_default();
                    let total = pv["playlists"]["total"].as_u64().unwrap_or(0) as u32;
                    (items, total)
                }
                // Any playlist-search failure (404/403/429/…) → degrade to no playlists
                // and keep the rest. Never spawn a scraper window for this.
                Err(_) => (Vec::new(), 0),
            };
            Ok(SearchAllResult {
                tracks: general_v["tracks"]["items"].as_array()
                    .map(|a| a.iter().filter_map(spotify::parse_track).collect()).unwrap_or_default(),
                playlists,
                playlist_total,
                artists: general_v["artists"]["items"].as_array()
                    .map(|a| a.iter().filter_map(spotify::parse_artist).collect()).unwrap_or_default(),
                albums: general_v["albums"]["items"].as_array()
                    .map(|a| a.iter().filter_map(spotify::parse_album).collect()).unwrap_or_default(),
            })
        }
        // Core search genuinely failed (not a playlist-only issue) → surface it. Rate
        // limits are reported as-is so the frontend can show its cached results; the
        // old scraper-window fallback is gone (it opened a visible browser window).
        Err(err) => Err(err),
    }
}

#[tauri::command]
async fn cmd_search_playlists_page(
    query: String,
    offset: u32,
    limit: u32,
    state: tauri::State<'_, AppState>,
) -> Result<SearchPlaylistsPage, String> {
    let (http, token) = auth_token(&state).await?;
    let clean_query = normalize_search_query(&query)?;
    let q = url_encode(&clean_query);
    let limit = limit.clamp(1, SPOTIFY_SEARCH_MAX_LIMIT);
    let search_result = sp_get_retrying(
        &state,
        &http,
        &token,
        &format!("search?q={q}&type=playlist&limit={limit}&offset={offset}"),
    ).await;

    match search_result {
        Ok(v) => {
            let playlists = v["playlists"]["items"].as_array()
                .map(|a| a.iter().filter_map(spotify::parse_playlist).collect())
                .unwrap_or_default();
            let total = v["playlists"]["total"].as_u64().unwrap_or(0) as u32;
            let next_offset = if offset.saturating_add(limit) < total {
                Some(offset.saturating_add(limit))
            } else {
                None
            };

            Ok(SearchPlaylistsPage {
                playlists,
                total,
                offset,
                limit,
                next_offset,
                done: next_offset.is_none(),
            })
        }
        // Any failure (Spotify's type=playlist search intermittently 404s, and can 429):
        // end the pager quietly with an empty terminal page. The first screen of
        // playlists already came from cmd_search_all, so the tab still shows results and
        // the user never sees an error — and we never open a scraper window.
        Err(_) => Ok(SearchPlaylistsPage {
            playlists: Vec::new(),
            total: offset,
            offset,
            limit,
            next_offset: None,
            done: true,
        }),
    }
}

#[tauri::command]
async fn cmd_artist_tracks(artist_id: String, state: tauri::State<'_, AppState>) -> Result<Vec<Track>, String> {
    validate_spotify_id(&artist_id, "artist")?;
    let (http, token) = auth_token(&state).await?;
    let page = fetch_artist_tracks_page(
        &state,
        &http,
        &token,
        &artist_id,
        None,
        0,
        ARTIST_TRACK_SEARCH_PAGE_LIMIT,
    ).await?;
    Ok(page.tracks)
}

async fn fetch_artist_tracks_page(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    artist_id: &str,
    artist_name_hint: Option<&str>,
    offset: u32,
    limit: u32,
) -> Result<PlaylistTracksPage, String> {
    validate_spotify_id(artist_id, "artist")?;
    let limit = limit.clamp(1, ARTIST_TRACK_SEARCH_PAGE_LIMIT);
    let path = format!("artists/{artist_id}/top-tracks?market=from_token");
    let v = match sp_get_retrying(state, http, token, &path).await {
        Ok(v) => v,
        Err(e)
            if e.starts_with("Spotify 400")
                || e.starts_with("Spotify 403")
                || e.starts_with("SPOTIFY_FORBIDDEN") =>
        {
            let fallback_name = match artist_name_hint {
                Some(name) => normalize_artist_lookup_name(name)?,
                None => {
                    let artist_v = sp_get_retrying(state, http, token, &format!("artists/{artist_id}")).await?;
                    normalize_artist_lookup_name(artist_v["name"].as_str().unwrap_or_default())?
                }
            };
            let fallback_key = artist_name_key(&fallback_name);
            let q = url_encode(&format!("artist:\"{fallback_name}\""));
            let search_limit = 10u32;
            let mut search_offset = 0u32;
            let mut seen_ids = std::collections::HashSet::new();
            let mut all_tracks = Vec::new();
            loop {
                let search_v = sp_get_retrying(
                    state,
                    http,
                    token,
                    &format!("search?q={q}&type=track&limit={search_limit}&offset={search_offset}&market=from_token"),
                ).await?;
                let items = search_v["tracks"]["items"].as_array().cloned().unwrap_or_default();
                for item in &items {
                    if !spotify_track_matches_artist(item, artist_id, &fallback_key) {
                        continue;
                    }
                    let Some(track) = spotify::parse_track(item) else { continue; };
                    if seen_ids.insert(track.id.clone()) {
                        all_tracks.push(track);
                    }
                }
                let has_more = search_v["tracks"]["next"]
                    .as_str()
                    .map(|next| !next.is_empty())
                    .unwrap_or(false);
                if !has_more || items.is_empty() || search_offset >= 90 {
                    break;
                }
                search_offset = search_offset.saturating_add(search_limit);
            }
            let total = all_tracks.len() as u32;
            let start = offset.min(total) as usize;
            let end = offset.saturating_add(limit).min(total) as usize;
            let tracks = all_tracks[start..end].to_vec();
            let next_offset = if tracks.is_empty() || offset.saturating_add(limit) >= total {
                None
            } else {
                Some(offset.saturating_add(limit))
            };

            return Ok(PlaylistTracksPage {
                tracks,
                total,
                offset,
                limit,
                next_offset,
                done: next_offset.is_none(),
                cached: false,
            });
        }
        Err(e) => return Err(e),
    };
    let all_tracks = v["tracks"].as_array()
        .map(|items| items.iter().filter_map(spotify::parse_track).collect::<Vec<_>>())
        .unwrap_or_default();
    let total = all_tracks.len() as u32;
    let start = offset.min(total) as usize;
    let end = offset.saturating_add(limit).min(total) as usize;
    let tracks = all_tracks[start..end].to_vec();
    let next_offset = if tracks.is_empty() || offset.saturating_add(limit) >= total {
        None
    } else {
        Some(offset.saturating_add(limit))
    };

    Ok(PlaylistTracksPage {
        tracks,
        total,
        offset,
        limit,
        next_offset,
        done: next_offset.is_none(),
        cached: false,
    })
}

#[tauri::command]
async fn cmd_artist_tracks_page(
    artist_id: String,
    artist_name: String,
    offset: u32,
    limit: Option<u32>,
    refresh: Option<bool>,
    state: tauri::State<'_, AppState>,
) -> Result<PlaylistTracksPage, String> {
    validate_spotify_id(&artist_id, "artist")?;
    let _ = normalize_artist_lookup_name(&artist_name)?;
    let (http, token) = auth_token(&state).await?;
    let limit = limit.unwrap_or(ARTIST_TRACK_SEARCH_PAGE_LIMIT).clamp(1, ARTIST_TRACK_SEARCH_PAGE_LIMIT);
    let (data_dir, user_id) = cache_identity(&state).await;
    if !refresh.unwrap_or(false) {
        if let Some(page) = read_cached_track_page(&data_dir, &user_id, "artist", &artist_id, offset, limit) {
            return Ok(page);
        }
    }
    let page = fetch_artist_tracks_page(
        &state,
        &http,
        &token,
        &artist_id,
        Some(&artist_name),
        offset,
        limit,
    ).await?;
    write_cached_track_page(&data_dir, &user_id, "artist", &artist_id, &page);
    Ok(page)
}

#[tauri::command]
async fn cmd_album_tracks(album_id: String, state: tauri::State<'_, AppState>) -> Result<Vec<Track>, String> {
    let (http, token) = auth_token(&state).await?;
    // market=from_token relinks region-locked album tracks and sets is_playable.
    let album_v = sp_get_retrying(&state, &http, &token, &format!("albums/{album_id}?market=from_token")).await?;
    Ok(parse_album_tracks(&album_v))
}

fn parse_album_tracks(album_v: &serde_json::Value) -> Vec<Track> {
    let album_art = album_v["images"].as_array()
        .and_then(|images| images.first())
        .and_then(|image| image["url"].as_str())
        .unwrap_or("")
        .to_string();
    let album_name = album_v["name"].as_str().unwrap_or("").to_string();
    album_v["tracks"]["items"].as_array()
        .map(|items| items.iter().filter_map(|track| {
            let id = track["id"].as_str()?.to_string();
            if id.is_empty() {
                return None;
            }
            let name = track["name"].as_str().unwrap_or("Unknown").to_string();
            let artists = track["artists"].as_array()
                .map(|arr| arr.iter().filter_map(|artist| artist["name"].as_str()).collect::<Vec<_>>().join(", "))
                .unwrap_or_default();
            let artist_ids = track["artists"].as_array()
                .map(|arr| arr.iter().filter_map(|a| a["id"].as_str().map(|s| s.to_string())).collect())
                .unwrap_or_default();
            let duration_ms = track["duration_ms"].as_u64().unwrap_or(0);
            let uri = track["uri"].as_str().unwrap_or("").to_string();
            let album_id_val = album_v["id"].as_str().unwrap_or("").to_string();
            let is_playable = track["is_playable"].as_bool().unwrap_or(true);
            Some(Track {
                id,
                name,
                artists,
                artist_ids,
                album: album_name.clone(),
                album_id: album_id_val,
                duration_ms,
                uri,
                album_art: album_art.clone(),
                added_at: None,
                is_playable,
            })
        }).collect::<Vec<_>>())
        .unwrap_or_default()
}

fn with_track_added_at(mut track: Track, added_at: Option<&str>) -> Track {
    track.added_at = added_at
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|value| value.to_string());
    track
}

#[tauri::command]
async fn cmd_playlists(state: tauri::State<'_, AppState>) -> Result<Vec<Playlist>, String> {
    let (http, token) = auth_token(&state).await?;
    let mut playlists = Vec::new();
    let mut path = "me/playlists?limit=50".to_string();
    loop {
        let v = sp_get_retrying(&state, &http, &token, &path).await?;
        if let Some(items) = v["items"].as_array() {
            playlists.extend(items.iter().filter_map(spotify::parse_playlist));
        }
        match v["next"].as_str() {
            Some(n) if !n.is_empty() => {
                path = n.trim_start_matches("https://api.spotify.com/v1/").to_string();
            }
            _ => break,
        }
    }
    Ok(playlists)
}

fn validate_spotify_id(id: &str, label: &str) -> Result<(), String> {
    if id.is_empty()
        || id.len() > 128
        || !id.chars().all(|c| c.is_ascii_alphanumeric())
    {
        return Err(format!("Invalid {label} id"));
    }
    Ok(())
}

fn track_id_from_uri(uri: &str) -> Option<&str> {
    let id = uri.strip_prefix("spotify:track:")?;
    if id.len() == 22 && id.chars().all(|c| c.is_ascii_alphanumeric()) {
        Some(id)
    } else {
        None
    }
}

fn parse_playlist_item_track(entry: &serde_json::Value) -> Option<Track> {
    let added_at = entry["added_at"].as_str();
    let item = &entry["item"];
    if !item.is_null() {
        return spotify::parse_track(item).map(|track| with_track_added_at(track, added_at));
    }
    spotify::parse_track(&entry["track"]).map(|track| with_track_added_at(track, added_at))
}

// Matches SSR-rendered track rows: extracts (track_id, track_name) from aria attributes.
// Spotify includes ~30 tracks in unauthenticated SSR HTML for public playlists.
fn spotify_web_listrow_re() -> &'static regex::Regex {
    static RE: OnceLock<regex::Regex> = OnceLock::new();
    RE.get_or_init(|| {
        regex::Regex::new(
            r#"aria-labelledby="listrow-title-track-spotify:track:([A-Za-z0-9]{22})-\d+" aria-label="([^"]+)""#,
        )
        .expect("valid Spotify listrow regex")
    })
}

fn spotify_embed_next_data_re() -> &'static regex::Regex {
    static RE: OnceLock<regex::Regex> = OnceLock::new();
    RE.get_or_init(|| {
        regex::Regex::new(r#"(?s)<script id="__NEXT_DATA__" type="application/json">(.*?)</script>"#)
            .expect("valid Spotify embed next data regex")
    })
}

fn spotify_web_track_uri_re() -> &'static regex::Regex {
    static RE: OnceLock<regex::Regex> = OnceLock::new();
    RE.get_or_init(|| {
        regex::Regex::new(r#"spotify:track:([A-Za-z0-9]{22})"#)
            .expect("valid Spotify playlist track uri regex")
    })
}

fn spotify_search_fallback_mutex() -> &'static tokio::sync::Mutex<()> {
    static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}

async fn wait_for_spotify_search_fallback_window_release(app: &tauri::AppHandle) {
    if let Some(existing) = app.get_webview_window(SPOTIFY_SEARCH_FALLBACK_WINDOW_LABEL) {
        let _ = existing.close();
    }

    for _ in 0..40 {
        if app.get_webview_window(SPOTIFY_SEARCH_FALLBACK_WINDOW_LABEL).is_none() {
            return;
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
}

fn fallback_track_artists(subtitle: &str) -> String {
    let normalized = rest_normalized(subtitle);
    let lower = normalized.to_ascii_lowercase();
    let Some(idx) = lower.find("song •") else {
        return String::new();
    };
    normalized[idx + "song •".len()..].trim().replace(" | ", ", ")
}

fn fallback_track_placeholder(entry: &SpotifySearchFallbackEntry) -> Track {
    Track {
        id: entry.id.clone(),
        name: if entry.name.trim().is_empty() {
            "Unknown track".to_string()
        } else {
            entry.name.trim().to_string()
        },
        artists: fallback_track_artists(&entry.subtitle),
        artist_ids: Vec::new(),
        album: String::new(),
        album_id: String::new(),
        duration_ms: 0,
        uri: format!("spotify:track:{}", entry.id),
        album_art: String::new(),
        added_at: None,
        is_playable: true,
    }
}

#[allow(dead_code)] // paired with fetch_playlist_search_results_via_fallback (see there)
fn fallback_playlist_placeholder(entry: &SpotifySearchFallbackEntry) -> Playlist {
    Playlist {
        id: entry.id.clone(),
        name: if entry.name.trim().is_empty() {
            "Playlist".to_string()
        } else {
            entry.name.trim().to_string()
        },
        tracks: 0,
        owner: String::new(),
        uri: format!("spotify:playlist:{}", entry.id),
    }
}

fn parse_spotify_embed_track(entry: &serde_json::Value) -> Option<Track> {
    if entry["entityType"].as_str().unwrap_or("track") != "track" {
        return None;
    }

    let uri = entry["uri"].as_str()?.to_string();
    let id = track_id_from_uri(&uri)?.to_string();

    Some(Track {
        id,
        name: entry["title"].as_str().unwrap_or("Unknown").to_string(),
        artists: entry["subtitle"]
            .as_str()
            .unwrap_or("")
            .replace('\u{00a0}', " "),
        artist_ids: Vec::new(),
        album: String::new(),
        album_id: String::new(),
        duration_ms: entry["duration"].as_u64().unwrap_or(0),
        uri,
        album_art: String::new(),
        added_at: None,
        is_playable: true,
    })
}

async fn enrich_tracks_by_id(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    tracks: Vec<Track>,
) -> Vec<Track> {
    if token.is_empty() || tracks.is_empty() {
        return tracks;
    }

    let ids: Vec<&str> = tracks
        .iter()
        .map(|track| track.id.as_str())
        .filter(|id| !id.is_empty())
        .collect();
    if ids.is_empty() {
        return tracks;
    }

    let mut enriched = HashMap::new();
    for chunk in ids.chunks(50) {
        // market=from_token relinks so enriched metadata carries is_playable too.
        let path = format!("tracks?ids={}&market=from_token", chunk.join(","));
        let Ok(resp) = sp_get_retrying(state, http, token, &path).await else {
            continue;
        };
        if let Some(items) = resp["tracks"].as_array() {
            for track in items.iter().filter_map(spotify::parse_track) {
                enriched.insert(track.id.clone(), track);
            }
        }
    }

    if enriched.is_empty() {
        return tracks;
    }

    tracks
        .into_iter()
        .map(|track| match enriched.get(&track.id) {
            Some(enriched_track) => {
                let mut merged = enriched_track.clone();
                if merged.added_at.is_none() {
                    merged.added_at = track.added_at.clone();
                }
                merged
            }
            None => track,
        })
        .collect()
}

fn build_spotify_search_fallback_script(
        query: &str,
        kind: SpotifySearchFallbackKind,
        limit: u32,
) -> String {
        let query_js = serde_json::to_string(query).unwrap_or_else(|_| "\"\"".to_string());
        let kind_js = serde_json::to_string(kind.as_str()).unwrap_or_else(|_| "\"track\"".to_string());

        format!(
                r#"(function () {{
    'use strict';
    const QUERY = {query_js};
    const KIND = {kind_js};
    const LIMIT = {limit};
    let done = false;

    function delay(ms) {{
        return new Promise(resolve => setTimeout(resolve, ms));
    }}

    function normalize(text) {{
        return String(text || '').replace(/\s+/g, ' ').trim();
    }}

    function findSearchButton() {{
        return Array.from(document.querySelectorAll('button, [role="button"]')).find(btn => {{
            const text = normalize(btn.textContent).toLowerCase();
            const aria = normalize(btn.getAttribute('aria-label')).toLowerCase();
            return text === 'search'
                || aria === 'search'
                || text.includes('what do you want to listen to')
                || aria.includes('what do you want to listen to');
        }}) || null;
    }}

    function findInput() {{
        return document.querySelector('input') || document.querySelector('[role="textbox"]');
    }}

    async function ensureSearchInput(timeoutMs) {{
        const deadline = Date.now() + timeoutMs;
        let lastClickAt = 0;
        while (Date.now() < deadline) {{
            const input = findInput();
            if (input) return input;

            const button = findSearchButton();
            if (button && (Date.now() - lastClickAt) > 500) {{
                button.click();
                lastClickAt = Date.now();
            }}

            await delay(250);
        }}
        return null;
    }}

    function setInputValue(input, value) {{
        const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
        if (setter) setter.call(input, value);
        else input.value = value;
        input.focus();
        input.dispatchEvent(new Event('input', {{ bubbles: true }}));
        input.dispatchEvent(new Event('change', {{ bubbles: true }}));
        input.dispatchEvent(new KeyboardEvent('keydown', {{ key: 'Enter', bubbles: true }}));
        input.dispatchEvent(new KeyboardEvent('keyup', {{ key: 'Enter', bubbles: true }}));
    }}

    async function waitFor(check, timeoutMs) {{
        const deadline = Date.now() + timeoutMs;
        while (Date.now() < deadline) {{
            const value = check();
            if (value) return value;
            await delay(200);
        }}
        return null;
    }}

    function parseEntries() {{
        const entries = [];
        const seen = new Set();
        const groups = Array.from(document.querySelectorAll('[role="group"]'));
        for (const group of groups) {{
            const aria = normalize(group.getAttribute('aria-labelledby'));
            const label = normalize(group.getAttribute('aria-label'));
            const text = normalize(group.textContent);
            let id = '';
            if (KIND === 'track') {{
                const match = aria.match(/^listrow-title-([A-Za-z0-9]{{22}})$/);
                if (!match || !/song\s*•/i.test(text)) continue;
                id = match[1];
            }} else {{
                const playlistMatch = aria.match(/spotify:playlist:([A-Za-z0-9]{{22}})/);
                const rowMatch = aria.match(/^listrow-title-([A-Za-z0-9]{{22}})$/);
                id = playlistMatch ? playlistMatch[1] : (rowMatch ? rowMatch[1] : '');
                if (!id) continue;
                if (!playlistMatch && !/playlist/i.test(text)) continue;
            }}
            if (seen.has(id)) continue;
            seen.add(id);
            entries.push({{ id, name: label || text, subtitle: text }});
            if (entries.length >= LIMIT) break;
        }}
        return entries;
    }}

    async function send(entries, error) {{
        if (done) return;
        done = true;
        function trySend(retries) {{
            const t = window.__TAURI__;
            if (t && t.core && t.core.invoke) {{
                t.core.invoke('cmd_spotify_search_fallback_result', {{
                    query: QUERY,
                    kind: KIND,
                    entries,
                    error: error || ''
                }}).catch(err => console.error('[Lightify] spotify search fallback error:', err));
            }} else if (retries > 0) {{
                setTimeout(() => trySend(retries - 1), 100);
            }}
        }}
        trySend(30);
    }}

    async function run() {{
        try {{
            let best = [];
            const deadline = Date.now() + 14000;
            while (Date.now() < deadline) {{
                const entries = parseEntries();
                if (entries.length > best.length) best = entries;
                if (best.length >= Math.min(LIMIT, 3)) break;
                await delay(500);
            }}

            await send(best, best.length > 0 ? '' : 'Spotify search fallback found no rendered results');
        }} catch (err) {{
            await send([], String(err));
        }}
    }}

    if (document.readyState === 'loading') {{
        document.addEventListener('DOMContentLoaded', () => {{ run(); }});
    }} else {{
        run();
    }}
}})();"#
        )
}

async fn fetch_spotify_search_fallback_entries(
        app: &tauri::AppHandle,
        state: &AppState,
        query: &str,
        kind: SpotifySearchFallbackKind,
        limit: u32,
) -> Result<Vec<SpotifySearchFallbackEntry>, String> {
    let _fallback_guard = spotify_search_fallback_mutex().lock().await;

    wait_for_spotify_search_fallback_window_release(app).await;

        let (sender, receiver) = oneshot::channel();
        {
                let mut guard = state.lock().await;
                guard.pending_spotify_search_fallback = Some(PendingSpotifySearchFallback {
                        query: query.to_string(),
                        kind,
                        sender,
                });
        }

        let script = build_spotify_search_fallback_script(query, kind, limit);
        let fallback_url = format!("https://open.spotify.com/search/results/{}", url_encode(query));
        let parsed: tauri::Url = fallback_url
                .parse()
                .map_err(|e| format!("Spotify search fallback URL parse error: {e}"))?;

        if let Err(err) = tauri::WebviewWindowBuilder::new(
                app,
                SPOTIFY_SEARCH_FALLBACK_WINDOW_LABEL,
                tauri::WebviewUrl::External(parsed),
        )
        .title("Spotify Search Fallback")
        .inner_size(420.0, 760.0)
        .position(-32000.0, -32000.0)
        .focused(false)
        .skip_taskbar(true)
        .visible(true)
        .initialization_script(&script)
        .build()
        {
                state.lock().await.pending_spotify_search_fallback = None;
                return Err(format!("Spotify search fallback window failed: {err}"));
        }

        let result = tokio::time::timeout(
                Duration::from_secs(SPOTIFY_SEARCH_FALLBACK_TIMEOUT_SECS),
                receiver,
        )
        .await;

        wait_for_spotify_search_fallback_window_release(app).await;

        match result {
                Ok(Ok(Ok(entries))) if !entries.is_empty() => Ok(entries),
                Ok(Ok(Ok(_))) => Err("Spotify search fallback found no results".to_string()),
                Ok(Ok(Err(err))) => Err(err),
                Ok(Err(_)) => Err("Spotify search fallback channel closed".to_string()),
                Err(_) => {
                        state.lock().await.pending_spotify_search_fallback = None;
                        Err("Spotify search fallback timed out".to_string())
                }
        }
}

async fn fetch_spotify_search_tracks_via_fallback(
        app: &tauri::AppHandle,
        state: &AppState,
        http: &reqwest::Client,
        token: &str,
        query: &str,
        limit: u32,
) -> Result<Vec<Track>, String> {
        let entries = fetch_spotify_search_fallback_entries(
                app,
                state,
                query,
                SpotifySearchFallbackKind::Track,
                limit,
        ).await?;
        let placeholders = entries
                .into_iter()
                .map(|entry| fallback_track_placeholder(&entry))
                .collect::<Vec<_>>();
        Ok(enrich_tracks_by_id(state, http, token, placeholders).await)
}

// Retained (not wired to search any more): the search paths must never spawn the
// scraper window, so playlist search now degrades to "no results" instead. Kept
// alongside its track sibling, which the Beatport matcher still uses on rate limits.
#[allow(dead_code)]
async fn fetch_playlist_search_results_via_fallback(
        app: &tauri::AppHandle,
        state: &AppState,
        http: &reqwest::Client,
        token: &str,
        query: &str,
        limit: u32,
) -> Result<Vec<Playlist>, String> {
        let entries = fetch_spotify_search_fallback_entries(
                app,
                state,
                query,
                SpotifySearchFallbackKind::Playlist,
                limit,
        ).await?;

        let mut playlists = Vec::with_capacity(entries.len());
        for entry in entries {
                let path = format!("playlists/{}", entry.id);
                match sp_get_retrying(state, http, token, &path).await {
                        Ok(value) => playlists.push(
                                spotify::parse_playlist(&value)
                                        .unwrap_or_else(|| fallback_playlist_placeholder(&entry))
                        ),
                        Err(_) => playlists.push(fallback_playlist_placeholder(&entry)),
                }
        }
        Ok(playlists)
}

async fn fetch_spotify_embed_playlist_page(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    playlist_id: &str,
    offset: u32,
    limit: u32,
) -> Result<PlaylistTracksPage, String> {
    let html = http
        .get(format!("https://open.spotify.com/embed/playlist/{playlist_id}"))
        .header(reqwest::header::USER_AGENT, "Mozilla/5.0 Lightify/2.0")
        .timeout(Duration::from_secs(SPOTIFY_REQUEST_TIMEOUT_SECS))
        .send()
        .await
        .map_err(|e| format!("Spotify embed playlist page: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Spotify embed playlist status: {e}"))?
        .text()
        .await
        .map_err(|e| format!("Spotify embed playlist html: {e}"))?;

    let next_data = spotify_embed_next_data_re()
        .captures(&html)
        .and_then(|caps| caps.get(1).map(|m| m.as_str()))
        .ok_or_else(|| "Spotify embed playlist missing bootstrap data".to_string())?;

    let payload: serde_json::Value = serde_json::from_str(next_data)
        .map_err(|e| format!("Spotify embed playlist bootstrap json: {e}"))?;

    let track_list = payload["props"]["pageProps"]["state"]["data"]["entity"]["trackList"]
        .as_array()
        .ok_or_else(|| "Spotify embed playlist missing trackList".to_string())?;

    let total = track_list.len() as u32;
    let start = offset.min(total) as usize;
    let end = offset.saturating_add(limit).min(total) as usize;
    let page_tracks = track_list[start..end]
        .iter()
        .filter_map(parse_spotify_embed_track)
        .collect::<Vec<_>>();
    let next_offset = if end < total as usize {
        Some(end as u32)
    } else {
        None
    };

    Ok(PlaylistTracksPage {
        tracks: enrich_tracks_by_id(state, http, token, page_tracks).await,
        total,
        offset,
        limit,
        next_offset,
        done: next_offset.is_none(),
        cached: false,
    })
}

async fn fetch_spotify_web_playlist_page(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    playlist_id: &str,
    offset: u32,
    limit: u32,
) -> Result<PlaylistTracksPage, String> {
    if let Ok(page) = fetch_spotify_embed_playlist_page(state, http, token, playlist_id, offset, limit).await {
        if !page.tracks.is_empty() {
            return Ok(page);
        }
    }

    let url = format!("https://open.spotify.com/playlist/{playlist_id}");
    let html = http
        .get(url)
        .header(reqwest::header::USER_AGENT, "Mozilla/5.0 Lightify/2.0")
        .timeout(Duration::from_secs(SPOTIFY_REQUEST_TIMEOUT_SECS))
        .send()
        .await
        .map_err(|e| e.to_string())?
        .error_for_status()
        .map_err(|e| e.to_string())?
        .text()
        .await
        .map_err(|e| e.to_string())?;

    let mut tracks = Vec::new();
    let mut seen_ids = std::collections::HashSet::new();

    // Primary: extract track ID + name from SSR-rendered aria attributes.
    for cap in spotify_web_listrow_re().captures_iter(&html) {
        let Some(id) = cap.get(1).map(|m| m.as_str().to_string()) else { continue; };
        if !seen_ids.insert(id.clone()) {
            continue;
        }
        let name = cap.get(2)
            .map(|m| decode_html_entities(m.as_str()))
            .unwrap_or_default();
        tracks.push(Track {
            id: id.clone(),
            name,
            artists: String::new(),
            artist_ids: Vec::new(),
            album: String::new(),
            album_id: String::new(),
            duration_ms: 0,
            uri: format!("spotify:track:{id}"),
            album_art: String::new(),
            added_at: None,
            is_playable: true,
        });
    }

    // Fallback: bare spotify:track: URI scan when SSR rows are absent.
    if tracks.is_empty() {
        for cap in spotify_web_track_uri_re().captures_iter(&html) {
            let Some(id) = cap.get(1).map(|m| m.as_str().to_string()) else { continue; };
            if !seen_ids.insert(id.clone()) {
                continue;
            }
            let n = tracks.len() + 1;
            tracks.push(Track {
                id: id.clone(),
                name: format!("Track {n}"),
                artists: String::new(),
                artist_ids: Vec::new(),
                album: String::new(),
                album_id: String::new(),
                duration_ms: 0,
                uri: format!("spotify:track:{id}"),
                album_art: String::new(),
                added_at: None,
                is_playable: true,
            });
        }
    }

    let total = tracks.len() as u32;
    let start = offset.min(total) as usize;
    let end = offset
        .saturating_add(limit.min(SPOTIFY_WEB_PLAYLIST_FALLBACK_LIMIT))
        .min(total) as usize;
    let page_tracks = tracks[start..end].to_vec();
    let next_offset = if end < total as usize {
        Some(end as u32)
    } else {
        None
    };

    Ok(PlaylistTracksPage {
        tracks: enrich_tracks_by_id(state, http, token, page_tracks).await,
        total,
        offset,
        limit,
        next_offset,
        done: next_offset.is_none(),
        cached: false,
    })
}

/// Fallback 1.5: fetch playlist tracks using an anonymous Spotify web player token.
/// The web player token is not bound to our developer app, so it bypasses
/// Dev Mode restrictions and can access any public playlist's track list.
async fn fetch_playlist_via_anon_token(
    http: &reqwest::Client,
    playlist_id: &str,
    offset: u32,
    limit: u32,
) -> Result<PlaylistTracksPage, String> {
    let token_v: serde_json::Value = http
        .get("https://open.spotify.com/get_access_token?reason=transport&productType=web_player")
        .header(reqwest::header::USER_AGENT, "Mozilla/5.0 Lightify/2.0")
        .timeout(Duration::from_secs(SPOTIFY_REQUEST_TIMEOUT_SECS))
        .send()
        .await
        .map_err(|e| format!("anon token: {e}"))?
        .error_for_status()
        .map_err(|e| format!("anon token status: {e}"))?
        .json()
        .await
        .map_err(|e| format!("anon token json: {e}"))?;

    let anon_token = token_v["accessToken"]
        .as_str()
        .ok_or_else(|| "no accessToken in web player response".to_string())?;

    let v: serde_json::Value = http
        .get(format!(
            "https://api.spotify.com/v1/playlists/{playlist_id}/items?limit={limit}&offset={offset}"
        ))
        .header(reqwest::header::AUTHORIZATION, format!("Bearer {anon_token}"))
        .header(reqwest::header::USER_AGENT, "Mozilla/5.0 Lightify/2.0")
        .timeout(Duration::from_secs(SPOTIFY_REQUEST_TIMEOUT_SECS))
        .send()
        .await
        .map_err(|e| format!("anon items: {e}"))?
        .error_for_status()
        .map_err(|e| format!("anon items status: {e}"))?
        .json()
        .await
        .map_err(|e| format!("anon items json: {e}"))?;

    let tracks = v["items"]
        .as_array()
        .map(|items| items.iter().filter_map(parse_playlist_item_track).collect::<Vec<_>>())
        .unwrap_or_default();
    let total = v["total"].as_u64().unwrap_or(offset as u64 + tracks.len() as u64) as u32;
    let next_offset = v["next"]
        .as_str()
        .filter(|s| !s.is_empty())
        .map(|_| offset.saturating_add(limit));

    Ok(PlaylistTracksPage {
        tracks,
        total,
        offset,
        limit,
        next_offset,
        done: next_offset.is_none(),
        cached: false,
    })
}

async fn fetch_playlist_tracks_page(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    playlist_id: &str,
    offset: u32,
    limit: u32,
) -> Result<PlaylistTracksPage, String> {
    validate_spotify_id(playlist_id, "playlist")?;
    let limit = limit.clamp(1, PLAYLIST_ITEM_PAGE_LIMIT);
    // Primary: /playlists/{id}/items (current Web API endpoint).
    // market=from_token relinks region-locked tracks and populates is_playable.
    let path = format!("playlists/{playlist_id}/items?limit={limit}&offset={offset}&market=from_token");
    match sp_get_retrying(state, http, token, &path).await {
        Ok(v) => {
            let tracks = v["items"].as_array()
                .map(|items| items.iter().filter_map(parse_playlist_item_track).collect::<Vec<_>>())
                .unwrap_or_default();
            let total = v["total"].as_u64().unwrap_or(offset as u64 + tracks.len() as u64) as u32;
            let next_offset = v["next"].as_str()
                .filter(|next| !next.is_empty())
                .map(|_| offset.saturating_add(limit));
            return Ok(PlaylistTracksPage {
                tracks,
                total,
                offset,
                limit,
                next_offset,
                done: next_offset.is_none(),
                cached: false,
            });
        }
        Err(e) if e.starts_with("SPOTIFY_FORBIDDEN") || e.contains("Spotify 404") => {} // fall through
        Err(e) => return Err(e),
    }

    // Fallback 1: /playlists/{id} full endpoint — returns 200 for non-owned playlists
    // where /items returns 403 (verified behaviour, repo memory note 7).
    // Guard: skip if `tracks` field is null (dev mode returns null for non-owned playlists).
    let full_path = format!("playlists/{playlist_id}?limit={limit}&offset={offset}&market=from_token");
    if let Ok(v) = sp_get_retrying(state, http, token, &full_path).await {
        if let Some(tracks_obj) = v.get("tracks").filter(|t| !t.is_null()) {
            let tracks = tracks_obj["items"].as_array()
                .map(|items| items.iter().filter_map(parse_playlist_item_track).collect::<Vec<_>>())
                .unwrap_or_default();
            let total = tracks_obj["total"].as_u64()
                .unwrap_or(offset as u64 + tracks.len() as u64) as u32;
            let next_offset = tracks_obj["next"].as_str()
                .filter(|next| !next.is_empty())
                .map(|_| offset.saturating_add(limit));
            return Ok(PlaylistTracksPage {
                tracks,
                total,
                offset,
                limit,
                next_offset,
                done: next_offset.is_none(),
                cached: false,
            });
        }
    }

    // Fallback 1.5: anonymous Spotify web player token — bypasses Dev Mode restrictions
    // for public playlists without needing the user's app token.
    if let Ok(page) = fetch_playlist_via_anon_token(http, playlist_id, offset, limit).await {
        if !page.tracks.is_empty() {
            return Ok(page);
        }
    }

    // Fallback 2: HTML scraping of open.spotify.com (last resort, limited data).
    // Pass state+token so scraped track IDs can be enriched via GET /tracks.
    fetch_spotify_web_playlist_page(state, http, token, playlist_id, offset, limit).await
}

#[tauri::command]
async fn cmd_playlist_tracks_page(
    playlist_id: String,
    offset: u32,
    limit: Option<u32>,
    refresh: Option<bool>,
    state: tauri::State<'_, AppState>,
) -> Result<PlaylistTracksPage, String> {
    validate_spotify_id(&playlist_id, "playlist")?;
    let (http, token) = auth_token(&state).await?;
    let limit = limit.unwrap_or(PLAYLIST_ITEM_PAGE_LIMIT).clamp(1, PLAYLIST_ITEM_PAGE_LIMIT);
    let (data_dir, user_id) = cache_identity(&state).await;
    if !refresh.unwrap_or(false) {
        if let Some(page) = read_cached_track_page(&data_dir, &user_id, "playlist", &playlist_id, offset, limit) {
            return Ok(page);
        }
    }
    let page = fetch_playlist_tracks_page(
        &state,
        &http,
        &token,
        &playlist_id,
        offset,
        limit,
    ).await?;
    write_cached_track_page(&data_dir, &user_id, "playlist", &playlist_id, &page);
    Ok(page)
}

#[tauri::command]
async fn cmd_playlist_tracks(playlist_id: String, state: tauri::State<'_, AppState>) -> Result<Vec<Track>, String> {
    let (http, token) = auth_token(&state).await?;
    validate_spotify_id(&playlist_id, "playlist")?;
    let mut tracks = Vec::new();
    let mut offset = 0;
    loop {
        let page = fetch_playlist_tracks_page(
            &state,
            &http,
            &token,
            &playlist_id,
            offset,
            PLAYLIST_ITEM_PAGE_LIMIT,
        ).await?;
        tracks.extend(page.tracks);
        let Some(next_offset) = page.next_offset else { break; };
        offset = next_offset;
    }
    Ok(tracks)
}

#[tauri::command]
async fn cmd_play_track(
    track_uri: String,
    context_uri: Option<String>,
    state: tauri::State<'_, AppState>,
) -> Result<(), String> {
    validate_track_uri(&track_uri)?;
    let (http, token) = auth_token(&state).await?;
    let body = match context_uri.filter(|s| !s.is_empty()) {
        Some(ctx) => serde_json::json!({
            "context_uri": ctx,
            "offset": { "uri": track_uri },
        }),
        None => serde_json::json!({ "uris": [track_uri] }),
    };
    put_player(&state, &http, &token, "me/player/play", Some(body)).await
}

#[tauri::command]
async fn cmd_play_context(context_uri: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let (http, token) = auth_token(&state).await?;
    put_player(&state, &http, &token, "me/player/play", Some(serde_json::json!({ "context_uri": context_uri }))).await
}

#[tauri::command]
async fn cmd_play_uris(track_uris: Vec<String>, state: tauri::State<'_, AppState>) -> Result<(), String> {
    if track_uris.is_empty() {
        return Err("No tracks supplied".to_string());
    }
    // Skip non-playable uris (local files, malformed) rather than failing the whole
    // selection — a playlist with a few local/unavailable tracks should still play
    // the rest instead of erroring out entirely.
    let track_uris: Vec<String> = track_uris
        .into_iter()
        .filter(|u| validate_track_uri(u).is_ok())
        .collect();
    if track_uris.is_empty() {
        return Err("No playable tracks in selection".to_string());
    }
    let (http, token) = auth_token(&state).await?;
    let lead_batch: Vec<String> = track_uris.iter().take(PLAY_URIS_MAX_BATCH).cloned().collect();
    put_player(
        &state,
        &http,
        &token,
        "me/player/play",
        Some(serde_json::json!({ "uris": lead_batch })),
    ).await?;

    for uri in track_uris.iter().skip(PLAY_URIS_MAX_BATCH) {
        let enc = url_encode(uri);
        post_player(&state, &http, &token, &format!("me/player/queue?uri={enc}"), None).await?;
    }

    Ok(())
}

// ── Debug export ────────────────────────────────────────────────────────────

#[tauri::command]
async fn cmd_log_event(
    level: String,
    category: String,
    message: String,
    state: tauri::State<'_, AppState>,
) -> Result<(), String> {
    push_diag(&state, &level, &category, message).await;
    Ok(())
}

#[tauri::command]
async fn cmd_get_diagnostics(state: tauri::State<'_, AppState>) -> Result<Vec<DiagEntry>, String> {
    let buf = { state.lock().await.diagnostics.clone() };
    let g = buf.lock().map_err(|_| "diagnostics lock poisoned".to_string())?;
    Ok(g.iter().cloned().collect())
}

async fn build_debug_report(state: &AppState, app: &tauri::AppHandle) -> Result<String, String> {
    use std::fmt::Write as FmtWrite;
    use cpal::traits::{DeviceTrait, HostTrait};

    let report_now = unix_secs();
    let (data_dir, cfg_client_id, cfg_audio_output, token_present, user_id, native_running, stderr_lines, onthespot_running, onthespot_lines, active_spotify_write_limits, last_spotify_write_limit) = {
        let mut g = state.lock().await;
        let running = g.native_audio.as_mut().map_or(false, |c| child_running(c));
        let stderr: Vec<String> = g.native_audio_stderr.lock().unwrap().iter().cloned().collect();
        let onthespot_running = g.onthespot_web.as_mut().map_or(false, |c| child_running(c));
        let onthespot_stderr: Vec<String> = g.onthespot_web_stderr.lock().unwrap().iter().cloned().collect();
        g.spotify_write_rate_limits.retain(|_, entry| entry.blocked_until_unix > report_now);
        let active_limits = g.spotify_write_rate_limits.values().cloned().collect::<Vec<_>>();
        (
            g.data_dir.clone(),
            // Truncate client_id for the report — show only first 8 chars.
            g.cfg.client_id.chars().take(8).collect::<String>() + "…",
            g.cfg.audio_output.clone(),
            g.token.is_some(),
            g.user_id.clone(),
            running,
            stderr,
            onthespot_running,
            onthespot_stderr,
            active_limits,
            g.last_spotify_write_rate_limit.clone(),
        )
    };

    let diag_buf = { state.lock().await.diagnostics.clone() };
    let diag_entries: Vec<DiagEntry> = diag_buf
        .lock()
        .map(|g| g.iter().cloned().collect())
        .unwrap_or_default();

    let resource_dir = app.path().resource_dir().ok();
    let candidates = native_audio_candidates(&data_dir, resource_dir.as_deref());
    let selected_candidate = find_native_audio_binary(&data_dir, resource_dir.as_deref());

    let (live_devices, live_player) = match auth_token(state).await {
        Ok((http, token)) => (
            fetch_devices(&http, &token).await,
            sp_get_retrying(state, &http, &token, "me/player").await,
        ),
        Err(err) => {
            let message = format!("Auth unavailable: {err}");
            (Err(message.clone()), Err(message))
        }
    };

    // Enumerate audio outputs
    let host = cpal::default_host();
    let default_dev = host.default_output_device().and_then(|d| d.name().ok()).unwrap_or_else(|| "<none>".into());
    let all_outputs: Vec<String> = host.output_devices()
        .map(|it| it.filter_map(|d| d.name().ok()).collect())
        .unwrap_or_default();

    let mut report = String::new();
    let _ = writeln!(report, "# Lightify Debug Report");
    let _ = writeln!(report, "Generated: `{}` (unix)", report_now);
    let _ = writeln!(report, "");
    let _ = writeln!(report, "## Auth");
    let _ = writeln!(report, "| Key | Value |");
    let _ = writeln!(report, "|-----|-------|");
    let _ = writeln!(report, "| Token present | {} |", token_present);
    let _ = writeln!(report, "| User ID | `{}` |", if user_id.is_empty() { "(none)".into() } else { user_id });
    let _ = writeln!(report, "| Client ID (first 8) | `{}` |", cfg_client_id);
    let _ = writeln!(report, "");
    let _ = writeln!(report, "## Native Audio Backend");
    let _ = writeln!(report, "| Key | Value |");
    let _ = writeln!(report, "|-----|-------|");
    let _ = writeln!(report, "| Process running | {} |", native_running);
    let _ = writeln!(report, "| Selected binary | `{}` |", selected_candidate.as_ref().map(|path| path.display().to_string()).unwrap_or_else(|| "(none)".to_string()));
    let _ = writeln!(report, "| Configured output | `{}` |", if cfg_audio_output.is_empty() { "System Default".into() } else { cfg_audio_output });
    let _ = writeln!(report, "| Data dir | `{}` |", data_dir.display());
    let _ = writeln!(report, "");
    let _ = writeln!(report, "### Binary search candidates");
    let _ = writeln!(report, "| Path | Exists | Size bytes | Modified unix |");
    let _ = writeln!(report, "|------|--------|------------|---------------|");
    for p in &candidates {
        let metadata = fs::metadata(p).ok();
        let exists = metadata.is_some();
        let size = metadata.as_ref().map(|m| m.len().to_string()).unwrap_or_default();
        let modified = metadata
            .as_ref()
            .and_then(|m| m.modified().ok())
            .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|duration| duration.as_secs().to_string())
            .unwrap_or_default();
        let selected = selected_candidate.as_ref().map(|selected| selected == p).unwrap_or(false);
        let _ = writeln!(
            report,
            "| `{}`{} | {} | `{}` | `{}` |",
            p.display(),
            if selected { " ⭐" } else { "" },
            if exists { "✅ YES" } else { "❌ no" },
            size,
            modified,
        );
    }
    let _ = writeln!(report, "");
    let _ = writeln!(report, "## Recent Diagnostics ({} recorded)", diag_entries.len());
    if diag_entries.is_empty() {
        let _ = writeln!(report, "*(no diagnostics recorded yet)*");
    } else {
        let _ = writeln!(report, "| unix | level | category | message |");
        let _ = writeln!(report, "|------|-------|----------|---------|");
        for entry in diag_entries.iter().rev().take(60) {
            let _ = writeln!(
                report,
                "| `{}` | {} | `{}` | {} |",
                entry.ts, entry.level, entry.category, entry.message,
            );
        }
    }
    let _ = writeln!(report, "");
    let _ = writeln!(report, "## Live Spotify Routing");
    match &live_devices {
        Ok(devices) => {
            let _ = writeln!(report, "### Devices");
            let _ = writeln!(report, "| Name | Active | Id |");
            let _ = writeln!(report, "|------|--------|----|");
            if devices.is_empty() {
                let _ = writeln!(report, "| *(no devices returned)* | | |");
            } else {
                for device in devices {
                    let _ = writeln!(
                        report,
                        "| `{}` | {} | `{}` |",
                        device.name,
                        if device.is_active { "✅" } else { "" },
                        device.id,
                    );
                }
            }
        }
        Err(err) => {
            let _ = writeln!(report, "### Devices");
            let _ = writeln!(report, "Failed to query devices: `{}`", err);
        }
    }
    match &live_player {
        Ok(player) if player.is_null() => {
            let _ = writeln!(report, "### Player");
            let _ = writeln!(report, "No active playback returned by `me/player`.");
        }
        Ok(player) => {
            let _ = writeln!(report, "### Player");
            let _ = writeln!(report, "| Key | Value |");
            let _ = writeln!(report, "|-----|-------|");
            let _ = writeln!(report, "| Is playing | {} |", player["is_playing"].as_bool().unwrap_or(false));
            let _ = writeln!(report, "| Device name | `{}` |", player["device"]["name"].as_str().unwrap_or(""));
            let _ = writeln!(report, "| Device id | `{}` |", player["device"]["id"].as_str().unwrap_or(""));
            let _ = writeln!(report, "| Track id | `{}` |", player["item"]["id"].as_str().unwrap_or(""));
            let _ = writeln!(report, "| Track name | `{}` |", player["item"]["name"].as_str().unwrap_or(""));
        }
        Err(err) => {
            let _ = writeln!(report, "### Player");
            let _ = writeln!(report, "Failed to query `me/player`: `{}`", err);
        }
    }
    let _ = writeln!(report, "");
    let _ = writeln!(report, "## Spotify Write Rate Limits");
    if active_spotify_write_limits.is_empty() {
        let _ = writeln!(report, "No active Spotify write cooldowns.");
    } else {
        let _ = writeln!(report, "| Family | Method | Path | Retry-After | Remaining | Status | Observed unix |");
        let _ = writeln!(report, "|--------|--------|------|-------------|-----------|--------|---------------|");
        for entry in &active_spotify_write_limits {
            let _ = writeln!(
                report,
                "| `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` |",
                entry.family,
                entry.method,
                entry.path,
                entry.retry_after_secs,
                entry.blocked_until_unix.saturating_sub(report_now),
                entry.status,
                entry.observed_at_unix,
            );
        }
    }
    match &last_spotify_write_limit {
        Some(entry) => {
            let _ = writeln!(report, "Last observed write rate limit: `{}` on `{}` `{}` (retry-after `{}`s).",
                entry.status,
                entry.method,
                entry.path,
                entry.retry_after_secs,
            );
        }
        None => {
            let _ = writeln!(report, "Last observed write rate limit: none.");
        }
    }
    let _ = writeln!(report, "");
    let _ = writeln!(report, "## Audio Output Devices");
    let _ = writeln!(report, "| Device | Default |");
    let _ = writeln!(report, "|--------|---------|");
    for name in &all_outputs {
        let is_default = *name == default_dev;
        let _ = writeln!(report, "| `{}` | {} |", name, if is_default { "⭐" } else { "" });
    }
    if all_outputs.is_empty() {
        let _ = writeln!(report, "| *(no output devices found)* | |");
    }
    let _ = writeln!(report, "");
    let _ = writeln!(report, "## lightify-audio.exe stderr (last {} lines)", stderr_lines.len());
    if stderr_lines.is_empty() {
        let _ = writeln!(report, "*(no output captured — process may not have run yet)*");
    } else {
        let _ = writeln!(report, "```");
        for line in &stderr_lines {
            let _ = writeln!(report, "{}", line);
        }
        let _ = writeln!(report, "```");
    }

    let _ = writeln!(report, "");
    let _ = writeln!(report, "## OnTheSpot Bridge");
    let _ = writeln!(report, "| Key | Value |");
    let _ = writeln!(report, "|-----|-------|");
    let _ = writeln!(report, "| Managed process running | {} |", onthespot_running);
    let _ = writeln!(report, "| URL | `{}` |", onthespot_root_url());
    let _ = writeln!(report, "| Log file | `{}` |", data_dir.join("onthespot-web.log").display());
    let _ = writeln!(report, "");
    let _ = writeln!(report, "## OnTheSpot Bridge Logs (last {} lines)", onthespot_lines.len());
    if onthespot_lines.is_empty() {
        let _ = writeln!(report, "*(no bridge output captured yet)*");
    } else {
        let _ = writeln!(report, "```");
        for line in &onthespot_lines {
            let _ = writeln!(report, "{}", line);
        }
        let _ = writeln!(report, "```");
    }

    Ok(report)
}

#[tauri::command]
async fn cmd_debug_report(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<String, String> {
    build_debug_report(state.inner(), &app).await
}

#[tauri::command]
async fn cmd_export_debug(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<String, String> {
    let report = build_debug_report(state.inner(), &app).await?;

    let data_dir = {
        let g = state.lock().await;
        g.data_dir.clone()
    };

    // Write next to the exe — always predictable regardless of launch method
    let out_path = if let Ok(exe) = std::env::current_exe() {
        exe.parent().unwrap_or(std::path::Path::new(".")).join("lightify-debug.md")
    } else if let Ok(cwd) = std::env::current_dir() {
        cwd.join("lightify-debug.md")
    } else {
        data_dir.join("lightify-debug.md")
    };

    std::fs::write(&out_path, &report).map_err(|e| format!("Failed to write debug report: {e}"))?;
    Ok(out_path.display().to_string())
}

#[tauri::command]
async fn cmd_logout(state: tauri::State<'_, AppState>) -> Result<(), String> {
    let mut g = state.lock().await;
    config::delete_token(&g.data_dir);
    g.token = None;
    g.user_id = String::new();
    g.active_device_id = None;
    stop_managed_children(&mut g);
    Ok(())
}

// ── Audio output device (local OS) ──────────────────────────────────────────

fn current_audio_outputs() -> Result<Vec<String>, String> {
    use cpal::traits::{DeviceTrait, HostTrait};
    let host = cpal::default_host();
    let default_name = host
        .default_output_device()
        .and_then(|d| d.name().ok())
        .unwrap_or_default();

    let mut names: Vec<String> = host
        .output_devices()
        .map_err(|e| e.to_string())?
        .filter_map(|d| d.name().ok())
        .collect();

    // Dedup, stable order, default first.
    names.sort();
    names.dedup();
    if !default_name.is_empty() {
        names.retain(|n| n != &default_name);
        names.insert(0, default_name);
    }
    Ok(names)
}

#[tauri::command]
async fn cmd_list_audio_outputs() -> Result<Vec<String>, String> {
    current_audio_outputs()
}

#[tauri::command]
async fn cmd_get_audio_output(state: tauri::State<'_, AppState>) -> Result<String, String> {
    Ok(state.lock().await.cfg.audio_output.clone())
}

#[tauri::command]
async fn cmd_set_audio_output(
    name: String,
    state: tauri::State<'_, AppState>,
) -> Result<(), String> {
    let mut g = state.lock().await;
    g.cfg.audio_output = name;
    config::save_config(&g.data_dir, &g.cfg);
    Ok(())
}

#[tauri::command]
async fn cmd_queue(state: tauri::State<'_, AppState>) -> Result<Vec<Track>, String> {
    let (http, token) = auth_token(&state).await?;
    let v = sp_get_retrying(&state, &http, &token, "me/player/queue").await?;
    Ok(v["queue"].as_array()
        .map(|a| a.iter().filter_map(spotify::parse_track).collect())
        .unwrap_or_default())
}

#[tauri::command]
async fn cmd_add_to_queue(uri: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    // Validate at the boundary: Spotify's queue endpoint only accepts track URIs,
    // and the frontend optimistically mirrors the add — a rejected context/album
    // URI would otherwise desync the local queue from Spotify's.
    validate_track_uri(&uri)?;
    let (http, token) = auth_token(&state).await?;
    let enc = url_encode(&uri);
    post_player(&state, &http, &token, &format!("me/player/queue?uri={enc}"), None).await.map(|_| ())
}

#[tauri::command]
async fn cmd_enqueue_download(url: String, state: tauri::State<'_, AppState>) -> Result<String, String> {
    let normalized = normalize_download_url(&url)?;
    ensure_onthespot_web(state.inner()).await?;
    if normalized.starts_with("https://open.spotify.com/") {
        let downloader_status = fetch_downloader_status(state.inner()).await?;
        if !downloader_status.spotify_ready {
            let _ = start_downloader_spotify_login(state.inner()).await?;
            return Err("Spotify downloader setup started. Open Spotify, choose OnTheSpot from the device picker, then retry Download.".to_string());
        }
    }
    let client = onthespot_http()?;
    let cookie = onthespot_session_cookie(&client).await?;
    client
        .post(format!("{}/api/parse_url/{}", onthespot_root_url(), url_encode(&normalized)))
        .header(reqwest::header::COOKIE, cookie)
        .send()
        .await
        .map_err(|e| format!("Failed to enqueue download: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Failed to enqueue download: {e}"))?;
    Ok(normalized)
}

#[tauri::command]
async fn cmd_downloader_status(state: tauri::State<'_, AppState>) -> Result<DownloaderStatus, String> {
    fetch_downloader_status(state.inner()).await
}

#[tauri::command]
async fn cmd_start_downloader_login(state: tauri::State<'_, AppState>) -> Result<DownloaderStatus, String> {
    start_downloader_spotify_login(state.inner()).await
}

#[tauri::command]
async fn cmd_downloads_snapshot(state: tauri::State<'_, AppState>) -> Result<Vec<DownloadQueueItem>, String> {
    ensure_onthespot_web(state.inner()).await?;
    let client = onthespot_http()?;
    let cookie = onthespot_session_cookie(&client).await?;
    let snapshot: serde_json::Value = client
        .get(format!("{}/api/download_queue", onthespot_root_url()))
        .header(reqwest::header::COOKIE, cookie)
        .send()
        .await
        .map_err(|e| format!("Failed to fetch downloads: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Failed to fetch downloads: {e}"))?
        .json()
        .await
        .map_err(|e| format!("Failed to parse downloads queue: {e}"))?;

    let mut items = snapshot
        .as_object()
        .map(|object| {
            object
                .values()
                .filter_map(|value| value.as_object())
                .map(download_queue_item_from_json)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    items.sort_by(|left, right| left.local_id.cmp(&right.local_id));
    Ok(items)
}

#[tauri::command]
async fn cmd_recently_played(state: tauri::State<'_, AppState>) -> Result<Vec<Track>, String> {
    let (http, token) = auth_token(&state).await?;
    let v = sp_get_retrying(&state, &http, &token, "me/player/recently-played?limit=50").await?;
    Ok(v["items"].as_array()
        .map(|a| a.iter().filter_map(|item| {
            let mut track = spotify::parse_track(&item["track"])?;
            track.added_at = item["played_at"].as_str().map(|value| value.to_string());
            Some(track)
        }).collect())
        .unwrap_or_default())
}

#[tauri::command]
async fn cmd_check_liked(track_id: String, state: tauri::State<'_, AppState>) -> Result<bool, String> {
    validate_spotify_id(&track_id, "track")?;
    let (http, token) = auth_token(&state).await?;
    let track_uri = url_encode(&format!("spotify:track:{track_id}"));
    let v = sp_get_retrying(&state, &http, &token, &format!("me/library/contains?uris={track_uri}")).await?;
    Ok(v.as_array().and_then(|a| a.first()).and_then(|b| b.as_bool()).unwrap_or(false))
}

#[tauri::command]
async fn cmd_liked_tracks_page(
    offset: u32,
    limit: Option<u32>,
    refresh: Option<bool>,
    state: tauri::State<'_, AppState>,
) -> Result<PlaylistTracksPage, String> {
    let (http, token) = auth_token(&state).await?;
    let limit = limit.unwrap_or(LIKED_TRACKS_PAGE_LIMIT).clamp(1, LIKED_TRACKS_PAGE_LIMIT);
    let (data_dir, user_id) = cache_identity(&state).await;
    let cached_page = read_cached_track_page(&data_dir, &user_id, "liked", "me", offset, limit);
    if !refresh.unwrap_or(false) {
        if let Some(page) = cached_page.clone() {
            return Ok(page);
        }
    }

    let v = match sp_get_retrying(&state, &http, &token, &format!("me/tracks?limit={limit}&offset={offset}&market=from_token")).await {
        Ok(value) => value,
        Err(err) if is_spotify_rate_limited_error(&err) => {
            if let Some(page) = cached_page {
                return Ok(page);
            }
            return Err(err);
        }
        Err(err) => return Err(err),
    };
    let tracks = v["items"].as_array()
        .map(|items| {
            items
                .iter()
                .filter_map(|item| spotify::parse_track(&item["track"]).map(|track| with_track_added_at(track, item["added_at"].as_str())))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let total = v["total"].as_u64().unwrap_or(offset as u64 + tracks.len() as u64) as u32;
    let next_offset = v["next"].as_str()
        .filter(|next| !next.is_empty())
        .map(|_| offset.saturating_add(limit));
    let page = PlaylistTracksPage {
        tracks,
        total,
        offset,
        limit,
        next_offset,
        done: next_offset.is_none(),
        cached: false,
    };
    write_cached_track_page(&data_dir, &user_id, "liked", "me", &page);
    Ok(page)
}

#[tauri::command]
async fn cmd_toggle_like(
    track_id: String,
    currently_liked: bool,
    state: tauri::State<'_, AppState>,
) -> Result<bool, String> {
    validate_spotify_id(&track_id, "track")?;
    let (http, token) = auth_token(&state).await?;
    let track_uri = url_encode(&format!("spotify:track:{track_id}"));
    let path = format!("me/library?uris={track_uri}");
    if currently_liked {
        sp_no_body_retrying(&state, &http, &token, reqwest::Method::DELETE, &path).await?;
        let (data_dir, user_id) = cache_identity(&state).await;
        clear_cached_track_source(&data_dir, &user_id, "liked", "me");
        Ok(false)
    } else {
        sp_no_body_retrying(&state, &http, &token, reqwest::Method::PUT, &path).await?;
        let (data_dir, user_id) = cache_identity(&state).await;
        clear_cached_track_source(&data_dir, &user_id, "liked", "me");
        Ok(true)
    }
}

#[tauri::command]
async fn cmd_start_station(track_id: String, state: tauri::State<'_, AppState>) -> Result<Vec<Track>, String> {
    validate_spotify_id(&track_id, "track")?;
    let (http, token) = auth_token(&state).await?;
    let seed_v = sp_get_retrying(&state, &http, &token, &format!("tracks/{track_id}")).await?;
    let seed_track = spotify::parse_track(&seed_v).ok_or("Failed to load seed track")?;

    let fallback_artist_id = seed_v["artists"].as_array()
        .and_then(|artists| artists.first())
        .and_then(|artist| artist["id"].as_str())
        .map(|id| id.to_string());
    let fallback_album_id = seed_v["album"]["id"].as_str().map(|id| id.to_string());

    let recommendation_tracks = match sp_get_retrying(
        &state,
        &http,
        &token,
        &format!("recommendations?limit=49&seed_tracks={track_id}"),
    ).await {
        Ok(recommendations_v) => {
            let tracks = recommendations_v["tracks"].as_array()
                .map(|items| items.iter().filter_map(spotify::parse_track).collect::<Vec<_>>())
                .unwrap_or_default();
            if tracks.is_empty() {
                if let Some(artist_id) = fallback_artist_id.as_deref() {
                    match fetch_artist_tracks_page(&state, &http, &token, artist_id, None, 0, ARTIST_TRACK_SEARCH_PAGE_LIMIT).await {
                        Ok(page) if !page.tracks.is_empty() => page.tracks,
                        Ok(_) | Err(_) => {
                            let Some(album_id) = fallback_album_id.as_deref() else {
                                return Err("Station returned no continuation tracks".into());
                            };
                            let album_v = sp_get_retrying(&state, &http, &token, &format!("albums/{album_id}")).await?;
                            parse_album_tracks(&album_v)
                        }
                    }
                } else if let Some(album_id) = fallback_album_id.as_deref() {
                    let album_v = sp_get_retrying(&state, &http, &token, &format!("albums/{album_id}")).await?;
                    parse_album_tracks(&album_v)
                } else {
                    return Err("Station returned no continuation tracks".into());
                }
            } else {
                tracks
            }
        }
        Err(e) if e.contains("Spotify 404") => {
            if let Some(artist_id) = fallback_artist_id.as_deref() {
                match fetch_artist_tracks_page(&state, &http, &token, artist_id, None, 0, ARTIST_TRACK_SEARCH_PAGE_LIMIT).await {
                    Ok(page) if !page.tracks.is_empty() => page.tracks,
                    Ok(_) | Err(_) => {
                        if let Some(album_id) = fallback_album_id.as_deref() {
                            let album_v = sp_get_retrying(&state, &http, &token, &format!("albums/{album_id}")).await?;
                            parse_album_tracks(&album_v)
                        } else {
                            return Err(e);
                        }
                    }
                }
            } else if let Some(album_id) = fallback_album_id.as_deref() {
                let album_v = sp_get_retrying(&state, &http, &token, &format!("albums/{album_id}")).await?;
                parse_album_tracks(&album_v)
            } else {
                return Err(e);
            }
        }
        Err(e) => return Err(e),
    };

    let seed_uri = seed_track.uri.trim().to_string();
    if seed_uri.is_empty() {
        return Err("Seed track missing uri".into());
    }

    let mut seen = std::collections::HashSet::new();
    let mut station_tracks = Vec::with_capacity(recommendation_tracks.len() + 1);
    seen.insert(seed_uri.clone());
    station_tracks.push(seed_track.clone());
    for track in recommendation_tracks {
        let uri = track.uri.trim();
        if !uri.is_empty() && seen.insert(uri.to_string()) {
            station_tracks.push(track);
        }
    }

    if station_tracks.len() <= 1 {
        return Err("Station returned no continuation tracks".into());
    }

    Ok(station_tracks)
}

#[tauri::command]
async fn cmd_beatport_genres() -> Result<Vec<(String, String)>, String> {
    Ok(beatport::genre_list()
        .into_iter()
        .map(|(n, s)| (n.to_string(), s.to_string()))
        .collect())
}

#[tauri::command]
async fn cmd_beatport_fetch(
        genre_slug: String,
        chart_kind: String,
        state: tauri::State<'_, AppState>,
) -> Result<Vec<beatport::BeatportTrack>, String> {
    let http = state.lock().await.http.clone();
        beatport::fetch_chart(&http, &genre_slug, &chart_kind).await
}

#[tauri::command]
fn cmd_open_beatport_chart(genre_slug: String, chart_kind: String) -> Result<(), String> {
    if !beatport::genre_list().iter().any(|(_, slug)| *slug == genre_slug) {
        return Err("Unknown Beatport genre".to_string());
    }
        let url = beatport::chart_url(&genre_slug, &chart_kind)?;
    open::that(&url).map_err(|e| format!("Failed to open browser: {e}"))
}

const BEATPORT_SESSION_FETCH_WINDOW_LABEL: &str = "beatport-session-fetch";
const BEATPORT_CHALLENGE_WINDOW_LABEL: &str = "beatport-challenge";

fn build_beatport_import_script(timeout_command: Option<&str>) -> String {
        let timeout_block = timeout_command
                .map(|command| {
                        format!(
                                r#"
    function _timeout() {{
        if (_done) return;
        _done = true;
        _invoke('{command}', {{}}, 20);
    }}
"#
                        )
                })
                .unwrap_or_else(|| {
                        "
    function _timeout() {
        _done = true;
    }
"
                        .to_string()
                });

        format!(
                r#"(function () {{
    'use strict';
    var _polls = 0;
    var _done = false;

    function _invoke(cmd, payload, retries) {{
        var t = window.__TAURI__;
        if (t && t.core && t.core.invoke) {{
            t.core.invoke(cmd, payload)
                .catch(function (e) {{ console.error('[Lightify] Beatport import IPC error:', e); }});
            return;
        }}
        if (retries > 0) setTimeout(function () {{ _invoke(cmd, payload, retries - 1); }}, 100);
    }}

    function _send(tracks) {{
        if (_done) return;
        _done = true;
        _invoke('cmd_beatport_challenge_result', {{ rawTracks: tracks }}, 20);
    }}

{timeout_block}

    function _extractTracks() {{
        var el = document.getElementById('__NEXT_DATA__');
        if (!el) return null;
        try {{
            var d = JSON.parse(el.textContent || '');
            var qs = (d.props && d.props.pageProps &&
                                d.props.pageProps.dehydratedState &&
                                d.props.pageProps.dehydratedState.queries) || [];
            for (var i = 0; i < qs.length; i++) {{
                var results = qs[i].state && qs[i].state.data && qs[i].state.data.results;
                if (Array.isArray(results) && results.length >= 5) {{
                    return results.slice(0, 100);
                }}
            }}
        }} catch (e) {{}}
        return null;
    }}

    function _poll() {{
        if (_done) return;
        _polls++;
        var tracks = _extractTracks();
        if (Array.isArray(tracks) && tracks.length >= 5) {{
            _send(tracks);
            return;
        }}
        if (_polls < 24) {{
            setTimeout(_poll, 500);
            return;
        }}
        _timeout();
    }}

    if (document.readyState === 'loading') {{
        document.addEventListener('DOMContentLoaded', function () {{ setTimeout(_poll, 400); }});
    }} else {{
        setTimeout(_poll, 400);
    }}
}})();
"#,
                timeout_block = timeout_block
        )
}

/// JavaScript injected into the Beatport challenge WebView on every document creation.
/// Polls for __NEXT_DATA__ with track results (only present once Cloudflare is solved),
/// then sends the raw track array to cmd_beatport_challenge_result via IPC.
#[tauri::command]
async fn cmd_beatport_challenge(
    genre_slug: String,
        chart_kind: String,
    app: tauri::AppHandle,
) -> Result<(), String> {
    if !genre_slug.is_empty() && !beatport::genre_list().iter().any(|(_, slug)| *slug == genre_slug) {
        return Err("Unknown Beatport genre".to_string());
    }
        let url = beatport::chart_url(&genre_slug, &chart_kind)?;
        if let Some(existing) = app.get_webview_window(BEATPORT_CHALLENGE_WINDOW_LABEL) {
        let _ = existing.close();
    }
    let parsed: tauri::Url = url.parse().map_err(|e| format!("URL parse error: {e}"))?;
    tauri::WebviewWindowBuilder::new(
        &app,
                BEATPORT_CHALLENGE_WINDOW_LABEL,
        tauri::WebviewUrl::External(parsed),
    )
    .title("Beatport \u{2014} Complete Verification")
    .inner_size(520.0, 660.0)
    .center()
        .initialization_script(&build_beatport_import_script(None))
    .build()
    .map_err(|e| format!("Failed to open verification window: {e}"))?;
    Ok(())
}

#[tauri::command]
async fn cmd_beatport_session_fetch(
        genre_slug: String,
        chart_kind: String,
        app: tauri::AppHandle,
) -> Result<(), String> {
        if !genre_slug.is_empty() && !beatport::genre_list().iter().any(|(_, slug)| *slug == genre_slug) {
                return Err("Unknown Beatport genre".to_string());
        }
        let url = beatport::chart_url(&genre_slug, &chart_kind)?;
        if let Some(existing) = app.get_webview_window(BEATPORT_SESSION_FETCH_WINDOW_LABEL) {
                let _ = existing.close();
        }
        let parsed: tauri::Url = url.parse().map_err(|e| format!("URL parse error: {e}"))?;
        tauri::WebviewWindowBuilder::new(
                &app,
                BEATPORT_SESSION_FETCH_WINDOW_LABEL,
                tauri::WebviewUrl::External(parsed),
        )
        .title("Beatport Session Fetch")
        .inner_size(420.0, 760.0)
        .position(-32000.0, -32000.0)
        .focused(false)
        .skip_taskbar(true)
        .visible(false)
        .initialization_script(&build_beatport_import_script(Some("cmd_beatport_session_fetch_failed")))
        .build()
        .map_err(|e| format!("Beatport session fetch window failed: {e}"))?;
        Ok(())
}

#[tauri::command]
fn cmd_beatport_session_fetch_failed(app: tauri::AppHandle) -> Result<(), String> {
        if let Some(w) = app.get_webview_window(BEATPORT_SESSION_FETCH_WINDOW_LABEL) {
                let _ = w.close();
        }
        app.emit("beatport-verification-required", serde_json::json!({}))
                .map_err(|e| format!("Event emit error: {e}"))
}

#[tauri::command]
fn cmd_beatport_challenge_result(
    raw_tracks: Vec<serde_json::Value>,
    app: tauri::AppHandle,
) -> Result<(), String> {
    let tracks = beatport::extract_track_array(&raw_tracks);
    if tracks.is_empty() {
        return Err("No tracks found after verification".to_string());
    }
    app.emit("beatport-tracks-loaded", &tracks)
        .map_err(|e| format!("Event emit error: {e}"))?;
    if let Some(w) = app.get_webview_window(BEATPORT_CHALLENGE_WINDOW_LABEL) {
        let _ = w.close();
    }
    if let Some(w) = app.get_webview_window(BEATPORT_SESSION_FETCH_WINDOW_LABEL) {
        let _ = w.close();
    }
    Ok(())
}

fn normalize_match_text(value: &str) -> String {
    let mut normalized = String::with_capacity(value.len());
    let mut last_was_space = true;

    for ch in value.chars() {
        let lower = ch.to_ascii_lowercase();
        if lower.is_ascii_alphanumeric() {
            normalized.push(lower);
            last_was_space = false;
        } else if !last_was_space {
            normalized.push(' ');
            last_was_space = true;
        }
    }

    normalized.trim().to_string()
}

fn normalize_artist_list(artists: &str) -> Vec<String> {
    artists
        .split(',')
        .map(normalize_match_text)
        .filter(|artist| !artist.is_empty())
        .collect()
}

fn token_overlap_score(left: &str, right: &str) -> i32 {
    let left_tokens: std::collections::HashSet<&str> = left.split_whitespace().collect();
    let right_tokens: std::collections::HashSet<&str> = right.split_whitespace().collect();
    if left_tokens.is_empty() || right_tokens.is_empty() {
        return 0;
    }
    let overlap = left_tokens.intersection(&right_tokens).count() as i32;
    let scale = left_tokens.len().max(right_tokens.len()) as i32;
    (overlap * 100) / scale.max(1)
}

fn score_beatport_track_match(target_name: &str, target_artists: &[String], track: &Track) -> i32 {
    let candidate_name = normalize_match_text(&track.name);
    if candidate_name.is_empty() || target_name.is_empty() {
        return 0;
    }

    let mut score = 0;

    if candidate_name == target_name {
        score += 500;
    } else if candidate_name.contains(target_name) || target_name.contains(&candidate_name) {
        score += 280;
    }
    score += token_overlap_score(target_name, &candidate_name) * 3;

    let candidate_artists = normalize_artist_list(&track.artists);
    if let Some(primary_artist) = target_artists.first() {
        if candidate_artists.iter().any(|artist| artist == primary_artist) {
            score += 180;
        } else if candidate_artists.iter().any(|artist| artist.contains(primary_artist) || primary_artist.contains(artist)) {
            score += 90;
        }
    }

    let mut exact_artist_hits = 0;
    for artist in target_artists {
        if candidate_artists.iter().any(|candidate| candidate == artist) {
            exact_artist_hits += 1;
            score += 70;
        } else if candidate_artists.iter().any(|candidate| candidate.contains(artist) || artist.contains(candidate)) {
            score += 30;
        }
    }

    if exact_artist_hits == 0 && !target_artists.is_empty() {
        score -= 120;
    }

    score
}

async fn beatport_match_track(
    state: &AppState,
    http: &reqwest::Client,
    token: &str,
    app: &tauri::AppHandle,
    name: &str,
    artists: &str,
) -> Result<Option<Track>, String> {
    let clean_name = beatport::clean_bp_name(name);
    let normalized_name = normalize_match_text(&clean_name);
    if normalized_name.is_empty() {
        return Ok(None);
    }

    let target_artists = normalize_artist_list(artists);
    let first_artist = artists.split(',').next().unwrap_or(artists).trim();
    let queries = [
        format!("track:{clean_name} artist:{first_artist}"),
        format!("{clean_name} {first_artist}"),
    ];

    let mut candidates = Vec::new();
    let mut seen_ids = std::collections::HashSet::new();

    for query in queries {
        let q = url_encode(&query);
        match sp_get_retrying(state, http, token, &format!("search?q={q}&type=track&limit=8&market=from_token")).await {
            Ok(v) => {
                let items = v["tracks"]["items"].as_array().cloned().unwrap_or_default();
                for item in items.iter().filter_map(spotify::parse_track) {
                    if seen_ids.insert(item.id.clone()) {
                        candidates.push(item);
                    }
                }
            }
            Err(err) if is_spotify_rate_limited_error(&err) => {
                let items = fetch_spotify_search_tracks_via_fallback(app, state, http, token, &query, 8).await?;
                for item in items {
                    if seen_ids.insert(item.id.clone()) {
                        candidates.push(item);
                    }
                }
            }
            Err(err) => return Err(err),
        }
        if !candidates.is_empty() {
            break;
        }
    }

    let best = candidates
        .into_iter()
        .map(|track| {
            let score = score_beatport_track_match(&normalized_name, &target_artists, &track);
            (score, track)
        })
        .max_by_key(|(score, _)| *score);

    match best {
        Some((score, track)) if score >= 160 => Ok(Some(track)),
        _ => Ok(None),
    }
}

#[tauri::command]
async fn cmd_beatport_match_track(
    name: String,
    artists: String,
    state: tauri::State<'_, AppState>,
    app: tauri::AppHandle,
) -> Result<Option<Track>, String> {
    let (http, token) = auth_token(&state).await?;
    beatport_match_track(state.inner(), &http, &token, &app, &name, &artists).await
}

#[tauri::command]
async fn cmd_beatport_search_track(
    name: String,
    artists: String,
    state: tauri::State<'_, AppState>,
    app: tauri::AppHandle,
) -> Result<Option<String>, String> {
    let (http, token) = auth_token(&state).await?;
    Ok(beatport_match_track(state.inner(), &http, &token, &app, &name, &artists).await?
        .map(|track| track.uri))
}

#[tauri::command]
async fn cmd_beatport_create_playlist(
    playlist_name: String,
    tracks: Vec<BpSearchInput>,
    state: tauri::State<'_, AppState>,
    app: tauri::AppHandle,
) -> Result<BpCreateResult, String> {
    let (http, _initial_token) = auth_token(&state).await?;
    if state.lock().await.user_id.is_empty() {
        return Err("Not authenticated (no user id)".into());
    }
    let total = tracks.len() as u32;

    // Search each track on Spotify. Re-fetch the token before every call so a
    // mid-batch expiry doesn't abort the entire creation.
    let mut uris: Vec<String> = Vec::with_capacity(tracks.len());
    let mut seen = std::collections::HashSet::<String>::new();
    for t in &tracks {
        let (_, token) = match auth_token(&state).await {
            Ok(p) => p,
            Err(_) => continue,
        };
        match beatport_match_track(state.inner(), &http, &token, &app, &t.name, &t.artists).await {
            Ok(Some(track)) => {
                let uri = track.uri.trim();
                if !uri.is_empty() {
                    let s = uri.to_string();
                    if seen.insert(s.clone()) {
                        uris.push(s);
                    }
                }
            }
            Ok(None) => {}
            Err(err) if is_spotify_rate_limited_error(&err) => return Err(err),
            Err(_) => {}
        }
    }

    let found = uris.len() as u32;
    if uris.is_empty() {
        return Err("No tracks matched on Spotify".into());
    }

    // Create the playlist (re-fetch token: search loop may have taken minutes).
    let (_, token) = auth_token(&state).await?;
    let pl = sp_post_retrying(
        &state,
        &http,
        &token,
        "me/playlists",
        Some(serde_json::json!({
            "name": playlist_name,
            "public": false,
            "description": "Created by Lightify from Beatport Top 100",
        })),
    ).await?;

    let pl_id = pl["id"].as_str().ok_or("Failed to get playlist ID")?;
    let pl_name = pl["name"].as_str().unwrap_or(&playlist_name).to_string();

    // Add tracks in batches of 100
    for chunk in uris.chunks(100) {
        let (_, token) = auth_token(&state).await?;
        sp_post_retrying(&state, &http, &token, &format!("playlists/{pl_id}/items"),
            Some(serde_json::json!({ "uris": chunk }))).await?;
    }

    Ok(BpCreateResult { name: pl_name, tracks_found: found, tracks_total: total })
}

#[tauri::command]
async fn cmd_spotify_search_fallback_result(
    query: String,
    kind: String,
    entries: Vec<SpotifySearchFallbackEntry>,
    error: String,
    state: tauri::State<'_, AppState>,
    app: tauri::AppHandle,
) -> Result<(), String> {
    let expected_kind = match kind.as_str() {
        "track" => SpotifySearchFallbackKind::Track,
        "playlist" => SpotifySearchFallbackKind::Playlist,
        _ => return Ok(()),
    };

    let sender = {
        let mut guard = state.lock().await;
        let Some(pending) = guard.pending_spotify_search_fallback.take() else {
            return Ok(());
        };
        if pending.query != query || pending.kind != expected_kind {
            guard.pending_spotify_search_fallback = Some(pending);
            return Ok(());
        }
        pending.sender
    };

    if let Some(window) = app.get_webview_window(SPOTIFY_SEARCH_FALLBACK_WINDOW_LABEL) {
        let _ = window.close();
    }

    let payload = if error.trim().is_empty() {
        Ok(entries)
    } else {
        Err(error)
    };
    let _ = sender.send(payload);
    Ok(())
}

#[tauri::command]
async fn cmd_create_playlist(
    name: String,
    track_uris: Vec<String>,
    state: tauri::State<'_, AppState>,
) -> Result<Playlist, String> {
    let (http, token) = auth_token(&state).await?;
    if state.lock().await.user_id.is_empty() {
        return Err("Not authenticated (no user id)".into());
    }

    let pl = sp_post_retrying(
        &state,
        &http,
        &token,
        "me/playlists",
        Some(serde_json::json!({ "name": name, "public": false })),
    ).await?;

    let pl_id = pl["id"].as_str().ok_or("Failed to get playlist ID")?;

    for chunk in track_uris.chunks(100) {
        let (_, token) = auth_token(&state).await?;
        sp_post_retrying(&state, &http, &token, &format!("playlists/{pl_id}/items"),
            Some(serde_json::json!({ "uris": chunk }))).await?;
    }

    Ok(Playlist {
        id: pl_id.to_string(),
        name: pl["name"].as_str().unwrap_or(&name).to_string(),
        tracks: track_uris.len() as u32,
        owner: String::new(),
        uri: pl["uri"].as_str().unwrap_or("").to_string(),
    })
}

// ── Download path ────────────────────────────────────────────────────────────

#[tauri::command]
async fn cmd_get_download_path(state: tauri::State<'_, AppState>) -> Result<String, String> {
    Ok(configured_download_path(state.inner()).await)
}

#[tauri::command]
async fn cmd_set_download_path(path: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let path = path.trim().to_string();
    if path.is_empty() {
        return Err("Download path cannot be empty".to_string());
    }
    std::fs::create_dir_all(&path)
        .map_err(|e| format!("Failed to create download path {}: {e}", path))?;
    {
        let mut g = state.lock().await;
        g.cfg.download_path = path.clone();
        g.onthespot_synced_audio_download_path = None;
        config::save_config(&g.data_dir, &g.cfg);
    }
    // If the bridge is already live, update its real download root immediately.
    if onthespot_ping().await {
        let _ = sync_onthespot_download_path(state.inner(), true).await?;
    }
    Ok(())
}

// ── Downloads panel ──────────────────────────────────────────────────────────

fn validate_download_local_id(local_id: &str) -> Result<String, String> {
    let trimmed = local_id.trim();
    if trimmed.is_empty() {
        return Err("Missing download item id".into());
    }
    if trimmed.len() > 512 {
        return Err("Download item id is too long".into());
    }
    Ok(trimmed.to_string())
}

#[tauri::command]
async fn cmd_clear_downloads(state: tauri::State<'_, AppState>) -> Result<(), String> {
    ensure_onthespot_web(state.inner()).await?;
    let client = onthespot_http()?;
    let cookie = onthespot_session_cookie(&client).await?;
    client
        .post(format!("{}/api/clear_items", onthespot_root_url()))
        .header(reqwest::header::COOKIE, cookie)
        .header("Content-Length", "0")
        .send()
        .await
        .map_err(|e| format!("Failed to clear downloads: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Failed to clear downloads: {e}"))?;
    Ok(())
}

#[tauri::command]
async fn cmd_retry_download_item(local_id: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let local_id = validate_download_local_id(&local_id)?;
    ensure_onthespot_web(state.inner()).await?;
    let client = onthespot_http()?;
    let cookie = onthespot_session_cookie(&client).await?;
    client
        .post(format!("{}/api/retry/{}", onthespot_root_url(), url_encode(&local_id)))
        .header(reqwest::header::COOKIE, cookie)
        .header("Content-Length", "0")
        .send()
        .await
        .map_err(|e| format!("Failed to retry download: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Failed to retry download: {e}"))?;
    Ok(())
}

#[tauri::command]
async fn cmd_cancel_download_item(local_id: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let local_id = validate_download_local_id(&local_id)?;
    ensure_onthespot_web(state.inner()).await?;
    let client = onthespot_http()?;
    let cookie = onthespot_session_cookie(&client).await?;
    client
        .post(format!("{}/api/cancel/{}", onthespot_root_url(), url_encode(&local_id)))
        .header(reqwest::header::COOKIE, cookie)
        .header("Content-Length", "0")
        .send()
        .await
        .map_err(|e| format!("Failed to cancel download: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Failed to cancel download: {e}"))?;
    Ok(())
}

#[tauri::command]
async fn cmd_delete_download_item(local_id: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let local_id = validate_download_local_id(&local_id)?;
    ensure_onthespot_web(state.inner()).await?;
    let client = onthespot_http()?;
    let cookie = onthespot_session_cookie(&client).await?;
    client
        .delete(format!("{}/api/delete/{}", onthespot_root_url(), url_encode(&local_id)))
        .header(reqwest::header::COOKIE, cookie)
        .send()
        .await
        .map_err(|e| format!("Failed to delete downloaded file: {e}"))?
        .error_for_status()
        .map_err(|e| format!("Failed to delete downloaded file: {e}"))?;
    Ok(())
}

#[tauri::command]
async fn cmd_open_download_folder(path: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    let dir: PathBuf = if !path.is_empty() {
        let p = Path::new(&path);
        if p.is_file() {
            p.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| p.to_path_buf())
        } else {
            p.to_path_buf()
        }
    } else {
        let cfg_path = state.lock().await.cfg.download_path.clone();
        if !cfg_path.is_empty() {
            PathBuf::from(cfg_path)
        } else {
            dirs::audio_dir()
                .or_else(|| dirs::home_dir().map(|h| h.join("Music")))
                .map(|d| d.join("LightifyDownloads"))
                .unwrap_or_else(|| PathBuf::from("LightifyDownloads"))
        }
    };
    Command::new("explorer")
        .arg(&dir)
        .spawn()
        .map_err(|e| format!("Failed to open folder: {e}"))?;
    Ok(())
}

// ── Queue ────────────────────────────────────────────────────────────────────

#[tauri::command]
async fn cmd_clear_queue(_state: tauri::State<'_, AppState>) -> Result<(), String> {
    // Spotify has no clear-queue API endpoint. Return success so the UI refreshes the panel.
    Ok(())
}

// ── Library follow / save ────────────────────────────────────────────────────

#[tauri::command]
async fn cmd_follow_playlist(playlist_id: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    validate_spotify_id(&playlist_id, "playlist")?;
    let (http, token) = auth_token(&state).await?;
    sp_no_body_retrying(&state, &http, &token, reqwest::Method::PUT,
        &format!("playlists/{playlist_id}/followers")).await
}

#[tauri::command]
async fn cmd_unfollow_playlist(playlist_id: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    validate_spotify_id(&playlist_id, "playlist")?;
    let (http, token) = auth_token(&state).await?;
    sp_no_body_retrying(&state, &http, &token, reqwest::Method::DELETE,
        &format!("playlists/{playlist_id}/followers")).await
}

#[tauri::command]
async fn cmd_follow_artist(artist_id: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    validate_spotify_id(&artist_id, "artist")?;
    let (http, token) = auth_token(&state).await?;
    sp_no_body_retrying(&state, &http, &token, reqwest::Method::PUT,
        &format!("me/following?type=artist&ids={artist_id}")).await
}

#[tauri::command]
async fn cmd_save_album(album_id: String, state: tauri::State<'_, AppState>) -> Result<(), String> {
    validate_spotify_id(&album_id, "album")?;
    let (http, token) = auth_token(&state).await?;
    sp_no_body_retrying(&state, &http, &token, reqwest::Method::PUT,
        &format!("me/albums?ids={album_id}")).await
}

fn show_main_window(app: &tauri::AppHandle) {
    if let Some(window) = app.get_webview_window("main") {
        let _ = window.show();
        let _ = window.unminimize();
        let _ = window.set_focus();
    }
}

fn hide_main_window(app: &tauri::AppHandle) -> Result<(), String> {
    let window = app.get_webview_window("main").ok_or("Main window not found")?;
    window.hide().map_err(|e| e.to_string())
}

#[tauri::command]
fn cmd_hide_to_tray(app: tauri::AppHandle) -> Result<(), String> {
    hide_main_window(&app)
}

fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
    let show = MenuItem::with_id(app, "show", "Show Lightify", true, None::<&str>)?;
    let hide = MenuItem::with_id(app, "hide", "Hide to tray", true, None::<&str>)?;
    let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
    let menu = Menu::with_items(app, &[&show, &hide, &quit])?;
    let mut tray = TrayIconBuilder::with_id("lightify")
        .tooltip("Lightify")
        .menu(&menu)
        .show_menu_on_left_click(false)
        .on_menu_event(|app, event| match event.id().as_ref() {
            "show" => show_main_window(app),
            "hide" => { let _ = hide_main_window(app); },
            "quit" => app.exit(0),
            _ => {}
        })
        .on_tray_icon_event(|tray, event| {
            if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
                show_main_window(tray.app_handle());
            }
        });
    if let Some(icon) = app.default_window_icon().cloned() {
        tray = tray.icon(icon);
    }
    let _ = tray.build(app)?;
    Ok(())
}

fn handle_global_media_shortcut(app: &tauri::AppHandle, shortcut: Shortcut, event: ShortcutEvent) {
    if event.state != ShortcutState::Pressed {
        return;
    }

    let code = match shortcut.key {
        ShortcutCode::MediaPlayPause => Some("MediaPlayPause"),
        ShortcutCode::MediaTrackNext => Some("MediaTrackNext"),
        ShortcutCode::MediaTrackPrevious => Some("MediaTrackPrevious"),
        _ => None,
    };

    if let Some(code) = code {
        if let Err(err) = app.emit("global-media-shortcut", serde_json::json!({ "code": code })) {
            eprintln!("global media shortcut emit failed: {err}");
        }
    }
}

// ── Entry point ─────────────────────────────────────────────────────────────

fn configure_webview2_runtime() {
    #[cfg(target_os = "windows")]
    {
        const KEY: &str = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS";
        if std::env::var_os(KEY).is_none() {
            std::env::set_var(
                KEY,
                concat!(
                    "--disable-background-networking ",
                    "--disable-extensions ",
                    "--disable-gpu ",
                    "--disable-sync ",
                    "--disable-features=msSmartScreenProtection,Translate,OptimizationHints"
                ),
            );
        }
    }
}

fn main() {
    configure_webview2_runtime();

    let data_dir = config::data_dir();
    let cfg = config::load_config(&data_dir);
    let http = reqwest::Client::builder()
        .user_agent("Lightify/2.0")
        .build()
        .expect("Failed to build HTTP client");

    let app_state: AppState = Arc::new(Mutex::new(AppInner {
        http,
        token: None,
        cfg,
        data_dir,
        resource_dir: None,
        user_id: String::new(),
        pending_auth: None,
        native_audio: None,
        native_audio_stdin: None,
        onthespot_web: None,
        onthespot_web_started_at: None,
        native_audio_stderr: Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(200))),
        onthespot_web_stderr: Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(200))),
        diagnostics: Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(DIAG_CAP))),
        onthespot_synced_audio_download_path: None,
        active_device_id: None,
        last_spotify_search_get_at: None,
        spotify_write_rate_limits: HashMap::new(),
        last_spotify_write_rate_limit: None,
        spotify_read_cooldown_until: None,
        pending_spotify_search_fallback: None,
    }));

    let media_shortcuts = [
        Shortcut::new(None, ShortcutCode::MediaPlayPause),
        Shortcut::new(None, ShortcutCode::MediaTrackNext),
        Shortcut::new(None, ShortcutCode::MediaTrackPrevious),
    ];

    let global_shortcut_plugin = GlobalShortcutBuilder::new()
        .with_shortcuts(media_shortcuts)
        .expect("register media shortcuts")
        .with_handler(|app, shortcut, event| handle_global_media_shortcut(app, *shortcut, event))
        .build();

    tauri::Builder::default()
        .plugin(global_shortcut_plugin)
        .manage(app_state)
        .setup(|app| {
            let resource_dir = app.path().resource_dir().ok();
            let state = app.state::<AppState>().inner().clone();
            tauri::async_runtime::block_on(async move {
                state.lock().await.resource_dir = resource_dir;
            });
            setup_tray(app)?;
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            get_config,
            try_cached_auth,
            start_auth,
            cmd_retry_auth_browser,
            cmd_open_spotify_dashboard,
            poll_playback,
            cmd_play,
            cmd_pause,
            cmd_next,
            cmd_prev,
            cmd_seek,
            cmd_volume,
            cmd_shuffle,
            cmd_repeat,
            cmd_set_expected_tracks,
            cmd_set_autoplay,
            cmd_start_native_station,
            cmd_engine_transport,
            cmd_engine_seek,
            cmd_engine_volume,
            cmd_get_devices,
            cmd_set_device,
            cmd_start_native_audio,
            cmd_restart_native_audio,
            cmd_native_audio_status,
            cmd_list_audio_outputs,
            cmd_get_audio_output,
            cmd_set_audio_output,
            cmd_hide_to_tray,
            cmd_search,
            cmd_search_all,
            cmd_search_playlists_page,
            cmd_spotify_search_fallback_result,
            cmd_artist_tracks,
            cmd_artist_tracks_page,
            cmd_album_tracks,
            cmd_playlists,
            cmd_playlist_tracks_page,
            cmd_playlist_tracks,
            cmd_play_track,
            cmd_play_context,
            cmd_play_uris,
            cmd_queue,
            cmd_add_to_queue,
            cmd_enqueue_download,
            cmd_downloader_status,
            cmd_start_downloader_login,
            cmd_downloads_snapshot,
            cmd_clear_downloads,
            cmd_retry_download_item,
            cmd_cancel_download_item,
            cmd_delete_download_item,
            cmd_open_download_folder,
            cmd_get_download_path,
            cmd_set_download_path,
            cmd_recently_played,
            cmd_check_liked,
            cmd_liked_tracks_page,
            cmd_toggle_like,
            cmd_start_station,
            cmd_beatport_genres,
            cmd_beatport_fetch,
            cmd_open_beatport_chart,
            cmd_beatport_challenge,
            cmd_beatport_session_fetch,
            cmd_beatport_session_fetch_failed,
            cmd_beatport_challenge_result,
            cmd_beatport_match_track,
            cmd_beatport_search_track,
            cmd_beatport_create_playlist,
            cmd_create_playlist,
            cmd_clear_queue,
            cmd_follow_playlist,
            cmd_unfollow_playlist,
            cmd_follow_artist,
            cmd_save_album,
            cmd_logout,
            cmd_debug_report,
            cmd_export_debug,
            cmd_log_event,
            cmd_get_diagnostics,
        ])
        .build(tauri::generate_context!())
        .expect("error while building tauri application")
        .run(|app, event| {
            if matches!(event, tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit) {
                let state: AppState = app.state::<AppState>().inner().clone();
                let mut g = state.blocking_lock();
                stop_managed_children(&mut g);
            }
        });
}