use crate::{validate_asset_path, write_if_changed, REGISTRY_PATH, RUNTIME_CATALOG_PATH};
use serde::{Deserialize, Serialize};
use shared::AssetRegistryDocument;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ContentOperation {
    CreateFolder {
        path: PathBuf,
    },
    Move {
        source: PathBuf,
        destination: PathBuf,
    },
    Copy {
        source: PathBuf,
        destination: PathBuf,
    },
    WriteFile {
        path: PathBuf,
        bytes: Vec<u8>,
    },
    /// Replace one existing authored file only if its reviewed bytes are still current.
    ReplaceFile {
        path: PathBuf,
        bytes: Vec<u8>,
        expected_fingerprint: String,
        previous_bytes: Vec<u8>,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReferenceRewrite {
    pub document: PathBuf,
    pub from: String,
    pub to: String,
}

/// One authored or registry-owned reference that will become unresolved if selected content is
/// removed. This is intentionally read-only: callers use it to build destructive-operation
/// previews and watcher diagnostics.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct ContentReferenceUsage {
    pub document: PathBuf,
    pub reference: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentTransactionPreview {
    pub operations: Vec<ContentOperation>,
    pub affected_asset_ids: Vec<String>,
    pub reference_rewrites: Vec<ReferenceRewrite>,
    pub collisions: Vec<PathBuf>,
}

/// Returns a deterministic digest for a file or directory tree used by guarded content undo.
pub fn tree_fingerprint(path: &Path) -> Result<String, String> {
    if !path.exists() {
        return Err(format!("content path no longer exists: {}", path.display()));
    }
    let mut hasher = blake3::Hasher::new();
    if path.is_file() {
        hasher.update(b"file\0");
        hasher.update(
            &fs::read(path)
                .map_err(|error| format!("could not read {}: {error}", path.display()))?,
        );
    } else {
        hasher.update(b"directory\0");
        let mut entries: Vec<PathBuf> = WalkDir::new(path)
            .follow_links(false)
            .into_iter()
            .collect::<Result<Vec<_>, _>>()
            .map_err(|error| format!("could not inspect {}: {error}", path.display()))?
            .into_iter()
            .filter(|entry| entry.path() != path)
            .map(|entry| entry.into_path())
            .collect();
        entries.sort();
        for entry in entries {
            let relative = entry.strip_prefix(path).unwrap_or(&entry);
            hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes());
            hasher.update(&[0]);
            if entry.is_file() {
                hasher.update(b"file\0");
                hasher.update(
                    &fs::read(&entry)
                        .map_err(|error| format!("could not read {}: {error}", entry.display()))?,
                );
            } else {
                hasher.update(b"directory\0");
            }
        }
    }
    Ok(hasher.finalize().to_hex().to_string())
}

impl ContentTransactionPreview {
    pub fn is_committable(&self) -> bool {
        self.collisions.is_empty()
    }
}

pub fn preview_transaction(
    project_root: &Path,
    operations: Vec<ContentOperation>,
    registry: &AssetRegistryDocument,
) -> Result<ContentTransactionPreview, String> {
    let mut collisions = Vec::new();
    let mut affected_asset_ids = Vec::new();
    let mut reference_rewrites = Vec::new();
    let mut planned_destinations = HashSet::new();
    for operation in &operations {
        match operation {
            ContentOperation::CreateFolder { path } => {
                validate_asset_path(path)?;
                if project_root.join(path).exists() {
                    collisions.push(path.clone());
                }
                if !planned_destinations.insert(normalize(path)) {
                    collisions.push(path.clone());
                }
            }
            ContentOperation::WriteFile { path, .. } => {
                validate_asset_path(path)?;
                if project_root.join(path).exists() {
                    collisions.push(path.clone());
                }
                if !planned_destinations.insert(normalize(path)) {
                    collisions.push(path.clone());
                }
            }
            ContentOperation::ReplaceFile {
                path,
                expected_fingerprint,
                ..
            } => {
                validate_asset_path(path)?;
                let destination = project_root.join(path);
                validate_regular_tree(&destination)?;
                if !destination.is_file() {
                    return Err(format!(
                        "replacement target is not a file: {}",
                        path.display()
                    ));
                }
                let actual = tree_fingerprint(&destination)?;
                if actual != *expected_fingerprint {
                    return Err(format!(
                        "replacement target changed after review: {}",
                        path.display()
                    ));
                }
                if !planned_destinations.insert(normalize(path)) {
                    collisions.push(path.clone());
                }
            }
            ContentOperation::Move {
                source,
                destination,
            }
            | ContentOperation::Copy {
                source,
                destination,
            } => {
                validate_asset_path(source)?;
                validate_asset_path(destination)?;
                validate_regular_tree(&project_root.join(source))?;
                if project_root.join(destination).exists() {
                    collisions.push(destination.clone());
                }
                if !planned_destinations.insert(normalize(destination)) {
                    collisions.push(destination.clone());
                }
                if path_is_or_descendant(&normalize(destination), &normalize(source)) {
                    return Err(format!(
                        "destination cannot be inside its source: {} -> {}",
                        source.display(),
                        destination.display()
                    ));
                }
                let from = normalize(source);
                let to = normalize(destination);
                for record in &registry.records {
                    if path_is_or_descendant(&record.path, &from) {
                        affected_asset_ids.push(record.id.as_string());
                    }
                }
                if matches!(operation, ContentOperation::Move { .. }) {
                    reference_rewrites.extend(find_reference_rewrites(project_root, &from, &to)?);
                }
            }
        }
    }
    affected_asset_ids.sort();
    affected_asset_ids.dedup();
    reference_rewrites.sort_by(|left, right| left.document.cmp(&right.document));
    reference_rewrites.dedup();
    Ok(ContentTransactionPreview {
        operations,
        affected_asset_ids,
        reference_rewrites,
        collisions,
    })
}

/// Commits filesystem, reference-cache, and registry changes as one rollback-capable unit.
pub fn commit_transaction(
    project_root: &Path,
    preview: &ContentTransactionPreview,
    registry: &mut AssetRegistryDocument,
) -> Result<(), String> {
    commit_transaction_with_registry_update(project_root, preview, registry, |_| Ok(()))
}

/// Commits a content transaction and applies a dependent registry edit after new files have
/// received stable asset IDs, but before the registry and runtime catalog are published.
/// Registry-edit failures participate in the same byte-restoring rollback as filesystem errors.
pub fn commit_transaction_with_registry_update(
    project_root: &Path,
    preview: &ContentTransactionPreview,
    registry: &mut AssetRegistryDocument,
    registry_update: impl FnOnce(&mut AssetRegistryDocument) -> Result<(), String>,
) -> Result<(), String> {
    if !preview.is_committable() {
        return Err(format!(
            "content transaction has {} collision(s)",
            preview.collisions.len()
        ));
    }
    let original_registry = registry.clone();
    let registry_path = project_root.join(REGISTRY_PATH);
    let original_registry_bytes = fs::read(&registry_path).ok();
    let runtime_catalog_path = project_root.join(RUNTIME_CATALOG_PATH);
    let original_runtime_catalog_bytes = fs::read(&runtime_catalog_path).ok();
    let backup_root = project_root
        .join("assets/.index/transactions")
        .join(uuid::Uuid::new_v4().to_string());
    fs::create_dir_all(&backup_root)
        .map_err(|error| format!("could not create transaction staging: {error}"))?;
    let mut applied_operations = vec![false; preview.operations.len()];

    let result: Result<(), String> = (|| {
        for (index, rewrite) in preview.reference_rewrites.iter().enumerate() {
            let path = project_root.join(&rewrite.document);
            let bytes = fs::read(&path)
                .map_err(|error| format!("could not read {}: {error}", path.display()))?;
            fs::write(backup_root.join(format!("reference-{index}.bak")), &bytes)
                .map_err(|error| format!("could not back up {}: {error}", path.display()))?;
            let source = String::from_utf8(bytes)
                .map_err(|_| format!("reference document is not UTF-8: {}", path.display()))?;
            let replaced = replace_quoted_path_prefix(&source, &rewrite.from, &rewrite.to);
            write_if_changed(&path, replaced.as_bytes())?;
        }

        for (index, operation) in preview.operations.iter().enumerate() {
            match operation {
                ContentOperation::CreateFolder { path } => {
                    fs::create_dir_all(project_root.join(path))
                        .map_err(|error| format!("could not create {}: {error}", path.display()))?;
                    applied_operations[index] = true;
                }
                ContentOperation::Move {
                    source,
                    destination,
                } => {
                    let source_path = project_root.join(source);
                    let destination_path = project_root.join(destination);
                    if let Some(parent) = destination_path.parent() {
                        fs::create_dir_all(parent).map_err(|error| {
                            format!("could not create {}: {error}", parent.display())
                        })?;
                    }
                    fs::rename(&source_path, &destination_path).map_err(|error| {
                        format!(
                            "could not move {} to {}: {error}",
                            source.display(),
                            destination.display()
                        )
                    })?;
                    applied_operations[index] = true;
                    fs::write(
                        backup_root.join(format!("move-{index}.ron")),
                        format!("{}\n{}", source.display(), destination.display()),
                    )
                    .map_err(|error| format!("could not record move rollback: {error}"))?;
                    rewrite_registry_paths(
                        registry,
                        &normalize(source),
                        &normalize(destination),
                        false,
                    );
                }
                ContentOperation::Copy {
                    source,
                    destination,
                } => {
                    if let Err(error) =
                        copy_tree(&project_root.join(source), &project_root.join(destination))
                    {
                        let target = project_root.join(destination);
                        let _ = if target.is_dir() {
                            fs::remove_dir_all(target)
                        } else {
                            fs::remove_file(target)
                        };
                        return Err(error);
                    }
                    applied_operations[index] = true;
                    let id_remap = duplicate_registry_records(
                        registry,
                        &normalize(source),
                        &normalize(destination),
                    );
                    rewrite_copied_reference_tree(
                        &project_root.join(destination),
                        &normalize(source),
                        &normalize(destination),
                        &id_remap,
                    )?;
                }
                ContentOperation::WriteFile { path, bytes } => {
                    let destination = project_root.join(path);
                    if let Some(parent) = destination.parent() {
                        fs::create_dir_all(parent).map_err(|error| {
                            format!("could not create {}: {error}", parent.display())
                        })?;
                    }
                    fs::write(&destination, bytes).map_err(|error| {
                        format!("could not write {}: {error}", destination.display())
                    })?;
                    applied_operations[index] = true;
                    register_written_asset(registry, path, bytes)?;
                }
                ContentOperation::ReplaceFile {
                    path,
                    bytes,
                    expected_fingerprint,
                    previous_bytes,
                } => {
                    let destination = project_root.join(path);
                    let actual = tree_fingerprint(&destination)?;
                    if actual != *expected_fingerprint {
                        return Err(format!(
                            "replacement target changed after review: {}",
                            path.display()
                        ));
                    }
                    let original = fs::read(&destination).map_err(|error| {
                        format!("could not read {}: {error}", destination.display())
                    })?;
                    if original != *previous_bytes {
                        return Err(format!(
                            "replacement target bytes changed after review: {}",
                            path.display()
                        ));
                    }
                    fs::write(backup_root.join(format!("replace-{index}.bak")), original).map_err(
                        |error| format!("could not back up {}: {error}", destination.display()),
                    )?;
                    // A failed write may have truncated the file, so rollback owns it from here.
                    applied_operations[index] = true;
                    fs::write(&destination, bytes).map_err(|error| {
                        format!("could not replace {}: {error}", destination.display())
                    })?;
                    update_replaced_asset(registry, path, bytes)?;
                }
            }
        }
        registry_update(registry)?;
        registry
            .records
            .sort_by(|left, right| left.path.cmp(&right.path));
        let bytes = crate::serialize_registry(registry)?;
        write_if_changed(&registry_path, &bytes)?;
        let runtime_catalog = shared::RuntimeContentCatalog::from(&*registry);
        let runtime_bytes = crate::serialize_runtime_catalog(&runtime_catalog)?;
        write_if_changed(&runtime_catalog_path, &runtime_bytes)?;
        Ok(())
    })();

    if let Err(error) = result {
        rollback_transaction(
            project_root,
            preview,
            &applied_operations,
            &backup_root,
            original_registry_bytes.as_deref(),
            original_runtime_catalog_bytes.as_deref(),
        );
        *registry = original_registry;
        let _ = fs::remove_dir_all(&backup_root);
        return Err(format!("content transaction rolled back: {error}"));
    }
    fs::remove_dir_all(&backup_root)
        .map_err(|error| format!("transaction committed but staging cleanup failed: {error}"))?;
    Ok(())
}

fn rollback_transaction(
    project_root: &Path,
    preview: &ContentTransactionPreview,
    applied_operations: &[bool],
    backup_root: &Path,
    original_registry: Option<&[u8]>,
    original_runtime_catalog: Option<&[u8]>,
) {
    for (index, operation) in preview.operations.iter().enumerate().rev() {
        if !applied_operations.get(index).copied().unwrap_or(false) {
            continue;
        }
        match operation {
            ContentOperation::CreateFolder { path } => {
                let _ = fs::remove_dir(project_root.join(path));
            }
            ContentOperation::Move {
                source,
                destination,
            } => {
                let _ = fs::rename(project_root.join(destination), project_root.join(source));
            }
            ContentOperation::Copy { destination, .. } => {
                let target = project_root.join(destination);
                let _ = if target.is_dir() {
                    fs::remove_dir_all(target)
                } else {
                    fs::remove_file(target)
                };
            }
            ContentOperation::WriteFile { path, .. } => {
                let _ = fs::remove_file(project_root.join(path));
            }
            ContentOperation::ReplaceFile { path, .. } => {
                if let Ok(bytes) = fs::read(backup_root.join(format!("replace-{index}.bak"))) {
                    let _ = fs::write(project_root.join(path), bytes);
                }
            }
        }
    }
    for (index, rewrite) in preview.reference_rewrites.iter().enumerate() {
        if let Ok(bytes) = fs::read(backup_root.join(format!("reference-{index}.bak"))) {
            let _ = fs::write(project_root.join(&rewrite.document), bytes);
        }
    }
    if let Some(bytes) = original_registry {
        let _ = fs::write(project_root.join(REGISTRY_PATH), bytes);
    } else {
        let _ = fs::remove_file(project_root.join(REGISTRY_PATH));
    }
    if let Some(bytes) = original_runtime_catalog {
        let _ = fs::write(project_root.join(RUNTIME_CATALOG_PATH), bytes);
    } else {
        let _ = fs::remove_file(project_root.join(RUNTIME_CATALOG_PATH));
    }
}

fn register_written_asset(
    registry: &mut AssetRegistryDocument,
    path: &Path,
    bytes: &[u8],
) -> Result<(), String> {
    let normalized = normalize(path);
    if registry
        .records
        .iter()
        .any(|record| record.path == normalized)
    {
        return Err(format!("registry already contains {}", path.display()));
    }
    let kind = crate::classify_asset(path);
    let label = if kind == shared::AssetKind::Material {
        ron::de::from_bytes::<shared::MaterialAsset>(bytes)
            .map(|asset| asset.label)
            .unwrap_or_else(|_| file_label(path))
    } else if kind == shared::AssetKind::MaterialInstance {
        ron::de::from_bytes::<shared::MaterialInstanceAsset>(bytes)
            .map(|asset| asset.label)
            .unwrap_or_else(|_| file_label(path))
    } else {
        file_label(path)
    };
    registry.records.push(shared::AssetRecord {
        id: shared::AssetId::new(),
        path: normalized,
        label,
        kind,
        source_fingerprint: None,
        import_settings: shared::AssetImportSettings::for_kind(kind),
        dependencies: Vec::new(),
    });
    Ok(())
}

fn update_replaced_asset(
    registry: &mut AssetRegistryDocument,
    path: &Path,
    bytes: &[u8],
) -> Result<(), String> {
    let normalized = normalize(path);
    let record = registry
        .records
        .iter_mut()
        .find(|record| record.path == normalized)
        .ok_or_else(|| {
            format!(
                "registry does not contain replacement target {}",
                path.display()
            )
        })?;
    record.kind = crate::classify_asset(path);
    record.label = if record.kind == shared::AssetKind::Material {
        ron::de::from_bytes::<shared::MaterialAsset>(bytes)
            .map(|asset| asset.label)
            .map_err(|error| format!("replacement Material is invalid: {error}"))?
    } else if record.kind == shared::AssetKind::MaterialInstance {
        ron::de::from_bytes::<shared::MaterialInstanceAsset>(bytes)
            .map(|asset| asset.label)
            .map_err(|error| format!("replacement Material Instance is invalid: {error}"))?
    } else {
        file_label(path)
    };
    Ok(())
}

fn file_label(path: &Path) -> String {
    path.file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("Asset")
        .to_string()
}

fn rewrite_registry_paths(
    registry: &mut AssetRegistryDocument,
    from: &str,
    to: &str,
    regenerate_ids: bool,
) {
    if let Some(reference) = &mut registry.defaults.default_material {
        rewrite_material_reference_path(reference, from, to);
    }
    for record in &mut registry.records {
        let moved = path_is_or_descendant(&record.path, from);
        rewrite_asset_record_paths(record, from, to);
        if moved && regenerate_ids {
            record.id = shared::AssetId::new();
        }
    }
}

fn rewrite_asset_record_paths(record: &mut shared::AssetRecord, from: &str, to: &str) {
    if path_is_or_descendant(&record.path, from) {
        record.path = replace_prefix(&record.path, from, to);
    }
    for dependency in &mut record.dependencies {
        if path_is_or_descendant(dependency, from) {
            *dependency = replace_prefix(dependency, from, to);
        }
    }
    if let Some(settings) = record.import_settings.model_mut() {
        for slot in &mut settings.material_slots {
            if let shared::ModelMaterialSelection::Project(reference) = &mut slot.selection {
                rewrite_material_reference_path(reference, from, to);
            }
        }
        for orphan in &mut settings.orphaned_material_slots {
            rewrite_editor_asset_reference_path(&mut orphan.material.0, from, to);
        }
        rewrite_optional_path(&mut settings.static_mesh_manifest_path, from, to);
        rewrite_optional_path(&mut settings.animation_manifest_path, from, to);
    }
}

fn rewrite_material_reference_path(reference: &mut shared::MaterialRef, from: &str, to: &str) {
    rewrite_editor_asset_reference_path(&mut reference.0, from, to);
}

fn rewrite_editor_asset_reference_path(
    reference: &mut shared::EditorAssetRef,
    from: &str,
    to: &str,
) {
    rewrite_optional_path(&mut reference.source_path, from, to);
}

fn rewrite_optional_path(path: &mut Option<String>, from: &str, to: &str) {
    if let Some(path) = path {
        if path_is_or_descendant(path, from) {
            *path = replace_prefix(path, from, to);
        }
    }
}

fn duplicate_registry_records(
    registry: &mut AssetRegistryDocument,
    from: &str,
    to: &str,
) -> HashMap<String, shared::AssetId> {
    let mut duplicates = registry
        .records
        .iter()
        .filter(|record| path_is_or_descendant(&record.path, from))
        .cloned()
        .collect::<Vec<_>>();
    let id_remap = duplicates
        .iter()
        .map(|record| (record.id.as_string(), shared::AssetId::new()))
        .collect::<HashMap<_, _>>();
    for record in &mut duplicates {
        let old_id = record.id.as_string();
        record.id = id_remap
            .get(&old_id)
            .expect("every duplicated record has a replacement ID")
            .clone();
        rewrite_asset_record_paths(record, from, to);
        remap_asset_record_references(record, &id_remap);
        if let Some(settings) = record.import_settings.model_mut() {
            settings.static_mesh_manifest_path = None;
            settings.animation_manifest_path = None;
        }
    }
    registry.records.extend(duplicates);
    id_remap
}

fn remap_asset_record_references(
    record: &mut shared::AssetRecord,
    ids: &HashMap<String, shared::AssetId>,
) {
    if let Some(settings) = record.import_settings.model_mut() {
        for slot in &mut settings.material_slots {
            if let shared::ModelMaterialSelection::Project(reference) = &mut slot.selection {
                remap_editor_asset_reference(&mut reference.0, ids);
            }
        }
        for orphan in &mut settings.orphaned_material_slots {
            remap_editor_asset_reference(&mut orphan.material.0, ids);
        }
    }
}

fn remap_editor_asset_reference(
    reference: &mut shared::EditorAssetRef,
    ids: &HashMap<String, shared::AssetId>,
) {
    if let Some(replacement) = ids.get(&reference.asset_id) {
        reference.asset_id = replacement.as_string();
    }
}

fn rewrite_copied_reference_tree(
    destination: &Path,
    from: &str,
    to: &str,
    id_remap: &HashMap<String, shared::AssetId>,
) -> Result<(), String> {
    for entry in WalkDir::new(destination).follow_links(false) {
        let entry = entry.map_err(|error| {
            format!(
                "could not inspect copied references in {}: {error}",
                destination.display()
            )
        })?;
        if !entry.file_type().is_file()
            || entry.path().extension().and_then(|value| value.to_str()) != Some("ron")
        {
            continue;
        }
        let bytes = fs::read(entry.path())
            .map_err(|error| format!("could not read {}: {error}", entry.path().display()))?;
        let mut source = String::from_utf8(bytes).map_err(|_| {
            format!(
                "copied reference document is not UTF-8: {}",
                entry.path().display()
            )
        })?;
        source = replace_quoted_path_prefix(&source, from, to);
        for (old_id, new_id) in id_remap {
            source = source.replace(
                &format!("\"{old_id}\""),
                &format!("\"{}\"", new_id.as_string()),
            );
        }
        write_if_changed(entry.path(), source.as_bytes())?;
    }
    Ok(())
}

fn find_reference_rewrites(
    project_root: &Path,
    from: &str,
    to: &str,
) -> Result<Vec<ReferenceRewrite>, String> {
    let mut rewrites = Vec::new();
    for entry in WalkDir::new(project_root.join("assets")).follow_links(false) {
        let entry = entry.map_err(|error| format!("could not inspect references: {error}"))?;
        if !entry.file_type().is_file()
            || entry.path().extension().and_then(|value| value.to_str()) != Some("ron")
        {
            continue;
        }
        let relative = entry
            .path()
            .strip_prefix(project_root)
            .map_err(|error| error.to_string())?;
        if relative.starts_with("assets/.index") || relative.starts_with("assets/.trash") {
            continue;
        }
        if fs::read_to_string(entry.path())
            .is_ok_and(|source| contains_quoted_path_prefix(&source, from))
        {
            rewrites.push(ReferenceRewrite {
                document: relative.to_path_buf(),
                from: from.into(),
                to: to.into(),
            });
        }
    }
    Ok(rewrites)
}

fn push_material_reference_usage(
    usages: &mut Vec<ContentReferenceUsage>,
    document: &Path,
    owner: &shared::AssetRecord,
    reference: &shared::MaterialRef,
    sources: &[String],
    selected_ids: &[String],
) {
    let selected_by_id = selected_ids.iter().any(|id| id == &reference.0.asset_id);
    let selected_by_path = reference.0.source_path.as_deref().is_some_and(|path| {
        sources
            .iter()
            .any(|source| path_is_or_descendant(path, source))
    });
    if selected_by_id || selected_by_path {
        usages.push(ContentReferenceUsage {
            document: document.to_path_buf(),
            reference: format!("model {}", owner.path),
        });
    }
}

/// Finds external usages of selected files or folders. References inside the selected tree are
/// excluded because they are removed as part of the same operation.
pub fn find_reference_usages(
    project_root: &Path,
    sources: &[PathBuf],
    registry: &AssetRegistryDocument,
) -> Result<Vec<ContentReferenceUsage>, String> {
    let sources = sources
        .iter()
        .map(|path| normalize(path))
        .collect::<Vec<_>>();
    for source in &sources {
        validate_asset_path(Path::new(source))?;
    }
    let selected_records = registry
        .records
        .iter()
        .filter(|record| {
            sources
                .iter()
                .any(|source| path_is_or_descendant(&record.path, source))
        })
        .collect::<Vec<_>>();
    let selected_ids = selected_records
        .iter()
        .map(|record| record.id.as_string())
        .collect::<Vec<_>>();
    let mut usages = Vec::new();

    for entry in WalkDir::new(project_root.join("assets")).follow_links(false) {
        let entry = entry.map_err(|error| format!("could not inspect references: {error}"))?;
        if !entry.file_type().is_file()
            || entry.path().extension().and_then(|value| value.to_str()) != Some("ron")
        {
            continue;
        }
        let relative = entry
            .path()
            .strip_prefix(project_root)
            .map_err(|error| error.to_string())?;
        let relative_normalized = normalize(relative);
        if relative.starts_with("assets/.index")
            || relative.starts_with("assets/.trash")
            || sources
                .iter()
                .any(|source| path_is_or_descendant(&relative_normalized, source))
        {
            continue;
        }
        let Ok(source) = fs::read_to_string(entry.path()) else {
            continue;
        };
        for selected in &sources {
            if contains_quoted_path_prefix(&source, selected) {
                usages.push(ContentReferenceUsage {
                    document: relative.to_path_buf(),
                    reference: selected.clone(),
                });
            }
        }
        for selected_id in &selected_ids {
            if source.contains(&format!("\"{selected_id}\"")) {
                usages.push(ContentReferenceUsage {
                    document: relative.to_path_buf(),
                    reference: selected_id.clone(),
                });
            }
        }
    }

    let registry_path = PathBuf::from(REGISTRY_PATH);
    for owner in &registry.records {
        if sources
            .iter()
            .any(|source| path_is_or_descendant(&owner.path, source))
        {
            continue;
        }
        let Some(settings) = owner.import_settings.model() else {
            continue;
        };
        for slot in &settings.material_slots {
            if let shared::ModelMaterialSelection::Project(reference) = &slot.selection {
                push_material_reference_usage(
                    &mut usages,
                    &registry_path,
                    owner,
                    reference,
                    &sources,
                    &selected_ids,
                );
            }
        }
        for orphan in &settings.orphaned_material_slots {
            push_material_reference_usage(
                &mut usages,
                &registry_path,
                owner,
                &orphan.material,
                &sources,
                &selected_ids,
            );
        }
        for dependency in &owner.dependencies {
            if sources
                .iter()
                .any(|source| path_is_or_descendant(dependency, source))
            {
                usages.push(ContentReferenceUsage {
                    document: registry_path.clone(),
                    reference: format!("dependency of {}", owner.path),
                });
            }
        }
    }
    if let Some(reference) = &registry.defaults.default_material {
        let selected_by_id = selected_ids.iter().any(|id| id == &reference.0.asset_id);
        let selected_by_path = reference.0.source_path.as_deref().is_some_and(|path| {
            sources
                .iter()
                .any(|source| path_is_or_descendant(path, source))
        });
        if selected_by_id || selected_by_path {
            usages.push(ContentReferenceUsage {
                document: registry_path,
                reference: "project default material".into(),
            });
        }
    }
    usages.sort_by(|left, right| {
        left.document
            .cmp(&right.document)
            .then_with(|| left.reference.cmp(&right.reference))
    });
    usages.dedup();
    Ok(usages)
}

fn validate_regular_tree(path: &Path) -> Result<(), String> {
    if !path.exists() {
        return Err(format!("content source does not exist: {}", path.display()));
    }
    for entry in WalkDir::new(path).follow_links(false) {
        let entry =
            entry.map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
        if entry.file_type().is_symlink() {
            return Err(format!(
                "content transactions reject symlinks: {}",
                entry.path().display()
            ));
        }
    }
    Ok(())
}

fn contains_quoted_path_prefix(source: &str, path: &str) -> bool {
    source.contains(&format!("\"{path}\"")) || source.contains(&format!("\"{path}/"))
}

fn replace_quoted_path_prefix(source: &str, from: &str, to: &str) -> String {
    source
        .replace(&format!("\"{from}\""), &format!("\"{to}\""))
        .replace(&format!("\"{from}/"), &format!("\"{to}/"))
}

fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
    if source.is_file() {
        if let Some(parent) = destination.parent() {
            fs::create_dir_all(parent).map_err(|error| error.to_string())?;
        }
        fs::copy(source, destination).map_err(|error| error.to_string())?;
        return Ok(());
    }
    for entry in WalkDir::new(source).follow_links(false) {
        let entry = entry.map_err(|error| error.to_string())?;
        let relative = entry
            .path()
            .strip_prefix(source)
            .map_err(|error| error.to_string())?;
        let target = destination.join(relative);
        if entry.file_type().is_dir() {
            fs::create_dir_all(&target).map_err(|error| error.to_string())?;
        } else if entry.file_type().is_file() {
            fs::copy(entry.path(), &target).map_err(|error| error.to_string())?;
        } else {
            return Err(format!(
                "unsupported filesystem entry: {}",
                entry.path().display()
            ));
        }
    }
    Ok(())
}

fn path_is_or_descendant(candidate: &str, parent: &str) -> bool {
    candidate == parent
        || candidate
            .strip_prefix(parent)
            .is_some_and(|suffix| suffix.starts_with('/'))
}

fn replace_prefix(candidate: &str, from: &str, to: &str) -> String {
    format!("{to}{}", &candidate[from.len()..])
}

fn normalize(path: &Path) -> String {
    path.to_string_lossy()
        .replace('\\', "/")
        .trim_end_matches('/')
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use shared::{AssetImportSettings, AssetKind, AssetRecord, ImportSettings};

    fn fixture() -> (PathBuf, AssetRegistryDocument) {
        let root = std::env::temp_dir().join(format!(
            "blacksite-content-transaction-{}",
            uuid::Uuid::new_v4()
        ));
        fs::create_dir_all(root.join("assets/Props/Office")).unwrap();
        fs::create_dir_all(root.join("assets/levels")).unwrap();
        fs::create_dir_all(root.join("assets/.index")).unwrap();
        fs::write(root.join("assets/Props/Office/desk.glb"), b"desk").unwrap();
        fs::write(
            root.join("assets/levels/main.scn.ron"),
            r#"(source_path:"assets/Props/Office/desk.glb")"#,
        )
        .unwrap();
        let registry = AssetRegistryDocument {
            records: vec![AssetRecord {
                id: shared::AssetId::new(),
                path: "assets/Props/Office/desk.glb".into(),
                label: "Desk".into(),
                kind: AssetKind::Model,
                source_fingerprint: None,
                import_settings: AssetImportSettings::Model(ImportSettings::default()),
                dependencies: Vec::new(),
            }],
            ..Default::default()
        };
        fs::write(
            root.join(REGISTRY_PATH),
            crate::serialize_registry(&registry).unwrap(),
        )
        .unwrap();
        (root, registry)
    }

    #[test]
    fn quoted_path_rewrite_respects_path_component_boundaries() {
        let source = r#"(exact:"assets/Props",child:"assets/Props/Desk.glb",neighbor:"assets/Props2/Desk.glb")"#;

        assert!(contains_quoted_path_prefix(source, "assets/Props"));
        assert!(!contains_quoted_path_prefix(source, "assets/Prop"));
        assert_eq!(
            replace_quoted_path_prefix(source, "assets/Props", "assets/Furniture"),
            r#"(exact:"assets/Furniture",child:"assets/Furniture/Desk.glb",neighbor:"assets/Props2/Desk.glb")"#
        );
    }

    #[test]
    fn move_preserves_id_and_rewrites_cached_references() {
        let (root, mut registry) = fixture();
        let id = registry.records[0].id.clone();
        let material = shared::MaterialRef::new(
            shared::EditorAssetRef::new("material-id", "", "Desk")
                .with_source_path("assets/Props/Office/desk.material.ron"),
        );
        registry.defaults.default_material = Some(material.clone());
        let settings = registry.records[0].model_import_mut();
        settings
            .material_slots
            .push(shared::ModelMaterialSlotSelection {
                slot_id: shared::ComponentInstanceId::new("slot:desk"),
                selection: shared::ModelMaterialSelection::Project(material.clone()),
            });
        settings
            .orphaned_material_slots
            .push(shared::OrphanedModelMaterialSelection {
                slot_id: shared::ComponentInstanceId::new("slot:removed"),
                last_known_name: "Removed".into(),
                material,
            });
        settings.static_mesh_manifest_path =
            Some("assets/Props/Office/generated.static_mesh.ron".into());
        settings.animation_manifest_path =
            Some("assets/Props/Office/generated.animation.ron".into());
        let preview = preview_transaction(
            &root,
            vec![ContentOperation::Move {
                source: "assets/Props/Office".into(),
                destination: "assets/Furniture/Office".into(),
            }],
            &registry,
        )
        .unwrap();
        assert!(preview.is_committable());
        assert_eq!(preview.reference_rewrites.len(), 1);
        commit_transaction(&root, &preview, &mut registry).unwrap();
        assert_eq!(registry.records[0].id, id);
        assert_eq!(registry.records[0].path, "assets/Furniture/Office/desk.glb");
        let project_material = match &registry.records[0].model_import().material_slots[0].selection
        {
            shared::ModelMaterialSelection::Project(reference) => reference,
            selection => panic!("expected project material, got {selection:?}"),
        };
        assert_eq!(
            project_material.0.source_path.as_deref(),
            Some("assets/Furniture/Office/desk.material.ron")
        );
        assert_eq!(
            registry.records[0].model_import().orphaned_material_slots[0]
                .material
                .0
                .source_path
                .as_deref(),
            Some("assets/Furniture/Office/desk.material.ron")
        );
        assert_eq!(
            registry
                .defaults
                .default_material
                .as_ref()
                .and_then(|reference| reference.0.source_path.as_deref()),
            Some("assets/Furniture/Office/desk.material.ron")
        );
        assert_eq!(
            registry.records[0]
                .model_import()
                .static_mesh_manifest_path
                .as_deref(),
            Some("assets/Furniture/Office/generated.static_mesh.ron")
        );
        assert_eq!(
            registry.records[0]
                .model_import()
                .animation_manifest_path
                .as_deref(),
            Some("assets/Furniture/Office/generated.animation.ron")
        );
        assert!(fs::read_to_string(root.join("assets/levels/main.scn.ron"))
            .unwrap()
            .contains("assets/Furniture/Office/desk.glb"));
        let registry_source = fs::read_to_string(root.join(REGISTRY_PATH)).unwrap();
        assert!(!registry_source.contains("assets/Props/Office"));
        let catalog_source = fs::read_to_string(root.join(RUNTIME_CATALOG_PATH)).unwrap();
        assert!(!catalog_source.contains("assets/Props/Office"));
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn reference_usage_preview_reports_external_documents_and_registry_consumers() {
        let (root, mut registry) = fixture();
        let selected_id = registry.records[0].id.clone();
        let material_reference = shared::MaterialRef::new(
            shared::EditorAssetRef::new(selected_id.as_string(), "", "Desk")
                .with_source_path("assets/Props/Office/desk.glb"),
        );
        registry.defaults.default_material = Some(material_reference.clone());
        registry.records.push(AssetRecord {
            id: shared::AssetId::new(),
            path: "assets/consumer.gltf".into(),
            label: "Consumer".into(),
            kind: AssetKind::Model,
            source_fingerprint: None,
            import_settings: AssetImportSettings::Model(ImportSettings {
                material_slots: vec![shared::ModelMaterialSlotSelection {
                    slot_id: shared::ComponentInstanceId::new("slot:0"),
                    selection: shared::ModelMaterialSelection::Project(material_reference),
                }],
                ..Default::default()
            }),
            dependencies: vec!["assets/Props/Office/desk.glb".into()],
        });
        fs::write(root.join("assets/consumer.gltf"), b"consumer").unwrap();
        fs::write(
            root.join("assets/Props/Office/internal.ron"),
            format!("(asset_id:\"{}\")", selected_id.as_string()),
        )
        .unwrap();

        let usages =
            find_reference_usages(&root, &[PathBuf::from("assets/Props/Office")], &registry)
                .unwrap();

        assert!(usages.iter().any(|usage| {
            usage.document == Path::new("assets/levels/main.scn.ron")
                && usage.reference == "assets/Props/Office"
        }));
        assert!(usages.iter().any(|usage| {
            usage.document == Path::new(REGISTRY_PATH)
                && usage.reference == "project default material"
        }));
        assert!(usages.iter().any(|usage| {
            usage.document == Path::new(REGISTRY_PATH)
                && usage.reference == "model assets/consumer.gltf"
        }));
        assert!(usages.iter().any(|usage| {
            usage.document == Path::new(REGISTRY_PATH)
                && usage.reference == "dependency of assets/consumer.gltf"
        }));
        assert!(!usages
            .iter()
            .any(|usage| usage.document == Path::new("assets/Props/Office/internal.ron")));
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn copy_assigns_new_ids_and_rewrites_only_internal_duplicate_references() {
        let (root, mut registry) = fixture();
        let material_id = shared::AssetId::new();
        let material_path = "assets/Props/Office/desk.material.ron";
        fs::write(
            root.join(material_path),
            format!(
                r#"(asset_id:"{}",source_path:"{material_path}")"#,
                material_id.as_string()
            ),
        )
        .unwrap();
        registry.records.push(AssetRecord {
            id: material_id.clone(),
            path: material_path.into(),
            label: "Desk Material".into(),
            kind: AssetKind::Material,
            source_fingerprint: None,
            import_settings: AssetImportSettings::None,
            dependencies: Vec::new(),
        });
        registry.records[0].model_import_mut().material_slots.push(
            shared::ModelMaterialSlotSelection {
                slot_id: shared::ComponentInstanceId::new("slot:desk"),
                selection: shared::ModelMaterialSelection::Project(shared::MaterialRef::new(
                    shared::EditorAssetRef::new(material_id.as_string(), "", "Desk Material")
                        .with_source_path(material_path),
                )),
            },
        );
        registry.records[0]
            .model_import_mut()
            .static_mesh_manifest_path =
            Some("assets/meshes/generated/original.static_mesh.ron".into());
        registry.records[0]
            .model_import_mut()
            .animation_manifest_path =
            Some("assets/animations/generated/original.animation.ron".into());
        let source_model_id = registry.records[0].id.clone();

        let preview = preview_transaction(
            &root,
            vec![ContentOperation::Copy {
                source: "assets/Props/Office".into(),
                destination: "assets/Furniture/Office Copy".into(),
            }],
            &registry,
        )
        .unwrap();
        commit_transaction(&root, &preview, &mut registry).unwrap();

        let copied_model = registry
            .records
            .iter()
            .find(|record| record.path == "assets/Furniture/Office Copy/desk.glb")
            .unwrap();
        let copied_material = registry
            .records
            .iter()
            .find(|record| record.path == "assets/Furniture/Office Copy/desk.material.ron")
            .unwrap();
        assert_ne!(copied_model.id, source_model_id);
        assert_ne!(copied_material.id, material_id);
        assert!(copied_model
            .model_import()
            .static_mesh_manifest_path
            .is_none());
        assert!(copied_model
            .model_import()
            .animation_manifest_path
            .is_none());
        let copied_reference = match &copied_model.model_import().material_slots[0].selection {
            shared::ModelMaterialSelection::Project(reference) => reference,
            selection => panic!("expected project material, got {selection:?}"),
        };
        assert_eq!(copied_reference.0.asset_id, copied_material.id.as_string());
        assert_eq!(
            copied_reference.0.source_path.as_deref(),
            Some("assets/Furniture/Office Copy/desk.material.ron")
        );
        let copied_source =
            fs::read_to_string(root.join("assets/Furniture/Office Copy/desk.material.ron"))
                .unwrap();
        assert!(copied_source.contains("assets/Furniture/Office Copy/desk.material.ron"));
        assert!(copied_source.contains(&copied_material.id.as_string()));
        let original_source = fs::read_to_string(root.join(material_path)).unwrap();
        assert!(original_source.contains(material_path));
        assert!(original_source.contains(&material_id.as_string()));
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn collisions_are_previewed_without_mutation() {
        let (root, registry) = fixture();
        fs::create_dir_all(root.join("assets/Existing")).unwrap();
        let preview = preview_transaction(
            &root,
            vec![ContentOperation::Move {
                source: "assets/Props/Office".into(),
                destination: "assets/Existing".into(),
            }],
            &registry,
        )
        .unwrap();
        assert!(!preview.is_committable());
        assert!(root.join("assets/Props/Office/desk.glb").is_file());
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn duplicate_batch_destinations_are_reported_as_collisions() {
        let (root, registry) = fixture();
        fs::write(root.join("assets/Props/chair.glb"), b"chair").unwrap();
        let destination = PathBuf::from("assets/Furniture/item.glb");
        let preview = preview_transaction(
            &root,
            vec![
                ContentOperation::Copy {
                    source: "assets/Props/Office/desk.glb".into(),
                    destination: destination.clone(),
                },
                ContentOperation::Copy {
                    source: "assets/Props/chair.glb".into(),
                    destination: destination.clone(),
                },
            ],
            &registry,
        )
        .unwrap();
        assert_eq!(preview.collisions, vec![destination]);
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn failed_batch_rolls_back_only_operations_that_ran() {
        let (root, mut registry) = fixture();
        fs::write(root.join("assets/Props/chair.glb"), b"chair").unwrap();
        let preview = preview_transaction(
            &root,
            vec![
                ContentOperation::Move {
                    source: "assets/Props/Office/desk.glb".into(),
                    destination: "assets/Furniture/desk.glb".into(),
                },
                ContentOperation::Move {
                    source: "assets/Props/chair.glb".into(),
                    destination: "assets/Furniture/chair.glb".into(),
                },
            ],
            &registry,
        )
        .unwrap();
        fs::remove_file(root.join("assets/Props/chair.glb")).unwrap();

        let error = commit_transaction(&root, &preview, &mut registry).unwrap_err();
        assert!(error.contains("rolled back"));
        assert_eq!(
            fs::read(root.join("assets/Props/Office/desk.glb")).unwrap(),
            b"desk"
        );
        assert!(!root.join("assets/Furniture/desk.glb").exists());
        assert!(!root.join("assets/Furniture/chair.glb").exists());
        assert_eq!(registry.records[0].path, "assets/Props/Office/desk.glb");
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn tree_fingerprint_detects_external_file_changes() {
        let (root, _registry) = fixture();
        let folder = root.join("assets/Props/Office");
        let before = tree_fingerprint(&folder).unwrap();
        fs::write(folder.join("desk.glb"), b"changed").unwrap();
        let after = tree_fingerprint(&folder).unwrap();
        assert_ne!(before, after);
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn write_file_publishes_bytes_and_registry_record_atomically() {
        let (root, mut registry) = fixture();
        let material = shared::MaterialAsset {
            schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
            label: "Desk Steel".into(),
            shader: shared::ShaderRefDesc::default(),
            shader_ref: None,
            render_state: Default::default(),
            provenance: None,
            inputs: Default::default(),
        };
        let bytes = ron::ser::to_string(&material).unwrap().into_bytes();
        let path = PathBuf::from("assets/Props/desk_steel.material.ron");
        let preview = preview_transaction(
            &root,
            vec![ContentOperation::WriteFile {
                path: path.clone(),
                bytes: bytes.clone(),
            }],
            &registry,
        )
        .unwrap();
        commit_transaction(&root, &preview, &mut registry).unwrap();

        assert_eq!(fs::read(root.join(&path)).unwrap(), bytes);
        let record = registry
            .records
            .iter()
            .find(|record| record.path == "assets/Props/desk_steel.material.ron")
            .unwrap();
        assert_eq!(record.label, "Desk Steel");
        assert_eq!(record.kind, shared::AssetKind::Material);
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn dependent_registry_update_sees_written_asset_and_publishes_atomically() {
        let (root, mut registry) = fixture();
        let path = PathBuf::from("assets/Props/desk_steel.material.ron");
        let material = shared::MaterialAsset {
            schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
            label: "Desk Steel".into(),
            shader: shared::ShaderRefDesc::default(),
            shader_ref: None,
            render_state: Default::default(),
            provenance: None,
            inputs: Default::default(),
        };
        let preview = preview_transaction(
            &root,
            vec![ContentOperation::WriteFile {
                path: path.clone(),
                bytes: ron::ser::to_string(&material).unwrap().into_bytes(),
            }],
            &registry,
        )
        .unwrap();

        commit_transaction_with_registry_update(&root, &preview, &mut registry, |document| {
            let material_id = document
                .records
                .iter()
                .find(|record| record.path == "assets/Props/desk_steel.material.ron")
                .map(|record| record.id.as_string())
                .ok_or_else(|| "written material was not registered".to_string())?;
            document.records[0].dependencies.push(material_id);
            Ok(())
        })
        .unwrap();

        assert_eq!(registry.records[0].dependencies.len(), 1);
        let published = fs::read_to_string(root.join(REGISTRY_PATH)).unwrap();
        assert!(published.contains(&registry.records[0].dependencies[0]));
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn failed_dependent_registry_update_rolls_back_written_assets() {
        let (root, mut registry) = fixture();
        let original = registry.clone();
        let path = PathBuf::from("assets/Props/invalid.material.ron");
        let preview = preview_transaction(
            &root,
            vec![ContentOperation::WriteFile {
                path: path.clone(),
                bytes: b"invalid".to_vec(),
            }],
            &registry,
        )
        .unwrap();

        let error = commit_transaction_with_registry_update(&root, &preview, &mut registry, |_| {
            Err("dependent registry edit failed".into())
        })
        .unwrap_err();

        assert!(error.contains("dependent registry edit failed"));
        assert!(!root.join(path).exists());
        assert_eq!(registry, original);
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn reviewed_replace_preserves_id_and_rolls_back_exact_bytes() {
        let (root, mut registry) = fixture();
        let path = PathBuf::from("assets/Props/desk_steel.material.ron");
        let old_material = shared::MaterialAsset {
            schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
            label: "Edited Desk Steel".into(),
            shader: shared::ShaderRefDesc::default(),
            shader_ref: None,
            render_state: Default::default(),
            provenance: None,
            inputs: Default::default(),
        };
        let previous_bytes = ron::ser::to_string(&old_material).unwrap().into_bytes();
        fs::write(root.join(&path), &previous_bytes).unwrap();
        let id = shared::AssetId::new();
        registry.records.push(shared::AssetRecord {
            id: id.clone(),
            path: normalize(&path),
            label: old_material.label.clone(),
            kind: shared::AssetKind::Material,
            source_fingerprint: None,
            import_settings: Default::default(),
            dependencies: Vec::new(),
        });
        let original_registry = registry.clone();
        let mut replacement = old_material.clone();
        replacement.label = "Reimported Desk Steel".into();
        let replacement_bytes = ron::ser::to_string(&replacement).unwrap().into_bytes();
        let preview = preview_transaction(
            &root,
            vec![ContentOperation::ReplaceFile {
                path: path.clone(),
                bytes: replacement_bytes,
                expected_fingerprint: tree_fingerprint(&root.join(&path)).unwrap(),
                previous_bytes: previous_bytes.clone(),
            }],
            &registry,
        )
        .unwrap();

        let error = commit_transaction_with_registry_update(&root, &preview, &mut registry, |_| {
            Err("dependent mapping failed".into())
        })
        .unwrap_err();

        assert!(error.contains("dependent mapping failed"));
        assert_eq!(fs::read(root.join(&path)).unwrap(), previous_bytes);
        assert_eq!(registry, original_registry);
        assert_eq!(
            registry
                .records
                .iter()
                .find(|record| record.path == normalize(&path))
                .unwrap()
                .id,
            id
        );
        commit_transaction(&root, &preview, &mut registry).unwrap();
        assert_eq!(
            fs::read(root.join(&path)).unwrap(),
            ron::ser::to_string(&replacement).unwrap().into_bytes()
        );
        let replaced_record = registry
            .records
            .iter()
            .find(|record| record.path == normalize(&path))
            .unwrap();
        assert_eq!(replaced_record.id, id);
        assert_eq!(replaced_record.label, "Reimported Desk Steel");
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn reviewed_replace_rejects_external_changes_before_publish() {
        let (root, mut registry) = fixture();
        let path = PathBuf::from("assets/Props/desk_steel.material.ron");
        let previous_bytes = b"reviewed bytes".to_vec();
        fs::write(root.join(&path), &previous_bytes).unwrap();
        registry.records.push(shared::AssetRecord {
            id: shared::AssetId::new(),
            path: normalize(&path),
            label: "Desk Steel".into(),
            kind: shared::AssetKind::Material,
            source_fingerprint: None,
            import_settings: Default::default(),
            dependencies: Vec::new(),
        });
        let preview = preview_transaction(
            &root,
            vec![ContentOperation::ReplaceFile {
                path: path.clone(),
                bytes: b"replacement".to_vec(),
                expected_fingerprint: tree_fingerprint(&root.join(&path)).unwrap(),
                previous_bytes,
            }],
            &registry,
        )
        .unwrap();
        fs::write(root.join(&path), b"external edit").unwrap();

        let error = commit_transaction(&root, &preview, &mut registry).unwrap_err();

        assert!(error.contains("changed after review"));
        assert_eq!(fs::read(root.join(path)).unwrap(), b"external edit");
        fs::remove_dir_all(root).unwrap();
    }
}
