plavno 0.1.1

A lightweight Linux terminal music player with service waves and library shuffle
use anyhow::{Context, Result, ensure};
use std::{
    env, fs,
    io::Write,
    os::unix::fs::{OpenOptionsExt, PermissionsExt},
    path::PathBuf,
};

pub const JSON_PROVIDERS: [(&str, &str); 4] = [
    ("soundcloud", "SoundCloud"),
    ("spotify", "Spotify"),
    ("vk", "VK Music"),
    ("ytmusic", "YouTube Music"),
];

pub fn profile_path(source: &str) -> Result<PathBuf> {
    ensure!(
        JSON_PROVIDERS.iter().any(|(key, _)| *key == source),
        "Unknown source"
    );
    Ok(token_path()?.with_file_name(format!("{source}.json")))
}

type Registry = (
    Vec<Box<dyn crate::backend::Backend>>,
    Vec<crate::backend::Service>,
);

pub fn backends() -> Result<Registry> {
    use crate::backend::{Backend, Bridge, Service, SoundCloud, Vk, Yandex};
    let mut backends: Vec<Box<dyn Backend>> = Vec::new();
    let mut services = Vec::new();
    match load() {
        Ok(token) => {
            backends.push(Box::new(Yandex::new(token)));
            services.push(Service {
                key: "yandex",
                name: "Yandex Music",
                status: "Ready".into(),
                available: true,
                favorites: true,
                favorites_shuffle: true,
                wave_styles: true,
            });
        }
        Err(_) => services.push(Service {
            key: "yandex",
            name: "Yandex Music",
            status: "Connect with: plavno login yandex".into(),
            available: false,
            favorites: true,
            favorites_shuffle: true,
            wave_styles: true,
        }),
    }
    for (key, name) in JSON_PROVIDERS {
        let path = profile_path(key)?;
        let ready = fs::symlink_metadata(&path)
            .is_ok_and(|meta| meta.file_type().is_file() && meta.permissions().mode() & 0o077 == 0);
        services.push(Service {
            key,
            name,
            available: ready,
            favorites: key == "vk",
            favorites_shuffle: key == "vk",
            wave_styles: key == "vk",
            status: if ready {
                if matches!(key, "spotify" | "ytmusic") {
                    "Configured · native adapter required".into()
                } else {
                    "Configured · native backend".into()
                }
            } else {
                format!("Connect with: plavno login {key}")
            },
        });
        if ready {
            backends.push(match key {
                "soundcloud" => Box::new(SoundCloud::new(path)),
                "vk" => Box::new(Vk::new(path)),
                _ => Box::new(Bridge::new(key, name, path)),
            });
        }
    }
    Ok((backends, services))
}

pub fn login_provider(source: &str) -> Result<()> {
    if matches!(source, "soundcloud" | "vk") {
        return crate::browser_login::login(source);
    }
    login_manual(source)
}

pub fn login_manual(source: &str) -> Result<()> {
    let path = profile_path(source)?;
    println!("Enter a JSON connection profile. See README for fields and optional dependencies.");
    let value = rpassword::prompt_password("Connection profile (hidden input): ")?;
    let profile: serde_json::Value =
        serde_json::from_str(&value).map_err(|_| anyhow::anyhow!("Invalid JSON profile"))?;
    ensure!(
        profile.as_object().is_some_and(|obj| !obj.is_empty()),
        "The profile must be a non-empty JSON object"
    );
    save_private(&path, serde_json::to_vec(&profile)?.as_slice())?;
    println!("Profile saved to {}", path.display());
    Ok(())
}

pub(crate) fn save_private(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
    let parent = path.parent().context("Missing configuration directory")?;
    fs::create_dir_all(parent)?;
    fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
    let temp = parent.join(format!(".profile-{}", std::process::id()));
    let result = (|| -> Result<()> {
        let mut file = fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .mode(0o600)
            .open(&temp)?;
        file.write_all(bytes)?;
        file.sync_all()?;
        fs::rename(&temp, path)?;
        Ok(())
    })();
    if result.is_err() {
        let _ = fs::remove_file(temp);
    }
    result
}

fn token_path() -> Result<PathBuf> {
    let base = match env::var_os("XDG_CONFIG_HOME").filter(|p| !p.is_empty()) {
        Some(path) => PathBuf::from(path),
        None => PathBuf::from(env::var_os("HOME").context("HOME is not set")?).join(".config"),
    };
    Ok(base.join("plavno/yandex.token"))
}

pub fn load() -> Result<String> {
    if let Ok(token) = env::var("YANDEX_MUSIC_TOKEN") {
        ensure!(!token.trim().is_empty(), "YANDEX_MUSIC_TOKEN is empty");
        return Ok(token.trim().into());
    }
    let path = token_path()?;
    let meta = fs::symlink_metadata(&path).context("To sign in, run: plavno login yandex")?;
    ensure!(
        meta.file_type().is_file() && meta.permissions().mode() & 0o077 == 0,
        "The token file must have permissions 0600 and must not be a symlink"
    );
    let token = fs::read_to_string(path)?;
    ensure!(
        !token.trim().is_empty(),
        "The token is empty; run plavno login yandex"
    );
    Ok(token.trim().into())
}

pub fn wave_styles() -> Result<std::collections::HashMap<String, crate::backend::WaveStyle>> {
    let path = token_path()?.with_file_name("wave-styles.json");
    if !path.exists() {
        return Ok(Default::default());
    }
    serde_json::from_str(&crate::backend::web::private(&path)?)
        .context("Invalid wave style settings")
}

pub fn save_wave_styles(
    styles: &std::collections::HashMap<String, crate::backend::WaveStyle>,
) -> Result<()> {
    let path = token_path()?.with_file_name("wave-styles.json");
    save_private(&path, &serde_json::to_vec_pretty(styles)?)
}

pub fn login() -> Result<()> {
    let token = rpassword::prompt_password("Yandex Music OAuth token (hidden input): ")?;
    ensure!(!token.trim().is_empty(), "The token is empty");
    let path = token_path()?;
    let parent = path.parent().unwrap();
    fs::create_dir_all(parent)?;
    fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
    let temp = parent.join(format!(".token-{}", std::process::id()));
    let result = (|| -> Result<()> {
        let mut file = fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .mode(0o600)
            .open(&temp)?;
        file.write_all(token.trim().as_bytes())?;
        file.sync_all()?;
        fs::rename(&temp, &path)?;
        Ok(())
    })();
    if result.is_err() {
        let _ = fs::remove_file(temp);
    }
    result?;
    println!("Token saved to {}", path.display());
    Ok(())
}