#![cfg(not(target_arch = "wasm32"))]

use std::fmt::Write as _;
use std::path::Path;

const REPORT_PATH: &str = "target/gate-artifacts/demo-material-audit/connector-material-audit.md";
const CONNECTOR_ASSETS: [&str; 3] = [
    "demo/samples/connector-snap/drive_unit.glb",
    "demo/samples/connector-snap/load_unit.glb",
    "demo/samples/connector-snap/connector_snap_assembly.glb",
];
const MATERIAL_SOURCE_BASE_COLORS: [(&str, &str); 3] = [
    (
        "brushed steel",
        "demo/samples/materials/ambientcg/Metal030/source/Metal030_1K-JPG_Color.jpg",
    ),
    (
        "machined aluminium",
        "demo/samples/materials/ambientcg/Metal010/source/Metal010_1K-JPG_Color.jpg",
    ),
    (
        "isolator pad",
        "demo/samples/materials/ambientcg/Rubber002/source/Rubber002_1K-JPG_Color.jpg",
    ),
];

#[test]
fn connector_assets_material_audit_identifies_bright_mapless_placeholder_materials() {
    let waterbottle = audit_materials("tests/assets/gltf/khronos/WaterBottle/WaterBottle.gltf");
    let connector_reports = CONNECTOR_ASSETS
        .iter()
        .map(|path| audit_materials(path))
        .collect::<Vec<_>>();

    let mut report = String::new();
    writeln!(
        &mut report,
        "# Connector Material Audit\n\nThis report is generated by `{}`.\n",
        module_path!()
    )
    .expect("write report header");
    writeln!(
        &mut report,
        "## Summary\n\n- Connector assets audited: {}\n- WaterBottle reference audited: yes\n",
        connector_reports.len()
    )
    .expect("write summary");
    for asset in &connector_reports {
        write_asset_report(&mut report, asset);
    }
    writeln!(&mut report, "## Reference\n").expect("write reference header");
    write_asset_report(&mut report, &waterbottle);

    std::fs::create_dir_all(
        Path::new(REPORT_PATH)
            .parent()
            .expect("report path has a parent"),
    )
    .expect("create material audit artifact dir");
    std::fs::write(REPORT_PATH, report).expect("write connector material audit report");

    let flat_connector_materials: usize = connector_reports
        .iter()
        .map(|asset| {
            asset
                .materials
                .iter()
                .filter(|material| material.is_mapless_placeholder())
                .count()
        })
        .sum();
    assert_eq!(
        flat_connector_materials, 0,
        "connector assets must not contain remaining mapless flat materials"
    );
    assert!(
        waterbottle
            .materials
            .iter()
            .any(MaterialAudit::has_full_pbr_texture_set),
        "WaterBottle reference must keep baseColor, metallicRoughness, normal, and occlusion maps"
    );
}

#[test]
fn connector_brushed_steel_uses_documented_library_pbr_textures() {
    assert_connector_material_uses_documented_library_pbr_textures(
        "brushed steel",
        "Metal030",
        "https://ambientcg.com/a/Metal030",
    );
}

#[test]
fn connector_machined_aluminium_uses_documented_library_pbr_textures() {
    assert_connector_material_uses_documented_library_pbr_textures(
        "machined aluminium",
        "Metal010",
        "https://ambientcg.com/a/Metal010",
    );
}

#[test]
fn connector_baseplate_steel_uses_documented_library_pbr_textures() {
    assert_connector_material_uses_documented_library_pbr_textures(
        "baseplate steel",
        "Metal030",
        "https://ambientcg.com/a/Metal030",
    );
}

#[test]
fn every_connector_material_uses_pbr_texture_maps() {
    let connector_reports = CONNECTOR_ASSETS
        .iter()
        .map(|path| audit_materials(path))
        .collect::<Vec<_>>();
    for material in connector_reports
        .iter()
        .flat_map(|asset| asset.materials.iter())
    {
        assert!(
            material.has_full_pbr_texture_set(),
            "{} must use baseColor, metallicRoughness, normal, and occlusion texture maps",
            material.name
        );
    }
}

#[test]
fn connector_library_material_sources_are_not_near_white() {
    for (material_name, path) in MATERIAL_SOURCE_BASE_COLORS {
        let luminance = average_srgb_luminance(path);
        assert!(
            luminance <= 0.82,
            "{material_name} source base color is too close to white for this demo: {luminance:.3}"
        );
    }
}

#[test]
fn connector_machined_aluminium_textures_are_tiled_for_demo_part_scale() {
    let connector_reports = CONNECTOR_ASSETS
        .iter()
        .map(|path| audit_materials(path))
        .collect::<Vec<_>>();
    for material in connector_reports
        .iter()
        .flat_map(|asset| asset.materials.iter())
        .filter(|material| material.normalized_name() == "machined aluminium")
    {
        assert!(
            material.has_tiled_material_texture_scale(),
            "{} must tile its library texture maps so source scratches do not stretch across whole demo parts",
            material.name
        );
    }
}

#[test]
fn connector_baseplate_steel_textures_are_tiled_for_demo_part_scale() {
    let connector_reports = CONNECTOR_ASSETS
        .iter()
        .map(|path| audit_materials(path))
        .collect::<Vec<_>>();
    for material in connector_reports
        .iter()
        .flat_map(|asset| asset.materials.iter())
        .filter(|material| material.normalized_name() == "baseplate steel")
    {
        assert!(
            material.has_tiled_material_texture_scale(),
            "{} must tile its library texture maps so the base plates do not render as flat clay",
            material.name
        );
    }
}

#[test]
fn every_non_shaft_connector_material_tiles_texture_maps_for_demo_scale() {
    let connector_reports = CONNECTOR_ASSETS
        .iter()
        .map(|path| audit_materials(path))
        .collect::<Vec<_>>();
    for material in connector_reports
        .iter()
        .flat_map(|asset| asset.materials.iter())
        .filter(|material| material.normalized_name() != "brushed steel")
    {
        assert!(
            material.has_tiled_material_texture_scale(),
            "{} must tile its texture maps for demo part scale",
            material.name
        );
    }
}

fn assert_connector_material_uses_documented_library_pbr_textures(
    material_name: &str,
    source_id: &str,
    source_url: &str,
) {
    let connector_reports = CONNECTOR_ASSETS
        .iter()
        .map(|path| audit_materials(path))
        .collect::<Vec<_>>();
    let matching_materials = connector_reports
        .iter()
        .flat_map(|asset| asset.materials.iter())
        .filter(|material| material.normalized_name() == material_name)
        .collect::<Vec<_>>();

    assert_eq!(
        matching_materials.len(),
        3,
        "drive, load, and assembled connector assets must each contain one {material_name} material"
    );
    for material in matching_materials {
        assert!(
            material.has_full_pbr_texture_set(),
            "{} must use baseColor, metallicRoughness, normal, and occlusion texture maps",
            material.name
        );
        assert!(
            !material.is_bright_without_detail_maps(),
            "{} must not remain a bright mapless placeholder",
            material.name
        );
    }

    let sources = std::fs::read_to_string("demo/samples/SOURCES.md")
        .expect("demo material sources manifest exists");
    assert!(
        sources.contains(source_id) && sources.contains(source_url),
        "{material_name} source must be documented with the ambientCG asset id and source URL"
    );
}

#[derive(Debug)]
struct AssetAudit {
    path: String,
    materials: Vec<MaterialAudit>,
}

#[derive(Debug)]
struct MaterialAudit {
    name: String,
    base_color: [f32; 4],
    metallic: f32,
    roughness: f32,
    base_color_texture: bool,
    metallic_roughness_texture: bool,
    normal_texture: bool,
    occlusion_texture: bool,
    emissive_texture: bool,
    base_color_texture_scale: Option<[f32; 2]>,
    metallic_roughness_texture_scale: Option<[f32; 2]>,
    normal_texture_scale: Option<[f32; 2]>,
    occlusion_texture_scale: Option<[f32; 2]>,
}

impl MaterialAudit {
    fn normalized_name(&self) -> &str {
        self.name
            .split('.')
            .next()
            .expect("split always returns at least one segment")
    }

    fn is_mapless_placeholder(&self) -> bool {
        !self.base_color_texture
            && !self.metallic_roughness_texture
            && !self.normal_texture
            && !self.occlusion_texture
            && !self.emissive_texture
    }

    fn is_bright_without_detail_maps(&self) -> bool {
        self.base_color[0] >= 0.75
            && self.base_color[1] >= 0.75
            && self.base_color[2] >= 0.75
            && !self.base_color_texture
            && !self.normal_texture
            && !self.occlusion_texture
    }

    fn has_full_pbr_texture_set(&self) -> bool {
        self.base_color_texture
            && self.metallic_roughness_texture
            && self.normal_texture
            && self.occlusion_texture
    }

    fn has_tiled_material_texture_scale(&self) -> bool {
        [
            self.base_color_texture_scale,
            self.metallic_roughness_texture_scale,
            self.normal_texture_scale,
            self.occlusion_texture_scale,
        ]
        .iter()
        .all(|scale| scale.is_some_and(|scale| scale[0] >= 4.0 && scale[1] >= 4.0))
    }
}

fn audit_materials(path: &str) -> AssetAudit {
    let gltf = gltf::Gltf::open(path).unwrap_or_else(|error| panic!("{path} parses: {error}"));
    let raw_json = raw_gltf_json(path);
    let raw_materials = raw_json
        .get("materials")
        .and_then(serde_json::Value::as_array)
        .cloned()
        .unwrap_or_default();
    let materials = gltf
        .document
        .materials()
        .enumerate()
        .map(|(index, material)| {
            let pbr = material.pbr_metallic_roughness();
            let raw_material = raw_materials.get(index);
            MaterialAudit {
                name: material
                    .name()
                    .map(str::to_owned)
                    .unwrap_or_else(|| format!("material_{index}")),
                base_color: pbr.base_color_factor(),
                metallic: pbr.metallic_factor(),
                roughness: pbr.roughness_factor(),
                base_color_texture: pbr.base_color_texture().is_some(),
                metallic_roughness_texture: pbr.metallic_roughness_texture().is_some(),
                normal_texture: material.normal_texture().is_some(),
                occlusion_texture: material.occlusion_texture().is_some(),
                emissive_texture: material.emissive_texture().is_some(),
                base_color_texture_scale: raw_texture_scale(
                    raw_material,
                    &["pbrMetallicRoughness", "baseColorTexture"],
                ),
                metallic_roughness_texture_scale: raw_texture_scale(
                    raw_material,
                    &["pbrMetallicRoughness", "metallicRoughnessTexture"],
                ),
                normal_texture_scale: raw_texture_scale(raw_material, &["normalTexture"]),
                occlusion_texture_scale: raw_texture_scale(raw_material, &["occlusionTexture"]),
            }
        })
        .collect();
    AssetAudit {
        path: path.to_owned(),
        materials,
    }
}

fn raw_gltf_json(path: &str) -> serde_json::Value {
    let bytes = std::fs::read(path).unwrap_or_else(|error| panic!("{path} reads: {error}"));
    if path.ends_with(".gltf") {
        return serde_json::from_slice(&bytes)
            .unwrap_or_else(|error| panic!("{path} JSON parses: {error}"));
    }
    assert!(path.ends_with(".glb"), "{path} must be .gltf or .glb");
    assert!(
        bytes.len() >= 20,
        "{path} must contain a GLB header and JSON chunk"
    );
    let magic = u32::from_le_bytes(bytes[0..4].try_into().expect("magic bytes"));
    let version = u32::from_le_bytes(bytes[4..8].try_into().expect("version bytes"));
    let json_len = u32::from_le_bytes(bytes[12..16].try_into().expect("chunk length")) as usize;
    let json_type = u32::from_le_bytes(bytes[16..20].try_into().expect("chunk type"));
    assert_eq!(magic, 0x4654_6c67, "{path} must be a GLB file");
    assert_eq!(version, 2, "{path} must be a GLB v2 file");
    assert_eq!(json_type, 0x4e4f_534a, "{path} first chunk must be JSON");
    let json_bytes = &bytes[20..20 + json_len];
    serde_json::from_slice(json_bytes).unwrap_or_else(|error| panic!("{path} JSON parses: {error}"))
}

fn raw_texture_scale(
    raw_material: Option<&serde_json::Value>,
    texture_path: &[&str],
) -> Option<[f32; 2]> {
    let mut value = raw_material?;
    for segment in texture_path {
        value = value.get(*segment)?;
    }
    let scale = value
        .get("extensions")?
        .get("KHR_texture_transform")?
        .get("scale")?
        .as_array()?;
    Some([
        scale.first()?.as_f64()? as f32,
        scale.get(1)?.as_f64()? as f32,
    ])
}

fn write_asset_report(report: &mut String, asset: &AssetAudit) {
    writeln!(report, "### `{}`\n", asset.path).expect("write asset heading");
    writeln!(
        report,
        "| Material | Base Color | Metallic | Roughness | Base | MR | Normal | AO | Emissive | Flags |"
    )
    .expect("write table header");
    writeln!(
        report,
        "| --- | --- | ---: | ---: | --- | --- | --- | --- | --- | --- |"
    )
    .expect("write table divider");
    for material in &asset.materials {
        let mut flags = Vec::new();
        if material.is_mapless_placeholder() {
            flags.push("mapless-flat");
        }
        if material.is_bright_without_detail_maps() {
            flags.push("bright-no-detail");
        }
        if material.has_full_pbr_texture_set() {
            flags.push("full-pbr-textures");
        }
        writeln!(
            report,
            "| `{}` | `{:.3}, {:.3}, {:.3}, {:.3}` | {:.3} | {:.3} | {} | {} | {} | {} | {} | {} |",
            material.name,
            material.base_color[0],
            material.base_color[1],
            material.base_color[2],
            material.base_color[3],
            material.metallic,
            material.roughness,
            yes_no(material.base_color_texture),
            yes_no(material.metallic_roughness_texture),
            yes_no(material.normal_texture),
            yes_no(material.occlusion_texture),
            yes_no(material.emissive_texture),
            flags.join(", ")
        )
        .expect("write material row");
    }
    writeln!(report).expect("write trailing newline");
}

fn yes_no(value: bool) -> &'static str {
    if value { "yes" } else { "no" }
}

fn average_srgb_luminance(path: &str) -> f32 {
    let image = image::ImageReader::open(path)
        .unwrap_or_else(|error| panic!("{path} opens: {error}"))
        .decode()
        .unwrap_or_else(|error| panic!("{path} decodes: {error}"))
        .to_rgb8();
    let pixel_count = image.width() as f32 * image.height() as f32;
    let total = image.pixels().fold(0.0_f32, |sum, pixel| {
        let [r, g, b] = pixel.0;
        sum + (0.2126 * f32::from(r) + 0.7152 * f32::from(g) + 0.0722 * f32::from(b)) / 255.0
    });
    total / pixel_count
}
