//! Stable subscene reference validation for authored level compositions.

use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};

use shared::SceneComposition;

use crate::document::SceneDocument;

pub fn validate_scene_composition(composition: &SceneComposition) -> Result<(), String> {
    if composition.scene_id.trim().is_empty() {
        return Err("scene composition has an empty scene_id".to_string());
    }
    let mut reference_ids = HashSet::new();
    for reference in &composition.subscenes {
        if reference.id.trim().is_empty() {
            return Err("subscene reference has an empty id".to_string());
        }
        if !reference_ids.insert(reference.id.as_str()) {
            return Err(format!(
                "scene composition contains duplicate subscene id `{}`",
                reference.id
            ));
        }
        validate_project_relative_path(&reference.path)?;
    }
    Ok(())
}

pub fn validate_composition_graph(entry: &Path, project_root: &Path) -> Result<(), String> {
    let entry = canonical_existing(entry)?;
    let project_root = canonical_existing(project_root)?;
    let mut visiting = Vec::new();
    let mut visited = HashSet::new();
    let mut scene_ids = HashMap::<String, PathBuf>::new();
    visit_scene(
        &entry,
        &project_root,
        &mut visiting,
        &mut visited,
        &mut scene_ids,
    )
}

pub(crate) fn find_project_root(path: &Path) -> Option<PathBuf> {
    let absolute = if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir().ok()?.join(path)
    };
    absolute.ancestors().find_map(|ancestor| {
        ancestor
            .join("assets/project.ron")
            .is_file()
            .then(|| ancestor.to_path_buf())
    })
}

fn visit_scene(
    path: &Path,
    project_root: &Path,
    visiting: &mut Vec<PathBuf>,
    visited: &mut HashSet<PathBuf>,
    scene_ids: &mut HashMap<String, PathBuf>,
) -> Result<(), String> {
    if let Some(cycle_start) = visiting.iter().position(|candidate| candidate == path) {
        let mut cycle: Vec<String> = visiting[cycle_start..]
            .iter()
            .map(|candidate| project_label(candidate, project_root))
            .collect();
        cycle.push(project_label(path, project_root));
        return Err(format!("cyclic subscene reference: {}", cycle.join(" -> ")));
    }
    if !visited.insert(path.to_path_buf()) {
        return Ok(());
    }

    let text = std::fs::read_to_string(path)
        .map_err(|error| format!("could not read {}: {error}", path.display()))?;
    let document = SceneDocument::from_ron_text(&text)
        .map_err(|error| format!("{}: {error}", path.display()))?;
    let Some(composition) = document.composition else {
        return Ok(());
    };
    validate_scene_composition(&composition)
        .map_err(|error| format!("{}: {error}", path.display()))?;
    if let Some(existing) = scene_ids.insert(composition.scene_id.clone(), path.to_path_buf()) {
        if existing != path {
            return Err(format!(
                "duplicate scene_id `{}` in {} and {}",
                composition.scene_id,
                existing.display(),
                path.display()
            ));
        }
    }

    visiting.push(path.to_path_buf());
    for reference in composition.subscenes {
        let target = canonical_project_path(project_root, &reference.path)?;
        visit_scene(&target, project_root, visiting, visited, scene_ids)?;
    }
    visiting.pop();
    Ok(())
}

fn canonical_project_path(project_root: &Path, relative: &str) -> Result<PathBuf, String> {
    validate_project_relative_path(relative)?;
    let target = project_root.join(relative);
    let target = canonical_existing(&target).map_err(|error| {
        format!(
            "missing subscene `{relative}` under {}: {error}",
            project_root.display()
        )
    })?;
    if !target.starts_with(project_root) {
        return Err(format!("subscene `{relative}` escapes the project root"));
    }
    Ok(target)
}

fn validate_project_relative_path(path: &str) -> Result<(), String> {
    let path = Path::new(path);
    if path.as_os_str().is_empty() || path.is_absolute() {
        return Err(format!(
            "subscene path `{}` must be project-relative",
            path.display()
        ));
    }
    if path.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    }) {
        return Err(format!(
            "subscene path `{}` may not escape the project root",
            path.display()
        ));
    }
    if !path
        .file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.ends_with(".scn.ron"))
    {
        return Err(format!(
            "subscene path `{}` must name a .scn.ron file",
            path.display()
        ));
    }
    Ok(())
}

fn canonical_existing(path: &Path) -> Result<PathBuf, String> {
    path.canonicalize()
        .map_err(|error| format!("{}: {error}", path.display()))
}

fn project_label(path: &Path, project_root: &Path) -> String {
    path.strip_prefix(project_root)
        .unwrap_or(path)
        .display()
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::sync::atomic::{AtomicU64, Ordering};

    static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1);

    fn fixture_root() -> PathBuf {
        let root = std::env::temp_dir().join(format!(
            "blacksite-composition-{}-{}",
            std::process::id(),
            NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(root.join("assets/levels")).unwrap();
        fs::write(root.join("assets/project.ron"), "()").unwrap();
        root
    }

    fn scene(scene_id: &str, references: &[(&str, &str)]) -> String {
        let refs = references
            .iter()
            .map(|(id, path)| format!("(id:\"{id}\",path:\"{path}\",visible:true,locked:true)"))
            .collect::<Vec<_>>()
            .join(",");
        format!(
            "(schema_version:2,resources:{{\"shared::components::SceneComposition\":(scene_id:\"{scene_id}\",subscenes:[{refs}])}},entities:{{}})"
        )
    }

    #[test]
    fn valid_two_subscene_graph_passes() {
        let root = fixture_root();
        let main = root.join("assets/levels/main.scn.ron");
        fs::write(
            &main,
            scene(
                "main",
                &[
                    ("geometry", "assets/levels/geometry.scn.ron"),
                    ("lighting", "assets/levels/lighting.scn.ron"),
                ],
            ),
        )
        .unwrap();
        fs::write(
            root.join("assets/levels/geometry.scn.ron"),
            scene("geometry", &[]),
        )
        .unwrap();
        fs::write(
            root.join("assets/levels/lighting.scn.ron"),
            scene("lighting", &[]),
        )
        .unwrap();

        validate_composition_graph(&main, &root).unwrap();
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn missing_and_cyclic_references_are_rejected() {
        let root = fixture_root();
        let main = root.join("assets/levels/main.scn.ron");
        fs::write(
            &main,
            scene("main", &[("missing", "assets/levels/missing.scn.ron")]),
        )
        .unwrap();
        assert!(validate_composition_graph(&main, &root)
            .unwrap_err()
            .contains("missing subscene"));

        let child = root.join("assets/levels/child.scn.ron");
        fs::write(
            &main,
            scene("main", &[("child", "assets/levels/child.scn.ron")]),
        )
        .unwrap();
        fs::write(
            &child,
            scene("child", &[("main", "assets/levels/main.scn.ron")]),
        )
        .unwrap();
        assert!(validate_composition_graph(&main, &root)
            .unwrap_err()
            .contains("cyclic subscene reference"));
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn unsafe_paths_and_duplicate_reference_ids_are_rejected() {
        let mut composition = SceneComposition {
            scene_id: "main".to_string(),
            subscenes: vec![
                shared::SubsceneReference {
                    id: "same".to_string(),
                    path: "../outside.scn.ron".to_string(),
                    ..Default::default()
                },
                shared::SubsceneReference {
                    id: "same".to_string(),
                    path: "assets/levels/other.scn.ron".to_string(),
                    ..Default::default()
                },
            ],
        };
        assert!(validate_scene_composition(&composition).is_err());
        composition.subscenes[0].path = "assets/levels/one.scn.ron".to_string();
        assert!(validate_scene_composition(&composition)
            .unwrap_err()
            .contains("duplicate subscene id"));
    }
}
