#![cfg(any())]

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

use serde_json::Value;

fn unique_test_dir(prefix: &str) -> PathBuf {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time")
        .as_nanos();
    let path = std::env::temp_dir().join(format!("tyler-{prefix}-{unique}"));
    fs::create_dir_all(&path).expect("create test dir");
    path
}

fn repo_path(relative: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative)
}

fn read_fixture(relative: &str) -> String {
    fs::read_to_string(repo_path(relative)).expect("read fixture")
}

fn write_ndjson_dataset(prefix: &str, metadata: &str, feature_blobs: &[String]) -> PathBuf {
    let dataset = unique_test_dir(prefix);
    let mut contents = String::new();
    contents.push_str(metadata.trim_end());
    contents.push('\n');
    for feature_blob in feature_blobs {
        contents.push_str(feature_blob.trim_end());
        contents.push('\n');
    }
    fs::write(dataset.join("source.city.jsonl"), contents).expect("write ndjson source");
    dataset
}

fn run_tyler(dataset: &Path, output: &Path, args: &[&str]) -> Output {
    let mut command = Command::new(env!("CARGO_BIN_EXE_tyler"));
    command.arg(dataset).arg("--output").arg(output);
    for arg in args {
        command.arg(arg);
    }
    command.output().expect("run tyler")
}

fn read_json(path: &Path) -> Value {
    serde_json::from_slice(&fs::read(path).expect("read json file")).expect("parse json file")
}

fn read_glb_json(bytes: &[u8]) -> Value {
    assert!(bytes.len() >= 20, "glb should contain a header");
    assert_eq!(&bytes[0..4], b"glTF");
    assert_eq!(u32::from_le_bytes(bytes[4..8].try_into().unwrap()), 2);

    let declared_length = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
    assert_eq!(declared_length, bytes.len());

    let json_length = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
    assert_eq!(&bytes[16..20], b"JSON");

    serde_json::from_slice(&bytes[20..20 + json_length]).expect("GLB JSON chunk should parse")
}

fn find_first_glb(dir: &Path) -> Option<PathBuf> {
    for entry in fs::read_dir(dir).ok()? {
        let entry = entry.ok()?;
        let path = entry.path();
        if path.is_dir() {
            if let Some(found) = find_first_glb(&path) {
                return Some(found);
            }
        } else if path.extension().is_some_and(|extension| extension == "glb") {
            return Some(path);
        }
    }
    None
}

fn collect_paths_with_extension(dir: &Path, extension: &str, out: &mut Vec<PathBuf>) {
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                collect_paths_with_extension(&path, extension, out);
            } else if path
                .file_name()
                .and_then(|name| name.to_str())
                .is_some_and(|name| name.ends_with(&format!(".{extension}")))
            {
                out.push(path);
            }
        }
    }
}

fn region_from_bounding_volume(bounding_volume: &Value) -> Vec<f64> {
    bounding_volume["region"]
        .as_array()
        .expect("region should be an array")
        .iter()
        .map(|value| value.as_f64().expect("region component should be numeric"))
        .collect()
}

fn first_glb_json(output_dir: &Path) -> Value {
    let glb_path = find_first_glb(&output_dir.join("t")).expect("expected at least one GLB");
    read_glb_json(&fs::read(glb_path).expect("read glb"))
}

fn material_count(glb_json: &Value) -> usize {
    glb_json["materials"]
        .as_array()
        .expect("glTF materials should exist")
        .len()
}

fn metadata_feature_count(glb_json: &Value) -> u64 {
    glb_json["extensions"]["EXT_structural_metadata"]["propertyTables"][0]["count"]
        .as_u64()
        .expect("structural metadata feature count should exist")
}

fn first_tile_with_content(tile: &Value) -> Option<&Value> {
    if tile.get("content").is_some() {
        return Some(tile);
    }
    tile.get("children")
        .and_then(Value::as_array)
        .and_then(|children| children.iter().find_map(first_tile_with_content))
}

fn leaf_content_tile(tile: &Value) -> &Value {
    first_tile_with_content(tile).expect("expected at least one tile with content")
}

fn assert_success(output: &Output, context: &str) {
    assert!(
        output.status.success(),
        "{context} failed: status={:?}\nstdout:\n{}\nstderr:\n{}",
        output.status,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn object_type_filters_mixed_feature_glb_and_accepts_union() {
    let metadata = read_fixture("resources/data/3dbag_x00.city.json");
    let mixed_feature = serde_json::json!({
        "type": "CityJSONFeature",
        "id": "mixed-feature",
        "CityObjects": {
            "building-object": {
                "type": "Building",
                "attributes": {"name": "Building A"},
                "geometry": [{
                    "type": "MultiSurface",
                    "lod": "1",
                    "boundaries": [[[0, 1, 2], [0, 2, 3]]]
                }]
            },
            "water-object": {
                "type": "WaterBody",
                "attributes": {"name": "Canal Segment"},
                "geometry": [{
                    "type": "MultiSurface",
                    "lod": "1",
                    "boundaries": [[[4, 5, 6], [4, 6, 7]]]
                }]
            }
        },
        "vertices": [
            [0, 0, 0],
            [4, 0, 0],
            [4, 4, 0],
            [0, 4, 0],
            [10, 0, 0],
            [13, 0, 0],
            [13, 3, 0],
            [10, 3, 0]
        ]
    })
    .to_string();
    let dataset = write_ndjson_dataset("object-type", &metadata, &[mixed_feature]);

    let building_output = unique_test_dir("object-type-building");
    let output = run_tyler(&dataset, &building_output, &["--object-type", "Building"]);
    assert_success(&output, "building-only object type run");

    let building_glb = first_glb_json(&building_output);
    assert_eq!(material_count(&building_glb), 1);
    assert_eq!(metadata_feature_count(&building_glb), 1);

    let union_output = unique_test_dir("object-type-union");
    let output = run_tyler(
        &dataset,
        &union_output,
        &["--object-type", "Building", "--object-type", "WaterBody"],
    );
    assert_success(&output, "union object type run");

    let union_glb = first_glb_json(&union_output);
    assert_eq!(material_count(&union_glb), 2);
    assert_eq!(metadata_feature_count(&union_glb), 2);
}

#[test]
fn object_type_missing_type_fails() {
    let metadata = read_fixture("resources/data/3dbag_x00.city.json");
    let features = read_fixture("cityjson-convert/tests/data/multi_feature_types.city.jsonl");
    let dataset = write_ndjson_dataset("object-type-missing", &metadata, &[features]);
    let output_dir = unique_test_dir("object-type-missing-output");

    let output = run_tyler(&dataset, &output_dir, &["--object-type", "Road"]);

    assert!(
        !output.status.success(),
        "expected missing object type selection to fail"
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("Did not find any CityJSONFeatures of type"),
        "unexpected stderr:\n{stderr}"
    );
}

#[test]
fn lod_pruning_reduces_generated_glb_content() {
    let metadata = read_fixture("resources/data/3dbag_x00.city.json");
    let features = read_fixture("cityjson-convert/tests/data/multi_lod_building_part.city.jsonl");
    let dataset = write_ndjson_dataset("lod-pruning", &metadata, &[features]);

    let control_output = unique_test_dir("lod-pruning-control");
    let output = run_tyler(
        &dataset,
        &control_output,
        &["--object-type", "BuildingPart"],
    );
    assert_success(&output, "lod pruning control run");

    let control_glb = read_glb_json(
        &fs::read(
            find_first_glb(&control_output.join("t"))
                .expect("expected at least one GLB for the control run"),
        )
        .expect("read control glb"),
    );
    let control_primitive_count = control_glb["meshes"][0]["primitives"]
        .as_array()
        .expect("control primitives should exist")
        .len();
    let control_byte_length = control_glb["buffers"][0]["byteLength"]
        .as_u64()
        .expect("control buffer length should exist");

    let pruned_output = unique_test_dir("lod-pruning-pruned");
    let output = run_tyler(
        &dataset,
        &pruned_output,
        &[
            "--object-type",
            "BuildingPart",
            "--lod-building-part",
            "2.2",
        ],
    );
    assert_success(&output, "lod pruning run");

    let pruned_glb = read_glb_json(
        &fs::read(
            find_first_glb(&pruned_output.join("t"))
                .expect("expected at least one GLB for the pruned run"),
        )
        .expect("read pruned glb"),
    );
    let pruned_primitive_count = pruned_glb["meshes"][0]["primitives"]
        .as_array()
        .expect("pruned primitives should exist")
        .len();
    let pruned_byte_length = pruned_glb["buffers"][0]["byteLength"]
        .as_u64()
        .expect("pruned buffer length should exist");

    assert!(pruned_byte_length < control_byte_length);
    assert!(pruned_primitive_count <= control_primitive_count);
}

#[test]
fn include_parent_attributes_copies_parent_values_into_selected_child_objects() {
    let metadata = read_fixture("resources/data/3dbag_x00.city.json");
    let feature = serde_json::json!({
        "type": "CityJSONFeature",
        "id": "building-parent",
        "CityObjects": {
            "building-parent": {
                "type": "Building",
                "attributes": {
                    "parent_only": "parent",
                    "shared": "parent",
                    "levels": 7
                },
                "children": ["building-part"]
            },
            "building-part": {
                "type": "BuildingPart",
                "parents": ["building-parent"],
                "attributes": {
                    "child_only": "child",
                    "shared": "child"
                },
                "geometry": [{
                    "type": "MultiSurface",
                    "lod": "1",
                    "boundaries": [[[0, 1, 2], [0, 2, 3]]]
                }]
            }
        },
        "vertices": [
            [0, 0, 0],
            [4, 0, 0],
            [4, 4, 0],
            [0, 4, 0]
        ]
    })
    .to_string();
    let dataset = write_ndjson_dataset("parent-attributes", &metadata, &[feature]);

    let control_output = unique_test_dir("parent-attributes-control");
    let output = run_tyler(
        &dataset,
        &control_output,
        &[
            "--object-type",
            "BuildingPart",
            "--3dtiles-metadata-class",
            "parcel",
        ],
    );
    assert_success(&output, "parent attribute control run");

    let control_glb = read_glb_json(
        &fs::read(
            find_first_glb(&control_output.join("t"))
                .expect("expected at least one GLB for the control run"),
        )
        .expect("read control glb"),
    );
    let control_properties = control_glb["extensions"]["EXT_structural_metadata"]["schema"]
        ["classes"]["parcel"]["properties"]
        .as_object()
        .expect("structural metadata properties should exist");
    assert!(!control_properties.contains_key("parent_only"));

    let inherited_output = unique_test_dir("parent-attributes-inherited");
    let output = run_tyler(
        &dataset,
        &inherited_output,
        &[
            "--object-type",
            "BuildingPart",
            "--include-parent-attributes",
            "--3dtiles-metadata-class",
            "parcel",
        ],
    );
    assert_success(&output, "parent attribute inheritance run");

    let inherited_glb = read_glb_json(
        &fs::read(
            find_first_glb(&inherited_output.join("t"))
                .expect("expected at least one GLB for the inheritance run"),
        )
        .expect("read inherited glb"),
    );
    let inherited_properties = inherited_glb["extensions"]["EXT_structural_metadata"]["schema"]
        ["classes"]["parcel"]["properties"]
        .as_object()
        .expect("structural metadata properties should exist");
    assert!(inherited_properties.contains_key("parent_only"));
    assert!(inherited_properties.contains_key("child_only"));
    assert!(inherited_properties.contains_key("shared"));
}

#[test]
fn tileset_flags_change_output_artifacts_and_numeric_fields() {
    let metadata = read_fixture("resources/data/3dbag_x00.city.json");
    let features = read_fixture("cityjson-convert/tests/data/multi_feature_types.city.jsonl");
    let dataset = write_ndjson_dataset("tileset-flags", &metadata, &[features]);

    let default_output = unique_test_dir("tileset-default");
    let output = run_tyler(&dataset, &default_output, &["--qtree-capacity", "1"]);
    assert_success(&output, "default tileset run");

    let default_tileset = read_json(&default_output.join("tileset.json"));
    let default_root = &default_tileset["root"];
    let default_geometric_error = default_root["geometricError"]
        .as_f64()
        .expect("default geometricError should be numeric");
    let default_region = region_from_bounding_volume(&default_root["boundingVolume"]);
    assert!(default_root["content"]["boundingVolume"].is_null());

    let with_content_bv = unique_test_dir("tileset-content-bv");
    let output = run_tyler(
        &dataset,
        &with_content_bv,
        &["--3dtiles-content-add-bv", "--qtree-capacity", "1"],
    );
    assert_success(&output, "content bounding volume run");

    let with_content_bv_tileset = read_json(&with_content_bv.join("tileset.json"));
    let with_content_bv_root = &with_content_bv_tileset["root"];
    let content_tile = leaf_content_tile(with_content_bv_root);
    assert!(content_tile["content"]["boundingVolume"].is_object());

    let content_region = region_from_bounding_volume(&content_tile["content"]["boundingVolume"]);
    let tile_region = region_from_bounding_volume(&content_tile["boundingVolume"]);
    assert_ne!(
        content_region, tile_region,
        "without --3dtiles-content-bv-from-tile the content BV should not be forced to match the tile BV"
    );

    let modified_output = unique_test_dir("tileset-modified");
    let output = run_tyler(
        &dataset,
        &modified_output,
        &[
            "--3dtiles-content-add-bv",
            "--3dtiles-content-bv-from-tile",
            "--geometric-error-factor",
            "0.05",
            "--qtree-capacity",
            "1",
            "--grid-minz=-10",
            "--grid-maxz=10",
        ],
    );
    assert_success(&output, "modified tileset run");

    let modified_tileset = read_json(&modified_output.join("tileset.json"));
    let modified_root = &modified_tileset["root"];
    let modified_geometric_error = modified_root["geometricError"]
        .as_f64()
        .expect("modified geometricError should be numeric");
    let modified_region = region_from_bounding_volume(&modified_root["boundingVolume"]);
    assert!(
        (default_geometric_error - modified_geometric_error).abs() > f64::EPSILON,
        "expected geometricError to change"
    );
    assert!(modified_geometric_error > default_geometric_error);
    assert!(modified_region[4] <= default_region[4]);
    assert!(modified_region[5] >= default_region[5]);

    let modified_content_tile = leaf_content_tile(modified_root);
    let modified_content_region =
        region_from_bounding_volume(&modified_content_tile["content"]["boundingVolume"]);
    let modified_tile_region =
        region_from_bounding_volume(&modified_content_tile["boundingVolume"]);
    assert_eq!(
        modified_content_region, modified_tile_region,
        "with --3dtiles-content-bv-from-tile the content BV should match the tile BV"
    );
}

#[test]
fn metadata_class_and_color_flags_propagate_into_glb_json() {
    let metadata = read_fixture("resources/data/3dbag_x00.city.json");
    let features = read_fixture("cityjson-convert/tests/data/multi_feature_types.city.jsonl");
    let dataset = write_ndjson_dataset("glb-metadata", &metadata, &[features]);
    let output_dir = unique_test_dir("glb-metadata-output");

    let output = run_tyler(
        &dataset,
        &output_dir,
        &[
            "--object-type",
            "Building",
            "--3dtiles-metadata-class",
            "parcel",
            "--color-building",
            "#010203",
        ],
    );
    assert_success(&output, "metadata/color run");

    let glb_path = find_first_glb(&output_dir.join("t")).expect("expected at least one GLB");
    let glb_json = read_glb_json(&fs::read(glb_path).expect("read glb"));

    let extensions_used = glb_json["extensionsUsed"]
        .as_array()
        .expect("glTF should declare extensionsUsed");
    assert!(extensions_used
        .iter()
        .any(|value| value.as_str() == Some("EXT_structural_metadata")));

    let structural_metadata = glb_json["extensions"]["EXT_structural_metadata"]
        .as_object()
        .expect("structural metadata extension should exist");
    let schema = structural_metadata["schema"]
        .as_object()
        .expect("structural metadata schema should exist");
    let classes = schema["classes"]
        .as_object()
        .expect("structural metadata classes should exist");
    assert!(classes.contains_key("parcel"));
    assert_eq!(classes["parcel"]["name"].as_str(), Some("parcel"));
    assert_eq!(
        structural_metadata["propertyTables"][0]["class"].as_str(),
        Some("parcel")
    );

    let material = &glb_json["materials"][0];
    let base_color = material["pbrMetallicRoughness"]["baseColorFactor"]
        .as_array()
        .expect("baseColorFactor should be present");
    let expected = [1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 1.0];
    for (component, expected) in base_color.iter().zip(expected) {
        let actual = component
            .as_f64()
            .expect("baseColorFactor component should be numeric");
        assert!(
            (actual - expected).abs() < 1.0e-6,
            "expected {expected}, got {actual}"
        );
    }
}

#[test]
fn implicit_tiling_writes_subtrees_and_marks_tileset_as_implicit() {
    let metadata = read_fixture("resources/data/3dbag_x00.city.json");
    let features = read_fixture("cityjson-convert/tests/data/multi_feature_types.city.jsonl");
    let dataset = write_ndjson_dataset("implicit-tiling", &metadata, &[features]);
    let output_dir = unique_test_dir("implicit-tiling-output");

    let output = run_tyler(
        &dataset,
        &output_dir,
        &[
            "--object-type",
            "Building",
            "--3dtiles-implicit",
            "--3dtiles-metadata-class",
            "cityobject",
        ],
    );
    assert_success(&output, "implicit tiling run");

    let tileset = read_json(&output_dir.join("tileset.json"));
    assert!(tileset["root"]["implicitTiling"].is_object());

    let mut subtree_files = Vec::new();
    collect_paths_with_extension(&output_dir.join("subtrees"), "subtree", &mut subtree_files);
    assert!(
        !subtree_files.is_empty(),
        "expected implicit tiling subtree outputs under {}",
        output_dir.join("subtrees").display()
    );
}

#[test]
fn tileset_only_skips_glb_tile_directory() {
    let metadata = read_fixture("resources/data/3dbag_x00.city.json");
    let features = read_fixture("cityjson-convert/tests/data/multi_feature_types.city.jsonl");
    let dataset = write_ndjson_dataset("tileset-only", &metadata, &[features]);
    let output_dir = unique_test_dir("tileset-only-output");

    let output = run_tyler(
        &dataset,
        &output_dir,
        &[
            "--3dtiles-tileset-only",
            "--3dtiles-metadata-class",
            "cityobject",
        ],
    );
    assert_success(&output, "tileset-only run");

    assert!(output_dir.join("tileset.json").is_file());
    assert!(
        !output_dir.join("t").exists(),
        "tileset-only output should not create GLB tile directories"
    );
}
