// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0

use std::collections::{HashMap, HashSet};
use std::path::PathBuf;

fn main() {
    napi_build::setup();

    // workaround bug that the `#[napi]` macro generate some invalid `#[cfg(feature="...")]`
    println!("cargo:rustc-check-cfg=cfg(feature,values(\"noop\", \"used_linker\"))");

    generate_language_module();
}

/// Collect the public language types (`pub enum` enums and `pub struct` structs)
/// and emit `typescript/generated/language.ts`. Enums become `as const` maps from the Rust
/// variant identifier to the kebab-case string the Slint runtime accepts; structs become
/// TS type aliases. A type-only `namespace language { … }` declaration provides the named
/// types so users can write `let s: language.ColorScheme = language.ColorScheme.Dark;` or
/// type a callback parameter as `language.PointerEvent`.
fn generate_language_module() {
    struct EnumEntry {
        name: &'static str,
        docs: Vec<&'static str>,
        values: Vec<(&'static str, Vec<&'static str>)>,
    }
    let mut enums: Vec<EnumEntry> = Vec::new();
    macro_rules! collect_public_enums {
        ($(
            $(#[doc = $enum_doc:literal])*
            $(#[non_exhaustive])?
            $vis:vis enum $Name:ident {
                $( $(#[doc = $value_doc:literal])* $Value:ident, )*
            }
        )*) => {
            $(
                if stringify!($vis) == "pub" {
                    enums.push(EnumEntry {
                        name: stringify!($Name),
                        docs: vec![$($enum_doc),*],
                        values: vec![$( (stringify!($Value), vec![$($value_doc),*]) ),*],
                    });
                }
            )*
        };
    }
    i_slint_common::for_each_enums!(collect_public_enums);

    let mut structs: Vec<StructEntry> = Vec::new();
    macro_rules! collect_public_structs {
        ($(
            $(#[doc = $struct_doc:literal])*
            $(#[non_exhaustive])?
            $(#[derive(Copy, Eq)])?
            $vis:vis struct $Name:ident {
                $( $(#[doc = $field_doc:literal])* $field:ident : $field_type:ty $(= $field_default:expr)?, )*
            }
        )*) => {
            $(
                if stringify!($vis) == "pub" {
                    structs.push(StructEntry {
                        name: stringify!($Name),
                        docs: vec![$($struct_doc),*],
                        fields: vec![$(
                            (stringify!($field), stringify!($field_type), vec![$($field_doc),*],
                                i_slint_common::builtin_struct_field_default_tokens!($($field_default)?))
                        ),*],
                    });
                }
            )*
        };
    }
    i_slint_common::for_each_builtin_structs!(collect_public_structs);

    let mut in_language: HashSet<&'static str> = HashSet::new();
    for e in &enums {
        in_language.insert(e.name);
    }
    for s in &structs {
        in_language.insert(s.name);
    }
    let mut struct_names: HashSet<&'static str> = HashSet::new();
    for s in &structs {
        struct_names.insert(s.name);
    }
    // First variant of each public enum, as the kebab-case literal we'd write in JS. Mirrors
    // the Rust `Default for Enum` impl in `internal/core/items.rs` which always returns
    // the first variant.
    let mut enum_defaults: HashMap<&'static str, String> = HashMap::new();
    for e in &enums {
        if let Some((first, _)) = e.values.first() {
            enum_defaults.insert(e.name, to_kebab_case(first));
        }
    }

    let mut ts = String::new();
    ts.push_str("// Copyright © SixtyFPS GmbH <info@slint.dev>\n");
    // Build the SPDX header from concatenated parts so REUSE's linter doesn't try to
    // parse the literal as if it applied to this Rust file.
    ts.push_str(concat!(
        "// SPDX-License-",
        "Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0\n\n",
    ));
    ts.push_str("// AUTO-GENERATED by api/node/build.rs from internal/common/enums.rs\n");
    ts.push_str("// and internal/common/builtin_structs.rs. Do not edit.\n\n");

    // DataTransfer is referenced by DropEvent's `data` field; the type lives at the
    // package top level rather than under `language`. Import it through the loader
    // (binding.cjs) so it resolves whichever native binary variant was built.
    ts.push_str("import { DataTransfer } from \"../../binding.cjs\";\n\n");

    ts.push_str("const _data = {\n");
    for entry in &enums {
        write_jsdoc(&mut ts, "    ", &entry.docs);
        ts.push_str(&format!("    {}: {{\n", entry.name));
        for (variant, value_docs) in &entry.values {
            write_jsdoc(&mut ts, "        ", value_docs);
            ts.push_str(&format!(
                "        {variant}: \"{kebab}\",\n",
                kebab = to_kebab_case(variant)
            ));
        }
        ts.push_str("    },\n");
    }
    for s in &structs {
        emit_struct_factory(&mut ts, s, &enum_defaults, &struct_names);
    }
    ts.push_str("} as const;\n\n");

    ts.push_str("/**\n");
    ts.push_str(" * Built-in enums and structs from the Slint language.\n");
    ts.push_str(
        " * Enum values are accessed via `language.ColorScheme.Dark`; struct values via the\n",
    );
    ts.push_str(
        " * factory call `language.PointerEvent({ button: … })`. Enum and struct types are\n",
    );
    ts.push_str(
        " * available in type position as `language.ColorScheme` / `language.PointerEvent`.\n",
    );
    ts.push_str(" */\n");
    ts.push_str("export const language = _data;\n\n");

    ts.push_str("/** Named types for the enum values in {@link language} and the built-in language structs. */\n");
    ts.push_str("// biome-ignore lint/style/useConst: declaration-merging namespace, type-only.\n");
    ts.push_str("export namespace language {\n");
    for entry in &enums {
        // The TS type alias is a computed union, so TypeDoc can't list "members" from it
        // the way it would for a TS `enum`. Append each variant (with its kebab-case value
        // and first-line description) to the JSDoc body so the type's documentation page
        // explains what the variants are.
        let mut docs: Vec<String> = entry.docs.iter().map(|s| s.to_string()).collect();
        if !entry.values.is_empty() {
            if !docs.is_empty() {
                docs.push(String::new());
            }
            docs.push(" Variants:".to_string());
            for (variant, value_docs) in &entry.values {
                let kebab = to_kebab_case(variant);
                let desc = value_docs
                    .iter()
                    .map(|s| s.trim())
                    .collect::<Vec<_>>()
                    .join(" ")
                    .trim()
                    .to_string();
                let sep = if desc.is_empty() { "" } else { " — " };
                docs.push(format!(
                    " - `language.{enum_name}.{variant}` (`\"{kebab}\"`){sep}{desc}",
                    enum_name = entry.name,
                ));
            }
        }
        write_jsdoc(&mut ts, "    ", &docs);
        ts.push_str(&format!(
            "    export type {name} = (typeof _data.{name})[keyof typeof _data.{name}];\n",
            name = entry.name
        ));
    }
    for s in &structs {
        write_jsdoc(&mut ts, "    ", &s.docs);
        ts.push_str(&format!("    export type {name} = {{\n", name = s.name));
        for (field, rust_ty, field_docs, _) in &s.fields {
            write_jsdoc(&mut ts, "        ", field_docs);
            ts.push_str(&format!(
                "        {field}: {ts_ty};\n",
                ts_ty = map_field_type(rust_ty, &in_language),
            ));
        }
        ts.push_str("    };\n");
    }
    ts.push_str("}\n");

    let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("typescript").join("generated");
    std::fs::create_dir_all(&out_dir).expect("create typescript/generated directory");
    let out_path = out_dir.join("language.ts");
    // Only write if the content actually changed, so we don't invalidate downstream builds.
    let need_write =
        std::fs::read_to_string(&out_path).map(|existing| existing != ts).unwrap_or(true);
    if need_write {
        std::fs::write(&out_path, ts).expect("write typescript/generated/language.ts");
    }
}

/// Map a Rust field type (as stringified by the macro) to the TS type used in the generated
/// language module. Fails the build with a clear message when a new struct adds an unmapped
/// type — better than silently emitting `unknown`.
fn map_field_type(rust_ty: &str, in_language: &HashSet<&'static str>) -> String {
    let t = rust_ty.trim();
    match t {
        "bool" => "boolean".to_string(),
        "i32" | "f32" | "f64" | "Coord" => "number".to_string(),
        "SharedString" => "string".to_string(),
        // Types exposed by the binding outside the `language` namespace.
        "DataTransfer" => "DataTransfer".to_string(),
        "LogicalPosition" => "{ x: number; y: number }".to_string(),
        ident if in_language.contains(ident) => ident.to_string(),
        other => panic!(
            "Unmapped struct field type `{other}` in for_each_builtin_structs!. \
             Extend `map_field_type` in api/node/build.rs."
        ),
    }
}

/// Compute the JS default expression for a struct field of the given Rust type.
/// A default value declared in builtin_structs.rs wins; otherwise primitives
/// fall back to zero/empty/false, enum fields use the first-variant kebab string (matching
/// the Rust `Default` impl), and nested struct fields recurse via `_data.<Name>()`.
fn field_default(
    rust_ty: &str,
    declared: Option<&str>,
    enum_defaults: &HashMap<&'static str, String>,
    structs: &HashSet<&'static str>,
) -> String {
    if let Some(declared) = declared {
        let text: String =
            declared.chars().filter(|c| !c.is_whitespace() && *c != ')' && *c != '(').collect();
        return match text.split_once("::") {
            // Enum values are kebab-case strings in the JS API
            Some((_, variant)) => {
                format!("\"{}\"", to_kebab_case(variant.trim_start_matches("r#")))
            }
            // bool and number literals are the same in JS
            None => text,
        };
    }
    let t = rust_ty.trim();
    match t {
        "bool" => "false".to_string(),
        "i32" | "f32" | "f64" | "Coord" => "0".to_string(),
        "SharedString" => "\"\"".to_string(),
        "DataTransfer" => "new DataTransfer()".to_string(),
        "LogicalPosition" => "{ x: 0, y: 0 }".to_string(),
        ident if enum_defaults.contains_key(ident) => format!("\"{}\"", enum_defaults[ident]),
        ident if structs.contains(ident) => format!("_data.{ident}()"),
        other => panic!(
            "Unmapped struct field type `{other}` for default-value computation. \
             Extend `field_default` in api/node/build.rs."
        ),
    }
}

/// Emit a `Foo: (props?: Partial<language.Foo>): language.Foo => Object.freeze({ … })` factory
/// inside `_data`. The factory exists so consumers can build values without specifying every
/// field, which gives us forward-compatibility (the Rust-side analogue of `#[non_exhaustive]`).
fn emit_struct_factory(
    out: &mut String,
    s: &StructEntry,
    enum_defaults: &HashMap<&'static str, String>,
    struct_names: &HashSet<&'static str>,
) {
    out.push_str("    /**\n");
    out.push_str(
        "     * Build a value of this struct. Any field you omit takes a documented default,\n",
    );
    out.push_str(
        "     * which lets Slint add fields later without breaking existing call-sites.\n",
    );
    if !s.docs.is_empty() {
        out.push_str("     *\n");
        for line in &s.docs {
            out.push_str("     *");
            out.push_str(line);
            out.push('\n');
        }
    }
    out.push_str("     */\n");
    out.push_str(&format!(
        "    {name}: (props?: Partial<language.{name}>): language.{name} => Object.freeze({{",
        name = s.name
    ));
    for (field, rust_ty, _, declared_default) in &s.fields {
        out.push_str(&format!(
            " {field}: {default},",
            default = field_default(rust_ty, *declared_default, enum_defaults, struct_names)
        ));
    }
    out.push_str(" ...props }),\n");
}

/// Mirror of `StructEntry` defined inside `generate_language_module`; declared at module
/// scope so helper fns can reference it.
struct StructEntry {
    name: &'static str,
    docs: Vec<&'static str>,
    /// (name, rust type, docs, declared default value tokens)
    fields: Vec<(&'static str, &'static str, Vec<&'static str>, Option<&'static str>)>,
}

/// Emit the rustdoc lines as a JSDoc block at the given indent. No-op if `docs` is empty.
/// Each Rust `///` line carries a leading space; we keep it (it's how the original prose is
/// formatted) and just wrap with `/**` … `*/`.
fn write_jsdoc<S: AsRef<str>>(out: &mut String, indent: &str, docs: &[S]) {
    if docs.is_empty() {
        return;
    }
    out.push_str(indent);
    out.push_str("/**\n");
    for line in docs {
        out.push_str(indent);
        out.push_str(" *");
        out.push_str(line.as_ref());
        out.push('\n');
    }
    out.push_str(indent);
    out.push_str(" */\n");
}

/// Convert `CamelCase` to `kebab-case`. Matches `i_slint_compiler::generator::to_kebab_case`
/// so generated values line up with `Enumeration::values` in the type register.
fn to_kebab_case(s: &str) -> String {
    let mut out = Vec::with_capacity(s.len());
    for b in s.as_bytes() {
        if b.is_ascii_uppercase() {
            if !out.is_empty() {
                out.push(b'-');
            }
            out.push(b.to_ascii_lowercase());
        } else {
            out.push(*b);
        }
    }
    String::from_utf8(out).unwrap()
}
