//! UI-independent project content discovery and publication.

use serde::{Deserialize, Serialize};
use shared::{
    AssetKind, AssetRecord, AssetRegistryDocument, AssetSourceFingerprint, ComponentInstanceId,
    MaterialImportPolicy, ModelMaterialSelection, ModelMaterialSlotSelection,
    RuntimeContentCatalog,
};
use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fs;
use std::path::{Component, Path, PathBuf};
use walkdir::{DirEntry, WalkDir};

pub mod animation;
mod fingerprint;
mod import;
mod pbr_grouping;
pub mod static_mesh;
mod texture;
mod transaction;
mod trash;
mod watcher;
pub use fingerprint::*;
pub use import::*;
pub use pbr_grouping::*;
pub use texture::*;
pub use transaction::*;
pub use trash::*;
pub use watcher::*;

pub const ASSETS_DIRECTORY: &str = "assets";
pub const REGISTRY_PATH: &str = "assets/.index/registry.ron";
pub const RUNTIME_CATALOG_PATH: &str = "assets/content.catalog.ron";

const MANAGED_DIRECTORIES: &[&str] = &[".index", ".thumbnails", ".trash", ".import-cache"];
const MANAGED_PATH_PREFIXES: &[&str] = &[
    "meshes/generated",
    "animations/generated",
    "navigation/generated",
];

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContentDiagnostic {
    pub path: String,
    pub message: String,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProcessingReport {
    pub discovered: usize,
    pub added: usize,
    pub moved: usize,
    pub removed: usize,
    pub changed: bool,
    pub diagnostics: Vec<ContentDiagnostic>,
}

#[derive(Debug, Clone)]
pub struct ProcessedContent {
    pub registry: AssetRegistryDocument,
    pub runtime_catalog: RuntimeContentCatalog,
    pub report: ProcessingReport,
}

/// One imported file whose current bytes match more than one missing registry record. Automatic
/// reconciliation must stop so the editor can ask which stable identity actually moved.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalMoveConflict {
    pub new_path: String,
    pub candidate_paths: Vec<String>,
    pub kind: AssetKind,
    pub fingerprint: AssetSourceFingerprint,
}

pub fn find_ambiguous_external_moves(
    project_root: &Path,
    previous: &AssetRegistryDocument,
) -> Result<Vec<ExternalMoveConflict>, String> {
    let assets_root = project_root.join(ASSETS_DIRECTORY);
    if !assets_root.is_dir() {
        return Err(format!(
            "project has no assets directory: {}",
            assets_root.display()
        ));
    }
    let mut discovered = Vec::new();
    for entry in WalkDir::new(&assets_root)
        .follow_links(false)
        .into_iter()
        .filter_entry(|entry| should_descend(entry, &assets_root))
    {
        let entry = entry.map_err(|error| format!("content scan failed: {error}"))?;
        if !entry.file_type().is_file() {
            continue;
        }
        let kind = classify_asset_file(entry.path());
        if !kind.is_imported_source() {
            continue;
        }
        let relative = entry
            .path()
            .strip_prefix(project_root)
            .map_err(|error| format!("content path escaped project: {error}"))?;
        let path = relative.to_string_lossy().replace('\\', "/");
        let bytes = fs::read(entry.path())
            .map_err(|error| format!("could not fingerprint {path}: {error}"))?;
        discovered.push((path, kind, AssetSourceFingerprint::from_bytes(&bytes)));
    }
    let current_paths = discovered
        .iter()
        .map(|(path, _, _)| path.clone())
        .collect::<HashSet<_>>();
    let previous_paths = previous
        .records
        .iter()
        .map(|record| record.path.as_str())
        .collect::<HashSet<_>>();
    let mut conflicts = Vec::new();
    for (new_path, kind, fingerprint) in discovered {
        if previous_paths.contains(new_path.as_str()) {
            continue;
        }
        let mut candidate_paths = previous
            .records
            .iter()
            .filter(|record| {
                !current_paths.contains(record.path.as_str())
                    && record.kind == kind
                    && record.source_fingerprint.as_ref() == Some(&fingerprint)
            })
            .map(|record| record.path.clone())
            .collect::<Vec<_>>();
        candidate_paths.sort();
        candidate_paths.dedup();
        if candidate_paths.len() > 1 {
            conflicts.push(ExternalMoveConflict {
                new_path,
                candidate_paths,
                kind,
                fingerprint,
            });
        }
    }
    conflicts.sort_by(|left, right| left.new_path.cmp(&right.new_path));
    Ok(conflicts)
}

pub fn is_managed_path(relative_to_assets: &Path) -> bool {
    let managed_directory = relative_to_assets
        .components()
        .next()
        .is_some_and(|component| {
            let Component::Normal(name) = component else {
                return false;
            };
            MANAGED_DIRECTORIES
                .iter()
                .any(|managed| name == OsStr::new(managed))
        });
    let normalized = relative_to_assets.to_string_lossy().replace('\\', "/");
    managed_directory
        || MANAGED_PATH_PREFIXES.iter().any(|prefix| {
            normalized == *prefix
                || normalized
                    .strip_prefix(prefix)
                    .is_some_and(|suffix| suffix.starts_with('/'))
        })
}

/// Validates a user-selected project content path without consulting the filesystem.
pub fn validate_asset_path(path: &Path) -> Result<(), String> {
    if path.is_absolute() {
        return Err("content paths must be project-relative".into());
    }
    let mut components = path.components();
    if components.next() != Some(Component::Normal(OsStr::new(ASSETS_DIRECTORY))) {
        return Err("content paths must be located under `assets/`".into());
    }
    let remainder = components.collect::<PathBuf>();
    if remainder.as_os_str().is_empty() {
        return Ok(());
    }
    if remainder.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    }) {
        return Err("content paths cannot escape `assets/`".into());
    }
    if is_managed_path(&remainder) {
        return Err("engine-managed content directories cannot be edited".into());
    }
    Ok(())
}

/// Classifies an asset by its filename/schema suffix rather than its containing folder.
pub fn classify_asset(path: &Path) -> AssetKind {
    let name = path
        .file_name()
        .and_then(OsStr::to_str)
        .unwrap_or_default()
        .to_ascii_lowercase();
    if name.ends_with(".material-instance.ron") {
        return AssetKind::MaterialInstance;
    }
    if name.ends_with(".material.ron") {
        return AssetKind::Material;
    }
    if name.ends_with(".scn.ron") {
        return AssetKind::Level;
    }
    if name.ends_with(".prefab.ron") {
        return AssetKind::Prefab;
    }
    if name.ends_with(".post-process.ron") {
        return AssetKind::PostProcessEffect;
    }
    if name.ends_with(".rendering-profile.ron") {
        return AssetKind::RenderingProfile;
    }
    if name.ends_with(".shader-schema.ron") {
        return AssetKind::ShaderSchema;
    }
    match path
        .extension()
        .and_then(OsStr::to_str)
        .unwrap_or_default()
        .to_ascii_lowercase()
        .as_str()
    {
        "gltf" | "glb" | "fbx" => AssetKind::Model,
        "png" | "jpg" | "jpeg" | "webp" | "ktx2" | "dds" | "tga" | "exr" | "hdr" => {
            AssetKind::Texture
        }
        "ogg" | "oga" | "spx" | "wav" | "mp3" | "flac" => AssetKind::AudioClip,
        "mat" | "material" => AssetKind::Material,
        _ => AssetKind::Unknown,
    }
}

/// Adds schema-based classification for authored RON assets whose filename is user-controlled.
pub fn classify_asset_file(path: &Path) -> AssetKind {
    let path_kind = classify_asset(path);
    if path_kind != AssetKind::Unknown && !path.to_string_lossy().ends_with(".scn.ron") {
        return path_kind;
    }
    if path.extension().and_then(OsStr::to_str) != Some("ron") {
        return path_kind;
    }
    let Ok(source) = fs::read_to_string(path) else {
        return path_kind;
    };
    if ron::from_str::<shared::MaterialInstanceAsset>(&source).is_ok() {
        return AssetKind::MaterialInstance;
    }
    if ron::from_str::<shared::MaterialAsset>(&source).is_ok() {
        return AssetKind::Material;
    }
    if ron::from_str::<shared::ShaderSchemaAsset>(&source).is_ok() {
        return AssetKind::ShaderSchema;
    }
    if ron::from_str::<shared::RenderingProfileAsset>(&source).is_ok() {
        return AssetKind::RenderingProfile;
    }
    if ron::from_str::<shared::PostProcessEffectAsset>(&source).is_ok() {
        return AssetKind::PostProcessEffect;
    }
    path_kind
}

fn should_descend(entry: &DirEntry, assets_root: &Path) -> bool {
    if entry.file_type().is_symlink() {
        return false;
    }
    entry
        .path()
        .strip_prefix(assets_root)
        .ok()
        .is_none_or(|relative| !is_managed_path(relative))
}

pub fn scan_project(
    project_root: &Path,
    previous: &AssetRegistryDocument,
) -> Result<ProcessedContent, String> {
    let assets_root = project_root.join(ASSETS_DIRECTORY);
    if !assets_root.is_dir() {
        return Err(format!(
            "project has no assets directory: {}",
            assets_root.display()
        ));
    }

    let mut discovered = Vec::new();
    let mut diagnostics = Vec::new();
    let previous_kinds = previous
        .records
        .iter()
        .map(|record| (record.path.as_str(), record.kind))
        .collect::<HashMap<_, _>>();
    for entry in WalkDir::new(&assets_root)
        .follow_links(false)
        .into_iter()
        .filter_entry(|entry| should_descend(entry, &assets_root))
    {
        let entry = entry.map_err(|error| format!("content scan failed: {error}"))?;
        if !entry.file_type().is_file() {
            continue;
        }
        let relative = entry
            .path()
            .strip_prefix(project_root)
            .map_err(|error| format!("content path escaped project: {error}"))?;
        let path = relative.to_string_lossy().replace('\\', "/");
        let mut kind = classify_asset_file(entry.path());
        if matches!(kind, AssetKind::Level | AssetKind::Unknown) {
            kind = previous_kinds.get(path.as_str()).copied().unwrap_or(kind);
        }
        if kind == AssetKind::Unknown {
            continue;
        }
        let fingerprint = if kind.is_imported_source() {
            match fs::read(entry.path()) {
                Ok(bytes) => Some(AssetSourceFingerprint::from_bytes(&bytes)),
                Err(error) => {
                    diagnostics.push(ContentDiagnostic {
                        path: path.clone(),
                        message: format!("could not fingerprint imported source: {error}"),
                    });
                    None
                }
            }
        } else {
            None
        };
        discovered.push((path, kind, fingerprint));
    }

    let mut by_path = previous
        .records
        .iter()
        .cloned()
        .map(|record| (record.path.clone(), record))
        .collect::<HashMap<_, _>>();
    let current_paths = discovered
        .iter()
        .map(|(path, _, _)| path.clone())
        .collect::<HashSet<_>>();
    let mut records = Vec::with_capacity(discovered.len());
    let mut report = ProcessingReport {
        discovered: discovered.len(),
        diagnostics,
        ..Default::default()
    };

    for (path, kind, fingerprint) in discovered {
        let label = Path::new(&path)
            .file_stem()
            .and_then(OsStr::to_str)
            .unwrap_or("Asset")
            .to_string();
        let mut record = by_path.remove(&path);
        if record.is_none() {
            if let Some(fingerprint) = fingerprint.as_ref() {
                let mut candidates = by_path
                    .iter()
                    .filter(|(old_path, record)| {
                        !current_paths.contains(*old_path)
                            && record.kind == kind
                            && record.source_fingerprint.as_ref() == Some(fingerprint)
                    })
                    .map(|(old_path, _)| old_path.clone())
                    .collect::<Vec<_>>();
                candidates.sort();
                match candidates.as_slice() {
                    [candidate] => record = by_path.remove(candidate),
                    [] => {}
                    _ => {
                        return Err(format!(
                            "ambiguous external move for {path}: source fingerprint matches {}; resolve the stable identity in the editor Content Browser before refreshing or running headless processing",
                            candidates.join(", ")
                        ));
                    }
                }
            }
        }
        if let Some(existing) = record.as_mut() {
            if existing.path != path {
                report.moved += 1;
            }
            existing.path = path;
            existing.label = label;
            existing.kind = kind;
            existing.source_fingerprint = fingerprint;
        } else {
            report.added += 1;
            record = Some(AssetRecord {
                id: shared::AssetId::new(),
                path,
                label,
                kind,
                source_fingerprint: fingerprint,
                import_settings: shared::AssetImportSettings::for_kind(kind),
                dependencies: Vec::new(),
            });
        }
        let mut record = record.expect("record is initialized");
        migrate_legacy_model_material_policy(project_root, &mut record, &mut report.diagnostics);
        records.push(record);
    }
    report.removed = by_path.len();
    records.sort_by(|left, right| left.path.cmp(&right.path));
    let registry = AssetRegistryDocument {
        schema_version: shared::ASSET_REGISTRY_SCHEMA_VERSION,
        defaults: previous.defaults.clone(),
        records,
    };
    report.changed = &registry != previous;
    let runtime_catalog = RuntimeContentCatalog::from(&registry);
    Ok(ProcessedContent {
        registry,
        runtime_catalog,
        report,
    })
}

#[derive(Deserialize)]
struct ModelManifestSlotView {
    #[serde(default)]
    parts: Vec<ModelManifestPartView>,
}

#[derive(Deserialize)]
struct ModelManifestPartView {
    #[serde(default)]
    id: String,
    #[serde(default)]
    material_id: Option<String>,
    #[serde(default)]
    material_label: Option<String>,
}

fn migrate_legacy_model_material_policy(
    project_root: &Path,
    record: &mut AssetRecord,
    diagnostics: &mut Vec<ContentDiagnostic>,
) {
    if record.kind != AssetKind::Model {
        return;
    }
    if record.model_import().material_policy != MaterialImportPolicy::AuthoringOverride {
        return;
    }
    let Some(manifest_path) = record.model_import().static_mesh_manifest_path.as_deref() else {
        diagnostics.push(ContentDiagnostic {
            path: record.path.clone(),
            message: "legacy Authoring Override needs a regenerated model manifest before per-slot migration"
                .into(),
        });
        return;
    };
    let path = project_root.join(manifest_path);
    let manifest = fs::read_to_string(&path)
        .map_err(|error| format!("could not read {}: {error}", path.display()))
        .and_then(|source| {
            ron::from_str::<ModelManifestSlotView>(&source)
                .map_err(|error| format!("could not parse {}: {error}", path.display()))
        });
    let manifest = match manifest {
        Ok(manifest) => manifest,
        Err(message) => {
            diagnostics.push(ContentDiagnostic {
                path: record.path.clone(),
                message,
            });
            return;
        }
    };
    let mut migrated = record.model_import().material_slots.clone();
    for part in manifest.parts {
        if part.material_id.is_none() && part.material_label.is_none() {
            continue;
        }
        if part.id.trim().is_empty() {
            diagnostics.push(ContentDiagnostic {
                path: record.path.clone(),
                message: "legacy material policy cannot migrate a model part with no stable ID; regenerate its manifest in the editor"
                    .into(),
            });
            return;
        }
        let slot_id = ComponentInstanceId::new(format!("slot:{}", part.id));
        if !migrated
            .iter()
            .any(|selection| selection.slot_id == slot_id)
        {
            migrated.push(ModelMaterialSlotSelection {
                slot_id,
                selection: ModelMaterialSelection::Default,
            });
        }
    }
    migrated.sort_by(|left, right| left.slot_id.0.cmp(&right.slot_id.0));
    record.model_import_mut().material_slots = migrated;
    record.model_import_mut().material_policy = MaterialImportPolicy::SourceMaterials;
}

pub fn serialize_registry(document: &AssetRegistryDocument) -> Result<Vec<u8>, String> {
    ron::ser::to_string_pretty(document, ron::ser::PrettyConfig::default())
        .map(|source| source.into_bytes())
        .map_err(|error| format!("could not serialize asset registry: {error}"))
}

pub fn serialize_runtime_catalog(catalog: &RuntimeContentCatalog) -> Result<Vec<u8>, String> {
    ron::ser::to_string_pretty(catalog, ron::ser::PrettyConfig::default())
        .map(|source| source.into_bytes())
        .map_err(|error| format!("could not serialize runtime catalog: {error}"))
}

/// Publishes the editor registry and stripped runtime catalog as one rollback-capable pair.
pub fn publish_content_documents(
    project_root: &Path,
    document: &AssetRegistryDocument,
) -> Result<(), String> {
    let runtime = RuntimeContentCatalog::from(document);
    publish_content_documents_with_catalog(project_root, document, &runtime)
}

pub fn publish_content_documents_with_catalog(
    project_root: &Path,
    document: &AssetRegistryDocument,
    runtime: &RuntimeContentCatalog,
) -> Result<(), String> {
    let registry_path = project_root.join(REGISTRY_PATH);
    let runtime_path = project_root.join(RUNTIME_CATALOG_PATH);
    let original_registry = fs::read(&registry_path).ok();
    let original_runtime = fs::read(&runtime_path).ok();
    let registry_bytes = serialize_registry(document)?;
    let runtime_bytes = serialize_runtime_catalog(runtime)?;
    if let Err(error) = write_if_changed(&registry_path, &registry_bytes)
        .and_then(|_| write_if_changed(&runtime_path, &runtime_bytes))
    {
        restore_published_file(&registry_path, original_registry.as_deref());
        restore_published_file(&runtime_path, original_runtime.as_deref());
        return Err(format!("content catalog publication rolled back: {error}"));
    }
    Ok(())
}

fn restore_published_file(path: &Path, bytes: Option<&[u8]>) {
    if let Some(bytes) = bytes {
        let _ = fs::write(path, bytes);
    } else {
        let _ = fs::remove_file(path);
    }
}

pub fn write_if_changed(path: &Path, bytes: &[u8]) -> Result<bool, String> {
    if fs::read(path).ok().as_deref() == Some(bytes) {
        return Ok(false);
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|error| format!("could not create {}: {error}", parent.display()))?;
    }
    let temporary = path.with_extension("tmp");
    fs::write(&temporary, bytes)
        .map_err(|error| format!("could not stage {}: {error}", path.display()))?;
    fs::rename(&temporary, path)
        .map_err(|error| format!("could not publish {}: {error}", path.display()))?;
    Ok(true)
}

#[derive(Debug, Clone)]
pub struct PlannedModelArtifacts {
    pub record: shared::AssetRecord,
    pub static_manifest: static_mesh::StaticMeshManifest,
    pub static_path: std::path::PathBuf,
    pub static_bytes: Vec<u8>,
    pub animation_path: std::path::PathBuf,
    pub animation_bytes: Vec<u8>,
}

pub fn plan_model_artifacts(record: &shared::AssetRecord) -> Result<PlannedModelArtifacts, String> {
    plan_model_artifacts_at(std::path::Path::new("."), record)
}

pub fn plan_model_artifacts_at(
    project_root: &std::path::Path,
    record: &shared::AssetRecord,
) -> Result<PlannedModelArtifacts, String> {
    let source_path = record.path.clone();
    let mut planned_record = record.clone();
    planned_record.path = project_root
        .join(&source_path)
        .to_string_lossy()
        .into_owned();
    let (static_manifest, static_path) =
        static_mesh::plan_static_mesh_artifact(&mut planned_record)
            .map_err(|error| format!("static mesh processor failed: {error}"))?;
    let (mut animation_manifest, animation_path) =
        animation::plan_animation_artifact(&mut planned_record)
            .map_err(|error| format!("animation processor failed: {error}"))?;
    let mut static_manifest = static_manifest;
    static_manifest.source.path.clone_from(&source_path);
    animation_manifest.source.path.clone_from(&source_path);
    for dependency in &mut static_manifest.source.dependencies {
        *dependency = project_catalog_path(project_root, dependency);
    }
    for dependency in &mut animation_manifest.source.dependencies {
        *dependency = project_catalog_path(project_root, dependency);
    }
    planned_record.path = source_path;
    planned_record.dependencies = static_manifest.source.dependencies.clone();
    planned_record
        .dependencies
        .extend(animation_manifest.source.dependencies.iter().cloned());
    planned_record.dependencies.sort();
    planned_record.dependencies.dedup();
    let pretty = ron::ser::PrettyConfig::default();
    let static_bytes = ron::ser::to_string_pretty(&static_manifest, pretty.clone())
        .map(|source| format!("{source}\n").into_bytes())
        .map_err(|error| format!("could not serialize static mesh artifact: {error}"))?;
    let animation_bytes = ron::ser::to_string_pretty(&animation_manifest, pretty)
        .map(|source| format!("{source}\n").into_bytes())
        .map_err(|error| format!("could not serialize animation artifact: {error}"))?;
    Ok(PlannedModelArtifacts {
        record: planned_record,
        static_manifest,
        static_path: project_root.join(static_path),
        static_bytes,
        animation_path: project_root.join(animation_path),
        animation_bytes,
    })
}

fn project_catalog_path(project_root: &std::path::Path, path: &str) -> String {
    let path = std::path::Path::new(path);
    let relative = path.strip_prefix(project_root).unwrap_or(path);
    relative.to_string_lossy().replace('\\', "/")
}

pub fn publish_model_artifacts(plan: &PlannedModelArtifacts) -> Result<(), String> {
    let original_static = std::fs::read(&plan.static_path).ok();
    let original_animation = std::fs::read(&plan.animation_path).ok();
    let result = write_if_changed(&plan.static_path, &plan.static_bytes)
        .and_then(|_| write_if_changed(&plan.animation_path, &plan.animation_bytes));
    if let Err(error) = result {
        restore_artifact(&plan.static_path, original_static.as_deref());
        restore_artifact(&plan.animation_path, original_animation.as_deref());
        return Err(format!("model artifact publication rolled back: {error}"));
    }
    Ok(())
}

fn restore_artifact(path: &std::path::Path, bytes: Option<&[u8]>) {
    if let Some(bytes) = bytes {
        let _ = std::fs::write(path, bytes);
    } else {
        let _ = std::fs::remove_file(path);
    }
}

/// Refreshes both generated contracts derived from one imported model source.
pub fn refresh_model_artifacts(
    record: &mut shared::AssetRecord,
) -> Result<static_mesh::StaticMeshManifest, String> {
    let plan = plan_model_artifacts(record)?;
    publish_model_artifacts(&plan)?;
    *record = plan.record.clone();
    Ok(plan.static_manifest)
}

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

    #[test]
    fn classification_is_folder_independent() {
        assert_eq!(
            classify_asset(Path::new("assets/Anything/hero.glb")),
            AssetKind::Model
        );
        assert_eq!(
            classify_asset(Path::new("assets/Props/Office/wall.material.ron")),
            AssetKind::Material
        );
    }

    #[test]
    fn managed_and_escaping_paths_are_rejected() {
        assert!(validate_asset_path(Path::new("assets/Props/Office")).is_ok());
        assert!(validate_asset_path(Path::new("assets/.index/registry.ron")).is_err());
        assert!(validate_asset_path(Path::new("assets/meshes/generated/a.ron")).is_err());
        assert!(validate_asset_path(Path::new("../outside")).is_err());
    }

    #[test]
    fn registry_publication_always_updates_the_runtime_catalog() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-content-publish-{}",
            shared::AssetId::new().as_string()
        ));
        let document = AssetRegistryDocument {
            records: vec![AssetRecord {
                id: shared::AssetId::new(),
                path: "assets/Props/a.png".into(),
                label: "A".into(),
                kind: AssetKind::Texture,
                source_fingerprint: None,
                import_settings: shared::AssetImportSettings::for_kind(AssetKind::Texture),
                dependencies: Vec::new(),
            }],
            ..Default::default()
        };

        publish_content_documents(&root, &document).unwrap();

        let runtime: RuntimeContentCatalog =
            ron::from_str(&fs::read_to_string(root.join(RUNTIME_CATALOG_PATH)).unwrap()).unwrap();
        assert_eq!(runtime.records[0].path, "assets/Props/a.png");
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn headless_scan_migrates_legacy_authoring_override_to_stable_slots() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-content-policy-migration-{}",
            shared::AssetId::new().as_string()
        ));
        fs::create_dir_all(root.join("assets/meshes/generated")).unwrap();
        fs::write(root.join("assets/chair.fbx"), b"chair").unwrap();
        fs::write(
            root.join("assets/meshes/generated/chair.ron"),
            "(parts:[(id:\"draw:0\",material_id:Some(\"material:0\"))])",
        )
        .unwrap();
        let previous = AssetRegistryDocument {
            records: vec![AssetRecord {
                id: shared::AssetId::new(),
                path: "assets/chair.fbx".into(),
                label: "Chair".into(),
                kind: AssetKind::Model,
                source_fingerprint: Some(AssetSourceFingerprint::from_bytes(b"chair")),
                import_settings: shared::AssetImportSettings::Model(shared::ImportSettings {
                    material_policy: MaterialImportPolicy::AuthoringOverride,
                    static_mesh_manifest_path: Some("assets/meshes/generated/chair.ron".into()),
                    ..Default::default()
                }),
                dependencies: Vec::new(),
            }],
            ..Default::default()
        };

        let processed = scan_project(&root, &previous).unwrap();
        let settings = processed.registry.records[0].model_import();

        assert_eq!(
            settings.material_policy,
            MaterialImportPolicy::SourceMaterials
        );
        assert_eq!(settings.material_slots.len(), 1);
        assert_eq!(settings.material_slots[0].slot_id.0, "slot:draw:0");
        assert!(matches!(
            settings.material_slots[0].selection,
            ModelMaterialSelection::Default
        ));
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn scan_refuses_ambiguous_external_moves_instead_of_replacing_stable_identity() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-content-ambiguous-move-{}",
            shared::AssetId::new().as_string()
        ));
        fs::create_dir_all(root.join("assets/New")).unwrap();
        fs::write(root.join("assets/New/desk.png"), b"duplicate pixels").unwrap();
        let fingerprint = AssetSourceFingerprint::from_bytes(b"duplicate pixels");
        let previous = AssetRegistryDocument {
            records: ["assets/OldA/desk.png", "assets/OldB/desk.png"]
                .into_iter()
                .map(|path| AssetRecord {
                    id: shared::AssetId::new(),
                    path: path.into(),
                    label: "Desk".into(),
                    kind: AssetKind::Texture,
                    source_fingerprint: Some(fingerprint.clone()),
                    import_settings: shared::AssetImportSettings::for_kind(AssetKind::Texture),
                    dependencies: Vec::new(),
                })
                .collect(),
            ..Default::default()
        };

        let conflicts = find_ambiguous_external_moves(&root, &previous).unwrap();
        assert_eq!(conflicts.len(), 1);
        assert_eq!(conflicts[0].new_path, "assets/New/desk.png");
        assert_eq!(
            conflicts[0].candidate_paths,
            ["assets/OldA/desk.png", "assets/OldB/desk.png"]
        );

        let error = scan_project(&root, &previous).unwrap_err();

        assert!(error.contains("ambiguous external move"));
        assert!(error.contains("assets/OldA/desk.png"));
        assert!(error.contains("assets/OldB/desk.png"));
        assert!(error.contains("Content Browser"));
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn scan_preserves_unique_imported_source_ids_across_external_moves() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-content-unique-moves-{}",
            shared::AssetId::new().as_string()
        ));
        fs::create_dir_all(root.join("assets/New")).unwrap();
        let fixtures = [
            ("model.glb", AssetKind::Model, b"model bytes".as_slice()),
            (
                "texture.png",
                AssetKind::Texture,
                b"texture bytes".as_slice(),
            ),
            ("audio.ogg", AssetKind::AudioClip, b"audio bytes".as_slice()),
        ];
        let mut expected = Vec::new();
        let mut records = Vec::new();
        for (name, kind, bytes) in fixtures {
            fs::write(root.join("assets/New").join(name), bytes).unwrap();
            let id = shared::AssetId::new();
            expected.push((format!("assets/New/{name}"), id.clone()));
            records.push(AssetRecord {
                id,
                path: format!("assets/Old/{name}"),
                label: name.into(),
                kind,
                source_fingerprint: Some(AssetSourceFingerprint::from_bytes(bytes)),
                import_settings: shared::AssetImportSettings::for_kind(kind),
                dependencies: Vec::new(),
            });
        }
        let previous = AssetRegistryDocument {
            records,
            ..Default::default()
        };

        let processed = scan_project(&root, &previous).unwrap();

        assert_eq!(processed.report.moved, 3);
        for (path, id) in expected {
            assert_eq!(
                processed
                    .registry
                    .records
                    .iter()
                    .find(|record| record.path == path)
                    .map(|record| &record.id),
                Some(&id)
            );
        }
        fs::remove_dir_all(root).unwrap();
    }
}
