//! OWASP LLM Top 10 (2025) coverage mapping for the security catalog.
//!
//! Each [`SecurityCheck`] already declares external `references()` that include
//! OWASP identifiers (for example `"OWASP LLM01"`). This module turns those
//! references into a coverage view: for each of the ten OWASP categories, which
//! `SEC-NNN` rules address it, so the vulnerability report can show where the
//! catalog is strong and where it is thin. It adds no new probe logic; it reads
//! the references the checks already carry.

use super::SecurityCheck;

/// One entry in the OWASP LLM Top 10 (2025), with a short remediation pointer
/// the report attaches to findings tagged with this category.
pub struct OwaspCategory {
    /// Identifier as it appears in a check's references, for example `LLM01`.
    pub id: &'static str,
    /// Human-readable title.
    pub title: &'static str,
    /// One-line remediation guidance for findings in this category.
    pub remediation: &'static str,
}

/// The OWASP LLM Top 10 (2025). Ordered by identifier so the report renders a
/// stable table.
pub const OWASP_LLM_TOP_10: &[OwaspCategory] = &[
    OwaspCategory {
        id: "LLM01",
        title: "Prompt Injection",
        remediation: "Constrain and validate tool/prompt/resource text; treat server-supplied content as untrusted input to the model.",
    },
    OwaspCategory {
        id: "LLM02",
        title: "Sensitive Information Disclosure",
        remediation: "Redact secrets and PII from tool output and errors; never echo credentials or internal paths back to the client.",
    },
    OwaspCategory {
        id: "LLM03",
        title: "Supply Chain",
        remediation: "Pin and verify server provenance; detect rug-pulls and schema drift against an approved baseline.",
    },
    OwaspCategory {
        id: "LLM04",
        title: "Data and Model Poisoning",
        remediation: "Validate untrusted content before it reaches the model or a tool that persists state.",
    },
    OwaspCategory {
        id: "LLM05",
        title: "Improper Output Handling",
        remediation: "Validate and encode tool output before it is acted on; do not pass it unchecked into shells, queries, or rendering.",
    },
    OwaspCategory {
        id: "LLM06",
        title: "Excessive Agency",
        remediation: "Scope tool capability to least privilege; flag destructive or high-capability tools and dangerous source-to-sink pairings.",
    },
    OwaspCategory {
        id: "LLM07",
        title: "System Prompt Leakage",
        remediation: "Keep secrets out of system prompts and tool descriptions; assume any prompt text can be extracted.",
    },
    OwaspCategory {
        id: "LLM08",
        title: "Vector and Embedding Weaknesses",
        remediation: "Isolate and validate retrieved context; guard against poisoned or cross-tenant embeddings.",
    },
    OwaspCategory {
        id: "LLM09",
        title: "Misinformation",
        remediation: "Ground answers in verifiable context; gate on factuality and context-faithfulness where correctness matters.",
    },
    OwaspCategory {
        id: "LLM10",
        title: "Unbounded Consumption",
        remediation: "Bound tool cost, output size, and call depth; cap resource use to resist denial-of-wallet and exhaustion.",
    },
];

/// Look up an OWASP category by bare id (for example `"LLM01"`).
pub fn category(id: &str) -> Option<&'static OwaspCategory> {
    OWASP_LLM_TOP_10.iter().find(|c| c.id == id)
}

/// Extract the bare OWASP LLM ids referenced by a check, for example turning
/// `"OWASP LLM01"` into `"LLM01"`. References that are not OWASP-LLM items
/// (MCP, CWE, papers) are ignored.
pub fn owasp_ids(references: &[&str]) -> Vec<String> {
    let mut out = Vec::new();
    for r in references {
        if let Some(rest) = r.strip_prefix("OWASP LLM") {
            // Keep only the well-formed two-digit ids the Top 10 declares.
            let id = format!("LLM{rest}");
            if category(&id).is_some() && !out.contains(&id) {
                out.push(id);
            }
        }
    }
    out
}

/// One row of the coverage view: an OWASP category and the `SEC-NNN` rule ids
/// in the catalog that reference it.
pub struct OwaspCoverage {
    /// The category this row covers.
    pub category: &'static OwaspCategory,
    /// Rule ids (for example `SEC-001`) whose references include this category.
    pub rule_ids: Vec<String>,
}

impl OwaspCoverage {
    /// True when at least one catalog rule addresses this category.
    pub fn is_covered(&self) -> bool {
        !self.rule_ids.is_empty()
    }
}

/// Build the OWASP LLM Top 10 coverage view for a check catalog.
///
/// Walks every check, reads its references, and routes its rule id under each
/// OWASP category it cites. The result has one row per Top 10 category, in
/// declaration order, with the covering rule ids sorted for stable output.
pub fn coverage(checks: &[Box<dyn SecurityCheck>]) -> Vec<OwaspCoverage> {
    OWASP_LLM_TOP_10
        .iter()
        .map(|cat| {
            let mut rule_ids: Vec<String> = checks
                .iter()
                .filter(|c| owasp_ids(c.references()).iter().any(|id| id == cat.id))
                .map(|c| c.id().to_string())
                .collect();
            rule_ids.sort();
            rule_ids.dedup();
            OwaspCoverage {
                category: cat,
                rule_ids,
            }
        })
        .collect()
}

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

    #[test]
    fn owasp_ids_extracts_only_llm_top_10_items() {
        let refs = ["OWASP LLM01", "OWASP MCP03", "CWE-506", "OWASP LLM06"];
        let ids = owasp_ids(&refs);
        assert_eq!(ids, vec!["LLM01".to_string(), "LLM06".to_string()]);
    }

    #[test]
    fn owasp_ids_ignores_unknown_llm_numbers() {
        // A reference to a non-existent LLM99 is dropped rather than invented.
        let ids = owasp_ids(&["OWASP LLM99"]);
        assert!(ids.is_empty());
    }

    #[test]
    fn coverage_has_one_row_per_category() {
        let cov = coverage(&surface_checks());
        assert_eq!(cov.len(), OWASP_LLM_TOP_10.len());
    }

    #[test]
    fn coverage_marks_prompt_injection_covered() {
        // The surface catalog ships a description-injection check tagged LLM01.
        let cov = coverage(&surface_checks());
        let llm01 = cov.iter().find(|r| r.category.id == "LLM01").unwrap();
        assert!(
            llm01.is_covered(),
            "LLM01 should be covered by surface lane"
        );
    }

    #[test]
    fn category_lookup_round_trips() {
        assert_eq!(category("LLM06").unwrap().title, "Excessive Agency");
        assert!(category("LLM00").is_none());
    }
}
