// 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

//! Version detection from package managers

use anyhow::{Context, Result, anyhow, bail};
use osv::schema::{Affected, Ecosystem, Event, RangeType, Version};
use serde::{Deserialize, Serialize};
use swh_graph::graph::{NodeId, SwhBackwardGraph, SwhGraphWithProperties, SwhLabeledForwardGraph};
use swh_graph::labels::Branch;
use swh_graph::{NodeType, SWHID, properties, swhid};

use super::versions::VersionType;
use super::{
    PackageNotFoundInSwh, PackageVersionInVcs, SerializablePackageVersionInVcs,
    SerializableVersionMatch, Stats, VersionMatch, VersionSearchResult, increment,
    increment_by_ecosystem, map_package_to_origin_urls,
};

fn branch_name_matches(branch_name: &[u8], version: &str) -> bool {
    branch_name == version.as_bytes()
        || branch_name.ends_with(format!("/{version}").as_bytes())
        || branch_name == format!("v{version}").as_bytes()
        || branch_name.ends_with(format!("/v{version}").as_bytes())
}

/// Find a single package version in SWH graph
///
/// Returns the match (if found) and all branch names that were seen
pub fn find_single_package_version_in_swh<G>(
    graph: &G,
    origins: &[(NodeId, String)],
    version: &str,
) -> VersionSearchResult
where
    G: SwhLabeledForwardGraph + SwhBackwardGraph + SwhGraphWithProperties,
    <G as SwhGraphWithProperties>::LabelNames: properties::LabelNames,
{
    use rapidhash::RapidHashSet;

    let mut branch_names = RapidHashSet::default();
    let mut fuzzy_match = None;

    for &(origin, ref origin_url) in origins {
        for snapshot in graph.successors(origin) {
            for (branch_target, labels) in graph.untyped_labeled_successors(snapshot) {
                for label in labels {
                    let label = Branch::from(label);
                    let branch_name = graph.properties().label_name(label.label_name_id());
                    branch_names.insert(branch_name.to_vec());

                    if branch_name_matches(&branch_name, version) {
                        // confident match
                        return VersionSearchResult::Found(VersionMatch {
                            version_name: version.to_string(),
                            origin_url: origin_url.clone(),
                            fuzzy_branch_name_match: false,
                            branch_name: branch_name.to_vec(),
                            node: branch_target,
                        });
                    } else if branch_name.ends_with(version.as_bytes()) {
                        // fuzzy match
                        if fuzzy_match.is_none() {
                            fuzzy_match = Some(VersionMatch {
                                version_name: version.to_string(),
                                origin_url: origin_url.clone(),
                                fuzzy_branch_name_match: true,
                                branch_name: branch_name.to_vec(),
                                node: branch_target,
                            });
                        }
                    } else if let Some(stripped_version) = version.strip_suffix(".0") {
                        // eg. Linux tags version v5.5.0 as v5.5
                        if fuzzy_match.is_none()
                            && branch_name_matches(&branch_name, stripped_version)
                        {
                            fuzzy_match = Some(VersionMatch {
                                version_name: version.to_string(),
                                origin_url: origin_url.clone(),
                                fuzzy_branch_name_match: true,
                                branch_name: branch_name.to_vec(),
                                node: branch_target,
                            });
                        }
                    }
                }
            }
        }
    }

    if let Some(fuzzy_match) = fuzzy_match {
        VersionSearchResult::Found(fuzzy_match)
    } else {
        let mut branch_names: Vec<_> = branch_names.into_iter().collect();
        branch_names.sort_unstable();
        VersionSearchResult::NotFound { branch_names }
    }
}

pub fn map_package_version_to_swh<G>(
    graph: &G,
    origins: &[(NodeId, String)],
    version: &Version,
    stats: &Stats,
    ecosystem: &Ecosystem,
    version_type: VersionType,
) -> Result<VersionInSwh>
where
    G: SwhLabeledForwardGraph + SwhBackwardGraph + SwhGraphWithProperties,
    <G as SwhGraphWithProperties>::Maps: properties::Maps,
    <G as SwhGraphWithProperties>::LabelNames: properties::LabelNames,
{
    if version == "0" {
        return Ok(VersionInSwh::SinceTheBeginning);
    }

    let search_result = find_single_package_version_in_swh(graph, origins, version);

    match search_result {
        VersionSearchResult::NotFound { branch_names } => {
            increment_by_ecosystem(
                &stats.unfound_package_version_in_swh_by_ecosystem,
                ecosystem,
            );

            Ok(VersionInSwh::VersionNotFound(VersionSearchInfo {
                version: version.clone(),
                tried_origin_urls: origins
                    .iter()
                    .map(|&(_origin_id, ref origin_url)| origin_url.clone())
                    .collect(),
                branch_names: branch_names
                    .into_iter()
                    .map(|name| String::from_utf8_lossy(&name).into())
                    .collect(),
            }))
        }
        VersionSearchResult::Found(version_match) => {
            if version_match.fuzzy_branch_name_match {
                increment_by_ecosystem(
                    &stats.found_fuzzy_package_version_in_swh_by_ecosystem,
                    ecosystem,
                );
            } else {
                increment_by_ecosystem(
                    &stats.found_sure_package_version_in_swh_by_ecosystem,
                    ecosystem,
                );
            }

            let vcs =
                find_corresponding_source_in_vcs(graph, stats, ecosystem, version_match.node)?;

            Ok(VersionInSwh::MappedFromVersion(VersionToSwhidMapping {
                package_version: version_match,
                version_type,
                vcs,
            }))
        }
    }
}

pub fn find_corresponding_source_in_vcs<G>(
    graph: &G,
    stats: &Stats,
    ecosystem: &Ecosystem,
    node: NodeId,
) -> Result<Vec<PackageVersionInVcs>>
where
    G: SwhLabeledForwardGraph + SwhBackwardGraph + SwhGraphWithProperties,
    <G as SwhGraphWithProperties>::Maps: properties::Maps,
    <G as SwhGraphWithProperties>::LabelNames: properties::LabelNames,
{
    let swhid = graph.properties().swhid(node);

    if !matches!(swhid.node_type, NodeType::Release | NodeType::Revision) {
        log::warn!(
            "Snapshot branch matching a package release points to unexpected node type {swhid}"
        );
        return Ok(Vec::new());
    }

    let Some(dir) = swh_graph_stdlib::find_root_dir(graph, node)
        .with_context(|| format!("Could not find root dir of {swhid}"))?
    else {
        log::debug!("{swhid} is pointed by a snapshot but has no root dir");
        return Ok(Vec::new());
    };

    let dir_swhid = graph.properties().swhid(dir);
    if dir_swhid == swhid!(swh:1:dir:4b825dc642cb6eb9a060e54bf8d69288fbee4904) {
        // empty directory.
        // not useful information, and .predecessors() only is way too long
        return Ok(Vec::new());
    }

    // Find all revisions that point to this directory
    let revisions: Vec<_> = graph
        .predecessors(dir)
        .into_iter()
        .filter(|&pred| graph.properties().node_type(pred) == NodeType::Revision)
        .collect();

    if revisions.is_empty() {
        increment_by_ecosystem(&stats.package_dirs_without_revisions, ecosystem);
    } else {
        increment_by_ecosystem(&stats.package_dirs_with_revisions, ecosystem);
    }

    let mut vcs_matches = Vec::new();

    // Check each revision and write to ndjson
    for rev in revisions {
        increment_by_ecosystem(&stats.total_revisions_pointing_to_package_dirs, ecosystem);

        // Check if rev has a parent revision (part of VCS history)
        let has_parent = graph
            .successors(rev)
            .into_iter()
            .any(|parent| graph.properties().node_type(parent) == NodeType::Revision);

        if has_parent {
            increment_by_ecosystem(&stats.package_version_dir_in_vcs_by_ecosystem, ecosystem);
        } else {
            increment_by_ecosystem(
                &stats.package_version_dir_not_in_vcs_by_ecosystem,
                ecosystem,
            );
        }

        if has_parent {
            // Otherwise it's probably not a VCS revision
            //
            // TODO: check if it has a child too, so we don't get false-negatives on root
            // revisions.
            vcs_matches.push(PackageVersionInVcs { dir, rev })
        }
    }
    Ok(vcs_matches)
}

#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct VersionSearchInfo {
    pub version: Version,
    pub tried_origin_urls: Vec<String>,
    pub branch_names: Vec<String>,
}

/// Payload of [`SwhEvent`]
///
/// Not to be confused with [`SwhVersion`] which is enumerated by [`list_affected_versions`] and
/// does not necessarily map to a version listed by OSV.
#[derive(Debug, PartialEq, Eq)]
pub enum VersionInSwh<T = VersionToSwhidMapping> {
    /// Equivalent to OSV's `"0"` version
    SinceTheBeginning,
    /// Mapped directly from a Git commit referenced by the vulnerability report
    FromGit(SWHID),
    /// Vulnerability report mentioned a version, and we found a match in SWH
    MappedFromVersion(T),
    /// The package referenced by the vulnerability report does not exist at all in SWH
    PackageNotFound,
    /// The package referenced by the vulnerability report exists, but the specific version was not
    /// found
    VersionNotFound(VersionSearchInfo),
}

/// Mapping of a version from OSV to SWH.
///
/// Not to be confused with [`SwhVersion`] which is enumerated by [`list_affected_versions`] and
/// does not necessarily map to a version listed by OSV.
#[derive(Debug, PartialEq, Eq)]
pub struct VersionToSwhidMapping {
    pub package_version: VersionMatch,
    pub version_type: VersionType,
    /// Equivalent code found in a VCS
    pub vcs: Vec<PackageVersionInVcs>,
}

impl VersionToSwhidMapping {
    pub fn new(
        package_version: VersionMatch,
        version_type: VersionType,
        vcs: Vec<PackageVersionInVcs>,
    ) -> Self {
        Self {
            package_version,
            version_type,
            vcs,
        }
    }

    pub fn to_serializable(
        self,
        graph: &impl SwhGraphWithProperties<Maps: properties::Maps>,
    ) -> SerializableVersionToSwhidMapping {
        let VersionToSwhidMapping {
            package_version,
            version_type,
            vcs,
        } = self;
        SerializableVersionToSwhidMapping {
            package_version: package_version.to_serializable(graph),
            version_type,
            vcs: vcs
                .into_iter()
                .map(|vcs_item| vcs_item.to_serializable(graph))
                .collect(),
        }
    }
}

#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SerializableVersionToSwhidMapping {
    pub package_version: SerializableVersionMatch,
    pub version_type: VersionType,
    /// Equivalent code found in a VCS
    pub vcs: Vec<SerializablePackageVersionInVcs>,
}

/// Equivalent to [`osv::schema::Event`] but maps to SWH objects (with traceability)
#[derive(Debug, PartialEq, Eq)]
pub enum SwhEvent {
    Introduced(VersionInSwh),
    Fixed(VersionInSwh),
    Limit(VersionInSwh),
    LastAffected(VersionInSwh),
}

impl SwhEvent {
    fn from_event(event: Event, f: impl FnOnce(String) -> Result<VersionInSwh>) -> Result<Self> {
        use SwhEvent::*;
        Ok(match event {
            Event::Introduced(commit) => Introduced(f(commit)?),
            Event::Fixed(commit) => Fixed(f(commit)?),
            Event::LastAffected(commit) => LastAffected(f(commit)?),
            Event::Limit(commit) => Limit(f(commit)?),
            _ => bail!("Unknown event type {event:?}"),
        })
    }
    pub fn version(&self) -> &VersionInSwh {
        use SwhEvent::*;
        match self {
            Introduced(version) | Fixed(version) | Limit(version) | LastAffected(version) => {
                version
            }
        }
    }
    pub fn into_version(self) -> VersionInSwh {
        use SwhEvent::*;
        match self {
            Introduced(version) | Fixed(version) | Limit(version) | LastAffected(version) => {
                version
            }
        }
    }
}

/// Equivalent to [`osv::schema::Range`] but maps to SWH objects (with traceability)
#[derive(Debug, PartialEq, Eq)]
pub struct SwhRange {
    pub events: Vec<SwhEvent>,
    pub version_type: VersionType,
}

/// Equivalent to [`osv::schema::Affected`] but maps to SWH objects (with traceability)
#[derive(Debug, PartialEq, Eq)]
pub struct SwhAffected {
    pub origins: Vec<String>,
    pub ranges: Option<Vec<SwhRange>>,
    pub versions: (), // TODO
}

#[derive(Debug, PartialEq, Eq)]
pub enum MapAffectedResult {
    /// the vulnerability report does not mention which package is vulnerable
    UnidentifiedPackage,
    /// the vulnerability report does mentions which package is vulnerable, but it was not found in
    /// SWH
    PackageNotInSwh(PackageNotFoundInSwh),
    /// SWH does not archive this ecosystem yet
    UnmappedEcosystem,
    Found(SwhAffected),
}

pub fn map_affected<G>(
    graph: &G,
    stats: &Stats,
    _filename: &str,
    affected: Affected,
) -> Result<MapAffectedResult>
where
    G: SwhLabeledForwardGraph + SwhBackwardGraph + SwhGraphWithProperties,
    <G as SwhGraphWithProperties>::Maps: properties::Maps,
    <G as SwhGraphWithProperties>::LabelNames: properties::LabelNames,
{
    let Some(package) = affected.package else {
        return Ok(MapAffectedResult::UnidentifiedPackage);
    };
    let ecosystem = &package.ecosystem;

    increment(&stats.vulns_with_packages);

    // Check if package is in SWH
    let origin_urls = map_package_to_origin_urls(&package.ecosystem, &package.name);

    if origin_urls.is_empty() {
        // Ecosystem not mapped
        increment(&stats.packages_unmapped_ecosystem);

        return Ok(MapAffectedResult::UnmappedEcosystem);
    }
    let origins: Vec<(NodeId, String)> = origin_urls
        .iter()
        .flat_map(|origin_url| {
            graph
                .properties()
                .node_id(SWHID::from_origin_url(origin_url))
                .map(|id| (id, origin_url.clone()))
        })
        .collect();

    if origins.is_empty() {
        increment_by_ecosystem(&stats.packages_not_found_in_swh_by_ecosystem, ecosystem);

        return Ok(MapAffectedResult::PackageNotInSwh(PackageNotFoundInSwh {
            tried_origin_urls: origin_urls,
        }));
    }
    increment_by_ecosystem(&stats.packages_found_in_swh_by_ecosystem, ecosystem);

    // Check for ranges
    let ranges = affected
        .ranges
        .map(|ranges| {
            ranges
                .into_iter()
                .map(|range| {
                    let (events, version_type) = match range.range_type {
                        RangeType::Git => {
                            increment(&stats.total_git_ranges);

                            // Check if repo field is present
                            if let Some(repo) = &range.repo {
                                if !repo.is_empty() {
                                    increment(&stats.git_ranges_with_repo);
                                } else {
                                    increment(&stats.git_ranges_without_repo);
                                }
                            } else {
                                increment(&stats.git_ranges_without_repo);
                            }

                            // Check if there are commits in events
                            if !range.events.is_empty() {
                                increment(&stats.git_ranges_with_commits);
                            } else {
                                increment(&stats.git_ranges_without_commits);
                            }

                            let events = range
                                .events
                                .into_iter()
                                .flat_map(|event| {
                                    match SwhEvent::from_event(event, parse_commit_id) {
                                        Ok(event) => Some(event),
                                        Err(e) => {
                                            log::info!("{e}");
                                            None
                                        }
                                    }
                                })
                                .collect();
                            (events, VersionType::Git)
                        }
                        RangeType::Semver => {
                            increment(&stats.total_semver_ranges);
                            let version_type = match ecosystem {
                                Ecosystem::PyPI | Ecosystem::Python => VersionType::Pep440,
                                _ => VersionType::Semver,
                            };
                            let events = range
                                .events
                                .into_iter()
                                .map(|event| {
                                    SwhEvent::from_event(event, |version| {
                                        map_package_version_to_swh(
                                            graph,
                                            &origins,
                                            &version,
                                            stats,
                                            ecosystem,
                                            version_type.clone(),
                                        )
                                    })
                                })
                                .collect::<Result<_>>()?;
                            (events, version_type)
                        }
                        RangeType::Ecosystem => {
                            increment(&stats.total_ecosystem_ranges);
                            let events = range
                                .events
                                .into_iter()
                                .map(|event| {
                                    SwhEvent::from_event(event, |version| {
                                        map_package_version_to_swh(
                                            graph,
                                            &origins,
                                            &version,
                                            stats,
                                            ecosystem,
                                            VersionType::from(ecosystem),
                                        )
                                    })
                                })
                                .collect::<Result<_>>()?;
                            (events, VersionType::from(ecosystem))
                        }
                        RangeType::Unspecified => {
                            log::warn!("Unspecified range type {range:?}");
                            bail!("Unspecified range type {range:?}");
                        }
                        _ => {
                            log::warn!("Unknown range type {range:?}");
                            bail!("Unknown range type {range:?}");
                        }
                    };
                    Ok(SwhRange {
                        events,
                        version_type,
                    })
                })
                .collect::<Result<_>>()
        })
        .transpose()?;

    let origins = origins
        .into_iter()
        .map(|(_origin_id, origin_url)| origin_url)
        .collect();
    Ok(MapAffectedResult::Found(SwhAffected {
        origins, // TODO: keep only relevant origins, and identify which origin each mapped version is from
        ranges,
        versions: (), // TODO
    }))
}

fn parse_commit_id(commit: impl AsRef<str>) -> Result<VersionInSwh> {
    let commit = commit.as_ref();
    if commit == "0" {
        Ok(VersionInSwh::SinceTheBeginning)
    } else {
        match format!("swh:1:rev:{commit}").parse() {
            Ok(swhid) => Ok(VersionInSwh::FromGit(swhid)),
            Err(_) => Err(anyhow!("Invalid commit id: {commit}")),
        }
    }
}

/// Represents a single version in SWH, enumerated by [`list_affected_versions`]
///
/// not to be confused with [`VersionInSwh`] which is the result of mapping a version from OSV to
/// SWH.
#[derive(Debug, PartialEq, Eq)]
pub struct SwhVersion {
    pub original_version: Option<Version>,
    pub branch_name: Option<String>,
    pub swhid: SWHID,
}
