//! Explicit, transactional project content upgrade support.

use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};
use shared::{
    AssetId, AssetKind, AssetRecord, AssetRegistryDocument, AuthoringComponentStates,
    ComponentInstanceId, InspectorOrder, MaterialAsset, MaterialDesc, MaterialInputGroupDesc,
    MaterialInputSchema, MaterialInputSet, MaterialInstanceAsset, MaterialOverride,
    MaterialParameter, MaterialProvenance, MaterialRef, MaterialRenderState, MaterialSlot,
    Primitive, ShaderPropertyType, ShaderSchemaAsset, SkinnedMeshRenderer, StaticMeshRenderer,
    COMPONENT_MATERIAL_DESC, COMPONENT_MATERIAL_OVERRIDE, COMPONENT_PRIMITIVE,
    COMPONENT_SKINNED_MESH_RENDERER, COMPONENT_STATIC_MESH_RENDERER, MATERIAL_ASSET_SCHEMA_VERSION,
};

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

#[path = "upgrade/io.rs"]
mod io;
use io::{collect_files, normalize_text};

const INSPECTOR_ORDER_COMPONENT: &str = "shared::components::InspectorOrder";
const AUTHORING_COMPONENT_STATES_COMPONENT: &str = "shared::components::AuthoringComponentStates";

#[derive(Debug, Clone, Serialize)]
pub struct ProjectUpgradeChange {
    pub path: String,
    pub kind: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct ProjectUpgradeReport {
    pub applied: bool,
    pub backup_path: Option<String>,
    pub changes: Vec<ProjectUpgradeChange>,
    pub warnings: Vec<String>,
}

struct PendingWrite {
    path: PathBuf,
    contents: String,
    kind: &'static str,
    existed: bool,
}

struct LegacyMaterialMigration<'a> {
    root: &'a Path,
    registry: AssetRegistryDocument,
    material_writes: std::collections::BTreeMap<PathBuf, String>,
}

pub fn upgrade_project(root: &Path, apply: bool) -> Result<ProjectUpgradeReport, String> {
    let assets = root.join("assets");
    let registry_path = assets.join(".index/registry.ron");
    let registry_original = fs::read_to_string(&registry_path).unwrap_or_default();
    let registry = if registry_original.trim().is_empty() {
        AssetRegistryDocument::default()
    } else {
        shared::parse_asset_registry(&registry_original)?.document
    };
    let mut legacy_materials = LegacyMaterialMigration {
        root,
        registry,
        material_writes: std::collections::BTreeMap::new(),
    };
    let mut files = Vec::new();
    collect_files(&assets, &mut files)?;
    files.sort();

    let mut pending = Vec::new();
    let mut warnings = Vec::new();
    for path in files {
        if path
            .file_name()
            .and_then(|name| name.to_str())
            .is_some_and(|name| name.starts_with('.'))
        {
            continue;
        }
        let relative = path.strip_prefix(root).unwrap_or(&path);
        let normalized = relative.to_string_lossy().replace('\\', "/");
        if normalized.starts_with("assets/.") {
            continue;
        }
        let registered_kind = legacy_materials
            .registry
            .records
            .iter()
            .find(|record| record.path == normalized)
            .map(|record| record.kind);
        let Some(original) = fs::read_to_string(&path).ok() else {
            continue;
        };
        let upgraded = if normalized == "assets/project.ron" {
            canonical_project_settings(&original)
        } else if normalized == "assets/.index/registry.ron" {
            None
        } else if normalized.ends_with(".scn.ron") || normalized.ends_with(".prefab.ron") {
            match canonical_scene_document(&original, &mut legacy_materials) {
                Ok(value) => Some((value, "scene-schema")),
                Err(error) => {
                    warnings.push(format!("{normalized}: {error}"));
                    None
                }
            }
        } else if registered_kind == Some(AssetKind::ShaderSchema)
            || normalized.ends_with(".shader.ron")
        {
            canonical_shader_document(&original)
        } else if matches!(
            registered_kind,
            Some(AssetKind::Material | AssetKind::MaterialInstance)
        ) || normalized.ends_with(".material.ron")
            || normalized.ends_with(".material-instance.ron")
        {
            canonical_material_document(&path, &original, registered_kind, &mut legacy_materials)
        } else {
            None
        };
        let Some((contents, kind)) = upgraded else {
            continue;
        };
        if normalize_text(&contents) != normalize_text(&original) {
            pending.push(PendingWrite {
                path,
                contents,
                kind,
                existed: true,
            });
        }
    }

    for (path, contents) in std::mem::take(&mut legacy_materials.material_writes) {
        let existed = path.exists();
        let unchanged = existed
            && fs::read_to_string(&path)
                .is_ok_and(|original| normalize_text(&original) == normalize_text(&contents));
        if !unchanged {
            pending.push(PendingWrite {
                path,
                contents,
                kind: "legacy-material-v5",
                existed,
            });
        }
    }
    let registry_contents = ron::ser::to_string_pretty(
        &legacy_materials.registry,
        ron::ser::PrettyConfig::default(),
    )
    .map_err(|error| format!("could not serialize upgraded asset registry: {error}"))?;
    if normalize_text(&registry_contents) != normalize_text(&registry_original) {
        pending.push(PendingWrite {
            path: registry_path,
            contents: registry_contents,
            kind: "asset-registry-v3",
            existed: !registry_original.is_empty(),
        });
    }

    let changes = pending
        .iter()
        .map(|write| ProjectUpgradeChange {
            path: write
                .path
                .strip_prefix(root)
                .unwrap_or(&write.path)
                .to_string_lossy()
                .replace('\\', "/"),
            kind: write.kind.to_string(),
        })
        .collect::<Vec<_>>();
    if !apply || pending.is_empty() {
        return Ok(ProjectUpgradeReport {
            applied: false,
            backup_path: None,
            changes,
            warnings,
        });
    }
    if !warnings.is_empty() {
        return Err(format!(
            "project upgrade is blocked by {} migration error(s): {}",
            warnings.len(),
            warnings.join("; ")
        ));
    }

    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|error| error.to_string())?
        .as_secs();
    let backup_root = root
        .join(".blacksite")
        .join("backups")
        .join(format!("material-slot-v5-{timestamp}"));
    for write in &pending {
        if !write.existed {
            continue;
        }
        let relative = write
            .path
            .strip_prefix(root)
            .map_err(|error| error.to_string())?;
        let backup = backup_root.join(relative);
        if let Some(parent) = backup.parent() {
            if let Err(error) = fs::create_dir_all(parent) {
                let _ = fs::remove_dir_all(&backup_root);
                return Err(error.to_string());
            }
        }
        if let Err(error) = fs::copy(&write.path, &backup) {
            let _ = fs::remove_dir_all(&backup_root);
            return Err(format!(
                "could not back up {} to {}: {error}",
                write.path.display(),
                backup.display()
            ));
        }
    }

    let mut staged = Vec::new();
    let staging_root = backup_root.join(".staging");
    if let Err(error) = fs::create_dir_all(&staging_root) {
        let _ = fs::remove_dir_all(&backup_root);
        return Err(format!(
            "could not prepare project upgrade staging: {error}"
        ));
    }
    for (index, write) in pending.iter().enumerate() {
        let temporary = staging_root.join(format!("{index}.tmp"));
        if let Err(error) = fs::write(&temporary, &write.contents) {
            let _ = fs::remove_dir_all(&backup_root);
            return Err(format!("could not stage {}: {error}", write.path.display()));
        }
        staged.push((temporary, write.path.clone()));
    }
    let mut created_directories = Vec::new();
    for (temporary, destination) in &staged {
        if let Some(parent) = destination.parent().filter(|parent| !parent.exists()) {
            if let Err(error) = fs::create_dir_all(parent) {
                rollback_project_upgrade(
                    root,
                    &backup_root,
                    &changes,
                    &staged,
                    &created_directories,
                );
                return Err(format!(
                    "could not prepare upgrade destination {}: {error}",
                    parent.display()
                ));
            }
            created_directories.push(parent.to_path_buf());
        }
        if let Err(error) = fs::rename(temporary, destination) {
            rollback_project_upgrade(root, &backup_root, &changes, &staged, &created_directories);
            return Err(format!(
                "project upgrade failed while replacing {} and was rolled back: {error}",
                destination.display()
            ));
        }
    }
    let _ = fs::remove_dir_all(&staging_root);

    Ok(ProjectUpgradeReport {
        applied: true,
        backup_path: Some(
            backup_root
                .strip_prefix(root)
                .unwrap_or(&backup_root)
                .to_string_lossy()
                .replace('\\', "/"),
        ),
        changes,
        warnings,
    })
}

fn rollback_project_upgrade(
    root: &Path,
    backup_root: &Path,
    changes: &[ProjectUpgradeChange],
    staged: &[(PathBuf, PathBuf)],
    created_directories: &[PathBuf],
) {
    for change in changes {
        let backup = backup_root.join(&change.path);
        let destination = root.join(&change.path);
        if backup.exists() {
            let _ = fs::copy(backup, destination);
        } else {
            let _ = fs::remove_file(destination);
        }
    }
    for (temporary, _) in staged {
        let _ = fs::remove_file(temporary);
    }
    for directory in created_directories.iter().rev() {
        let _ = fs::remove_dir(directory);
    }
    let _ = fs::remove_dir_all(backup_root);
}

fn canonical_project_settings(text: &str) -> Option<(String, &'static str)> {
    let mut project = ron::from_str::<settings::ProjectSettings>(text).ok()?;
    project.version = settings::SETTINGS_VERSION;
    project.template_version = settings::PROJECT_TEMPLATE_VERSION;
    project.asset_roots = vec!["assets".into()];
    settings::save_project_settings_to_string(&project)
        .ok()
        .map(|contents| (contents, "project-content-settings-v2"))
}

fn canonical_scene_document(
    text: &str,
    legacy_materials: &mut LegacyMaterialMigration<'_>,
) -> Result<String, String> {
    let mut document = SceneDocument::from_ron_text(text)?;
    for entity in &mut document.entities {
        let legacy_material = entity
            .components
            .iter()
            .find(|component| component.type_name == COMPONENT_MATERIAL_DESC)
            .map(|component| {
                ron::from_str::<MaterialDesc>(&component.ron)
                    .map_err(|error| format!("could not upgrade MaterialDesc component: {error}"))
            })
            .transpose()?;
        let legacy_overrides = entity
            .components
            .iter()
            .find(|component| component.type_name == COMPONENT_MATERIAL_OVERRIDE)
            .map(|component| {
                ron::from_str::<MaterialOverride>(&component.ron).map_err(|error| {
                    format!("could not upgrade MaterialOverride component: {error}")
                })
            })
            .transpose()?;
        let mut consumed_legacy_material = false;

        if let Some(index) = entity
            .components
            .iter()
            .position(|component| component.type_name == COMPONENT_PRIMITIVE)
        {
            let mut primitive: Primitive = ron::from_str(&entity.components[index].ron)
                .map_err(|error| format!("could not upgrade Primitive component: {error}"))?;
            normalize_primitive_surface(&mut primitive);
            if let Some(material) = legacy_material.as_ref() {
                primitive.surface.material = Some(legacy_materials.reference_for(material)?);
                consumed_legacy_material = true;
            }
            entity.components[index] =
                SceneComponentBlob::from_serializable(COMPONENT_PRIMITIVE, &primitive)?;
        }

        if let Some(index) = entity
            .components
            .iter()
            .position(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER)
        {
            let mut renderer: StaticMeshRenderer = ron::from_str(&entity.components[index].ron)
                .map_err(|error| {
                    format!("could not upgrade StaticMeshRenderer component: {error}")
                })?;
            normalize_static_renderer(&mut renderer);
            if let Some(material) = legacy_material.as_ref() {
                let reference = legacy_materials.reference_for(material)?;
                for slot in &mut renderer.materials.slots {
                    slot.material = Some(reference.clone());
                }
                consumed_legacy_material = true;
            } else if let Some(overrides) = legacy_overrides.as_ref() {
                for override_slot in &overrides.slots {
                    let Some(part) = renderer
                        .slots
                        .iter()
                        .find(|part| part.id == override_slot.slot_id)
                    else {
                        continue;
                    };
                    let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) else {
                        continue;
                    };
                    slot.material = Some(legacy_materials.reference_for(&override_slot.material)?);
                }
            }
            entity.components[index] =
                SceneComponentBlob::from_serializable(COMPONENT_STATIC_MESH_RENDERER, &renderer)?;
        }

        if let Some(index) = entity
            .components
            .iter()
            .position(|component| component.type_name == COMPONENT_SKINNED_MESH_RENDERER)
        {
            let mut renderer: SkinnedMeshRenderer = ron::from_str(&entity.components[index].ron)
                .map_err(|error| {
                    format!("could not upgrade SkinnedMeshRenderer component: {error}")
                })?;
            if let Some(material) = legacy_material.as_ref() {
                let reference = legacy_materials.reference_for(material)?;
                for slot in &mut renderer.materials.slots {
                    slot.material = Some(reference.clone());
                }
                consumed_legacy_material = true;
            } else if let Some(overrides) = legacy_overrides.as_ref() {
                for override_slot in &overrides.slots {
                    if let Some(slot) = renderer.materials.slot_mut(&override_slot.slot_id) {
                        slot.material =
                            Some(legacy_materials.reference_for(&override_slot.material)?);
                    }
                }
            }
            entity.components[index] =
                SceneComponentBlob::from_serializable(COMPONENT_SKINNED_MESH_RENDERER, &renderer)?;
        }

        if consumed_legacy_material
            || entity.components.iter().any(|component| {
                component.type_name == COMPONENT_STATIC_MESH_RENDERER
                    || component.type_name == COMPONENT_SKINNED_MESH_RENDERER
            })
        {
            entity.components.retain(|component| {
                component.type_name != COMPONENT_MATERIAL_DESC
                    && component.type_name != COMPONENT_MATERIAL_OVERRIDE
            });
        }

        let present_types = entity
            .components
            .iter()
            .filter(|component| shared::authoring_component_id(&component.type_name).is_some())
            .map(|component| component.type_name.clone())
            .collect::<Vec<_>>();

        let states_index = entity
            .components
            .iter()
            .position(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT);
        let mut states = states_index
            .and_then(|index| {
                ron::from_str::<AuthoringComponentStates>(&entity.components[index].ron).ok()
            })
            .unwrap_or_default();
        normalize_component_states(&mut states);

        let mut migrated_legacy_state = false;
        if let Some(order_index) = entity
            .components
            .iter()
            .position(|component| component.type_name == INSPECTOR_ORDER_COMPONENT)
        {
            let mut order: InspectorOrder = ron::from_str(&entity.components[order_index].ron)
                .map_err(|error| format!("could not upgrade InspectorOrder component: {error}"))?;
            for legacy in std::mem::take(&mut order.component_states) {
                let key = if legacy.component_id.trim().is_empty() {
                    legacy.type_name
                } else {
                    legacy.component_id
                };
                if !key.trim().is_empty() {
                    states.set_component_active(key, legacy.active);
                    migrated_legacy_state = true;
                }
            }
            let present = present_types.iter().map(String::as_str).collect::<Vec<_>>();
            order.ensure_component_order(&present);
            entity.components[order_index] =
                SceneComponentBlob::from_serializable(INSPECTOR_ORDER_COMPONENT, &order)?;
        }
        if states_index.is_some() || migrated_legacy_state {
            let component = SceneComponentBlob::from_serializable(
                AUTHORING_COMPONENT_STATES_COMPONENT,
                &states,
            )?;
            if let Some(index) = states_index {
                entity.components[index] = component;
            } else {
                entity.components.push(component);
            }
        }
    }
    document.to_ron_text()
}

fn normalize_static_renderer(renderer: &mut StaticMeshRenderer) {
    for (index, part) in renderer.slots.iter_mut().enumerate() {
        if part.id.is_empty() {
            let id = if part.mesh.sub_asset_id.trim().is_empty() {
                format!("draw:legacy:{index}")
            } else {
                part.mesh.sub_asset_id.clone()
            };
            part.id = ComponentInstanceId::new(id);
        }
        if part.material_slot_id.is_empty() {
            part.material_slot_id = ComponentInstanceId::new(format!("slot:{}", part.id.0));
        }
        if let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) {
            if slot.name.trim().is_empty() {
                slot.name = part.name.clone();
            }
        } else {
            renderer.materials.slots.push(MaterialSlot {
                id: part.material_slot_id.clone(),
                name: part.name.clone(),
                material: None,
            });
        }
    }
}

fn normalize_primitive_surface(primitive: &mut Primitive) {
    if primitive.surface.id.is_empty() {
        primitive.surface.id = ComponentInstanceId::new(shared::PRIMITIVE_SURFACE_SLOT_ID);
    }
    if primitive.surface.name.trim().is_empty() {
        primitive.surface.name = "Surface".into();
    }
}

impl LegacyMaterialMigration<'_> {
    fn reference_for(&mut self, material: &MaterialDesc) -> Result<MaterialRef, String> {
        if let Some(path) = material.material_asset_path.as_deref() {
            if let Some(reference) = self.reference_for_existing(path) {
                return Ok(reference);
            }
        }

        let mut canonical = material.clone();
        canonical.material_asset_path = None;
        let canonical_ron = ron::ser::to_string(&canonical)
            .map_err(|error| format!("could not canonicalize legacy material: {error}"))?;
        let hash = blake3::hash(canonical_ron.as_bytes()).to_hex().to_string();
        let short = &hash[..16];
        let relative = format!("assets/materials/migrated/legacy-{short}.material.ron");
        let destination = self.root.join(&relative);
        let label = format!("Legacy Material {short}");
        let asset = MaterialAsset {
            schema_version: MATERIAL_ASSET_SCHEMA_VERSION,
            label: label.clone(),
            shader: canonical.shader.clone(),
            shader_ref: None,
            render_state: shared::MaterialRenderState::default(),
            provenance: None,
            inputs: shared::MaterialInputSet::from_material_desc(&canonical),
        };
        let contents = ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default())
            .map_err(|error| format!("could not serialize migrated material: {error}"))?;

        if destination.exists() {
            let existing = MaterialAsset::load_from_path(destination.to_string_lossy().as_ref())
                .map_err(|_| {
                    format!(
                        "legacy material migration collision at {}; choose another migration destination",
                        destination.display()
                    )
                })?;
            if existing.inputs != shared::MaterialInputSet::from_material_desc(&canonical) {
                return Err(format!(
                    "legacy material migration collision at {}",
                    destination.display()
                ));
            }
        } else {
            self.material_writes.entry(destination).or_insert(contents);
        }

        let record_index = if let Some(index) = self
            .registry
            .records
            .iter()
            .position(|record| record.path == relative)
        {
            index
        } else {
            let digest = blake3::hash(format!("blacksite:{relative}").as_bytes());
            let mut bytes = [0_u8; 16];
            bytes.copy_from_slice(&digest.as_bytes()[..16]);
            bytes[6] = (bytes[6] & 0x0f) | 0x50;
            bytes[8] = (bytes[8] & 0x3f) | 0x80;
            self.registry.records.push(AssetRecord {
                id: AssetId(uuid::Uuid::from_bytes(bytes)),
                path: relative.clone(),
                label: label.clone(),
                kind: AssetKind::Material,
                source_fingerprint: None,
                import_settings: shared::AssetImportSettings::None,
                dependencies: material_dependencies(&canonical),
            });
            self.registry.records.len() - 1
        };
        let record = &self.registry.records[record_index];
        Ok(MaterialRef::new(
            shared::EditorAssetRef::new(record.id.as_string(), "material:source", &record.label)
                .with_source_path(&record.path),
        ))
    }

    fn reference_for_existing(&self, path: &str) -> Option<MaterialRef> {
        let record = self
            .registry
            .records
            .iter()
            .find(|record| record.path == path)?;
        let full = self.root.join(path);
        let sub_asset_id =
            if MaterialInstanceAsset::load_from_path(full.to_string_lossy().as_ref()).is_ok() {
                "material:instance"
            } else if MaterialAsset::load_from_path(full.to_string_lossy().as_ref()).is_ok() {
                "material:source"
            } else {
                return None;
            };
        Some(MaterialRef::new(
            shared::EditorAssetRef::new(record.id.as_string(), sub_asset_id, &record.label)
                .with_source_path(path),
        ))
    }
}

fn material_dependencies(material: &MaterialDesc) -> Vec<String> {
    let mut dependencies = [
        material.base_color_texture.as_ref(),
        material.emissive_texture.as_ref(),
        material.normal_map_texture.as_ref(),
        material.metallic_roughness_texture.as_ref(),
    ]
    .into_iter()
    .flatten()
    .cloned()
    .collect::<Vec<_>>();
    dependencies.extend(material.textures.iter().filter_map(|texture| {
        texture
            .texture
            .as_ref()
            .and_then(|reference| reference.source_path.clone())
    }));
    dependencies.sort();
    dependencies.dedup();
    dependencies
}

fn normalize_component_states(states: &mut AuthoringComponentStates) {
    let old = std::mem::take(&mut states.states);
    for state in old {
        let key = if state.component_id.trim().is_empty() {
            state.type_name
        } else {
            state.component_id
        };
        if !key.trim().is_empty() {
            states.set_component_active(key, state.active);
        }
    }
}

#[derive(Deserialize)]
struct LegacyMaterialAssetV1 {
    label: String,
    #[serde(default)]
    shader: Option<String>,
    #[serde(default)]
    shader_ref: Option<shared::EditorAssetRef>,
    #[serde(default)]
    render_state: MaterialRenderState,
    #[serde(default)]
    provenance: Option<MaterialProvenance>,
    material: MaterialDesc,
}

#[derive(Deserialize)]
struct LegacyMaterialInstanceV1 {
    label: String,
    base: MaterialRef,
    #[serde(default)]
    parameters: Vec<MaterialParameter>,
    #[serde(default)]
    textures: Vec<shared::MaterialTextureBinding>,
}

#[derive(Deserialize)]
struct LegacyShaderSchemaV1 {
    label: String,
    kind: shared::MaterialShaderKind,
    #[serde(default)]
    wgsl_path: Option<String>,
    #[serde(default)]
    parameters: Vec<LegacyShaderInputV1>,
    #[serde(default)]
    default_values: Vec<MaterialParameter>,
    #[serde(default)]
    default_textures: Vec<shared::MaterialTextureBinding>,
}

#[derive(Deserialize)]
struct LegacyShaderInputV1 {
    name: String,
    display_name: String,
    #[serde(default)]
    group: String,
    property_type: ShaderPropertyType,
}

fn canonical_material_document(
    path: &Path,
    text: &str,
    registered_kind: Option<AssetKind>,
    migration: &mut LegacyMaterialMigration<'_>,
) -> Option<(String, &'static str)> {
    let is_instance = registered_kind == Some(AssetKind::MaterialInstance)
        || path
            .file_name()
            .and_then(|name| name.to_str())
            .is_some_and(|name| name.ends_with(".material-instance.ron"));
    if is_instance {
        if let Ok(instance) = ron::from_str::<MaterialInstanceAsset>(text) {
            if instance.schema_version == shared::MATERIAL_INSTANCE_SCHEMA_VERSION {
                return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default())
                    .ok()
                    .map(|text| (text, "material-instance-schema"));
            }
        }
        let legacy = ron::from_str::<LegacyMaterialInstanceV1>(text).ok()?;
        let mut overrides = MaterialInputSet {
            values: legacy.parameters,
            textures: legacy.textures,
        };
        resolve_texture_references(&mut overrides, &migration.registry);
        let instance = MaterialInstanceAsset {
            schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION,
            label: legacy.label,
            base: legacy.base,
            overrides,
        };
        update_material_instance_registry_dependencies(path, &instance, migration);
        return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default())
            .ok()
            .map(|text| (text, "material-instance-schema-v2"));
    }

    if let Ok(asset) = ron::from_str::<MaterialAsset>(text) {
        if asset.schema_version == shared::MATERIAL_ASSET_SCHEMA_VERSION {
            return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default())
                .ok()
                .map(|text| (text, "material-schema"));
        }
    }
    if let Ok(legacy) = ron::from_str::<LegacyMaterialAssetV1>(text) {
        let mut inputs = MaterialInputSet::from_material_desc(&legacy.material);
        resolve_texture_references(&mut inputs, &migration.registry);
        let mut shader = legacy.material.shader;
        if shader.schema_path.is_none() {
            shader.schema_path = legacy
                .shader
                .as_deref()
                .filter(|path| path.ends_with(".ron"))
                .map(str::to_string);
        }
        let asset = MaterialAsset {
            schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
            label: legacy.label,
            shader,
            shader_ref: legacy.shader_ref,
            render_state: legacy.render_state,
            provenance: legacy.provenance,
            inputs,
        };
        update_material_registry_dependencies(path, &asset.inputs, migration);
        return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default())
            .ok()
            .map(|text| (text, "material-schema-v2"));
    }
    None
}

fn canonical_shader_document(text: &str) -> Option<(String, &'static str)> {
    if let Ok(schema) = ron::from_str::<ShaderSchemaAsset>(text) {
        if schema.schema_version == shared::SURFACE_SHADER_SCHEMA_VERSION {
            return ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default())
                .ok()
                .map(|text| (text, "surface-shader-schema"));
        }
    }
    let legacy = ron::from_str::<LegacyShaderSchemaV1>(text).ok()?;
    let schema = if legacy.kind == shared::MaterialShaderKind::StandardLit {
        shared::standard_lit_input_schema()
    } else {
        legacy_shader_input_schema(&legacy)
    };
    let schema = ShaderSchemaAsset {
        schema_version: shared::SURFACE_SHADER_SCHEMA_VERSION,
        label: legacy.label,
        kind: legacy.kind,
        wgsl_path: legacy.wgsl_path,
        schema,
    };
    ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default())
        .ok()
        .map(|text| (text, "surface-shader-schema-v2"))
}

fn resolve_texture_references(inputs: &mut MaterialInputSet, registry: &AssetRegistryDocument) {
    for binding in &mut inputs.textures {
        let Some(reference) = binding.texture.as_mut() else {
            continue;
        };
        let Some(path) = reference.source_path.as_deref() else {
            continue;
        };
        if let Some(record) = registry.records.iter().find(|record| record.path == path) {
            reference.asset_id = record.id.as_string();
            reference.sub_asset_id = "texture:source".into();
            reference.label = record.label.clone();
        }
    }
}

fn update_material_registry_dependencies(
    path: &Path,
    inputs: &MaterialInputSet,
    migration: &mut LegacyMaterialMigration<'_>,
) {
    let relative = path
        .strip_prefix(migration.root)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/");
    let dependencies = inputs
        .textures
        .iter()
        .filter_map(|binding| binding.texture.as_ref())
        .filter_map(|reference| reference.source_path.clone())
        .collect::<std::collections::BTreeSet<_>>()
        .into_iter()
        .collect::<Vec<_>>();
    if let Some(record) = migration
        .registry
        .records
        .iter_mut()
        .find(|record| record.path == relative)
    {
        record.dependencies = dependencies;
    }
}

fn update_material_instance_registry_dependencies(
    path: &Path,
    instance: &MaterialInstanceAsset,
    migration: &mut LegacyMaterialMigration<'_>,
) {
    let relative = path
        .strip_prefix(migration.root)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/");
    let mut dependencies = instance
        .overrides
        .textures
        .iter()
        .filter_map(|binding| binding.texture.as_ref())
        .filter_map(|reference| reference.source_path.clone())
        .collect::<std::collections::BTreeSet<_>>();
    if let Some(path) = instance.base.0.source_path.as_ref() {
        dependencies.insert(path.clone());
    }
    if let Some(record) = migration
        .registry
        .records
        .iter_mut()
        .find(|record| record.path == relative)
    {
        record.dependencies = dependencies.into_iter().collect();
    }
}

fn legacy_shader_input_schema(legacy: &LegacyShaderSchemaV1) -> MaterialInputSchema {
    let mut group_names = Vec::new();
    for input in &legacy.parameters {
        if !group_names.contains(&input.group) {
            group_names.push(input.group.clone());
        }
    }
    let groups = group_names
        .iter()
        .enumerate()
        .map(|(index, name)| MaterialInputGroupDesc {
            id: legacy_group_id(name),
            display_name: if name.trim().is_empty() {
                "Properties".into()
            } else {
                name.clone()
            },
            order: index as i32,
            advanced: name.to_ascii_lowercase().contains("advanced"),
        })
        .collect::<Vec<_>>();
    let inputs = legacy
        .parameters
        .iter()
        .enumerate()
        .map(|(index, input)| shared::MaterialInputDesc {
            name: input.name.clone(),
            display_name: input.display_name.clone(),
            group: legacy_group_id(&input.group),
            order: index as i32,
            property_type: input.property_type.clone(),
            default_value: legacy
                .default_values
                .iter()
                .find(|value| value.name == input.name)
                .map(|value| value.value.clone()),
            texture: matches!(input.property_type, ShaderPropertyType::Texture).then(|| {
                let default_channel = legacy
                    .default_textures
                    .iter()
                    .find(|binding| binding.name == input.name)
                    .map(|binding| binding.channel)
                    .unwrap_or_default();
                shared::MaterialTextureInputDesc {
                    semantic: shared::TextureSemantic::Mask,
                    default_channel,
                    allow_channel_override: true,
                }
            }),
            ..Default::default()
        })
        .collect();
    MaterialInputSchema { groups, inputs }
}

fn legacy_group_id(name: &str) -> String {
    let value = name
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() {
                character.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect::<String>();
    let value = value.trim_matches('_');
    if value.is_empty() {
        "properties".into()
    } else {
        value.into()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use shared::{
        EditorAssetRef, InspectorComponentState, MaterialSlotOverride, MaterialSlotSet,
        MeshRenderSlot, AUTHORING_COMPONENT_STATIC_MESH_RENDERER,
    };

    fn migration_context(root: &Path) -> LegacyMaterialMigration<'_> {
        LegacyMaterialMigration {
            root,
            registry: AssetRegistryDocument::default(),
            material_writes: std::collections::BTreeMap::new(),
        }
    }

    #[test]
    fn dry_run_does_not_write_and_apply_creates_backup() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-upgrader-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let level = root.join("assets/levels/main.scn.ron");
        fs::create_dir_all(level.parent().unwrap()).unwrap();
        fs::write(&level, "(schema_version: 3, resources: {}, entities: {})").unwrap();

        let dry_run = upgrade_project(&root, false).unwrap();
        assert!(!dry_run.applied);
        assert_eq!(dry_run.changes.len(), 2);
        assert!(dry_run
            .changes
            .iter()
            .any(|change| change.path == "assets/levels/main.scn.ron"));
        assert!(dry_run
            .changes
            .iter()
            .any(|change| change.path == "assets/.index/registry.ron"));
        assert!(fs::read_to_string(&level)
            .unwrap()
            .contains("schema_version: 3"));

        let applied = upgrade_project(&root, true).unwrap();
        assert!(applied.applied);
        assert!(fs::read_to_string(&level)
            .unwrap()
            .contains("schema_version: 6"));
        assert!(root.join(applied.backup_path.unwrap()).exists());
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn canonical_upgrade_materializes_slots_and_separates_active_state() {
        let renderer = StaticMeshRenderer {
            slots: vec![MeshRenderSlot {
                id: ComponentInstanceId::new("draw:body"),
                name: "Body".into(),
                mesh: EditorAssetRef::new("model", "mesh:body", "Body"),
                material_slot_id: ComponentInstanceId::new("slot:draw:body"),
                ..Default::default()
            }],
            materials: MaterialSlotSet {
                slots: vec![MaterialSlot {
                    id: ComponentInstanceId::new("slot:draw:body"),
                    name: "Body".into(),
                    material: Some(MaterialRef::new(EditorAssetRef::new(
                        "model",
                        "material:body",
                        "Body Material",
                    ))),
                }],
                orphaned_assignments: Vec::new(),
            },
        };
        let order = InspectorOrder {
            component_type_names: vec![COMPONENT_STATIC_MESH_RENDERER.into()],
            component_states: vec![InspectorComponentState {
                type_name: COMPONENT_STATIC_MESH_RENDERER.into(),
                active: false,
                ..Default::default()
            }],
            ..Default::default()
        };
        let text = format!(
            "(schema_version: 3, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_STATIC_MESH_RENDERER}\": {}, \"{INSPECTOR_ORDER_COMPONENT}\": {}}})}})",
            ron::to_string(&renderer).unwrap(),
            ron::to_string(&order).unwrap(),
        );

        let migration_root = std::env::temp_dir().join(format!(
            "blacksite-canonical-upgrader-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let mut migration = migration_context(&migration_root);
        let upgraded = canonical_scene_document(&text, &mut migration).unwrap();
        let document = SceneDocument::from_ron_text(&upgraded).unwrap();
        let components = &document.entities[0].components;
        let renderer: StaticMeshRenderer = ron::from_str(
            &components
                .iter()
                .find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER)
                .unwrap()
                .ron,
        )
        .unwrap();
        assert_eq!(renderer.slots[0].material_slot_id.0, "slot:draw:body");
        assert_eq!(renderer.materials.slots.len(), 1);
        assert_eq!(
            renderer.materials.slots[0]
                .material
                .as_ref()
                .unwrap()
                .0
                .sub_asset_id,
            "material:body"
        );
        let order: InspectorOrder = ron::from_str(
            &components
                .iter()
                .find(|component| component.type_name == INSPECTOR_ORDER_COMPONENT)
                .unwrap()
                .ron,
        )
        .unwrap();
        assert!(order.component_states.is_empty());
        assert_eq!(
            order.component_ids,
            vec![AUTHORING_COMPONENT_STATIC_MESH_RENDERER]
        );
        let states: AuthoringComponentStates = ron::from_str(
            &components
                .iter()
                .find(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT)
                .unwrap()
                .ron,
        )
        .unwrap();
        assert!(!states.is_component_active(COMPONENT_STATIC_MESH_RENDERER));
    }

    #[test]
    fn primitive_legacy_material_becomes_one_deduplicated_project_asset_and_surface_assignment() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-primitive-material-upgrader-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let primitive = Primitive::default();
        let material = MaterialDesc {
            metallic: 0.65,
            roughness: 0.2,
            ..Default::default()
        };
        let entity = format!(
            "(components: {{\"{COMPONENT_PRIMITIVE}\": {}, \"{COMPONENT_MATERIAL_DESC}\": {}}})",
            ron::to_string(&primitive).unwrap(),
            ron::to_string(&material).unwrap(),
        );
        let text =
            format!("(schema_version: 4, resources: {{}}, entities: {{1: {entity}, 2: {entity}}})");
        let mut migration = migration_context(&root);

        let upgraded = canonical_scene_document(&text, &mut migration).unwrap();
        let document = SceneDocument::from_ron_text(&upgraded).unwrap();

        assert_eq!(migration.material_writes.len(), 1);
        assert_eq!(migration.registry.records.len(), 1);
        let assigned = document
            .entities
            .iter()
            .map(|entity| {
                assert!(!entity
                    .components
                    .iter()
                    .any(|component| component.type_name == COMPONENT_MATERIAL_DESC));
                let primitive: Primitive = ron::from_str(
                    &entity
                        .components
                        .iter()
                        .find(|component| component.type_name == COMPONENT_PRIMITIVE)
                        .unwrap()
                        .ron,
                )
                .unwrap();
                assert_eq!(primitive.surface.id.0, shared::PRIMITIVE_SURFACE_SLOT_ID);
                primitive.surface.material.unwrap()
            })
            .collect::<Vec<_>>();
        assert_eq!(assigned[0], assigned[1]);
        assert_eq!(
            assigned[0].0.asset_id,
            migration.registry.records[0].id.as_string()
        );
    }

    #[test]
    fn skinned_slot_override_migrates_to_the_exact_saved_slot() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-skinned-material-upgrader-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let slot_id = ComponentInstanceId::new("slot:body");
        let renderer = SkinnedMeshRenderer {
            materials: MaterialSlotSet {
                slots: vec![MaterialSlot {
                    id: slot_id.clone(),
                    name: "Body".into(),
                    ..Default::default()
                }],
                ..Default::default()
            },
            ..Default::default()
        };
        let overrides = MaterialOverride {
            slots: vec![MaterialSlotOverride {
                slot_id: slot_id.clone(),
                base_material: None,
                material: MaterialDesc {
                    metallic: 1.0,
                    ..Default::default()
                },
            }],
        };
        let text = format!(
            "(schema_version: 4, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_SKINNED_MESH_RENDERER}\": {}, \"{COMPONENT_MATERIAL_OVERRIDE}\": {}}})}})",
            ron::to_string(&renderer).unwrap(),
            ron::to_string(&overrides).unwrap(),
        );
        let mut migration = migration_context(&root);

        let upgraded = canonical_scene_document(&text, &mut migration).unwrap();
        let document = SceneDocument::from_ron_text(&upgraded).unwrap();
        let components = &document.entities[0].components;
        let renderer: SkinnedMeshRenderer = ron::from_str(
            &components
                .iter()
                .find(|component| component.type_name == COMPONENT_SKINNED_MESH_RENDERER)
                .unwrap()
                .ron,
        )
        .unwrap();

        assert!(renderer
            .materials
            .slot(&slot_id)
            .unwrap()
            .material
            .is_some());
        assert!(!components
            .iter()
            .any(|component| component.type_name == COMPONENT_MATERIAL_OVERRIDE));
        assert_eq!(migration.material_writes.len(), 1);
    }

    #[test]
    fn material_collision_blocks_apply_without_writing_any_project_bytes() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-material-collision-upgrader-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let material = MaterialDesc {
            metallic: 0.42,
            ..Default::default()
        };
        let scene = root.join("assets/levels/collision.scn.ron");
        fs::create_dir_all(scene.parent().unwrap()).unwrap();
        let scene_bytes = format!(
            "(schema_version: 4, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_PRIMITIVE}\": {}, \"{COMPONENT_MATERIAL_DESC}\": {}}})}})",
            ron::to_string(&Primitive::default()).unwrap(),
            ron::to_string(&material).unwrap(),
        );
        fs::write(&scene, &scene_bytes).unwrap();
        let canonical = ron::ser::to_string(&material).unwrap();
        let hash = blake3::hash(canonical.as_bytes()).to_hex().to_string();
        let collision = root.join(format!(
            "assets/materials/migrated/legacy-{}.material.ron",
            &hash[..16]
        ));
        fs::create_dir_all(collision.parent().unwrap()).unwrap();
        fs::write(&collision, "owned by another asset").unwrap();

        let preview = upgrade_project(&root, false).unwrap();
        assert_eq!(preview.warnings.len(), 1);
        let error = upgrade_project(&root, true).unwrap_err();

        assert!(error.contains("blocked"));
        assert_eq!(fs::read_to_string(&scene).unwrap(), scene_bytes);
        assert_eq!(
            fs::read_to_string(&collision).unwrap(),
            "owned by another asset"
        );
        assert!(!root.join("assets/.index/registry.ron").exists());
        assert!(!root.join(".blacksite").exists());
        fs::remove_dir_all(root).unwrap();
    }
}
