// Copyright (C) 2026  The Software Heritage developers
// See the AUTHORS file at the top-level directory of this distribution
// License: GNU General Public License version 3, or any later version
// See top-level LICENSE file for more information

use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::Path;

use anyhow::Result;
use dashmap::DashSet;
use osv::schema::Ecosystem;
use swh_graph::graph::{NodeId, SwhGraphWithProperties};
use swh_graph::graph_builder::GraphBuilder;
use swh_graph::{SWHID, swhid};

use swh_osv::packages::graph::collect_commits_from_package2vcs;
use swh_osv::packages::versions::VersionType;
use swh_osv::packages::{
    PackageVersionMappingResult, SerializablePackageVersionInVcs, SerializableVersionMatch,
    SerializableVersionToSwhidMapping, VersionSearchInfo,
};

fn create_test_mapping_json(
    origin_url: &str,
    node_swhid: &str,
    version_name: &str,
    vcs: Vec<(SWHID, SWHID)>,
) -> String {
    let vcs_items: Vec<_> = vcs
        .into_iter()
        .map(|(dir, rev)| SerializablePackageVersionInVcs { dir, rev })
        .collect();

    let mapping = PackageVersionMappingResult {
        vulnerability_filename: "TEST-001.json".to_string(),
        ecosystem: Ecosystem::Npm,
        package_name: "test-package".to_string(),
        result: SerializableVersionToSwhidMapping {
            package_version: SerializableVersionMatch {
                version_name: version_name.to_string(),
                origin_url: origin_url.to_string(),
                fuzzy_branch_name_match: false,
                branch_name: version_name.to_string(),
                node: node_swhid.parse().unwrap(),
            },
            version_type: VersionType::Semver,
            vcs: vcs_items,
        },
    };

    serde_json::to_string(&mapping).unwrap()
}

fn create_test_version_search_info_json(
    version: &str,
    tried_origin_urls: Vec<String>,
    branch_names: Vec<String>,
) -> String {
    let search_info = PackageVersionMappingResult {
        vulnerability_filename: "TEST-001.json".to_string(),
        ecosystem: Ecosystem::Npm,
        package_name: "test-package".to_string(),
        result: VersionSearchInfo {
            version: version.to_string(),
            tried_origin_urls,
            branch_names,
        },
    };

    serde_json::to_string(&search_info).unwrap()
}

fn write_ndjson_file(dir: &Path, filename: &str, lines: Vec<String>) -> Result<()> {
    fs::create_dir_all(dir)?;
    let file_path = dir.join(filename);
    let mut file = fs::File::create(file_path)?;
    for line in lines {
        writeln!(file, "{}", line)?;
    }
    Ok(())
}

fn dashset_to_hashset<S: std::hash::BuildHasher + Clone>(
    dashset: &DashSet<NodeId, S>,
) -> HashSet<NodeId> {
    dashset.iter().map(|entry| *entry.key()).collect()
}

struct TestGraph {
    origin: NodeId,
    rev: NodeId,
    vcs_rev: NodeId,
    rev_with_empty_dir: NodeId,
    vcs_rev_with_empty_dir: NodeId,
    dir: NodeId,
}

fn build_test_graph(
    origin_url: &str,
) -> Result<(
    impl swh_graph::graph::SwhGraphWithProperties<Maps: swh_graph::properties::Maps>,
    TestGraph,
)> {
    let mut builder = GraphBuilder::default();

    let origin = builder
        .node(SWHID::from_origin_url(origin_url))
        .unwrap()
        .message(origin_url.as_bytes().to_vec())
        .done();
    let rev_with_empty_dir = builder
        .node(swhid!(swh:1:rev:0000000000000000000000000000000000000100))
        .unwrap()
        .done();
    let vcs_rev_with_empty_dir = builder
        .node(swhid!(swh:1:rev:0000000000000000000000000000000000000101))
        .unwrap()
        .done();
    let rev = builder
        .node(swhid!(swh:1:rev:0000000000000000000000000000000000000102))
        .unwrap()
        .done();
    let vcs_rev = builder
        .node(swhid!(swh:1:rev:0000000000000000000000000000000000000103))
        .unwrap()
        .done();
    let dir = builder
        .node(swhid!(swh:1:dir:0000000000000000000000000000000000000201))
        .unwrap()
        .done();
    let empty_dir = builder
        .node(swhid!(swh:1:dir:4b825dc642cb6eb9a060e54bf8d69288fbee4904))
        .unwrap()
        .done();
    let empty_content = builder
        .node(swhid!(swh:1:cnt:e69de29bb2d1d6434b8b29ae775ad8c2e48c5391))
        .unwrap()
        .done();
    let gitkeep_dir = builder
        .node(swhid!(swh:1:dir:d564d0bc3dd917926892c55e3706cc116d5b165e))
        .unwrap()
        .done();
    let dummy = builder
        .node(swhid!(swh:1:cnt:0000000000000000000000000000000000000001))
        .unwrap()
        .done();

    builder.arc(origin, rev);
    builder.arc(rev, dir);
    builder.arc(vcs_rev, dir);
    builder.arc(dir, rev);
    builder.arc(rev_with_empty_dir, empty_dir);
    builder.arc(vcs_rev_with_empty_dir, empty_dir);
    builder.arc(empty_dir, empty_content);
    builder.arc(empty_content, gitkeep_dir);
    builder.arc(gitkeep_dir, dummy);
    builder.arc(dummy, origin);

    let graph = builder.done()?;

    Ok((
        graph,
        TestGraph {
            origin,
            rev,
            vcs_rev,
            rev_with_empty_dir,
            vcs_rev_with_empty_dir,
            dir,
        },
    ))
}

#[test]
fn test_collect_commits_from_found_revisions() -> Result<()> {
    let temp_dir = tempfile::tempdir()?;
    let package_revisions_dir = temp_dir.path().join("package_revisions");
    let package_versions_not_found_dir = temp_dir.path().join("package_versions_not_found");

    let origin_url = "https://www.npmjs.com/package/test-package";
    let (graph, nodes) = build_test_graph(origin_url)?;
    let rev_swhid = graph.properties().swhid(nodes.rev);
    let vcs_rev_swhid = graph.properties().swhid(nodes.vcs_rev);
    let dir_swhid = graph.properties().swhid(nodes.dir);

    let json_line = create_test_mapping_json(
        origin_url,
        &rev_swhid.to_string(),
        "1.0.0",
        vec![(dir_swhid, rev_swhid), (dir_swhid, vcs_rev_swhid)],
    );

    write_ndjson_file(&package_revisions_dir, "test.json", vec![json_line])?;
    write_ndjson_file(&package_versions_not_found_dir, "empty.json", vec![])?;

    // include_vcs_matches: true -> vcs_rev is included
    let commits_set = DashSet::new();
    collect_commits_from_package2vcs(&graph, temp_dir.path(), true, &commits_set)?;
    let expected = HashSet::from([nodes.origin, nodes.rev, nodes.vcs_rev, nodes.dir]);
    assert_eq!(dashset_to_hashset(&commits_set), expected);

    // include_vcs_matches: false -> vcs_rev is excluded
    let commits_set = DashSet::new();
    collect_commits_from_package2vcs(&graph, temp_dir.path(), false, &commits_set)?;
    let expected = HashSet::from([nodes.origin, nodes.rev, nodes.dir]);
    assert_eq!(dashset_to_hashset(&commits_set), expected);

    Ok(())
}

#[test]
fn test_collect_commits_from_versions_not_found() -> Result<()> {
    let temp_dir = tempfile::tempdir()?;
    let package_revisions_dir = temp_dir.path().join("package_revisions");
    let package_versions_not_found_dir = temp_dir.path().join("package_versions_not_found");

    let origin_url = "https://www.npmjs.com/package/test-package";

    let json_line = create_test_version_search_info_json(
        "1.0.0",
        vec![origin_url.to_string()],
        vec!["1.0.1".to_string(), "1.1.0".to_string()],
    );

    write_ndjson_file(&package_revisions_dir, "empty.json", vec![])?;
    write_ndjson_file(
        &package_versions_not_found_dir,
        "test.json",
        vec![json_line],
    )?;

    let (graph, nodes) = build_test_graph(origin_url)?;

    let commits_set = DashSet::new();
    collect_commits_from_package2vcs(&graph, temp_dir.path(), true, &commits_set)?;

    let expected = HashSet::from([nodes.origin]);
    assert_eq!(dashset_to_hashset(&commits_set), expected);

    Ok(())
}

#[test]
fn test_collect_commits_with_origin_not_in_graph_for_versions_not_found() -> Result<()> {
    let temp_dir = tempfile::tempdir()?;
    let package_revisions_dir = temp_dir.path().join("package_revisions");
    let package_versions_not_found_dir = temp_dir.path().join("package_versions_not_found");

    let origin_url = "https://www.npmjs.com/package/nonexistent";

    let json_line = create_test_version_search_info_json(
        "1.0.0",
        vec![origin_url.to_string()],
        vec!["1.0.1".to_string()],
    );

    write_ndjson_file(&package_revisions_dir, "empty.json", vec![])?;
    write_ndjson_file(
        &package_versions_not_found_dir,
        "test.json",
        vec![json_line],
    )?;

    let builder = GraphBuilder::default();
    let graph = builder.done()?;

    let commits_set = DashSet::new();
    collect_commits_from_package2vcs(&graph, temp_dir.path(), true, &commits_set)?;

    let expected = HashSet::new();
    assert_eq!(dashset_to_hashset(&commits_set), expected);

    Ok(())
}

#[test]
fn test_ignores_special_nodes() -> Result<()> {
    let temp_dir = tempfile::tempdir()?;
    let package_revisions_dir = temp_dir.path().join("package_revisions");
    let package_versions_not_found_dir = temp_dir.path().join("package_versions_not_found");

    let origin_url = "https://www.npmjs.com/package/test-package";
    let (graph, nodes) = build_test_graph(origin_url)?;
    let rev_with_empty_dir = graph.properties().swhid(nodes.rev_with_empty_dir);
    let vcs_rev_with_empty_dir = graph.properties().swhid(nodes.vcs_rev_with_empty_dir);
    let empty_dir = swhid!(swh:1:dir:4b825dc642cb6eb9a060e54bf8d69288fbee4904);
    let _empty_content = swhid!(swh:1:cnt:e69de29bb2d1d6434b8b29ae775ad8c2e48c5391);
    let gitkeep_dir = swhid!(swh:1:dir:d564d0bc3dd917926892c55e3706cc116d5b165e);

    let json_line = create_test_mapping_json(
        origin_url,
        &rev_with_empty_dir.to_string(),
        "1.0.0",
        vec![
            (empty_dir, rev_with_empty_dir),
            (empty_dir, vcs_rev_with_empty_dir),
            (
                gitkeep_dir,
                swhid!(swh:1:rev:0000000000000000000000000000000000000100),
            ),
        ],
    );

    write_ndjson_file(&package_revisions_dir, "test.json", vec![json_line])?;
    write_ndjson_file(&package_versions_not_found_dir, "empty.json", vec![])?;

    let commits_set = DashSet::new();
    collect_commits_from_package2vcs(&graph, temp_dir.path(), true, &commits_set)?;

    let expected = HashSet::from([nodes.origin, nodes.rev_with_empty_dir]); // excludes vcs_rev_with_empty_dir
    assert_eq!(dashset_to_hashset(&commits_set), expected);

    Ok(())
}
