//! Discovery and listing of available card themes.
//!
//! On startup the registry collects:
//!
//! - The bundled default theme — always present, served from
//!   `embedded://`.
//! - Every valid user-supplied theme found under
//!   [`crate::assets::user_theme_dir`] — one entry per immediate
//!   subdirectory whose `theme.ron` parses cleanly.
//!
//! The picker UI (Phase 6 acceptance: "dropping a valid theme folder
//! into the user themes dir makes it appear on next app start") reads
//! [`ThemeRegistry`] to populate its list of options.
//!
//! Per the plan, this only parses the `meta` block of each manifest —
//! we don't validate face/back paths here because (a) that work
//! already lives in [`super::manifest::ThemeManifest::validate`] and
//! [`super::loader::CardThemeLoader`], and (b) the registry should
//! surface entries quickly enough for a startup scan to feel free,
//! even with dozens of user themes installed.

use std::path::Path;

use bevy::log::warn;
#[cfg(not(target_arch = "wasm32"))]
use bevy::prelude::Startup;
use bevy::prelude::{App, Plugin, Resource};
use serde::Deserialize;

use super::ThemeMeta;
use crate::assets::DARK_THEME_MANIFEST_URL;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_theme_dir;

/// One entry in the [`ThemeRegistry`] — the data the picker UI needs
/// to render a row and load the theme on selection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeEntry {
    /// Stable identifier; matches `meta.id` from the manifest. For
    /// user themes this is also the directory name on disk; for the
    /// bundled default it is the literal string `"default"`.
    pub id: String,
    /// Human-readable label for the picker.
    pub display_name: String,
    /// Asset URL the picker passes to
    /// [`super::set_theme`] / `AssetServer::load`.
    pub manifest_url: String,
    /// The full meta block. Kept around so the picker can display
    /// author + version without a second round-trip through disk.
    pub meta: ThemeMeta,
}

/// Resource holding every theme available at app start.
///
/// The order is stable: default first, then user themes in the order
/// returned by [`std::fs::read_dir`] (filesystem-defined; usually
/// alphabetical on tested filesystems but not guaranteed by the OS).
#[derive(Resource, Debug, Default)]
pub struct ThemeRegistry {
    pub entries: Vec<ThemeEntry>,
}

impl ThemeRegistry {
    /// Returns the entry whose `id` matches, if any.
    pub fn find(&self, id: &str) -> Option<&ThemeEntry> {
        self.entries.iter().find(|e| e.id == id)
    }

    /// Iterator over every registered theme.
    pub fn iter(&self) -> impl Iterator<Item = &ThemeEntry> {
        self.entries.iter()
    }

    /// Number of registered themes (always ≥ 1 because the default
    /// entry is always inserted, even if user-theme discovery fails).
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// True only when the default entry is missing — should never
    /// happen at runtime; provided for API completeness.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

/// Bevy plugin that builds [`ThemeRegistry`] on startup.
pub struct ThemeRegistryPlugin;

impl Plugin for ThemeRegistryPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<ThemeRegistry>();
        // User-themes directory scan requires a filesystem. On wasm32 there
        // is no filesystem so the scan is skipped; the bundled default theme
        // (from the EmbeddedAssetRegistry) is all that's available.
        #[cfg(not(target_arch = "wasm32"))]
        app.add_systems(Startup, build_registry_on_startup);
    }
}

/// Reads `user_theme_dir()` and replaces the registry's contents with
/// the bundled default plus every valid user theme.
#[cfg(not(target_arch = "wasm32"))]
fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegistry>) {
    *registry = build_registry(&user_theme_dir());
}

/// Pure helper: builds a registry given an explicit user-themes
/// directory. Tests pass a temp dir; production uses
/// [`user_theme_dir`].
pub fn build_registry(user_dir: &Path) -> ThemeRegistry {
    let mut entries = Vec::new();
    entries.push(dark_entry());
    entries.push(classic_entry());
    entries.extend(discover_user_themes(user_dir));
    ThemeRegistry { entries }
}

/// The always-present embedded Dark theme entry.
fn dark_entry() -> ThemeEntry {
    ThemeEntry {
        id: "dark".to_string(),
        display_name: "Dark".to_string(),
        manifest_url: DARK_THEME_MANIFEST_URL.to_string(),
        meta: ThemeMeta {
            id: "dark".to_string(),
            name: "Dark".to_string(),
            author: "Ferrous Solitaire".to_string(),
            version: "1.0.0".to_string(),
            card_aspect: (2, 3),
        },
    }
}

/// Classic bundled theme — white card faces with traditional red/black
/// colour scheme, loaded from `assets/themes/classic/` via the default
/// AssetServer source.
fn classic_entry() -> ThemeEntry {
    ThemeEntry {
        id: "classic".to_string(),
        display_name: "Classic".to_string(),
        manifest_url: "themes/classic/theme.ron".to_string(),
        meta: ThemeMeta {
            id: "classic".to_string(),
            name: "Classic".to_string(),
            author: "Ferrous Solitaire".to_string(),
            version: "1.0.0".to_string(),
            card_aspect: (2, 3),
        },
    }
}

/// Walks `user_dir`, treating every immediate subdirectory as a
/// candidate theme. A subdirectory contributes one entry if and only
/// if it contains a `theme.ron` whose `meta` block parses cleanly and
/// passes `ThemeMeta::validate`. Failed candidates are warned and
/// skipped — broken themes don't poison discovery.
fn discover_user_themes(user_dir: &Path) -> Vec<ThemeEntry> {
    let mut out = Vec::new();
    let Ok(read) = std::fs::read_dir(user_dir) else {
        // Missing or unreadable user directory is the common case
        // before any theme is imported; treat it as "no themes" and
        // move on.
        return out;
    };

    for entry in read.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let manifest_path = path.join("theme.ron");
        if !manifest_path.is_file() {
            continue;
        }
        let Some(theme_entry) = read_meta_only(&manifest_path) else {
            continue;
        };
        out.push(theme_entry);
    }
    out
}

/// Partial deserialiser that only extracts `meta` from a theme
/// manifest. RON / serde silently skip unknown fields by default, so
/// this works against the full [`ThemeManifest`] schema without
/// having to load the 52 face paths or the back path.
#[derive(Deserialize)]
struct ManifestMetaOnly {
    meta: ThemeMeta,
}

/// Reads a single `theme.ron` and turns its `meta` block into a
/// [`ThemeEntry`]. Returns `None` for any I/O / parse / validation
/// failure — discovery is best-effort.
fn read_meta_only(manifest_path: &Path) -> Option<ThemeEntry> {
    let theme_id = manifest_path
        .parent()
        .and_then(Path::file_name)
        .and_then(|name| name.to_str())
        .unwrap_or("<unknown>");
    let bytes = match std::fs::read(manifest_path) {
        Ok(bytes) => bytes,
        Err(e) => {
            warn!("Skipping theme '{}': {}", theme_id, e);
            return None;
        }
    };
    let parsed: ManifestMetaOnly = match ron::de::from_bytes(&bytes) {
        Ok(parsed) => parsed,
        Err(e) => {
            warn!("Skipping theme '{}': {}", theme_id, e);
            return None;
        }
    };
    if let Err(e) = parsed.meta.validate() {
        warn!("Skipping theme '{}': {}", theme_id, e);
        return None;
    }
    let id = parsed.meta.id.clone();
    let display_name = parsed.meta.name.clone();
    let manifest_url = format!("themes://{id}/theme.ron");
    Some(ThemeEntry {
        id,
        display_name,
        manifest_url,
        meta: parsed.meta,
    })
}

/// Refreshes [`ThemeRegistry`] in place — call after a successful
/// [`super::import_theme`] so the new theme is visible in the picker
/// without restarting the app.
pub fn refresh_registry(registry: &mut ThemeRegistry, user_dir: &Path) {
    *registry = build_registry(user_dir);
}

#[cfg(test)]
mod tests {
    use std::fs;

    use super::*;

    fn write_manifest(dir: &Path, id: &str, name: &str) {
        let manifest = format!(
            r#"(
    meta: (
        id: "{id}",
        name: "{name}",
        author: "tester",
        version: "1.0.0",
        card_aspect: (2, 3),
    ),
    back: "back.svg",
    faces: {{}},
)"#
        );
        fs::write(dir.join("theme.ron"), manifest).unwrap();
    }

    fn write_full_manifest(dir: &Path, id: &str, name: &str) {
        // A complete manifest with the 52 face entries and back.
        // Only used when a test specifically wants the full schema;
        // most discovery tests use the meta-only stub via
        // write_manifest above because the meta-only deserialiser
        // ignores the rest of the file anyway.
        let mut faces = String::new();
        for key in crate::theme::CardKey::all() {
            let mn = key.manifest_name();
            faces.push_str(&format!("        \"{mn}\": \"{mn}.svg\",\n"));
        }
        let manifest = format!(
            r#"(
    meta: (
        id: "{id}",
        name: "{name}",
        author: "tester",
        version: "1.0.0",
        card_aspect: (2, 3),
    ),
    back: "back.svg",
    faces: {{
{faces}    }},
)"#
        );
        fs::write(dir.join("theme.ron"), manifest).unwrap();
    }

    // Number of always-present bundled themes (default + classic).
    const BUNDLED_COUNT: usize = 2;

    #[test]
    fn empty_user_dir_yields_only_bundled_entries() {
        let tmp = tempfile::tempdir().unwrap();
        let registry = build_registry(tmp.path());
        assert_eq!(registry.len(), BUNDLED_COUNT);
        assert_eq!(registry.entries[0].id, "dark");
        assert_eq!(registry.entries[1].id, "classic");
    }

    #[test]
    fn nonexistent_user_dir_still_yields_bundled_entries() {
        let registry = build_registry(Path::new("/definitely/not/a/real/path/should/not/panic"));
        assert_eq!(registry.len(), BUNDLED_COUNT);
        assert!(registry.find("classic").is_some());
        assert!(registry.find("dark").is_some());
    }

    #[test]
    fn user_theme_with_valid_manifest_appears_in_registry() {
        let tmp = tempfile::tempdir().unwrap();
        let theme_dir = tmp.path().join("midnight");
        fs::create_dir_all(&theme_dir).unwrap();
        write_manifest(&theme_dir, "midnight", "Midnight");

        let registry = build_registry(tmp.path());
        assert_eq!(registry.len(), BUNDLED_COUNT + 1);
        let entry = registry.find("midnight").expect("midnight registered");
        assert_eq!(entry.display_name, "Midnight");
        assert_eq!(entry.manifest_url, "themes://midnight/theme.ron");
    }

    #[test]
    fn full_manifest_also_works_via_meta_only_parser() {
        // The meta-only deserialiser must tolerate the full ThemeManifest
        // schema without complaining about unknown fields.
        let tmp = tempfile::tempdir().unwrap();
        let theme_dir = tmp.path().join("full");
        fs::create_dir_all(&theme_dir).unwrap();
        write_full_manifest(&theme_dir, "full", "Full");

        let registry = build_registry(tmp.path());
        assert!(registry.find("full").is_some());
    }

    #[test]
    fn malformed_manifest_is_skipped() {
        let tmp = tempfile::tempdir().unwrap();
        let theme_dir = tmp.path().join("broken");
        fs::create_dir_all(&theme_dir).unwrap();
        fs::write(theme_dir.join("theme.ron"), "this is not valid ron").unwrap();

        // Plus a valid theme so we can confirm one bad apple doesn't
        // poison discovery.
        let good_dir = tmp.path().join("good");
        fs::create_dir_all(&good_dir).unwrap();
        write_manifest(&good_dir, "good", "Good Theme");

        let registry = build_registry(tmp.path());
        assert!(registry.find("broken").is_none());
        assert!(registry.find("good").is_some());
    }

    #[test]
    fn manifest_with_invalid_meta_is_skipped() {
        // id with a path separator violates ThemeMeta::validate.
        let tmp = tempfile::tempdir().unwrap();
        let theme_dir = tmp.path().join("escape");
        fs::create_dir_all(&theme_dir).unwrap();
        write_manifest(&theme_dir, "../etc/passwd", "Evil");

        let registry = build_registry(tmp.path());
        assert_eq!(
            registry.len(),
            BUNDLED_COUNT,
            "escape attempt must not register"
        );
        assert!(registry.find("classic").is_some());
    }

    #[test]
    fn directory_without_theme_ron_is_ignored() {
        let tmp = tempfile::tempdir().unwrap();
        let lonely = tmp.path().join("no-manifest-here");
        fs::create_dir_all(&lonely).unwrap();
        fs::write(lonely.join("readme.md"), "wrong filename").unwrap();

        let registry = build_registry(tmp.path());
        assert_eq!(registry.len(), BUNDLED_COUNT);
    }

    #[test]
    fn find_returns_none_for_unknown_id() {
        let registry = build_registry(Path::new("/nonexistent"));
        assert!(registry.find("definitely-not-a-theme").is_none());
    }

    #[test]
    fn refresh_replaces_existing_entries() {
        let tmp = tempfile::tempdir().unwrap();
        let mut registry = ThemeRegistry::default();
        registry.entries.push(ThemeEntry {
            id: "stale".into(),
            display_name: "Stale".into(),
            manifest_url: "themes://stale/theme.ron".into(),
            meta: ThemeMeta {
                id: "stale".into(),
                name: "Stale".into(),
                author: "x".into(),
                version: "x".into(),
                card_aspect: (2, 3),
            },
        });

        refresh_registry(&mut registry, tmp.path());

        assert_eq!(registry.len(), BUNDLED_COUNT);
        assert!(registry.find("classic").is_some());
        assert!(registry.find("stale").is_none());
    }

    #[test]
    fn dark_entry_url_matches_embedded_constant() {
        let entry = dark_entry();
        assert_eq!(entry.manifest_url, DARK_THEME_MANIFEST_URL);
    }
}
