//! Malware harvest + future hooks for Socket / Phylum / typosquat.
//!
//! Phase 2.5.1 scope: classify a `RawAdvisory` as malware when one of:
//! - Its OSV id starts with `MAL-`
//! - `database_specific.severity == "malicious"` (case-insensitive)
//! - `database_specific.type` mentions `"malicious"`
//!
//! Malware advisories are *not* CVEs; we route them to `MalwareReport`
//! rather than `Vulnerability` so the CLI can present them differently
//! (icons, footer counters, dedicated audit section).

use crate::normalize::{RawAdvisory, RawAffected};
use packguard_core::{MalwareKind, MalwareReport};
use serde_json::Value;

/// Inspect the OSV id + database_specific block to decide whether this
/// advisory describes a malicious package.
pub fn is_malware_advisory(raw: &RawAdvisory) -> bool {
    if raw.id.starts_with("MAL-") {
        return true;
    }
    let Some(obj) = raw.database_specific.as_ref().and_then(Value::as_object) else {
        return false;
    };
    if let Some(sev) = obj.get("severity").and_then(Value::as_str) {
        if sev.eq_ignore_ascii_case("malicious") || sev.eq_ignore_ascii_case("malware") {
            return true;
        }
    }
    if let Some(t) = obj.get("type").and_then(Value::as_str) {
        if t.eq_ignore_ascii_case("malicious-package") {
            return true;
        }
    }
    false
}

/// Convert a raw advisory's affected entries into `MalwareReport` rows.
/// One row per (affected package × range/version), so npm `MAL-*` packages
/// that ship multiple bad versions surface each one separately.
pub fn to_malware_reports(raw: &RawAdvisory) -> Vec<MalwareReport> {
    let summary = raw.summary.clone();
    let url = primary_url(raw);
    let mut out = Vec::new();
    for aff in &raw.affected {
        let Some(ecosystem) = normalize_ecosystem(&aff.package.ecosystem) else {
            continue;
        };
        let package_name = normalize_name(ecosystem, &aff.package.name);
        let versions = harvest_versions(aff);
        let evidence = serde_json::to_value(EvidenceSlim {
            id: &raw.id,
            aliases: &raw.aliases,
            ecosystem: &aff.package.ecosystem,
            package: &aff.package.name,
            versions: &versions,
            published: raw.published.as_deref(),
        })
        .unwrap_or(Value::Null);
        if versions.is_empty() {
            out.push(MalwareReport {
                source: source_for(raw),
                ref_id: raw.id.clone(),
                ecosystem: ecosystem.to_string(),
                package_name: package_name.clone(),
                version: String::new(),
                kind: MalwareKind::Malware,
                summary: summary.clone(),
                url: url.clone(),
                evidence: evidence.clone(),
                reported_at: raw.published.clone(),
            });
        } else {
            for v in versions {
                out.push(MalwareReport {
                    source: source_for(raw),
                    ref_id: raw.id.clone(),
                    ecosystem: ecosystem.to_string(),
                    package_name: package_name.clone(),
                    version: v,
                    kind: MalwareKind::Malware,
                    summary: summary.clone(),
                    url: url.clone(),
                    evidence: evidence.clone(),
                    reported_at: raw.published.clone(),
                });
            }
        }
    }
    out
}

fn source_for(raw: &RawAdvisory) -> String {
    if raw.id.starts_with("GHSA-") {
        "ghsa-malware".to_string()
    } else {
        "osv-mal".to_string()
    }
}

fn primary_url(raw: &RawAdvisory) -> Option<String> {
    raw.references
        .iter()
        .find(|r| r.kind.as_deref() == Some("ADVISORY"))
        .and_then(|r| r.url.clone())
        .or_else(|| raw.references.iter().find_map(|r| r.url.clone()))
}

/// MAL advisories typically list the bad versions either explicitly under
/// `affected.versions` or via SEMVER ranges with `introduced` / `fixed`
/// pairs (often `introduced: <bad-version>`, no `fixed`). We harvest both.
fn harvest_versions(aff: &RawAffected) -> Vec<String> {
    let mut out: Vec<String> = aff.versions.clone();
    for range in &aff.ranges {
        for ev in &range.events {
            if let Some(obj) = ev.as_object() {
                for (key, val) in obj {
                    if key == "introduced" {
                        if let Some(v) = val.as_str() {
                            if v != "0" && v != "0.0.0" {
                                out.push(v.to_string());
                            }
                        }
                    }
                }
            }
        }
    }
    out.sort();
    out.dedup();
    out
}

fn normalize_ecosystem(raw: &str) -> Option<&'static str> {
    let base = raw.split(':').next().unwrap_or(raw);
    match base {
        "npm" => Some("npm"),
        "PyPI" => Some("pypi"),
        _ => None,
    }
}

fn normalize_name(ecosystem: &str, raw: &str) -> String {
    if ecosystem == "pypi" {
        packguard_core::normalize_name(raw)
    } else {
        raw.to_string()
    }
}

#[derive(serde::Serialize)]
struct EvidenceSlim<'a> {
    id: &'a str,
    aliases: &'a [String],
    ecosystem: &'a str,
    package: &'a str,
    versions: &'a [String],
    published: Option<&'a str>,
}

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

    fn parse_raw(s: &str) -> RawAdvisory {
        parse_advisory_json_raw(s.as_bytes()).unwrap()
    }

    #[test]
    fn mal_id_classified_as_malware() {
        let raw = parse_raw(
            r#"{"id":"MAL-2024-1234","summary":"malicious package","affected":[
                {"package":{"ecosystem":"npm","name":"evil-pkg"},
                 "versions":["1.0.0","1.0.1"]}
            ]}"#,
        );
        assert!(is_malware_advisory(&raw));
        let reports = to_malware_reports(&raw);
        assert_eq!(reports.len(), 2);
        assert_eq!(reports[0].source, "osv-mal");
        assert_eq!(reports[0].kind, MalwareKind::Malware);
        assert_eq!(reports[0].package_name, "evil-pkg");
        let versions: Vec<_> = reports.iter().map(|r| r.version.as_str()).collect();
        assert!(versions.contains(&"1.0.0"));
        assert!(versions.contains(&"1.0.1"));
    }

    #[test]
    fn database_specific_severity_malicious_classified_as_malware() {
        let raw = parse_raw(
            r#"{"id":"GHSA-aaaa-bbbb-cccc",
                "database_specific":{"severity":"MALICIOUS"},
                "affected":[{"package":{"ecosystem":"npm","name":"bad"}}]}"#,
        );
        assert!(is_malware_advisory(&raw));
        let reports = to_malware_reports(&raw);
        assert_eq!(reports.len(), 1);
        assert_eq!(reports[0].source, "ghsa-malware");
        assert_eq!(reports[0].version, ""); // no version specified → whole-package
    }

    #[test]
    fn regular_advisory_not_classified_as_malware() {
        let raw = parse_raw(
            r#"{"id":"GHSA-x","database_specific":{"severity":"HIGH"},
                "affected":[{"package":{"ecosystem":"npm","name":"lodash"}}]}"#,
        );
        assert!(!is_malware_advisory(&raw));
    }

    #[test]
    fn pypi_name_pep503_normalized() {
        let raw = parse_raw(
            r#"{"id":"MAL-2024-9","summary":"bad py",
                "affected":[{"package":{"ecosystem":"PyPI","name":"Evil_Package"},
                             "ranges":[{"type":"ECOSYSTEM",
                                         "events":[{"introduced":"0.1"}]}]}]}"#,
        );
        let reports = to_malware_reports(&raw);
        assert_eq!(reports.len(), 1);
        assert_eq!(reports[0].ecosystem, "pypi");
        assert_eq!(reports[0].package_name, "evil-package");
        assert_eq!(reports[0].version, "0.1");
    }
}
