//! Scene file schema versioning for `assets/levels/*.scn.ron`.
//!
//! Bevy dynamic scenes are stored as RON tuples. On save the editor stamps
//! `schema_version` as the first root field; loaders strip it before deserializing.

mod composition;
pub mod document;
mod migrate;
pub mod navigation;
mod prefab;
mod project_validation;
mod upgrade;

use std::path::Path;

use serde::{Deserialize, Serialize};

pub use composition::{validate_composition_graph, validate_scene_composition};
pub use migrate::{
    migrate_v1_to_v2, migrate_v2_to_v3, migrate_v3_to_v4, DEFAULT_SUN_ILLUMINANCE_LUX,
};
pub use prefab::{
    prefab_graph_revision, validate_prefab_graph, validate_prefab_graph_text,
    validate_prefab_references, validate_prefab_references_text,
};
pub use project_validation::{
    validate_project, ProjectDependency, ProjectValidationFinding, ProjectValidationReport,
    ValidationSeverity,
};
pub use upgrade::{upgrade_project, ProjectUpgradeChange, ProjectUpgradeReport};

pub const CURRENT_SCENE_SCHEMA_VERSION: u32 = 4;

/// Whether an asset-relative path names a tree excluded from runtime packages.
pub fn is_runtime_package_excluded_tree(relative: &Path) -> bool {
    relative
        .components()
        .filter_map(|component| match component {
            std::path::Component::Normal(component) => component.to_str(),
            _ => None,
        })
        .any(|component| {
            component.starts_with('.')
                || matches!(
                    component,
                    "thumbnails" | "build_profiles" | "recovery" | "autosave"
                )
        })
}

/// Shared validator/packager policy for files that may ship under `assets/`.
pub fn is_runtime_package_asset(relative: &Path) -> bool {
    if is_runtime_package_excluded_tree(relative) {
        return false;
    }
    let filename = relative
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase();
    if filename.starts_with('.')
        || filename.ends_with(".bak")
        || filename.ends_with(".tmp")
        || filename.ends_with(".autosave")
        || filename.ends_with(".recovery")
    {
        return false;
    }
    !matches!(
        relative
            .extension()
            .and_then(|extension| extension.to_str())
            .map(str::to_ascii_lowercase)
            .as_deref(),
        Some("blend" | "blend1" | "psd" | "kra" | "xcf" | "fbm")
    )
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneSchemaHeader {
    pub schema_version: u32,
}

impl Default for SceneSchemaHeader {
    fn default() -> Self {
        Self {
            schema_version: CURRENT_SCENE_SCHEMA_VERSION,
        }
    }
}

fn read_schema_version(text: &str) -> u32 {
    let trimmed = text.trim();
    if !trimmed.starts_with("(schema_version:") {
        return 1;
    }
    let inner = trimmed
        .strip_prefix('(')
        .and_then(|s| s.strip_prefix("schema_version:"))
        .unwrap_or("");
    let version_str = inner.trim().split(',').next().unwrap_or("1").trim();
    version_str.parse().unwrap_or(1)
}

/// Inserts `schema_version` as the first field of a Bevy dynamic-scene RON root tuple.
pub fn stamp_schema_version(text: &str) -> Result<String, String> {
    let trimmed = text.trim();
    if trimmed.starts_with("(schema_version:") {
        let version = read_schema_version(trimmed);
        if version == CURRENT_SCENE_SCHEMA_VERSION {
            return Ok(trimmed.to_string());
        }
        let body = strip_schema_version(trimmed)?;
        return stamp_schema_version(&body);
    }
    if !trimmed.starts_with('(') || !trimmed.ends_with(')') {
        return Err("expected scene RON root tuple".into());
    }
    Ok(format!(
        "(schema_version: {},{}",
        CURRENT_SCENE_SCHEMA_VERSION,
        &trimmed[1..]
    ))
}

/// Applies versioned migrations and stamps the current schema header.
pub fn migrate_scene_text(text: &str) -> Result<String, String> {
    let trimmed = text.trim();
    let version = read_schema_version(trimmed);
    let body = if trimmed.starts_with("(schema_version:") {
        strip_schema_version(trimmed)?
    } else {
        trimmed.to_string()
    };

    let migrated_body = match version {
        0 | 1 => migrate_v3_to_v4(&migrate_v2_to_v3(&migrate_v1_to_v2(&body))),
        2 => migrate_v3_to_v4(&migrate_v2_to_v3(&body)),
        3 => migrate_v3_to_v4(&body),
        4 => body,
        _ => {
            return Err(format!(
                "scene schema version {version} is newer than supported version {CURRENT_SCENE_SCHEMA_VERSION}"
            ));
        }
    };

    stamp_schema_version(&migrated_body)
}

/// Removes the schema header so Bevy can deserialize the dynamic scene body.
pub fn strip_schema_version(text: &str) -> Result<String, String> {
    let trimmed = text.trim();
    if !trimmed.starts_with("(schema_version:") {
        return Ok(trimmed.to_string());
    }
    let inner = trimmed
        .strip_prefix('(')
        .ok_or_else(|| "invalid schema wrapper".to_string())?;
    let after_key = inner
        .strip_prefix("schema_version:")
        .ok_or_else(|| "invalid schema_version prefix".to_string())?;
    let comma = after_key
        .find(',')
        .ok_or_else(|| "schema_version tuple missing body".to_string())?;
    Ok(format!("({}", after_key[comma + 1..].trim_start()))
}

/// Hydrated/runtime component type names that must not appear in saved `.scn.ron` files.
pub const FORBIDDEN_SAVED_COMPONENT_MARKERS: &[&str] = &[
    "bevy_pbr::PointLight",
    "bevy_pbr::SpotLight",
    "bevy_pbr::DirectionalLight",
    "bevy_mesh::Mesh3d",
    "bevy_mesh::MeshMaterial3d",
    "avian3d::RigidBody",
    "avian3d::Collider",
    "bevy_world_serialization::components::WorldAssetRoot",
    "bevy_world_serialization::components::DynamicWorldRoot",
    "bevy_render::visibility::ComputedVisibility",
    "bevy_audio::",
    "game::audio::AuthoredAudioVoice",
    "bevy_animation::AnimationGraphHandle",
    "bevy_animation::AnimationPlayer",
    "bevy_animation::AnimationTransitions",
    "game::animation::AuthoredAnimationRuntime",
];

/// Ensures serialized scene text does not contain hydrated ECS component entries.
pub fn validate_scene_authoring_only(text: &str) -> Result<(), String> {
    for marker in FORBIDDEN_SAVED_COMPONENT_MARKERS {
        if text.contains(marker) {
            return Err(format!(
                "scene file contains hydrated component marker `{marker}` (save allowlist violation)"
            ));
        }
    }
    Ok(())
}

pub fn validate_level_text(text: &str) -> Result<(), String> {
    if text.trim().is_empty() {
        return Err("empty scene file".into());
    }
    let migrated = migrate_scene_text(text)?;
    strip_schema_version(&migrated)?;
    validate_scene_authoring_only(&migrated)?;
    document::validate_hierarchy_references(&migrated)?;
    Ok(())
}

pub fn validate_level_file(path: &Path) -> Result<(), String> {
    let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
    validate_level_text(&text)?;
    if let Some(project_root) = composition::find_project_root(path) {
        validate_composition_graph(path, &project_root)?;
        validate_prefab_references(path, &project_root)?;
    }
    Ok(())
}

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

    #[test]
    fn stamp_and_strip_round_trip() {
        let body = "(resources: {}, entities: {})";
        let stamped = stamp_schema_version(body).unwrap();
        assert!(stamped.starts_with("(schema_version: 4,"));
        let stripped = strip_schema_version(&stamped).unwrap();
        assert_eq!(stripped, body);
    }

    #[test]
    fn legacy_scene_migrates_to_current_schema() {
        let legacy = "(resources: {}, entities: {4294967133: ()})";
        let migrated = migrate_scene_text(legacy).unwrap();
        assert!(migrated.contains("schema_version: 4"));
    }

    #[test]
    fn newer_scene_schema_is_rejected_instead_of_downstamped() {
        let future = "(schema_version: 999, resources: {}, entities: {})";
        let error = migrate_scene_text(future).unwrap_err();
        assert!(error.contains("newer than supported"), "{error}");
        assert!(validate_level_text(future).is_err());
    }

    #[test]
    fn runtime_package_policy_excludes_editor_trees_and_source_formats() {
        for excluded in [
            ".index/registry.ron",
            ".env/secret",
            "build_profiles/release.ron",
            "levels/recovery/main.scn.ron",
            "textures/source.psd",
            "models/source.blend",
            "levels/main.scn.ron.bak",
        ] {
            assert!(!is_runtime_package_asset(Path::new(excluded)), "{excluded}");
        }
        for included in [
            "project.ron",
            "levels/main.scn.ron",
            "textures/albedo.png",
            "models/mesh.glb",
        ] {
            assert!(is_runtime_package_asset(Path::new(included)), "{included}");
        }
    }

    #[test]
    #[ignore = "run manually to rewrite assets/levels/editor_scene.scn.ron"]
    fn migrate_editor_scene_on_disk() {
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../assets/levels/editor_scene.scn.ron");
        let text = std::fs::read_to_string(&path).expect("read editor scene");
        let migrated = migrate_scene_text(&text).expect("migrate");
        std::fs::write(&path, migrated).expect("write migrated scene");
    }

    #[test]
    fn repo_editor_scene_has_no_hydrated_components() {
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../assets/levels/editor_scene.scn.ron");
        let text = std::fs::read_to_string(&path).expect("read editor scene");
        validate_scene_authoring_only(&text).expect("editor scene must be authoring-only");
    }

    #[test]
    fn runtime_audio_components_are_rejected_from_authored_scenes() {
        for marker in [
            "bevy_audio::audio::AudioPlayer",
            "bevy_audio::sinks::SpatialAudioSink",
            "game::audio::AuthoredAudioVoice",
        ] {
            let text =
                format!("(resources: {{}}, entities: {{1:(components:{{\"{marker}\":()}})}})");
            assert!(
                validate_scene_authoring_only(&text).is_err(),
                "{marker} must be runtime-only"
            );
        }
    }

    #[test]
    fn v1_scene_gains_actor_kind_on_migrate() {
        let v1 = "(schema_version: 1,(resources: {}, entities: {
    1: (
      components: {
        \"shared::components::LevelObject\": (),
        \"shared::components::Primitive\": (
          shape: Box,
          size: (1.0, 1.0, 1.0),
        ),
      },
    ),
  }))";
        let migrated = migrate_scene_text(v1).unwrap();
        assert!(migrated.contains("ActorKind"));
        assert!(migrated.contains("StaticMesh"));
    }

    #[test]
    fn hierarchy_validation_rejects_missing_parent_and_cycles() {
        let missing_parent = r#"(schema_version: 2, resources: {}, entities: {
            1: (components: { "bevy_ecs::hierarchy::ChildOf": (99) }),
        })"#;
        let error = validate_level_text(missing_parent).unwrap_err();
        assert!(error.contains("missing hierarchy parent `99`"));

        let cycle = r#"(schema_version: 2, resources: {}, entities: {
            1: (components: { "bevy_ecs::hierarchy::ChildOf": (2) }),
            2: (components: { "bevy_ecs::hierarchy::ChildOf": (1) }),
        })"#;
        let error = validate_level_text(cycle).unwrap_err();
        assert!(error.contains("scene hierarchy contains a cycle"));
    }
}
