use compliance_core::models::{CveAlert, CveSource, SbomEntry, VulnRef};
use compliance_core::CoreError;

pub struct CveScanner {
    http: reqwest::Client,
    #[allow(dead_code)]
    searxng_url: Option<String>,
    nvd_api_key: Option<String>,
}

impl CveScanner {
    pub fn new(
        http: reqwest::Client,
        searxng_url: Option<String>,
        nvd_api_key: Option<String>,
    ) -> Self {
        Self {
            http,
            searxng_url,
            nvd_api_key,
        }
    }

    #[tracing::instrument(skip_all)]
    pub async fn scan_dependencies(
        &self,
        repo_id: &str,
        entries: &mut [SbomEntry],
    ) -> Result<Vec<CveAlert>, CoreError> {
        tracing::info!("scanning {} SBOM entries for known CVEs", entries.len());
        let mut alerts = Vec::new();

        // Batch query OSV.dev
        let osv_results = self.query_osv_batch(entries).await?;
        for (idx, vulns) in osv_results.into_iter().enumerate() {
            if let Some(entry) = entries.get_mut(idx) {
                for vuln in &vulns {
                    entry.known_vulnerabilities.push(VulnRef {
                        id: vuln.id.clone(),
                        source: "osv".to_string(),
                        severity: vuln.severity.clone(),
                        url: Some(format!("https://osv.dev/vulnerability/{}", vuln.id)),
                    });

                    let mut alert = CveAlert::new(
                        vuln.id.clone(),
                        repo_id.to_string(),
                        entry.name.clone(),
                        entry.version.clone(),
                        CveSource::Osv,
                    );
                    alert.summary = vuln.summary.clone();
                    alerts.push(alert);
                }
            }
        }

        // Enrich with NVD CVSS scores
        for alert in &mut alerts {
            if let Ok(Some(cvss)) = self.query_nvd(&alert.cve_id).await {
                alert.cvss_score = Some(cvss);
            }
        }

        Ok(alerts)
    }

    async fn query_osv_batch(&self, entries: &[SbomEntry]) -> Result<Vec<Vec<OsvVuln>>, CoreError> {
        const OSV_BATCH_SIZE: usize = 500;

        let queries: Vec<_> = entries
            .iter()
            .filter_map(|e| {
                e.purl.as_ref().map(|purl| {
                    serde_json::json!({
                        "package": { "purl": purl }
                    })
                })
            })
            .collect();

        if queries.is_empty() {
            return Ok(Vec::new());
        }

        let mut all_vulns: Vec<Vec<OsvVuln>> = Vec::with_capacity(queries.len());

        for chunk in queries.chunks(OSV_BATCH_SIZE) {
            let body = serde_json::json!({ "queries": chunk });

            let resp = self
                .http
                .post("https://api.osv.dev/v1/querybatch")
                .json(&body)
                .send()
                .await
                .map_err(|e| {
                    tracing::warn!("OSV.dev API call failed: {e}");
                    CoreError::Http(format!("OSV.dev request failed: {e}"))
                })?;

            if !resp.status().is_success() {
                let status = resp.status();
                let body = resp.text().await.unwrap_or_default();
                tracing::warn!("OSV.dev returned {status}: {body}");
                // Push empty results for this chunk so indices stay aligned
                all_vulns.extend(std::iter::repeat_with(Vec::new).take(chunk.len()));
                continue;
            }

            let result: OsvBatchResponse = resp.json().await.map_err(|e| {
                tracing::warn!("failed to parse OSV.dev response: {e}");
                CoreError::Http(format!("Failed to parse OSV.dev response: {e}"))
            })?;

            let chunk_vulns = result.results.into_iter().map(|r| {
                r.vulns
                    .unwrap_or_default()
                    .into_iter()
                    .map(|v| OsvVuln {
                        id: v.id,
                        summary: v.summary,
                        severity: v.database_specific.and_then(|d| {
                            d.get("severity").and_then(|s| s.as_str()).map(String::from)
                        }),
                    })
                    .collect()
            });

            all_vulns.extend(chunk_vulns);
        }

        Ok(all_vulns)
    }

    async fn query_nvd(&self, cve_id: &str) -> Result<Option<f64>, CoreError> {
        if !cve_id.starts_with("CVE-") {
            return Ok(None);
        }

        let url = format!("https://services.nvd.nist.gov/rest/json/cves/2.0?cveId={cve_id}");
        let mut req = self.http.get(&url);

        if let Some(key) = &self.nvd_api_key {
            req = req.header("apiKey", key.as_str());
        }

        let resp = req
            .send()
            .await
            .map_err(|e| CoreError::Http(format!("NVD request failed: {e}")))?;

        if !resp.status().is_success() {
            return Ok(None);
        }

        let body: serde_json::Value = resp
            .json()
            .await
            .map_err(|e| CoreError::Http(format!("Failed to parse NVD response: {e}")))?;

        // Extract CVSS v3.1 base score
        let score = body["vulnerabilities"]
            .as_array()
            .and_then(|v| v.first())
            .and_then(|v| v["cve"]["metrics"]["cvssMetricV31"].as_array())
            .and_then(|m| m.first())
            .and_then(|m| m["cvssData"]["baseScore"].as_f64());

        Ok(score)
    }

    #[allow(dead_code)]
    pub async fn search_context(&self, cve_id: &str) -> Result<Vec<String>, CoreError> {
        let Some(searxng_url) = &self.searxng_url else {
            return Ok(Vec::new());
        };

        let url = format!(
            "{}/search?q={cve_id}&format=json&engines=duckduckgo",
            searxng_url.trim_end_matches('/')
        );
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .map_err(|e| CoreError::Http(format!("SearXNG request failed: {e}")))?;

        if !resp.status().is_success() {
            return Ok(Vec::new());
        }

        let body: serde_json::Value = resp.json().await.unwrap_or_default();
        let results = body["results"]
            .as_array()
            .map(|arr| {
                arr.iter()
                    .take(5)
                    .filter_map(|r| r["url"].as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default();

        Ok(results)
    }

    /// Match the CODESYS **runtime** component against NVD by CPE.
    ///
    /// CODESYS advisories (the CoDe16 cluster and friends) are indexed in NVD by
    /// CPE (`cpe:2.3:a:codesys:control*`) keyed off the *runtime* version — not by
    /// the internal `Cmp*`/`Sys*` library names OSV-by-purl would look up. So we
    /// find the runtime SBOM entry, pull every `cpe:2.3:a:codesys:*` CVE from NVD,
    /// and keep the ones whose affected-version range covers our runtime version.
    /// Best-effort: returns empty without an NVD key, on a network error, or when
    /// no CODESYS runtime component is present.
    pub async fn scan_codesys(&self, repo_id: &str, entries: &mut [SbomEntry]) -> Vec<CveAlert> {
        let Some((name, version)) = codesys_runtime(entries) else {
            return Vec::new();
        };
        let url = "https://services.nvd.nist.gov/rest/json/cves/2.0\
                   ?virtualMatchString=cpe:2.3:a:codesys";
        let mut req = self.http.get(url);
        if let Some(key) = &self.nvd_api_key {
            req = req.header("apiKey", key.as_str());
        }
        let body: serde_json::Value = match req.send().await {
            Ok(r) if r.status().is_success() => match r.json().await {
                Ok(b) => b,
                Err(e) => {
                    tracing::warn!("CODESYS NVD parse failed: {e}");
                    return Vec::new();
                }
            },
            Ok(r) => {
                tracing::warn!("CODESYS NVD returned {}", r.status());
                return Vec::new();
            }
            Err(e) => {
                tracing::warn!("CODESYS NVD request failed: {e}");
                return Vec::new();
            }
        };

        let matched = parse_codesys_nvd(&body, &version);
        let mut alerts = Vec::new();
        for cve in matched {
            if let Some(e) = entries
                .iter_mut()
                .find(|e| e.name == name && e.version == version)
            {
                e.known_vulnerabilities.push(VulnRef {
                    id: cve.id.clone(),
                    source: "nvd".to_string(),
                    severity: None,
                    url: Some(format!("https://nvd.nist.gov/vuln/detail/{}", cve.id)),
                });
            }
            let mut alert = CveAlert::new(
                cve.id,
                repo_id.to_string(),
                name.clone(),
                version.clone(),
                CveSource::Nvd,
            );
            alert.summary = cve.summary;
            alert.cvss_score = cve.cvss;
            alerts.push(alert);
        }
        tracing::info!(runtime = %name, version = %version, cves = alerts.len(), "CODESYS CVE match");
        alerts
    }
}

/// The CODESYS runtime component (name + version) from an SBOM, if present. The
/// runtime carries the version CODESYS advisories key off; the internal library
/// components do not.
fn codesys_runtime(entries: &[SbomEntry]) -> Option<(String, String)> {
    entries
        .iter()
        .find(|e| e.package_manager == "codesys" && e.name.starts_with("CODESYS Control"))
        .map(|e| (e.name.clone(), e.version.clone()))
}

/// A parsed NVD CVE that affects the CODESYS runtime.
struct CodesysCve {
    id: String,
    summary: Option<String>,
    cvss: Option<f64>,
}

/// Version constraints from an NVD `cpeMatch` node.
#[derive(Default)]
struct CpeRange {
    exact: Option<String>,
    start_incl: Option<String>,
    start_excl: Option<String>,
    end_incl: Option<String>,
    end_excl: Option<String>,
}

/// Parse an NVD CVE-list response and keep the CVEs whose CODESYS CPE match covers
/// `runtime_version`.
fn parse_codesys_nvd(body: &serde_json::Value, runtime_version: &str) -> Vec<CodesysCve> {
    let mut out = Vec::new();
    let Some(vulns) = body["vulnerabilities"].as_array() else {
        return out;
    };
    for v in vulns {
        let cve = &v["cve"];
        let Some(id) = cve["id"].as_str() else {
            continue;
        };
        let covered = cve["configurations"]
            .as_array()
            .into_iter()
            .flatten()
            .flat_map(|c| c["nodes"].as_array().into_iter().flatten())
            .flat_map(|n| n["cpeMatch"].as_array().into_iter().flatten())
            .any(|cm| {
                cm["vulnerable"].as_bool() == Some(true)
                    && cm["criteria"]
                        .as_str()
                        .is_some_and(|c| c.contains(":codesys:"))
                    && version_matches(runtime_version, &cpe_range(cm))
            });
        if covered {
            let summary = cve["descriptions"]
                .as_array()
                .and_then(|d| d.iter().find(|x| x["lang"].as_str() == Some("en")))
                .and_then(|x| x["value"].as_str())
                .map(String::from);
            let cvss = cve["metrics"]["cvssMetricV31"]
                .as_array()
                .and_then(|m| m.first())
                .and_then(|m| m["cvssData"]["baseScore"].as_f64());
            out.push(CodesysCve {
                id: id.to_string(),
                summary,
                cvss,
            });
        }
    }
    out
}

/// Build a [`CpeRange`] from an NVD `cpeMatch` object.
fn cpe_range(cm: &serde_json::Value) -> CpeRange {
    let exact = cm["criteria"]
        .as_str()
        .and_then(cpe_version)
        .filter(|v| v != "*" && v != "-" && !v.is_empty());
    CpeRange {
        exact,
        start_incl: cm["versionStartIncluding"].as_str().map(String::from),
        start_excl: cm["versionStartExcluding"].as_str().map(String::from),
        end_incl: cm["versionEndIncluding"].as_str().map(String::from),
        end_excl: cm["versionEndExcluding"].as_str().map(String::from),
    }
}

/// The version field (6th component) of a CPE 2.3 string.
fn cpe_version(criteria: &str) -> Option<String> {
    criteria.split(':').nth(5).map(String::from)
}

/// Whether `v` satisfies a CPE version range.
fn version_matches(v: &str, r: &CpeRange) -> bool {
    use std::cmp::Ordering::{Equal, Greater, Less};
    if let Some(exact) = &r.exact {
        return cmp_dotted(v, exact) == Equal;
    }
    let mut ok = true;
    if let Some(s) = &r.start_incl {
        ok &= cmp_dotted(v, s) != Less;
    }
    if let Some(s) = &r.start_excl {
        ok &= cmp_dotted(v, s) == Greater;
    }
    if let Some(e) = &r.end_incl {
        ok &= cmp_dotted(v, e) != Greater;
    }
    if let Some(e) = &r.end_excl {
        ok &= cmp_dotted(v, e) == Less;
    }
    ok
}

/// Compare two dotted numeric versions (`4.17.0.0` vs `4.9.0.0`); missing
/// components count as 0, non-numeric components as 0.
fn cmp_dotted(a: &str, b: &str) -> std::cmp::Ordering {
    let pa: Vec<u64> = a.split('.').map(|x| x.parse().unwrap_or(0)).collect();
    let pb: Vec<u64> = b.split('.').map(|x| x.parse().unwrap_or(0)).collect();
    for i in 0..pa.len().max(pb.len()) {
        let x = pa.get(i).copied().unwrap_or(0);
        let y = pb.get(i).copied().unwrap_or(0);
        match x.cmp(&y) {
            std::cmp::Ordering::Equal => continue,
            other => return other,
        }
    }
    std::cmp::Ordering::Equal
}

#[derive(serde::Deserialize)]
struct OsvBatchResponse {
    results: Vec<OsvBatchResult>,
}

#[derive(serde::Deserialize)]
struct OsvBatchResult {
    vulns: Option<Vec<OsvVulnEntry>>,
}

#[derive(serde::Deserialize)]
struct OsvVulnEntry {
    id: String,
    summary: Option<String>,
    database_specific: Option<serde_json::Value>,
}

struct OsvVuln {
    id: String,
    summary: Option<String>,
    severity: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cmp::Ordering::{Equal, Greater, Less};

    fn entry(name: &str, ver: &str, pm: &str) -> SbomEntry {
        SbomEntry::new("t".into(), name.into(), ver.into(), pm.into())
    }

    #[test]
    fn finds_the_codesys_runtime_component() {
        let entries = vec![
            entry("Standard", "3.5.18.0", "codesys"),
            entry("CODESYS Control for Linux ARM SL", "4.17.0.0", "codesys"),
        ];
        assert_eq!(
            codesys_runtime(&entries),
            Some(("CODESYS Control for Linux ARM SL".into(), "4.17.0.0".into()))
        );
        // Internal library components are not the runtime.
        assert!(codesys_runtime(&[entry("Util", "3.5.21.0", "codesys")]).is_none());
    }

    #[test]
    fn dotted_version_comparison() {
        assert_eq!(cmp_dotted("4.17.0.0", "4.9.0.0"), Greater);
        assert_eq!(cmp_dotted("4.9.0.0", "4.17.0.0"), Less);
        assert_eq!(cmp_dotted("3.5.18.0", "3.5.18.0"), Equal);
        assert_eq!(cmp_dotted("4.2", "4.2.0.0"), Equal); // missing components = 0
    }

    #[test]
    fn version_range_matching() {
        let end_excl = CpeRange {
            end_excl: Some("4.9.0.0".into()),
            ..Default::default()
        };
        assert!(!version_matches("4.17.0.0", &end_excl)); // patched
        assert!(version_matches("4.5.0.0", &end_excl)); // affected

        let exact = CpeRange {
            exact: Some("3.5.16.0".into()),
            ..Default::default()
        };
        assert!(version_matches("3.5.16.0", &exact));
        assert!(!version_matches("3.5.17.0", &exact));

        let span = CpeRange {
            start_incl: Some("3.0.0.0".into()),
            end_incl: Some("3.5.16.0".into()),
            ..Default::default()
        };
        assert!(version_matches("3.5.16.0", &span));
        assert!(!version_matches("3.5.17.0", &span));
    }

    #[test]
    fn parses_nvd_and_matches_by_runtime_version() {
        // Two CODESYS CVEs: one affects < 4.9 (our 4.17 is patched), one affects
        // <= 4.20 (our 4.17 is affected). Only the latter should match.
        let body = serde_json::json!({
          "vulnerabilities": [
            {"cve": {"id":"CVE-2023-0001",
              "descriptions":[{"lang":"en","value":"old CmpBlkDrvTcp bug"}],
              "metrics":{"cvssMetricV31":[{"cvssData":{"baseScore":7.5}}]},
              "configurations":[{"nodes":[{"cpeMatch":[
                {"vulnerable":true,
                 "criteria":"cpe:2.3:a:codesys:control_for_linux_sl:*:*:*:*:*:*:*:*",
                 "versionEndExcluding":"4.9.0.0"}
              ]}]}]}},
            {"cve": {"id":"CVE-2024-0002",
              "descriptions":[{"lang":"en","value":"recent runtime bug"}],
              "metrics":{"cvssMetricV31":[{"cvssData":{"baseScore":9.8}}]},
              "configurations":[{"nodes":[{"cpeMatch":[
                {"vulnerable":true,
                 "criteria":"cpe:2.3:a:codesys:control_for_linux_sl:*:*:*:*:*:*:*:*",
                 "versionEndIncluding":"4.20.0.0"}
              ]}]}]}}
          ]
        });
        let matched = parse_codesys_nvd(&body, "4.17.0.0");
        let ids: Vec<&str> = matched.iter().map(|c| c.id.as_str()).collect();
        assert_eq!(ids, vec!["CVE-2024-0002"]);
        assert_eq!(matched[0].cvss, Some(9.8));
    }
}
