// Copyright 2018-2026 the Deno authors. MIT license.

use std::io::Write;
use std::sync::Arc;

use deno_core::anyhow::Context;
use deno_core::error::AnyError;
use deno_core::futures;
use deno_core::futures::FutureExt;
use deno_core::futures::StreamExt;
use deno_core::serde_json;
use deno_npm::resolution::NpmResolutionSnapshot;
use eszip::v2::Url;
use http::header::HeaderName;
use http::header::HeaderValue;
use serde::Deserialize;

use crate::args::AuditFlags;
use crate::args::Flags;
use crate::colors;
use crate::factory::CliFactory;
use crate::http_util;
use crate::http_util::HttpClient;
use crate::http_util::HttpClientProvider;
use crate::util::console::escape_terminal_control_chars;

struct FixableAction {
  module_name: String,
  target_version: String,
  is_major: bool,
}

struct AuditResult {
  exit_code: i32,
  fixable_actions: Vec<FixableAction>,
}

pub async fn audit(
  flags: Arc<Flags>,
  audit_flags: AuditFlags,
) -> Result<i32, AnyError> {
  let factory = CliFactory::from_flags(flags.clone());
  let npm_resolver = factory.npm_resolver().await?;
  let npm_resolver = npm_resolver.as_managed().unwrap();
  let snapshot = npm_resolver.resolution().snapshot();

  let npm_url = &factory.npmrc()?.default_config.registry_url;
  let http_provider = HttpClientProvider::new(None, None);
  let http_client = http_provider
    .get_or_create()
    .context("Failed to create HTTP client")?;

  let use_socket = audit_flags.socket;
  let fix = audit_flags.fix;

  let result =
    npm::call_audits_api(audit_flags, npm_url, &snapshot, http_client).await?;

  if use_socket {
    socket_dev::call_firewall_api(
      &snapshot,
      http_provider.get_or_create().unwrap(),
    )
    .await?;
  }

  if fix && !result.fixable_actions.is_empty() {
    apply_fixes(&factory, flags, &result.fixable_actions).await?;
  }

  Ok(result.exit_code)
}

async fn apply_fixes(
  factory: &CliFactory,
  flags: Arc<Flags>,
  fixable_actions: &[FixableAction],
) -> Result<(), AnyError> {
  use deno_semver::VersionReq;

  use super::CacheTopLevelDepsOptions;

  let (mut deps, jsr_fetch_resolver) =
    super::create_dep_manager_and_resolvers(factory).await?;

  let mut fixed = Vec::new();
  let mut unfixable = Vec::new();

  // Build a map of dep name -> (dep_id, version_req_str) for matching.
  // If multiple deps share the same package name (e.g. aliases), treat
  // them as unfixable to avoid updating the wrong one.
  let mut dep_lookup: std::collections::HashMap<
    String,
    Option<(super::deps::DepId, String)>,
  > = std::collections::HashMap::new();
  for (id, dep) in deps.deps_with_ids() {
    let name = dep.req.name.to_string();
    let entry = dep_lookup.entry(name);
    match entry {
      std::collections::hash_map::Entry::Vacant(e) => {
        e.insert(Some((id, dep.req.version_req.to_string())));
      }
      std::collections::hash_map::Entry::Occupied(mut e) => {
        // Duplicate - mark as ambiguous
        e.insert(None);
      }
    }
  }

  for action in fixable_actions {
    if action.is_major {
      unfixable.push(format!(
        "{} (major upgrade to {})",
        action.module_name, action.target_version
      ));
      continue;
    }

    match dep_lookup.get(&action.module_name) {
      Some(Some((dep_id, version_req_str))) => {
        // Preserve the original version requirement style.
        // Only handle simple spec styles (caret, tilde, exact pin)
        // to avoid silently rewriting complex ranges.
        let trimmed = version_req_str.trim();
        let new_spec = if trimmed.starts_with('~') {
          format!("~{}", action.target_version)
        } else if trimmed.starts_with('^') {
          format!("^{}", action.target_version)
        } else if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
          action.target_version.clone()
        } else {
          unfixable.push(format!(
            "{} (unsupported version spec: {})",
            action.module_name, version_req_str
          ));
          continue;
        };
        let new_version_req = VersionReq::parse_from_specifier(&new_spec)?;
        deps.update_dep(*dep_id, new_version_req);
        fixed.push(format!(
          "{} {} -> {}",
          action.module_name, version_req_str, new_spec
        ));
      }
      Some(None) => {
        unfixable.push(format!(
          "{} (ambiguous: multiple dependencies with this name)",
          action.module_name
        ));
      }
      None => {
        unfixable
          .push(format!("{} (transitive dependency)", action.module_name));
      }
    }
  }

  if !fixed.is_empty() {
    deps.commit_changes()?;

    super::npm_install_after_modification(
      flags,
      Some(jsr_fetch_resolver),
      CacheTopLevelDepsOptions {
        lockfile_only: false,
        additional_roots: vec![],
      },
    )
    .await?;
  }
  print_fix_summary(&mut std::io::stdout(), &fixed, &unfixable);

  Ok(())
}

fn print_fix_summary(
  stdout: &mut impl Write,
  fixed: &[String],
  unfixable: &[String],
) {
  if !fixed.is_empty() {
    _ = writeln!(
      stdout,
      "\nFixed {} vulnerabilit{}:",
      fixed.len(),
      if fixed.len() == 1 { "y" } else { "ies" }
    );
    for f in fixed {
      _ = writeln!(stdout, "  {}", escape_terminal_control_chars(f));
    }
  }

  if !unfixable.is_empty() {
    _ = writeln!(
      stdout,
      "\n{} vulnerabilit{} could not be fixed automatically:",
      unfixable.len(),
      if unfixable.len() == 1 { "y" } else { "ies" }
    );
    for u in unfixable {
      _ = writeln!(stdout, "  {}", escape_terminal_control_chars(u));
    }
  }
}

mod npm {
  use std::collections::HashMap;
  use std::collections::HashSet;

  use super::*;

  #[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
  enum AdvisorySeverity {
    Low,
    Moderate,
    High,
    Critical,
  }

  impl AdvisorySeverity {
    fn parse(str_: &str) -> Option<Self> {
      match str_ {
        "low" => Some(Self::Low),
        "moderate" => Some(Self::Moderate),
        "high" => Some(Self::High),
        "critical" => Some(Self::Critical),
        _ => None,
      }
    }
  }

  pub async fn call_audits_api_inner(
    client: &HttpClient,
    npm_url: Url,
    body: serde_json::Value,
  ) -> Result<BulkAuditResponse, AnyError> {
    let url = npm_url.join("-/npm/v1/security/advisories/bulk").unwrap();
    let future = client.post_json(url, &body)?.send().boxed_local();
    let response = future.await?;
    let json_str = http_util::body_to_string(response)
      .await
      .context("Failed to read response from the npm registry API")?;
    let response: BulkAuditResponse = serde_json::from_str(&json_str)
      .context("Failed to deserialize response from the npm registry API")?;
    Ok(response)
  }

  pub async fn call_audits_api(
    audit_flags: AuditFlags,
    npm_url: &Url,
    npm_resolution_snapshot: &NpmResolutionSnapshot,
    client: HttpClient,
  ) -> Result<super::AuditResult, AnyError> {
    // Build request body for the bulk advisory endpoint:
    // { "pkg-name": ["ver1", "ver2"], ... }
    let mut body_map: HashMap<String, HashSet<String>> = HashMap::new();
    for pkg in npm_resolution_snapshot.all_packages_for_every_system() {
      body_map
        .entry(pkg.id.nv.name.to_string())
        .or_default()
        .insert(pkg.id.nv.version.to_string());
    }
    let body: HashMap<String, Vec<String>> = body_map
      .into_iter()
      .map(|(k, v)| (k, v.into_iter().collect()))
      .collect();
    let body = serde_json::to_value(&body).unwrap();

    let bulk_response =
      match call_audits_api_inner(&client, npm_url.clone(), body).await {
        Ok(s) => s,
        Err(err) => {
          if audit_flags.ignore_registry_errors {
            log::error!("Failed to get data from the registry: {}", err);
            return Ok(super::AuditResult {
              exit_code: 0,
              fixable_actions: vec![],
            });
          } else {
            return Err(err);
          }
        }
      };

    // Convert bulk response to flat list of advisories
    let mut advisories: Vec<AuditAdvisory> = Vec::new();
    for (pkg_name, pkg_advisories) in &bulk_response {
      for adv in pkg_advisories {
        advisories.push(AuditAdvisory {
          title: adv.title.clone(),
          severity: adv.severity.clone(),
          url: adv.url.clone(),
          module_name: pkg_name.clone(),
          vulnerable_versions: adv.vulnerable_versions.clone(),
          patched_versions: adv.patched_versions.clone().unwrap_or_default(),
          cves: adv.cves.clone(),
        });
      }
    }

    // Build map of installed versions per package for vulnerability filtering
    // and fix target computation.
    let mut installed_versions: HashMap<String, Vec<deno_semver::Version>> =
      HashMap::new();
    for pkg in npm_resolution_snapshot.all_packages_for_every_system() {
      installed_versions
        .entry(pkg.id.nv.name.to_string())
        .or_default()
        .push(pkg.id.nv.version.clone());
    }

    // Filter out advisories where no installed version falls within
    // the vulnerable range. This handles package.json overrides that
    // force a patched version.
    advisories.retain(|adv| {
      let Ok(vulnerable_range) =
        deno_semver::VersionReq::parse_from_npm(&adv.vulnerable_versions)
      else {
        return true;
      };
      if let Some(versions) = installed_versions.get(&adv.module_name) {
        versions.iter().any(|v| vulnerable_range.matches(v))
      } else {
        false
      }
    });

    // Filter out ignored CVEs
    if !audit_flags.ignore.is_empty() {
      advisories.retain(|adv| {
        !adv.cves.iter().any(|cve| audit_flags.ignore.contains(cve))
      });
    }

    // Compute vulnerability counts from remaining advisories
    let mut vulns = AuditVulnerabilities {
      low: 0,
      moderate: 0,
      high: 0,
      critical: 0,
    };
    for adv in &advisories {
      match AdvisorySeverity::parse(&adv.severity) {
        Some(AdvisorySeverity::Low) => vulns.low += 1,
        Some(AdvisorySeverity::Moderate) => vulns.moderate += 1,
        Some(AdvisorySeverity::High) => vulns.high += 1,
        Some(AdvisorySeverity::Critical) => vulns.critical += 1,
        None => {}
      }
    }

    if vulns.total() == 0 {
      _ = writeln!(&mut std::io::stdout(), "No known vulnerabilities found",);
      return Ok(super::AuditResult {
        exit_code: 0,
        fixable_actions: vec![],
      });
    }

    advisories.sort_by_cached_key(|adv| {
      format!("{}@{}", adv.module_name, adv.vulnerable_versions)
    });

    let minimal_severity =
      AdvisorySeverity::parse(&audit_flags.severity).unwrap();
    print_report(
      &vulns,
      &advisories,
      minimal_severity,
      audit_flags.ignore_unfixable,
    );

    // Derive fixable actions from advisories at or above the severity
    // threshold that have patched versions. The bulk API does not return
    // explicit "actions" like the retired full audit API did, so we
    // extract the minimum satisfying version from patched_versions ranges.
    let fixable_actions = derive_fixable_actions(
      &advisories,
      &installed_versions,
      minimal_severity,
    );

    // Exit code 1 only if there are vulnerabilities at or above the specified level
    let exit_code = if vulns.count_at_or_above(minimal_severity) > 0 {
      1
    } else {
      0
    };
    Ok(super::AuditResult {
      exit_code,
      fixable_actions,
    })
  }

  /// Derive fix actions from advisory patched_versions ranges.
  ///
  /// Only considers advisories at or above `min_severity` so that
  /// `--fix` respects the `--severity` flag. For each vulnerable
  /// package, parse the patched_versions range to find the minimum
  /// version that fixes the vulnerability. If multiple advisories
  /// affect the same package, pick the highest target version so all
  /// are resolved. Compare the target major version with the installed
  /// major version to determine if it is a major upgrade.
  fn derive_fixable_actions(
    advisories: &[AuditAdvisory],
    installed_versions: &HashMap<String, Vec<deno_semver::Version>>,
    min_severity: AdvisorySeverity,
  ) -> Vec<super::FixableAction> {
    use deno_semver::Version;

    // module_name -> (target_version, is_major)
    let mut best_target: HashMap<String, (Version, bool)> = HashMap::new();

    for adv in advisories {
      // Skip advisories below the severity threshold
      let Some(severity) = AdvisorySeverity::parse(&adv.severity) else {
        continue;
      };
      if severity < min_severity {
        continue;
      }

      if adv.patched_versions.is_empty() {
        continue;
      }

      let Some(target) = min_version_from_range(&adv.patched_versions) else {
        continue;
      };

      let installed_major = installed_versions
        .get(&adv.module_name)
        .and_then(|vs| vs.iter().min())
        .map(|v| v.major)
        .unwrap_or(0);
      let is_major = target.major > installed_major;

      match best_target.get(&adv.module_name) {
        Some((existing, _)) if *existing >= target => {}
        _ => {
          best_target.insert(adv.module_name.clone(), (target, is_major));
        }
      }
    }

    best_target
      .into_iter()
      .map(|(module_name, (target, is_major))| super::FixableAction {
        module_name,
        target_version: target.to_string(),
        is_major,
      })
      .collect()
  }

  /// Extract the minimum patched version from a npm version range string.
  ///
  /// Handles common patched_versions formats from npm advisories:
  /// - ">=1.1.0" or ">=1.1.0 <2.0.0" (lower-bounded range)
  /// - ">1.0.0" (exclusive lower bound -- we cannot determine exact
  ///   min version, so skip)
  /// - "=1.1.0" or "1.1.0" (exact version)
  ///
  /// Returns None if the range cannot be parsed or has no usable lower
  /// bound, in which case the advisory is skipped for auto-fix.
  fn min_version_from_range(range: &str) -> Option<deno_semver::Version> {
    let trimmed = range.trim();

    // ">=X.Y.Z ..." -- most common npm patched_versions format
    if let Some(rest) = trimmed.strip_prefix(">=") {
      let ver_str = rest.split_whitespace().next()?;
      return deno_semver::Version::parse_standard(ver_str).ok();
    }

    // "=X.Y.Z" -- exact version
    if let Some(rest) = trimmed.strip_prefix('=') {
      let ver_str = rest.split_whitespace().next()?;
      return deno_semver::Version::parse_standard(ver_str).ok();
    }

    // Bare version "X.Y.Z" (no operator) -- treat as exact
    if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
      let ver_str = trimmed.split_whitespace().next()?;
      return deno_semver::Version::parse_standard(ver_str).ok();
    }

    // ">X.Y.Z", "~X.Y.Z", "^X.Y.Z", "||" ranges, etc.
    // We cannot reliably determine the exact minimum version
    // for these, so skip them (advisory will not be auto-fixed).
    None
  }

  fn print_report(
    vulns: &AuditVulnerabilities,
    advisories: &[AuditAdvisory],
    minimal_severity: AdvisorySeverity,
    ignore_unfixable: bool,
  ) {
    let stdout = &mut std::io::stdout();
    print_report_to(
      stdout,
      vulns,
      advisories,
      minimal_severity,
      ignore_unfixable,
    );
  }

  fn print_report_to(
    stdout: &mut impl Write,
    vulns: &AuditVulnerabilities,
    advisories: &[AuditAdvisory],
    minimal_severity: AdvisorySeverity,
    ignore_unfixable: bool,
  ) {
    for adv in advisories {
      let Some(severity) = AdvisorySeverity::parse(&adv.severity) else {
        continue;
      };
      if severity < minimal_severity {
        continue;
      }

      let has_fix = !adv.patched_versions.is_empty();
      if !has_fix && ignore_unfixable {
        continue;
      }

      let title = escape_terminal_control_chars(&adv.title);
      let module_name = escape_terminal_control_chars(&adv.module_name);
      let vulnerable_versions =
        escape_terminal_control_chars(&adv.vulnerable_versions);
      let patched_versions =
        escape_terminal_control_chars(&adv.patched_versions);
      let url = escape_terminal_control_chars(&adv.url);

      _ = writeln!(stdout, "╭ {}", colors::bold(title));
      _ = writeln!(
        stdout,
        "│ {}   {}",
        colors::gray("Severity:"),
        match severity {
          AdvisorySeverity::Low => colors::bold("low"),
          AdvisorySeverity::Moderate => colors::yellow("moderate"),
          AdvisorySeverity::High => colors::red("high"),
          AdvisorySeverity::Critical => colors::red("critical"),
        }
      );
      _ = writeln!(stdout, "│ {}    {}", colors::gray("Package:"), module_name);
      _ = writeln!(
        stdout,
        "│ {} {}",
        colors::gray("Vulnerable:"),
        vulnerable_versions
      );
      if has_fix {
        _ = writeln!(
          stdout,
          "│ {}    {}",
          colors::gray("Patched:"),
          patched_versions
        );
        _ = writeln!(stdout, "│ {}       {}", colors::gray("Info:"), url);
        _ = writeln!(
          stdout,
          "╰ {}    update {} to {}",
          colors::gray("Actions:"),
          module_name,
          patched_versions
        );
      } else {
        _ = writeln!(stdout, "╰ {}       {}", colors::gray("Info:"), url);
      }
      _ = writeln!(stdout);
    }

    _ = writeln!(
      stdout,
      "Found {} vulnerabilities",
      colors::red(vulns.total()),
    );
    _ = writeln!(
      stdout,
      "Severity: {} {}, {} {}, {} {}, {} {}",
      colors::bold(vulns.low),
      colors::bold("low"),
      colors::yellow(vulns.moderate),
      colors::yellow("moderate"),
      colors::red(vulns.high),
      colors::red("high"),
      colors::red(vulns.critical),
      colors::red("critical"),
    );
  }

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

    #[test]
    fn print_report_escapes_advisory_controls() {
      let vulns = AuditVulnerabilities {
        low: 0,
        moderate: 0,
        high: 1,
        critical: 0,
      };
      let advisories = [AuditAdvisory {
        title: "title\x1b[2J".to_string(),
        severity: "high".to_string(),
        url: "https://example.com/\u{202e}info".to_string(),
        module_name: "pkg\nname".to_string(),
        vulnerable_versions: "<1\u{009b}31m".to_string(),
        patched_versions: ">=2\x07".to_string(),
        cves: vec![],
      }];
      let mut output = Vec::new();

      print_report_to(
        &mut output,
        &vulns,
        &advisories,
        AdvisorySeverity::Low,
        false,
      );

      let output = String::from_utf8(output).unwrap();
      assert!(output.contains(r"title\u{1b}[2J"));
      assert!(output.contains(r"pkg\nname"));
      assert!(output.contains(r"<1\u{9b}31m"));
      assert!(output.contains(r">=2\u{7}"));
      assert!(output.contains(r"https://example.com/\u{202e}info"));
      assert!(!output.contains("title\x1b[2J"));
      assert!(!output.contains('\u{202e}'));
    }
  }

  /// Advisory item from the bulk API response.
  #[derive(Debug, Deserialize)]
  pub struct BulkAdvisoryItem {
    pub url: String,
    pub title: String,
    pub severity: String,
    pub vulnerable_versions: String,
    #[serde(default)]
    pub patched_versions: Option<String>,
    #[serde(default)]
    pub cves: Vec<String>,
    #[serde(default)]
    #[allow(dead_code, reason = "deserialized but not yet displayed")]
    pub cwe: Vec<String>,
  }

  /// The bulk advisory endpoint response: { "package-name": [advisory, ...] }
  pub type BulkAuditResponse = HashMap<String, Vec<BulkAdvisoryItem>>;

  /// Internal advisory representation with module name from the response key.
  struct AuditAdvisory {
    title: String,
    severity: String,
    url: String,
    module_name: String,
    vulnerable_versions: String,
    patched_versions: String,
    cves: Vec<String>,
  }

  struct AuditVulnerabilities {
    low: i32,
    moderate: i32,
    high: i32,
    critical: i32,
  }

  impl AuditVulnerabilities {
    fn total(&self) -> i32 {
      self.low + self.moderate + self.high + self.critical
    }

    fn count_at_or_above(&self, min_severity: AdvisorySeverity) -> i32 {
      match min_severity {
        AdvisorySeverity::Low => self.total(),
        AdvisorySeverity::Moderate => self.moderate + self.high + self.critical,
        AdvisorySeverity::High => self.high + self.critical,
        AdvisorySeverity::Critical => self.critical,
      }
    }
  }
}

mod socket_dev {
  use super::*;

  pub async fn call_firewall_api(
    npm_resolution_snapshot: &NpmResolutionSnapshot,
    client: HttpClient,
  ) -> Result<(), AnyError> {
    let purls = npm_resolution_snapshot
      .all_packages_for_every_system()
      .map(|package| {
        format!("pkg:npm/{}@{}", package.id.nv.name, package.id.nv.version)
      })
      .collect::<Vec<_>>();

    let api_key = std::env::var("SOCKET_API_KEY").ok();

    let mut purl_responses = if let Some(api_key) = api_key {
      call_authenticated_api(&client, &purls, &api_key).await?
    } else {
      call_unauthenticated_api(&client, &purls).await?
    };

    purl_responses.sort_by_cached_key(|r| r.name.to_string());

    print_firewall_report(&purl_responses);

    Ok(())
  }

  async fn call_authenticated_api(
    client: &HttpClient,
    purls: &[String],
    api_key: &str,
  ) -> Result<Vec<FirewallResponse>, AnyError> {
    let socket_dev_url =
      std::env::var("SOCKET_DEV_URL").ok().unwrap_or_else(|| {
        "https://api.socket.dev/v0/purl?actions=error,warn".to_string()
      });
    let url = Url::parse(&socket_dev_url).unwrap();

    let body = serde_json::json!({
      "components": purls.iter().map(|purl| {
        serde_json::json!({ "purl": purl })
      }).collect::<Vec<_>>()
    });

    let auth_value = HeaderValue::from_str(&format!("Bearer {}", api_key))
      .context("Failed to create Authorization header")?;

    let request = client
      .post_json(url, &body)?
      .header(HeaderName::from_static("authorization"), auth_value);

    let response = request.send().boxed_local().await?;
    let text = http_util::body_to_string(response).await?;

    // Response is nJSON
    let responses = text
      .lines()
      .filter(|line| !line.trim().is_empty())
      .map(|line| {
        serde_json::from_str::<FirewallResponse>(line)
          .context("Failed to parse Socket.dev response")
      })
      .collect::<Result<Vec<_>, _>>()?;

    Ok(responses)
  }

  async fn call_unauthenticated_api(
    client: &HttpClient,
    purls: &[String],
  ) -> Result<Vec<FirewallResponse>, AnyError> {
    let socket_dev_url = std::env::var("SOCKET_DEV_URL")
      .ok()
      .unwrap_or_else(|| "https://firewall-api.socket.dev/".to_string());

    let futures = purls
      .iter()
      .map(|purl| {
        let url = Url::parse(&format!(
          "{}purl/{}",
          socket_dev_url,
          percent_encoding::utf8_percent_encode(
            purl,
            percent_encoding::NON_ALPHANUMERIC
          )
        ))
        .unwrap();
        client.download_text(url).boxed_local()
      })
      .collect::<Vec<_>>();

    let purl_results = futures::stream::iter(futures)
      .buffer_unordered(20)
      .collect::<Vec<_>>()
      .await;

    let responses = purl_results
      .into_iter()
      .filter_map(|result| match result {
        Ok(a) => Some(a),
        Err(err) => {
          log::error!("Failed to get PURL result {:?}", err);
          None
        }
      })
      .filter_map(|json_response| {
        match serde_json::from_str::<FirewallResponse>(&json_response) {
          Ok(response) => Some(response),
          Err(err) => {
            log::error!("Failed deserializing socket.dev response {:?}", err);
            None
          }
        }
      })
      .collect::<Vec<_>>();

    Ok(responses)
  }

  fn print_firewall_report(responses: &[FirewallResponse]) {
    let stdout = &mut std::io::stdout();
    print_firewall_report_to(stdout, responses);
  }

  fn print_firewall_report_to(
    stdout: &mut impl Write,
    responses: &[FirewallResponse],
  ) {
    let responses_with_alerts = responses
      .iter()
      .filter(|r| !r.alerts.is_empty())
      .collect::<Vec<_>>();

    if responses_with_alerts.is_empty() {
      return;
    }

    _ = writeln!(stdout);
    _ = writeln!(stdout, "{}", colors::bold("Socket.dev firewall report"));
    _ = writeln!(stdout);

    // Count total alerts by severity
    let mut total_critical = 0;
    let mut total_high = 0;
    let mut total_medium = 0;
    let mut total_low = 0;
    let mut packages_with_issues = 0;

    for response in responses_with_alerts {
      packages_with_issues += 1;

      _ = writeln!(
        stdout,
        "╭ pkg:npm/{}@{}",
        escape_terminal_control_chars(&response.name),
        escape_terminal_control_chars(&response.version)
      );

      if let Some(score) = &response.score {
        _ = writeln!(
          stdout,
          "│ {:<20} {:>3}",
          colors::gray("Supply Chain Risk:"),
          format_score(score.supply_chain)
        );
        _ = writeln!(
          stdout,
          "│ {:<20} {:>3}",
          colors::gray("Maintenance:"),
          format_score(score.maintenance)
        );
        _ = writeln!(
          stdout,
          "│ {:<20} {:>3}",
          colors::gray("Quality:"),
          format_score(score.quality)
        );
        _ = writeln!(
          stdout,
          "│ {:<20} {:>3}",
          colors::gray("Vulnerabilities:"),
          format_score(score.vulnerability)
        );
        _ = writeln!(
          stdout,
          "│ {:<20} {:>3}",
          colors::gray("License:"),
          format_score(score.license)
        );
      }

      // critical and high are counted as one for display.
      let mut critical_count = 0;
      let mut medium_count = 0;
      let mut low_count = 0;

      for alert in &response.alerts {
        match alert.severity.as_str() {
          "critical" => {
            total_critical += 1;
            critical_count += 1;
          }
          "high" => {
            total_high += 1;
            critical_count += 1;
          }
          "medium" => {
            total_medium += 1;
            medium_count += 1;
          }
          "low" => {
            total_low += 1;
            low_count += 1;
          }
          _ => {}
        }
      }

      if !response.alerts.is_empty() {
        let alerts_str = response
          .alerts
          .iter()
          .map(|alert| {
            let severity_bracket = match alert.severity.as_str() {
              "critical" => colors::red("critical").to_string(),
              "high" => colors::red("high").to_string(),
              "medium" => colors::yellow("medium").to_string(),
              "low" => "low".to_string(),
              _ => escape_terminal_control_chars(&alert.severity).into_owned(),
            };
            format!(
              "[{}] {}",
              severity_bracket,
              escape_terminal_control_chars(&alert.r#type)
            )
          })
          .collect::<Vec<_>>()
          .join(", ");

        let label = format!(
          "Alerts ({}/{}/{}):",
          critical_count, medium_count, low_count
        );
        _ = writeln!(stdout, "╰ {:<20} {}", colors::gray(&label), alerts_str);
      } else {
        _ = writeln!(stdout, "╰");
      }
      _ = writeln!(stdout);
    }

    let total_alerts = total_critical + total_high + total_medium + total_low;

    if total_alerts == 0 && packages_with_issues == 0 {
      _ = writeln!(stdout, "No security alerts found from Socket.dev");
      return;
    }

    if total_alerts > 0 {
      _ = writeln!(
        stdout,
        "Found {} alerts across {} packages",
        colors::red(total_alerts),
        colors::bold(packages_with_issues)
      );
      _ = writeln!(
        stdout,
        "Severity: {} {}, {} {}, {} {}, {} {}",
        colors::bold(total_low),
        colors::bold("low"),
        colors::yellow(total_medium),
        colors::yellow("medium"),
        colors::red(total_high),
        colors::red("high"),
        colors::red(total_critical),
        colors::red("critical"),
      );
    }
  }

  fn format_score(score: f64) -> String {
    let percentage = (score * 100.0) as i32;
    let colored = if percentage >= 80 {
      colors::green(percentage)
    } else if percentage >= 60 {
      colors::yellow(percentage)
    } else {
      colors::red(percentage)
    };
    format!("{}", colored)
  }

  #[derive(Debug, Deserialize)]
  #[serde(rename_all = "camelCase")]
  pub struct FirewallScore {
    pub license: f64,
    pub maintenance: f64,
    #[allow(dead_code, reason = "we don't use it yet")]
    pub overall: f64,
    pub quality: f64,
    pub supply_chain: f64,
    pub vulnerability: f64,
  }

  #[derive(Debug, Deserialize)]
  #[serde(rename_all = "camelCase")]
  pub struct FirewallAlert {
    pub r#type: String,
    #[allow(dead_code, reason = "we don't use it yet")]
    pub action: String,
    pub severity: String,
    #[allow(dead_code, reason = "we don't use it yet")]
    pub category: String,
  }

  #[derive(Debug, Deserialize)]
  #[serde(rename_all = "camelCase")]
  pub struct FirewallResponse {
    #[allow(dead_code, reason = "we don't use it yet")]
    pub id: String,
    pub name: String,
    pub version: String,
    pub score: Option<FirewallScore>,
    #[serde(default)]
    pub alerts: Vec<FirewallAlert>,
  }

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

    #[test]
    fn print_firewall_report_escapes_external_text() {
      let responses = [FirewallResponse {
        id: "id".to_string(),
        name: "pkg\nname".to_string(),
        version: "1\u{202e}.0".to_string(),
        score: None,
        alerts: vec![
          FirewallAlert {
            r#type: "supply\x1b[2J\u{200b}chain".to_string(),
            action: "warn".to_string(),
            severity: "high".to_string(),
            category: "test".to_string(),
          },
          FirewallAlert {
            r#type: "other".to_string(),
            action: "warn".to_string(),
            severity: "custom\nseverity".to_string(),
            category: "test".to_string(),
          },
        ],
      }];
      let mut output = Vec::new();

      print_firewall_report_to(&mut output, &responses);

      let output = String::from_utf8(output).unwrap();
      assert!(output.contains(r"pkg\nname"));
      assert!(output.contains(r"1\u{202e}.0"));
      assert!(output.contains(r"supply\u{1b}[2J\u{200b}chain"));
      assert!(output.contains(r"custom\nseverity"));
      assert!(!output.contains("pkg\nname"));
      assert!(!output.contains('\u{202e}'));
      assert!(!output.contains("supply\x1b[2J"));
    }
  }
}

#[cfg(test)]
mod tests {
  use deno_core::serde_json;

  use super::npm::BulkAuditResponse;
  use super::print_fix_summary;

  #[test]
  fn print_fix_summary_escapes_external_text() {
    let fixed = vec!["pkg\nname ^1 -> ^2".to_string()];
    let unfixable = vec!["pkg\u{200b}name (transitive dependency)".to_string()];
    let mut output = Vec::new();

    print_fix_summary(&mut output, &fixed, &unfixable);

    let output = String::from_utf8(output).unwrap();
    assert!(output.contains(r"pkg\nname ^1 -> ^2"));
    assert!(output.contains(r"pkg\u{200b}name (transitive dependency)"));
    assert!(!output.contains("pkg\nname"));
    assert!(!output.contains('\u{200b}'));
  }

  #[test]
  fn test_bulk_audit_response_deserialize_empty() {
    let json = r#"{}"#;
    let response: BulkAuditResponse = serde_json::from_str(json).unwrap();
    assert!(response.is_empty());
  }

  #[test]
  fn test_bulk_audit_response_deserialize_with_advisory() {
    let json = r#"{
      "@denotest/with-vuln1": [{
        "url": "https://example.com/vuln/101010",
        "title": "test vulnerability",
        "severity": "high",
        "vulnerable_versions": "<1.1.0"
      }]
    }"#;
    let response: BulkAuditResponse = serde_json::from_str(json).unwrap();
    assert_eq!(response.len(), 1);
    let advisories = &response["@denotest/with-vuln1"];
    assert_eq!(advisories.len(), 1);
    assert_eq!(advisories[0].severity, "high");
    assert!(advisories[0].patched_versions.is_none());
    assert!(advisories[0].cves.is_empty());
  }

  #[test]
  fn test_bulk_audit_response_deserialize_with_optional_fields() {
    let json = r#"{
      "test-pkg": [{
        "url": "https://example.com",
        "title": "test",
        "severity": "critical",
        "vulnerable_versions": "<2.0.0",
        "patched_versions": ">=2.0.0",
        "cves": ["CVE-2025-0001"],
        "cwe": ["CWE-1333"]
      }]
    }"#;
    let response: BulkAuditResponse = serde_json::from_str(json).unwrap();
    let advisories = &response["test-pkg"];
    assert_eq!(advisories[0].patched_versions.as_deref(), Some(">=2.0.0"));
    assert_eq!(advisories[0].cves, vec!["CVE-2025-0001"]);
    assert_eq!(advisories[0].cwe, vec!["CWE-1333"]);
  }
}
