//! Generates static Rust catalog data from the JSON object catalogs in
//! `specs/`, so the validator does no JSON parsing at runtime. The JSON files
//! remain the canonical interchange format produced by the export pipeline;
//! this build script only transcribes them into `&'static` structures.

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

fn main() {
    println!("cargo:rerun-if-changed=specs");

    let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
    let specs_dir = Path::new(&manifest_dir).join("specs");
    let out_path = Path::new(&env::var("OUT_DIR").expect("OUT_DIR")).join("static_catalogs.rs");

    let mut catalog_files: Vec<_> = fs::read_dir(&specs_dir)
        .expect("read specs dir")
        .filter_map(|entry| entry.ok().map(|e| e.path()))
        .filter(|path| {
            path.file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.starts_with("openrtb-") && n.ends_with("-object-catalog.json"))
        })
        .collect();
    catalog_files.sort();

    let mut out = String::new();
    out.push_str("// @generated by build.rs from specs/*.json. Do not edit.\n\n");
    let mut index_entries = Vec::new();

    for (i, path) in catalog_files.iter().enumerate() {
        println!("cargo:rerun-if-changed={}", path.display());
        let raw = fs::read_to_string(path).expect("read catalog json");
        let catalog: serde_json::Value = serde_json::from_str(&raw)
            .unwrap_or_else(|e| panic!("invalid catalog JSON {}: {e}", path.display()));

        let version = catalog["version"].as_str().expect("catalog version");
        let ident = format!("CATALOG_{i}");
        index_entries.push(format!("({version:?}, &{ident})"));

        writeln!(out, "static {ident}: StaticCatalog = StaticCatalog {{").unwrap();
        writeln!(out, "    version: {version:?},").unwrap();
        writeln!(
            out,
            "    family: {:?},",
            catalog["family"].as_str().unwrap_or("")
        )
        .unwrap();
        writeln!(
            out,
            "    release_date: {:?},",
            catalog["release_date"].as_str().unwrap_or("")
        )
        .unwrap();
        writeln!(
            out,
            "    archive_path: {:?},",
            catalog["archive_path"].as_str().unwrap_or("")
        )
        .unwrap();
        out.push_str("    objects: &[\n");
        for object in catalog["objects"].as_array().expect("objects array") {
            write_object(&mut out, object);
        }
        out.push_str("    ],\n};\n\n");
    }

    writeln!(
        out,
        "pub(crate) static GENERATED_CATALOGS: &[(&str, &StaticCatalog)] = &[{}];",
        index_entries.join(", ")
    )
    .unwrap();

    fs::write(&out_path, out).expect("write static_catalogs.rs");
}

fn write_object(out: &mut String, object: &serde_json::Value) {
    writeln!(out, "        StaticObject {{").unwrap();
    writeln!(
        out,
        "            name: {:?},",
        object["name"].as_str().unwrap()
    )
    .unwrap();
    writeln!(
        out,
        "            section: {:?},",
        object["section"].as_str().unwrap_or("")
    )
    .unwrap();
    write_citation(out, &object["citation"], "            ");
    out.push_str("            fields: &[\n");
    for field in object["fields"]
        .as_array()
        .map(|a| a.as_slice())
        .unwrap_or(&[])
    {
        write_field(out, field);
    }
    out.push_str("            ],\n        },\n");
}

fn write_field(out: &mut String, field: &serde_json::Value) {
    writeln!(out, "                StaticField {{").unwrap();
    writeln!(
        out,
        "                    name: {:?},",
        field["name"].as_str().unwrap()
    )
    .unwrap();
    let type_spec = field["type_spec"].as_str().unwrap_or("");
    writeln!(out, "                    type_spec: {type_spec:?},").unwrap();
    writeln!(
        out,
        "                    shape: ExpectedShape::{},",
        expected_shape_variant(type_spec)
    )
    .unwrap();
    writeln!(
        out,
        "                    required: {},",
        is_required(type_spec)
    )
    .unwrap();
    writeln!(
        out,
        "                    deprecated: {},",
        type_spec.to_ascii_lowercase().contains("deprecated")
    )
    .unwrap();
    writeln!(
        out,
        "                    child_object: {},",
        opt_str(field.get("child_object"))
    )
    .unwrap();
    writeln!(
        out,
        "                    adcom_list: {},",
        opt_str(field.get("adcom_list"))
    )
    .unwrap();
    match field.get("value_set").filter(|v| !v.is_null()) {
        Some(value_set) => {
            // Sorted and deduplicated so the validator can binary-search
            // the emitted slice directly.
            let mut numeric_values: Vec<i64> = value_set["values"]
                .as_array()
                .map(|a| a.iter().map(|v| v.as_i64().unwrap()).collect())
                .unwrap_or_default();
            numeric_values.sort_unstable();
            numeric_values.dedup();
            let values: Vec<String> = numeric_values.iter().map(ToString::to_string).collect();
            let minimum = value_set
                .get("minimum_inclusive")
                .and_then(|v| v.as_i64())
                .map(|v| format!("Some({v})"))
                .unwrap_or_else(|| String::from("None"));
            writeln!(
                out,
                "                    value_set: Some(StaticValueSet {{ values: &[{}], minimum_inclusive: {} }}),",
                values.join(", "),
                minimum
            )
            .unwrap();
        }
        None => out.push_str("                    value_set: None,\n"),
    }
    write_citation(out, &field["citation"], "                    ");
    out.push_str("                },\n");
}

fn write_citation(out: &mut String, citation: &serde_json::Value, indent: &str) {
    writeln!(out, "{indent}citation: StaticCitation {{").unwrap();
    writeln!(
        out,
        "{indent}    section: {:?},",
        citation["section"].as_str().unwrap_or("")
    )
    .unwrap();
    writeln!(
        out,
        "{indent}    canonical_source_file: {:?},",
        citation["canonical_source_file"].as_str().unwrap_or("")
    )
    .unwrap();
    writeln!(
        out,
        "{indent}    helper_source_file: {:?},",
        citation["helper_source_file"].as_str().unwrap_or("")
    )
    .unwrap();
    writeln!(
        out,
        "{indent}    start_line: {},",
        citation
            .get("start_line")
            .and_then(|v| v.as_u64())
            .unwrap_or(0)
    )
    .unwrap();
    writeln!(
        out,
        "{indent}    end_line: {},",
        citation
            .get("end_line")
            .and_then(|v| v.as_u64())
            .unwrap_or(0)
    )
    .unwrap();
    writeln!(out, "{indent}}},").unwrap();
}

fn opt_str(value: Option<&serde_json::Value>) -> String {
    match value.and_then(|v| v.as_str()) {
        Some(s) => format!("Some({s:?})"),
        None => String::from("None"),
    }
}

/// Mirrors the shape mapping the validator used to compute at runtime; the
/// check order matters (compound array specs before their scalar prefixes).
fn expected_shape_variant(type_spec: &str) -> &'static str {
    let normalized = type_spec.to_ascii_lowercase();

    if normalized.contains("object array") {
        return "ObjectArray";
    }
    if normalized.contains("string array") {
        return "StringArray";
    }
    if normalized.contains("integer array") {
        return "IntegerArray";
    }
    if normalized.contains("float array") {
        return "FloatArray";
    }
    if normalized.contains("boolean array") {
        return "BooleanArray";
    }
    if normalized.contains("enum array") {
        return "AnyArray";
    }
    if normalized.contains("object") {
        return "Object";
    }
    if normalized.contains("string") {
        return "String";
    }
    if normalized.contains("integer") {
        return "Integer";
    }
    if normalized.contains("float") {
        return "Float";
    }
    if normalized.contains("boolean") {
        return "Boolean";
    }
    "Unknown"
}

/// A field is unconditionally required only when the type column says so as
/// its own segment ("string; required", "scope: required; type: ...").
/// Conditional phrasings from the spec's prose ("required for Flex Ads",
/// "required if sourcetype is present") must not count.
fn is_required(type_spec: &str) -> bool {
    type_spec
        .to_ascii_lowercase()
        .split(';')
        .map(str::trim)
        .any(|segment| {
            segment == "required" || segment == "required *" || segment == "scope: required"
        })
}
