//! Application configuration loaded from `config.toml`.
//!
//! Each top-level section maps to a TOML table.  Missing sections get sane
//! defaults so existing config files continue to work after upgrades.

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

/// Root configuration object, serialised from/to `config.toml`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
    pub backup: BackupSection,
    #[serde(default)]
    pub player: PlayerSection,
    #[serde(default)]
    pub ui: UiSection,
    #[serde(default)]
    pub scheduler: SchedulerSection,
    #[serde(default)]
    pub web: WebSection,
    #[serde(default)]
    pub plex: PlexSection,
    #[serde(default)]
    pub subtitles: SubtitlesSection,
    #[serde(default)]
    pub convert: ConvertSection,
    /// Federation: other catacomb instances whose libraries can be browsed
    /// read-only from this one (see [`crate::remote`]). Each is a
    /// `[[remote]]` table in config.toml.
    #[serde(default, rename = "remote")]
    pub remotes: Vec<RemoteSection>,
}

/// One `[[remote]]` entry — a peer catacomb instance to browse read-only.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RemoteSection {
    /// Display name in the UI's remote switcher.
    pub name: String,
    /// Base URL of the peer's web server, e.g. `http://woofbox:8081` or
    /// `https://archive.example`. No trailing slash needed.
    pub url: String,
    /// Peer kind — a catacomb federation peer (default) or a PeerTube target.
    #[serde(default)]
    pub kind: RemoteKind,
    /// Username for PeerTube OAuth2. `None` for catacomb peers / public
    /// PeerTube instances.
    #[serde(default)]
    pub username: Option<String>,
    /// Password for the peer, if it has one set. `None`/absent = open peer
    /// (e.g. bound to a trusted tailnet). Stored plaintext like `cookies`.
    #[serde(default)]
    pub password: Option<String>,
}

/// Which kind of peer a `[[remote]]` is. Defaults to a catacomb peer so
/// pre-existing config.toml entries keep working unchanged.
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RemoteKind {
    #[default]
    Catacomb,
    Peertube,
}

/// `[convert]` table — global post-download format-conversion defaults.
/// A separate ffmpeg pass runs after the download completes. Per-channel
/// [`crate::download_options::DownloadOptions`] can override `mode`.
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct ConvertSection {
    /// What to do after download. One of:
    /// - `""` / `"off"`: nothing (keep the downloaded mkv as-is).
    /// - `"remux-mp4"`: fast container change mkv→mp4, no re-encode.
    /// - `"h264-mp4"`: re-encode video to H.264/AAC mp4 at [`Self::crf`].
    /// - `"audio"`: extract audio to [`Self::audio_format`].
    #[serde(default)]
    pub mode: String,
    /// CRF for `h264-mp4` (0–51; lower = bigger/better). 23 is a sane
    /// default if left at 0.
    #[serde(default)]
    pub crf: u8,
    /// x264 preset for `h264-mp4` (ultrafast…veryslow). Empty = "medium".
    #[serde(default)]
    pub preset: String,
    /// Audio format for `audio` mode (mp3 / m4a / opus / flac). Empty =
    /// "mp3".
    #[serde(default)]
    pub audio_format: String,
    /// Keep the original downloaded file alongside the converted one
    /// (renamed to `<stem>.original.<ext>`). When false, the original is
    /// deleted after a successful convert.
    #[serde(default)]
    pub keep_original: bool,
}

/// `[subtitles]` table — global subtitle download defaults. Per-channel
/// [`crate::download_options::DownloadOptions`] can override any of these.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SubtitlesSection {
    /// Master switch. When false, no subtitle flags are emitted at all.
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Include auto-generated (machine) captions via `--write-auto-subs`.
    /// Off-by-default-ish content is noisy; keep it on globally but let
    /// channels turn it off.
    #[serde(default = "default_true")]
    pub auto_generated: bool,
    /// Embed subtitle tracks into the video container (`--embed-subs`),
    /// in addition to writing sidecar files. Soft subs, toggleable in
    /// the player.
    #[serde(default)]
    pub embed: bool,
    /// Convert downloaded subtitles to this format (`--convert-subs`).
    /// Empty = keep native. Common: "srt" (Plex-friendly), "vtt", "ass".
    #[serde(default)]
    pub format: String,
    /// Comma-separated language codes (`--sub-langs`). Empty = all
    /// available. e.g. "en", "en,ja", "en.*" for all English variants.
    #[serde(default)]
    pub langs: String,
}

impl Default for SubtitlesSection {
    fn default() -> Self {
        Self {
            enabled: true,
            auto_generated: true,
            embed: false,
            format: String::new(),
            langs: String::new(),
        }
    }
}

/// `[backup]` table — where to store downloaded videos.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BackupSection {
    pub directory: PathBuf,
    /// Maximum simultaneous yt-dlp processes. Extra downloads queue and start
    /// automatically when a slot opens. Set to 0 for no limit (not recommended).
    #[serde(default = "default_max_concurrent")]
    pub max_concurrent: usize,
    /// If true, use the bundled yt-dlp + deno binaries managed by catacomb
    /// (installed under `~/.local/share/catacomb/bin/`). If false, use the
    /// `yt-dlp` found on the system PATH.
    #[serde(default)]
    pub use_bundled_ytdlp: bool,
    /// If true and the bundled yt-dlp is in use, spawn the bgutil-pot
    /// HTTP server on startup and pass its extractor-args to every
    /// yt-dlp invocation. YouTube increasingly requires a per-video
    /// Proof-of-Origin token; without one, format URLs come back empty.
    ///
    /// Only effective in tandem with `use_bundled_ytdlp` because the
    /// matching Python plugin gets pip-installed into the bundled venv,
    /// not the system Python. System-yt-dlp users who want POT support
    /// install the plugin and run the server themselves.
    #[serde(default)]
    pub use_pot_provider: bool,
    /// YouTube player clients to try, as a comma-separated list passed to
    /// `--extractor-args youtube:player_client=…`. Empty = let yt-dlp pick
    /// its defaults (recommended). YouTube's bot-detection cracks down on
    /// different clients over time; setting e.g. `tv,mweb` routes around a
    /// client that's currently being captcha-walled. Per-channel overrides
    /// live in [`crate::download_options::DownloadOptions`].
    #[serde(default)]
    pub youtube_player_clients: String,
    /// SponsorBlock handling for downloaded videos. `"mark"` (the default)
    /// chapter-marks segments via `--sponsorblock-mark all` so a player can
    /// skip them; `"remove"` cuts them from the file via
    /// `--sponsorblock-remove all`; `"off"` disables it. Per-channel
    /// overrides live in [`crate::download_options::DownloadOptions`].
    #[serde(default = "default_sponsorblock_mode")]
    pub sponsorblock_mode: String,
    /// Download the comment tree into each video's info.json sidecar via
    /// yt-dlp `--write-comments`. Off by default — comment download is slow
    /// (yt-dlp paginates through thousands of replies on popular videos).
    /// When on, the player's Comments tab is populated. Per-channel overrides
    /// live in [`crate::download_options::DownloadOptions`].
    #[serde(default)]
    pub fetch_comments: bool,
    /// Whether the perceptual "similar content" dedup scan is available. On by
    /// default; set to `false` to hard-disable fingerprinting in both UIs (the
    /// scan button no-ops / the web scan endpoint returns 409). Useful on
    /// low-powered machines where the ffmpeg keyframe pass is unwanted.
    #[serde(default = "default_true")]
    pub dedup_enabled: bool,
}

fn default_max_concurrent() -> usize { 3 }
pub fn default_true() -> bool { true }
fn default_sponsorblock_mode() -> String { "mark".to_string() }

/// `[player]` table — external player and browser cookie source.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlayerSection {
    #[serde(default = "default_player")]
    pub command: String,
    #[serde(default = "default_browser")]
    pub browser: String,
}

impl Default for PlayerSection {
    fn default() -> Self {
        Self { command: default_player(), browser: default_browser() }
    }
}

/// `[ui]` table — egui desktop theme.
///
/// Available themes: `dark`, `light`, `dracula`, `trans`, `emo-nocturnal`, `emo-coffin`, `emo-scene-queen`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UiSection {
    #[serde(default = "default_theme")]
    pub theme: String,
    /// When true, clicking the window close button hides the window
    /// into the system tray instead of exiting. The tray menu's Quit
    /// item (or Ctrl+Q) still terminates the app. Off by default so
    /// users without a working SNI host don't get stuck in an invisible
    /// hidden window.
    #[serde(default)]
    pub minimize_to_tray: bool,
    /// Global egui zoom factor — scales the *entire* desktop UI (fonts,
    /// spacing, widgets), not just the card grid. 1.0 = native. Persisted
    /// here so the chosen size survives restarts and is kept in sync with
    /// the built-in Ctrl + / Ctrl - / Ctrl 0 keyboard zoom.
    #[serde(default = "default_ui_scale")]
    pub ui_scale: f32,
    /// Default video-list render mode: "list", "card", or "grid".
    /// Per-view overrides live in App state (not persisted beyond session).
    #[serde(default = "default_view_mode")]
    pub default_view_mode: String,
}

fn default_ui_scale() -> f32 { 1.0 }
fn default_view_mode() -> String { "list".to_string() }

impl Default for UiSection {
    fn default() -> Self {
        Self {
            theme: default_theme(),
            minimize_to_tray: false,
            ui_scale: default_ui_scale(),
            default_view_mode: default_view_mode(),
        }
    }
}

/// `[scheduler]` table — periodic background download schedule.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SchedulerSection {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_interval_hours")]
    pub interval_hours: u32,
}

impl Default for SchedulerSection {
    fn default() -> Self {
        Self { enabled: false, interval_hours: default_interval_hours() }
    }
}

/// `[plex]` table — Plex-compatible TV-show symlink library.
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct PlexSection {
    /// Directory where the Plex symlink tree is written.
    /// Leave unset to disable Plex library generation.
    pub library_path: Option<PathBuf>,
}

/// `[web]` table — built-in HTTP server settings.
///
/// `source_url` is **required for AGPL §13 compliance**: set it to a URL
/// where the running source code can be obtained (e.g. your Codeberg repo).
/// It is shown as a "Source" link in the web UI footer.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WebSection {
    #[serde(default = "default_web_port")]
    pub port: u16,
    #[serde(default = "default_web_bind")]
    /// Address to bind the HTTP server to. Defaults to `127.0.0.1` (localhost only).
    /// Set to `0.0.0.0` to accept connections from any interface (not recommended).
    pub bind: String,
    #[serde(default)]
    pub transcode: bool,
    /// Public URL to the source repository, shown in the web UI per AGPL §13.
    pub source_url: Option<String>,
}

impl Default for WebSection {
    fn default() -> Self {
        Self {
            port: default_web_port(),
            bind: default_web_bind(),
            transcode: false,
            source_url: None,
        }
    }
}

fn default_player() -> String { "mpv".to_string() }
fn default_browser() -> String { "firefox".to_string() }
fn default_theme() -> String { "dark".to_string() }
fn default_interval_hours() -> u32 { 24 }
fn default_web_port() -> u16 { 8080 }
fn default_web_bind() -> String { "127.0.0.1".to_string() }

impl Config {
    /// Load and parse `config.toml` from `path`.
    pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
        let contents = std::fs::read_to_string(path)?;
        toml::from_str(&contents).map_err(|e| e.into())
    }

    /// Serialise the config back to `path` in pretty TOML.
    pub fn save(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
        let contents = toml::to_string_pretty(self)?;
        std::fs::write(path, contents)?;
        Ok(())
    }

    /// Construct a minimal default config pointing `backup.directory` at `dir`.
    pub fn default_with_dir(dir: PathBuf) -> Self {
        Self {
            backup: BackupSection {
                directory: dir,
                max_concurrent: default_max_concurrent(),
                use_bundled_ytdlp: false,
                use_pot_provider: false,
                youtube_player_clients: String::new(),
                sponsorblock_mode: default_sponsorblock_mode(),
                fetch_comments: false,
                dedup_enabled: true,
            },
            player: PlayerSection::default(),
            ui: UiSection::default(),
            scheduler: SchedulerSection::default(),
            web: WebSection::default(),
            plex: PlexSection::default(),
            subtitles: SubtitlesSection::default(),
            convert: ConvertSection::default(),
            remotes: Vec::new(),
        }
    }
}

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

    #[test]
    fn ui_section_default_view_mode_is_list() {
        let ui = UiSection::default();
        assert_eq!(ui.default_view_mode, "list");
    }

    #[test]
    fn ui_section_round_trips_through_toml() {
        let ui = UiSection { default_view_mode: "grid".into(), ..Default::default() };
        let s = toml::to_string(&ui).unwrap();
        let back: UiSection = toml::from_str(&s).unwrap();
        assert_eq!(back.default_view_mode, "grid");
    }

    #[test]
    fn remote_kind_defaults_to_catacomb() {
        // A legacy [[remote]] with no `kind`/`username` still parses. `[backup]`
        // has no serde default (directory is required), so include a minimal one.
        let toml = r#"
            [backup]
            directory = "/tmp/lib"

            [[remote]]
            name = "peer"
            url = "http://peer:8081"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.remotes.len(), 1);
        assert_eq!(cfg.remotes[0].kind, RemoteKind::Catacomb);
        assert!(cfg.remotes[0].username.is_none());
    }

    #[test]
    fn remote_kind_parses_peertube_and_username() {
        let toml = r#"
            [backup]
            directory = "/tmp/lib"

            [[remote]]
            name = "frama"
            url = "https://framatube.org"
            kind = "peertube"
            username = "alice"
            password = "secret"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.remotes[0].kind, RemoteKind::Peertube);
        assert_eq!(cfg.remotes[0].username.as_deref(), Some("alice"));
    }
}
