//! Presenter for `rustsec::Report` information.

use std::{collections::BTreeSet as Set, io, path::Path};
use std::{io::Write as _, string::ToString as _};

use abscissa_core::terminal::{
    self,
    Color::{self, Red, Yellow},
};
use rustsec::{
    Vulnerability, Warning, WarningKind,
    advisory::License,
    cargo_lock::{
        Lockfile, Package,
        dependency::{Dependency, Tree, graph::EdgeDirection},
    },
};
#[cfg(feature = "binary-scanning")]
use rustsec::{advisory::affected::FunctionPath, binary_scanning::BinaryReport};

#[cfg(feature = "binary-scanning")]
use crate::binary_scanning::SymbolSet;
use crate::{
    config::{DenyOption, OutputConfig, OutputFormat},
    prelude::*,
};

/// Vulnerability information presenter
#[derive(Clone, Debug)]
pub struct Presenter {
    /// Keep track packages we've displayed once so we don't show the same dep tree
    // TODO(tarcieri): group advisories about the same package?
    displayed_packages: Set<Dependency>,

    /// Keep track of the warning kinds that correspond to deny-warnings options
    deny_warning_kinds: Set<WarningKind>,

    /// Output configuration
    config: OutputConfig,

    /// Binary contents for affected-function analysis
    #[cfg(feature = "binary-scanning")]
    binary_contents: Option<Vec<u8>>,
}

impl Presenter {
    /// Create a new vulnerability information presenter
    pub fn new(config: &OutputConfig) -> Self {
        Self {
            displayed_packages: Set::new(),
            deny_warning_kinds: config
                .deny
                .iter()
                .flat_map(|k| k.get_warning_kind())
                .copied()
                .collect(),
            config: config.clone(),
            #[cfg(feature = "binary-scanning")]
            binary_contents: None,
        }
    }

    /// Set the binary contents for affected-function analysis
    #[cfg(feature = "binary-scanning")]
    pub fn set_binary_contents(&mut self, contents: Vec<u8>) {
        self.binary_contents = Some(contents);
    }

    /// Information to display before a report is generated
    pub fn before_report(&mut self, path: &Path, lockfile: &Lockfile) {
        if !self.config.is_quiet() {
            status_ok!(
                "Scanning",
                "{} for vulnerabilities ({} crate dependencies)",
                path.display(),
                lockfile.packages.len(),
            );
        }
    }

    #[cfg(feature = "binary-scanning")]
    /// Information to display before a binary file is scanned
    pub fn binary_scan_report(&mut self, report: &BinaryReport, path: &Path) {
        use rustsec::binary_scanning::BinaryReport::*;
        if !self.config.is_quiet() {
            match report {
                Complete(lockfile) => status_ok!(
                    "Found",
                    "'cargo auditable' data in {} ({} dependencies)",
                    path.display(),
                    lockfile.packages.len()
                ),
                Incomplete(lockfile) => {
                    status_warn!(
                        "{} was not built with 'cargo auditable', the report will be incomplete ({} dependencies recovered)",
                        path.display(),
                        lockfile.packages.len()
                    );
                }
                None => status_err!(
                    "No dependency information found in {}! Is it a Rust program built with cargo?",
                    path.display(),
                ),
            }
        }
    }

    fn warning_word(&self, count: u64) -> &str {
        if count != 1 { "warnings" } else { "warning" }
    }

    /// Print the vulnerability report generated by an audit
    pub fn print_report(
        &mut self,
        report: &rustsec::Report,
        lockfile: &Lockfile,
        path: Option<&Path>,
    ) {
        match self.config.format {
            OutputFormat::Json => {
                let mut stdout = io::stdout().lock();
                serde_json::to_writer(&mut stdout, &report).unwrap();
                // End with a newline as a terminator/separator. Another json report may follow.
                writeln!(&mut stdout).unwrap();
                return;
            }
            OutputFormat::Sarif => {
                let cargo_lock_path = path
                    .map(|p| p.to_string_lossy().into_owned())
                    .unwrap_or_else(|| "Cargo.lock".to_string());
                let sarif_log = crate::sarif::SarifLog::from_report(report, &cargo_lock_path);
                let mut stdout = io::stdout().lock();
                serde_json::to_writer(&mut stdout, &sarif_log).unwrap();
                // End with a newline as a terminator/separator. Another sarif report may follow.
                writeln!(&mut stdout).unwrap();
                return;
            }
            OutputFormat::Terminal => {
                // Continue with terminal output below
            }
        }

        let tree = lockfile
            .dependency_tree()
            .expect("invalid Cargo.lock dependency tree");

        #[cfg(feature = "binary-scanning")]
        let symbols = match &self.binary_contents {
            Some(binary_contents) => {
                let packages = report
                    .vulnerabilities
                    .list
                    .iter()
                    .map(|v| &v.package)
                    .chain(report.warnings.values().flatten().map(|w| &w.package));

                match SymbolSet::from_file(binary_contents, packages) {
                    Ok(symbols) => Some(symbols),
                    Err(e) => {
                        status_warn!(
                            "Failed to extract symbols from binary for affected-function analysis: {}",
                            e
                        );
                        None
                    }
                }
            }
            None => None,
        };

        // NOTE: when modifying the following logic, be sure to also update should_exit_with_failure()

        // Print out vulnerabilities and warnings
        for vulnerability in &report.vulnerabilities.list {
            self.print_vulnerability(vulnerability);

            #[cfg(feature = "binary-scanning")]
            if let Some(symbols) = &symbols {
                self.print_affected(
                    Red,
                    symbols.filter(vulnerability.affected_functions().unwrap_or_default()),
                );
            }

            self.print_tree(Red, &vulnerability.package, &tree);
            println!();
        }

        for warnings in report.warnings.values() {
            for warning in warnings.iter() {
                let color = self.warning_color(self.deny_warning_kinds.contains(&warning.kind));
                self.print_warning(warning, color);

                #[cfg(feature = "binary-scanning")]
                if let Some(symbols) = &symbols {
                    self.print_affected(
                        color,
                        symbols.filter(
                            warning
                                .affected
                                .as_ref()
                                .map(|affected| affected.functions.iter())
                                .unwrap_or_default()
                                .filter_map(|(path, version_reqs)| {
                                    if version_reqs
                                        .iter()
                                        .any(|req| req.matches(&warning.package.version))
                                    {
                                        Some(path.clone())
                                    } else {
                                        None
                                    }
                                }),
                        ),
                    );
                }

                self.print_tree(color, &warning.package, &tree);
                println!();
            }
        }

        if report.vulnerabilities.found {
            if report.vulnerabilities.count == 1 {
                match path {
                    Some(path) => status_err!("1 vulnerability found in {}", path.display()),
                    None => status_err!("1 vulnerability found!"),
                }
            } else {
                match path {
                    Some(path) => status_err!(
                        "{} vulnerabilities found in {}",
                        report.vulnerabilities.count,
                        path.display()
                    ),
                    None => status_err!("{} vulnerabilities found!", report.vulnerabilities.count),
                }
            }
        }

        let (num_denied, num_not_denied) = self.count_warnings(report);

        if num_denied > 0 || num_not_denied > 0 {
            if num_denied > 0 {
                match path {
                    Some(path) => status_err!(
                        "{} denied {} found in {}",
                        num_denied,
                        self.warning_word(num_denied),
                        path.display(),
                    ),
                    None => status_err!(
                        "{} denied {} found!",
                        num_denied,
                        self.warning_word(num_denied)
                    ),
                }
            }
            if num_not_denied > 0 {
                match path {
                    Some(path) => status_warn!(
                        "{} allowed {} found in {}",
                        num_not_denied,
                        self.warning_word(num_not_denied),
                        path.display(),
                    ),
                    None => status_warn!(
                        "{} allowed {} found",
                        num_not_denied,
                        self.warning_word(num_not_denied)
                    ),
                }
            }
        }
    }

    /// Print the vulnerability report for cargo-audit
    pub fn print_self_report(&mut self, self_advisories: &[rustsec::Advisory]) {
        if self_advisories.is_empty() {
            return;
        }
        // Print out any self-advisories
        let msg = "This copy of cargo-audit has known advisories! Upgrade cargo-audit to the \
        latest version: cargo install --force cargo-audit";

        if self.config.deny.contains(&DenyOption::Warnings) {
            status_err!(msg);
        } else {
            status_warn!(msg);
        }

        for advisory in self_advisories {
            self.print_metadata(
                &advisory.metadata,
                self.warning_color(self.config.deny.contains(&DenyOption::Warnings)),
            );
        }
        println!();
    }

    /// Determines whether the process should exit with failure based on configuration
    /// such as --deny=warnings
    #[must_use]
    pub fn should_exit_with_failure(&self, report: &rustsec::Report) -> bool {
        if report.vulnerabilities.found {
            return true;
        }
        let (denied, _allowed) = self.count_warnings(report);
        if denied != 0 {
            return true;
        }
        false
    }

    /// Determines whether the process should exit with failure based on configuration
    /// such as --deny=warnings
    #[must_use]
    pub fn should_exit_with_failure_due_to_self(
        &self,
        self_advisories: &[rustsec::Advisory],
    ) -> bool {
        !self_advisories.is_empty() && self.config.deny.contains(&DenyOption::Warnings)
    }

    /// Count up the warnings, sorting into denied and allowed.
    /// Returns `(denied, allowed)`
    fn count_warnings(&self, report: &rustsec::Report) -> (u64, u64) {
        let mut num_denied: u64 = 0;
        let mut num_not_denied: u64 = 0;

        for (kind, warnings) in report.warnings.iter() {
            if self.deny_warning_kinds.contains(kind) {
                num_denied += warnings.len() as u64;
            } else {
                num_not_denied += warnings.len() as u64;
            }
        }
        (num_denied, num_not_denied)
    }

    /// Print information about the given vulnerability
    fn print_vulnerability(&self, vulnerability: &Vulnerability) {
        self.print_attr(Red, "Crate:    ", &vulnerability.package.name);
        self.print_attr(Red, "Version:  ", vulnerability.package.version.to_string());
        self.print_metadata(&vulnerability.advisory, Red);

        if vulnerability.versions.patched().is_empty() {
            self.print_attr(Red, "Solution: ", "No fixed upgrade is available!");
        } else {
            self.print_attr(
                Red,
                "Solution: ",
                format!(
                    "Upgrade to {}",
                    vulnerability
                        .versions
                        .patched()
                        .iter()
                        .map(ToString::to_string)
                        .collect::<Vec<_>>()
                        .as_slice()
                        .join(" OR ")
                ),
            );
        }
    }

    /// Print information about a given warning
    fn print_warning(&self, warning: &Warning, color: Color) {
        self.print_attr(color, "Crate:    ", &warning.package.name);
        self.print_attr(color, "Version:  ", warning.package.version.to_string());
        self.print_attr(color, "Warning:  ", warning.kind.as_str());

        if let Some(metadata) = &warning.advisory {
            self.print_metadata(metadata, color)
        }
    }

    /// Get the color to use when displaying warnings
    fn warning_color(&self, deny_warning: bool) -> Color {
        if deny_warning { Red } else { Yellow }
    }

    /// Print a warning about a particular advisory
    fn print_metadata(&self, metadata: &rustsec::advisory::Metadata, color: Color) {
        self.print_attr(color, "Title:    ", &metadata.title);
        self.print_attr(color, "Date:     ", &metadata.date);
        self.print_attr(color, "ID:       ", &metadata.id);

        if metadata.license == License::CcBy40 {
            // We must preserve the original URL from the `url` field
            if let Some(url) = &metadata.url {
                self.print_attr(color, "URL:      ", url);
            } else if let Some(url) = &metadata.id.url() {
                self.print_attr(color, "URL:      ", url);
            }
        } else {
            // Prefer ID URL because the `url` field usually points to a bug tracker
            // or any other non-canonical source rather than an actual security advisory
            if let Some(url) = &metadata.id.url() {
                self.print_attr(color, "URL:      ", url);
            } else if let Some(url) = &metadata.url {
                self.print_attr(color, "URL:      ", url);
            }
        }

        if let Some(cvss) = &metadata.cvss {
            self.print_attr(
                color,
                "Severity: ",
                format!("{} ({})", cvss.score(), cvss.severity()),
            );
        }
    }

    /// Display an attribute of a particular vulnerability
    fn print_attr(&self, color: Color, attr: &str, content: impl AsRef<str>) {
        terminal::status::Status::new()
            .bold()
            .color(color)
            .status(attr)
            .print_stdout(content.as_ref())
            .unwrap();
    }

    #[cfg(feature = "binary-scanning")]
    fn print_affected(&self, color: Color, funcs: impl IntoIterator<Item = FunctionPath>) {
        let mut funcs = funcs.into_iter().peekable();
        if funcs.peek().is_none() {
            return;
        }
        self.print_attr(
            color,
            "Affected: ",
            funcs
                .map(|func| func.to_string())
                .collect::<Vec<_>>()
                .join(", "),
        );
    }

    /// Print the inverse dependency tree to standard output
    fn print_tree(&mut self, color: Color, package: &Package, tree: &Tree) {
        // Only show the tree once per package
        if !self.displayed_packages.insert(Dependency::from(package)) {
            return;
        }

        if !self.config.show_tree {
            return;
        }

        terminal::status::Status::new()
            .bold()
            .color(color)
            .status("Dependency tree:\n")
            .print_stdout("")
            .unwrap();

        let package_node = tree.nodes()[&Dependency::from(package)];
        tree.render(
            &mut io::stdout(),
            package_node,
            EdgeDirection::Incoming,
            false,
        )
        .unwrap();
    }
}

#[cfg(test)]
mod tests {
    use abscissa_core::testing::{CmdRunner, process::Process};
    use once_cell::sync::Lazy;
    use std::{
        collections::{BTreeMap, BTreeSet},
        io::Read,
        path::Path,
        str::from_utf8,
    };
    use tempfile::TempDir;

    #[test]
    fn affected_functions() {
        let binary_path = Path::new("tests/support/binaries/binary-with-affected-functions");

        // Run `cargo audit bin...` on the binary and read its stdout.
        let mut cmd = RUNNER.clone();
        let mut process = cmd.arg(binary_path).capture_stdout().capture_stderr().run();
        let reports = read_process_stdout(&mut process);

        // Ensure `cargo audit bin...` exits with 1.
        let status = process.wait().unwrap();
        assert_eq!(1, status.code());

        // Verify that all of the expected affected functions were reported.
        for (id, function_paths) in EXPECTED_FUNCTION_PATHS {
            let report = reports
                .iter()
                .find(|map| map.get("ID").unwrap() == id)
                .unwrap_or_else(|| panic!("failed to find {id}"));
            let affected = report
                .get("Affected")
                .unwrap_or_else(|| panic!("{id} has no 'Affected' line"));
            assert_eq!(function_paths.join(", "), *affected);
        }

        // Verify that each report with an "Affected" line was expected.
        for report in reports {
            if !report.contains_key("Affected") {
                continue;
            }
            let id = report.get("ID").unwrap();
            assert!(
                EXPECTED_FUNCTION_PATHS.iter().any(|(key, _)| key == id),
                "{id} has unexpected 'Affected' line"
            );
        }
    }

    fn read_process_stdout(process: &mut Process<'_>) -> BTreeSet<BTreeMap<String, String>> {
        let stdout = process.stdout();
        let mut buf = Vec::new();
        stdout.read_to_end(&mut buf).unwrap();
        let s = from_utf8(&buf).unwrap();

        let mut reports = BTreeSet::new();
        let mut report = BTreeMap::new();
        for line in s.lines() {
            let Some(index) = line.as_bytes().iter().position(|&x| x == b':') else {
                continue;
            };
            let key = &line[..index];
            let value = line[index + 1..].trim_start();
            // Assume "Crate:" starts a new report.
            if key == "Crate" && !report.is_empty() {
                reports.insert(report);
                report = BTreeMap::new();
            }
            report.insert(key.to_owned(), value.to_owned());
        }
        if !report.is_empty() {
            reports.insert(report);
        }
        reports
    }

    #[rustfmt::skip]
    const EXPECTED_FUNCTION_PATHS: &[(&str, &[&str])] = &[
        ("RUSTSEC-2019-0036", &["failure::Fail::__private_get_type_id__"]),
        ("RUSTSEC-2020-0071", &["time::OffsetDateTime::now_local", "time::OffsetDateTime::try_now_local", "time::UtcOffset::current_local_offset", "time::UtcOffset::local_offset_at", "time::UtcOffset::try_current_local_offset", "time::UtcOffset::try_local_offset_at"]),
        ("RUSTSEC-2020-0075", &["branca::Branca::decode", "branca::decode"]),
        ("RUSTSEC-2021-0041", &["parse_duration::parse"]),
        ("RUSTSEC-2022-0004", &["rustc_serialize::json::Json::from_str"]),
        ("RUSTSEC-2022-0067", &["lzf::compress", "lzf::decompress"]),
        ("RUSTSEC-2023-0032", &["ntru::types::PrivateKey::export", "ntru::types::PublicKey::export"]),
        ("RUSTSEC-2023-0054", &["mail_internals::utils::vec_insert_bytes"]),
        ("RUSTSEC-2024-0018", &["crayon::utils::object_pool::ObjectPool<H,T>::free"]),
        ("RUSTSEC-2024-0020", &["whoami::realname", "whoami::realname_os", "whoami::username", "whoami::username_os"]),
        ("RUSTSEC-2024-0360", &["xmp_toolkit::XmpFile::close"]),
        ("RUSTSEC-2024-0401", &["zlib_rs::inflate::inflate"]),
        ("RUSTSEC-2024-0404", &["anstream::adapter::strip_str"]),
        ("RUSTSEC-2024-0442", &["wasmtime_jit_debug::perf_jitdump::JitDumpFile::dump_code_load_record"]),
        ("RUSTSEC-2025-0027", &["mp3_metadata::read_from_slice"]),
        ("RUSTSEC-2025-0113", &["shaman::cryptoutil::read_u32v_be", "shaman::cryptoutil::read_u32v_le", "shaman::cryptoutil::read_u64v_be", "shaman::cryptoutil::read_u64v_le", "shaman::cryptoutil::write_u32v_le", "shaman::cryptoutil::write_u64v_le"]),
        ("RUSTSEC-2025-0131", &["rtvm_interpreter::Interpreter::program_counter"]),
        ("RUSTSEC-2025-0136", &["sequoia_openpgp::crypto::ecdh::aes_key_unwrap"]),
        ("RUSTSEC-2025-0137", &["ruint::algorithms::div::reciprocal_mg10"]),
        ("RUSTSEC-2025-0140", &["gix_date::parse::TimeBuf::as_str"]),
        ("RUSTSEC-2026-0097", &["rand::rng"]),
    ];

    static RUNNER: Lazy<CmdRunner> = Lazy::new(|| {
        let mut runner = CmdRunner::default();
        runner
            .arg("audit")
            .arg("--color=never")
            .arg("--db")
            .arg(ADVISORY_DB_DIR.path())
            .arg("bin");
        runner
    });

    static ADVISORY_DB_DIR: Lazy<TempDir> = Lazy::new(|| TempDir::new().unwrap());
}
