//! Projects: the on-disk unit a game is authored as.
//!
//! A **project** is a root directory containing a project file plus a defined
//! folder layout (scenes, assets, scripts). The project file (RON) records the
//! project name, the engine version it was made with, the set of enabled
//! [modules](crate::app::Module), and per-project settings. The format lives in
//! the engine — not the editor — because the exported runtime and the Stage-16
//! packer read it too; the editor adds the create/open/save UI on top.
//!
//! Per-project settings are stored as **opaque per-section RON blobs**
//! (`section name → RON`), so this module stays independent of the typed
//! settings framework: that framework serializes its typed sections to these
//! strings and back.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// The project file's name within the project root.
pub const PROJECT_FILE_NAME: &str = "project.oxide";

/// The subdirectory holding scene files.
pub const SCENES_DIR: &str = "scenes";
/// The subdirectory holding asset files (meshes, textures, audio, …).
pub const ASSETS_DIR: &str = "assets";
/// The subdirectory holding game scripts.
pub const SCRIPTS_DIR: &str = "scripts";

/// Errors from project operations.
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
    /// A project file already exists where a new project was to be created.
    #[error("a project already exists at {0}")]
    AlreadyExists(PathBuf),

    /// No project file was found at the given location.
    #[error("no project file found at {0}")]
    NotFound(PathBuf),

    /// Filesystem I/O failed.
    #[error("project i/o error at {path}: {source}")]
    Io {
        /// The path involved.
        path: PathBuf,
        /// The underlying error.
        source: std::io::Error,
    },

    /// The project file could not be parsed.
    #[error("malformed project file at {path}: {message}")]
    Parse {
        /// The project file path.
        path: PathBuf,
        /// The parser message.
        message: String,
    },

    /// The project file could not be serialized.
    #[error("failed to serialize project: {0}")]
    Serialize(String),
}

/// The serialized contents of a project file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectMeta {
    /// Human-readable project name.
    pub name: String,
    /// The engine version this project was last saved with.
    pub engine_version: String,
    /// Names of the modules enabled for this project.
    pub enabled_modules: Vec<String>,
    /// Per-project settings as opaque RON blobs, keyed by section name. The
    /// typed settings framework round-trips its sections through here.
    pub settings: BTreeMap<String, String>,
}

impl ProjectMeta {
    fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            engine_version: env!("CARGO_PKG_VERSION").to_string(),
            enabled_modules: Vec::new(),
            settings: BTreeMap::new(),
        }
    }
}

/// An open project: its root directory plus the loaded [`ProjectMeta`].
#[derive(Debug, Clone)]
pub struct Project {
    root: PathBuf,
    meta: ProjectMeta,
}

impl Project {
    /// Creates a new project rooted at `root` (created if missing), scaffolding
    /// the `scenes`/`assets`/`scripts` folders and writing the project file.
    ///
    /// # Errors
    /// [`AlreadyExists`](ProjectError::AlreadyExists) if a project file is
    /// already present, or [`Io`](ProjectError::Io) on filesystem failure.
    pub fn create(root: impl AsRef<Path>, name: impl Into<String>) -> Result<Self, ProjectError> {
        let root = root.as_ref().to_path_buf();
        let file = root.join(PROJECT_FILE_NAME);
        if file.exists() {
            return Err(ProjectError::AlreadyExists(file));
        }
        for dir in [
            &root,
            &root.join(SCENES_DIR),
            &root.join(ASSETS_DIR),
            &root.join(SCRIPTS_DIR),
        ] {
            std::fs::create_dir_all(dir).map_err(|source| ProjectError::Io {
                path: dir.clone(),
                source,
            })?;
        }
        let project = Self {
            root,
            meta: ProjectMeta::new(name),
        };
        project.save()?;
        Ok(project)
    }

    /// Opens an existing project. `path` may be the project root directory or
    /// the project file itself.
    ///
    /// # Errors
    /// [`NotFound`](ProjectError::NotFound) if no project file is present, or
    /// [`Parse`](ProjectError::Parse)/[`Io`](ProjectError::Io) on failure.
    pub fn open(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
        let path = path.as_ref();
        let (root, file) = if path.is_dir() {
            (path.to_path_buf(), path.join(PROJECT_FILE_NAME))
        } else {
            let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
            (root, path.to_path_buf())
        };
        if !file.exists() {
            return Err(ProjectError::NotFound(file));
        }
        let text = std::fs::read_to_string(&file).map_err(|source| ProjectError::Io {
            path: file.clone(),
            source,
        })?;
        let meta: ProjectMeta = ron::from_str(&text).map_err(|err| ProjectError::Parse {
            path: file.clone(),
            message: err.to_string(),
        })?;
        Ok(Self { root, meta })
    }

    /// Writes the project file, stamping it with the current engine version.
    pub fn save(&self) -> Result<(), ProjectError> {
        let file = self.project_file_path();
        let pretty = ron::ser::PrettyConfig::default();
        let text = ron::ser::to_string_pretty(&self.meta, pretty)
            .map_err(|err| ProjectError::Serialize(err.to_string()))?;
        std::fs::write(&file, text).map_err(|source| ProjectError::Io { path: file, source })
    }

    // --- Layout ------------------------------------------------------------

    /// The project root directory.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// The path of the project file.
    pub fn project_file_path(&self) -> PathBuf {
        self.root.join(PROJECT_FILE_NAME)
    }

    /// The scenes directory.
    pub fn scenes_dir(&self) -> PathBuf {
        self.root.join(SCENES_DIR)
    }

    /// The assets directory.
    pub fn assets_dir(&self) -> PathBuf {
        self.root.join(ASSETS_DIR)
    }

    /// The scripts directory.
    pub fn scripts_dir(&self) -> PathBuf {
        self.root.join(SCRIPTS_DIR)
    }

    // --- Metadata ----------------------------------------------------------

    /// The project's metadata (name, modules, settings).
    pub fn meta(&self) -> &ProjectMeta {
        &self.meta
    }

    /// The project name.
    pub fn name(&self) -> &str {
        &self.meta.name
    }

    /// Renames the project (call [`save`](Self::save) to persist).
    pub fn set_name(&mut self, name: impl Into<String>) {
        self.meta.name = name.into();
    }

    /// Whether `module` is enabled for this project.
    pub fn is_module_enabled(&self, module: &str) -> bool {
        self.meta.enabled_modules.iter().any(|m| m == module)
    }

    /// Enables `module` (no-op if already enabled).
    pub fn enable_module(&mut self, module: impl Into<String>) {
        let module = module.into();
        if !self.is_module_enabled(&module) {
            self.meta.enabled_modules.push(module);
        }
    }

    /// Disables `module`. Returns whether it was enabled.
    pub fn disable_module(&mut self, module: &str) -> bool {
        let before = self.meta.enabled_modules.len();
        self.meta.enabled_modules.retain(|m| m != module);
        self.meta.enabled_modules.len() != before
    }

    /// The raw RON blob stored for settings `section`, if any.
    pub fn settings_section(&self, section: &str) -> Option<&str> {
        self.meta.settings.get(section).map(String::as_str)
    }

    /// Stores a raw RON blob for settings `section`.
    pub fn set_settings_section(&mut self, section: impl Into<String>, ron: impl Into<String>) {
        self.meta.settings.insert(section.into(), ron.into());
    }
}

/// A most-recently-used list of project roots, persisted globally (an editor
/// preference, not part of any single project).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecentProjects {
    entries: Vec<PathBuf>,
    #[serde(default = "default_limit")]
    limit: usize,
}

fn default_limit() -> usize {
    10
}

impl RecentProjects {
    /// A list retaining at most `limit` entries.
    pub fn new(limit: usize) -> Self {
        Self {
            entries: Vec::new(),
            limit: limit.max(1),
        }
    }

    /// Records `root` as the most recent project, de-duplicating and capping.
    pub fn record(&mut self, root: impl AsRef<Path>) {
        let root = root.as_ref().to_path_buf();
        self.entries.retain(|p| p != &root);
        self.entries.insert(0, root);
        self.entries.truncate(self.limit.max(1));
    }

    /// The recorded roots, most-recent first.
    pub fn entries(&self) -> &[PathBuf] {
        &self.entries
    }

    /// Loads the list from a RON file, or returns an empty list if absent.
    pub fn load(path: impl AsRef<Path>) -> Self {
        std::fs::read_to_string(path)
            .ok()
            .and_then(|text| ron::from_str(&text).ok())
            .unwrap_or_default()
    }

    /// Saves the list to a RON file.
    pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
        let text = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, text)
    }
}

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

    fn temp_root(tag: &str) -> PathBuf {
        let mut path = std::env::temp_dir();
        path.push(format!(
            "oxide_project_test_{}_{}_{tag}",
            std::process::id(),
            // A counter to keep tests isolated within the process.
            COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
        ));
        path
    }

    static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);

    #[test]
    fn create_scaffolds_layout_and_file() {
        let root = temp_root("create");
        let project = Project::create(&root, "My Game").unwrap();
        assert!(project.project_file_path().exists());
        assert!(project.scenes_dir().is_dir());
        assert!(project.assets_dir().is_dir());
        assert!(project.scripts_dir().is_dir());
        assert_eq!(project.name(), "My Game");
        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn create_then_open_round_trips() {
        let root = temp_root("roundtrip");
        let mut project = Project::create(&root, "Game").unwrap();
        project.enable_module("physics");
        project.enable_module("audio");
        project.set_settings_section("editor", "(theme:\"dark\")");
        project.save().unwrap();

        // Open by directory.
        let opened = Project::open(&root).unwrap();
        assert_eq!(opened.name(), "Game");
        assert!(opened.is_module_enabled("physics") && opened.is_module_enabled("audio"));
        assert_eq!(opened.settings_section("editor"), Some("(theme:\"dark\")"));

        // Open by file path.
        let by_file = Project::open(opened.project_file_path()).unwrap();
        assert_eq!(by_file.meta(), opened.meta());
        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn create_refuses_to_overwrite() {
        let root = temp_root("nooverwrite");
        Project::create(&root, "A").unwrap();
        let err = Project::create(&root, "B").unwrap_err();
        assert!(matches!(err, ProjectError::AlreadyExists(_)));
        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn open_missing_is_not_found() {
        let root = temp_root("missing");
        let err = Project::open(&root).unwrap_err();
        assert!(matches!(err, ProjectError::NotFound(_)));
    }

    #[test]
    fn module_enable_disable() {
        let root = temp_root("modules");
        let mut project = Project::create(&root, "M").unwrap();
        project.enable_module("terrain");
        project.enable_module("terrain"); // idempotent
        assert_eq!(project.meta().enabled_modules, vec!["terrain"]);
        assert!(project.disable_module("terrain"));
        assert!(!project.disable_module("terrain"));
        assert!(!project.is_module_enabled("terrain"));
        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn recent_projects_dedup_and_cap() {
        let mut recent = RecentProjects::new(3);
        recent.record("/a");
        recent.record("/b");
        recent.record("/a"); // moves /a to front, no dup
        recent.record("/c");
        recent.record("/d"); // evicts the oldest (/b)
        let entries: Vec<_> = recent
            .entries()
            .iter()
            .map(|p| p.to_str().unwrap())
            .collect();
        assert_eq!(entries, vec!["/d", "/c", "/a"]);
    }

    #[test]
    fn recent_projects_persist() {
        let root = temp_root("recent");
        std::fs::create_dir_all(&root).unwrap();
        let file = root.join("recent.ron");
        let mut recent = RecentProjects::new(5);
        recent.record("/x");
        recent.record("/y");
        recent.save(&file).unwrap();
        let loaded = RecentProjects::load(&file);
        assert_eq!(loaded.entries(), recent.entries());
        std::fs::remove_dir_all(root).ok();
    }
}
