//! Deterministic, GPU-free texture processing for editor, watcher, CI, and packaging.

use image::{imageops::FilterType, Rgba, RgbaImage};
use shared::{
    resolves_srgb, AssetKind, AssetRecord, AssetRegistryDocument, MaterialAsset, MaterialInputSet,
    MaterialInstanceAsset, MaterialRuntimeData, MaterialTextureBinding, NormalMapConvention,
    TextureAssetSemantic, TextureChannel, TextureColorSpace, TextureCompression,
    TextureImportSettings, TextureMipmapMode, TextureRuntimeData,
};
use std::path::{Path, PathBuf};

pub const TEXTURE_PROCESSOR_VERSION: u32 = 1;

#[derive(Debug, Clone)]
pub struct TextureArtifactPlan {
    pub output_path: PathBuf,
    pub output_bytes: Vec<u8>,
    pub runtime: TextureRuntimeData,
    pub reused: bool,
}

#[derive(Debug, Clone)]
pub struct MaterialArtifactPlan {
    pub output_path: PathBuf,
    pub output_bytes: Vec<u8>,
    pub runtime: MaterialRuntimeData,
    pub reused: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MaterialPackingSignature(pub String);

pub fn plan_texture_artifact(
    project_root: &Path,
    record: &AssetRecord,
) -> Result<TextureArtifactPlan, String> {
    if record.kind != AssetKind::Texture {
        return Err(format!("{} is not a Texture asset", record.path));
    }
    let source_path = project_root.join(&record.path);
    let source_bytes = std::fs::read(&source_path)
        .map_err(|error| format!("could not read texture {}: {error}", source_path.display()))?;
    let mut settings = record
        .texture_import()
        .cloned()
        .ok_or_else(|| format!("{} has no Texture import settings", record.path))?;
    normalize_texture_settings(&record.path, &mut settings);

    let settings_bytes = ron::ser::to_string(&settings)
        .map_err(|error| format!("could not hash Texture import settings: {error}"))?;
    let mut hasher = blake3::Hasher::new();
    hasher.update(&source_bytes);
    hasher.update(settings_bytes.as_bytes());
    hasher.update(&TEXTURE_PROCESSOR_VERSION.to_le_bytes());
    let processing_key = hasher.finalize().to_hex()[..24].to_string();

    let is_srgb = resolves_srgb(&settings);
    let extension = match settings.compression {
        TextureCompression::Uastc | TextureCompression::Auto => "basis",
        TextureCompression::Uncompressed => "ktx2",
    };
    let relative_output = PathBuf::from(format!(
        "assets/.import-cache/runtime/textures/{}/{processing_key}/texture.{extension}",
        record.id.as_string()
    ));
    let output_path = project_root.join(&relative_output);
    let runtime = TextureRuntimeData {
        source_path: record.path.clone(),
        processed_path: Some(normalize_path(&relative_output)),
        processing_key: Some(processing_key),
        settings,
        is_srgb,
    };
    if output_path.is_file() {
        return Ok(TextureArtifactPlan {
            output_path,
            output_bytes: Vec::new(),
            runtime,
            reused: true,
        });
    }

    let mut image = image::load_from_memory(&source_bytes)
        .map_err(|error| format!("could not decode texture {}: {error}", record.path))?
        .to_rgba8();
    image = resize_to_limit(image, runtime.settings.max_dimension);
    if runtime.settings.semantic == TextureAssetSemantic::Normal
        && runtime.settings.normal_map_convention == NormalMapConvention::DirectX
    {
        flip_normal_green(&mut image);
    }

    let output_bytes = match runtime.settings.compression {
        TextureCompression::Uastc | TextureCompression::Auto => {
            let mip_count =
                requested_mip_count(image.width(), image.height(), runtime.settings.mipmaps);
            encode_uastc(&image, is_srgb, mip_count > 1)?
        }
        TextureCompression::Uncompressed => {
            let mips = generate_mips(&image, runtime.settings.mipmaps);
            encode_uncompressed_ktx2(&mips, is_srgb)?
        }
    };
    Ok(TextureArtifactPlan {
        output_path,
        output_bytes,
        runtime,
        reused: false,
    })
}

pub fn publish_texture_artifact(plan: &TextureArtifactPlan) -> Result<bool, String> {
    if plan.reused {
        return Ok(false);
    }
    crate::write_if_changed(&plan.output_path, &plan.output_bytes)
}

pub fn plan_material_artifact(
    project_root: &Path,
    record: &AssetRecord,
    registry: &AssetRegistryDocument,
) -> Result<Option<MaterialArtifactPlan>, String> {
    if !matches!(
        record.kind,
        AssetKind::Material | AssetKind::MaterialInstance
    ) {
        return Ok(None);
    }
    let inputs = load_resolved_material_inputs(project_root, record)?;
    let scalar_bindings = [
        assigned_texture(&inputs, "occlusion"),
        assigned_texture(&inputs, "roughness"),
        assigned_texture(&inputs, "metallic"),
    ];
    if scalar_bindings.iter().all(|binding| binding.is_none()) {
        return Ok(None);
    }

    let signature = material_packing_signature_from_inputs(project_root, registry, &inputs)?;
    let processing_key = signature.0;
    let relative_output = PathBuf::from(format!(
        "assets/.import-cache/runtime/materials/{}/{}-arm.basis",
        record.id.as_string(),
        processing_key
    ));
    let output_path = project_root.join(&relative_output);
    if output_path.is_file() {
        return Ok(Some(MaterialArtifactPlan {
            output_path,
            output_bytes: Vec::new(),
            runtime: MaterialRuntimeData {
                packed_arm_path: Some(normalize_path(&relative_output)),
                processing_key: Some(processing_key),
            },
            reused: true,
        }));
    }

    let loaded = scalar_bindings
        .iter()
        .map(|binding| {
            (*binding)
                .map(|binding| load_binding_image(project_root, registry, binding))
                .transpose()
        })
        .collect::<Result<Vec<_>, _>>()?;
    let (width, height) = loaded
        .iter()
        .flatten()
        .map(|(_, image)| (image.width(), image.height()))
        .next()
        .ok_or_else(|| format!("{} has packed inputs but no readable Texture", record.path))?;
    let loaded = loaded
        .into_iter()
        .map(|entry| {
            entry.map(|(bytes, image)| {
                let image = if image.width() == width && image.height() == height {
                    image
                } else {
                    image::imageops::resize(&image, width, height, FilterType::Lanczos3)
                };
                (bytes, image)
            })
        })
        .collect::<Vec<_>>();
    let mut packed = RgbaImage::new(width, height);
    for y in 0..height {
        for x in 0..width {
            let ao = sample_scalar(loaded[0].as_ref(), scalar_bindings[0], x, y, 255);
            let roughness = sample_scalar(loaded[1].as_ref(), scalar_bindings[1], x, y, 255);
            let metallic = sample_scalar(loaded[2].as_ref(), scalar_bindings[2], x, y, 0);
            packed.put_pixel(x, y, Rgba([ao, roughness, metallic, 255]));
        }
    }
    Ok(Some(MaterialArtifactPlan {
        output_path,
        output_bytes: encode_uastc(&packed, false, true)?,
        runtime: MaterialRuntimeData {
            packed_arm_path: Some(normalize_path(&relative_output)),
            processing_key: Some(processing_key),
        },
        reused: false,
    }))
}

pub fn publish_material_artifact(plan: &MaterialArtifactPlan) -> Result<bool, String> {
    if plan.reused {
        return Ok(false);
    }
    crate::write_if_changed(&plan.output_path, &plan.output_bytes)
}

pub fn material_packing_signature(
    project_root: &Path,
    record: &AssetRecord,
    registry: &AssetRegistryDocument,
) -> Result<Option<MaterialPackingSignature>, String> {
    if !matches!(
        record.kind,
        AssetKind::Material | AssetKind::MaterialInstance
    ) {
        return Ok(None);
    }
    let inputs = load_resolved_material_inputs(project_root, record)?;
    if ["occlusion", "roughness", "metallic"]
        .into_iter()
        .all(|name| assigned_texture(&inputs, name).is_none())
    {
        return Ok(None);
    }
    material_packing_signature_from_inputs(project_root, registry, &inputs).map(Some)
}

fn material_packing_signature_from_inputs(
    project_root: &Path,
    registry: &AssetRegistryDocument,
    inputs: &MaterialInputSet,
) -> Result<MaterialPackingSignature, String> {
    let mut hasher = blake3::Hasher::new();
    hasher.update(&TEXTURE_PROCESSOR_VERSION.to_le_bytes());
    for name in ["occlusion", "roughness", "metallic"] {
        hasher.update(name.as_bytes());
        let Some(binding) = assigned_texture(inputs, name) else {
            hasher.update(b"unassigned");
            continue;
        };
        hasher.update(format!("{:?}", binding.channel).as_bytes());
        let (path, bytes, settings) = binding_source(project_root, registry, binding)?;
        hasher.update(path.as_bytes());
        hasher.update(blake3::hash(&bytes).as_bytes());
        let settings = ron::ser::to_string(&settings)
            .map_err(|error| format!("could not hash Texture import settings: {error}"))?;
        hasher.update(settings.as_bytes());
    }
    Ok(MaterialPackingSignature(
        hasher.finalize().to_hex()[..24].to_string(),
    ))
}

fn assigned_texture<'a>(
    inputs: &'a MaterialInputSet,
    name: &str,
) -> Option<&'a MaterialTextureBinding> {
    inputs
        .texture(name)
        .filter(|binding| binding.texture.is_some())
}

pub fn apply_material_plan_to_catalog(
    catalog: &mut shared::RuntimeContentCatalog,
    asset_id: &shared::AssetId,
    plan: &MaterialArtifactPlan,
) -> Result<(), String> {
    let record = catalog
        .records
        .iter_mut()
        .find(|record| &record.id == asset_id)
        .ok_or_else(|| format!("runtime catalog is missing Material asset {asset_id:?}"))?;
    record.material = Some(plan.runtime.clone());
    Ok(())
}

pub fn apply_texture_plan_to_catalog(
    catalog: &mut shared::RuntimeContentCatalog,
    asset_id: &shared::AssetId,
    plan: &TextureArtifactPlan,
) -> Result<(), String> {
    let record = catalog
        .records
        .iter_mut()
        .find(|record| &record.id == asset_id)
        .ok_or_else(|| format!("runtime catalog is missing Texture asset {asset_id:?}"))?;
    record.texture = Some(plan.runtime.clone());
    Ok(())
}

pub fn normalize_texture_settings(path: &str, settings: &mut TextureImportSettings) {
    if settings.semantic == TextureAssetSemantic::Auto {
        settings.semantic = infer_texture_semantic(path);
    }
    if settings.color_space == TextureColorSpace::Auto {
        settings.color_space = match settings.semantic {
            TextureAssetSemantic::Color | TextureAssetSemantic::Ui => TextureColorSpace::Srgb,
            TextureAssetSemantic::Auto
            | TextureAssetSemantic::Normal
            | TextureAssetSemantic::MaskData
            | TextureAssetSemantic::Hdr => TextureColorSpace::Linear,
        };
    }
    if settings.compression == TextureCompression::Auto {
        settings.compression = if settings.semantic == TextureAssetSemantic::Hdr {
            TextureCompression::Uncompressed
        } else {
            TextureCompression::Uastc
        };
    }
    settings.anisotropy = settings.anisotropy.clamp(1, 16);
    settings.max_dimension = settings.max_dimension.filter(|dimension| *dimension > 0);
}

pub fn infer_texture_semantic(path: &str) -> TextureAssetSemantic {
    let lowercase = path.to_ascii_lowercase();
    if lowercase.ends_with(".hdr") || lowercase.ends_with(".exr") {
        TextureAssetSemantic::Hdr
    } else if ["_normal", "_nor", "_nrm", "normal.", "nor_gl", "nor_dx"]
        .iter()
        .any(|marker| lowercase.contains(marker))
    {
        TextureAssetSemantic::Normal
    } else if [
        "_arm",
        "_orm",
        "_rma",
        "_mask",
        "_rough",
        "_metal",
        "_ao",
        "occlusion",
        "height",
    ]
    .iter()
    .any(|marker| lowercase.contains(marker))
    {
        TextureAssetSemantic::MaskData
    } else {
        TextureAssetSemantic::Color
    }
}

fn resize_to_limit(image: RgbaImage, max_dimension: Option<u32>) -> RgbaImage {
    let Some(limit) = max_dimension else {
        return image;
    };
    let largest = image.width().max(image.height());
    if largest <= limit {
        return image;
    }
    let scale = limit as f64 / largest as f64;
    let width = ((image.width() as f64 * scale).round() as u32).max(1);
    let height = ((image.height() as f64 * scale).round() as u32).max(1);
    image::imageops::resize(&image, width, height, FilterType::Lanczos3)
}

pub fn load_resolved_material_inputs(
    project_root: &Path,
    record: &AssetRecord,
) -> Result<MaterialInputSet, String> {
    let path = project_root.join(&record.path);
    match record.kind {
        AssetKind::Material => Ok(load_ron::<MaterialAsset>(&path)?.inputs),
        AssetKind::MaterialInstance => {
            let instance = load_ron::<MaterialInstanceAsset>(&path)?;
            let base_path = instance
                .base
                .0
                .source_path
                .as_deref()
                .ok_or_else(|| format!("{} has no loadable base Material path", record.path))?;
            let mut inputs = load_ron::<MaterialAsset>(&project_root.join(base_path))?.inputs;
            merge_input_sets(&mut inputs, &instance.overrides);
            Ok(inputs)
        }
        _ => Err(format!("{} is not a Material asset", record.path)),
    }
}

/// Returns direct Material Instance consumers of one base Material.
///
/// Blacksite currently supports direct-base instances only, so this is the complete dependency
/// closure for a base Material packing change.
pub fn direct_material_instance_dependents(
    project_root: &Path,
    registry: &AssetRegistryDocument,
    base: &AssetRecord,
) -> Vec<AssetRecord> {
    registry
        .records
        .iter()
        .filter(|record| record.kind == AssetKind::MaterialInstance)
        .filter_map(|record| {
            let instance =
                load_ron::<MaterialInstanceAsset>(&project_root.join(&record.path)).ok()?;
            reference_targets_record(&instance.base.0, base).then(|| record.clone())
        })
        .collect()
}

/// Returns project Materials and direct Material Instances whose effective inputs reference a
/// Texture through its stable registry identity (with source path as a guarded legacy fallback).
pub fn material_dependents_of_texture(
    project_root: &Path,
    registry: &AssetRegistryDocument,
    texture: &AssetRecord,
) -> Vec<AssetRecord> {
    registry
        .records
        .iter()
        .filter(|record| {
            matches!(
                record.kind,
                AssetKind::Material | AssetKind::MaterialInstance
            )
        })
        .filter_map(|record| {
            let inputs = load_resolved_material_inputs(project_root, record).ok()?;
            inputs
                .textures
                .iter()
                .filter_map(|binding| binding.texture.as_ref())
                .any(|reference| reference_targets_record(reference, texture))
                .then(|| record.clone())
        })
        .collect()
}

fn reference_targets_record(reference: &shared::EditorAssetRef, record: &AssetRecord) -> bool {
    (!reference.asset_id.is_empty() && reference.asset_id == record.id.as_string())
        || reference.source_path.as_deref() == Some(record.path.as_str())
}

fn load_ron<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
    let source = std::fs::read_to_string(path)
        .map_err(|error| format!("could not read {}: {error}", path.display()))?;
    ron::from_str(&source).map_err(|error| format!("invalid RON in {}: {error}", path.display()))
}

fn merge_input_sets(base: &mut MaterialInputSet, overrides: &MaterialInputSet) {
    for value in &overrides.values {
        if let Some(existing) = base
            .values
            .iter_mut()
            .find(|entry| entry.name == value.name)
        {
            *existing = value.clone();
        } else {
            base.values.push(value.clone());
        }
    }
    for texture in &overrides.textures {
        if let Some(existing) = base
            .textures
            .iter_mut()
            .find(|entry| entry.name == texture.name)
        {
            *existing = texture.clone();
        } else {
            base.textures.push(texture.clone());
        }
    }
}

fn load_binding_image(
    project_root: &Path,
    registry: &AssetRegistryDocument,
    binding: &MaterialTextureBinding,
) -> Result<(Vec<u8>, RgbaImage), String> {
    let (_, bytes, _) = binding_source(project_root, registry, binding)?;
    let image = image::load_from_memory(&bytes)
        .map_err(|error| format!("could not decode Texture for {}: {error}", binding.name))?
        .to_rgba8();
    Ok((bytes, image))
}

fn binding_source(
    project_root: &Path,
    registry: &AssetRegistryDocument,
    binding: &MaterialTextureBinding,
) -> Result<(String, Vec<u8>, TextureImportSettings), String> {
    let reference = binding
        .texture
        .as_ref()
        .ok_or_else(|| format!("Material input {} is unassigned", binding.name))?;
    let record = registry.records.iter().find(|record| {
        record.id.as_string() == reference.asset_id
            || reference
                .source_path
                .as_deref()
                .is_some_and(|path| record.path == path)
    });
    let path = record
        .map(|record| record.path.clone())
        .or_else(|| reference.source_path.clone())
        .ok_or_else(|| format!("Texture {} has no project path", reference.label))?;
    let bytes = std::fs::read(project_root.join(&path))
        .map_err(|error| format!("could not read Texture {path}: {error}"))?;
    let mut settings = record
        .and_then(|record| record.texture_import().cloned())
        .unwrap_or_default();
    normalize_texture_settings(&path, &mut settings);
    Ok((path, bytes, settings))
}

fn sample_scalar(
    image: Option<&(Vec<u8>, RgbaImage)>,
    binding: Option<&MaterialTextureBinding>,
    x: u32,
    y: u32,
    fallback: u8,
) -> u8 {
    let (Some((_, image)), Some(binding)) = (image, binding) else {
        return fallback;
    };
    let pixel = image.get_pixel(x, y).0;
    match binding.channel {
        TextureChannel::R | TextureChannel::Rgb | TextureChannel::Rgba => pixel[0],
        TextureChannel::G => pixel[1],
        TextureChannel::B => pixel[2],
        TextureChannel::A => pixel[3],
    }
}

fn flip_normal_green(image: &mut RgbaImage) {
    for pixel in image.pixels_mut() {
        pixel.0[1] = 255_u8.saturating_sub(pixel.0[1]);
    }
}

fn requested_mip_count(width: u32, height: u32, mode: TextureMipmapMode) -> u32 {
    if mode != TextureMipmapMode::Generate {
        return 1;
    }
    width.max(height).ilog2() + 1
}

fn generate_mips(image: &RgbaImage, mode: TextureMipmapMode) -> Vec<RgbaImage> {
    let mut levels = vec![image.clone()];
    if mode != TextureMipmapMode::Generate {
        return levels;
    }
    while levels
        .last()
        .is_some_and(|level| level.width() > 1 || level.height() > 1)
    {
        let previous = levels.last().expect("mip chain has a base level");
        let width = (previous.width() / 2).max(1);
        let height = (previous.height() / 2).max(1);
        levels.push(image::imageops::resize(
            previous,
            width,
            height,
            FilterType::Lanczos3,
        ));
    }
    levels
}

fn encode_uastc(
    image: &RgbaImage,
    is_srgb: bool,
    generate_mipmaps: bool,
) -> Result<Vec<u8>, String> {
    let mut params = basis_universal::CompressorParams::new();
    params.set_basis_format(basis_universal::BasisTextureFormat::UASTC4x4);
    params.set_uastc_quality_level(basis_universal::UASTC_QUALITY_DEFAULT);
    params.set_generate_mipmaps(generate_mipmaps);
    params.set_print_status_to_stdout(false);
    params.set_color_space(if is_srgb {
        basis_universal::ColorSpace::Srgb
    } else {
        basis_universal::ColorSpace::Linear
    });
    params
        .source_image_mut(0)
        .init(image.as_raw(), image.width(), image.height(), 4);
    let mut compressor = basis_universal::Compressor::new(4);
    // SAFETY: the image is tightly packed RGBA8 and all compressor parameters above are valid.
    unsafe {
        compressor.init(&params);
        compressor
            .process()
            .map_err(|error| format!("UASTC compression failed: {error:?}"))?;
    }
    Ok(compressor.basis_file().to_vec())
}

fn encode_uncompressed_ktx2(mips: &[RgbaImage], is_srgb: bool) -> Result<Vec<u8>, String> {
    let Some(base) = mips.first() else {
        return Err("cannot encode an empty mip chain".into());
    };
    let level_count = u32::try_from(mips.len()).map_err(|_| "too many mip levels".to_string())?;
    let dfd = rgba8_data_format_descriptor(is_srgb);
    let header_len = 80_usize;
    let level_index_len = mips.len() * 24;
    let dfd_offset = header_len + level_index_len;
    let data_offset = align_to(dfd_offset + dfd.len(), 8);
    let mut level_offsets = Vec::with_capacity(mips.len());
    let mut cursor = data_offset;
    for level in mips {
        cursor = align_to(cursor, 8);
        level_offsets.push(cursor);
        cursor += level.as_raw().len();
    }
    let mut bytes = Vec::with_capacity(cursor);
    bytes.extend_from_slice(&[
        0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A,
    ]);
    push_u32(&mut bytes, if is_srgb { 43 } else { 37 });
    push_u32(&mut bytes, 1);
    push_u32(&mut bytes, base.width());
    push_u32(&mut bytes, base.height());
    push_u32(&mut bytes, 0);
    push_u32(&mut bytes, 0);
    push_u32(&mut bytes, 1);
    push_u32(&mut bytes, level_count);
    push_u32(&mut bytes, 0);
    push_u32(
        &mut bytes,
        u32::try_from(dfd_offset).map_err(|_| "KTX2 is too large".to_string())?,
    );
    push_u32(&mut bytes, u32::try_from(dfd.len()).unwrap_or(u32::MAX));
    push_u32(&mut bytes, 0);
    push_u32(&mut bytes, 0);
    push_u64(&mut bytes, 0);
    push_u64(&mut bytes, 0);
    for (level, offset) in mips.iter().zip(&level_offsets) {
        push_u64(
            &mut bytes,
            u64::try_from(*offset).map_err(|_| "KTX2 is too large".to_string())?,
        );
        push_u64(&mut bytes, level.as_raw().len() as u64);
        push_u64(&mut bytes, level.as_raw().len() as u64);
    }
    bytes.extend_from_slice(&dfd);
    bytes.resize(data_offset, 0);
    for (level, offset) in mips.iter().zip(level_offsets) {
        bytes.resize(offset, 0);
        bytes.extend_from_slice(level.as_raw());
    }
    Ok(bytes)
}

fn rgba8_data_format_descriptor(is_srgb: bool) -> Vec<u8> {
    let mut dfd = Vec::with_capacity(92);
    push_u32(&mut dfd, 92);
    push_u16(&mut dfd, 0);
    push_u16(&mut dfd, 0);
    push_u16(&mut dfd, 2);
    push_u16(&mut dfd, 88);
    dfd.extend_from_slice(&[1, 1, if is_srgb { 2 } else { 1 }, 0]);
    dfd.extend_from_slice(&[0, 0, 0, 0]);
    dfd.extend_from_slice(&[4, 0, 0, 0, 0, 0, 0, 0]);
    for (bit_offset, channel) in [(0_u16, 0_u8), (8, 1), (16, 2), (24, 15)] {
        push_u16(&mut dfd, bit_offset);
        dfd.extend_from_slice(&[7, channel]);
        dfd.extend_from_slice(&[0, 0, 0, 0]);
        push_u32(&mut dfd, 0);
        push_u32(&mut dfd, 255);
    }
    dfd
}

fn align_to(value: usize, alignment: usize) -> usize {
    value.div_ceil(alignment) * alignment
}

fn push_u16(bytes: &mut Vec<u8>, value: u16) {
    bytes.extend_from_slice(&value.to_le_bytes());
}

fn push_u32(bytes: &mut Vec<u8>, value: u32) {
    bytes.extend_from_slice(&value.to_le_bytes());
}

fn push_u64(bytes: &mut Vec<u8>, value: u64) {
    bytes.extend_from_slice(&value.to_le_bytes());
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use image::Rgba;
    use shared::{
        AssetId, AssetImportSettings, EditorAssetRef, MaterialParameter, MaterialParameterValue,
        MaterialRef, ShaderRefDesc,
    };
    use uuid::Uuid;

    fn test_root(name: &str) -> PathBuf {
        std::env::temp_dir().join(format!(
            "blacksite-texture-{name}-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ))
    }

    fn record(id: u128, path: &str, kind: AssetKind) -> AssetRecord {
        AssetRecord {
            id: AssetId(Uuid::from_u128(id)),
            path: path.into(),
            label: path.into(),
            kind,
            source_fingerprint: None,
            import_settings: if kind == AssetKind::Texture {
                AssetImportSettings::Texture(TextureImportSettings::default())
            } else {
                AssetImportSettings::None
            },
            dependencies: Vec::new(),
        }
    }

    fn material(label: &str, texture: Option<EditorAssetRef>, metallic: f32) -> MaterialAsset {
        MaterialAsset {
            schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
            label: label.into(),
            shader: ShaderRefDesc::default(),
            shader_ref: None,
            render_state: Default::default(),
            provenance: None,
            inputs: MaterialInputSet {
                values: vec![MaterialParameter {
                    name: "metallic".into(),
                    value: MaterialParameterValue::Float(metallic),
                }],
                textures: texture
                    .map(|texture| MaterialTextureBinding {
                        name: "metallic".into(),
                        texture: Some(texture),
                        channel: TextureChannel::B,
                    })
                    .into_iter()
                    .collect(),
            },
        }
    }

    fn write_ron(path: &Path, value: &impl serde::Serialize) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(
            path,
            ron::ser::to_string_pretty(value, ron::ser::PrettyConfig::default()).unwrap(),
        )
        .unwrap();
    }

    #[test]
    fn infers_poly_haven_texture_roles() {
        assert_eq!(
            infer_texture_semantic("metal_office_desk_diff_2k.jpg"),
            TextureAssetSemantic::Color
        );
        assert_eq!(
            infer_texture_semantic("metal_office_desk_nor_gl_2k.png"),
            TextureAssetSemantic::Normal
        );
        assert_eq!(
            infer_texture_semantic("metal_office_desk_arm_2k.jpg"),
            TextureAssetSemantic::MaskData
        );
    }

    #[test]
    fn normal_y_conversion_is_exact() {
        let mut image = RgbaImage::from_pixel(1, 1, Rgba([1, 64, 3, 255]));
        flip_normal_green(&mut image);
        assert_eq!(image.get_pixel(0, 0).0, [1, 191, 3, 255]);
    }

    #[test]
    fn uncompressed_ktx2_contains_all_mips() {
        let image = RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 4]));
        let mips = generate_mips(&image, TextureMipmapMode::Generate);
        let encoded = encode_uncompressed_ktx2(&mips, false).expect("KTX2 should encode");
        assert_eq!(
            &encoded[..12],
            &[0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A]
        );
        assert_eq!(u32::from_le_bytes(encoded[40..44].try_into().unwrap()), 3);
    }

    #[test]
    fn existing_texture_artifact_is_reused_before_source_decode() {
        let root = test_root("texture-reuse");
        let texture = record(21, "assets/textures/cached.png", AssetKind::Texture);
        let source_bytes = b"intentionally not an image";
        std::fs::create_dir_all(root.join("assets/textures")).unwrap();
        std::fs::write(root.join(&texture.path), source_bytes).unwrap();

        let mut settings = texture.texture_import().cloned().unwrap();
        normalize_texture_settings(&texture.path, &mut settings);
        let settings_bytes = ron::ser::to_string(&settings).unwrap();
        let mut hasher = blake3::Hasher::new();
        hasher.update(source_bytes);
        hasher.update(settings_bytes.as_bytes());
        hasher.update(&TEXTURE_PROCESSOR_VERSION.to_le_bytes());
        let processing_key = &hasher.finalize().to_hex()[..24];
        let output = root.join(format!(
            "assets/.import-cache/runtime/textures/{}/{processing_key}/texture.basis",
            texture.id.as_string()
        ));
        std::fs::create_dir_all(output.parent().unwrap()).unwrap();
        std::fs::write(&output, b"cached artifact").unwrap();

        let plan = plan_texture_artifact(&root, &texture).unwrap();
        assert!(plan.reused);
        assert!(plan.output_bytes.is_empty());
        assert_eq!(plan.output_path, output);
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn packing_signature_ignores_owner_and_scalar_multipliers() {
        let root = test_root("signature");
        let texture = record(1, "assets/textures/arm.bin", AssetKind::Texture);
        std::fs::create_dir_all(root.join("assets/textures")).unwrap();
        std::fs::write(root.join(&texture.path), b"stable packed source").unwrap();
        let texture_ref = EditorAssetRef::new(texture.id.as_string(), "texture:source", "ARM")
            .with_source_path(&texture.path);
        let first = record(2, "assets/materials/first.ron", AssetKind::Material);
        let second = record(3, "assets/materials/second.ron", AssetKind::Material);
        write_ron(
            &root.join(&first.path),
            &material("First", Some(texture_ref.clone()), 0.1),
        );
        write_ron(
            &root.join(&second.path),
            &material("Second", Some(texture_ref), 0.9),
        );
        let registry = AssetRegistryDocument {
            records: vec![texture, first.clone(), second.clone()],
            ..Default::default()
        };

        assert_eq!(
            material_packing_signature(&root, &first, &registry).unwrap(),
            material_packing_signature(&root, &second, &registry).unwrap(),
            "owner identity and scalar multipliers must not invalidate identical packed data"
        );
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn unassigned_scalar_texture_rows_do_not_request_packing() {
        let root = test_root("unassigned-packing");
        let material_record = record(22, "assets/materials/plain.ron", AssetKind::Material);
        let mut authored = material("Plain", None, 0.5);
        authored.inputs.textures.push(MaterialTextureBinding {
            name: "occlusion".into(),
            texture: None,
            channel: TextureChannel::R,
        });
        write_ron(&root.join(&material_record.path), &authored);
        let registry = AssetRegistryDocument {
            records: vec![material_record.clone()],
            ..Default::default()
        };

        assert!(
            material_packing_signature(&root, &material_record, &registry)
                .unwrap()
                .is_none()
        );
        assert!(plan_material_artifact(&root, &material_record, &registry)
            .unwrap()
            .is_none());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn existing_material_artifact_is_reused_before_texture_decode() {
        let root = test_root("material-reuse");
        let texture = record(31, "assets/textures/arm.bin", AssetKind::Texture);
        let material_record = record(32, "assets/materials/cached.ron", AssetKind::Material);
        std::fs::create_dir_all(root.join("assets/textures")).unwrap();
        std::fs::write(root.join(&texture.path), b"intentionally not an image").unwrap();
        let texture_ref = EditorAssetRef::new(texture.id.as_string(), "texture:source", "ARM")
            .with_source_path(&texture.path);
        write_ron(
            &root.join(&material_record.path),
            &material("Cached", Some(texture_ref), 1.0),
        );
        let registry = AssetRegistryDocument {
            records: vec![texture, material_record.clone()],
            ..Default::default()
        };
        let signature = material_packing_signature(&root, &material_record, &registry)
            .unwrap()
            .unwrap();
        let output = root.join(format!(
            "assets/.import-cache/runtime/materials/{}/{}-arm.basis",
            material_record.id.as_string(),
            signature.0
        ));
        std::fs::create_dir_all(output.parent().unwrap()).unwrap();
        std::fs::write(&output, b"cached artifact").unwrap();

        let plan = plan_material_artifact(&root, &material_record, &registry)
            .unwrap()
            .unwrap();
        assert!(plan.reused);
        assert!(plan.output_bytes.is_empty());
        assert_eq!(plan.output_path, output);
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn dependency_closure_uses_stable_material_and_texture_references() {
        let root = test_root("dependencies");
        let texture = record(11, "assets/textures/arm.bin", AssetKind::Texture);
        let base = record(12, "assets/materials/base.ron", AssetKind::Material);
        let instance = record(
            13,
            "assets/materials/base_instance.ron",
            AssetKind::MaterialInstance,
        );
        let unrelated = record(14, "assets/materials/unrelated.ron", AssetKind::Material);
        let texture_ref = EditorAssetRef::new(texture.id.as_string(), "texture:source", "ARM")
            .with_source_path(&texture.path);
        write_ron(
            &root.join(&base.path),
            &material("Base", Some(texture_ref), 1.0),
        );
        write_ron(
            &root.join(&unrelated.path),
            &material("Unrelated", None, 1.0),
        );
        write_ron(
            &root.join(&instance.path),
            &MaterialInstanceAsset {
                schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION,
                label: "Base Instance".into(),
                base: MaterialRef::new(
                    EditorAssetRef::new(base.id.as_string(), "material:source", "Base")
                        .with_source_path(&base.path),
                ),
                overrides: MaterialInputSet::default(),
            },
        );
        let registry = AssetRegistryDocument {
            records: vec![texture.clone(), base.clone(), instance.clone(), unrelated],
            ..Default::default()
        };

        assert_eq!(
            direct_material_instance_dependents(&root, &registry, &base)
                .into_iter()
                .map(|record| record.id)
                .collect::<Vec<_>>(),
            vec![instance.id.clone()]
        );
        let dependents = material_dependents_of_texture(&root, &registry, &texture)
            .into_iter()
            .map(|record| record.id)
            .collect::<Vec<_>>();
        assert!(dependents.contains(&base.id));
        assert!(dependents.contains(&instance.id));
        assert_eq!(dependents.len(), 2);
        std::fs::remove_dir_all(root).unwrap();
    }
}
