gosuto 0.11.1

Gōsuto (ゴースト) — a cyberpunk terminal Matrix client with vim motions
use std::path::PathBuf;
use std::sync::OnceLock;

use anyhow::Result;
use tracing::{info, warn};

use crate::fs_utils::write_private_file;
use crate::ui::icons::{self, Icons};

pub const APP_NAME: &str = "gosuto";

static PROFILE: OnceLock<Option<String>> = OnceLock::new();

/// Initialize the active profile. Must be called once before any path functions.
pub fn init_profile(profile: Option<String>) {
    PROFILE
        .set(profile)
        .expect("init_profile must only be called once");
}

/// Returns the active profile name, if any.
pub fn active_profile() -> Option<&'static str> {
    PROFILE.get_or_init(|| None).as_deref()
}

/// Returns `"gosuto"` or `"gosuto-{name}"` depending on the active profile.
fn app_dir_name() -> String {
    match active_profile() {
        Some(name) => format!("{APP_NAME}-{name}"),
        None => APP_NAME.to_owned(),
    }
}
pub const TICK_RATE_MS: u64 = 250;
pub const RENDER_RATE_MS: u64 = 50;

#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct GosutoConfig {
    #[serde(default)]
    pub network: NetworkConfig,
    #[serde(default)]
    pub effects: EffectsConfig,
    #[serde(default)]
    pub audio: AudioConfig,
    #[serde(default)]
    pub ui: UiConfig,
}

impl GosutoConfig {
    pub fn icons(&self) -> &'static Icons {
        icons::icons(self.ui.use_nerd_fonts)
    }
}

#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct UiConfig {
    #[serde(default)]
    pub use_nerd_fonts: bool,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct AudioConfig {
    pub input_device: Option<String>,
    pub output_device: Option<String>,
    #[serde(default = "default_volume")]
    pub input_volume: f32,
    #[serde(default = "default_volume")]
    pub output_volume: f32,
    #[serde(default)]
    pub voice_activity: bool,
    #[serde(default = "default_sensitivity")]
    pub sensitivity: f32,
    #[serde(default)]
    pub push_to_talk: bool,
    #[serde(default)]
    pub push_to_talk_key: Option<String>,
    #[serde(default = "default_true")]
    pub e2ee: bool,
    #[serde(default = "default_vad_hold_ms")]
    pub vad_hold_ms: u64,
}

impl Default for AudioConfig {
    fn default() -> Self {
        Self {
            input_device: None,
            output_device: None,
            input_volume: 1.0,
            output_volume: 1.0,
            voice_activity: false,
            sensitivity: 0.15,
            push_to_talk: false,
            push_to_talk_key: None,
            e2ee: true,
            vad_hold_ms: 300,
        }
    }
}

fn default_volume() -> f32 {
    1.0
}

fn default_sensitivity() -> f32 {
    0.15
}

fn default_vad_hold_ms() -> u64 {
    300
}

#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct EffectsConfig {
    #[serde(default = "default_true")]
    pub rain: bool,
    #[serde(default = "default_true")]
    pub glitch: bool,
}

fn default_true() -> bool {
    true
}

impl Default for EffectsConfig {
    fn default() -> Self {
        Self {
            rain: true,
            glitch: true,
        }
    }
}

#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct NetworkConfig {
    #[serde(default)]
    pub accept_invalid_certs: bool,
}

pub fn config_dir() -> Result<PathBuf> {
    let dir = dirs::config_dir()
        .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?
        .join(app_dir_name());
    Ok(dir)
}

pub fn load_config() -> GosutoConfig {
    let path = match config_dir() {
        Ok(dir) => dir.join("config.toml"),
        Err(_) => return GosutoConfig::default(),
    };

    match std::fs::read_to_string(&path) {
        Ok(contents) => match toml::from_str(&contents) {
            Ok(config) => {
                info!("Loaded config from {}", path.display());
                config
            }
            Err(e) => {
                warn!("Failed to parse config at {}: {}", path.display(), e);
                GosutoConfig::default()
            }
        },
        Err(_) => {
            let config = GosutoConfig::default();
            if let Some(parent) = path.parent()
                && let Err(e) = std::fs::create_dir_all(parent)
            {
                warn!("Could not create config dir {}: {}", parent.display(), e);
                return config;
            }
            match toml::to_string_pretty(&config) {
                Ok(contents) => {
                    if let Err(e) = write_private_file(&path, &contents) {
                        warn!(
                            "Could not write default config to {}: {}",
                            path.display(),
                            e
                        );
                    } else {
                        info!("Created default config at {}", path.display());
                    }
                }
                Err(e) => {
                    warn!("Could not serialize default config: {}", e);
                }
            }
            config
        }
    }
}

pub fn save_config(config: &GosutoConfig) {
    let path = match config_dir() {
        Ok(dir) => dir.join("config.toml"),
        Err(_) => return,
    };
    if let Some(parent) = path.parent()
        && let Err(e) = std::fs::create_dir_all(parent)
    {
        warn!("Could not create config dir {}: {}", parent.display(), e);
        return;
    }
    match toml::to_string_pretty(config) {
        Ok(contents) => {
            if let Err(e) = write_private_file(&path, &contents) {
                warn!("Could not write config to {}: {}", path.display(), e);
            }
        }
        Err(e) => warn!("Could not serialize config: {}", e),
    }
}

pub fn data_dir() -> Result<PathBuf> {
    let dir = dirs::data_local_dir()
        .ok_or_else(|| anyhow::anyhow!("Could not determine local data directory"))?
        .join(app_dir_name());
    std::fs::create_dir_all(&dir)?;
    Ok(dir)
}

pub fn session_path() -> Result<PathBuf> {
    Ok(data_dir()?.join("session.json"))
}

fn extract_hostname(homeserver: &str) -> String {
    url::Url::parse(homeserver)
        .ok()
        .and_then(|u| u.host_str().map(|h| h.to_string()))
        .unwrap_or_else(|| homeserver.replace(['/', ':', '\\'], "_"))
}

pub fn store_path_for_homeserver(homeserver: &str) -> Result<PathBuf> {
    let path = data_dir()?.join("store").join(extract_hostname(homeserver));
    std::fs::create_dir_all(&path)?;
    Ok(path)
}

/// Returns the store path for a homeserver without creating it.
/// Use this for cleanup/deletion paths.
pub fn store_path_for_homeserver_unchecked(homeserver: &str) -> Result<PathBuf> {
    Ok(data_dir()?.join("store").join(extract_hostname(homeserver)))
}

pub fn log_path() -> Result<PathBuf> {
    let path = data_dir()?.join("logs");
    std::fs::create_dir_all(&path)?;
    Ok(path)
}

/// Delete log files older than `max_days`. Best-effort — errors are silently ignored.
pub fn cleanup_old_logs(path: &std::path::Path, max_days: u64) {
    let cutoff =
        std::time::SystemTime::now() - std::time::Duration::from_secs(max_days * 24 * 60 * 60);
    let entries = match std::fs::read_dir(path) {
        Ok(e) => e,
        Err(_) => return,
    };
    for entry in entries.flatten() {
        let Ok(meta) = entry.metadata() else {
            continue;
        };
        if meta.is_file()
            && let Ok(modified) = meta.modified()
            && modified < cutoff
            && let Err(e) = std::fs::remove_file(entry.path())
        {
            warn!(
                "Failed to remove old log file {}: {}",
                entry.path().display(),
                e
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn store_path_extracts_hostname_from_url() {
        let path = store_path_for_homeserver("https://matrix.org").unwrap();
        assert_eq!(path.file_name().unwrap(), "matrix.org");
    }

    #[test]
    fn store_path_strips_port_from_url() {
        let path = store_path_for_homeserver("https://matrix.org:8448").unwrap();
        assert_eq!(path.file_name().unwrap(), "matrix.org");
    }

    #[test]
    fn store_path_sanitizes_non_url_input() {
        let path = store_path_for_homeserver("not://valid").unwrap();
        // url::Url::parse succeeds for "not://valid" with host "valid"
        // but for truly unparseable input, it sanitizes slashes/colons
        let name = path.file_name().unwrap().to_str().unwrap();
        assert!(!name.contains('/'));
        assert!(!name.contains('\\'));
    }

    #[test]
    fn store_path_unchecked_matches_checked() {
        let checked = store_path_for_homeserver("https://example.com").unwrap();
        let unchecked = store_path_for_homeserver_unchecked("https://example.com").unwrap();
        assert_eq!(checked, unchecked);
    }

    #[test]
    fn default_config_values() {
        let config = GosutoConfig::default();
        assert!(!config.network.accept_invalid_certs);
        assert!(config.effects.rain);
        assert!(config.effects.glitch);
        assert!(!config.ui.use_nerd_fonts);
    }

    #[test]
    fn audio_default_values() {
        let audio = AudioConfig::default();
        assert_eq!(audio.input_volume, 1.0);
        assert_eq!(audio.output_volume, 1.0);
        assert!(!audio.voice_activity);
        assert_eq!(audio.sensitivity, 0.15);
        assert!(!audio.push_to_talk);
        assert!(audio.push_to_talk_key.is_none());
        assert!(audio.input_device.is_none());
        assert!(audio.output_device.is_none());
        assert!(audio.e2ee);
        assert_eq!(audio.vad_hold_ms, 300);
    }

    #[test]
    fn config_roundtrip_toml() {
        let config = GosutoConfig::default();
        let serialized = toml::to_string_pretty(&config).unwrap();
        let deserialized: GosutoConfig = toml::from_str(&serialized).unwrap();
        assert_eq!(deserialized.effects.rain, config.effects.rain);
        assert_eq!(deserialized.effects.glitch, config.effects.glitch);
        assert_eq!(
            deserialized.network.accept_invalid_certs,
            config.network.accept_invalid_certs
        );
        assert_eq!(deserialized.audio.input_volume, config.audio.input_volume);
        assert_eq!(deserialized.audio.output_volume, config.audio.output_volume);
        assert_eq!(deserialized.ui.use_nerd_fonts, config.ui.use_nerd_fonts);
    }

    #[test]
    fn ui_config_nerd_fonts_roundtrip() {
        let toml_str = r#"
[ui]
use_nerd_fonts = true
"#;
        let config: GosutoConfig = toml::from_str(toml_str).unwrap();
        assert!(config.ui.use_nerd_fonts);
        assert_eq!(config.icons().room, crate::ui::icons::NERD.room);
    }

    #[test]
    fn icons_default_returns_unicode() {
        let config = GosutoConfig::default();
        assert_eq!(config.icons().room, crate::ui::icons::UNICODE.room);
    }

    #[test]
    fn effects_default_enabled() {
        let effects = EffectsConfig::default();
        assert!(effects.rain);
        assert!(effects.glitch);
    }
}