//! Stable prefab dependency validation for nested instances and variants.

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

use crate::document::SceneDocument;

pub fn validate_prefab_graph(entry: &Path, project_root: &Path) -> Result<(), String> {
    let requested = entry.to_path_buf();
    let entry = canonical_existing(entry)
        .map_err(|error| format!("missing prefab source `{}`: {error}", requested.display()))?;
    let project_root = canonical_existing(project_root)?;
    let text = std::fs::read_to_string(&entry)
        .map_err(|error| format!("could not read {}: {error}", entry.display()))?;
    validate_prefab_graph_text(&text, &entry, &project_root)
}

pub fn validate_prefab_references(entry: &Path, project_root: &Path) -> Result<(), String> {
    let entry = canonical_existing(entry)?;
    let project_root = canonical_existing(project_root)?;
    let text = std::fs::read_to_string(&entry)
        .map_err(|error| format!("could not read {}: {error}", entry.display()))?;
    validate_prefab_references_text(&text, &entry, &project_root)
}

pub fn prefab_graph_revision(entry: &Path, project_root: &Path) -> Result<String, String> {
    validate_prefab_graph(entry, project_root)?;
    let entry = canonical_existing(entry)?;
    let project_root = canonical_existing(project_root)?;
    let mut files = BTreeMap::new();
    collect_prefab_graph_files(&entry, &project_root, &mut files)?;

    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
    for (path, bytes) in files {
        for byte in path.as_bytes().iter().chain([0xff].iter()).chain(&bytes) {
            hash ^= u64::from(*byte);
            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
        }
    }
    Ok(format!("fnv1a64:{hash:016x}"))
}

fn collect_prefab_graph_files(
    path: &Path,
    project_root: &Path,
    files: &mut BTreeMap<String, Vec<u8>>,
) -> Result<(), String> {
    let label = project_label(path, project_root);
    if files.contains_key(&label) {
        return Ok(());
    }
    let bytes = std::fs::read(path)
        .map_err(|error| format!("could not read {}: {error}", path.display()))?;
    let text = std::str::from_utf8(&bytes)
        .map_err(|error| format!("{} is not UTF-8: {error}", path.display()))?;
    let document = SceneDocument::from_ron_text(text)
        .map_err(|error| format!("{}: {error}", path.display()))?;
    let mut dependencies: Vec<String> = document
        .prefab_instances
        .into_iter()
        .map(|instance| instance.source_path)
        .collect();
    dependencies.sort();
    dependencies.dedup();
    files.insert(label, bytes);
    for source_path in dependencies {
        let target = resolve_source_path(project_root, &source_path)?;
        collect_prefab_graph_files(&target, project_root, files)?;
    }
    Ok(())
}

pub fn validate_prefab_graph_text(
    entry_text: &str,
    entry: &Path,
    project_root: &Path,
) -> Result<(), String> {
    validate_prefab_graph_text_inner(entry_text, entry, project_root, true)
}

pub fn validate_prefab_references_text(
    entry_text: &str,
    entry: &Path,
    project_root: &Path,
) -> Result<(), String> {
    validate_prefab_graph_text_inner(entry_text, entry, project_root, false)
}

fn validate_prefab_graph_text_inner(
    entry_text: &str,
    entry: &Path,
    project_root: &Path,
    require_stable_ids: bool,
) -> Result<(), String> {
    let project_root = canonical_existing(project_root)?;
    let entry = canonical_or_nearest_existing(entry)?;
    if !entry.starts_with(&project_root) {
        return Err(format!(
            "prefab document {} is outside project root {}",
            entry.display(),
            project_root.display()
        ));
    }
    let relative_entry = entry
        .strip_prefix(&project_root)
        .map_err(|_| "prefab document is outside the project root".to_string())?;
    validate_entry_path(relative_entry, require_stable_ids)?;

    let mut visiting = Vec::new();
    let mut visited = HashSet::new();
    visit_prefab_text(
        &entry,
        entry_text,
        &project_root,
        &mut visiting,
        &mut visited,
        require_stable_ids,
    )
}

fn visit_prefab_text(
    path: &Path,
    text: &str,
    project_root: &Path,
    visiting: &mut Vec<PathBuf>,
    visited: &mut HashSet<PathBuf>,
    require_stable_ids: bool,
) -> 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 prefab reference: {}", cycle.join(" -> ")));
    }
    if !visited.insert(path.to_path_buf()) {
        return Ok(());
    }

    let document = SceneDocument::from_ron_text(text)
        .map_err(|error| format!("{}: {error}", path.display()))?;
    if require_stable_ids {
        if !document.resource_types.is_empty() {
            return Err(format!(
                "{}: prefab documents may not contain resources ({})",
                path.display(),
                document.resource_types.join(", ")
            ));
        }
        validate_actor_ids(&document, path)?;
        validate_prefab_link_anchors(&document, path)?;
    }
    for instance in &document.prefab_instances {
        shared::decode_prefab_overrides(instance).map_err(|error| {
            format!(
                "{}: prefab instance `{}` has invalid overrides: {error}",
                path.display(),
                instance.asset_id
            )
        })?;
    }
    let instance_paths: HashSet<String> = document
        .prefab_instances
        .iter()
        .map(|instance| instance.source_path.clone())
        .collect();
    let legacy_refs: Vec<_> = document
        .prefab_refs
        .into_iter()
        .filter(|prefab_ref| !instance_paths.contains(&prefab_ref.path))
        .collect();
    if require_stable_ids && !legacy_refs.is_empty() {
        return Err(format!(
            "{} contains legacy PrefabRef links without PrefabInstance identity",
            path.display()
        ));
    }
    let mut dependencies: Vec<(String, String)> = document
        .prefab_instances
        .into_iter()
        .map(|instance| (instance.asset_id, instance.source_path))
        .collect();
    dependencies.extend(
        legacy_refs
            .into_iter()
            .map(|prefab_ref| (format!("legacy:{}", prefab_ref.path), prefab_ref.path)),
    );

    visiting.push(path.to_path_buf());
    for (asset_id, source_path) in dependencies {
        if asset_id.trim().is_empty() {
            return Err(format!(
                "{}: prefab instance has an empty asset_id",
                path.display()
            ));
        }
        validate_source_path(&source_path)?;
        let lexical_target = project_root.join(&source_path);
        if let Some(cycle_start) = visiting
            .iter()
            .position(|candidate| candidate == &lexical_target)
        {
            let mut cycle: Vec<String> = visiting[cycle_start..]
                .iter()
                .map(|candidate| project_label(candidate, project_root))
                .collect();
            cycle.push(project_label(&lexical_target, project_root));
            return Err(format!("cyclic prefab reference: {}", cycle.join(" -> ")));
        }
        let target = resolve_source_path(project_root, &source_path)?;
        if let Some(cycle_start) = visiting.iter().position(|candidate| candidate == &target) {
            let mut cycle: Vec<String> = visiting[cycle_start..]
                .iter()
                .map(|candidate| project_label(candidate, project_root))
                .collect();
            cycle.push(project_label(&target, project_root));
            return Err(format!("cyclic prefab reference: {}", cycle.join(" -> ")));
        }
        if visited.contains(&target) {
            continue;
        }
        let target_text = std::fs::read_to_string(&target).map_err(|error| {
            format!(
                "missing prefab source `{}` referenced by {}: {error}",
                source_path,
                project_label(path, project_root)
            )
        })?;
        visit_prefab_text(&target, &target_text, project_root, visiting, visited, true)?;
    }
    visiting.pop();
    Ok(())
}

fn validate_prefab_link_anchors(document: &SceneDocument, path: &Path) -> Result<(), String> {
    const LEVEL_OBJECT: &str = "shared::components::LevelObject";
    const PREFAB_INSTANCE: &str = "shared::components::PrefabInstance";
    const PREFAB_REF: &str = "shared::components::PrefabRef";
    const ACTOR_KIND: &str = "shared::components::ActorKind";
    const TRANSFORM: &str = "bevy_transform::components::transform::Transform";

    for entity in &document.entities {
        let has = |type_name: &str| {
            entity
                .components
                .iter()
                .any(|component| component.type_name == type_name)
        };
        if !has(PREFAB_INSTANCE) && !has(PREFAB_REF) {
            continue;
        }
        let label = entity
            .actor_id
            .as_deref()
            .unwrap_or("anonymous prefab link");
        for (component, description) in [
            (LEVEL_OBJECT, "LevelObject"),
            (TRANSFORM, "Transform"),
            (ACTOR_KIND, "ActorKind"),
        ] {
            if !has(component) {
                return Err(format!(
                    "{}: prefab link `{label}` is missing required {description}",
                    path.display()
                ));
            }
        }
    }
    Ok(())
}

fn validate_actor_ids(document: &SceneDocument, path: &Path) -> Result<(), String> {
    let mut ids = HashSet::new();
    for entity in &document.entities {
        let authored_actor = entity
            .components
            .iter()
            .any(|component| component.type_name == "shared::components::LevelObject");
        if authored_actor && entity.actor_id.is_none() {
            return Err(format!(
                "{} contains an authored prefab actor without ActorId",
                path.display()
            ));
        }
        let Some(actor_id) = entity.actor_id.as_deref() else {
            continue;
        };
        if !ids.insert(actor_id) {
            return Err(format!(
                "{} contains duplicate ActorId `{actor_id}`",
                path.display()
            ));
        }
    }
    Ok(())
}

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

fn validate_source_path(source_path: &str) -> Result<(), String> {
    let path = Path::new(source_path);
    if path.as_os_str().is_empty() || path.is_absolute() {
        return Err(format!(
            "prefab source `{}` must be project-relative",
            path.display()
        ));
    }
    if path.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    }) {
        return Err(format!(
            "prefab source `{}` may not escape the project root",
            path.display()
        ));
    }
    let components: Vec<_> = path.components().collect();
    if components.first() != Some(&Component::Normal("assets".as_ref()))
        || components.get(1) != Some(&Component::Normal("prefabs".as_ref()))
    {
        return Err(format!(
            "prefab source `{}` must be under assets/prefabs/",
            path.display()
        ));
    }
    if !path
        .file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.ends_with(".scn.ron"))
    {
        return Err(format!(
            "prefab source `{}` must name a .scn.ron file",
            path.display()
        ));
    }
    Ok(())
}

fn validate_entry_path(path: &Path, strict_prefab: bool) -> Result<(), String> {
    let text = path.to_string_lossy();
    if strict_prefab {
        validate_source_path(&text)
    } else {
        if path.components().any(|component| {
            matches!(
                component,
                Component::ParentDir | Component::RootDir | Component::Prefix(_)
            )
        }) || path.components().next() != Some(Component::Normal("assets".as_ref()))
        {
            return Err(format!(
                "scene document `{}` must remain under assets/",
                path.display()
            ));
        }
        if !path
            .file_name()
            .and_then(|name| name.to_str())
            .is_some_and(|name| name.ends_with(".scn.ron"))
        {
            return Err(format!(
                "scene document `{}` must name a .scn.ron file",
                path.display()
            ));
        }
        Ok(())
    }
}

fn absolute_lexical(path: &Path) -> Result<PathBuf, String> {
    if path.is_absolute() {
        Ok(path.to_path_buf())
    } else {
        std::env::current_dir()
            .map(|cwd| cwd.join(path))
            .map_err(|error| format!("could not resolve {}: {error}", path.display()))
    }
}

fn canonical_or_nearest_existing(path: &Path) -> Result<PathBuf, String> {
    let absolute = absolute_lexical(path)?;
    let mut ancestor = absolute.as_path();
    let mut suffix = Vec::new();
    loop {
        match ancestor.canonicalize() {
            Ok(mut canonical) => {
                for component in suffix.iter().rev() {
                    canonical.push(component);
                }
                return Ok(canonical);
            }
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                let Some(name) = ancestor.file_name() else {
                    return Err(format!("{}: {error}", path.display()));
                };
                suffix.push(name.to_os_string());
                let Some(parent) = ancestor.parent() else {
                    return Err(format!("{}: {error}", path.display()));
                };
                ancestor = parent;
            }
            Err(error) => return Err(format!("{}: {error}", path.display())),
        }
    }
}

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-prefab-{}-{}",
            std::process::id(),
            NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(root.join("assets/prefabs")).unwrap();
        fs::write(root.join("assets/project.ron"), "()").unwrap();
        root
    }

    fn prefab(actor_id: &str, references: &[(&str, &str)]) -> String {
        let actor_components = |id: &str| {
            format!(
                "\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"{id}\"),\"shared::components::ActorKind\":PrefabAnchor,\"bevy_transform::components::transform::Transform\":(translation:(0.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))"
            )
        };
        let components = references
            .iter()
            .enumerate()
            .map(|(index, (asset_id, path))| {
                let actor_id = format!("{actor_id}-{index}");
                format!(
                    "{}:(components:{{{},\"shared::components::PrefabInstance\":(asset_id:\"{asset_id}\",source_path:\"{path}\",overrides_ron:None)}})",
                    index + 1,
                    actor_components(&actor_id)
                )
            })
            .collect::<Vec<_>>()
            .join(",");
        let root = if references.is_empty() {
            format!("1:(components:{{{}}})", actor_components(actor_id))
        } else {
            components
        };
        format!("(schema_version:2,resources:{{}},entities:{{{root}}})")
    }

    #[test]
    fn nested_prefab_graph_passes() {
        let root = fixture_root();
        let chair = root.join("assets/prefabs/chair.scn.ron");
        let room = root.join("assets/prefabs/room.scn.ron");
        fs::write(&chair, prefab("chair", &[])).unwrap();
        fs::write(
            &room,
            prefab("room", &[("chair-asset", "assets/prefabs/chair.scn.ron")]),
        )
        .unwrap();

        validate_prefab_graph(&room, &root).unwrap();
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn nested_instance_with_invalid_override_payload_is_rejected() {
        let root = fixture_root();
        let base = root.join("assets/prefabs/base.scn.ron");
        let outer = root.join("assets/prefabs/outer.scn.ron");
        fs::write(&base, prefab("base", &[])).unwrap();
        let mut instance = shared::PrefabInstance::new("base", "assets/prefabs/base.scn.ron");
        instance.overrides_ron = Some("not valid override ron".to_string());
        let text = format!(
            "(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"nested\"),\"shared::components::ActorKind\":PrefabAnchor,\"bevy_transform::components::transform::Transform\":(translation:(0.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)),\"shared::components::PrefabInstance\":{}}})}})",
            ron::to_string(&instance).unwrap()
        );
        fs::write(&outer, text).unwrap();

        let error = validate_prefab_graph(&outer, &root).unwrap_err();
        assert!(error.contains("invalid overrides"), "{error}");
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn graph_revision_changes_when_nested_base_changes() {
        let root = fixture_root();
        let base = root.join("assets/prefabs/base.scn.ron");
        let variant = root.join("assets/prefabs/variant.scn.ron");
        fs::write(&base, prefab("base-a", &[])).unwrap();
        fs::write(
            &variant,
            prefab("variant", &[("base", "assets/prefabs/base.scn.ron")]),
        )
        .unwrap();
        let before = prefab_graph_revision(&variant, &root).unwrap();

        fs::write(&base, prefab("base-b", &[])).unwrap();
        let after = prefab_graph_revision(&variant, &root).unwrap();

        assert_ne!(before, after);
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn missing_and_cyclic_sources_are_rejected() {
        let root = fixture_root();
        let a = root.join("assets/prefabs/a.scn.ron");
        let b = root.join("assets/prefabs/b.scn.ron");
        fs::write(&a, prefab("a", &[("b", "assets/prefabs/b.scn.ron")])).unwrap();
        let error = validate_prefab_graph(&a, &root).unwrap_err();
        assert!(error.contains("missing prefab source"), "{error}");

        fs::write(&b, prefab("b", &[("a", "assets/prefabs/a.scn.ron")])).unwrap();
        let error = validate_prefab_graph(&a, &root).unwrap_err();
        assert!(error.contains("cyclic prefab reference"), "{error}");
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn virtual_variant_cycle_and_unsafe_path_are_rejected() {
        let root = fixture_root();
        let variant_path = root.join("assets/prefabs/variant.scn.ron");
        let text = prefab("variant", &[("self", "assets/prefabs/variant.scn.ron")]);
        let error = validate_prefab_graph_text(&text, &variant_path, &root).unwrap_err();
        assert!(error.contains("cyclic prefab reference"), "{error}");

        let text = prefab("unsafe", &[("escape", "../outside.scn.ron")]);
        let error = validate_prefab_graph_text(&text, &variant_path, &root).unwrap_err();
        assert!(error.contains("may not escape"), "{error}");
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn duplicate_actor_ids_are_rejected() {
        let root = fixture_root();
        let prefab_path = root.join("assets/prefabs/duplicate.scn.ron");
        let text = concat!(
            "(schema_version:2,resources:{},entities:{",
            "1:(components:{\"shared::components::LevelObject\":(),",
            "\"shared::components::ActorId\":(\"same-actor\")}),",
            "2:(components:{\"shared::components::LevelObject\":(),",
            "\"shared::components::ActorId\":(\"same-actor\")})",
            "})"
        );

        let error = validate_prefab_graph_text(text, &prefab_path, &root).unwrap_err();

        assert!(error.contains("duplicate ActorId `same-actor`"), "{error}");
        fs::remove_dir_all(root).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn symlink_alias_cycle_is_rejected() {
        use std::os::unix::fs::symlink;

        let root = fixture_root();
        let real = root.join("assets/prefabs/real.scn.ron");
        let alias = root.join("assets/prefabs/alias.scn.ron");
        fs::write(
            &real,
            prefab("real", &[("alias", "assets/prefabs/alias.scn.ron")]),
        )
        .unwrap();
        symlink("real.scn.ron", &alias).unwrap();

        let error = validate_prefab_graph(&real, &root).unwrap_err();
        assert!(error.contains("cyclic prefab reference"), "{error}");
        fs::remove_dir_all(root).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn new_prefab_path_cannot_escape_through_symlink_parent() {
        use std::os::unix::fs::symlink;

        let root = fixture_root();
        let outside = std::env::temp_dir().join(format!(
            "blacksite-prefab-outside-{}-{}",
            std::process::id(),
            NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(&outside).unwrap();
        fs::remove_dir(root.join("assets/prefabs")).unwrap();
        symlink(&outside, root.join("assets/prefabs")).unwrap();

        let output = root.join("assets/prefabs/escaped.scn.ron");
        let error = validate_prefab_graph_text(&prefab("safe", &[]), &output, &root).unwrap_err();
        assert!(error.contains("outside project root"), "{error}");

        fs::remove_dir_all(root).unwrap();
        fs::remove_dir_all(outside).unwrap();
    }
}
