//! Versioned material, material-instance, and surface-shader asset documents.

use bevy::prelude::*;
use serde::{Deserialize, Serialize};

use crate::{
    EditorAssetRef, MaterialDesc, MaterialInputSchema, MaterialInputSet, MaterialInputSource,
    MaterialInputTarget, MaterialParameter, MaterialParameterValue, MaterialRef,
    MaterialRenderState, MaterialShaderKind, MaterialTextureBinding, ShaderRefDesc, TextureChannel,
};

pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 2;
pub const MATERIAL_INSTANCE_SCHEMA_VERSION: u32 = 2;
pub const SURFACE_SHADER_SCHEMA_VERSION: u32 = 2;

const fn default_material_schema_version() -> u32 {
    MATERIAL_ASSET_SCHEMA_VERSION
}

const fn default_material_instance_schema_version() -> u32 {
    MATERIAL_INSTANCE_SCHEMA_VERSION
}

const fn default_surface_shader_schema_version() -> u32 {
    SURFACE_SHADER_SCHEMA_VERSION
}

/// Converts the runtime/legacy descriptor into the lightweight authored input representation.
pub fn material_inputs_from_desc(material: &MaterialDesc) -> MaterialInputSet {
    let mut values = vec![
        MaterialParameter {
            name: "base_color".into(),
            value: MaterialParameterValue::Color(material.base_color),
        },
        MaterialParameter {
            name: "metallic".into(),
            value: MaterialParameterValue::Float(material.metallic),
        },
        MaterialParameter {
            name: "roughness".into(),
            value: MaterialParameterValue::Float(material.roughness),
        },
        MaterialParameter {
            name: "emissive_color".into(),
            value: MaterialParameterValue::Color(material.emissive_color),
        },
        MaterialParameter {
            name: "emissive_intensity".into(),
            value: MaterialParameterValue::Float(material.emissive_intensity),
        },
        MaterialParameter {
            name: "uv_offset".into(),
            value: MaterialParameterValue::Vec2(material.uv_offset),
        },
        MaterialParameter {
            name: "uv_tiling".into(),
            value: MaterialParameterValue::Vec2(material.uv_tiling),
        },
    ];
    for parameter in &material.parameters {
        if !is_standard_value_name(&parameter.name) {
            upsert_parameter(&mut values, parameter.clone());
        }
    }

    let mut textures = Vec::new();
    if let Some(path) = material.base_color_texture.as_deref() {
        textures.push(path_texture_binding(
            "base_color",
            path,
            TextureChannel::Rgba,
        ));
    }
    if let Some(path) = material.normal_map_texture.as_deref() {
        textures.push(path_texture_binding("normal", path, TextureChannel::Rgb));
    }
    if let Some(path) = material.metallic_roughness_texture.as_deref() {
        textures.push(path_texture_binding("occlusion", path, TextureChannel::R));
        textures.push(path_texture_binding("roughness", path, TextureChannel::G));
        textures.push(path_texture_binding("metallic", path, TextureChannel::B));
    }
    if let Some(path) = material.occlusion_texture.as_deref() {
        upsert_texture(
            &mut textures,
            path_texture_binding("occlusion", path, TextureChannel::R),
        );
    }
    if let Some(path) = material.emissive_texture.as_deref() {
        textures.push(path_texture_binding("emissive", path, TextureChannel::Rgb));
    }
    for texture in &material.textures {
        if !is_standard_texture_name(&texture.name) {
            upsert_texture(&mut textures, texture.clone());
        }
    }
    MaterialInputSet { values, textures }
}

/// Applies authored values to the runtime/legacy material representation.
pub fn apply_material_inputs_to_desc(inputs: &MaterialInputSet, material: &mut MaterialDesc) {
    material.parameters.clear();
    material.textures.clear();
    for parameter in &inputs.values {
        match (parameter.name.as_str(), &parameter.value) {
            ("base_color", MaterialParameterValue::Color(value)) => material.base_color = *value,
            ("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value,
            ("roughness", MaterialParameterValue::Float(value)) => material.roughness = *value,
            ("emissive_color", MaterialParameterValue::Color(value)) => {
                material.emissive_color = *value
            }
            ("emissive_intensity", MaterialParameterValue::Float(value)) => {
                material.emissive_intensity = *value
            }
            ("uv_offset", MaterialParameterValue::Vec2(value)) => material.uv_offset = *value,
            ("uv_tiling", MaterialParameterValue::Vec2(value)) => material.uv_tiling = *value,
            _ => upsert_parameter(&mut material.parameters, parameter.clone()),
        }
    }
    material.base_color_texture = binding_path(inputs.texture("base_color"));
    material.normal_map_texture = binding_path(inputs.texture("normal"));
    material.emissive_texture = binding_path(inputs.texture("emissive"));
    let roughness = binding_path(inputs.texture("roughness"));
    let metallic = binding_path(inputs.texture("metallic"));
    material.metallic_roughness_texture = match (roughness, metallic) {
        (Some(roughness), Some(metallic)) if roughness == metallic => Some(roughness),
        (Some(path), None) | (None, Some(path)) => Some(path),
        _ => None,
    };
    material.occlusion_texture = binding_path(inputs.texture("occlusion"));
    for texture in &inputs.textures {
        if !is_standard_texture_name(&texture.name) {
            upsert_texture(&mut material.textures, texture.clone());
        }
    }
}

impl MaterialInputSource for MaterialDesc {
    fn to_material_inputs(&self) -> MaterialInputSet {
        material_inputs_from_desc(self)
    }
}

impl MaterialInputTarget for MaterialDesc {
    fn apply_material_inputs(&mut self, inputs: &MaterialInputSet) {
        apply_material_inputs_to_desc(inputs, self);
    }
}

fn path_texture_binding(name: &str, path: &str, channel: TextureChannel) -> MaterialTextureBinding {
    let label = std::path::Path::new(path)
        .file_stem()
        .and_then(|value| value.to_str())
        .unwrap_or(path);
    MaterialTextureBinding {
        name: name.into(),
        texture: Some(
            EditorAssetRef::new(String::new(), format!("texture:{name}"), label)
                .with_source_path(path),
        ),
        channel,
    }
}

fn binding_path(binding: Option<&MaterialTextureBinding>) -> Option<String> {
    binding
        .and_then(|binding| binding.texture.as_ref())
        .and_then(|reference| reference.source_path.clone())
}

fn is_standard_value_name(name: &str) -> bool {
    matches!(
        name,
        "base_color"
            | "metallic"
            | "roughness"
            | "emissive_color"
            | "emissive_intensity"
            | "uv_offset"
            | "uv_tiling"
    )
}

fn is_standard_texture_name(name: &str) -> bool {
    matches!(
        name,
        "base_color"
            | "base_color_texture"
            | "normal"
            | "normal_map_texture"
            | "occlusion"
            | "roughness"
            | "metallic"
            | "metallic_roughness"
            | "metallic_roughness_texture"
            | "emissive"
            | "emissive_texture"
    )
}

/// RON material asset with schema-driven authoring inputs.
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct MaterialAsset {
    #[serde(default = "default_material_schema_version")]
    pub schema_version: u32,
    pub label: String,
    #[serde(default)]
    pub shader: ShaderRefDesc,
    #[serde(default)]
    pub shader_ref: Option<EditorAssetRef>,
    #[serde(default)]
    pub render_state: MaterialRenderState,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provenance: Option<MaterialProvenance>,
    #[serde(default)]
    pub inputs: MaterialInputSet,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MaterialProvenance {
    pub source_path: String,
    pub source_fingerprint: String,
    pub source_sub_asset_id: String,
    pub source_label: String,
}

impl MaterialAsset {
    pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
        let text = std::fs::read_to_string(catalog_path)
            .map_err(|err| format!("could not read {catalog_path}: {err}"))?;
        ron::from_str(&text).map_err(|err| format!("invalid material RON in {catalog_path}: {err}"))
    }
}

/// Loads a project Material or direct-base Material Instance into one effective descriptor.
///
/// The returned descriptor retains the selected asset path so primitive/brush authoring can locate
/// the shared source while renderer slots continue to preserve their stable [`MaterialRef`].
pub fn load_resolved_material_from_path(
    catalog_path: &str,
) -> Result<(MaterialDesc, MaterialRenderState), String> {
    if let Ok(asset) = MaterialAsset::load_from_path(catalog_path) {
        let mut material = MaterialDesc {
            shader: asset.shader,
            ..Default::default()
        };
        asset.inputs.apply_to_material_desc(&mut material);
        material.material_asset_path = Some(catalog_path.to_string());
        return Ok((material, asset.render_state));
    }

    let instance = MaterialInstanceAsset::load_from_path(catalog_path)?;
    let base_path = instance.base.0.source_path.as_deref().ok_or_else(|| {
        format!(
            "material instance {} has no loadable base path",
            instance.label
        )
    })?;
    let base = MaterialAsset::load_from_path(base_path)?;
    let mut material = MaterialDesc {
        shader: base.shader,
        ..Default::default()
    };
    base.inputs.apply_to_material_desc(&mut material);
    instance.apply_to(&mut material);
    material.material_asset_path = Some(catalog_path.to_string());
    Ok((material, base.render_state))
}

/// Explicit reusable overrides over one project Material asset.
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct MaterialInstanceAsset {
    #[serde(default = "default_material_instance_schema_version")]
    pub schema_version: u32,
    pub label: String,
    pub base: MaterialRef,
    #[serde(default)]
    pub overrides: MaterialInputSet,
}

impl MaterialInstanceAsset {
    pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
        let text = std::fs::read_to_string(catalog_path)
            .map_err(|err| format!("could not read {catalog_path}: {err}"))?;
        ron::from_str(&text)
            .map_err(|err| format!("invalid material-instance RON in {catalog_path}: {err}"))
    }

    /// Applies this instance's sparse overrides without mutating the shared base asset.
    pub fn apply_to(&self, material: &mut MaterialDesc) {
        for parameter in &self.overrides.values {
            match (parameter.name.as_str(), &parameter.value) {
                ("base_color", crate::MaterialParameterValue::Color(value)) => {
                    material.base_color = *value;
                }
                ("metallic", crate::MaterialParameterValue::Float(value)) => {
                    material.metallic = *value;
                }
                ("roughness", crate::MaterialParameterValue::Float(value)) => {
                    material.roughness = *value;
                }
                ("emissive_color", crate::MaterialParameterValue::Color(value)) => {
                    material.emissive_color = *value;
                }
                ("emissive_intensity", crate::MaterialParameterValue::Float(value)) => {
                    material.emissive_intensity = *value;
                }
                ("uv_offset", crate::MaterialParameterValue::Vec2(value)) => {
                    material.uv_offset = *value;
                }
                ("uv_tiling", crate::MaterialParameterValue::Vec2(value)) => {
                    material.uv_tiling = *value;
                }
                _ => upsert_parameter(&mut material.parameters, parameter.clone()),
            }
        }
        for texture in &self.overrides.textures {
            let path = texture
                .texture
                .as_ref()
                .and_then(|reference| reference.source_path.clone());
            match texture.name.as_str() {
                "base_color" | "base_color_texture" => material.base_color_texture = path,
                "emissive" | "emissive_texture" => material.emissive_texture = path,
                "normal" | "normal_map_texture" => material.normal_map_texture = path,
                "roughness" | "metallic" | "metallic_roughness_texture" => {
                    material.metallic_roughness_texture = path
                }
                _ => upsert_texture(&mut material.textures, texture.clone()),
            }
        }
    }

    /// Overlays sparse runtime/property-block values onto this direct-base instance.
    pub fn merge_overrides(
        &mut self,
        parameters: impl IntoIterator<Item = MaterialParameter>,
        textures: impl IntoIterator<Item = MaterialTextureBinding>,
    ) {
        for parameter in parameters {
            upsert_parameter(&mut self.overrides.values, parameter);
        }
        for texture in textures {
            upsert_texture(&mut self.overrides.textures, texture);
        }
    }
}

fn upsert_parameter(values: &mut Vec<MaterialParameter>, value: MaterialParameter) {
    if let Some(existing) = values.iter_mut().find(|entry| entry.name == value.name) {
        *existing = value;
    } else {
        values.push(value);
    }
}

fn upsert_texture(values: &mut Vec<MaterialTextureBinding>, value: MaterialTextureBinding) {
    if let Some(existing) = values.iter_mut().find(|entry| entry.name == value.name) {
        *existing = value;
    } else {
        values.push(value);
    }
}

/// RON shader schema describing material inspector parameters.
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ShaderSchemaAsset {
    #[serde(default = "default_surface_shader_schema_version")]
    pub schema_version: u32,
    pub label: String,
    pub kind: MaterialShaderKind,
    #[serde(default)]
    pub wgsl_path: Option<String>,
    #[serde(default)]
    pub schema: MaterialInputSchema,
}

impl ShaderSchemaAsset {
    pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
        let text = std::fs::read_to_string(catalog_path)
            .map_err(|err| format!("could not read {catalog_path}: {err}"))?;
        ron::from_str(&text)
            .map_err(|err| format!("invalid shader schema RON in {catalog_path}: {err}"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        standard_lit_input_schema, MaterialAlphaMode, MaterialInputPresentation, ShaderPropertyType,
    };

    #[test]
    fn material_v2_defaults_to_opaque() {
        let parsed = MaterialAsset {
            schema_version: MATERIAL_ASSET_SCHEMA_VERSION,
            label: "Material".into(),
            shader: ShaderRefDesc::default(),
            shader_ref: None,
            render_state: MaterialRenderState::default(),
            provenance: None,
            inputs: MaterialInputSet::default(),
        };

        assert_eq!(parsed.render_state.alpha_mode, MaterialAlphaMode::Opaque);
    }

    #[test]
    fn material_instance_requires_an_explicit_base_ref() {
        let instance = MaterialInstanceAsset {
            schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION,
            label: "Red Variant".into(),
            base: MaterialRef::new(EditorAssetRef::new(
                "material-id",
                "material:source",
                "Base",
            )),
            overrides: MaterialInputSet::default(),
        };

        assert!(instance.base.is_resolved());
    }

    #[test]
    fn material_instance_applies_standard_and_custom_overrides() {
        let mut material = MaterialDesc::default();
        let instance = MaterialInstanceAsset {
            schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION,
            label: "Variant".into(),
            base: MaterialRef::default(),
            overrides: MaterialInputSet {
                values: vec![
                    MaterialParameter {
                        name: "roughness".into(),
                        value: crate::MaterialParameterValue::Float(0.2),
                    },
                    MaterialParameter {
                        name: "edge_width".into(),
                        value: crate::MaterialParameterValue::Float(3.0),
                    },
                ],
                textures: vec![MaterialTextureBinding {
                    name: "base_color_texture".into(),
                    texture: Some(
                        EditorAssetRef::new("texture", "texture:source", "Grid")
                            .with_source_path("assets/textures/grid.png"),
                    ),
                    channel: TextureChannel::Rgba,
                }],
            },
        };

        instance.apply_to(&mut material);

        assert_eq!(material.roughness, 0.2);
        assert_eq!(
            material.base_color_texture.as_deref(),
            Some("assets/textures/grid.png")
        );
        assert!(material
            .parameters
            .iter()
            .any(|value| value.name == "edge_width"));
    }

    #[test]
    fn material_inputs_round_trip_shared_uv_transform() {
        let source = MaterialDesc {
            uv_offset: Vec2::new(0.25, -0.5),
            uv_tiling: Vec2::new(4.0, 2.0),
            ..Default::default()
        };
        let inputs = MaterialInputSet::from_material_desc(&source);
        let mut resolved = MaterialDesc::default();

        inputs.apply_to_material_desc(&mut resolved);

        assert_eq!(resolved.uv_offset, source.uv_offset);
        assert_eq!(resolved.uv_tiling, source.uv_tiling);
    }

    #[test]
    fn standard_lit_surface_inputs_match_penpot_bindings() {
        let schema = standard_lit_input_schema();
        let surface = schema
            .inputs
            .iter()
            .filter(|input| input.group == "surface_inputs")
            .collect::<Vec<_>>();
        let rows = surface
            .iter()
            .filter(|input| matches!(input.presentation, MaterialInputPresentation::Row))
            .count();
        assert_eq!(surface.len(), 7);
        assert_eq!(rows, 6);
        assert_eq!(surface[0].name, "base_color");
        assert_eq!(surface[4].name, "normal");
        assert!(matches!(
            surface[4].property_type,
            ShaderPropertyType::Float {
                min: Some(0.0),
                max: Some(1.0)
            }
        ));
        assert_eq!(surface[6].name, "emissive_intensity");
        assert_eq!(
            surface[6].presentation,
            MaterialInputPresentation::Companion {
                owner: "emissive_color".into()
            }
        );
    }

    #[test]
    fn promotion_merge_replaces_existing_values_without_duplicates() {
        let mut instance = MaterialInstanceAsset {
            schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION,
            label: "Variant".into(),
            base: MaterialRef::default(),
            overrides: MaterialInputSet {
                values: vec![MaterialParameter {
                    name: "roughness".into(),
                    value: crate::MaterialParameterValue::Float(0.8),
                }],
                textures: Vec::new(),
            },
        };
        instance.merge_overrides(
            [MaterialParameter {
                name: "roughness".into(),
                value: crate::MaterialParameterValue::Float(0.2),
            }],
            [MaterialTextureBinding {
                name: "normal_map_texture".into(),
                texture: None,
                channel: TextureChannel::Rgb,
            }],
        );

        assert_eq!(instance.overrides.values.len(), 1);
        assert_eq!(
            instance.overrides.values[0].value,
            crate::MaterialParameterValue::Float(0.2)
        );
        assert_eq!(instance.overrides.textures.len(), 1);
    }

    #[test]
    fn resolved_material_instance_applies_sparse_overrides_and_keeps_instance_path() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-resolved-material-instance-{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let base_path = root.join("base.ron");
        let instance_path = root.join("instance.ron");
        std::fs::create_dir_all(&root).unwrap();
        let base = MaterialAsset {
            schema_version: MATERIAL_ASSET_SCHEMA_VERSION,
            label: "Base".into(),
            shader: ShaderRefDesc::default(),
            shader_ref: None,
            render_state: MaterialRenderState {
                double_sided: true,
                ..Default::default()
            },
            provenance: None,
            inputs: MaterialInputSet::from_material_desc(&MaterialDesc {
                roughness: 0.8,
                ..Default::default()
            }),
        };
        std::fs::write(
            &base_path,
            ron::ser::to_string_pretty(&base, ron::ser::PrettyConfig::default()).unwrap(),
        )
        .unwrap();
        let instance = MaterialInstanceAsset {
            schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION,
            label: "Instance".into(),
            base: MaterialRef::new(
                EditorAssetRef::new("base", "material:source", "Base")
                    .with_source_path(base_path.to_string_lossy().into_owned()),
            ),
            overrides: MaterialInputSet {
                values: vec![MaterialParameter {
                    name: "roughness".into(),
                    value: crate::MaterialParameterValue::Float(0.25),
                }],
                textures: Vec::new(),
            },
        };
        std::fs::write(
            &instance_path,
            ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()).unwrap(),
        )
        .unwrap();

        let (resolved, state) =
            load_resolved_material_from_path(instance_path.to_str().unwrap()).unwrap();

        assert_eq!(resolved.roughness, 0.25);
        assert_eq!(
            resolved.material_asset_path.as_deref(),
            instance_path.to_str()
        );
        assert!(state.double_sided);
        std::fs::remove_dir_all(root).unwrap();
    }
}
