//! Authoritative project content validation shared by editor and headless tooling.

use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Component, Path, PathBuf};

use serde::{Deserialize, Serialize};
use shared::{
    animation_clip_source_index, animation_clip_sub_asset_id, animation_skeleton_source_index,
    animation_skeleton_sub_asset_id,
    brush_math::{validate_brush, BrushDiagnosticSeverity},
    AnimationControllerDesc, AnimationDiagnosticSeverity, AnimationManifest, AudioListenerDesc,
    AudioSourceDesc, BrushDesc, ColliderDesc, ColliderShapeDesc, EditorAssetRef, MaterialAsset,
    MaterialDesc, MaterialInstanceAsset, MaterialOverride, ModelRef, PostProcessEffectAsset,
    PostProcessVolumeDesc, PrefabInstance, PrefabRef, RendererMaterialSet, ShaderSchemaAsset,
    SkinnedMeshRenderer, StaticMeshRenderer, ANIMATION_MANIFEST_SCHEMA_VERSION,
    AUDIO_CLIP_SUB_ASSET_ID, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER,
    NAVIGATION_GENERATED_ARTIFACT_DIRECTORY,
};

use crate::document::{SceneComponentBlob, SceneDocument};

const MODEL_REF: &str = "shared::components::ModelRef";
const MATERIAL_DESC: &str = "shared::components::MaterialDesc";
const MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride";
const STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer";
const BRUSH_DESC: &str = "shared::components::BrushDesc";
const COLLIDER_DESC: &str = "shared::components::ColliderDesc";
const POST_PROCESS_VOLUME: &str = "shared::components::PostProcessVolumeDesc";
const PREFAB_INSTANCE: &str = "shared::components::PrefabInstance";
const PREFAB_REF: &str = "shared::components::PrefabRef";
const AUDIO_SOURCE_DESC: &str = "shared::components::AudioSourceDesc";
const AUDIO_LISTENER_DESC: &str = "shared::components::AudioListenerDesc";
const GIT_LFS_POINTER_HEADER_LF: &[u8] = b"version https://git-lfs.github.com/spec/v1\n";
const GIT_LFS_POINTER_HEADER_CRLF: &[u8] = b"version https://git-lfs.github.com/spec/v1\r\n";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ValidationSeverity {
    Error,
    Warning,
    Info,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectDependency {
    pub owner_path: String,
    pub owner_actor_id: Option<String>,
    pub kind: String,
    pub reference: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectValidationFinding {
    pub severity: ValidationSeverity,
    pub code: String,
    pub source_path: String,
    pub owner_actor_id: Option<String>,
    pub reference: Option<String>,
    pub message: String,
    pub repair: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectValidationReport {
    pub dependencies: Vec<ProjectDependency>,
    pub findings: Vec<ProjectValidationFinding>,
}

impl ProjectValidationReport {
    pub fn blocking_error_count(&self) -> usize {
        self.findings
            .iter()
            .filter(|finding| finding.severity == ValidationSeverity::Error)
            .count()
    }

    pub fn is_release_ready(&self) -> bool {
        self.blocking_error_count() == 0
    }
}

/// Validates every authored scene/prefab and returns a stable dependency/finding report.
pub fn validate_project(project_root: &Path) -> ProjectValidationReport {
    let mut report = ProjectValidationReport::default();
    let audio_settings = validate_project_manifest(project_root, &mut report).unwrap_or_default();
    let mut animation_catalog = validate_asset_registry(project_root, &mut report);
    validate_material_assets(project_root, &mut report);
    validate_shader_assets(project_root, &mut report);
    validate_post_process_assets(project_root, &mut report);
    validate_static_mesh_manifests(project_root, &mut report);
    validate_animation_manifests(project_root, &mut report, &mut animation_catalog);
    let mut files = Vec::new();
    collect_scene_files(
        project_root,
        &project_root.join("assets/levels"),
        &mut files,
        &mut report,
    );
    collect_scene_files(
        project_root,
        &project_root.join("assets/prefabs"),
        &mut files,
        &mut report,
    );
    files.sort();

    for path in files {
        validate_document(
            project_root,
            &path,
            &audio_settings,
            &animation_catalog,
            &mut report,
        );
    }

    validate_navigation_artifact_ownership(&mut report);

    report.dependencies.sort_by(|left, right| {
        (
            &left.owner_path,
            &left.owner_actor_id,
            &left.kind,
            &left.reference,
        )
            .cmp(&(
                &right.owner_path,
                &right.owner_actor_id,
                &right.kind,
                &right.reference,
            ))
    });
    report.dependencies.dedup();
    report.findings.sort_by(|left, right| {
        (
            &left.source_path,
            &left.owner_actor_id,
            &left.code,
            &left.reference,
        )
            .cmp(&(
                &right.source_path,
                &right.owner_actor_id,
                &right.code,
                &right.reference,
            ))
    });
    report.findings.dedup();
    report
}

fn collect_scene_files(
    project_root: &Path,
    root: &Path,
    files: &mut Vec<PathBuf>,
    report: &mut ProjectValidationReport,
) {
    collect_files(project_root, root, ".scn.ron", files, report);
}

#[derive(Deserialize)]
struct ProjectManifestView {
    version: u32,
    #[serde(default)]
    template_version: u32,
    default_level: String,
    #[serde(default)]
    asset_roots: Vec<String>,
    #[serde(default)]
    capabilities: Vec<String>,
    #[serde(default)]
    audio: settings::AudioSettings,
}

fn validate_project_manifest(
    project_root: &Path,
    report: &mut ProjectValidationReport,
) -> Option<settings::AudioSettings> {
    let path = project_root.join("assets/project.ron");
    let source = "assets/project.ron";
    let manifest = parse_document::<ProjectManifestView>(&path, source, "project", report)?;
    if let Err(error) = manifest.audio.validate() {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "audio.bus_graph_invalid".into(),
            source_path: source.into(),
            owner_actor_id: None,
            reference: None,
            message: error.to_string(),
            repair: "Repair the project audio buses so required IDs exist and all parent routes terminate at Master.".into(),
        });
    }
    if manifest.version != 1 || manifest.template_version > 1 {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "project.incompatible_schema".into(),
            source_path: source.into(),
            owner_actor_id: None,
            reference: None,
            message: format!(
                "unsupported project version {} / template version {}",
                manifest.version, manifest.template_version
            ),
            repair: "Open the project in a compatible editor or run its project migration.".into(),
        });
    }
    add_reference(
        project_root,
        report,
        source,
        None,
        "default_level",
        &manifest.default_level,
    );
    let default_is_audited_level = resolve_reference(project_root, &manifest.default_level)
        .and_then(|path| {
            let metadata = std::fs::symlink_metadata(&path).ok()?;
            if !metadata.is_file()
                || metadata.file_type().is_symlink()
                || !path.to_string_lossy().ends_with(".scn.ron")
            {
                return None;
            }
            path.strip_prefix(project_root.join("assets"))
                .ok()
                .map(Path::to_path_buf)
        })
        .is_some_and(|relative| relative.starts_with("levels") && relative != Path::new("levels"));
    if !default_is_audited_level {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "project.default_level_outside_levels".into(),
            source_path: source.into(),
            owner_actor_id: None,
            reference: Some(manifest.default_level.clone()),
            message: "project default level must be a regular .scn.ron file under assets/levels/ so full dependency validation covers it".into(),
            repair: "Move the startup scene under assets/levels/, use the .scn.ron format, and update default_level.".into(),
        });
    }
    for asset_root in manifest.asset_roots {
        match resolve_reference(project_root, &asset_root) {
            Some(path)
                if std::fs::symlink_metadata(&path)
                    .is_ok_and(|metadata| metadata.file_type().is_symlink()) =>
            {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "asset.symlink_unsupported".into(),
                    source_path: source.into(),
                    owner_actor_id: None,
                    reference: Some(asset_root),
                    message: "configured asset root is a symlink and cannot be packaged".into(),
                    repair: "Replace the symlink with a regular project asset directory.".into(),
                });
            }
            Some(path) if path.is_dir() => {}
            Some(_) => report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "project.missing_asset_root".into(),
                source_path: source.into(),
                owner_actor_id: None,
                reference: Some(asset_root),
                message: "configured asset root does not exist or is not a directory".into(),
                repair: "Create the asset directory or remove it from project settings.".into(),
            }),
            None => report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "asset.unsafe_path".into(),
                source_path: source.into(),
                owner_actor_id: None,
                reference: Some(asset_root),
                message: "configured asset root escapes the project".into(),
                repair: "Choose a project-relative directory under assets/.".into(),
            }),
        }
    }
    let mut capabilities = BTreeSet::new();
    for capability in manifest.capabilities {
        if !capabilities.insert(capability.clone()) {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Warning,
                code: "project.duplicate_capability".into(),
                source_path: source.into(),
                owner_actor_id: None,
                reference: Some(capability),
                message: "project capability is listed more than once".into(),
                repair: "Remove the duplicate project capability tag.".into(),
            });
        }
    }
    if capabilities.contains("solari") {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Info,
            code: "platform.ray_tracing_optional".into(),
            source_path: source.into(),
            owner_actor_id: None,
            reference: Some("solari".into()),
            message: "Solari is enabled; unsupported GPUs will use the forward fallback".into(),
            repair: "Verify both Solari and forward fallback during target-platform QA.".into(),
        });
    }
    Some(manifest.audio)
}

#[derive(Deserialize)]
struct RegistryAssetId(String);

#[derive(Default, Deserialize)]
struct RegistryImportSettings {
    #[serde(default)]
    static_mesh_manifest_path: Option<String>,
    #[serde(default)]
    animation_manifest_path: Option<String>,
    #[serde(default)]
    default_animation_clip_id: Option<String>,
}

#[derive(Deserialize)]
struct RegistryAssetRecord {
    id: RegistryAssetId,
    path: String,
    kind_tag: String,
    #[serde(default)]
    import_settings: RegistryImportSettings,
    #[serde(default)]
    dependencies: Vec<String>,
}

#[derive(Debug, Clone)]
struct AnimationRegistryRecord {
    source_path: String,
    manifest_path: Option<String>,
    default_animation_clip_id: Option<String>,
}

#[derive(Debug, Clone)]
enum AnimationManifestLoad {
    Valid(Box<AnimationManifest>),
    Invalid(String),
}

#[derive(Debug, Default)]
struct AnimationValidationCatalog {
    registry_by_id: BTreeMap<String, AnimationRegistryRecord>,
    manifests_by_path: BTreeMap<String, AnimationManifestLoad>,
}

fn validate_asset_registry(
    project_root: &Path,
    report: &mut ProjectValidationReport,
) -> AnimationValidationCatalog {
    let mut animation_catalog = AnimationValidationCatalog::default();
    let path = project_root.join("assets/.index/registry.ron");
    if !path.exists() {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Warning,
            code: "registry.missing".into(),
            source_path: "assets/.index/registry.ron".into(),
            owner_actor_id: None,
            reference: None,
            message: "asset registry does not exist".into(),
            repair: "Open the project in the editor to rebuild the asset registry.".into(),
        });
        return animation_catalog;
    }
    let Some(records) = parse_document::<Vec<RegistryAssetRecord>>(
        &path,
        "assets/.index/registry.ron",
        "registry",
        report,
    ) else {
        return animation_catalog;
    };
    let mut ids = BTreeSet::new();
    let mut paths = BTreeSet::new();
    for record in records {
        if record.id.0.trim().is_empty() || !ids.insert(record.id.0.clone()) {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "registry.duplicate_or_empty_id".into(),
                source_path: "assets/.index/registry.ron".into(),
                owner_actor_id: None,
                reference: Some(record.id.0.clone()),
                message: "asset registry ID is empty or duplicated".into(),
                repair: "Reimport the affected asset to assign a unique stable ID.".into(),
            });
        }
        if !paths.insert(record.path.clone()) {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "registry.duplicate_path".into(),
                source_path: "assets/.index/registry.ron".into(),
                owner_actor_id: None,
                reference: Some(record.path.clone()),
                message: "asset path appears more than once in the registry".into(),
                repair: "Refresh the asset registry and retain one stable record.".into(),
            });
        }
        let runtime_record = reference_is_runtime_package_asset(&record.path);
        if runtime_record {
            add_reference(
                project_root,
                report,
                "assets/.index/registry.ron",
                None,
                &format!("registry_{}", record.kind_tag.to_lowercase()),
                &record.path,
            );
            for dependency in &record.dependencies {
                add_reference(
                    project_root,
                    report,
                    &record.path,
                    None,
                    "import_dependency",
                    dependency,
                );
            }
            if let Some(manifest) = record.import_settings.static_mesh_manifest_path.as_deref() {
                add_reference(
                    project_root,
                    report,
                    &record.path,
                    None,
                    "generated_manifest",
                    manifest,
                );
            }
            if let Some(manifest) = record.import_settings.animation_manifest_path.as_deref() {
                add_reference(
                    project_root,
                    report,
                    &record.path,
                    None,
                    "animation_manifest",
                    manifest,
                );
            }
        }
        if record.kind_tag == "Model" {
            animation_catalog
                .registry_by_id
                .entry(record.id.0.clone())
                .or_insert(AnimationRegistryRecord {
                    source_path: record.path.clone(),
                    manifest_path: record.import_settings.animation_manifest_path.clone(),
                    default_animation_clip_id: record
                        .import_settings
                        .default_animation_clip_id
                        .clone(),
                });
        }
        if record.kind_tag == "Model"
            && !matches!(
                Path::new(&record.path)
                    .extension()
                    .and_then(|extension| extension.to_str())
                    .map(str::to_ascii_lowercase)
                    .as_deref(),
                Some("gltf" | "glb" | "fbx")
            )
        {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "import.unsupported_model_format".into(),
                source_path: "assets/.index/registry.ron".into(),
                owner_actor_id: None,
                reference: Some(record.path.clone()),
                message: "model registry entry uses an unsupported import format".into(),
                repair: "Convert the model to glTF, GLB, or FBX and reimport it.".into(),
            });
        }
        if record.kind_tag == "AudioClip" && !is_supported_audio_reference(&record.path) {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "import.unsupported_audio_format".into(),
                source_path: "assets/.index/registry.ron".into(),
                owner_actor_id: None,
                reference: Some(record.path.clone()),
                message: "audio registry entry uses a format not enabled by the runtime".into(),
                repair: "Convert the clip to Ogg Vorbis, WAV, MP3, or FLAC and reimport it.".into(),
            });
        }
    }
    animation_catalog
}

fn reference_is_runtime_package_asset(reference: &str) -> bool {
    let relative = Path::new(reference.split('#').next().unwrap_or(reference));
    if relative.is_absolute()
        || relative.components().any(|component| {
            matches!(
                component,
                Component::ParentDir | Component::RootDir | Component::Prefix(_)
            )
        })
    {
        return true;
    }
    let relative = relative.strip_prefix("assets").unwrap_or(relative);
    crate::is_runtime_package_asset(relative)
}

fn validate_material_assets(project_root: &Path, report: &mut ProjectValidationReport) {
    for path in ron_files(
        project_root,
        &project_root.join("assets/materials"),
        ".ron",
        report,
    ) {
        let source = display_project_path(project_root, &path);
        let Ok(text) = std::fs::read_to_string(&path) else {
            continue;
        };
        if let Ok(asset) = ron::from_str::<MaterialAsset>(&text) {
            if asset.schema_version != shared::MATERIAL_ASSET_SCHEMA_VERSION {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "material.incompatible_schema".into(),
                    source_path: source.clone(),
                    owner_actor_id: None,
                    reference: Some(asset.schema_version.to_string()),
                    message: "material asset schema is not supported by this build".into(),
                    repair: "Run `cargo upgrade-project --apply` and review the upgrade report."
                        .into(),
                });
            }
            if let Some(shader) = asset.shader {
                add_reference(
                    project_root,
                    report,
                    &source,
                    None,
                    "shader_schema",
                    &shader,
                );
            }
            if let Some(shader) = asset.shader_ref {
                collect_asset_ref(
                    project_root,
                    report,
                    &source,
                    None,
                    "shader_schema",
                    &shader,
                );
            }
            collect_material(project_root, report, &source, None, &asset.material);
            continue;
        }
        if let Ok(instance) = ron::from_str::<MaterialInstanceAsset>(&text) {
            if instance.schema_version != shared::MATERIAL_INSTANCE_SCHEMA_VERSION {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "material_instance.incompatible_schema".into(),
                    source_path: source.clone(),
                    owner_actor_id: None,
                    reference: Some(instance.schema_version.to_string()),
                    message: "material-instance schema is not supported by this build".into(),
                    repair: "Run `cargo upgrade-project --apply` and review the upgrade report."
                        .into(),
                });
            }
            let nested_base = instance.base.0.sub_asset_id == "material:instance"
                || instance.base.0.source_path.as_deref().is_some_and(|path| {
                    let path = Path::new(path);
                    let path = if path.is_absolute() {
                        path.to_path_buf()
                    } else {
                        project_root.join(path)
                    };
                    MaterialInstanceAsset::load_from_path(&path.to_string_lossy()).is_ok()
                });
            if nested_base {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "material_instance.nested_base".into(),
                    source_path: source.clone(),
                    owner_actor_id: None,
                    reference: Some(instance.base.0.label.clone()),
                    message: "material instances must directly inherit a Material asset".into(),
                    repair: "Choose a Material asset as the instance base.".into(),
                });
            }
            collect_asset_ref(
                project_root,
                report,
                &source,
                None,
                "material",
                &instance.base.0,
            );
            for texture in instance.textures {
                if let Some(reference) = texture.texture {
                    collect_asset_ref(project_root, report, &source, None, "texture", &reference);
                }
            }
            continue;
        }
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "material.invalid".into(),
            source_path: source,
            owner_actor_id: None,
            reference: None,
            message: "could not parse file as a Material or Material Instance asset".into(),
            repair: "Repair the document or restore it from the project-upgrade backup.".into(),
        });
    }
}

fn validate_shader_assets(project_root: &Path, report: &mut ProjectValidationReport) {
    for path in ron_files(
        project_root,
        &project_root.join("assets/shaders"),
        ".shader.ron",
        report,
    ) {
        let source = display_project_path(project_root, &path);
        let Some(asset) =
            parse_document::<ShaderSchemaAsset>(&path, &source, "shader schema", report)
        else {
            continue;
        };
        if asset.schema_version != shared::SURFACE_SHADER_SCHEMA_VERSION {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "surface_shader.incompatible_schema".into(),
                source_path: source.clone(),
                owner_actor_id: None,
                reference: Some(asset.schema_version.to_string()),
                message: "surface shader schema is not supported by this build".into(),
                repair: "Run `cargo upgrade-project --apply` and review the upgrade report.".into(),
            });
        }
        if let Some(wgsl) = asset.wgsl_path {
            add_reference(project_root, report, &source, None, "shader", &wgsl);
            if let Ok(code) = std::fs::read_to_string(project_root.join(&wgsl)) {
                if !code.contains("fn evaluate(")
                    || [
                        "@group",
                        "@binding",
                        "@vertex",
                        "@fragment",
                        "@compute",
                        "var<",
                        "rayQuery",
                        "dpdx",
                        "dpdy",
                        "fwidth",
                        "discard",
                        "Barrier",
                        "subgroup",
                        "textureStore",
                        "atomic",
                    ]
                    .iter()
                    .any(|forbidden| code.contains(forbidden))
                {
                    report.findings.push(ProjectValidationFinding {
                        severity: ValidationSeverity::Error,
                        code: "surface_shader.invalid_contract".into(),
                        source_path: source.clone(),
                        owner_actor_id: None,
                        reference: Some(wgsl.clone()),
                        message: "WGSL does not satisfy the Surface ABI evaluator contract".into(),
                        repair: "Define only `fn evaluate(...) -> Surface` and remove resource bindings/entry points."
                            .into(),
                    });
                }
            }
        }
        for texture in asset.default_textures {
            if let Some(reference) = texture.texture {
                collect_asset_ref(project_root, report, &source, None, "texture", &reference);
            }
        }
    }
}

fn validate_post_process_assets(project_root: &Path, report: &mut ProjectValidationReport) {
    for path in ron_files(
        project_root,
        &project_root.join("assets/post_fx"),
        ".ron",
        report,
    ) {
        let source = display_project_path(project_root, &path);
        let Some(asset) =
            parse_document::<PostProcessEffectAsset>(&path, &source, "post effect", report)
        else {
            continue;
        };
        add_reference(project_root, report, &source, None, "shader", &asset.shader);
    }
}

#[derive(Deserialize)]
struct StaticMeshManifestView {
    schema_version: u32,
    asset_id: String,
    source: StaticMeshSourceView,
    #[serde(default)]
    warnings: Vec<String>,
}

#[derive(Deserialize)]
struct StaticMeshSourceView {
    path: String,
    #[serde(default)]
    dependencies: Vec<String>,
}

const STATIC_MESH_MANIFEST_SCHEMA_VERSION: u32 = 3;

fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValidationReport) {
    for path in ron_files(
        project_root,
        &project_root.join("assets/meshes/generated"),
        ".static_mesh.ron",
        report,
    ) {
        let source = display_project_path(project_root, &path);
        let Some(manifest) = parse_document::<StaticMeshManifestView>(
            &path,
            &source,
            "static mesh manifest",
            report,
        ) else {
            continue;
        };
        if manifest.schema_version != STATIC_MESH_MANIFEST_SCHEMA_VERSION
            || manifest.asset_id.trim().is_empty()
        {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "manifest.incompatible_schema".into(),
                source_path: source.clone(),
                owner_actor_id: None,
                reference: None,
                message: format!(
                    "unsupported static mesh manifest schema {} or empty asset ID",
                    manifest.schema_version
                ),
                repair: "Reimport the source model to regenerate its static mesh manifest.".into(),
            });
        }
        add_reference(
            project_root,
            report,
            &source,
            None,
            "model",
            &manifest.source.path,
        );
        for dependency in manifest.source.dependencies {
            add_reference(
                project_root,
                report,
                &source,
                None,
                "import_dependency",
                &dependency,
            );
        }
        for warning in manifest.warnings {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Warning,
                code: "import.unsupported_feature".into(),
                source_path: source.clone(),
                owner_actor_id: None,
                reference: Some(manifest.source.path.clone()),
                message: warning,
                repair:
                    "Review the generated static representation or use Scene Instance placement."
                        .into(),
            });
        }
    }
}

fn validate_animation_manifests(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    catalog: &mut AnimationValidationCatalog,
) {
    for path in ron_files(
        project_root,
        &project_root.join(shared::ANIMATION_ARTIFACT_DIR),
        ".animation.ron",
        report,
    ) {
        let source = display_project_path(project_root, &path);
        let load = match std::fs::read_to_string(&path) {
            Ok(text) => match ron::from_str::<AnimationManifest>(&text) {
                Ok(manifest) => {
                    validate_animation_manifest_contract(project_root, report, &source, &manifest);
                    AnimationManifestLoad::Valid(Box::new(manifest))
                }
                Err(error) => {
                    let message = format!("could not parse animation manifest: {error}");
                    report.findings.push(ProjectValidationFinding {
                        severity: ValidationSeverity::Error,
                        code: "animation.manifest_malformed".into(),
                        source_path: source.clone(),
                        owner_actor_id: None,
                        reference: None,
                        message: message.clone(),
                        repair: "Reimport the source model to regenerate the animation manifest."
                            .into(),
                    });
                    AnimationManifestLoad::Invalid(message)
                }
            },
            Err(error) => {
                let message = format!("could not read animation manifest: {error}");
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "animation.manifest_unreadable".into(),
                    source_path: source.clone(),
                    owner_actor_id: None,
                    reference: None,
                    message: message.clone(),
                    repair: "Restore the generated manifest or reimport the source model.".into(),
                });
                AnimationManifestLoad::Invalid(message)
            }
        };
        catalog.manifests_by_path.insert(source, load);
    }

    let registry_records = catalog
        .registry_by_id
        .iter()
        .map(|(asset_id, record)| (asset_id.clone(), record.clone()))
        .collect::<Vec<_>>();
    for (asset_id, record) in registry_records {
        let Some(manifest_path) = record.manifest_path.as_deref() else {
            continue;
        };
        let normalized = normalized_project_reference(manifest_path);
        let manifest = match catalog.manifests_by_path.get(&normalized) {
            Some(AnimationManifestLoad::Valid(manifest)) => manifest,
            Some(AnimationManifestLoad::Invalid(_)) => continue,
            None => {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "animation.manifest_missing".into(),
                    source_path: "assets/.index/registry.ron".into(),
                    owner_actor_id: None,
                    reference: Some(manifest_path.to_string()),
                    message: format!(
                        "registered model `{}` has no readable generated animation manifest",
                        record.source_path
                    ),
                    repair: "Reimport the model to regenerate its UUID-keyed animation manifest."
                        .into(),
                });
                continue;
            }
        };
        if manifest.asset_id != asset_id {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.manifest_asset_mismatch".into(),
                source_path: normalized.clone(),
                owner_actor_id: None,
                reference: Some(asset_id),
                message: format!(
                    "registry animation manifest for `{}` declares asset ID `{}`",
                    record.source_path, manifest.asset_id
                ),
                repair: "Reimport the model so its UUID-keyed animation manifest matches the asset registry.".into(),
            });
        }
        if manifest.default_animation_clip_id != record.default_animation_clip_id {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.default_clip_manifest_mismatch".into(),
                source_path: normalized.clone(),
                owner_actor_id: None,
                reference: record.default_animation_clip_id.clone(),
                message: "model import settings and generated animation manifest disagree on the default clip".into(),
                repair: "Reimport the model so its generated manifest records the current Default animation setting.".into(),
            });
        }
        if manifest.source.path != record.source_path {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.manifest_source_mismatch".into(),
                source_path: normalized,
                owner_actor_id: None,
                reference: Some(record.source_path),
                message: format!(
                    "animation manifest source `{}` does not match its registry model",
                    manifest.source.path
                ),
                repair:
                    "Reimport the moved model so the generated animation source path is refreshed."
                        .into(),
            });
        }
    }
}

fn validate_animation_manifest_contract(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    manifest: &AnimationManifest,
) {
    if manifest.schema_version != ANIMATION_MANIFEST_SCHEMA_VERSION
        || manifest.asset_id.trim().is_empty()
    {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "animation.manifest_incompatible_schema".into(),
            source_path: source_path.into(),
            owner_actor_id: None,
            reference: Some(manifest.asset_id.clone()),
            message: format!(
                "unsupported animation manifest schema {} or empty asset ID",
                manifest.schema_version
            ),
            repair: "Reimport the source model with this editor version to regenerate the animation manifest.".into(),
        });
    }
    let expected_name = format!("{}.animation.ron", manifest.asset_id);
    if Path::new(source_path)
        .file_name()
        .and_then(|name| name.to_str())
        != Some(expected_name.as_str())
    {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "animation.manifest_unstable_path".into(),
            source_path: source_path.into(),
            owner_actor_id: None,
            reference: Some(manifest.asset_id.clone()),
            message: "animation manifest filename is not keyed by its stable registry UUID".into(),
            repair:
                "Reimport the source model and remove the incorrectly named generated manifest."
                    .into(),
        });
    }

    let source_format = manifest.source.format.to_ascii_lowercase();
    let source_extension = Path::new(&manifest.source.path)
        .extension()
        .and_then(|extension| extension.to_str())
        .map(str::to_ascii_lowercase);
    let source_present = !manifest.source.path.trim().is_empty()
        && matches!(source_format.as_str(), "gltf" | "glb" | "fbx")
        && source_extension.as_deref() == Some(source_format.as_str());
    if !source_present {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "animation.manifest_source_invalid".into(),
            source_path: source_path.into(),
            owner_actor_id: None,
            reference: Some(manifest.source.path.clone()),
            message: "animation manifest has no supported source path/format, or the format disagrees with the file extension".into(),
            repair: "Reimport a glTF/GLB animated model or a static FBX model.".into(),
        });
    } else {
        add_reference(
            project_root,
            report,
            source_path,
            None,
            "animation_source",
            &manifest.source.path,
        );
    }

    let fingerprint = &manifest.source.fingerprint;
    let hash_is_present = fingerprint.content_hash.len() == 64
        && fingerprint
            .content_hash
            .bytes()
            .all(|byte| byte.is_ascii_hexdigit());
    if fingerprint.byte_len == 0 || fingerprint.modified_unix_secs == 0 || !hash_is_present {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "animation.manifest_fingerprint_missing".into(),
            source_path: source_path.into(),
            owner_actor_id: None,
            reference: Some(manifest.source.path.clone()),
            message: "animation manifest source fingerprint is incomplete or malformed".into(),
            repair:
                "Reimport the source model to regenerate its byte length and BLAKE3 fingerprint."
                    .into(),
        });
    } else if let Some(path) = resolve_reference(project_root, &manifest.source.path) {
        if let Ok(metadata) = std::fs::metadata(&path) {
            if metadata.len() != fingerprint.byte_len {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "animation.manifest_stale".into(),
                    source_path: source_path.into(),
                    owner_actor_id: None,
                    reference: Some(manifest.source.path.clone()),
                    message: format!(
                        "animation source size changed from {} to {} bytes after import",
                        fingerprint.byte_len,
                        metadata.len()
                    ),
                    repair: "Reimport the changed model before validation or packaging.".into(),
                });
            } else {
                match blake3_file_hash(&path) {
                    Ok(content_hash) if content_hash != fingerprint.content_hash => {
                        report.findings.push(ProjectValidationFinding {
                            severity: ValidationSeverity::Error,
                            code: "animation.manifest_stale".into(),
                            source_path: source_path.into(),
                            owner_actor_id: None,
                            reference: Some(manifest.source.path.clone()),
                            message: "animation source content changed after import even though its byte length is unchanged".into(),
                            repair: "Reimport the changed model before validation or packaging."
                                .into(),
                        });
                    }
                    Ok(_) => {}
                    Err(error) => report.findings.push(ProjectValidationFinding {
                        severity: ValidationSeverity::Error,
                        code: "animation.manifest_source_unreadable".into(),
                        source_path: source_path.into(),
                        owner_actor_id: None,
                        reference: Some(manifest.source.path.clone()),
                        message: format!("could not hash animation source: {error}"),
                        repair: "Restore readable source content and reimport the model.".into(),
                    }),
                }
            }
        }
    }
    for dependency in &manifest.source.dependencies {
        add_reference(
            project_root,
            report,
            source_path,
            None,
            "animation_import_dependency",
            dependency,
        );
    }

    let mut skeleton_ids = BTreeSet::new();
    let mut skeleton_indices = BTreeSet::new();
    for skeleton in &manifest.skeletons {
        if skeleton.id.trim().is_empty()
            || !skeleton_ids.insert(skeleton.id.clone())
            || !skeleton_indices.insert(skeleton.source_index)
            || animation_skeleton_source_index(&skeleton.id) != Some(skeleton.source_index)
            || skeleton.id
                != animation_skeleton_sub_asset_id(skeleton.source_index, &skeleton.label)
        {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.skeleton_ref_invalid".into(),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: Some(skeleton.id.clone()),
                message: "skeleton sub-asset ID/index is empty, duplicated, or not deterministically derived from its source label".into(),
                repair: "Reimport the model to regenerate deterministic skeleton sub-asset IDs."
                    .into(),
            });
        }
        if skeleton.signature.is_empty() || skeleton.joint_paths.is_empty() {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.skeleton_signature_missing".into(),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: Some(skeleton.id.clone()),
                message: "skeleton has no exact signature or ordered joint paths".into(),
                repair: "Re-export a valid skin with joints and reimport the glTF/GLB model."
                    .into(),
            });
        }
    }

    let mut clip_ids = BTreeSet::new();
    let mut clip_indices = BTreeSet::new();
    for clip in &manifest.clips {
        if clip.id.trim().is_empty()
            || !clip_ids.insert(clip.id.clone())
            || !clip_indices.insert(clip.source_index)
            || animation_clip_source_index(&clip.id) != Some(clip.source_index)
            || clip.id != animation_clip_sub_asset_id(clip.source_index, &clip.label)
        {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.clip_ref_invalid".into(),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: Some(clip.id.clone()),
                message: "clip sub-asset ID/index is empty, duplicated, or not deterministically derived from its source label".into(),
                repair: "Reimport the model to regenerate deterministic clip sub-asset IDs.".into(),
            });
        }
        if !clip.duration_seconds.is_finite() || clip.duration_seconds < 0.0 {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.clip_duration_invalid".into(),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: Some(clip.id.clone()),
                message: "clip duration is negative or non-finite".into(),
                repair: "Repair the source clip timeline and reimport the model.".into(),
            });
        }
        if clip
            .target_skeleton_signature
            .as_ref()
            .is_some_and(|signature| signature.is_empty())
        {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.clip_signature_missing".into(),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: Some(clip.id.clone()),
                message: "clip declares an empty target skeleton signature".into(),
                repair:
                    "Re-export the clip with channels targeting one valid skeleton and reimport."
                        .into(),
            });
        }
        let mut event_ids = BTreeSet::new();
        for event in &clip.events {
            if event.id.trim().is_empty()
                || !event_ids.insert(event.id.clone())
                || !event.time_seconds.is_finite()
                || event.time_seconds < 0.0
                || event.time_seconds > clip.duration_seconds
            {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "animation.event_invalid".into(),
                    source_path: source_path.into(),
                    owner_actor_id: None,
                    reference: Some(format!("{}#{}", clip.id, event.id)),
                    message:
                        "animation event ID is empty/duplicated or its time is outside the clip"
                            .into(),
                    repair:
                        "Give events unique stable IDs inside the clip duration, then reimport."
                            .into(),
                });
            }
        }
    }

    if let Some(default_clip_id) = manifest.default_animation_clip_id.as_deref() {
        if (default_clip_id.trim().is_empty() || !clip_ids.contains(default_clip_id))
            && !manifest
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "animation.default_clip_missing")
        {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.default_clip_missing".into(),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: Some(default_clip_id.to_string()),
                message: "animation manifest default clip does not resolve to an imported clip"
                    .into(),
                repair: "Choose an existing Default animation in Model Import Settings, or select Imported rest pose, then reimport.".into(),
            });
        }
    }

    match source_format.as_str() {
        "gltf" | "glb" if !manifest.runtime_supported => {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.runtime_unsupported".into(),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: Some(manifest.source.path.clone()),
                message: "glTF/GLB animation manifest is unexpectedly marked runtime-unsupported"
                    .into(),
                repair: "Reimport the glTF/GLB model with the current animation importer.".into(),
            });
        }
        "fbx"
            if (!manifest.skeletons.is_empty() || !manifest.clips.is_empty())
                && !manifest.diagnostics.iter().any(|diagnostic| {
                    diagnostic.severity == AnimationDiagnosticSeverity::Error
                        && diagnostic.code == "animation.fbx_runtime_unsupported"
                }) =>
        {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "animation.fbx_runtime_unsupported".into(),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: Some(manifest.source.path.clone()),
                message: "animated/skinned FBX cannot hydrate with the current FBX loader".into(),
                repair: "Export the animated source as glTF 2.0 (.glb or .gltf) and reimport it."
                    .into(),
            });
        }
        _ => {}
    }
    for diagnostic in &manifest.diagnostics {
        report.findings.push(ProjectValidationFinding {
            severity: match diagnostic.severity {
                AnimationDiagnosticSeverity::Info => ValidationSeverity::Info,
                AnimationDiagnosticSeverity::Warning => ValidationSeverity::Warning,
                AnimationDiagnosticSeverity::Error => ValidationSeverity::Error,
            },
            code: diagnostic.code.clone(),
            source_path: source_path.into(),
            owner_actor_id: None,
            reference: Some(manifest.source.path.clone()),
            message: diagnostic.message.clone(),
            repair: diagnostic.repair.clone(),
        });
    }
}

fn parse_document<T: for<'de> Deserialize<'de>>(
    path: &Path,
    source_path: &str,
    kind: &str,
    report: &mut ProjectValidationReport,
) -> Option<T> {
    let text = match std::fs::read_to_string(path) {
        Ok(text) => text,
        Err(error) => {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: format!("{kind}.unreadable").replace(' ', "_"),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: None,
                message: format!("could not read {kind}: {error}"),
                repair: "Restore the file or correct its filesystem permissions.".into(),
            });
            return None;
        }
    };
    match ron::from_str(&text) {
        Ok(document) => Some(document),
        Err(error) => {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: format!("{kind}.invalid").replace(' ', "_"),
                source_path: source_path.into(),
                owner_actor_id: None,
                reference: None,
                message: format!("could not parse {kind}: {error}"),
                repair: format!("Repair or regenerate the {kind} document."),
            });
            None
        }
    }
}

fn ron_files(
    project_root: &Path,
    root: &Path,
    suffix: &str,
    report: &mut ProjectValidationReport,
) -> Vec<PathBuf> {
    let mut files = Vec::new();
    collect_files(project_root, root, suffix, &mut files, report);
    files.sort();
    files
}

fn collect_files(
    project_root: &Path,
    root: &Path,
    suffix: &str,
    files: &mut Vec<PathBuf>,
    report: &mut ProjectValidationReport,
) {
    let metadata = match std::fs::symlink_metadata(root) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
        Err(error) => {
            push_traversal_finding(project_root, root, error.to_string(), report);
            return;
        }
    };
    if metadata.file_type().is_symlink() {
        push_traversal_finding(project_root, root, "asset tree is a symlink".into(), report);
        return;
    }
    if !metadata.is_dir() {
        return;
    }
    let entries = match std::fs::read_dir(root) {
        Ok(entries) => entries,
        Err(error) => {
            push_traversal_finding(project_root, root, error.to_string(), report);
            return;
        }
    };
    for entry in entries {
        let entry = match entry {
            Ok(entry) => entry,
            Err(error) => {
                push_traversal_finding(project_root, root, error.to_string(), report);
                continue;
            }
        };
        let path = entry.path();
        let file_type = match entry.file_type() {
            Ok(file_type) => file_type,
            Err(error) => {
                push_traversal_finding(project_root, &path, error.to_string(), report);
                continue;
            }
        };
        if file_type.is_symlink() {
            push_traversal_finding(
                project_root,
                &path,
                "asset entry is a symlink".into(),
                report,
            );
        } else if file_type.is_dir() {
            collect_files(project_root, &path, suffix, files, report);
        } else if path.to_string_lossy().ends_with(suffix) {
            files.push(path);
        }
    }
}

fn push_traversal_finding(
    project_root: &Path,
    path: &Path,
    message: String,
    report: &mut ProjectValidationReport,
) {
    report.findings.push(ProjectValidationFinding {
        severity: ValidationSeverity::Error,
        code: "asset.traversal_failed".into(),
        source_path: display_project_path(project_root, path),
        owner_actor_id: None,
        reference: None,
        message: format!("could not safely traverse project content: {message}"),
        repair: "Replace symlinks with regular project files and restore directory read access."
            .into(),
    });
}

fn validate_document(
    project_root: &Path,
    path: &Path,
    audio_settings: &settings::AudioSettings,
    animation_catalog: &AnimationValidationCatalog,
    report: &mut ProjectValidationReport,
) {
    let source_path = display_project_path(project_root, path);
    if let Err(error) = crate::validate_level_file(path) {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "scene.invalid".into(),
            source_path: source_path.clone(),
            owner_actor_id: None,
            reference: None,
            message: error,
            repair: "Open the source document and repair the reported schema or graph error."
                .into(),
        });
    }

    let text = match std::fs::read_to_string(path) {
        Ok(text) => text,
        Err(error) => {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "scene.unreadable".into(),
                source_path,
                owner_actor_id: None,
                reference: None,
                message: format!("could not read scene document: {error}"),
                repair: "Restore the file or correct its filesystem permissions.".into(),
            });
            return;
        }
    };
    let Ok(document) = SceneDocument::from_ron_text(&text) else {
        return;
    };

    validate_navigation_document(project_root, &source_path, &document, report);

    if let Some(composition) = &document.composition {
        for subscene in &composition.subscenes {
            add_reference(
                project_root,
                report,
                &source_path,
                None,
                "subscene",
                &subscene.path,
            );
        }
    }
    let mut enabled_listeners = Vec::new();
    for entity in &document.entities {
        for component in &entity.components {
            if component.type_name == AUDIO_LISTENER_DESC {
                if let Ok(listener) = ron::from_str::<AudioListenerDesc>(&component.ron) {
                    if listener.enabled {
                        enabled_listeners.push((entity.actor_id.clone(), listener.priority));
                    }
                }
            }
            collect_component_references(
                project_root,
                report,
                &source_path,
                entity.actor_id.as_deref(),
                audio_settings,
                animation_catalog,
                component,
            );
        }
    }
    if let Some(highest) = enabled_listeners
        .iter()
        .map(|(_, priority)| *priority)
        .max()
    {
        let tied: Vec<_> = enabled_listeners
            .iter()
            .filter(|(_, priority)| *priority == highest)
            .collect();
        if tied.len() > 1 {
            for (actor_id, _) in tied {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "audio.listener_priority_tie".into(),
                    source_path: source_path.clone(),
                    owner_actor_id: actor_id.clone(),
                    reference: Some(highest.to_string()),
                    message: format!(
                        "multiple enabled audio listeners share the highest priority {highest}"
                    ),
                    repair: "Give the intended listener a unique higher priority or disable the alternatives.".into(),
                });
            }
        }
    }
}

fn validate_navigation_document(
    project_root: &Path,
    source_path: &str,
    document: &SceneDocument,
    report: &mut ProjectValidationReport,
) {
    let jobs = match crate::navigation::resolve_navigation_bake_jobs(
        project_root,
        &project_root.join(source_path),
        Some(document.normalized_ron()),
    ) {
        Ok(jobs) => jobs,
        Err(error) => {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "navigation.authoring_invalid".into(),
                source_path: source_path.to_string(),
                owner_actor_id: None,
                reference: None,
                message: error,
                repair: "Repair the named navigation actor and rebake the scene.".into(),
            });
            return;
        }
    };

    for job in jobs {
        let normalized_artifact_path = match shared::navigation_generated_artifact_path(
            &job.artifact_path,
        ) {
            Ok(path) => path,
            Err(error) => {
                push_navigation_finding(
                    report,
                    source_path,
                    &job.input.bounds_actor_id,
                    "navigation.artifact_outside_generated_directory",
                    &job.artifact_path,
                    error,
                    "Assign a project-relative artifact path under assets/navigation/generated/ and rebake this bounds actor.",
                );
                continue;
            }
        };
        report.dependencies.push(ProjectDependency {
            owner_path: source_path.to_string(),
            owner_actor_id: Some(job.input.bounds_actor_id.clone()),
            kind: "navigation_artifact".into(),
            reference: normalized_artifact_path.clone(),
        });
        let fresh_artifact = match crate::navigation::bake_navigation(&job.input) {
            Ok(artifact) => artifact,
            Err(error) => {
                push_navigation_finding(
                    report,
                    source_path,
                    &job.input.bounds_actor_id,
                    "navigation.bake_failed",
                    &normalized_artifact_path,
                    &format!("deterministic navigation bake failed: {error}"),
                    "Repair the bounds, agent profile, or source geometry named by the bake error, then rebake this actor.",
                );
                continue;
            }
        };
        if fresh_artifact.diagnostics.effective_component_count > 1 {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Warning,
                code: "navigation.isolated_regions".into(),
                source_path: source_path.into(),
                owner_actor_id: Some(job.input.bounds_actor_id.clone()),
                reference: Some(normalized_artifact_path.clone()),
                message: format!(
                    "navigation bake has {} raw walkable islands and {} disconnected traversable components after valid links",
                    fresh_artifact.diagnostics.island_count,
                    fresh_artifact.diagnostics.effective_component_count
                ),
                repair: "Add explicit Navigation Links between regions that agents must traverse, or confirm the isolation is intentional.".into(),
            });
        }
        let Some(path) = resolve_reference(project_root, &normalized_artifact_path) else {
            push_navigation_finding(
                report,
                source_path,
                &job.input.bounds_actor_id,
                "navigation.artifact_unsafe_path",
                &normalized_artifact_path,
                "navigation artifact path escapes the project",
                "Choose an artifact path under assets/navigation/generated/.",
            );
            continue;
        };
        if !path.is_file() {
            push_navigation_finding(
                report,
                source_path,
                &job.input.bounds_actor_id,
                "navigation.artifact_missing",
                &normalized_artifact_path,
                "navigation bounds has no baked artifact",
                "Select the bounds actor and run Scene > Navigation > Bake Selected Bounds.",
            );
            continue;
        }
        let artifact = match crate::navigation::read_navigation_artifact(&path) {
            Ok(artifact) => artifact,
            Err(error) => {
                push_navigation_finding(
                    report,
                    source_path,
                    &job.input.bounds_actor_id,
                    "navigation.artifact_invalid",
                    &normalized_artifact_path,
                    &error,
                    "Delete the invalid generated artifact and rebake the bounds actor.",
                );
                continue;
            }
        };
        let stale = artifact.is_stale_for(&job.input);
        if stale {
            push_navigation_finding(
                report,
                source_path,
                &job.input.bounds_actor_id,
                "navigation.artifact_stale",
                &normalized_artifact_path,
                "navigation artifact fingerprint does not match authored navigation data",
                "Rebake navigation after the authored geometry or agent-profile change.",
            );
        } else {
            let stored_hash = crate::navigation::navigation_artifact_content_hash(&artifact);
            let fresh_hash = crate::navigation::navigation_artifact_content_hash(&fresh_artifact);
            match (stored_hash, fresh_hash) {
                (Ok(stored_hash), Ok(fresh_hash)) if stored_hash != fresh_hash => {
                    push_navigation_finding(
                        report,
                        source_path,
                        &job.input.bounds_actor_id,
                        "navigation.artifact_content_mismatch",
                        &normalized_artifact_path,
                        "stored navigation artifact differs from a deterministic fresh bake despite a current source fingerprint",
                        "Rebake this bounds actor and commit the regenerated artifact.",
                    );
                }
                (Err(error), _) | (_, Err(error)) => push_navigation_finding(
                    report,
                    source_path,
                    &job.input.bounds_actor_id,
                    "navigation.artifact_hash_failed",
                    &normalized_artifact_path,
                    &error,
                    "Rebake this bounds actor; if hashing still fails, repair the generated artifact serializer.",
                ),
                _ => {}
            }
        }

        let runtime = match crate::navigation::NavigationQueryRuntime::from_artifact(
            fresh_artifact.clone(),
        ) {
            Ok(runtime) => runtime,
            Err(error) => {
                push_navigation_finding(
                    report,
                    source_path,
                    &job.input.bounds_actor_id,
                    "navigation.runtime_invalid",
                    &normalized_artifact_path,
                    &error,
                    "Repair the bake inputs and rebake this bounds actor.",
                );
                continue;
            }
        };
        for finding in runtime.validate_link_findings() {
            push_navigation_finding(
                report,
                source_path,
                &finding.actor_id,
                "navigation.link_unreachable",
                &normalized_artifact_path,
                &finding.message,
                "Move the endpoint onto a visible navigation polygon and rebake.",
            );
        }
        for sample in fresh_artifact
            .samples
            .iter()
            .filter(|sample| sample.enabled)
        {
            if let Err(error) = runtime.query(sample.start, sample.end) {
                push_navigation_finding(
                    report,
                    source_path,
                    &job.input.bounds_actor_id,
                    "navigation.sample_unreachable",
                    &sample.id,
                    &format!("navigation validation sample `{}` failed: {error}", sample.id),
                    "Move the sample endpoints onto connected visible navigation polygons, add the required links, and rebake.",
                );
            }
        }
    }
}

fn validate_navigation_artifact_ownership(report: &mut ProjectValidationReport) {
    let mut owners_by_artifact = BTreeMap::<String, Vec<(String, String)>>::new();
    for dependency in report
        .dependencies
        .iter()
        .filter(|dependency| dependency.kind == "navigation_artifact")
    {
        let Some(actor_id) = dependency.owner_actor_id.as_ref() else {
            continue;
        };
        owners_by_artifact
            .entry(dependency.reference.clone())
            .or_default()
            .push((dependency.owner_path.clone(), actor_id.clone()));
    }

    for (artifact_path, owners) in owners_by_artifact {
        let owners = owners.into_iter().collect::<BTreeSet<_>>();
        if owners.len() < 2 {
            continue;
        }
        let owner_list = owners
            .iter()
            .map(|(source_path, actor_id)| format!("actor `{actor_id}` in {source_path}"))
            .collect::<Vec<_>>()
            .join(", ");
        for (source_path, actor_id) in owners {
            push_navigation_finding(
                report,
                &source_path,
                &actor_id,
                "navigation.artifact_duplicate_owner",
                &artifact_path,
                &format!(
                    "navigation artifact is claimed by multiple bounds actors: {owner_list}"
                ),
                &format!(
                    "Keep one owner; give every other bounds actor a unique path under {NAVIGATION_GENERATED_ARTIFACT_DIRECTORY} (or remove and re-add Navigation Bounds), then rebake all affected actors."
                ),
            );
        }
    }
}

fn push_navigation_finding(
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: &str,
    code: &str,
    reference: &str,
    message: &str,
    repair: &str,
) {
    report.findings.push(ProjectValidationFinding {
        severity: ValidationSeverity::Error,
        code: code.into(),
        source_path: source_path.into(),
        owner_actor_id: Some(actor_id.into()),
        reference: Some(reference.into()),
        message: message.into(),
        repair: repair.into(),
    });
}

fn collect_component_references(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    audio_settings: &settings::AudioSettings,
    animation_catalog: &AnimationValidationCatalog,
    component: &SceneComponentBlob,
) {
    macro_rules! parse {
        ($ty:ty) => {
            match ron::from_str::<$ty>(&component.ron) {
                Ok(value) => value,
                Err(_) => return,
            }
        };
    }
    match component.type_name.as_str() {
        COMPONENT_ANIMATION_CONTROLLER_DESC => {
            match ron::from_str::<AnimationControllerDesc>(&component.ron) {
                Ok(controller) => validate_animation_controller(
                    project_root,
                    report,
                    source_path,
                    actor_id,
                    &controller,
                    animation_catalog,
                ),
                Err(error) => push_animation_actor_finding(
                    report,
                    source_path,
                    actor_id,
                    "animation.controller_malformed",
                    "AnimationControllerDesc",
                    None,
                    format!("could not parse AnimationControllerDesc: {error}"),
                    "Repair the controller component or remove and recreate it in the Inspector.",
                ),
            }
        }
        MODEL_REF => {
            let model = parse!(ModelRef);
            collect_model_source_reference(
                project_root,
                report,
                source_path,
                actor_id,
                &model.asset_id,
                &model.path,
                "ModelRef",
                animation_catalog,
            );
        }
        COMPONENT_SKINNED_MESH_RENDERER => {
            let renderer = parse!(SkinnedMeshRenderer);
            collect_model_source_reference(
                project_root,
                report,
                source_path,
                actor_id,
                &renderer.asset_id,
                &renderer.path,
                "SkinnedMeshRenderer",
                animation_catalog,
            );
            collect_renderer_material_references(
                project_root,
                report,
                source_path,
                actor_id,
                &renderer.materials,
            );
        }
        MATERIAL_DESC => collect_material(
            project_root,
            report,
            source_path,
            actor_id,
            &parse!(MaterialDesc),
        ),
        MATERIAL_OVERRIDE => {
            for slot in parse!(MaterialOverride).slots {
                if let Some(reference) = slot.base_material {
                    collect_asset_ref(
                        project_root,
                        report,
                        source_path,
                        actor_id,
                        "material",
                        &reference,
                    );
                }
                collect_material(project_root, report, source_path, actor_id, &slot.material);
            }
        }
        STATIC_MESH_RENDERER => {
            let renderer = parse!(StaticMeshRenderer);
            collect_renderer_material_references(
                project_root,
                report,
                source_path,
                actor_id,
                &renderer.materials,
            );
            if let Some(slot) = renderer.slots.iter().find(|slot| {
                animation_asset_requires_skinned_renderer(&slot.mesh.asset_id, animation_catalog)
            }) {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "static_mesh.hierarchy_bound_geometry".into(),
                    source_path: source_path.to_string(),
                    owner_actor_id: actor_id.map(str::to_string),
                    reference: Some(format!(
                        "{}#{}",
                        slot.mesh.asset_id, slot.mesh.sub_asset_id
                    )),
                    message:
                        "StaticMeshRenderer references geometry from a skinned or animated model"
                            .into(),
                    repair: "Replace this actor by placing the source model through SkinnedMeshRenderer; static slots cannot retain its imported hierarchy.".into(),
                });
            }
            for slot in renderer.slots {
                collect_asset_ref(
                    project_root,
                    report,
                    source_path,
                    actor_id,
                    "mesh",
                    &slot.mesh,
                );
                if let Some(material) = slot.material {
                    collect_asset_ref(
                        project_root,
                        report,
                        source_path,
                        actor_id,
                        "material",
                        &material,
                    );
                }
            }
        }
        BRUSH_DESC => {
            let brush = parse!(BrushDesc);
            for diagnostic in validate_brush(&brush).diagnostics {
                report.findings.push(ProjectValidationFinding {
                    severity: match diagnostic.severity {
                        BrushDiagnosticSeverity::Error => ValidationSeverity::Error,
                        BrushDiagnosticSeverity::Warning => ValidationSeverity::Warning,
                    },
                    code: "brush.invalid".into(),
                    source_path: source_path.to_string(),
                    owner_actor_id: actor_id.map(str::to_string),
                    reference: diagnostic.face.map(|face| face.0),
                    message: diagnostic.message,
                    repair: "Repair or rebuild the brush face in Brush Edit mode.".into(),
                });
            }
            for face in brush.faces {
                if let Some(material) = face.material {
                    collect_asset_ref(
                        project_root,
                        report,
                        source_path,
                        actor_id,
                        "material",
                        &material,
                    );
                }
                if let Some(texture) = face.texture {
                    collect_asset_ref(
                        project_root,
                        report,
                        source_path,
                        actor_id,
                        "texture",
                        &texture,
                    );
                }
            }
        }
        COLLIDER_DESC => validate_collider(
            project_root,
            report,
            source_path,
            actor_id,
            &parse!(ColliderDesc),
        ),
        POST_PROCESS_VOLUME => {
            let volume = parse!(PostProcessVolumeDesc);
            for (kind, path) in [
                ("post_process", volume.fullscreen_effect),
                ("rendering_profile", volume.profile),
            ] {
                if let Some(path) = path {
                    add_reference(project_root, report, source_path, actor_id, kind, &path);
                }
            }
        }
        PREFAB_INSTANCE => add_reference(
            project_root,
            report,
            source_path,
            actor_id,
            "prefab",
            &parse!(PrefabInstance).source_path,
        ),
        PREFAB_REF => add_reference(
            project_root,
            report,
            source_path,
            actor_id,
            "prefab",
            &parse!(PrefabRef).path,
        ),
        AUDIO_SOURCE_DESC => validate_audio_source(
            project_root,
            report,
            source_path,
            actor_id,
            audio_settings,
            &parse!(AudioSourceDesc),
        ),
        AUDIO_LISTENER_DESC => {
            let listener = parse!(AudioListenerDesc);
            if !listener.ear_gap.is_finite() || listener.ear_gap <= 0.0 {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "audio.listener_invalid".into(),
                    source_path: source_path.to_string(),
                    owner_actor_id: actor_id.map(str::to_string),
                    reference: Some("ear_gap".into()),
                    message: "audio listener ear gap must be positive and finite".into(),
                    repair: "Set ear_gap to a positive distance in meters.".into(),
                });
            }
        }
        _ => {}
    }
}

fn collect_renderer_material_references(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    materials: &RendererMaterialSet,
) {
    for slot in &materials.slots {
        for reference in [slot.source_material.as_ref(), slot.material.as_ref()]
            .into_iter()
            .flatten()
        {
            collect_asset_ref(
                project_root,
                report,
                source_path,
                actor_id,
                "material",
                &reference.0,
            );
        }
    }
    for orphan in &materials.orphaned_assignments {
        collect_asset_ref(
            project_root,
            report,
            source_path,
            actor_id,
            "material",
            &orphan.material.0,
        );
    }
}

fn animation_asset_requires_skinned_renderer(
    asset_id: &str,
    catalog: &AnimationValidationCatalog,
) -> bool {
    let Some(record) = catalog.registry_by_id.get(asset_id) else {
        return false;
    };
    let Some(manifest_path) = record.manifest_path.as_deref() else {
        return false;
    };
    matches!(
        catalog
            .manifests_by_path
            .get(&normalized_project_reference(manifest_path)),
        Some(AnimationManifestLoad::Valid(manifest))
            if !manifest.skeletons.is_empty() || !manifest.clips.is_empty()
    )
}

#[allow(clippy::too_many_arguments)]
fn collect_model_source_reference(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    asset_id: &str,
    cached_path: &str,
    component_name: &str,
    animation_catalog: &AnimationValidationCatalog,
) {
    if asset_id.trim().is_empty() {
        add_reference(
            project_root,
            report,
            source_path,
            actor_id,
            "model",
            cached_path,
        );
    } else if let Some(record) = animation_catalog.registry_by_id.get(asset_id) {
        add_reference(
            project_root,
            report,
            source_path,
            actor_id,
            "model",
            &record.source_path,
        );
        if normalized_project_reference(cached_path)
            != normalized_project_reference(&record.source_path)
        {
            report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Warning,
                code: "model.cached_path_stale".into(),
                source_path: source_path.to_string(),
                owner_actor_id: actor_id.map(str::to_string),
                reference: Some(asset_id.to_string()),
                message: format!(
                    "{component_name} cached path `{cached_path}` differs from registered path `{}`",
                    record.source_path
                ),
                repair: "Open and save the scene to refresh the cached path hint; runtime resolution already uses the stable asset ID.".into(),
            });
        }
    } else {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "model.asset_unresolved".into(),
            source_path: source_path.to_string(),
            owner_actor_id: actor_id.map(str::to_string),
            reference: Some(asset_id.to_string()),
            message: format!("{component_name} asset ID is absent from the project registry"),
            repair:
                "Restore/reimport the owning model or reassign the model reference in the editor."
                    .into(),
        });
    }
}

fn validate_animation_controller(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    controller: &AnimationControllerDesc,
    catalog: &AnimationValidationCatalog,
) {
    if !controller.default_crossfade_seconds.is_finite()
        || controller.default_crossfade_seconds < 0.0
    {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.crossfade_invalid",
            "AnimationControllerDesc.default_crossfade_seconds",
            Some(controller.default_crossfade_seconds.to_string()),
            "default animation crossfade must be finite and non-negative",
            "Set the controller default crossfade to a finite duration of zero or more seconds.",
        );
    }

    let skeleton = controller.skeleton.as_ref().and_then(|reference| {
        let property = "AnimationControllerDesc.skeleton";
        if animation_skeleton_source_index(&reference.sub_asset_id).is_none() {
            push_animation_actor_finding(
                report,
                source_path,
                actor_id,
                "animation.skeleton_ref_invalid",
                property,
                Some(stable_animation_reference(reference)),
                "controller skeleton is not a stable imported skeleton reference",
                "Select a skeleton sub-asset from the imported glTF/GLB model.",
            );
            return None;
        }
        let manifest = animation_manifest_for_reference(
            project_root,
            report,
            source_path,
            actor_id,
            property,
            reference,
            "animation_skeleton",
            catalog,
        )?;
        match manifest
            .skeletons
            .iter()
            .find(|skeleton| skeleton.id == reference.sub_asset_id)
        {
            Some(skeleton) => Some(skeleton.clone()),
            None => {
                push_animation_actor_finding(
                    report,
                    source_path,
                    actor_id,
                    "animation.skeleton_missing",
                    property,
                    Some(stable_animation_reference(reference)),
                    "controller skeleton sub-asset is missing from its generated manifest",
                    "Choose an available skeleton or reimport the model to restore the referenced skeleton ID.",
                );
                None
            }
        }
    });
    if controller.skeleton.is_none() {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.skeleton_missing",
            "AnimationControllerDesc.skeleton",
            None,
            "animation controller has no skeleton reference",
            "Assign the exact imported skeleton used by this animated model.",
        );
    }

    if controller.states.is_empty() {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.states_missing",
            "AnimationControllerDesc.states",
            None,
            "animation controller has no named states",
            "Add at least one named state and assign an imported clip.",
        );
    }
    let mut state_ids = BTreeSet::new();
    for (index, state) in controller.states.iter().enumerate() {
        let state_key = if state.id.trim().is_empty() {
            format!("index {index}")
        } else {
            state.id.clone()
        };
        let state_property = format!("AnimationControllerDesc.states[{state_key}]");
        if state.id.trim().is_empty() || !state_ids.insert(state.id.clone()) {
            push_animation_actor_finding(
                report,
                source_path,
                actor_id,
                "animation.state_id_invalid",
                &format!("{state_property}.id"),
                Some(state.id.clone()),
                "animation state ID is empty or duplicated",
                "Give every controller state a unique, stable non-empty ID.",
            );
        }
        if !state.speed.is_finite() || state.speed <= 0.0 {
            push_animation_actor_finding(
                report,
                source_path,
                actor_id,
                "animation.speed_invalid",
                &format!("{state_property}.speed"),
                Some(state.speed.to_string()),
                "animation state speed must be positive and finite",
                "Set playback speed to a finite value greater than zero.",
            );
        }
        if !state.range.start_seconds.is_finite() || state.range.start_seconds < 0.0 {
            push_animation_actor_finding(
                report,
                source_path,
                actor_id,
                "animation.range_invalid",
                &format!("{state_property}.range.start_seconds"),
                Some(state.range.start_seconds.to_string()),
                "animation range start must be finite and non-negative",
                "Set the state range start inside the selected clip.",
            );
        }
        if state
            .range
            .end_seconds
            .is_some_and(|end| !end.is_finite() || end <= state.range.start_seconds)
        {
            push_animation_actor_finding(
                report,
                source_path,
                actor_id,
                "animation.range_invalid",
                &format!("{state_property}.range.end_seconds"),
                state.range.end_seconds.map(|end| end.to_string()),
                "animation range end must be finite and greater than its start",
                "Set the optional range end after the start and inside the selected clip.",
            );
        }

        let clip_property = format!("{state_property}.clip");
        if animation_clip_source_index(&state.clip.sub_asset_id).is_none() {
            push_animation_actor_finding(
                report,
                source_path,
                actor_id,
                "animation.clip_ref_invalid",
                &clip_property,
                Some(stable_animation_reference(&state.clip)),
                "state clip is not a stable imported animation clip reference",
                "Assign a clip sub-asset from a supported glTF/GLB animation manifest.",
            );
            continue;
        }
        let Some(manifest) = animation_manifest_for_reference(
            project_root,
            report,
            source_path,
            actor_id,
            &clip_property,
            &state.clip,
            "animation_clip",
            catalog,
        ) else {
            continue;
        };
        let Some(clip) = manifest
            .clips
            .iter()
            .find(|clip| clip.id == state.clip.sub_asset_id)
            .cloned()
        else {
            push_animation_actor_finding(
                report,
                source_path,
                actor_id,
                "animation.clip_missing",
                &clip_property,
                Some(stable_animation_reference(&state.clip)),
                "state clip sub-asset is missing from its generated manifest",
                "Choose an available clip or reimport the model to restore the referenced clip ID.",
            );
            continue;
        };

        let effective_end = state.range.end_seconds.unwrap_or(clip.duration_seconds);
        if !effective_end.is_finite()
            || state.range.start_seconds >= effective_end
            || effective_end > clip.duration_seconds
        {
            push_animation_actor_finding(
                report,
                source_path,
                actor_id,
                "animation.range_out_of_clip",
                &format!("{state_property}.range"),
                Some(format!(
                    "{}..{} / {}s",
                    state.range.start_seconds, effective_end, clip.duration_seconds
                )),
                "authored playback range falls outside the imported clip duration",
                "Choose a start/end range within the selected clip, or clear the end to use its duration.",
            );
        }

        if let Some(skeleton) = &skeleton {
            match &clip.target_skeleton_signature {
                Some(signature) if signature == &skeleton.signature => {}
                Some(signature) => push_animation_actor_finding(
                    report,
                    source_path,
                    actor_id,
                    "animation.skeleton_mismatch",
                    &clip_property,
                    Some(stable_animation_reference(&state.clip)),
                    format!(
                        "clip skeleton signature `{}` does not match controller skeleton `{}`",
                        signature.0, skeleton.signature.0
                    ),
                    "Assign a clip exported for the exact selected skeleton; v1 does not retarget mismatched rigs.",
                ),
                None => push_animation_actor_finding(
                    report,
                    source_path,
                    actor_id,
                    "animation.clip_signature_missing",
                    &clip_property,
                    Some(stable_animation_reference(&state.clip)),
                    "clip has no uniquely resolved target skeleton signature",
                    "Re-export the clip with channels targeting exactly one skeleton and reimport it.",
                ),
            }
        }
    }

    if controller.default_state.trim().is_empty()
        || controller.state(&controller.default_state).is_none()
    {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.default_state_invalid",
            "AnimationControllerDesc.default_state",
            Some(controller.default_state.clone()),
            "default animation state is empty or does not name an authored state",
            "Choose one unique state ID as the controller default.",
        );
    }
}

#[allow(clippy::too_many_arguments)]
fn animation_manifest_for_reference<'a>(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    property: &str,
    reference: &EditorAssetRef,
    dependency_kind: &str,
    catalog: &'a AnimationValidationCatalog,
) -> Option<&'a AnimationManifest> {
    if !reference.is_resolved() {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.reference_unresolved",
            property,
            Some(stable_animation_reference(reference)),
            "animation reference has no stable registry asset/sub-asset identity",
            "Reassign the skeleton or clip from the Asset Browser.",
        );
        return None;
    }
    report.dependencies.push(ProjectDependency {
        owner_path: source_path.to_string(),
        owner_actor_id: actor_id.map(str::to_string),
        kind: dependency_kind.into(),
        reference: stable_animation_reference(reference),
    });
    let Some(record) = catalog.registry_by_id.get(&reference.asset_id) else {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.asset_unresolved",
            property,
            Some(stable_animation_reference(reference)),
            "animation reference asset ID is absent from the project registry",
            "Restore/reimport the owning model or reassign this reference to a registered asset.",
        );
        return None;
    };
    let Some(manifest_path) = record.manifest_path.as_deref() else {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.manifest_missing",
            property,
            Some(record.source_path.clone()),
            "registered model has no generated animation manifest",
            "Reimport the model to generate its UUID-keyed animation manifest.",
        );
        return None;
    };
    add_reference(
        project_root,
        report,
        source_path,
        actor_id,
        "animation_manifest",
        manifest_path,
    );
    let normalized = normalized_project_reference(manifest_path);
    let Some(load) = catalog.manifests_by_path.get(&normalized) else {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.manifest_missing",
            property,
            Some(manifest_path.to_string()),
            "generated animation manifest does not exist at the registry path",
            "Reimport the model to regenerate the missing animation manifest.",
        );
        return None;
    };
    let manifest = match load {
        AnimationManifestLoad::Valid(manifest) => manifest,
        AnimationManifestLoad::Invalid(error) => {
            push_animation_actor_finding(
                report,
                source_path,
                actor_id,
                "animation.manifest_malformed",
                property,
                Some(manifest_path.to_string()),
                format!("generated animation manifest is unusable: {error}"),
                "Reimport the model to replace the malformed generated manifest.",
            );
            return None;
        }
    };
    if manifest.asset_id != reference.asset_id {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.manifest_asset_mismatch",
            property,
            Some(stable_animation_reference(reference)),
            format!(
                "animation manifest declares asset ID `{}` instead of `{}`",
                manifest.asset_id, reference.asset_id
            ),
            "Reimport the owning model and reassign the stable animation reference.",
        );
        return None;
    }
    let format = manifest.source.format.to_ascii_lowercase();
    if !manifest.runtime_supported || !matches!(format.as_str(), "gltf" | "glb") {
        push_animation_actor_finding(
            report,
            source_path,
            actor_id,
            "animation.runtime_unsupported",
            property,
            Some(manifest.source.path.clone()),
            "controller references animation data that the runtime cannot hydrate",
            "Export animated content as glTF 2.0 (.glb or .gltf), reimport it, and reassign the reference.",
        );
    }
    Some(manifest)
}

fn stable_animation_reference(reference: &EditorAssetRef) -> String {
    format!("{}#{}", reference.asset_id, reference.sub_asset_id)
}

#[allow(clippy::too_many_arguments)]
fn push_animation_actor_finding(
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    code: &str,
    property: &str,
    value: Option<String>,
    message: impl Into<String>,
    repair: impl Into<String>,
) {
    let reference = Some(match value {
        Some(value) if !value.is_empty() => format!("{property}: {value}"),
        _ => property.to_string(),
    });
    report.findings.push(ProjectValidationFinding {
        severity: ValidationSeverity::Error,
        code: code.into(),
        source_path: source_path.into(),
        owner_actor_id: actor_id.map(str::to_string),
        reference,
        message: message.into(),
        repair: repair.into(),
    });
}

fn validate_audio_source(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    audio_settings: &settings::AudioSettings,
    source: &AudioSourceDesc,
) {
    let mut invalid = Vec::new();
    if !source.gain_db.is_finite()
        || !(settings::AUDIO_GAIN_DB_MIN..=settings::AUDIO_GAIN_DB_MAX).contains(&source.gain_db)
    {
        invalid.push("gain_db");
    }
    if !source.pitch.is_finite() || source.pitch <= 0.0 {
        invalid.push("pitch");
    }
    if !source.spatial_blend.is_finite() || !(0.0..=1.0).contains(&source.spatial_blend) {
        invalid.push("spatial_blend");
    }
    if !source.attenuation.min_distance.is_finite()
        || source.attenuation.min_distance < 0.0
        || !source.attenuation.max_distance.is_finite()
        || source.attenuation.max_distance <= source.attenuation.min_distance
        || !source.attenuation.rolloff_factor.is_finite()
        || source.attenuation.rolloff_factor < 0.0
    {
        invalid.push("attenuation");
    }
    for property in invalid {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "audio.source_invalid".into(),
            source_path: source_path.to_string(),
            owner_actor_id: actor_id.map(str::to_string),
            reference: Some(property.into()),
            message: format!("audio source property `{property}` is outside its supported range"),
            repair: "Repair the highlighted audio source property in the Inspector.".into(),
        });
    }

    if audio_settings.bus(&source.bus).is_none() {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "audio.bus_unknown".into(),
            source_path: source_path.to_string(),
            owner_actor_id: actor_id.map(str::to_string),
            reference: Some(source.bus.clone()),
            message: format!("audio source routes to unknown bus `{}`", source.bus),
            repair: "Select an existing project audio bus or restore the missing stable bus ID."
                .into(),
        });
    }

    let Some(clip) = source.clip.as_ref() else {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "audio.clip_missing".into(),
            source_path: source_path.to_string(),
            owner_actor_id: actor_id.map(str::to_string),
            reference: None,
            message: "audio source has no clip assigned".into(),
            repair: "Assign a supported audio clip in the Audio Source Inspector.".into(),
        });
        return;
    };
    if !clip.is_resolved() || clip.sub_asset_id != AUDIO_CLIP_SUB_ASSET_ID {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "audio.clip_reference_invalid".into(),
            source_path: source_path.to_string(),
            owner_actor_id: actor_id.map(str::to_string),
            reference: clip.source_path.clone(),
            message: "audio clip reference lacks a stable audio asset identity".into(),
            repair: "Reassign the clip from the Asset Browser to rebuild its stable reference."
                .into(),
        });
    }
    let Some(path) = clip.source_path.as_deref() else {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "audio.clip_runtime_path_missing".into(),
            source_path: source_path.to_string(),
            owner_actor_id: actor_id.map(str::to_string),
            reference: Some(clip.asset_id.clone()),
            message: "audio clip reference has no standalone runtime path".into(),
            repair: "Reassign the clip from the Asset Browser to cache its project path.".into(),
        });
        return;
    };
    if !is_supported_audio_reference(path) {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "audio.codec_unsupported".into(),
            source_path: source_path.to_string(),
            owner_actor_id: actor_id.map(str::to_string),
            reference: Some(path.to_string()),
            message: "audio clip format is not enabled by the runtime codec set".into(),
            repair: "Convert the clip to Ogg Vorbis, WAV, MP3, or FLAC and reassign it.".into(),
        });
    }
    collect_asset_ref(
        project_root,
        report,
        source_path,
        actor_id,
        "audio_clip",
        clip,
    );
}

fn is_supported_audio_reference(path: &str) -> bool {
    Path::new(path)
        .extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| {
            matches!(
                extension.to_ascii_lowercase().as_str(),
                "ogg" | "oga" | "spx" | "wav" | "mp3" | "flac"
            )
        })
}

fn validate_collider(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    collider: &ColliderDesc,
) {
    let invalid = match &collider.shape {
        ColliderShapeDesc::Cuboid {
            x_length,
            y_length,
            z_length,
        } => {
            !x_length.is_finite()
                || !y_length.is_finite()
                || !z_length.is_finite()
                || *x_length <= 0.0
                || *y_length <= 0.0
                || *z_length <= 0.0
        }
        ColliderShapeDesc::Sphere { radius } => !radius.is_finite() || *radius <= 0.0,
        ColliderShapeDesc::Capsule { radius, height } => {
            !radius.is_finite() || !height.is_finite() || *radius <= 0.0 || *height <= 0.0
        }
        ColliderShapeDesc::StaticMesh { meshes, .. } => {
            for mesh in meshes {
                collect_asset_ref(project_root, report, source_path, actor_id, "mesh", mesh);
            }
            meshes.is_empty()
        }
    };
    if invalid {
        report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "collider.invalid".into(),
            source_path: source_path.to_string(),
            owner_actor_id: actor_id.map(str::to_string),
            reference: None,
            message: "collider dimensions or mesh references are invalid".into(),
            repair: "Choose positive finite dimensions or at least one valid static mesh.".into(),
        });
    }
}

fn collect_material(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    material: &MaterialDesc,
) {
    let mut paths = Vec::new();
    paths.extend(
        material
            .shader
            .schema_path
            .clone()
            .map(|path| ("shader_schema", path)),
    );
    paths.extend(
        material
            .shader
            .shader_path
            .clone()
            .map(|path| ("shader", path)),
    );
    paths.extend(
        material
            .base_color_texture
            .clone()
            .map(|path| ("texture", path)),
    );
    paths.extend(
        material
            .emissive_texture
            .clone()
            .map(|path| ("texture", path)),
    );
    paths.extend(
        material
            .normal_map_texture
            .clone()
            .map(|path| ("texture", path)),
    );
    paths.extend(
        material
            .metallic_roughness_texture
            .clone()
            .map(|path| ("texture", path)),
    );
    paths.extend(
        material
            .material_asset_path
            .clone()
            .map(|path| ("material", path)),
    );
    for binding in &material.textures {
        if let Some(reference) = &binding.texture {
            collect_asset_ref(
                project_root,
                report,
                source_path,
                actor_id,
                "texture",
                reference,
            );
        }
    }
    for (kind, path) in paths {
        add_reference(project_root, report, source_path, actor_id, kind, &path);
    }
}

fn collect_asset_ref(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    source_path: &str,
    actor_id: Option<&str>,
    kind: &str,
    reference: &shared::EditorAssetRef,
) {
    if let Some(path) = reference.source_path.as_deref() {
        add_reference(project_root, report, source_path, actor_id, kind, path);
        return;
    }
    if !reference.asset_id.trim().is_empty() {
        add_reference(
            project_root,
            report,
            source_path,
            actor_id,
            "generated_manifest",
            &format!(
                "assets/meshes/generated/{}.static_mesh.ron",
                reference.asset_id
            ),
        );
    }
}

fn add_reference(
    project_root: &Path,
    report: &mut ProjectValidationReport,
    owner_path: &str,
    owner_actor_id: Option<&str>,
    kind: &str,
    reference: &str,
) {
    if reference.trim().is_empty() {
        return;
    }
    let dependency = ProjectDependency {
        owner_path: owner_path.to_string(),
        owner_actor_id: owner_actor_id.map(str::to_string),
        kind: kind.to_string(),
        reference: reference.to_string(),
    };
    let resolved = resolve_reference(project_root, reference);
    report.dependencies.push(dependency);
    match resolved {
        Some(path) if path.is_file() => {
            let relative = path.strip_prefix(project_root.join("assets")).ok();
            if std::fs::symlink_metadata(&path)
                .is_ok_and(|metadata| metadata.file_type().is_symlink())
            {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "asset.symlink_unsupported".into(),
                    source_path: owner_path.to_string(),
                    owner_actor_id: owner_actor_id.map(str::to_string),
                    reference: Some(reference.to_string()),
                    message: format!(
                        "referenced {kind} asset `{reference}` is a symlink and cannot be packaged"
                    ),
                    repair: "Replace the symlink with a regular project asset file.".into(),
                });
            } else if relative.is_none_or(|relative| !crate::is_runtime_package_asset(relative)) {
                report.findings.push(ProjectValidationFinding {
                    severity: ValidationSeverity::Error,
                    code: "asset.not_packageable".into(),
                    source_path: owner_path.to_string(),
                    owner_actor_id: owner_actor_id.map(str::to_string),
                    reference: Some(reference.to_string()),
                    message: format!(
                        "referenced {kind} asset `{reference}` is excluded from runtime packages"
                    ),
                    repair: "Reference a regular runtime asset outside editor-only trees and convert source-authoring files to a supported runtime format.".into(),
                });
            } else {
                match is_git_lfs_pointer(&path) {
                    Ok(false) => {}
                    Ok(true) => report.findings.push(ProjectValidationFinding {
                severity: ValidationSeverity::Error,
                code: "asset.git_lfs_object_missing".into(),
                source_path: owner_path.to_string(),
                owner_actor_id: owner_actor_id.map(str::to_string),
                reference: Some(reference.to_string()),
                message: format!(
                    "referenced {kind} asset `{reference}` is an unhydrated Git LFS pointer, not runtime content"
                ),
                repair: "Run `git lfs pull` in the project checkout, then rerun validation.".into(),
                    }),
                    Err(error) => report.findings.push(ProjectValidationFinding {
                        severity: ValidationSeverity::Error,
                        code: "asset.unreadable".into(),
                        source_path: owner_path.to_string(),
                        owner_actor_id: owner_actor_id.map(str::to_string),
                        reference: Some(reference.to_string()),
                        message: format!(
                            "referenced {kind} asset `{reference}` could not be read: {error}"
                        ),
                        repair: "Restore read access to the asset, then rerun validation.".into(),
                    }),
                }
            }
        }
        Some(_) => report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "asset.missing".into(),
            source_path: owner_path.to_string(),
            owner_actor_id: owner_actor_id.map(str::to_string),
            reference: Some(reference.to_string()),
            message: format!("referenced {kind} asset `{reference}` does not exist"),
            repair: "Restore the asset or update the owning actor/reference.".into(),
        }),
        None => report.findings.push(ProjectValidationFinding {
            severity: ValidationSeverity::Error,
            code: "asset.unsafe_path".into(),
            source_path: owner_path.to_string(),
            owner_actor_id: owner_actor_id.map(str::to_string),
            reference: Some(reference.to_string()),
            message: format!("referenced {kind} asset path `{reference}` escapes the project"),
            repair: "Choose a project-relative asset path under assets/.".into(),
        }),
    }
}

fn is_git_lfs_pointer(path: &Path) -> std::io::Result<bool> {
    let mut prefix = [0_u8; GIT_LFS_POINTER_HEADER_CRLF.len()];
    let byte_count = std::fs::File::open(path)?.read(&mut prefix)?;
    Ok(has_git_lfs_pointer_header(&prefix[..byte_count]))
}

fn blake3_file_hash(path: &Path) -> std::io::Result<String> {
    let mut file = std::fs::File::open(path)?;
    let mut hasher = blake3::Hasher::new();
    let mut buffer = [0_u8; 64 * 1024];
    loop {
        let byte_count = file.read(&mut buffer)?;
        if byte_count == 0 {
            break;
        }
        hasher.update(&buffer[..byte_count]);
    }
    Ok(hasher.finalize().to_hex().to_string())
}

fn has_git_lfs_pointer_header(bytes: &[u8]) -> bool {
    bytes.starts_with(GIT_LFS_POINTER_HEADER_LF) || bytes.starts_with(GIT_LFS_POINTER_HEADER_CRLF)
}

fn resolve_reference(project_root: &Path, reference: &str) -> Option<PathBuf> {
    let reference = Path::new(reference.split('#').next().unwrap_or(reference));
    if reference.is_absolute()
        || reference.components().any(|component| {
            matches!(
                component,
                Component::ParentDir | Component::RootDir | Component::Prefix(_)
            )
        })
    {
        return None;
    }
    let candidate = if reference.starts_with("assets") {
        project_root.join(reference)
    } else {
        project_root.join("assets").join(reference)
    };
    path_stays_within_root(project_root, &candidate).then_some(candidate)
}

fn normalized_project_reference(reference: &str) -> String {
    let normalized = reference.replace('\\', "/");
    let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
    if normalized.starts_with("assets/") {
        normalized.to_string()
    } else {
        format!("assets/{normalized}")
    }
}

fn path_stays_within_root(project_root: &Path, candidate: &Path) -> bool {
    let Ok(root) = project_root.canonicalize() else {
        return false;
    };
    let mut existing = candidate;
    while !existing.exists() {
        let Some(parent) = existing.parent() else {
            return false;
        };
        existing = parent;
    }
    existing
        .canonicalize()
        .is_ok_and(|resolved| resolved.starts_with(root))
}

fn display_project_path(project_root: &Path, path: &Path) -> String {
    path.strip_prefix(project_root)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/")
}

#[cfg(test)]
mod tests {
    use super::*;
    use shared::{
        AnimationClipRecord, AnimationImportDiagnostic, AnimationManifestSource,
        AnimationPlaybackRange, AnimationSkeletonRecord, AnimationSkeletonSignature,
        AnimationSourceFingerprint, AnimationStateDesc,
    };
    use std::sync::atomic::{AtomicU64, Ordering};

    static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1);
    const ANIMATION_ASSET_ID: &str = "model-id";
    const ANIMATION_MANIFEST_PATH: &str = "assets/animations/generated/model-id.animation.ron";

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

    #[test]
    fn static_mesh_manifest_schema_v3_is_current() {
        let root = fixture_root();
        let source_path = "assets/models/static.glb";
        std::fs::create_dir_all(root.join("assets/models")).unwrap();
        std::fs::write(root.join(source_path), b"model").unwrap();
        let artifact_dir = root.join("assets/meshes/generated");
        std::fs::create_dir_all(&artifact_dir).unwrap();
        std::fs::write(
            artifact_dir.join("static-id.static_mesh.ron"),
            format!(
                "(schema_version:{STATIC_MESH_MANIFEST_SCHEMA_VERSION},asset_id:\"static-id\",source:(path:\"{source_path}\",dependencies:[]),warnings:[])"
            ),
        )
        .unwrap();

        let report = validate_project(&root);

        assert!(!report.findings.iter().any(|finding| {
            finding.code == "manifest.incompatible_schema"
                && finding.source_path.ends_with("static-id.static_mesh.ron")
        }));
        std::fs::remove_dir_all(root).unwrap();
    }

    fn write_manifest(root: &Path, version: u32) {
        std::fs::write(
            root.join("assets/project.ron"),
            format!(
                "(version:{version},template_version:1,default_level:\"assets/levels/main.scn.ron\",asset_roots:[\"assets/levels\"],capabilities:[])"
            ),
        )
        .unwrap();
    }

    fn composition_scene(scene_id: &str, child_id: &str, child_path: &str) -> String {
        format!(
            "(schema_version:2,resources:{{\"shared::components::SceneComposition\":(scene_id:\"{scene_id}\",subscenes:[(id:\"{child_id}\",path:\"{child_path}\",visible:true,locked:true)])}},entities:{{}})"
        )
    }

    fn animation_fixture_manifest(
        source_path: &str,
        source_format: &str,
        signature: &str,
        runtime_supported: bool,
    ) -> AnimationManifest {
        let skeleton_signature = AnimationSkeletonSignature::new(signature);
        AnimationManifest {
            schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
            asset_id: ANIMATION_ASSET_ID.into(),
            label: "Hero".into(),
            default_animation_clip_id: None,
            source: AnimationManifestSource {
                path: source_path.into(),
                format: source_format.into(),
                fingerprint: AnimationSourceFingerprint {
                    byte_len: 5,
                    modified_unix_secs: 1,
                    content_hash: blake3::hash(b"model").to_hex().to_string(),
                },
                dependencies: Vec::new(),
            },
            runtime_supported,
            skeletons: vec![AnimationSkeletonRecord {
                id: animation_skeleton_sub_asset_id(0, "Hero Rig"),
                label: "Hero Rig".into(),
                source_index: 0,
                signature: skeleton_signature.clone(),
                joint_paths: vec!["Armature/Hip".into()],
            }],
            clips: vec![AnimationClipRecord {
                id: animation_clip_sub_asset_id(0, "Idle"),
                label: "Idle".into(),
                source_index: 0,
                duration_seconds: 1.0,
                target_skeleton_signature: Some(skeleton_signature),
                events: Vec::new(),
            }],
            diagnostics: Vec::new(),
        }
    }

    fn animation_fixture_controller(manifest: &AnimationManifest) -> AnimationControllerDesc {
        AnimationControllerDesc {
            skeleton: Some(EditorAssetRef::new(
                &manifest.asset_id,
                &manifest.skeletons[0].id,
                &manifest.skeletons[0].label,
            )),
            states: vec![AnimationStateDesc {
                id: "idle".into(),
                label: "Idle".into(),
                clip: EditorAssetRef::new(
                    &manifest.asset_id,
                    &manifest.clips[0].id,
                    &manifest.clips[0].label,
                ),
                looping: true,
                speed: 1.0,
                range: AnimationPlaybackRange::default(),
            }],
            default_state: "idle".into(),
            default_crossfade_seconds: 0.2,
        }
    }

    fn write_animation_source_and_registry(
        root: &Path,
        source_path: &str,
        animation_manifest_path: Option<&str>,
    ) {
        let source = root.join(source_path);
        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
        std::fs::write(source, b"model").unwrap();
        let manifest = animation_manifest_path
            .map(|path| format!("Some({path:?})"))
            .unwrap_or_else(|| "None".into());
        std::fs::write(
            root.join("assets/.index/registry.ron"),
            format!(
                "[(id:(\"{ANIMATION_ASSET_ID}\"),path:\"{source_path}\",kind_tag:\"Model\",import_settings:(static_mesh_manifest_path:None,animation_manifest_path:{manifest}),dependencies:[])]"
            ),
        )
        .unwrap();
    }

    fn write_animation_source_and_registry_default(
        root: &Path,
        source_path: &str,
        default_clip_id: &str,
    ) {
        let source = root.join(source_path);
        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
        std::fs::write(source, b"model").unwrap();
        std::fs::write(
            root.join("assets/.index/registry.ron"),
            format!(
                "[(id:(\"{ANIMATION_ASSET_ID}\"),path:\"{source_path}\",kind_tag:\"Model\",import_settings:(static_mesh_manifest_path:None,animation_manifest_path:Some(\"{ANIMATION_MANIFEST_PATH}\"),default_animation_clip_id:Some(\"{default_clip_id}\")),dependencies:[])]"
            ),
        )
        .unwrap();
    }

    fn write_animation_manifest(root: &Path, manifest: &AnimationManifest) {
        let path = root.join(ANIMATION_MANIFEST_PATH);
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(
            path,
            ron::ser::to_string_pretty(manifest, ron::ser::PrettyConfig::default()).unwrap(),
        )
        .unwrap();
    }

    fn write_animation_scene(root: &Path, controller: &AnimationControllerDesc) {
        let controller = ron::to_string(controller).unwrap();
        let mut scene = String::from(
            "(schema_version:2,resources:{},entities:{1:(components:{\"shared::components::ActorId\":(\"animated-actor\"),\"",
        );
        scene.push_str(COMPONENT_ANIMATION_CONTROLLER_DESC);
        scene.push_str("\":");
        scene.push_str(&controller);
        scene.push_str(",})})");
        std::fs::write(root.join("assets/levels/main.scn.ron"), scene).unwrap();
    }

    #[test]
    fn valid_project_fixture_has_no_blocking_findings() {
        let root = fixture_root();
        std::fs::create_dir_all(root.join("assets/models")).unwrap();
        std::fs::write(root.join("assets/models/valid.glb"), b"fixture").unwrap();
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            r#"(schema_version:2,resources:{},entities:{1:(components:{
                "shared::components::ActorId":("valid-model"),
                "shared::components::ModelRef":(path:"models/valid.glb",scene_index:0),
            })})"#,
        )
        .unwrap();

        let report = validate_project(&root);
        assert!(report.is_release_ready(), "{:?}", report.findings);
        assert_eq!(report.dependencies.len(), 2);
        std::fs::remove_dir_all(root).unwrap();
    }

    fn write_navigation_scene(root: &Path, radius: f32, write_artifact: bool) {
        let mut bounds = shared::NavigationBounds::default();
        bounds.agent.radius = radius;
        bounds.agent.min_region_size = 1;
        bounds.agent.merge_region_size = 2;
        bounds.artifact_path = "assets/navigation/generated/main_humanoid.nav.ron".to_string();
        let bounds_ron = ron::to_string(&bounds).unwrap();
        let scene = format!(
            "(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"nav-bounds\"),\"shared::navigation::NavigationBounds\":{bounds_ron},}}),2:(components:{{\"shared::components::ActorId\":(\"nav-floor\"),\"shared::components::Primitive\":(shape:Box,size:(10.0,0.2,10.0)),}})}})"
        );
        std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap();
        if write_artifact {
            let document = SceneDocument::from_ron_text(&scene).unwrap();
            let job = crate::navigation::navigation_bake_jobs_from_document(
                &document,
                "assets/levels/main.scn.ron",
            )
            .unwrap()
            .pop()
            .unwrap();
            let artifact = crate::navigation::bake_navigation(&job.input).unwrap();
            crate::navigation::write_navigation_artifact(&root.join(&job.artifact_path), &artifact)
                .unwrap();
        }
    }

    fn navigation_scene_with_artifact_path(
        bounds_actor_id: &str,
        artifact_path: &str,
        include_floor: bool,
        link_height: Option<f32>,
    ) -> String {
        let mut bounds = shared::NavigationBounds::for_actor(bounds_actor_id);
        bounds.half_extents = [5.0, link_height.unwrap_or(0.0).abs() + 2.0, 5.0].into();
        bounds.agent.min_region_size = 1;
        bounds.agent.merge_region_size = 2;
        bounds.artifact_path = artifact_path.to_string();
        let bounds = ron::to_string(&bounds).unwrap();
        let mut entities = format!(
            "1:(components:{{\"shared::components::ActorId\":({bounds_actor_id:?}),\"shared::navigation::NavigationBounds\":{bounds},}})"
        );
        if include_floor {
            entities.push_str(&format!(
                ",2:(components:{{\"shared::components::ActorId\":({:?}),\"shared::components::Primitive\":(shape:Box,size:(10.0,0.2,10.0)),}})",
                format!("{bounds_actor_id}-floor")
            ));
        }
        if let Some(height) = link_height {
            let link = shared::NavigationLink {
                start: [-1.0, height, 0.0].into(),
                end: [1.0, height, 0.0].into(),
                ..Default::default()
            };
            let link = ron::to_string(&link).unwrap();
            entities.push_str(&format!(
                ",3:(components:{{\"shared::components::ActorId\":({:?}),\"shared::navigation::NavigationLink\":{link},}})",
                format!("{bounds_actor_id}-link")
            ));
        }
        format!("(schema_version:2,resources:{{}},entities:{{{entities}}})")
    }

    #[test]
    fn missing_navigation_artifact_blocks_release_with_bounds_owner() {
        let root = fixture_root();
        write_navigation_scene(&root, 0.35, false);

        let report = validate_project(&root);
        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "navigation.artifact_missing")
            .expect("missing artifact should be reported");
        assert_eq!(finding.owner_actor_id.as_deref(), Some("nav-bounds"));
        assert!(finding.repair.contains("Bake Selected Bounds"));
    }

    #[test]
    fn navigation_agent_change_marks_bake_stale() {
        let root = fixture_root();
        write_navigation_scene(&root, 0.35, true);
        assert!(validate_project(&root).is_release_ready());

        write_navigation_scene(&root, 0.5, false);
        let report = validate_project(&root);
        assert!(report
            .findings
            .iter()
            .any(|finding| finding.code == "navigation.artifact_stale"));
    }

    #[test]
    fn navigation_artifacts_must_stay_in_generated_directory() {
        let root = fixture_root();
        let scene = navigation_scene_with_artifact_path(
            "outside-bounds",
            "assets/navigation/manual.nav.ron",
            true,
            None,
        );
        std::fs::write(root.join("assets/levels/main.scn.ron"), scene).unwrap();

        let report = validate_project(&root);
        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "navigation.artifact_outside_generated_directory")
            .expect("outside artifact path should be rejected");
        assert_eq!(finding.owner_actor_id.as_deref(), Some("outside-bounds"));
        assert!(finding.repair.contains("assets/navigation/generated/"));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn duplicate_navigation_artifact_owners_name_every_bounds_actor() {
        let root = fixture_root();
        let artifact_path = "assets/navigation/generated/shared.nav.ron";
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            navigation_scene_with_artifact_path("first-bounds", artifact_path, true, None),
        )
        .unwrap();
        std::fs::write(
            root.join("assets/levels/secondary.scn.ron"),
            navigation_scene_with_artifact_path("second-bounds", artifact_path, true, None),
        )
        .unwrap();

        let report = validate_project(&root);
        let findings = report
            .findings
            .iter()
            .filter(|finding| finding.code == "navigation.artifact_duplicate_owner")
            .collect::<Vec<_>>();
        assert_eq!(findings.len(), 2, "{:?}", report.findings);
        assert!(findings.iter().any(|finding| {
            finding.owner_actor_id.as_deref() == Some("first-bounds")
                && finding.message.contains("second-bounds")
        }));
        assert!(findings.iter().all(|finding| {
            finding.repair.contains("unique path") && finding.repair.contains("rebake")
        }));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn deterministic_bake_failures_are_attributed_to_bounds_actor() {
        let root = fixture_root();
        let scene = navigation_scene_with_artifact_path(
            "empty-bounds",
            "assets/navigation/generated/empty.nav.ron",
            false,
            None,
        );
        std::fs::write(root.join("assets/levels/main.scn.ron"), scene).unwrap();

        let report = validate_project(&root);
        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "navigation.bake_failed")
            .expect("empty bounds should fail a fresh validation bake");
        assert_eq!(finding.owner_actor_id.as_deref(), Some("empty-bounds"));
        assert!(finding.message.contains("no primitive or additive-brush"));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn link_validation_uses_endpoint_height_not_only_xz() {
        let root = fixture_root();
        let artifact_path = "assets/navigation/generated/high-link.nav.ron";
        let scene = navigation_scene_with_artifact_path(
            "high-link-bounds",
            artifact_path,
            true,
            Some(10.0),
        );
        std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap();
        let document = SceneDocument::from_ron_text(&scene).unwrap();
        let job = crate::navigation::navigation_bake_jobs_from_document(
            &document,
            "assets/levels/main.scn.ron",
        )
        .unwrap()
        .pop()
        .unwrap();
        let artifact = crate::navigation::bake_navigation(&job.input).unwrap();
        crate::navigation::write_navigation_artifact(&root.join(artifact_path), &artifact).unwrap();

        let report = validate_project(&root);
        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "navigation.link_unreachable")
            .expect("vertically distant link should be rejected");
        assert_eq!(
            finding.owner_actor_id.as_deref(),
            Some("high-link-bounds-link")
        );
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn unreachable_navigation_sample_blocks_release_with_bounds_owner() {
        let root = fixture_root();
        let artifact_path = "assets/navigation/generated/sample.nav.ron";
        let mut bounds = shared::NavigationBounds::for_actor("sample-bounds");
        bounds.half_extents = [5.0, 2.0, 5.0].into();
        bounds.agent.min_region_size = 1;
        bounds.agent.merge_region_size = 2;
        bounds.artifact_path = artifact_path.into();
        bounds
            .validation_samples
            .push(shared::NavigationPathSample {
                id: "outside-mesh".into(),
                start: [100.0, 0.0, 100.0].into(),
                end: [101.0, 0.0, 100.0].into(),
                enabled: true,
            });
        let bounds = ron::to_string(&bounds).unwrap();
        let scene = format!(
            r#"(schema_version:2,resources:{{}},entities:{{
                1:(components:{{"shared::components::ActorId":("sample-bounds"),"shared::navigation::NavigationBounds":{bounds},}}),
                2:(components:{{"shared::components::ActorId":("sample-floor"),"shared::components::Primitive":(shape:Box,size:(10.0,0.2,10.0)),}}),
            }})"#
        );
        std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap();
        let document = SceneDocument::from_ron_text(&scene).unwrap();
        let job = crate::navigation::navigation_bake_jobs_from_document(
            &document,
            "assets/levels/main.scn.ron",
        )
        .unwrap()
        .pop()
        .unwrap();
        let artifact = crate::navigation::bake_navigation(&job.input).unwrap();
        crate::navigation::write_navigation_artifact(&root.join(artifact_path), &artifact).unwrap();

        let report = validate_project(&root);
        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "navigation.sample_unreachable")
            .expect("unreachable sample should block release");
        assert_eq!(finding.owner_actor_id.as_deref(), Some("sample-bounds"));
        assert_eq!(finding.reference.as_deref(), Some("outside-mesh"));
        assert_eq!(finding.severity, ValidationSeverity::Error);
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn disconnected_navigation_islands_emit_owner_warning() {
        let root = fixture_root();
        let artifact_path = "assets/navigation/generated/islands.nav.ron";
        let mut bounds = shared::NavigationBounds::for_actor("island-bounds");
        bounds.half_extents = [6.0, 2.0, 5.0].into();
        bounds.agent.min_region_size = 1;
        bounds.agent.merge_region_size = 2;
        bounds.artifact_path = artifact_path.into();
        let bounds = ron::to_string(&bounds).unwrap();
        let scene = format!(
            r#"(schema_version:2,resources:{{}},entities:{{
                1:(components:{{"shared::components::ActorId":("island-bounds"),"shared::navigation::NavigationBounds":{bounds},}}),
                2:(components:{{"shared::components::ActorId":("left-floor"),"bevy_transform::components::transform::Transform":(translation:(-3.5,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)),"shared::components::Primitive":(shape:Box,size:(4.0,0.2,8.0)),}}),
                3:(components:{{"shared::components::ActorId":("right-floor"),"bevy_transform::components::transform::Transform":(translation:(3.5,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)),"shared::components::Primitive":(shape:Box,size:(4.0,0.2,8.0)),}}),
            }})"#
        );
        std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap();
        let document = SceneDocument::from_ron_text(&scene).unwrap();
        let job = crate::navigation::navigation_bake_jobs_from_document(
            &document,
            "assets/levels/main.scn.ron",
        )
        .unwrap()
        .pop()
        .unwrap();
        let artifact = crate::navigation::bake_navigation(&job.input).unwrap();
        assert!(artifact.diagnostics.effective_component_count > 1);
        crate::navigation::write_navigation_artifact(&root.join(artifact_path), &artifact).unwrap();

        let report = validate_project(&root);
        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "navigation.isolated_regions")
            .expect("multiple islands should produce an authoring warning");
        assert_eq!(finding.severity, ValidationSeverity::Warning);
        assert_eq!(finding.owner_actor_id.as_deref(), Some("island-bounds"));
        assert!(finding.message.contains("2 disconnected"));
        assert!(finding.repair.contains("Navigation Links"));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn valid_animation_controller_collects_stable_artifact_dependencies() {
        let root = fixture_root();
        let source_path = "assets/models/hero.glb";
        let manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
        let controller = animation_fixture_controller(&manifest);
        write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
        write_animation_manifest(&root, &manifest);
        write_animation_scene(&root, &controller);

        let report = validate_project(&root);

        assert!(report.is_release_ready(), "{:?}", report.findings);
        for kind in ["animation_manifest", "animation_skeleton", "animation_clip"] {
            assert!(
                report.dependencies.iter().any(|dependency| {
                    dependency.owner_actor_id.as_deref() == Some("animated-actor")
                        && dependency.kind == kind
                }),
                "missing actor-owned {kind} dependency: {:?}",
                report.dependencies
            );
        }
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn static_renderer_cannot_reference_skinned_or_animated_model_geometry() {
        let root = fixture_root();
        let source_path = "assets/models/hero.glb";
        let manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
        write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
        write_animation_manifest(&root, &manifest);
        let renderer = StaticMeshRenderer::single(shared::StaticMeshRendererEntry {
            mesh: EditorAssetRef::new(ANIMATION_ASSET_ID, "mesh:hero", "Hero"),
            ..Default::default()
        });
        let renderer = ron::to_string(&renderer).unwrap();
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            format!(
                "(schema_version:3,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"static-hero\"),\"shared::components::StaticMeshRenderer\":{renderer},}})}})"
            ),
        )
        .unwrap();

        let report = validate_project(&root);

        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "static_mesh.hierarchy_bound_geometry")
            .expect("static use of hierarchy-bound geometry should be blocking");
        assert_eq!(finding.owner_actor_id.as_deref(), Some("static-hero"));
        assert_eq!(finding.severity, ValidationSeverity::Error);
        assert!(finding.repair.contains("SkinnedMeshRenderer"));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn same_size_animation_source_change_invalidates_manifest_content_hash() {
        let root = fixture_root();
        let source_path = "assets/models/hero.glb";
        let manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
        let controller = animation_fixture_controller(&manifest);
        write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
        write_animation_manifest(&root, &manifest);
        write_animation_scene(&root, &controller);
        std::fs::write(root.join(source_path), b"modem").unwrap();

        let report = validate_project(&root);

        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "animation.manifest_stale")
            .expect("same-size source edits must invalidate the generated animation manifest");
        assert!(finding.message.contains("byte length is unchanged"));
        assert!(finding.repair.contains("Reimport"));
        assert!(!report.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn missing_animation_manifest_is_reported_globally_and_on_the_actor_property() {
        let root = fixture_root();
        let source_path = "assets/models/hero.glb";
        let manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
        write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
        write_animation_scene(&root, &animation_fixture_controller(&manifest));

        let report = validate_project(&root);

        assert!(report.findings.iter().any(|finding| {
            finding.code == "animation.manifest_missing" && finding.owner_actor_id.is_none()
        }));
        let finding = report
            .findings
            .iter()
            .find(|finding| {
                finding.code == "animation.manifest_missing"
                    && finding.owner_actor_id.as_deref() == Some("animated-actor")
            })
            .expect("missing manifest must identify the owning actor");
        assert!(finding
            .reference
            .as_deref()
            .is_some_and(|reference| reference.contains("AnimationControllerDesc.skeleton")));
        assert!(!finding.repair.trim().is_empty());
        assert!(!report.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn malformed_animation_manifest_is_reported_on_the_actor_property() {
        let root = fixture_root();
        let source_path = "assets/models/hero.glb";
        let manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
        write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
        let path = root.join(ANIMATION_MANIFEST_PATH);
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, "not valid RON").unwrap();
        write_animation_scene(&root, &animation_fixture_controller(&manifest));

        let report = validate_project(&root);

        let finding = report
            .findings
            .iter()
            .find(|finding| {
                finding.code == "animation.manifest_malformed"
                    && finding.owner_actor_id.as_deref() == Some("animated-actor")
            })
            .expect("malformed manifest must identify the owning actor");
        assert!(finding
            .reference
            .as_deref()
            .is_some_and(|reference| reference.contains("AnimationControllerDesc.skeleton")));
        assert!(!finding.repair.trim().is_empty());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn missing_animation_clip_is_reported_on_the_exact_state_property() {
        let root = fixture_root();
        let source_path = "assets/models/hero.glb";
        let manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
        let mut controller = animation_fixture_controller(&manifest);
        controller.states[0].clip.sub_asset_id = animation_clip_sub_asset_id(9, "Missing");
        write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
        write_animation_manifest(&root, &manifest);
        write_animation_scene(&root, &controller);

        let report = validate_project(&root);

        let finding = report
            .findings
            .iter()
            .find(|finding| {
                finding.code == "animation.clip_missing"
                    && finding.owner_actor_id.as_deref() == Some("animated-actor")
            })
            .expect("missing clip must identify the owning actor state");
        assert!(finding.reference.as_deref().is_some_and(|reference| {
            reference.contains("AnimationControllerDesc.states[idle].clip")
        }));
        assert!(!finding.repair.trim().is_empty());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn missing_import_default_clip_blocks_the_generated_manifest() {
        let root = fixture_root();
        let source_path = "assets/models/hero.glb";
        let missing = animation_clip_sub_asset_id(9, "Removed");
        let mut manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
        manifest.default_animation_clip_id = Some(missing.clone());
        write_animation_source_and_registry_default(&root, source_path, &missing);
        write_animation_manifest(&root, &manifest);

        let report = validate_project(&root);

        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "animation.default_clip_missing")
            .expect("a stale import default must be a blocking project diagnostic");
        assert_eq!(finding.severity, ValidationSeverity::Error);
        assert_eq!(finding.reference.as_deref(), Some(missing.as_str()));
        assert!(finding.repair.contains("Default animation"));
        assert!(!report.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn invalid_animation_state_contract_reports_each_actor_property() {
        let root = fixture_root();
        let source_path = "assets/models/hero.glb";
        let manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
        let mut controller = animation_fixture_controller(&manifest);
        controller.default_crossfade_seconds = -0.1;
        controller.states[0].speed = 0.0;
        controller.states[0].range = AnimationPlaybackRange {
            start_seconds: 0.75,
            end_seconds: Some(0.25),
        };
        controller.states.push(controller.states[0].clone());
        controller.default_state = "missing".into();
        write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
        write_animation_manifest(&root, &manifest);
        write_animation_scene(&root, &controller);

        let report = validate_project(&root);

        for (code, property) in [
            (
                "animation.crossfade_invalid",
                "AnimationControllerDesc.default_crossfade_seconds",
            ),
            (
                "animation.state_id_invalid",
                "AnimationControllerDesc.states[idle].id",
            ),
            (
                "animation.speed_invalid",
                "AnimationControllerDesc.states[idle].speed",
            ),
            (
                "animation.range_invalid",
                "AnimationControllerDesc.states[idle].range.end_seconds",
            ),
            (
                "animation.default_state_invalid",
                "AnimationControllerDesc.default_state",
            ),
        ] {
            assert!(
                report.findings.iter().any(|finding| {
                    finding.code == code
                        && finding.owner_actor_id.as_deref() == Some("animated-actor")
                        && finding
                            .reference
                            .as_deref()
                            .is_some_and(|reference| reference.contains(property))
                        && !finding.repair.trim().is_empty()
                }),
                "missing {code} finding for {property}: {:?}",
                report.findings
            );
        }
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn mismatched_clip_and_skeleton_signatures_block_the_owning_actor() {
        let root = fixture_root();
        let source_path = "assets/models/hero.glb";
        let mut manifest = animation_fixture_manifest(source_path, "glb", "rig-a", true);
        manifest.clips[0].target_skeleton_signature =
            Some(AnimationSkeletonSignature::new("rig-b"));
        let controller = animation_fixture_controller(&manifest);
        write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
        write_animation_manifest(&root, &manifest);
        write_animation_scene(&root, &controller);

        let report = validate_project(&root);

        let finding = report
            .findings
            .iter()
            .find(|finding| {
                finding.code == "animation.skeleton_mismatch"
                    && finding.owner_actor_id.as_deref() == Some("animated-actor")
            })
            .expect("signature mismatch must identify the owning actor state");
        assert!(finding.reference.as_deref().is_some_and(|reference| {
            reference.contains("AnimationControllerDesc.states[idle].clip")
        }));
        assert!(finding.repair.contains("does not retarget"));
        assert!(!report.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn animated_fbx_manifest_is_blocking_and_recommends_gltf_export() {
        let root = fixture_root();
        let source_path = "assets/models/hero.fbx";
        let mut manifest = animation_fixture_manifest(source_path, "fbx", "rig-signature", false);
        manifest.diagnostics.push(AnimationImportDiagnostic {
            severity: AnimationDiagnosticSeverity::Error,
            code: "animation.fbx_runtime_unsupported".into(),
            message: "animated FBX cannot hydrate at runtime".into(),
            repair: "Export the animated source as glTF 2.0 (.glb or .gltf).".into(),
        });
        let controller = animation_fixture_controller(&manifest);
        write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
        write_animation_manifest(&root, &manifest);
        write_animation_scene(&root, &controller);

        let report = validate_project(&root);

        assert!(report.findings.iter().any(|finding| {
            finding.code == "animation.fbx_runtime_unsupported"
                && finding.owner_actor_id.is_none()
                && finding.repair.contains("glTF 2.0")
        }));
        assert!(report.findings.iter().any(|finding| {
            finding.code == "animation.runtime_unsupported"
                && finding.owner_actor_id.as_deref() == Some("animated-actor")
                && finding
                    .reference
                    .as_deref()
                    .is_some_and(|reference| reference.contains("AnimationControllerDesc"))
                && finding.repair.contains("glTF 2.0")
        }));
        assert!(!report.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn static_fbx_without_animation_manifest_or_controller_remains_valid() {
        let root = fixture_root();
        let source_path = "assets/models/static.fbx";
        write_animation_source_and_registry(&root, source_path, None);
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            r#"(schema_version:2,resources:{},entities:{1:(components:{
                "shared::components::ActorId":("static-model"),
                "shared::components::ModelRef":(path:"models/static.fbx",scene_index:0),
            })})"#,
        )
        .unwrap();

        let report = validate_project(&root);

        assert!(report.is_release_ready(), "{:?}", report.findings);
        assert!(!report
            .findings
            .iter()
            .any(|finding| finding.code.starts_with("animation.")));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn audio_clip_and_bus_are_owner_attributed_runtime_dependencies() {
        let root = fixture_root();
        std::fs::create_dir_all(root.join("assets/audio")).unwrap();
        std::fs::write(root.join("assets/audio/impact.ogg"), b"fixture audio").unwrap();
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            r#"(schema_version:2,resources:{},entities:{1:(components:{
                "shared::components::ActorId":("audio-source"),
                "shared::components::AudioSourceDesc":(
                    clip:Some((asset_id:"audio-id",sub_asset_id:"audio:source",label:"Impact",source_path:Some("assets/audio/impact.ogg"))),
                    gain_db:0.0,pitch:1.0,looping:false,autoplay:true,spatial_blend:1.0,
                    attenuation:(min_distance:1.0,max_distance:25.0,rolloff:Inverse,rolloff_factor:1.0),
                    bus:"sfx",
                ),
            })})"#,
        )
        .unwrap();

        let report = validate_project(&root);

        assert!(!report.findings.iter().any(|finding| {
            finding.owner_actor_id.as_deref() == Some("audio-source")
                && finding.severity == ValidationSeverity::Error
        }));
        assert!(report.dependencies.iter().any(|dependency| {
            dependency.owner_actor_id.as_deref() == Some("audio-source")
                && dependency.kind == "audio_clip"
                && dependency.reference == "assets/audio/impact.ogg"
        }));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn audio_validation_blocks_missing_clip_unknown_bus_and_listener_ties() {
        let root = fixture_root();
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            r#"(schema_version:2,resources:{},entities:{
                1:(components:{
                    "shared::components::ActorId":("bad-source"),
                    "shared::components::AudioSourceDesc":(
                        clip:None,gain_db:0.0,pitch:1.0,looping:false,autoplay:true,spatial_blend:0.0,
                        attenuation:(min_distance:1.0,max_distance:25.0,rolloff:Inverse,rolloff_factor:1.0),
                        bus:"missing",
                    ),
                }),
                2:(components:{
                    "shared::components::ActorId":("listener-a"),
                    "shared::components::AudioListenerDesc":(enabled:true,priority:10,ear_gap:0.2),
                }),
                3:(components:{
                    "shared::components::ActorId":("listener-b"),
                    "shared::components::AudioListenerDesc":(enabled:true,priority:10,ear_gap:0.2),
                }),
            })"#,
        )
        .unwrap();

        let report = validate_project(&root);

        assert!(report.findings.iter().any(|finding| {
            finding.code == "audio.clip_missing"
                && finding.owner_actor_id.as_deref() == Some("bad-source")
        }));
        assert!(report.findings.iter().any(|finding| {
            finding.code == "audio.bus_unknown"
                && finding.owner_actor_id.as_deref() == Some("bad-source")
        }));
        assert_eq!(
            report
                .findings
                .iter()
                .filter(|finding| finding.code == "audio.listener_priority_tie")
                .count(),
            2
        );
        assert!(!report.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn missing_asset_finding_names_owning_actor() {
        let root = fixture_root();
        let scene = r#"(schema_version:2,resources:{},entities:{1:(components:{
            "shared::components::ActorId":("actor-1"),
            "shared::components::ModelRef":(path:"models/missing.glb",scene_index:0),
        })})"#;
        std::fs::write(root.join("assets/levels/main.scn.ron"), scene).unwrap();

        let report = validate_project(&root);
        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "asset.missing")
            .unwrap();
        assert_eq!(finding.source_path, "assets/levels/main.scn.ron");
        assert_eq!(finding.owner_actor_id.as_deref(), Some("actor-1"));
        assert_eq!(finding.reference.as_deref(), Some("models/missing.glb"));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn project_default_level_must_be_in_the_audited_levels_tree() {
        let root = fixture_root();
        std::fs::create_dir_all(root.join("assets/custom")).unwrap();
        std::fs::write(
            root.join("assets/custom/start.scn.ron"),
            "(schema_version:2,resources:{},entities:{})",
        )
        .unwrap();
        std::fs::write(
            root.join("assets/project.ron"),
            "(version:1,template_version:1,default_level:\"assets/custom/start.scn.ron\",asset_roots:[\"assets/levels\"],capabilities:[])",
        )
        .unwrap();

        let report = validate_project(&root);

        assert!(report
            .findings
            .iter()
            .any(|finding| finding.code == "project.default_level_outside_levels"));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn project_default_level_must_be_a_scene_document() {
        let root = fixture_root();
        std::fs::write(root.join("assets/levels/splash.png"), b"not a scene").unwrap();
        std::fs::write(
            root.join("assets/project.ron"),
            "(version:1,template_version:1,default_level:\"assets/levels/splash.png\",asset_roots:[\"assets/levels\"],capabilities:[])",
        )
        .unwrap();

        let report = validate_project(&root);

        assert!(report
            .findings
            .iter()
            .any(|finding| finding.code == "project.default_level_outside_levels"));
        std::fs::remove_dir_all(root).unwrap();
    }

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

        let root = fixture_root();
        let outside = std::env::temp_dir().join(format!(
            "blacksite-validation-outside-{}-{}",
            std::process::id(),
            NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
        ));
        std::fs::create_dir_all(&outside).unwrap();
        std::fs::write(
            outside.join("external.scn.ron"),
            "(schema_version:2,resources:{},entities:{})",
        )
        .unwrap();
        symlink(&outside, root.join("assets/levels/external")).unwrap();

        let report = validate_project(&root);

        assert!(report.findings.iter().any(|finding| {
            finding.code == "asset.traversal_failed"
                && finding.source_path.contains("assets/levels/external")
        }));
        std::fs::remove_dir_all(root).unwrap();
        std::fs::remove_dir_all(outside).unwrap();
    }

    #[test]
    fn git_lfs_pointer_is_a_blocking_owner_attributed_finding() {
        let root = fixture_root();
        std::fs::create_dir_all(root.join("assets/models")).unwrap();
        std::fs::write(
            root.join("assets/models/missing-lfs.glb"),
            b"version https://git-lfs.github.com/spec/v1\noid sha256:0123456789abcdef\nsize 1234\n",
        )
        .unwrap();
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            r#"(schema_version:2,resources:{},entities:{1:(components:{
                "shared::components::ActorId":("lfs-model-owner"),
                "shared::components::ModelRef":(path:"models/missing-lfs.glb",scene_index:0),
            })})"#,
        )
        .unwrap();

        let report = validate_project(&root);
        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "asset.git_lfs_object_missing")
            .expect("Git LFS pointer must block project validation");
        assert_eq!(finding.severity, ValidationSeverity::Error);
        assert_eq!(finding.source_path, "assets/levels/main.scn.ron");
        assert_eq!(finding.owner_actor_id.as_deref(), Some("lfs-model-owner"));
        assert_eq!(finding.reference.as_deref(), Some("models/missing-lfs.glb"));
        assert!(finding.repair.contains("git lfs pull"));
        assert!(!report.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn git_lfs_header_detection_requires_the_canonical_first_line() {
        assert!(has_git_lfs_pointer_header(
            b"version https://git-lfs.github.com/spec/v1\noid sha256:a\n"
        ));
        assert!(has_git_lfs_pointer_header(
            b"version https://git-lfs.github.com/spec/v1\r\noid sha256:a\r\n"
        ));
        assert!(!has_git_lfs_pointer_header(
            b"prefix version https://git-lfs.github.com/spec/v1\n"
        ));
        assert!(!has_git_lfs_pointer_header(
            b"version https://git-lfs.github.com/spec/v2\n"
        ));
    }

    #[test]
    fn excluded_source_asset_is_a_blocking_runtime_dependency() {
        let root = fixture_root();
        std::fs::create_dir_all(root.join("assets/textures")).unwrap();
        std::fs::write(root.join("assets/textures/source.psd"), b"source").unwrap();
        let mut report = ProjectValidationReport::default();

        add_reference(
            &root,
            &mut report,
            "assets/levels/main.scn.ron",
            Some("material-owner"),
            "texture",
            "textures/source.psd",
        );

        let finding = report
            .findings
            .iter()
            .find(|finding| finding.code == "asset.not_packageable")
            .expect("source-only dependency must block release validation");
        assert_eq!(finding.owner_actor_id.as_deref(), Some("material-owner"));
        assert_eq!(finding.reference.as_deref(), Some("textures/source.psd"));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn excluded_editor_registry_records_are_not_runtime_dependencies() {
        let root = fixture_root();
        std::fs::create_dir_all(root.join("assets/build_profiles")).unwrap();
        std::fs::write(root.join("assets/build_profiles/development.ron"), b"()").unwrap();
        std::fs::write(
            root.join("assets/.index/registry.ron"),
            "[(id:(\"profile-id\"),path:\"assets/build_profiles/development.ron\",kind_tag:\"Level\",import_settings:(static_mesh_manifest_path:None),dependencies:[])]",
        )
        .unwrap();
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            "(schema_version:2,resources:{},entities:{})",
        )
        .unwrap();

        let report = validate_project(&root);

        assert!(!report
            .findings
            .iter()
            .any(|finding| finding.code == "asset.not_packageable"));
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn cyclic_project_fixture_is_blocking() {
        let root = fixture_root();
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            composition_scene("main", "child", "assets/levels/child.scn.ron"),
        )
        .unwrap();
        std::fs::write(
            root.join("assets/levels/child.scn.ron"),
            composition_scene("child", "main", "assets/levels/main.scn.ron"),
        )
        .unwrap();

        let report = validate_project(&root);
        assert!(report.findings.iter().any(|finding| {
            finding.code == "scene.invalid" && finding.message.contains("cyclic subscene")
        }));
        assert!(!report.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn incompatible_project_fixture_reports_schema_and_collider() {
        let root = fixture_root();
        write_manifest(&root, 99);
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            r#"(schema_version:2,resources:{},entities:{1:(components:{
                "shared::components::ActorId":("bad-collider"),
                "shared::components::ColliderDesc":(enabled:true,is_trigger:false,shape:Sphere(radius:0.0)),
            })})"#,
        )
        .unwrap();

        let report = validate_project(&root);
        let codes = report
            .findings
            .iter()
            .map(|finding| finding.code.as_str())
            .collect::<BTreeSet<_>>();
        assert!(codes.contains("project.incompatible_schema"));
        assert!(codes.contains("collider.invalid"));
        assert!(!report.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

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

        let root = fixture_root();
        let outside = std::env::temp_dir().join(format!(
            "blacksite-project-validation-outside-{}-{}",
            std::process::id(),
            NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
        ));
        std::fs::create_dir_all(&outside).unwrap();
        symlink(&outside, root.join("assets/linked")).unwrap();
        std::fs::write(
            root.join("assets/levels/main.scn.ron"),
            r#"(schema_version:2,resources:{},entities:{1:(components:{
                "shared::components::ActorId":("unsafe-model"),
                "shared::components::ModelRef":(path:"linked/model.glb",scene_index:0),
            })})"#,
        )
        .unwrap();
        let report = validate_project(&root);
        assert!(report.findings.iter().any(|finding| {
            finding.code == "asset.unsafe_path"
                && finding.owner_actor_id.as_deref() == Some("unsafe-model")
        }));
        std::fs::remove_dir_all(root).unwrap();
        std::fs::remove_dir_all(outside).unwrap();
    }
}
