//! Pretty and JSON renderers for a [`super::SecurityReport`] and the unified
//! [`super::SecurityRun`].
//!
//! These are deterministic text transforms of the findings. The CLI
//! chooses one based on `--format`; the library exposes both so any caller can
//! render without re-deriving the layout. The run renderers ([`render_run_pretty`],
//! [`render_run_json`]) add the posture coverage map and, when present, the
//! advisory section, always labelled so a reader never mistakes the advisory
//! signal for the deterministic verdict (ADR 0041).

use serde_json::json;

use super::posture;
use super::{advisory, owasp, FindingSource, SecurityEngine, SecurityReport, SecurityRun};
use crate::style::Styler;

/// The OWASP LLM Top-10 coverage of the bundled catalog: which SEC rules
/// address each category. A static property of the catalog (not the run), so
/// it is computed from the full bundled check set rather than threaded in.
fn owasp_coverage() -> Vec<owasp::OwaspCoverage> {
    owasp::coverage(SecurityEngine::with_bundled_checks().checks())
}

/// Render a report as a human-readable summary. Clean reports say so rather
/// than printing nothing, so a passing run is unambiguous in a CI log. When the
/// report carries imported external-scanner findings, they follow the bundled
/// verdict in their own section.
pub fn render_pretty(report: &SecurityReport) -> String {
    render_pretty_styled(report, &Styler::plain())
}

/// Like [`render_pretty`], colorized via `sty`: severity by level (critical and
/// high red, medium yellow, low cyan, info dim), the rule id accented, evidence
/// dimmed. A plain styler reproduces the original monochrome output exactly, so
/// the same path serves a TTY and a redirected file.
pub fn render_pretty_styled(report: &SecurityReport, sty: &Styler) -> String {
    let mut out = String::new();
    if report.findings.is_empty() {
        out.push_str(&format!(
            "security: {} across {} definitions\n",
            sty.pass("no findings"),
            report.definitions_checked
        ));
    } else {
        out.push_str(&format!(
            "security: {} finding(s) across {} definitions\n",
            report.findings.len(),
            report.definitions_checked
        ));
        for f in &report.findings {
            out.push_str(&format!(
                "  {} {} {} [{}] {}\n",
                paint_severity(sty, f.severity),
                sty.accent(&format!("{:<9}", f.rule_id)),
                f.definition,
                f.name,
                f.message
            ));
            if !f.evidence.is_empty() {
                out.push_str(&format!("           evidence: {}\n", sty.dim(&f.evidence)));
            }
        }
    }
    render_supplement_pretty(report, &mut out);
    out
}

/// Paint a severity label, padded to the column width so color codes (which are
/// zero-width) do not disturb alignment.
fn paint_severity(sty: &Styler, sev: super::SecuritySeverity) -> String {
    use super::SecuritySeverity::*;
    let label = format!("{:<8}", sev.as_str());
    match sev {
        Critical | High => sty.fail(&label),
        Medium => sty.warn(&label),
        Low => sty.accent(&label),
        Info => sty.dim(&label),
    }
}

/// Append the imported external-scanner findings, labelled with the scanner that
/// raised each one and whether it counts toward the grade. Advisory and
/// duplicate findings are marked "not counted" so a reader sees they do not move
/// the verdict (ADR 0041). A no-op when nothing was imported.
fn render_supplement_pretty(report: &SecurityReport, out: &mut String) {
    if report.supplement.is_empty() {
        return;
    }
    let counted = report
        .supplement
        .iter()
        .filter(|f| f.counts_toward_grade())
        .count();
    out.push_str(&format!(
        "\nexternal findings: {} imported, {} counted toward the grade\n",
        report.supplement.len(),
        counted
    ));
    for f in &report.supplement {
        let tool = match &f.source {
            FindingSource::External { tool } => tool.as_str(),
            FindingSource::Bundled => "bundled",
        };
        let note = if f.advisory {
            " (advisory, not counted)".to_string()
        } else if let Some(dup) = &f.duplicate_of {
            format!(" (duplicate of {dup}, not counted)")
        } else {
            String::new()
        };
        out.push_str(&format!(
            "  {:<8} {:<9} [{}] {}: {}{}\n",
            f.severity.as_str(),
            f.rule_id,
            tool,
            f.name,
            f.message,
            note
        ));
        if !f.evidence.is_empty() {
            out.push_str(&format!("           evidence: {}\n", f.evidence));
        }
    }
}

/// Render a report as pretty-printed JSON for machine consumers and the
/// scorecard. Errors only on a serialization fault, which a fixed struct
/// shape does not produce in practice.
pub fn render_json(report: &SecurityReport) -> serde_json::Result<String> {
    serde_json::to_string_pretty(report)
}

/// Render a full run as human-readable text: the deterministic verdict first,
/// then the posture coverage map, then the advisory section when present. The
/// advisory block is explicitly labelled "not part of the security grade" so a
/// reader never folds it into the verdict (ADR 0041).
pub fn render_run_pretty(run: &SecurityRun) -> String {
    render_run_pretty_styled(run, &Styler::plain())
}

/// Like [`render_run_pretty`], colorized via `sty`. Only the deterministic
/// findings are colored today; the posture and advisory blocks stay plain.
pub fn render_run_pretty_styled(run: &SecurityRun, sty: &Styler) -> String {
    let mut out = render_pretty_styled(&run.report, sty);
    out.push('\n');
    out.push_str(&posture::render_pretty(&run.posture));
    out.push('\n');
    render_owasp_coverage_pretty(&mut out);
    if let Some(advisory) = &run.advisory {
        out.push('\n');
        out.push_str(&advisory::render_pretty(advisory));
    }
    out
}

/// Append the OWASP LLM Top-10 coverage badge: a covered/total headline, the
/// rule ids addressing each category, and an explicit list of the categories
/// the catalog does not yet cover, so a reader sees both strength and gaps.
fn render_owasp_coverage_pretty(out: &mut String) {
    let rows = owasp_coverage();
    let covered = rows.iter().filter(|r| r.is_covered()).count();
    out.push_str(&format!(
        "security coverage: OWASP LLM Top-10 ({covered}/{} categories addressed)\n",
        rows.len()
    ));
    for row in &rows {
        if row.is_covered() {
            out.push_str(&format!(
                "  {} {:<32} {}\n",
                row.category.id,
                row.category.title,
                row.rule_ids.join(", ")
            ));
        }
    }
    let uncovered: Vec<String> = rows
        .iter()
        .filter(|r| !r.is_covered())
        .map(|r| format!("{} {}", r.category.id, r.category.title))
        .collect();
    if uncovered.is_empty() {
        out.push_str("  uncovered: none\n");
    } else {
        out.push_str(&format!("  uncovered: {}\n", uncovered.join("; ")));
    }
}

/// Render a full run as pretty-printed JSON. The deterministic `report` and the
/// posture map sit under their own keys; the advisory signal appears under
/// `advisory` only when it ran, with a sibling note marking it advisory so a
/// machine consumer cannot mistake it for the verdict.
pub fn render_run_json(run: &SecurityRun) -> serde_json::Result<String> {
    let coverage: Vec<serde_json::Value> = owasp_coverage()
        .iter()
        .map(|row| {
            json!({
                "category": row.category.id,
                "title": row.category.title,
                "rule_ids": row.rule_ids,
                "covered": row.is_covered(),
            })
        })
        .collect();
    let covered = coverage
        .iter()
        .filter(|c| c["covered"] == json!(true))
        .count();
    let mut value = json!({
        "report": run.report,
        "posture": run.posture,
        "owasp_coverage": {
            "covered": covered,
            "total": coverage.len(),
            "categories": coverage,
        },
    });
    if let Some(advisory) = &run.advisory {
        value["advisory"] = serde_json::to_value(advisory)?;
        value["advisory_note"] =
            json!("advisory signal from an LLM judge, not part of the security verdict");
    }
    serde_json::to_string_pretty(&value)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::security::{
        CheckCategory, DefinitionKind, SecurityFinding, SecurityReport, SecuritySeverity,
    };

    fn report_with_one() -> SecurityReport {
        SecurityReport {
            findings: vec![SecurityFinding {
                rule_id: "SEC-001".into(),
                name: "description-injection".into(),
                severity: SecuritySeverity::High,
                category: CheckCategory::Surface,
                definition: "search".into(),
                kind: DefinitionKind::Tool,
                message: "imperative instruction".into(),
                evidence: "ignore all previous".into(),
            }],
            definitions_checked: 2,
            supplement: Vec::new(),
        }
    }

    #[test]
    fn pretty_styled_emits_color_when_enabled_and_none_when_plain() {
        let report = report_with_one();
        let colored = render_pretty_styled(&report, &Styler::new(true));
        assert!(
            colored.contains('\u{1b}'),
            "expected ANSI codes when enabled"
        );
        assert!(colored.contains("SEC-001"));
        let plain = render_pretty_styled(&report, &Styler::plain());
        assert!(
            !plain.contains('\u{1b}'),
            "plain styler must emit no escape codes"
        );
        // The plain styled path matches the default renderer byte for byte.
        assert_eq!(plain, render_pretty(&report));
    }

    #[test]
    fn pretty_states_clean_runs_explicitly() {
        let clean = SecurityReport {
            findings: vec![],
            definitions_checked: 3,
            supplement: Vec::new(),
        };
        let text = render_pretty(&clean);
        assert!(text.contains("no findings"));
        assert!(text.contains("3 definitions"));
    }

    #[test]
    fn pretty_renders_imported_findings_in_their_own_section() {
        use crate::security::{normalize_generic_json, run_import, SecurityRunOptions};
        let doc = serde_json::json!([
            { "rule_id": "SEC-001", "severity": "high", "message": "duplicate" },
            { "rule_id": "ext.novel", "name": "novel-rule", "severity": "high", "message": "novel" }
        ]);
        let supplements = normalize_generic_json(&doc, "semgrep", false);
        let run = run_import(None, &SecurityRunOptions::default(), supplements);
        let text = render_pretty(&run.report);
        // The section header reports how many imports counted toward the grade.
        assert!(text.contains("external findings: 2 imported, 1 counted"));
        // Provenance and the duplicate marker both render.
        assert!(text.contains("[semgrep]"));
        assert!(text.contains("duplicate of SEC-001"));
    }

    #[test]
    fn pretty_lists_finding_with_evidence() {
        let text = render_pretty(&report_with_one());
        assert!(text.contains("SEC-001"));
        assert!(text.contains("description-injection"));
        assert!(text.contains("evidence: ignore all previous"));
    }

    #[test]
    fn json_round_trips_the_report() {
        let json = render_json(&report_with_one()).expect("serialize");
        let back: SecurityReport = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back.findings.len(), 1);
        assert_eq!(back.findings[0].rule_id, "SEC-001");
    }

    #[test]
    fn run_pretty_carries_the_verdict_and_the_posture_map() {
        use crate::security::{run_deterministic, SecurityRunOptions};
        let snapshot =
            serde_json::json!({ "tools": [{ "name": "search", "description": "Search." }] });
        let run = run_deterministic(&snapshot, &SecurityRunOptions::default());
        let text = render_run_pretty(&run);
        // The verdict block and the posture coverage map both render.
        assert!(text.contains("security:"));
        assert!(text.contains("security posture:"));
        // No model ran, so there is no advisory block.
        assert!(!text.contains("ADVISORY"));
    }

    #[test]
    fn run_pretty_labels_the_advisory_block_when_present() {
        use crate::security::{run_with_advisory, SecurityRunOptions};
        let snapshot = serde_json::json!({ "tools": [{
            "name": "send_email",
            "description": "Send an email."
        }] });
        let judge = |_: &str| {
            Some(
                serde_json::json!({ "findings": [{
                    "threat": "tool-poisoning", "severity": "high",
                    "message": "m", "rationale": "r", "confidence": "high"
                }] })
                .to_string(),
            )
        };
        let run = run_with_advisory(&snapshot, &SecurityRunOptions::default(), judge);
        let text = render_run_pretty(&run);
        assert!(text.contains("ADVISORY"));
        assert!(text.contains("not part of the security grade"));
    }

    #[test]
    fn run_json_marks_the_advisory_section_advisory() {
        use crate::security::{run_with_advisory, SecurityRunOptions};
        let snapshot = serde_json::json!({ "tools": [{ "name": "t", "description": "d" }] });
        let judge = |_: &str| {
            Some(
                serde_json::json!({ "findings": [{
                    "threat": "persuasive-manipulation", "severity": "low",
                    "message": "m", "rationale": "r", "confidence": "medium"
                }] })
                .to_string(),
            )
        };
        let run = run_with_advisory(&snapshot, &SecurityRunOptions::default(), judge);
        let json = render_run_json(&run).expect("serialize");
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
        assert!(parsed.get("report").is_some());
        assert!(parsed.get("posture").is_some());
        assert!(parsed.get("advisory").is_some());
        assert!(parsed["advisory_note"]
            .as_str()
            .is_some_and(|s| s.contains("not part of the security verdict")));
    }

    #[test]
    fn run_pretty_renders_the_owasp_coverage_badge() {
        use crate::security::{run_deterministic, SecurityRunOptions};
        let snapshot =
            serde_json::json!({ "tools": [{ "name": "search", "description": "Search." }] });
        let run = run_deterministic(&snapshot, &SecurityRunOptions::default());
        let text = render_run_pretty(&run);
        assert!(text.contains("OWASP LLM Top-10"), "{text}");
        // The catalog ships prompt-injection (LLM01) checks, so that category is
        // listed as covered with at least one SEC rule.
        assert!(text.contains("LLM01"), "{text}");
        assert!(text.contains("uncovered:"), "{text}");
    }

    #[test]
    fn run_json_carries_owasp_coverage_with_a_covered_count() {
        use crate::security::{run_deterministic, SecurityRunOptions};
        let snapshot =
            serde_json::json!({ "tools": [{ "name": "search", "description": "Search." }] });
        let run = run_deterministic(&snapshot, &SecurityRunOptions::default());
        let json = render_run_json(&run).expect("serialize");
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
        let cov = &parsed["owasp_coverage"];
        assert_eq!(cov["total"], serde_json::json!(10));
        assert!(cov["covered"].as_u64().is_some_and(|n| n >= 1), "{cov}");
        // Every category row carries its id and a covered flag.
        let cats = cov["categories"].as_array().expect("categories array");
        assert_eq!(cats.len(), 10);
        assert!(cats
            .iter()
            .any(|c| c["category"] == serde_json::json!("LLM01")));
    }
}
