//! Preamble comment block for generated files.
//!
//! Builds the copyright, license, and provenance comment that appears at the
//! top of every generated `.h` and `.c` file.

use indexmap::IndexMap;

use crate::build_info;
use crate::provenance::manifest::ProvenancePin;
use crate::provenance::{self, Attribution};
use crate::resolve::{FeatureSet, SelectionReason};

/// Build the preamble comment block for a generated file.
///
/// The block includes, in order:
///   - gloam version and the command line that produced the file
///   - extension provenance summary
///   - MIT license notice for gloam itself
///   - one copyright notice per contributing rights holder (grouped/sorted)
///   - the upstream source provenance, grouped by repository
///   - a "DO NOT EDIT" footer
///
/// `pins` maps each of `fs.source_keys` to its resolved provenance.
pub fn build_preamble(
    fs: &FeatureSet,
    pins: &IndexMap<String, ProvenancePin>,
    command_line: &str,
) -> String {
    let mut lines: Vec<String> = Vec::new();
    let year = build_info::BUILD_YEAR;

    lines.push(format!(" * Generated by gloam {}.", build_info::VERSION));
    lines.push(" *".to_string());
    lines.push(format!(" *   {command_line}"));

    // ---- Extension provenance ----
    if !fs.extensions.is_empty()
        || !fs.excluded_explicit.is_empty()
        || !fs.excluded_baseline.is_empty()
    {
        lines.push(" *".to_string());
        lines.push(format!(" * {}", extension_summary(fs)));

        // List implicitly-included extensions (dependency, promoted, predecessor)
        // so the user can see what was pulled in automatically.
        for (label, reason) in &[
            ("dependency", SelectionReason::Dependency),
            ("promoted", SelectionReason::Promoted),
            ("predecessor", SelectionReason::Predecessor),
        ] {
            let mut names: Vec<&str> = fs
                .extensions
                .iter()
                .filter(|e| e.reason == *reason)
                .map(|e| e.name.as_str())
                .collect();
            names.sort_unstable();
            if !names.is_empty() {
                lines.push(format!(" *   {}: {}", label, names.join(", ")));
            }
        }

        // List excluded extensions.
        if !fs.excluded_baseline.is_empty() {
            lines.push(format!(
                " *   excluded by baseline: {} extensions",
                fs.excluded_baseline.len()
            ));
        }
        if !fs.excluded_explicit.is_empty() {
            let mut names = fs.excluded_explicit.clone();
            names.sort_unstable();
            lines.push(format!(" *   excluded explicitly: {}", names.join(", ")));
        }
    }

    // ---- gloam license (always first) ----
    lines.push(" *".to_string());
    lines.push(format!(" * Copyright (c) {year} Steven Noonan"));
    lines.push(" * SPDX-License-Identifier: MIT".to_string());

    // ---- third-party copyrights, one per contributing rights holder ----
    for attr in contributing_attributions(fs) {
        lines.push(" *".to_string());
        lines.push(format!(" * {}", attr.blurb));
        // Trim any trailing period on the holder so we don't double it (e.g.
        // "The Khronos Group Inc.").
        lines.push(format!(
            " * Copyright (c) {}-{year} {}.",
            attr.year_start,
            attr.holder.trim_end_matches('.')
        ));
        lines.push(format!(" * SPDX-License-Identifier: {}", attr.license));
    }

    // ---- upstream source provenance, grouped by repository ----
    let groups = provenance::group_pins_by_repo(pins);
    if !groups.is_empty() {
        lines.push(" *".to_string());
        lines.push(" * Generated from the following upstream sources:".to_string());
        lines.push(" *".to_string());
        for group in &groups {
            lines.push(format!(" *   {} ({})", group.repo, group.describe));
            for (path, blob) in &group.files {
                lines.push(format!(" *     {} (blob {})", path, short_blob(blob)));
            }
        }
    }

    // ---- footer ----
    lines.push(" *".to_string());
    lines.push(
        " * DO NOT EDIT. This file is generated by gloam and will be overwritten;".to_string(),
    );
    lines.push(" * make changes to the gloam invocation or inputs, not to this file.".to_string());

    format!("/*\n{}\n */", lines.join("\n"))
}

/// Distinct non-gloam attributions among this loader's contributing sources,
/// sorted by SPDX license id then holder for deterministic output.  gloam's own
/// files (`is_self`) are covered by the primary MIT notice and excluded here.
fn contributing_attributions(fs: &FeatureSet) -> Vec<&'static Attribution> {
    let mut seen: Vec<&'static Attribution> = Vec::new();
    for key in &fs.source_keys {
        if let Some((cluster, _)) = provenance::find(key) {
            let attr = cluster.attribution;
            if attr.is_self {
                continue;
            }
            if !seen.iter().any(|a| std::ptr::eq(*a, attr)) {
                seen.push(attr);
            }
        }
    }
    seen.sort_by(|a, b| a.license.cmp(b.license).then(a.holder.cmp(b.holder)));
    seen
}

/// Short (7-char) form of a blob SHA-1 for the header's source list.
fn short_blob(blob: &str) -> &str {
    &blob[..7.min(blob.len())]
}

/// Build a one-line summary of extension selection.
///
/// Examples:
///   "Extensions: all (451 included)"
///   "Extensions: all, 68 excluded by baseline, 3 excluded explicitly (380 included)"
///   "Extensions: 5 explicit, 12 promoted, 3 predecessor (20 included)"
fn extension_summary(fs: &FeatureSet) -> String {
    let total = fs.extensions.len();
    let n_baseline_excluded = fs.excluded_baseline.len();
    let n_explicit_excluded = fs.excluded_explicit.len();

    let count = |reason: SelectionReason| -> usize {
        fs.extensions.iter().filter(|e| e.reason == reason).count()
    };

    let n_all = count(SelectionReason::AllExtensions);
    let n_explicit = count(SelectionReason::Explicit);
    let n_mandatory = count(SelectionReason::Mandatory);
    let n_dependency = count(SelectionReason::Dependency);
    let n_promoted = count(SelectionReason::Promoted);
    let n_predecessor = count(SelectionReason::Predecessor);

    let mut parts: Vec<String> = Vec::new();

    // If everything came from "all extensions" (no filter), use the short form.
    if n_all + n_mandatory == total {
        parts.push("all".to_string());
    } else {
        if n_explicit > 0 {
            parts.push(format!("{n_explicit} explicit"));
        }
        if n_mandatory > 0 {
            parts.push(format!("{n_mandatory} mandatory"));
        }
        if n_dependency > 0 {
            parts.push(format!("{n_dependency} dependency"));
        }
        if n_promoted > 0 {
            parts.push(format!("{n_promoted} promoted"));
        }
        if n_predecessor > 0 {
            parts.push(format!("{n_predecessor} predecessor"));
        }
    }

    if n_baseline_excluded > 0 {
        parts.push(format!("{n_baseline_excluded} excluded by baseline"));
    }
    if n_explicit_excluded > 0 {
        parts.push(format!("{n_explicit_excluded} excluded explicitly"));
    }

    format!("Extensions: {} ({total} included)", parts.join(", "))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::resolve::Extension;

    /// Build a preamble with no resolved pins (sources block empty) — for tests
    /// that only assert on copyright/extension content.
    fn preamble_no_pins(fs: &FeatureSet, command_line: &str) -> String {
        build_preamble(fs, &IndexMap::new(), command_line)
    }

    /// Minimal FeatureSet for testing.
    fn stub_fs(spec_name: &str) -> FeatureSet {
        FeatureSet {
            spec_name: spec_name.to_string(),
            display_name: String::new(),
            apis: vec![],
            is_merged: false,
            is_vulkan: false,
            is_gl_family: false,
            context_name: String::new(),
            features: vec![],
            extensions: vec![],
            commands: vec![],
            types: vec![],
            flat_enums: vec![],
            enum_groups: vec![],
            feature_pfn_ranges: vec![],
            ext_pfn_ranges: Default::default(),
            ext_subset_indices: Default::default(),
            alias_pairs: vec![],
            required_headers: vec![],
            // Realistic default: the primary spec plus xxhash.h (always emitted),
            // so Khronos + xxHash attributions appear.
            source_keys: provenance::primary_key(spec_name)
                .map(|p| vec![p.to_string(), "xxhash.h".to_string()])
                .unwrap_or_default(),
            excluded_explicit: vec![],
            excluded_baseline: vec![],
            include_type_groups: vec![],
            type_groups: vec![],
            ext_guard_groups: vec![],
            cmd_pfn_groups: vec![],
            flat_enum_groups: vec![],
        }
    }

    fn stub_ext(name: &str, reason: SelectionReason) -> Extension {
        Extension {
            index: 0,
            name: name.to_string(),
            short_name: name.to_string(),
            hash: String::new(),
            protect: vec![],
            reason,
        }
    }

    #[test]
    fn preamble_contains_version_and_command() {
        let fs = stub_fs("vk");
        let p = preamble_no_pins(&fs, "gloam --api vk=1.3 c");
        assert!(p.contains(build_info::VERSION));
        assert!(p.contains("gloam --api vk=1.3 c"));
    }

    #[test]
    fn preamble_contains_gloam_mit_license() {
        let fs = stub_fs("vk");
        let p = preamble_no_pins(&fs, "gloam --api vk=1.3 c");
        assert!(p.contains("Steven Noonan"));
        assert!(p.contains("SPDX-License-Identifier: MIT"));
    }

    #[test]
    fn preamble_contains_khronos_copyright() {
        let fs = stub_fs("vk");
        let p = preamble_no_pins(&fs, "gloam --api vk=1.3 c");
        assert!(p.contains("Khronos Group"));
        assert!(p.contains("Apache-2.0"));
    }

    #[test]
    fn preamble_includes_angle_when_gl_angle_ext_merged() {
        // GLES in scope → gl_angle_ext.xml merged → ANGLE notice.
        let mut fs = stub_fs("gl");
        fs.source_keys = vec![
            "gl.xml".into(),
            "gl_angle_ext.xml".into(),
            "glsl_exts.xml".into(),
        ];
        let p = preamble_no_pins(&fs, "gloam --api gles2 c");
        assert!(p.contains("Includes extensions from the ANGLE project."));
        assert!(p.contains("The ANGLE Project Authors"));
        assert!(p.contains("BSD-3-Clause"));
    }

    #[test]
    fn preamble_includes_angle_for_egl() {
        let mut fs = stub_fs("egl");
        fs.source_keys = vec!["egl.xml".into(), "egl_angle_ext.xml".into()];
        let p = preamble_no_pins(&fs, "gloam --api egl c");
        assert!(p.contains("Includes extensions from the ANGLE project."));
        assert!(p.contains("BSD-3-Clause"));
    }

    #[test]
    fn preamble_excludes_angle_for_gl_core() {
        // The bug fix: gl:core never merges gl_angle_ext.xml, so no ANGLE notice.
        let mut fs = stub_fs("gl");
        fs.source_keys = vec![
            "gl.xml".into(),
            "glsl_exts.xml".into(),
            "KHR/khrplatform.h".into(),
        ];
        let p = preamble_no_pins(&fs, "gloam --api gl:core=3.3 c");
        assert!(!p.contains("ANGLE"), "gl:core must not advertise ANGLE");
        assert!(!p.contains("BSD-3-Clause"));
    }

    #[test]
    fn preamble_excludes_angle_for_vulkan() {
        let fs = stub_fs("vk");
        let p = preamble_no_pins(&fs, "gloam --api vk=1.3 c");
        assert!(!p.contains("ANGLE"));
        assert!(!p.contains("BSD-3-Clause"));
    }

    #[test]
    fn preamble_excludes_angle_for_glx() {
        let fs = stub_fs("glx");
        let p = preamble_no_pins(&fs, "gloam --api glx c");
        assert!(!p.contains("ANGLE"));
    }

    #[test]
    fn preamble_includes_xxhash_attribution() {
        // xxhash.h is in every loader's source_keys (default stub), so its
        // BSD-2-Clause notice always appears.
        let fs = stub_fs("vk");
        let p = preamble_no_pins(&fs, "gloam --api vk=1.3 c");
        assert!(p.contains("Portions derived from xxHash."));
        assert!(p.contains("Yann Collet"));
        assert!(p.contains("BSD-2-Clause"));
    }

    #[test]
    fn preamble_copyright_groups_are_ordered_and_deduped() {
        // Two Khronos-attributed files collapse to a single Apache-2.0 notice,
        // ordered before BSD-3-Clause (ANGLE) by SPDX id.
        let mut fs = stub_fs("gl");
        fs.source_keys = vec![
            "gl.xml".into(),            // Khronos (OpenGL-Registry)
            "KHR/khrplatform.h".into(), // Khronos (EGL-Registry)
            "gl_angle_ext.xml".into(),  // ANGLE
            "xxhash.h".into(),          // xxHash
        ];
        let p = preamble_no_pins(&fs, "gloam --api gles2 c");
        assert_eq!(
            p.matches("The Khronos Group Inc.").count(),
            1,
            "Khronos collapses"
        );
        let apache = p.find("Apache-2.0").unwrap();
        let bsd2 = p.find("BSD-2-Clause").unwrap();
        let bsd3 = p.find("BSD-3-Clause").unwrap();
        assert!(
            apache < bsd2 && bsd2 < bsd3,
            "ordered Apache < BSD-2 < BSD-3"
        );
    }

    #[test]
    fn preamble_sources_block_lists_pins_grouped_by_repo() {
        let mut fs = stub_fs("gl");
        fs.source_keys = vec!["gl.xml".into(), "xxhash.h".into()];
        let mut pins = IndexMap::new();
        pins.insert(
            "gl.xml".to_string(),
            ProvenancePin {
                repo: "KhronosGroup/OpenGL-Registry".into(),
                repo_url: "https://github.com/KhronosGroup/OpenGL-Registry".into(),
                path_in_repo: "xml/gl.xml".into(),
                commit: "a1b2c3d4".into(),
                describe: "a1b2c3d".into(),
                blob: "0fa1e2d3aaaa".into(),
            },
        );
        pins.insert(
            "xxhash.h".to_string(),
            ProvenancePin {
                repo: "Cyan4973/xxHash".into(),
                repo_url: "https://github.com/Cyan4973/xxHash".into(),
                path_in_repo: "xxhash.h".into(),
                commit: "7e3f2a19".into(),
                describe: "v0.8.2-9-g7e3f2a1".into(),
                blob: "abcabc12".into(),
            },
        );
        let p = build_preamble(&fs, &pins, "gloam --api gl:core=3.3 c");
        assert!(p.contains("Generated from the following upstream sources:"));
        assert!(p.contains("Cyan4973/xxHash (v0.8.2-9-g7e3f2a1)"));
        assert!(p.contains("KhronosGroup/OpenGL-Registry (a1b2c3d)"));
        assert!(p.contains("xml/gl.xml (blob 0fa1e2d)"));
        // Repos sorted case-insensitively: Cyan4973 before KhronosGroup.
        assert!(p.find("Cyan4973").unwrap() < p.find("KhronosGroup").unwrap());
    }

    #[test]
    fn preamble_has_do_not_edit_footer() {
        let fs = stub_fs("vk");
        let p = preamble_no_pins(&fs, "gloam --api vk=1.3 c");
        assert!(p.contains("DO NOT EDIT."));
        // Footer is near the end, not on the "Generated by" line.
        assert!(!p.lines().next().unwrap().contains("DO NOT EDIT"));
        let do_not_edit_line = p.lines().position(|l| l.contains("DO NOT EDIT")).unwrap();
        let generated_line = p
            .lines()
            .position(|l| l.contains("Generated by gloam"))
            .unwrap();
        assert!(do_not_edit_line > generated_line);
    }

    #[test]
    fn preamble_is_valid_c_comment() {
        let fs = stub_fs("gl");
        let p = preamble_no_pins(&fs, "gloam --api gl:core=3.3 c");
        assert!(p.starts_with("/*"));
        assert!(p.ends_with(" */"));
    }

    #[test]
    fn preamble_uses_build_year_not_hardcoded() {
        let fs = stub_fs("vk");
        let p = preamble_no_pins(&fs, "gloam --api vk=1.3 c");
        let year = build_info::BUILD_YEAR.to_string();
        assert!(
            p.contains(&format!("Copyright (c) {year} Steven Noonan")),
            "gloam copyright should use BUILD_YEAR ({year})"
        );
        assert!(
            p.contains(&format!("2013-{year} The Khronos Group")),
            "Khronos copyright range should end with BUILD_YEAR ({year})"
        );
    }

    // ---- Extension provenance summary ----

    #[test]
    fn summary_all_extensions() {
        let mut fs = stub_fs("vk");
        fs.extensions = vec![
            stub_ext("VK_KHR_swapchain", SelectionReason::AllExtensions),
            stub_ext("VK_KHR_surface", SelectionReason::AllExtensions),
        ];
        assert_eq!(extension_summary(&fs), "Extensions: all (2 included)");
    }

    #[test]
    fn summary_all_plus_mandatory() {
        let mut fs = stub_fs("wgl");
        fs.extensions = vec![
            stub_ext("WGL_ARB_extensions_string", SelectionReason::Mandatory),
            stub_ext("WGL_ARB_pixel_format", SelectionReason::AllExtensions),
        ];
        assert_eq!(extension_summary(&fs), "Extensions: all (2 included)");
    }

    #[test]
    fn summary_explicit_only() {
        let mut fs = stub_fs("vk");
        fs.extensions = vec![
            stub_ext("VK_KHR_swapchain", SelectionReason::Explicit),
            stub_ext("VK_KHR_surface", SelectionReason::Explicit),
        ];
        assert_eq!(
            extension_summary(&fs),
            "Extensions: 2 explicit (2 included)"
        );
    }

    #[test]
    fn summary_mixed_reasons() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![
            stub_ext("GL_KHR_debug", SelectionReason::Explicit),
            stub_ext("GL_ARB_copy_buffer", SelectionReason::Promoted),
            stub_ext("GL_ARB_multitexture", SelectionReason::Promoted),
            stub_ext(
                "GL_ARB_parallel_shader_compile",
                SelectionReason::Predecessor,
            ),
        ];
        assert_eq!(
            extension_summary(&fs),
            "Extensions: 1 explicit, 2 promoted, 1 predecessor (4 included)"
        );
    }

    #[test]
    fn preamble_lists_promoted_extensions() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![
            stub_ext("GL_KHR_debug", SelectionReason::Explicit),
            stub_ext("GL_ARB_copy_buffer", SelectionReason::Promoted),
        ];
        let p = preamble_no_pins(&fs, "gloam --api gl:core=3.3 c");
        assert!(p.contains("promoted: GL_ARB_copy_buffer"));
    }

    #[test]
    fn preamble_lists_predecessor_extensions() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![
            stub_ext("GL_KHR_parallel_shader_compile", SelectionReason::Explicit),
            stub_ext(
                "GL_ARB_parallel_shader_compile",
                SelectionReason::Predecessor,
            ),
        ];
        let p = preamble_no_pins(&fs, "gloam --api gl:core=3.3 c");
        assert!(p.contains("predecessor: GL_ARB_parallel_shader_compile"));
    }

    #[test]
    fn preamble_lists_dependency_extensions() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![
            stub_ext("GL_ARB_multi_draw_indirect", SelectionReason::Explicit),
            stub_ext("GL_ARB_draw_indirect", SelectionReason::Dependency),
        ];
        let p = preamble_no_pins(&fs, "gloam --api gl:core=3.3 c");
        assert!(p.contains("dependency: GL_ARB_draw_indirect"));
    }

    #[test]
    fn summary_with_dependencies() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![
            stub_ext("GL_ARB_multi_draw_indirect", SelectionReason::Explicit),
            stub_ext("GL_ARB_draw_indirect", SelectionReason::Dependency),
        ];
        assert_eq!(
            extension_summary(&fs),
            "Extensions: 1 explicit, 1 dependency (2 included)"
        );
    }

    #[test]
    fn preamble_no_extension_section_when_empty() {
        let fs = stub_fs("vk");
        let p = preamble_no_pins(&fs, "gloam --api vk=1.3 c");
        assert!(!p.contains("Extensions:"));
    }

    // ---- Exclusion display ----

    #[test]
    fn summary_with_baseline_exclusions() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![stub_ext("GL_KHR_debug", SelectionReason::AllExtensions)];
        fs.excluded_baseline = vec![
            "GL_ARB_copy_buffer".to_string(),
            "GL_ARB_multitexture".to_string(),
        ];
        assert_eq!(
            extension_summary(&fs),
            "Extensions: all, 2 excluded by baseline (1 included)"
        );
    }

    #[test]
    fn summary_with_explicit_exclusions() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![stub_ext("GL_KHR_debug", SelectionReason::AllExtensions)];
        fs.excluded_explicit = vec!["GL_EXT_direct_state_access".to_string()];
        assert_eq!(
            extension_summary(&fs),
            "Extensions: all, 1 excluded explicitly (1 included)"
        );
    }

    #[test]
    fn summary_with_both_exclusion_types() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![stub_ext("GL_KHR_debug", SelectionReason::AllExtensions)];
        fs.excluded_baseline = vec!["GL_ARB_copy_buffer".to_string()];
        fs.excluded_explicit = vec!["GL_EXT_direct_state_access".to_string()];
        assert_eq!(
            extension_summary(&fs),
            "Extensions: all, 1 excluded by baseline, 1 excluded explicitly (1 included)"
        );
    }

    #[test]
    fn preamble_shows_baseline_exclusion_count() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![stub_ext("GL_KHR_debug", SelectionReason::AllExtensions)];
        fs.excluded_baseline = vec![
            "GL_ARB_copy_buffer".to_string(),
            "GL_ARB_multitexture".to_string(),
        ];
        let p = preamble_no_pins(&fs, "gloam --api gl:core c");
        assert!(p.contains("excluded by baseline: 2 extensions"));
    }

    #[test]
    fn preamble_shows_explicit_exclusion_names() {
        let mut fs = stub_fs("gl");
        fs.extensions = vec![stub_ext("GL_KHR_debug", SelectionReason::AllExtensions)];
        fs.excluded_explicit = vec!["GL_EXT_direct_state_access".to_string()];
        let p = preamble_no_pins(&fs, "gloam --api gl:core c");
        assert!(p.contains("excluded explicitly: GL_EXT_direct_state_access"));
    }
}
