//! Explicit, transactional project content upgrade support.

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

use serde::Serialize;
use shared::{
    AuthoringComponentStates, ComponentInstanceId, InspectorOrder, MaterialAsset,
    MaterialInstanceAsset, MaterialRef, RendererMaterialSlot, ShaderSchemaAsset,
    StaticMeshRenderer, COMPONENT_STATIC_MESH_RENDERER,
};

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

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,
}

pub fn upgrade_project(root: &Path, apply: bool) -> Result<ProjectUpgradeReport, String> {
    let assets = root.join("assets");
    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('\\', "/");
        let Some(original) = fs::read_to_string(&path).ok() else {
            continue;
        };
        let upgraded = if normalized.ends_with(".scn.ron") || normalized.ends_with(".prefab.ron") {
            match canonical_scene_document(&original) {
                Ok(value) => Some((value, "scene-schema")),
                Err(error) => {
                    warnings.push(format!("{normalized}: {error}"));
                    None
                }
            }
        } else if normalized.contains("/materials/") && normalized.ends_with(".ron") {
            canonical_material_document(&path, &original)
        } else if normalized.contains("/shaders/") && normalized.ends_with(".shader.ron") {
            canonical_shader_document(&original)
        } else {
            None
        };
        let Some((contents, kind)) = upgraded else {
            continue;
        };
        if normalize_text(&contents) != normalize_text(&original) {
            pending.push(PendingWrite {
                path,
                contents,
                kind,
            });
        }
    }

    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,
        });
    }

    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-component-v4-{timestamp}"));
    for write in &pending {
        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() {
            fs::create_dir_all(parent).map_err(|error| error.to_string())?;
        }
        fs::copy(&write.path, &backup).map_err(|error| {
            format!(
                "could not back up {} to {}: {error}",
                write.path.display(),
                backup.display()
            )
        })?;
    }

    let mut staged = Vec::new();
    for (index, write) in pending.iter().enumerate() {
        let temporary = write
            .path
            .with_extension(format!("blacksite-upgrade-{index}.tmp"));
        fs::write(&temporary, &write.contents)
            .map_err(|error| format!("could not stage {}: {error}", write.path.display()))?;
        staged.push((temporary, write.path.clone()));
    }
    for (temporary, destination) in &staged {
        if let Err(error) = fs::rename(temporary, destination) {
            for change in &changes {
                let backup = backup_root.join(&change.path);
                let destination = root.join(&change.path);
                let _ = fs::copy(backup, destination);
            }
            return Err(format!(
                "project upgrade failed while replacing {} and was rolled back: {error}",
                destination.display()
            ));
        }
    }

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

fn canonical_scene_document(text: &str) -> Result<String, String> {
    let mut document = SceneDocument::from_ron_text(text)?;
    for entity in &mut document.entities {
        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<_>>();

        if let Some(component) = entity
            .components
            .iter_mut()
            .find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER)
        {
            let mut renderer: StaticMeshRenderer =
                ron::from_str(&component.ron).map_err(|error| {
                    format!("could not upgrade StaticMeshRenderer component: {error}")
                })?;
            normalize_static_renderer(&mut renderer);
            *component =
                SceneComponentBlob::from_serializable(COMPONENT_STATIC_MESH_RENDERER, &renderer)?;
        }

        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));
        }
        let legacy_material = part.material.take().map(MaterialRef::new);
        if let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) {
            if slot.source_material.is_none() {
                slot.source_material = legacy_material;
            }
            if slot.name.trim().is_empty() {
                slot.name = part.name.clone();
            }
        } else {
            renderer.materials.slots.push(RendererMaterialSlot {
                id: part.material_slot_id.clone(),
                name: part.name.clone(),
                source_material: legacy_material,
                material: None,
            });
        }
    }
}

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);
        }
    }
}

fn canonical_material_document(path: &Path, text: &str) -> Option<(String, &'static str)> {
    if let Ok(asset) = ron::from_str::<MaterialAsset>(text) {
        return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default())
            .ok()
            .map(|text| (text, "material-schema"));
    }
    if let Ok(instance) = ron::from_str::<MaterialInstanceAsset>(text) {
        return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default())
            .ok()
            .map(|text| (text, "material-instance-schema"));
    }
    let _ = path;
    None
}

fn canonical_shader_document(text: &str) -> Option<(String, &'static str)> {
    let schema = ron::from_str::<ShaderSchemaAsset>(text).ok()?;
    ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default())
        .ok()
        .map(|text| (text, "surface-shader-schema"))
}

fn collect_files(path: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
    if !path.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
        let entry = entry.map_err(|error| error.to_string())?;
        let path = entry.path();
        if entry
            .file_type()
            .map_err(|error| error.to_string())?
            .is_dir()
        {
            collect_files(&path, output)?;
        } else {
            output.push(path);
        }
    }
    Ok(())
}

fn normalize_text(text: &str) -> String {
    text.trim().replace("\r\n", "\n")
}

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

    #[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(), 1);
        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: 4"));
        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: Some(EditorAssetRef::new(
                    "model",
                    "material:body",
                    "Body Material",
                )),
                ..Default::default()
            }],
            ..Default::default()
        };
        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 upgraded = canonical_scene_document(&text).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!(renderer.slots[0].material.is_none());
        assert_eq!(renderer.materials.slots.len(), 1);
        assert_eq!(
            renderer.materials.slots[0]
                .source_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));
    }
}
