// 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::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;

use anyhow::{Context, Result};
use dashmap::DashSet;
use dsi_progress_logger::{ProgressLog, concurrent_progress_logger};
use rayon::prelude::*;
use serde::Deserialize;
use swh_graph::graph::{NodeId, SwhGraphWithProperties};
use swh_graph::properties;
use swh_graph::{SWHID, swhid};

use crate::packages::map_versions::SerializableVersionToSwhidMapping;
use crate::packages::{
    PackageVersionMappingResult, SerializablePackageVersionInVcs, SerializableVersionMatch,
    VersionSearchInfo,
};

// Special SWHIDs that appear in many projects and should be ignored
const EMPTY_DIR: SWHID = swhid!(swh:1:dir:4b825dc642cb6eb9a060e54bf8d69288fbee4904);
const EMPTY_CONTENT: SWHID = swhid!(swh:1:cnt:e69de29bb2d1d6434b8b29ae775ad8c2e48c5391);
const GITKEEP_DIR: SWHID = swhid!(swh:1:dir:d564d0bc3dd917926892c55e3706cc116d5b165e);
const EMPTY_SNP: SWHID = swhid!(swh:1:snp:1a8893e6a86f444e8be8e7bda6cb34fb1735a00e);

pub fn should_ignore_node<G: SwhGraphWithProperties<Maps: properties::Maps>>(
    graph: &G,
    node: NodeId,
) -> bool {
    let node_swhid = graph.properties().swhid(node);
    matches!(
        node_swhid,
        EMPTY_DIR | EMPTY_CONTENT | GITKEEP_DIR | EMPTY_SNP
    )
}

/// extracts all node ids from `dir / package_revisions` and `dir / package_versions_not_found`
/// (but not `dir / packages_not_found` because it contains no SWH object)
///
/// If `include_vcs_matches` is true, then any commit that shares its root directory with a
/// vulnerable package version is included as well.
pub fn collect_commits_from_package2vcs<
    G: SwhGraphWithProperties<Maps: properties::Maps> + Sync + Send,
>(
    graph: &G,
    dir: &Path,
    include_vcs_matches: bool,
    commits_in_vulnerabilities: &DashSet<NodeId, impl std::hash::BuildHasher + Clone + Sync + Send>,
) -> Result<()> {
    let mut pl = concurrent_progress_logger!(
        display_memory = true,
        item_name = "affected range",
        local_speed = true,
    );
    pl.threshold(1000);
    pl.start(format!("Listing revisions in {}...", dir.display()));

    let package_revisions_dir = dir.join("package_revisions");
    par_iter_ndjson_files(&package_revisions_dir)?
        .try_for_each(
            |mapping: Result<PackageVersionMappingResult<SerializableVersionToSwhidMapping>>| -> Result<()> {
                let PackageVersionMappingResult {
                    result: SerializableVersionToSwhidMapping {
                        package_version: SerializableVersionMatch {
                            origin_url, node, ..
                        },
                        version_type: _,
                        vcs,
                    },
                    ..
                } = mapping?;

                let insert_if_not_ignored = |swhid: SWHID, error_msg: &str| -> Result<()> {
                    let node = graph.properties().node_id(swhid).with_context(|| {
                        format!("{error_msg} {swhid} in {}", package_revisions_dir.display())
                    })?;
                    if !should_ignore_node(graph, node) {
                        commits_in_vulnerabilities.insert(node);
                    }
                    Ok(())
                };

                // Return errors because we produced the input data file from SWHIDs that ought to be
                // in the graph.
                insert_if_not_ignored(SWHID::from_origin_url(&origin_url), "Unknown origin URL")?;
                insert_if_not_ignored(node, "Unknown version SWHID")?;

                for SerializablePackageVersionInVcs { dir, rev } in vcs {
                    let dir_should_be_ignored = graph
                        .properties()
                        .node_id(dir)
                        .map(|node| should_ignore_node(graph, node))
                        .unwrap_or(false);

                    if !dir_should_be_ignored {
                        // 'rev' was found through 'dir'. So if 'dir' should be ignored, then so
                        // should 'rev'.
                        if include_vcs_matches {
                            insert_if_not_ignored(rev, "Unknown rev SWHID")?;
                        }
                        insert_if_not_ignored(dir, "Unknown dir SWHID")?;
                    }
                }

                Ok(())
            },
        )?;

    let package_revisions_dir = dir.join("package_versions_not_found");
    par_iter_ndjson_files(&package_revisions_dir)?.try_for_each(
        |search_result: Result<PackageVersionMappingResult<VersionSearchInfo>>| -> Result<()> {
            let PackageVersionMappingResult {
                result:
                    VersionSearchInfo {
                        version: _,
                        tried_origin_urls,
                        branch_names: _,
                    },
                ..
            } = search_result?;

            let insert_if_not_ignored = |swhid: SWHID| {
                if let Ok(node) = graph.properties().node_id(swhid) {
                    if !should_ignore_node(graph, node) {
                        commits_in_vulnerabilities.insert(node);
                    }
                }
            };

            for origin_url in tried_origin_urls {
                insert_if_not_ignored(SWHID::from_origin_url(&origin_url));
            }

            Ok(())
        },
    )?;

    Ok(())
}

fn par_iter_ndjson_files<T>(dir: &Path) -> Result<impl ParallelIterator<Item = Result<T>>>
where
    for<'a> T: Deserialize<'a> + Sync + Send,
{
    Ok(crate::utils::list_dir(dir)?
        .into_iter()
        .map(|path| {
            let file =
                File::open(&path).with_context(|| format!("Could not open {}", path.display()))?;
            let decoder = deko::AnyDecoder::new(BufReader::new(file));
            Ok((path, BufReader::new(decoder)))
        })
        .collect::<Result<Vec<_>>>()
        .context("Could not initialize readers")?
        .into_par_iter()
        .flat_map_iter(|(path, reader)| {
            reader.lines().map(move |line| -> Result<T> {
                let line =
                    line.with_context(|| format!("Could not read line from {}", path.display()))?;
                serde_json::from_str(&line)
                    .with_context(|| format!("Could not parse record from {}", path.display()))
            })
        }))
}
