//! External asset bundle inspection, conversion, and transactional import.

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

use bevy_ufbx::texture::{external_texture_paths, external_texture_reference};
use shared::{
    AssetSourceFingerprint, ColorDesc, MaterialAlphaMode, MaterialAsset, MaterialDesc,
    MaterialRenderState,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FbxDependencyInspection {
    pub relative_paths: Vec<String>,
    pub resolved_paths: Vec<PathBuf>,
    pub missing_paths: Vec<PathBuf>,
}

/// Parses every external FBX texture reference without loading it through Bevy.
pub fn inspect_fbx_dependencies(source: &Path) -> Result<FbxDependencyInspection, String> {
    let bytes = fs::read(source)
        .map_err(|error| format!("could not read FBX {}: {error}", source.display()))?;
    let filename_hint = source.to_string_lossy();
    let scene = ufbx::load_memory(
        &bytes,
        ufbx::LoadOpts {
            target_unit_meters: 1.0,
            target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
            filename: ufbx::StringOpt::Ref(&filename_hint),
            ..Default::default()
        },
    )
    .map_err(|error| format!("could not parse FBX {}: {error:?}", source.display()))?;
    let relative_paths = external_texture_paths(&scene).map_err(|errors| {
        format!(
            "unsafe FBX texture reference(s) in {}: {}",
            source.display(),
            errors
                .into_iter()
                .map(|error| error.to_string())
                .collect::<Vec<_>>()
                .join("; ")
        )
    })?;
    let source_root = source.parent().unwrap_or_else(|| Path::new(""));
    let canonical_root = fs::canonicalize(source_root).map_err(|error| {
        format!(
            "could not resolve FBX source directory {}: {error}",
            source_root.display()
        )
    })?;
    let mut resolved_paths = Vec::new();
    let mut missing_paths = Vec::new();
    for relative_path in &relative_paths {
        let resolved = source_root.join(relative_path);
        if !resolved.is_file() {
            missing_paths.push(resolved.clone());
            resolved_paths.push(resolved);
            continue;
        }
        let canonical = fs::canonicalize(&resolved).map_err(|error| {
            format!(
                "could not resolve FBX dependency {}: {error}",
                resolved.display()
            )
        })?;
        if !canonical.starts_with(&canonical_root) {
            return Err(format!(
                "FBX dependency {} resolves outside source directory {}",
                resolved.display(),
                source_root.display()
            ));
        }
        resolved_paths.push(resolved);
    }
    missing_paths.sort();
    Ok(FbxDependencyInspection {
        relative_paths,
        resolved_paths,
        missing_paths,
    })
}

/// Returns one stable browser-facing error for all missing FBX source textures.
pub fn validate_fbx_dependencies(source: &Path) -> Result<(), String> {
    let inspection = inspect_fbx_dependencies(source)?;
    if inspection.missing_paths.is_empty() {
        return Ok(());
    }
    Err(format!(
        "missing {} FBX source texture(s): {}",
        inspection.missing_paths.len(),
        inspection
            .missing_paths
            .iter()
            .map(|path| path.to_string_lossy().replace('\\', "/"))
            .collect::<Vec<_>>()
            .join(", ")
    ))
}

/// Copies an FBX and every referenced external texture as one staged filesystem transaction.
pub fn copy_fbx_bundle(source: &Path, dest_dir: &Path) -> Result<(), String> {
    let entries = plan_fbx_bundle(source)?;
    copy_bundle_transactionally(&entries, dest_dir)
}

fn plan_fbx_bundle(source: &Path) -> Result<BTreeMap<PathBuf, PathBuf>, String> {
    let inspection = inspect_fbx_dependencies(source)?;
    if !inspection.missing_paths.is_empty() {
        return Err(format!(
            "FBX import is missing {} required texture(s): {}",
            inspection.missing_paths.len(),
            inspection
                .missing_paths
                .iter()
                .map(|path| path.display().to_string())
                .collect::<Vec<_>>()
                .join(", ")
        ));
    }
    let file_name = source
        .file_name()
        .ok_or_else(|| "FBX import path has no file name".to_string())?;
    let mut entries = BTreeMap::new();
    entries.insert(PathBuf::from(file_name), source.to_path_buf());
    for (relative, resolved) in inspection
        .relative_paths
        .iter()
        .zip(inspection.resolved_paths.iter())
    {
        let relative = PathBuf::from(relative);
        if let Some(existing) = entries.insert(relative.clone(), resolved.clone()) {
            if existing != *resolved {
                return Err(format!(
                    "FBX import maps multiple source files to {}",
                    relative.display()
                ));
            }
        }
    }
    Ok(entries)
}

/// Copies a textual glTF and all of its external buffers and images as one transaction.
/// Binary GLB files are self-contained and should use the ordinary single-file import path.
pub fn copy_gltf_bundle(source: &Path, dest_dir: &Path) -> Result<(), String> {
    let entries = plan_gltf_bundle(source)?;
    copy_bundle_transactionally(&entries, dest_dir)
}

fn plan_gltf_bundle(source: &Path) -> Result<BTreeMap<PathBuf, PathBuf>, String> {
    let document = gltf::Gltf::open(source)
        .map_err(|error| format!("could not parse glTF {}: {error}", source.display()))?;
    let source_root = source.parent().unwrap_or_else(|| Path::new(""));
    let canonical_root = fs::canonicalize(source_root).map_err(|error| {
        format!(
            "could not resolve glTF source directory {}: {error}",
            source_root.display()
        )
    })?;
    let file_name = source
        .file_name()
        .ok_or_else(|| "glTF import path has no file name".to_string())?;
    let mut entries = BTreeMap::from([(PathBuf::from(file_name), source.to_path_buf())]);
    let uris = document
        .buffers()
        .filter_map(|buffer| match buffer.source() {
            gltf::buffer::Source::Uri(uri) => Some(uri),
            gltf::buffer::Source::Bin => None,
        })
        .chain(document.images().filter_map(|image| match image.source() {
            gltf::image::Source::Uri { uri, .. } if !uri.starts_with("data:") => Some(uri),
            _ => None,
        }));
    for uri in uris {
        let relative = decode_gltf_uri_path(uri)?;
        validate_bundle_relative_path(&relative)?;
        let resolved = source_root.join(&relative);
        let metadata = fs::symlink_metadata(&resolved).map_err(|error| {
            format!(
                "missing glTF dependency {} referenced by {}: {error}",
                resolved.display(),
                source.display()
            )
        })?;
        if metadata.file_type().is_symlink() || !metadata.is_file() {
            return Err(format!(
                "glTF dependency {} must be a regular non-symlink file",
                resolved.display()
            ));
        }
        let canonical = fs::canonicalize(&resolved).map_err(|error| {
            format!(
                "could not resolve glTF dependency {}: {error}",
                resolved.display()
            )
        })?;
        if !canonical.starts_with(&canonical_root) {
            return Err(format!(
                "glTF dependency {} resolves outside source directory {}",
                resolved.display(),
                source_root.display()
            ));
        }
        if let Some(existing) = entries.insert(relative.clone(), resolved.clone()) {
            if existing != resolved {
                return Err(format!(
                    "glTF import maps multiple source files to {}",
                    relative.display()
                ));
            }
        }
    }
    Ok(entries)
}

pub fn planned_import_targets(source: &Path, destination: &Path) -> Result<Vec<PathBuf>, String> {
    let extension = source
        .extension()
        .and_then(|extension| extension.to_str())
        .unwrap_or_default();
    let relative_paths = if extension.eq_ignore_ascii_case("fbx") {
        plan_fbx_bundle(source)?.into_keys().collect::<Vec<_>>()
    } else if extension.eq_ignore_ascii_case("gltf") {
        plan_gltf_bundle(source)?.into_keys().collect::<Vec<_>>()
    } else {
        vec![PathBuf::from(source.file_name().ok_or_else(|| {
            format!("import path has no file name: {}", source.display())
        })?)]
    };
    Ok(relative_paths
        .into_iter()
        .map(|relative| destination.join(relative))
        .collect())
}

fn fingerprint_file(path: impl AsRef<Path>) -> Result<AssetSourceFingerprint, String> {
    let path = path.as_ref();
    let bytes = fs::read(path)
        .map_err(|error| format!("could not read imported source {}: {error}", path.display()))?;
    Ok(AssetSourceFingerprint::from_bytes(&bytes))
}

pub fn import_external_assets(paths: &[PathBuf]) -> Result<usize, String> {
    import_external_assets_to(paths, Path::new(crate::ASSETS_DIRECTORY))
}

pub fn import_external_assets_to(paths: &[PathBuf], destination: &Path) -> Result<usize, String> {
    let plan = plan_external_assets_import(paths, destination)?;
    commit_external_assets_import(&plan)
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAssetImportEntry {
    pub source: PathBuf,
    pub source_files: Vec<(PathBuf, AssetSourceFingerprint)>,
    pub targets: Vec<PathBuf>,
    pub adopt_in_place: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAssetImportPlan {
    pub project_root: PathBuf,
    pub destination: PathBuf,
    pub entries: Vec<ExternalAssetImportEntry>,
    pub conflicts: Vec<String>,
    /// Reviewed editor registry bytes. Import refuses publication if another process changes them.
    pub registry_snapshot: Option<Vec<u8>>,
    /// Reviewed stripped runtime catalog bytes, guarded independently from the source registry.
    pub runtime_catalog_snapshot: Option<Vec<u8>>,
}

impl ExternalAssetImportPlan {
    pub fn imported_count(&self) -> usize {
        self.entries.len()
    }

    pub fn can_commit(&self) -> bool {
        self.conflicts.is_empty()
    }
}

pub fn plan_external_assets_import(
    paths: &[PathBuf],
    destination: &Path,
) -> Result<ExternalAssetImportPlan, String> {
    plan_external_assets_import_at(Path::new("."), paths, destination)
}

pub fn plan_external_assets_import_at(
    project_root: &Path,
    paths: &[PathBuf],
    destination: &Path,
) -> Result<ExternalAssetImportPlan, String> {
    crate::validate_asset_path(destination)?;
    if destination
        .components()
        .any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
    {
        return Err("imports cannot target hidden or engine-managed directories".into());
    }
    let assets_root = fs::canonicalize(project_root.join(crate::ASSETS_DIRECTORY))
        .map_err(|error| format!("could not resolve project assets root: {error}"))?;
    let destination_absolute = project_root.join(destination);
    let mut entries = Vec::new();
    let mut planned_targets = BTreeSet::new();
    let mut conflicts = Vec::new();
    for source in paths {
        let canonical_source = fs::canonicalize(source)
            .map_err(|error| format!("could not resolve import {}: {error}", source.display()))?;
        let source_fingerprint = fingerprint_file(&canonical_source)?;
        if canonical_source.starts_with(&assets_root) {
            let target = canonical_source
                .strip_prefix(project_root)
                .unwrap_or(&canonical_source)
                .to_path_buf();
            entries.push(ExternalAssetImportEntry {
                source: source.clone(),
                source_files: vec![(canonical_source.clone(), source_fingerprint)],
                targets: vec![target],
                adopt_in_place: true,
            });
            continue;
        }
        let absolute_targets = planned_import_targets(source, &destination_absolute)?;
        let mut targets = Vec::with_capacity(absolute_targets.len());
        for absolute_target in &absolute_targets {
            let target = absolute_target
                .strip_prefix(project_root)
                .map_err(|_| {
                    format!(
                        "planned target {} escaped project root {}",
                        absolute_target.display(),
                        project_root.display()
                    )
                })?
                .to_path_buf();
            if absolute_target.exists() {
                conflicts.push(format!("{} already exists", target.display()));
            }
            if !planned_targets.insert(target.clone()) {
                conflicts.push(format!("multiple sources target {}", target.display()));
            }
            targets.push(target);
        }
        let source_parent = source.parent().unwrap_or_else(|| Path::new("."));
        let mut source_files = Vec::with_capacity(targets.len());
        for target in &targets {
            let relative = target.strip_prefix(destination).map_err(|_| {
                format!(
                    "planned target {} escaped destination {}",
                    target.display(),
                    destination.display()
                )
            })?;
            let dependency = source_parent.join(relative);
            source_files.push((dependency.clone(), fingerprint_file(&dependency)?));
        }
        entries.push(ExternalAssetImportEntry {
            source: source.clone(),
            source_files,
            targets,
            adopt_in_place: false,
        });
    }
    conflicts.sort();
    conflicts.dedup();
    Ok(ExternalAssetImportPlan {
        project_root: project_root.to_path_buf(),
        destination: destination.to_path_buf(),
        entries,
        conflicts,
        registry_snapshot: fs::read(project_root.join(crate::REGISTRY_PATH)).ok(),
        runtime_catalog_snapshot: fs::read(project_root.join(crate::RUNTIME_CATALOG_PATH)).ok(),
    })
}

pub fn commit_external_assets_import(plan: &ExternalAssetImportPlan) -> Result<usize, String> {
    if !plan.can_commit() {
        return Err(format!(
            "import has unresolved conflicts: {}",
            plan.conflicts.join("; ")
        ));
    }
    if fs::read(plan.project_root.join(crate::REGISTRY_PATH))
        .ok()
        .as_deref()
        != plan.registry_snapshot.as_deref()
    {
        return Err("asset registry changed after import review".into());
    }
    if fs::read(plan.project_root.join(crate::RUNTIME_CATALOG_PATH))
        .ok()
        .as_deref()
        != plan.runtime_catalog_snapshot.as_deref()
    {
        return Err("runtime content catalog changed after import review".into());
    }
    let destination = plan.project_root.join(&plan.destination);
    for entry in &plan.entries {
        for (source, reviewed) in &entry.source_files {
            let current = fingerprint_file(source)?;
            if &current != reviewed {
                return Err(format!(
                    "import source {} changed after review",
                    source.display()
                ));
            }
        }
        if !entry.adopt_in_place {
            for target in &entry.targets {
                if plan.project_root.join(target).exists() {
                    return Err(format!(
                        "import target {} changed after review",
                        target.display()
                    ));
                }
            }
        }
    }
    let planned_targets = plan
        .entries
        .iter()
        .filter(|entry| !entry.adopt_in_place)
        .flat_map(|entry| {
            entry
                .targets
                .iter()
                .map(|target| plan.project_root.join(target))
        })
        .collect::<BTreeSet<_>>();

    let mut published = Vec::new();
    let mut created_directories = BTreeSet::new();
    for target in &planned_targets {
        let mut parent = target.parent();
        while let Some(directory) = parent {
            if directory.exists() || !directory.starts_with(&destination) {
                break;
            }
            created_directories.insert(directory.to_path_buf());
            parent = directory.parent();
        }
    }
    let result: Result<(), String> = (|| {
        for entry in plan.entries.iter().filter(|entry| !entry.adopt_in_place) {
            let source = &entry.source;
            let targets = &entry.targets;
            let extension = source
                .extension()
                .and_then(|extension| extension.to_str())
                .unwrap_or_default();
            if extension.eq_ignore_ascii_case("fbx") {
                copy_fbx_bundle(source, &destination)?;
                published.extend(targets.iter().map(|target| plan.project_root.join(target)));
                continue;
            }
            if extension.eq_ignore_ascii_case("gltf") {
                copy_gltf_bundle(source, &destination)?;
                published.extend(targets.iter().map(|target| plan.project_root.join(target)));
                continue;
            }
            fs::create_dir_all(&destination)
                .map_err(|err| format!("could not create {}: {err}", destination.display()))?;
            let dest = plan.project_root.join(
                targets
                    .first()
                    .ok_or_else(|| format!("import {} produced no target", source.display()))?,
            );
            copy_file_create_new(source, &dest).map_err(|err| {
                format!(
                    "could not copy {} to {}: {err}",
                    source.display(),
                    dest.display()
                )
            })?;
            published.push(dest);
        }
        Ok(())
    })();
    if let Err(error) = result {
        for target in published.into_iter().rev() {
            let _ = fs::remove_file(target);
        }
        let mut created_directories = created_directories.into_iter().collect::<Vec<_>>();
        created_directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
        for directory in created_directories {
            let _ = fs::remove_dir(directory);
        }
        return Err(format!("import batch rolled back: {error}"));
    }
    Ok(plan.imported_count())
}

/// Removes only files published by a reviewed external import. In-project adoption is never
/// removed. Empty directories created beneath the selected destination are cleaned deepest-first.
pub fn rollback_external_assets_import(plan: &ExternalAssetImportPlan) -> Result<(), String> {
    let destination = plan.project_root.join(&plan.destination);
    let mut failures = Vec::new();
    let mut directories = BTreeSet::new();
    let mut targets = plan
        .entries
        .iter()
        .filter(|entry| !entry.adopt_in_place)
        .flat_map(|entry| entry.targets.iter())
        .map(|target| plan.project_root.join(target))
        .collect::<Vec<_>>();
    targets.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
    targets.dedup();
    for target in targets {
        if let Some(parent) = target.parent() {
            let mut candidate = Some(parent);
            while let Some(directory) = candidate {
                if !directory.starts_with(&destination) {
                    break;
                }
                directories.insert(directory.to_path_buf());
                candidate = directory.parent();
            }
        }
        if target.exists() {
            let result = if target.is_dir() {
                fs::remove_dir_all(&target)
            } else {
                fs::remove_file(&target)
            };
            if let Err(error) = result {
                failures.push(format!("could not remove {}: {error}", target.display()));
            }
        }
    }
    let mut directories = directories.into_iter().collect::<Vec<_>>();
    directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
    for directory in directories {
        if directory != destination {
            let _ = fs::remove_dir(&directory);
        }
    }
    if failures.is_empty() {
        Ok(())
    } else {
        Err(failures.join("; "))
    }
}

fn copy_file_create_new(source: &Path, destination: &Path) -> io::Result<u64> {
    let mut source = fs::File::open(source)?;
    let mut output = fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(destination)?;
    match io::copy(&mut source, &mut output) {
        Ok(bytes) => Ok(bytes),
        Err(error) => {
            drop(output);
            let _ = fs::remove_file(destination);
            Err(error)
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractedGltfMaterial {
    pub source_index: usize,
    pub source_name: String,
    pub path: PathBuf,
}

#[derive(Debug, Clone)]
pub struct PlannedGltfMaterial {
    pub source_index: usize,
    pub source_name: String,
    pub path: PathBuf,
    pub asset: MaterialAsset,
}

/// Plans editable project Materials for any model source format whose PBR material contract is
/// understood by the importer. Embedded texture payloads remain source-owned; scalar values and
/// safe external texture references are still converted.
pub fn plan_model_material_extraction(
    source: &Path,
    destination: &Path,
) -> Result<Vec<PlannedGltfMaterial>, String> {
    match source
        .extension()
        .and_then(|extension| extension.to_str())
        .map(str::to_ascii_lowercase)
        .as_deref()
    {
        Some("gltf" | "glb") => plan_gltf_material_extraction(source, destination),
        Some("fbx") => plan_fbx_material_extraction(source, destination),
        Some(extension) => Err(format!(
            "material extraction does not support model format `{extension}`"
        )),
        None => Err("model source has no file extension".into()),
    }
}

pub fn plan_gltf_material_extraction(
    source: &Path,
    destination: &Path,
) -> Result<Vec<PlannedGltfMaterial>, String> {
    let document = gltf::Gltf::open(source)
        .map_err(|error| format!("could not parse glTF {}: {error}", source.display()))?;
    let source_fingerprint = blake3::hash(
        &fs::read(source)
            .map_err(|error| format!("could not fingerprint {}: {error}", source.display()))?,
    )
    .to_hex()
    .to_string();
    crate::validate_asset_path(destination)?;
    let mut planned = Vec::new();
    let mut reserved = BTreeSet::new();
    for material in document.materials() {
        let index = material.index().unwrap_or(planned.len());
        let source_name = material
            .name()
            .map(str::to_string)
            .unwrap_or_else(|| format!("Material {index}"));
        let pbr = material.pbr_metallic_roughness();
        let color = pbr.base_color_factor();
        let emissive = material.emissive_factor();
        let parent = source.parent().unwrap_or_else(|| Path::new(""));
        let material_desc = MaterialDesc {
            base_color: ColorDesc::srgb(color[0], color[1], color[2]),
            metallic: pbr.metallic_factor(),
            roughness: pbr.roughness_factor(),
            emissive_color: ColorDesc::srgb(emissive[0], emissive[1], emissive[2]),
            emissive_intensity: if emissive == [0.0, 0.0, 0.0] {
                0.0
            } else {
                1.0
            },
            base_color_texture: pbr
                .base_color_texture()
                .and_then(|texture| gltf_texture_path(parent, texture.texture())),
            normal_map_texture: material
                .normal_texture()
                .and_then(|texture| gltf_texture_path(parent, texture.texture())),
            metallic_roughness_texture: pbr
                .metallic_roughness_texture()
                .and_then(|texture| gltf_texture_path(parent, texture.texture())),
            emissive_texture: material
                .emissive_texture()
                .and_then(|texture| gltf_texture_path(parent, texture.texture())),
            ..Default::default()
        };
        let asset = MaterialAsset {
            schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
            label: source_name.clone(),
            shader: material_desc.shader.clone(),
            shader_ref: None,
            render_state: MaterialRenderState {
                alpha_mode: match material.alpha_mode() {
                    gltf::material::AlphaMode::Mask => MaterialAlphaMode::Cutout,
                    _ => MaterialAlphaMode::Opaque,
                },
                alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5),
                double_sided: material.double_sided(),
            },
            provenance: Some(shared::MaterialProvenance {
                source_path: source.to_string_lossy().replace('\\', "/"),
                source_fingerprint: source_fingerprint.clone(),
                source_sub_asset_id: format!("material:{index}"),
                source_label: source_name.clone(),
            }),
            inputs: shared::MaterialInputSet::from_material_desc(&material_desc),
        };
        let stem = safe_material_stem(&source_name);
        let path = unique_material_path(destination, &stem, &mut reserved);
        planned.push(PlannedGltfMaterial {
            source_index: index,
            source_name,
            path,
            asset,
        });
    }
    Ok(planned)
}

pub fn plan_fbx_material_extraction(
    source: &Path,
    destination: &Path,
) -> Result<Vec<PlannedGltfMaterial>, String> {
    let bytes = fs::read(source)
        .map_err(|error| format!("could not read FBX {}: {error}", source.display()))?;
    let filename_hint = source.to_string_lossy();
    let scene = ufbx::load_memory(
        &bytes,
        ufbx::LoadOpts {
            target_unit_meters: 1.0,
            target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
            filename: ufbx::StringOpt::Ref(&filename_hint),
            ..Default::default()
        },
    )
    .map_err(|error| format!("could not parse FBX {}: {error:?}", source.display()))?;
    crate::validate_asset_path(destination)?;
    let source_fingerprint = blake3::hash(&bytes).to_hex().to_string();
    let parent = source.parent().unwrap_or_else(|| Path::new(""));
    let mut texture_paths = BTreeMap::new();
    for (index, texture) in scene.textures.as_ref().iter().enumerate() {
        let reference = external_texture_reference(index, texture)
            .map_err(|error| format!("unsafe FBX texture reference: {error}"))?;
        if let Some(reference) = reference {
            texture_paths.insert(
                reference.element_id,
                parent
                    .join(reference.relative_path)
                    .to_string_lossy()
                    .replace('\\', "/"),
            );
        }
    }

    let mut planned = Vec::new();
    let mut reserved = BTreeSet::new();
    for (index, material) in scene.materials.as_ref().iter().enumerate() {
        if material.element.element_id == 0 {
            continue;
        }
        let source_name = if material.element.name.is_empty() {
            format!("Material {index}")
        } else {
            material.element.name.to_string()
        };
        let base_color = if material.pbr.base_color.has_value {
            material.pbr.base_color.value_vec4
        } else {
            material.fbx.diffuse_color.value_vec4
        };
        let emission = if material.pbr.emission_color.has_value {
            material.pbr.emission_color.value_vec4
        } else {
            material.fbx.emission_color.value_vec4
        };
        let metallic = if material.pbr.metalness.has_value {
            material.pbr.metalness.value_vec4.x as f32
        } else {
            0.0
        };
        let roughness = if material.pbr.roughness.has_value {
            material.pbr.roughness.value_vec4.x as f32
        } else {
            MaterialDesc::default().roughness
        };
        let mut material_desc = MaterialDesc {
            base_color: ColorDesc::srgb(
                base_color.x as f32,
                base_color.y as f32,
                base_color.z as f32,
            ),
            metallic,
            roughness,
            emissive_color: ColorDesc::srgb(
                emission.x as f32,
                emission.y as f32,
                emission.z as f32,
            ),
            emissive_intensity: if emission.x == 0.0 && emission.y == 0.0 && emission.z == 0.0 {
                0.0
            } else {
                1.0
            },
            ..Default::default()
        };
        for texture in &material.textures {
            let Some(path) = texture_paths.get(&texture.texture.element.element_id) else {
                continue;
            };
            match texture.material_prop.as_ref() {
                "DiffuseColor" | "BaseColor" => {
                    material_desc.base_color_texture = Some(path.clone())
                }
                "NormalMap" => material_desc.normal_map_texture = Some(path.clone()),
                "Metallic" => material_desc.metallic_roughness_texture = Some(path.clone()),
                "Roughness" if material_desc.metallic_roughness_texture.is_none() => {
                    material_desc.metallic_roughness_texture = Some(path.clone())
                }
                "EmissiveColor" => material_desc.emissive_texture = Some(path.clone()),
                _ => {}
            }
        }
        let asset = MaterialAsset {
            schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
            label: source_name.clone(),
            shader: material_desc.shader.clone(),
            shader_ref: None,
            render_state: MaterialRenderState {
                // MaterialAsset currently has no blended mode. Preserve visibility and report the
                // converted state as opaque instead of pretending a lossy blend is exact.
                alpha_mode: MaterialAlphaMode::Opaque,
                alpha_cutoff: 0.5,
                double_sided: material.features.double_sided.enabled,
            },
            provenance: Some(shared::MaterialProvenance {
                source_path: source.to_string_lossy().replace('\\', "/"),
                source_fingerprint: source_fingerprint.clone(),
                source_sub_asset_id: format!("material:{index}"),
                source_label: source_name.clone(),
            }),
            inputs: shared::MaterialInputSet::from_material_desc(&material_desc),
        };
        let stem = safe_material_stem(&source_name);
        let path = unique_material_path(destination, &stem, &mut reserved);
        planned.push(PlannedGltfMaterial {
            source_index: index,
            source_name,
            path,
            asset,
        });
    }
    Ok(planned)
}

/// Converts supported glTF metallic/roughness materials into ordinary editable project assets.
/// Existing files are never overwritten; deterministic numeric suffixes create a new revision.
pub fn extract_gltf_materials(
    source: &Path,
    destination: &Path,
) -> Result<Vec<ExtractedGltfMaterial>, String> {
    let planned = plan_gltf_material_extraction(source, destination)?;
    fs::create_dir_all(destination)
        .map_err(|error| format!("could not create {}: {error}", destination.display()))?;
    let mut extracted = Vec::new();
    let mut published = Vec::new();
    let result: Result<(), String> = (|| {
        for material in planned {
            let bytes =
                ron::ser::to_string_pretty(&material.asset, ron::ser::PrettyConfig::default())
                    .map_err(|error| format!("could not serialize extracted material: {error}"))?;
            fs::write(&material.path, format!("{bytes}\n")).map_err(|error| {
                format!("could not publish {}: {error}", material.path.display())
            })?;
            published.push(material.path.clone());
            extracted.push(ExtractedGltfMaterial {
                source_index: material.source_index,
                source_name: material.source_name,
                path: material.path,
            });
        }
        Ok(())
    })();
    if let Err(error) = result {
        for path in published {
            let _ = fs::remove_file(path);
        }
        return Err(error);
    }
    Ok(extracted)
}

fn gltf_texture_path(parent: &Path, texture: gltf::Texture<'_>) -> Option<String> {
    let gltf::image::Source::Uri { uri, .. } = texture.source().source() else {
        return None;
    };
    let relative = decode_gltf_uri_path(uri).ok()?;
    Some(parent.join(relative).to_string_lossy().replace('\\', "/"))
}

fn safe_material_stem(label: &str) -> String {
    let stem = label
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
                character
            } else {
                '_'
            }
        })
        .collect::<String>();
    let stem = stem.trim_matches('_');
    if stem.is_empty() {
        "material".into()
    } else {
        stem.into()
    }
}

fn unique_material_path(
    destination: &Path,
    stem: &str,
    reserved: &mut BTreeSet<PathBuf>,
) -> PathBuf {
    (1..)
        .map(|suffix| {
            if suffix == 1 {
                destination.join(format!("{stem}.material.ron"))
            } else {
                destination.join(format!("{stem}_{suffix}.material.ron"))
            }
        })
        .find(|candidate| !candidate.exists() && reserved.insert(candidate.clone()))
        .expect("numeric material suffix space is effectively unbounded")
}

/// Finds an editable Material previously extracted from one stable source-material slot.
pub fn find_matching_extracted_material(
    destination: &Path,
    source: &Path,
    source_sub_asset_id: &str,
) -> Option<PathBuf> {
    let normalized_source = source.to_string_lossy().replace('\\', "/");
    let mut existing = fs::read_dir(destination)
        .ok()
        .into_iter()
        .flatten()
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| path.is_file())
        .filter_map(|path| {
            let asset = MaterialAsset::load_from_path(path.to_string_lossy().as_ref()).ok()?;
            let provenance = asset.provenance.as_ref()?;
            (provenance.source_path == normalized_source
                && provenance.source_sub_asset_id == source_sub_asset_id)
                .then_some(path)
        })
        .collect::<Vec<_>>();
    existing.sort();
    existing.into_iter().next()
}

fn decode_gltf_uri_path(uri: &str) -> Result<PathBuf, String> {
    if uri.starts_with("data:") {
        return Err("embedded glTF data URI is not an external dependency".into());
    }
    if uri.contains(['?', '#']) || uri.contains('\\') {
        return Err(format!(
            "glTF dependency URI {uri:?} is not a safe file path"
        ));
    }
    let bytes = uri.as_bytes();
    let mut decoded = Vec::with_capacity(bytes.len());
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'%' {
            let Some(pair) = bytes.get(index + 1..index + 3) else {
                return Err(format!(
                    "glTF dependency URI {uri:?} has invalid percent encoding"
                ));
            };
            let text = std::str::from_utf8(pair)
                .map_err(|_| format!("glTF dependency URI {uri:?} has invalid percent encoding"))?;
            decoded.push(u8::from_str_radix(text, 16).map_err(|_| {
                format!("glTF dependency URI {uri:?} has invalid percent encoding")
            })?);
            index += 3;
        } else {
            decoded.push(bytes[index]);
            index += 1;
        }
    }
    let decoded = String::from_utf8(decoded)
        .map_err(|_| format!("glTF dependency URI {uri:?} is not UTF-8"))?;
    Ok(PathBuf::from(decoded))
}

fn copy_bundle_transactionally(
    entries: &BTreeMap<PathBuf, PathBuf>,
    dest_dir: &Path,
) -> Result<(), String> {
    fs::create_dir_all(dest_dir)
        .map_err(|error| format!("could not create {}: {error}", dest_dir.display()))?;
    let canonical_dest_dir = fs::canonicalize(dest_dir).map_err(|error| {
        format!(
            "could not resolve import destination {}: {error}",
            dest_dir.display()
        )
    })?;
    for relative in entries.keys() {
        validate_bundle_relative_path(relative)?;
        validate_existing_destination_parents(dest_dir, &canonical_dest_dir, relative)?;
        let target = dest_dir.join(relative);
        if target.is_dir() {
            return Err(format!(
                "import target {} is an existing directory",
                target.display()
            ));
        }
    }
    let collisions = entries
        .keys()
        .map(|relative| dest_dir.join(relative))
        .filter(|target| target.exists())
        .collect::<Vec<_>>();
    if !collisions.is_empty() {
        return Err(format!(
            "import collision(s): {}",
            collisions
                .iter()
                .map(|path| path.display().to_string())
                .collect::<Vec<_>>()
                .join(", ")
        ));
    }

    let stage_root = dest_dir.join(format!(".blacksite-import-{}", uuid::Uuid::new_v4()));
    let staged_root = stage_root.join("new");
    let stage_result = (|| {
        for (relative, source) in entries {
            let staged = staged_root.join(relative);
            if let Some(parent) = staged.parent() {
                fs::create_dir_all(parent).map_err(|error| {
                    format!(
                        "could not create import staging {}: {error}",
                        parent.display()
                    )
                })?;
            }
            fs::copy(source, &staged).map_err(|error| {
                format!(
                    "could not stage bundle file {} as {}: {error}",
                    source.display(),
                    relative.display()
                )
            })?;
        }
        commit_staged_bundle(entries, dest_dir, &canonical_dest_dir, &staged_root)
    })();
    let cleanup_result = fs::remove_dir_all(&stage_root);
    match (stage_result, cleanup_result) {
        (Err(error), _) => Err(error),
        (Ok(()), Ok(())) => Ok(()),
        (Ok(()), Err(error)) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        // The transaction is already committed at this point. A stale hidden staging directory is
        // safe to leave for later cleanup and must not turn a successful import into a false
        // rollback report.
        (Ok(()), Err(_)) => Ok(()),
    }
}

fn validate_bundle_relative_path(relative: &Path) -> Result<(), String> {
    if relative.as_os_str().is_empty()
        || !relative
            .components()
            .all(|component| matches!(component, Component::Normal(_)))
    {
        return Err(format!(
            "import target path {} is not a safe relative path",
            relative.display()
        ));
    }
    Ok(())
}

fn validate_existing_destination_parents(
    dest_dir: &Path,
    canonical_dest_dir: &Path,
    relative: &Path,
) -> Result<(), String> {
    let mut current = dest_dir.to_path_buf();
    let Some(parent) = relative.parent() else {
        return Ok(());
    };
    for component in parent.components() {
        let Component::Normal(segment) = component else {
            return Err(format!(
                "import target path {} is not a safe relative path",
                relative.display()
            ));
        };
        current.push(segment);
        let metadata = match fs::symlink_metadata(&current) {
            Ok(metadata) => metadata,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
            Err(error) => {
                return Err(format!(
                    "could not inspect import target {}: {error}",
                    current.display()
                ));
            }
        };
        if metadata.file_type().is_symlink() {
            return Err(format!(
                "import target parent {} is a symbolic link",
                current.display()
            ));
        }
        if !metadata.is_dir() {
            return Err(format!(
                "import target parent {} is not a directory",
                current.display()
            ));
        }
        let canonical = fs::canonicalize(&current).map_err(|error| {
            format!(
                "could not resolve import target parent {}: {error}",
                current.display()
            )
        })?;
        if !canonical.starts_with(canonical_dest_dir) {
            return Err(format!(
                "import target parent {} resolves outside destination {}",
                current.display(),
                dest_dir.display()
            ));
        }
    }
    Ok(())
}

fn commit_staged_bundle(
    entries: &BTreeMap<PathBuf, PathBuf>,
    dest_dir: &Path,
    canonical_dest_dir: &Path,
    staged_root: &Path,
) -> Result<(), String> {
    let mut installed = Vec::new();
    let backups = Vec::new();
    for relative in entries.keys() {
        let target = dest_dir.join(relative);
        let staged = staged_root.join(relative);
        if let Some(parent) = target.parent() {
            if let Err(error) = fs::create_dir_all(parent) {
                return rollback_bundle(
                    &installed,
                    &backups,
                    format!(
                        "could not create import target {}: {error}",
                        parent.display()
                    ),
                );
            }
            let canonical_parent = match fs::canonicalize(parent) {
                Ok(canonical_parent) => canonical_parent,
                Err(error) => {
                    return rollback_bundle(
                        &installed,
                        &backups,
                        format!(
                            "could not resolve import target {}: {error}",
                            parent.display()
                        ),
                    );
                }
            };
            if !canonical_parent.starts_with(canonical_dest_dir) {
                return rollback_bundle(
                    &installed,
                    &backups,
                    format!(
                        "import target {} resolves outside destination {}",
                        parent.display(),
                        dest_dir.display()
                    ),
                );
            }
        }
        if let Err(error) = fs::rename(&staged, &target) {
            return rollback_bundle(
                &installed,
                &backups,
                format!(
                    "could not publish import target {}: {error}",
                    target.display()
                ),
            );
        }
        installed.push(target);
    }
    Ok(())
}

fn rollback_bundle(
    installed: &[PathBuf],
    backups: &[(PathBuf, PathBuf)],
    cause: String,
) -> Result<(), String> {
    let mut rollback_errors = Vec::new();
    for target in installed.iter().rev() {
        if let Err(error) = fs::remove_file(target) {
            if error.kind() != std::io::ErrorKind::NotFound {
                rollback_errors.push(format!("remove {}: {error}", target.display()));
            }
        }
    }
    for (backup, target) in backups.iter().rev() {
        if let Err(error) = fs::rename(backup, target) {
            rollback_errors.push(format!(
                "restore {} to {}: {error}",
                backup.display(),
                target.display()
            ));
        }
    }
    if rollback_errors.is_empty() {
        Err(cause)
    } else {
        Err(format!(
            "{cause}; rollback also failed: {}",
            rollback_errors.join("; ")
        ))
    }
}

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

    fn temp_root(label: &str) -> PathBuf {
        std::env::temp_dir().join(format!("blacksite-fbx-{label}-{}", uuid::Uuid::new_v4()))
    }

    fn committed_chair_bytes() -> Vec<u8> {
        fs::read(
            Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("../../assets/models/painted_wooden_chair_02_2k.fbx"),
        )
        .unwrap()
    }

    fn replace_equal_length(bytes: &mut [u8], from: &[u8], to: &[u8]) -> usize {
        assert_eq!(from.len(), to.len());
        let mut count = 0;
        let mut offset = 0;
        while let Some(index) = bytes[offset..]
            .windows(from.len())
            .position(|window| window == from)
        {
            let start = offset + index;
            bytes[start..start + from.len()].copy_from_slice(to);
            offset = start + from.len();
            count += 1;
        }
        count
    }

    fn write_textures(root: &Path, folder: &str) {
        let folder = root.join(folder);
        fs::create_dir_all(&folder).unwrap();
        for name in [
            "painted_wooden_chair_02_diff_2k.jpg",
            "painted_wooden_chair_02_nor_gl_2k.exr",
            "painted_wooden_chair_02_rough_2k.exr",
        ] {
            fs::write(folder.join(name), name.as_bytes()).unwrap();
        }
    }

    #[test]
    fn committed_chair_reports_one_stable_missing_dependency_state() {
        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../assets/models/painted_wooden_chair_02_2k.fbx");

        let error = validate_fbx_dependencies(&path).unwrap_err();

        assert!(error.starts_with("missing 3 FBX source texture(s):"));
        assert_eq!(error.matches("painted_wooden_chair_02_").count(), 3);
    }

    #[test]
    fn sibling_texture_bundle_is_copied_with_relative_layout() {
        let root = temp_root("sibling");
        let source_root = root.join("source");
        let destination = root.join("destination");
        fs::create_dir_all(&source_root).unwrap();
        let source = source_root.join("chair.fbx");
        fs::write(&source, committed_chair_bytes()).unwrap();
        write_textures(&source_root, "textures");

        copy_fbx_bundle(&source, &destination).unwrap();

        assert!(destination.join("chair.fbx").is_file());
        assert!(destination
            .join("textures/painted_wooden_chair_02_diff_2k.jpg")
            .is_file());
        assert!(!destination.read_dir().unwrap().any(|entry| entry
            .unwrap()
            .file_name()
            .to_string_lossy()
            .starts_with(".blacksite-import-")));
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn valid_bundle_collision_never_overwrites_existing_content() {
        let root = temp_root("collision");
        let source_root = root.join("source");
        let destination = root.join("destination");
        fs::create_dir_all(&source_root).unwrap();
        fs::create_dir_all(&destination).unwrap();
        let source = source_root.join("chair.fbx");
        fs::write(&source, committed_chair_bytes()).unwrap();
        write_textures(&source_root, "textures");
        fs::write(destination.join("chair.fbx"), b"authored existing bytes").unwrap();

        let error = copy_fbx_bundle(&source, &destination).unwrap_err();

        assert!(error.contains("import collision"));
        assert_eq!(
            fs::read(destination.join("chair.fbx")).unwrap(),
            b"authored existing bytes"
        );
        assert!(!destination.join("textures").exists());
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn fbm_texture_bundle_is_copied_with_relative_layout() {
        let root = temp_root("fbm");
        let source_root = root.join("source");
        let destination = root.join("destination");
        fs::create_dir_all(&source_root).unwrap();
        let source = source_root.join("chair.fbx");
        let mut bytes = committed_chair_bytes();
        assert!(replace_equal_length(&mut bytes, b"textures/", b"test.fbm/") > 0);
        fs::write(&source, bytes).unwrap();
        write_textures(&source_root, "test.fbm");

        copy_fbx_bundle(&source, &destination).unwrap();

        assert!(destination
            .join("test.fbm/painted_wooden_chair_02_diff_2k.jpg")
            .is_file());
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn traversal_reference_is_rejected_before_destination_changes() {
        let root = temp_root("traversal");
        let source_root = root.join("source");
        let destination = root.join("destination");
        fs::create_dir_all(&source_root).unwrap();
        fs::create_dir_all(&destination).unwrap();
        let source = source_root.join("chair.fbx");
        let mut bytes = committed_chair_bytes();
        assert!(replace_equal_length(&mut bytes, b"textures/", b"../evil//") > 0);
        fs::write(&source, bytes).unwrap();
        fs::write(destination.join("chair.fbx"), b"original").unwrap();

        let error = copy_fbx_bundle(&source, &destination).unwrap_err();

        assert!(error.contains("parent traversal"));
        assert_eq!(
            fs::read(destination.join("chair.fbx")).unwrap(),
            b"original"
        );
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn missing_bundle_preserves_existing_destination() {
        let root = temp_root("missing");
        let source_root = root.join("source");
        let destination = root.join("destination");
        fs::create_dir_all(&source_root).unwrap();
        fs::create_dir_all(&destination).unwrap();
        let source = source_root.join("chair.fbx");
        fs::write(&source, committed_chair_bytes()).unwrap();
        fs::write(destination.join("chair.fbx"), b"original").unwrap();

        let error = copy_fbx_bundle(&source, &destination).unwrap_err();

        assert!(error.contains("missing 3 required texture(s)"));
        assert_eq!(
            fs::read(destination.join("chair.fbx")).unwrap(),
            b"original"
        );
        fs::remove_dir_all(root).unwrap();
    }

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

        let root = temp_root("destination-symlink");
        let source_root = root.join("source");
        let destination = root.join("destination");
        let outside = root.join("outside");
        fs::create_dir_all(&source_root).unwrap();
        fs::create_dir_all(&destination).unwrap();
        fs::create_dir_all(&outside).unwrap();
        let source = source_root.join("chair.fbx");
        fs::write(&source, committed_chair_bytes()).unwrap();
        write_textures(&source_root, "textures");
        symlink(&outside, destination.join("textures")).unwrap();

        let error = copy_fbx_bundle(&source, &destination).unwrap_err();

        assert!(error.contains("symbolic link"));
        assert!(!destination.join("chair.fbx").exists());
        assert!(!outside.join("painted_wooden_chair_02_diff_2k.jpg").exists());
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn gltf_external_dependencies_are_copied_with_relative_layout() {
        let root = temp_root("gltf-bundle");
        let source_root = root.join("source");
        let destination = root.join("destination");
        fs::create_dir_all(source_root.join("textures")).unwrap();
        fs::write(source_root.join("mesh.bin"), [0_u8; 12]).unwrap();
        fs::write(source_root.join("textures/base color.png"), b"png").unwrap();
        fs::write(
            source_root.join("mesh.gltf"),
            r#"{"asset":{"version":"2.0"},"buffers":[{"uri":"mesh.bin","byteLength":12}],"images":[{"uri":"textures/base%20color.png"}]}"#,
        )
        .unwrap();

        copy_gltf_bundle(&source_root.join("mesh.gltf"), &destination).unwrap();

        assert!(destination.join("mesh.gltf").is_file());
        assert!(destination.join("mesh.bin").is_file());
        assert!(destination.join("textures/base color.png").is_file());
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn gltf_traversal_is_rejected_without_destination_changes() {
        let root = temp_root("gltf-traversal");
        let source_root = root.join("source");
        let destination = root.join("destination");
        fs::create_dir_all(&source_root).unwrap();
        fs::create_dir_all(&destination).unwrap();
        fs::write(
            source_root.join("mesh.gltf"),
            r#"{"asset":{"version":"2.0"},"buffers":[{"uri":"../outside.bin","byteLength":4}]}"#,
        )
        .unwrap();
        fs::write(root.join("outside.bin"), [0_u8; 4]).unwrap();

        let error = copy_gltf_bundle(&source_root.join("mesh.gltf"), &destination).unwrap_err();

        assert!(error.contains("safe relative path"));
        assert!(!destination.join("mesh.gltf").exists());
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn gltf_pbr_material_extracts_without_overwriting_edits() {
        let root = temp_root("gltf-material");
        let source_root = root.join("source");
        let destination =
            PathBuf::from("assets").join(format!(".extract-test-{}", uuid::Uuid::new_v4()));
        fs::create_dir_all(&source_root).unwrap();
        fs::write(
            source_root.join("mesh.gltf"),
            r#"{"asset":{"version":"2.0"},"materials":[{"name":"Paint Red","pbrMetallicRoughness":{"baseColorFactor":[0.8,0.1,0.05,1.0],"metallicFactor":0.7,"roughnessFactor":0.3}}]}"#,
        )
        .unwrap();

        let first = extract_gltf_materials(&source_root.join("mesh.gltf"), &destination).unwrap();
        assert_eq!(
            find_matching_extracted_material(
                &destination,
                &source_root.join("mesh.gltf"),
                "material:0",
            )
            .as_deref(),
            Some(first[0].path.as_path())
        );
        let second = extract_gltf_materials(&source_root.join("mesh.gltf"), &destination).unwrap();

        assert_ne!(first[0].path, second[0].path);
        let asset = MaterialAsset::load_from_path(&first[0].path.to_string_lossy()).unwrap();
        assert_eq!(asset.label, "Paint Red");
        let mut material = MaterialDesc::default();
        asset.inputs.apply_to_material_desc(&mut material);
        assert_eq!(material.metallic, 0.7);
        assert_eq!(material.roughness, 0.3);
        fs::remove_dir_all(root).unwrap();
        fs::remove_dir_all(destination).unwrap();
    }

    #[test]
    fn fbx_pbr_material_planning_preserves_provenance_and_external_textures() {
        let root = temp_root("fbx-material");
        let source_root = root.join("source");
        let destination =
            PathBuf::from("assets").join(format!(".extract-test-{}", uuid::Uuid::new_v4()));
        fs::create_dir_all(&source_root).unwrap();
        let source = source_root.join("chair.fbx");
        fs::write(&source, committed_chair_bytes()).unwrap();

        let planned = plan_model_material_extraction(&source, &destination).unwrap();

        assert!(!planned.is_empty());
        assert!(planned
            .iter()
            .all(|entry| entry.path.starts_with(&destination)));
        assert!(planned.iter().all(|entry| entry
            .asset
            .provenance
            .as_ref()
            .is_some_and(|provenance| provenance.source_path.ends_with("chair.fbx")
                && provenance.source_sub_asset_id.starts_with("material:"))));
        assert!(planned
            .iter()
            .any(|entry| !entry.asset.inputs.textures.is_empty()));
        assert!(!destination.exists());
        fs::remove_dir_all(root).unwrap();
    }
}
