use allow_core::{Finding, FindingKind, MatchOutcome, MatchStatus, json_escape, normalize_path};
use std::collections::BTreeMap;

pub const REPORT_SCHEMA_VERSION: u32 = 1;
pub const REPORT_SCHEMA_ID: &str = "cargo-allow.report.v1";
pub const RECEIPT_SCHEMA_VERSION: u32 = 1;
pub const RECEIPT_SCHEMA_ID: &str = "cargo-allow.receipt.v1";

const CLAIM_BOUNDARY: &[&str] = &[
    "source_tree_inventory",
    "source_syntax_only",
    "cargo_metadata_not_invoked",
    "cargo_commands_not_invoked",
    "rustc_not_invoked",
    "clippy_not_invoked",
    "build_scripts_not_executed",
    "proc_macros_not_executed",
    "macro_expansion_not_analyzed",
    "macro_token_tree_contents_not_analyzed",
    "type_information_not_analyzed",
    "mir_not_analyzed",
    "build_output_not_analyzed",
    "control_flow_not_analyzed",
    "data_flow_not_analyzed",
    "repository_code_not_executed",
];

const SCANNER_LIMITATIONS: &[&str] = &[
    "cargo_metadata_not_invoked",
    "cargo_commands_not_invoked",
    "rustc_not_invoked",
    "clippy_not_invoked",
    "build_scripts_not_executed",
    "proc_macros_not_executed",
    "macro_expansion_not_analyzed",
    "macro_token_tree_contents_not_analyzed",
    "type_information_not_analyzed",
    "mir_not_analyzed",
    "build_output_not_analyzed",
    "control_flow_not_analyzed",
    "data_flow_not_analyzed",
    "repository_code_not_executed",
];

pub const CLAIM_BOUNDARY_TEXT: &str = "Claim boundary: scanned source-tree/source syntax only; cargo-allow did not invoke Cargo metadata, Cargo commands, rustc, Clippy, build scripts, proc macros, or repository code. Macro expansion, macro token-tree contents, type information, MIR, build output, control flow, and data flow were not analyzed.";

#[derive(Debug, Clone, Copy)]
pub struct ReportContext<'a> {
    pub inventory_source: &'a str,
}

impl Default for ReportContext<'static> {
    fn default() -> Self {
        Self {
            inventory_source: "unknown",
        }
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Summary {
    pub total: usize,
    pub by_status: BTreeMap<MatchStatus, usize>,
}

impl Summary {
    pub fn from_outcomes(outcomes: &[MatchOutcome]) -> Self {
        let mut summary = Self {
            total: outcomes.len(),
            by_status: BTreeMap::new(),
        };
        for outcome in outcomes {
            *summary.by_status.entry(outcome.status).or_insert(0) += 1;
        }
        summary
    }
    pub fn count(&self, status: MatchStatus) -> usize {
        *self.by_status.get(&status).unwrap_or(&0)
    }
}

pub fn render_human(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
) -> String {
    render_human_with_context(
        command,
        findings,
        outcomes,
        failed,
        ReportContext::default(),
    )
}

pub fn render_human_with_context(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
    context: ReportContext<'_>,
) -> String {
    let summary = Summary::from_outcomes(outcomes);
    let mut out = String::new();
    out.push_str(&format!("cargo-allow {command}\n\n"));
    out.push_str(&format!("Findings scanned: {}\n", findings.len()));
    out.push_str(&format!(
        "Inventory: source_tree/source_syntax via {}\n",
        context.inventory_source
    ));
    for status in [
        MatchStatus::Matched,
        MatchStatus::New,
        MatchStatus::Expired,
        MatchStatus::ReviewDue,
        MatchStatus::Stale,
        MatchStatus::Ambiguous,
        MatchStatus::InvalidSelector,
        MatchStatus::EvidenceMissing,
        MatchStatus::MissingRequiredField,
        MatchStatus::BaselineDebt,
    ] {
        let count = summary.count(status);
        if count > 0 {
            out.push_str(&format!("  {:24} {}\n", status.as_str(), count));
        }
    }
    if outcomes.is_empty() {
        out.push_str("  no outcomes\n");
    }
    render_non_rust_human(findings, outcomes, &mut out);
    out.push('\n');
    for outcome in outcomes
        .iter()
        .filter(|o| o.status != MatchStatus::Matched)
        .take(80)
    {
        out.push_str(&format!(
            "{}: {}\n",
            outcome.status.as_str(),
            outcome.message
        ));
    }
    out.push('\n');
    out.push_str(CLAIM_BOUNDARY_TEXT);
    out.push('\n');
    out.push_str(if failed {
        "Result: failed\n"
    } else {
        "Result: passed/advisory\n"
    });
    out
}

pub fn render_markdown(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
) -> String {
    render_markdown_with_context(
        command,
        findings,
        outcomes,
        failed,
        ReportContext::default(),
    )
}

pub fn render_markdown_with_context(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
    context: ReportContext<'_>,
) -> String {
    let summary = Summary::from_outcomes(outcomes);
    let mut out = String::new();
    out.push_str(&format!("# cargo-allow {command}\n\n"));
    out.push_str(&format!(
        "**Result:** {}\n\n",
        if failed { "failed" } else { "passed/advisory" }
    ));
    out.push_str(&format!("Findings scanned: `{}`\n\n", findings.len()));
    out.push_str(&format!(
        "Inventory: `source_tree` / `source_syntax` via `{}`\n\n",
        json_escape(context.inventory_source)
    ));
    out.push_str("| Status | Count |\n|---|---:|\n");
    for status in [
        MatchStatus::Matched,
        MatchStatus::New,
        MatchStatus::Expired,
        MatchStatus::ReviewDue,
        MatchStatus::Stale,
        MatchStatus::Ambiguous,
        MatchStatus::InvalidSelector,
        MatchStatus::EvidenceMissing,
        MatchStatus::MissingRequiredField,
        MatchStatus::BaselineDebt,
    ] {
        let count = summary.count(status);
        out.push_str(&format!("| `{}` | {} |\n", status.as_str(), count));
    }
    if command == "audit" {
        render_audit_summary_markdown(&summary, outcomes, &mut out);
    }
    render_non_rust_markdown(findings, outcomes, &mut out);
    let non_matched = outcomes
        .iter()
        .filter(|o| o.status != MatchStatus::Matched)
        .take(100)
        .collect::<Vec<_>>();
    if !non_matched.is_empty() {
        out.push_str("\n## Non-matched outcomes\n\n");
        for outcome in non_matched {
            out.push_str(&format!(
                "- `{}`: {}\n",
                outcome.status.as_str(),
                outcome.message
            ));
        }
    }
    out.push_str("\n> ");
    out.push_str(CLAIM_BOUNDARY_TEXT);
    out.push('\n');
    out
}

pub fn render_html(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
) -> String {
    render_html_with_context(
        command,
        findings,
        outcomes,
        failed,
        ReportContext::default(),
    )
}

pub fn render_html_with_context(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
    context: ReportContext<'_>,
) -> String {
    let summary = Summary::from_outcomes(outcomes);
    let mut out = String::new();
    out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
    out.push_str("  <meta charset=\"utf-8\">\n");
    out.push_str(&format!(
        "  <title>cargo-allow {}</title>\n",
        html_escape(command)
    ));
    out.push_str("  <style>body{font-family:system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;line-height:1.45}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{border:1px solid #d0d7de;padding:.4rem .55rem;text-align:left}th{background:#f6f8fa}td.count{text-align:right;font-variant-numeric:tabular-nums}.status{font-weight:700}.failed{color:#b42318}.passed{color:#1a7f37}code{background:#f6f8fa;padding:.1rem .25rem;border-radius:4px}.claim{border-left:4px solid #57606a;padding-left:1rem;color:#57606a}</style>\n");
    out.push_str("</head>\n<body>\n");
    out.push_str(&format!("<h1>cargo-allow {}</h1>\n", html_escape(command)));
    out.push_str(&format!(
        "<p class=\"status {}\">Result: {}</p>\n",
        if failed { "failed" } else { "passed" },
        if failed { "failed" } else { "passed/advisory" }
    ));
    out.push_str(&format!(
        "<p>Findings scanned: <code>{}</code></p>\n",
        findings.len()
    ));
    out.push_str(&format!(
        "<p>Inventory: <code>source_tree</code> / <code>source_syntax</code> via <code>{}</code></p>\n",
        html_escape(context.inventory_source)
    ));
    out.push_str("<h2>Status Counts</h2>\n");
    render_status_count_table_html(&summary, &mut out);
    if command == "audit" {
        render_audit_summary_html(&summary, outcomes, &mut out);
    }
    render_non_rust_html(findings, outcomes, &mut out);
    render_non_matched_html(outcomes, &mut out);
    out.push_str("<h2>Claim Boundary</h2>\n");
    out.push_str(&format!(
        "<p class=\"claim\">{}</p>\n",
        html_escape(CLAIM_BOUNDARY_TEXT)
    ));
    out.push_str("</body>\n</html>\n");
    out
}

fn render_status_count_table_html(summary: &Summary, out: &mut String) {
    out.push_str("<table><thead><tr><th>Status</th><th>Count</th></tr></thead><tbody>\n");
    for status in [
        MatchStatus::Matched,
        MatchStatus::New,
        MatchStatus::Expired,
        MatchStatus::ReviewDue,
        MatchStatus::Stale,
        MatchStatus::Ambiguous,
        MatchStatus::InvalidSelector,
        MatchStatus::EvidenceMissing,
        MatchStatus::MissingRequiredField,
        MatchStatus::BaselineDebt,
    ] {
        out.push_str(&format!(
            "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
            status.as_str(),
            summary.count(status)
        ));
    }
    out.push_str("</tbody></table>\n");
}

fn render_audit_summary_html(summary: &Summary, outcomes: &[MatchOutcome], out: &mut String) {
    let review_items = review_item_count(summary);
    out.push_str("<h2>Audit Summary</h2>\n");
    out.push_str("<table><thead><tr><th>Signal</th><th>Count</th></tr></thead><tbody>\n");
    for (name, value) in [
        ("Match outcomes", summary.total),
        ("Review items", review_items),
        ("New unreceipted", summary.count(MatchStatus::New)),
        ("Expired", summary.count(MatchStatus::Expired)),
        ("Evidence gaps", summary.count(MatchStatus::EvidenceMissing)),
        ("Baseline debt", summary.count(MatchStatus::BaselineDebt)),
    ] {
        out.push_str(&format!(
            "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
            html_escape(name),
            value
        ));
    }
    out.push_str("</tbody></table>\n");
    if review_items == 0 {
        out.push_str("<p>Recommended next step: keep <code>cargo-allow check --mode no-new</code> in CI.</p>\n");
    } else {
        out.push_str(
            "<p>Recommended next step: review the queue below before tightening policy.</p>\n",
        );
    }
    let queue = outcomes
        .iter()
        .filter(|outcome| outcome.status != MatchStatus::Matched)
        .take(20)
        .collect::<Vec<_>>();
    if !queue.is_empty() {
        out.push_str("<h2>Audit Review Queue</h2>\n<ul>\n");
        for outcome in queue {
            out.push_str(&format!(
                "<li><code>{}</code>: {}</li>\n",
                outcome.status.as_str(),
                html_escape(&outcome.message)
            ));
        }
        out.push_str("</ul>\n");
    }
}

fn render_audit_summary_markdown(summary: &Summary, outcomes: &[MatchOutcome], out: &mut String) {
    let review_statuses = [
        MatchStatus::New,
        MatchStatus::Expired,
        MatchStatus::Ambiguous,
        MatchStatus::EvidenceMissing,
        MatchStatus::MissingRequiredField,
        MatchStatus::BaselineDebt,
        MatchStatus::Stale,
        MatchStatus::ReviewDue,
    ];
    let review_items = review_item_count(summary);
    out.push_str("\n## Audit Summary\n\n");
    out.push_str("| Signal | Count |\n|---|---:|\n");
    out.push_str(&format!("| Match outcomes | {} |\n", summary.total));
    out.push_str(&format!("| Review items | {} |\n", review_items));
    out.push_str(&format!(
        "| New unreceipted | {} |\n",
        summary.count(MatchStatus::New)
    ));
    out.push_str(&format!(
        "| Expired | {} |\n",
        summary.count(MatchStatus::Expired)
    ));
    out.push_str(&format!(
        "| Evidence gaps | {} |\n",
        summary.count(MatchStatus::EvidenceMissing)
    ));
    out.push_str(&format!(
        "| Baseline debt | {} |\n",
        summary.count(MatchStatus::BaselineDebt)
    ));
    if review_items == 0 {
        out.push_str("\nRecommended next step: keep `cargo-allow check --mode no-new` in CI.\n");
    } else {
        out.push_str("\nRecommended next step: review the queue below before tightening policy.\n");
    }

    let queue = outcomes
        .iter()
        .filter(|outcome| review_statuses.contains(&outcome.status))
        .take(20)
        .collect::<Vec<_>>();
    if !queue.is_empty() {
        out.push_str("\n## Audit Review Queue\n\n");
        for outcome in queue {
            out.push_str(&format!(
                "- `{}`: {}\n",
                outcome.status.as_str(),
                outcome.message
            ));
        }
    }
}

pub fn render_json(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
) -> String {
    render_json_with_context(
        command,
        findings,
        outcomes,
        failed,
        ReportContext::default(),
    )
}

pub fn render_json_with_context(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
    context: ReportContext<'_>,
) -> String {
    let summary = Summary::from_outcomes(outcomes);
    let mut out = String::new();
    out.push_str("{\n");
    out.push_str(&format!("  \"schema_version\": {REPORT_SCHEMA_VERSION},\n"));
    out.push_str(&format!("  \"schema_id\": \"{REPORT_SCHEMA_ID}\",\n"));
    out.push_str("  \"tool\": \"cargo-allow\",\n");
    out.push_str(&format!("  \"command\": \"{}\",\n", json_escape(command)));
    out.push_str(&format!(
        "  \"status\": \"{}\",\n",
        if failed { "failed" } else { "passed" }
    ));
    out.push_str(&format!("  \"failed\": {},\n", bool_json(failed)));
    out.push_str(&format!(
        "  \"claim_boundary\": {},\n",
        json_string_array(CLAIM_BOUNDARY)
    ));
    out.push_str(&format!(
        "  \"scanner_limitations\": {},\n",
        json_string_array(SCANNER_LIMITATIONS)
    ));
    out.push_str("  \"inventory\": {\n");
    out.push_str("    \"scope\": \"source_tree\",\n");
    out.push_str("    \"scanner\": \"source_syntax\",\n");
    out.push_str(&format!(
        "    \"source\": \"{}\"\n",
        json_escape(context.inventory_source)
    ));
    out.push_str("  },\n");
    out.push_str("  \"summary\": {\n");
    out.push_str(&format!("    \"findings\": {},\n", findings.len()));
    out.push_str(&format!("    \"outcomes\": {},\n", summary.total));
    out.push_str(&render_counts_fields(&summary, "    "));
    out.push_str("  },\n");
    out.push_str("  \"trend\": {\n");
    out.push_str(&render_trend_fields(&summary, "    "));
    out.push_str("  },\n");
    out.push_str("  \"outcomes\": [\n");
    for (i, outcome) in outcomes.iter().enumerate() {
        if i > 0 {
            out.push_str(",\n");
        }
        out.push_str("    {");
        out.push_str(&format!("\"status\": \"{}\", ", outcome.status.as_str()));
        out.push_str(&format!(
            "\"allow_id\": {}, ",
            option_json(outcome.allow_id.as_deref())
        ));
        out.push_str(&format!(
            "\"finding_index\": {}, ",
            outcome
                .finding_index
                .map(|v| v.to_string())
                .unwrap_or_else(|| "null".to_string())
        ));
        out.push_str(&format!("\"score\": {}, ", outcome.score));
        out.push_str(&format!(
            "\"message\": \"{}\"",
            json_escape(&outcome.message)
        ));
        out.push('}');
    }
    out.push_str("\n  ],\n");
    out.push_str("  \"findings\": [\n");
    for (i, finding) in findings.iter().enumerate() {
        if i > 0 {
            out.push_str(",\n");
        }
        out.push_str("    {");
        out.push_str(&format!("\"kind\": \"{}\", ", finding.kind.as_str()));
        out.push_str(&format!(
            "\"family\": {}, ",
            option_json(finding.family.as_deref())
        ));
        out.push_str(&format!(
            "\"path\": \"{}\", ",
            json_escape(&normalize_path(&finding.path))
        ));
        out.push_str(&format!(
            "\"line\": {}, ",
            finding
                .span
                .as_ref()
                .map(|s| s.line.to_string())
                .unwrap_or_else(|| "null".to_string())
        ));
        out.push_str(&format!(
            "\"container\": {}, ",
            option_json(finding.identity.container.as_deref())
        ));
        out.push_str(&format!(
            "\"ast_kind\": \"{}\"",
            json_escape(&finding.identity.ast_kind)
        ));
        out.push('}');
    }
    out.push_str("\n  ]\n}");
    out
}

pub fn render_sarif(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
) -> String {
    render_sarif_with_context(
        command,
        findings,
        outcomes,
        failed,
        ReportContext::default(),
    )
}

pub fn render_sarif_with_context(
    command: &str,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    failed: bool,
    context: ReportContext<'_>,
) -> String {
    let reportable = outcomes
        .iter()
        .filter(|outcome| outcome.status != MatchStatus::Matched)
        .collect::<Vec<_>>();
    let mut out = String::new();
    out.push_str("{\n");
    out.push_str("  \"$schema\": \"https://json.schemastore.org/sarif-2.1.0.json\",\n");
    out.push_str("  \"version\": \"2.1.0\",\n");
    out.push_str("  \"runs\": [\n");
    out.push_str("    {\n");
    out.push_str("      \"tool\": {\n");
    out.push_str("        \"driver\": {\n");
    out.push_str("          \"name\": \"cargo-allow\",\n");
    out.push_str(
        "          \"informationUri\": \"https://github.com/EffortlessMetrics/cargo-allow\",\n",
    );
    out.push_str("          \"rules\": [\n");
    for (index, status) in SARIF_STATUSES.iter().enumerate() {
        if index > 0 {
            out.push_str(",\n");
        }
        out.push_str(&render_sarif_rule(*status));
    }
    out.push_str("\n          ]\n");
    out.push_str("        }\n");
    out.push_str("      },\n");
    out.push_str("      \"properties\": {\n");
    out.push_str(&format!(
        "        \"command\": \"{}\",\n",
        json_escape(command)
    ));
    out.push_str(&format!(
        "        \"status\": \"{}\",\n",
        if failed { "failed" } else { "passed" }
    ));
    out.push_str(&format!("        \"failed\": {},\n", bool_json(failed)));
    out.push_str("        \"inventory\": {\n");
    out.push_str("          \"scope\": \"source_tree\",\n");
    out.push_str("          \"scanner\": \"source_syntax\",\n");
    out.push_str(&format!(
        "          \"source\": \"{}\"\n",
        json_escape(context.inventory_source)
    ));
    out.push_str("        },\n");
    out.push_str(&format!(
        "        \"claim_boundary\": {},\n",
        json_string_array(CLAIM_BOUNDARY)
    ));
    out.push_str(&format!(
        "        \"scanner_limitations\": {}\n",
        json_string_array(SCANNER_LIMITATIONS)
    ));
    out.push_str("      },\n");
    out.push_str("      \"results\": [\n");
    for (index, outcome) in reportable.iter().enumerate() {
        if index > 0 {
            out.push_str(",\n");
        }
        let finding = outcome.finding_index.and_then(|idx| findings.get(idx));
        out.push_str(&render_sarif_result(outcome, finding));
    }
    out.push_str("\n      ]\n");
    out.push_str("    }\n");
    out.push_str("  ]\n");
    out.push_str("}\n");
    out
}

const SARIF_STATUSES: &[MatchStatus] = &[
    MatchStatus::New,
    MatchStatus::Expired,
    MatchStatus::ReviewDue,
    MatchStatus::Stale,
    MatchStatus::Ambiguous,
    MatchStatus::InvalidSelector,
    MatchStatus::MissingRequiredField,
    MatchStatus::EvidenceMissing,
    MatchStatus::BaselineDebt,
];

fn render_sarif_rule(status: MatchStatus) -> String {
    format!(
        "            {{\"id\": \"{}\", \"name\": \"{}\", \"shortDescription\": {{\"text\": \"{}\"}}}}",
        sarif_rule_id(status),
        status.as_str(),
        sarif_rule_description(status)
    )
}

fn render_sarif_result(outcome: &MatchOutcome, finding: Option<&Finding>) -> String {
    let mut out = String::new();
    out.push_str("        {\n");
    out.push_str(&format!(
        "          \"ruleId\": \"{}\",\n",
        sarif_rule_id(outcome.status)
    ));
    out.push_str(&format!(
        "          \"level\": \"{}\",\n",
        sarif_level(outcome.status)
    ));
    out.push_str(&format!(
        "          \"message\": {{\"text\": \"{}\"}},\n",
        json_escape(&outcome.message)
    ));
    out.push_str("          \"properties\": {\n");
    out.push_str(&format!(
        "            \"status\": \"{}\",\n",
        outcome.status.as_str()
    ));
    out.push_str(&format!(
        "            \"allow_id\": {},\n",
        option_json(outcome.allow_id.as_deref())
    ));
    out.push_str(&format!(
        "            \"finding_index\": {},\n",
        outcome
            .finding_index
            .map(|idx| idx.to_string())
            .unwrap_or_else(|| "null".to_string())
    ));
    out.push_str(&format!("            \"score\": {}\n", outcome.score));
    out.push_str("          }");
    if let Some(finding) = finding {
        out.push_str(",\n");
        out.push_str("          \"locations\": [\n");
        out.push_str(&render_sarif_location(finding));
        out.push_str("\n          ]\n");
        out.push_str("        }");
    } else {
        out.push('\n');
        out.push_str("        }");
    }
    out
}

fn render_sarif_location(finding: &Finding) -> String {
    let mut out = String::new();
    out.push_str("            {\n");
    out.push_str("              \"physicalLocation\": {\n");
    out.push_str(&format!(
        "                \"artifactLocation\": {{\"uri\": \"{}\"}}",
        json_escape(&normalize_path(&finding.path))
    ));
    if let Some(span) = &finding.span {
        out.push_str(",\n");
        out.push_str("                \"region\": {\n");
        out.push_str(&format!(
            "                  \"startLine\": {},\n",
            span.line
        ));
        out.push_str(&format!(
            "                  \"startColumn\": {}\n",
            span.column
        ));
        out.push_str("                }\n");
        out.push_str("              }\n");
    } else {
        out.push('\n');
        out.push_str("              }\n");
    }
    out.push_str("            }");
    out
}

fn sarif_rule_id(status: MatchStatus) -> String {
    format!("cargo-allow/{}", status.as_str())
}

fn sarif_rule_description(status: MatchStatus) -> &'static str {
    match status {
        MatchStatus::New => "New unreceipted source-tree exception finding.",
        MatchStatus::Expired => "Matched allow entry is expired.",
        MatchStatus::ReviewDue => "Matched allow entry is due for review.",
        MatchStatus::Stale => "Allow entry did not match any current finding.",
        MatchStatus::Ambiguous => "Selector matched ambiguously and needs narrowing.",
        MatchStatus::InvalidSelector => "Allow entry selector is invalid.",
        MatchStatus::MissingRequiredField => "Allow entry is missing required policy metadata.",
        MatchStatus::EvidenceMissing => "Allow entry is missing required evidence.",
        MatchStatus::BaselineDebt => "Generated baseline debt remains in policy.",
        MatchStatus::Matched => "Finding matched policy.",
    }
}

fn sarif_level(status: MatchStatus) -> &'static str {
    match status {
        MatchStatus::New
        | MatchStatus::Expired
        | MatchStatus::Ambiguous
        | MatchStatus::InvalidSelector
        | MatchStatus::MissingRequiredField
        | MatchStatus::EvidenceMissing => "error",
        MatchStatus::ReviewDue | MatchStatus::BaselineDebt => "warning",
        MatchStatus::Stale => "note",
        MatchStatus::Matched => "none",
    }
}

pub fn render_receipt(command: &str, outcomes: &[MatchOutcome], failed: bool) -> String {
    render_receipt_with_context(command, outcomes, failed, ReportContext::default())
}

pub fn render_receipt_with_context(
    command: &str,
    outcomes: &[MatchOutcome],
    failed: bool,
    context: ReportContext<'_>,
) -> String {
    let summary = Summary::from_outcomes(outcomes);
    format!(
        "{{\n  \"schema_version\": {RECEIPT_SCHEMA_VERSION},\n  \"schema_id\": \"{RECEIPT_SCHEMA_ID}\",\n  \"tool\": \"cargo-allow\",\n  \"command\": \"{}\",\n  \"status\": \"{}\",\n  \"failed\": {},\n  \"claim_boundary\": {},\n  \"scanner_limitations\": {},\n  \"inventory\": {{\n    \"scope\": \"source_tree\",\n    \"scanner\": \"source_syntax\",\n    \"source\": \"{}\"\n  }},\n  \"counts\": {{\n{}  }}\n}}\n",
        json_escape(command),
        if failed { "failed" } else { "passed" },
        bool_json(failed),
        json_string_array(CLAIM_BOUNDARY),
        json_string_array(SCANNER_LIMITATIONS),
        json_escape(context.inventory_source),
        render_counts_fields(&summary, "    ")
    )
}

fn option_json(value: Option<&str>) -> String {
    value
        .map(|v| format!("\"{}\"", json_escape(v)))
        .unwrap_or_else(|| "null".to_string())
}

fn bool_json(value: bool) -> &'static str {
    if value { "true" } else { "false" }
}

fn json_string_array(values: &[&str]) -> String {
    format!(
        "[{}]",
        values
            .iter()
            .map(|value| format!("\"{}\"", json_escape(value)))
            .collect::<Vec<_>>()
            .join(", ")
    )
}

fn render_counts_fields(summary: &Summary, indent: &str) -> String {
    let statuses = [
        MatchStatus::Matched,
        MatchStatus::New,
        MatchStatus::Expired,
        MatchStatus::ReviewDue,
        MatchStatus::Stale,
        MatchStatus::Ambiguous,
        MatchStatus::InvalidSelector,
        MatchStatus::MissingRequiredField,
        MatchStatus::EvidenceMissing,
        MatchStatus::BaselineDebt,
    ];
    statuses
        .iter()
        .enumerate()
        .map(|(idx, status)| {
            let comma = if idx + 1 == statuses.len() { "" } else { "," };
            format!(
                "{indent}\"{}\": {}{comma}\n",
                status.as_str(),
                summary.count(*status)
            )
        })
        .collect::<String>()
}

fn render_trend_fields(summary: &Summary, indent: &str) -> String {
    let fields = [
        ("review_items", review_item_count(summary)),
        ("new", summary.count(MatchStatus::New)),
        ("expired", summary.count(MatchStatus::Expired)),
        ("review_due", summary.count(MatchStatus::ReviewDue)),
        ("stale", summary.count(MatchStatus::Stale)),
        ("ambiguous", summary.count(MatchStatus::Ambiguous)),
        (
            "invalid_selector",
            summary.count(MatchStatus::InvalidSelector),
        ),
        (
            "missing_required_field",
            summary.count(MatchStatus::MissingRequiredField),
        ),
        (
            "evidence_missing",
            summary.count(MatchStatus::EvidenceMissing),
        ),
        ("baseline_debt", summary.count(MatchStatus::BaselineDebt)),
    ];
    fields
        .iter()
        .enumerate()
        .map(|(idx, (name, value))| {
            let comma = if idx + 1 == fields.len() { "" } else { "," };
            format!("{indent}\"{name}\": {value}{comma}\n")
        })
        .collect()
}

fn review_item_count(summary: &Summary) -> usize {
    [
        MatchStatus::New,
        MatchStatus::Expired,
        MatchStatus::ReviewDue,
        MatchStatus::Stale,
        MatchStatus::Ambiguous,
        MatchStatus::InvalidSelector,
        MatchStatus::MissingRequiredField,
        MatchStatus::EvidenceMissing,
        MatchStatus::BaselineDebt,
    ]
    .iter()
    .map(|status| summary.count(*status))
    .sum()
}

#[derive(Debug, Default)]
struct FilePosture {
    total: usize,
    by_family: BTreeMap<String, usize>,
    matched: usize,
    new: usize,
    generated: usize,
}

impl FilePosture {
    fn from_report(findings: &[Finding], outcomes: &[MatchOutcome]) -> Self {
        let mut posture = Self::default();
        for finding in findings.iter().filter(|finding| is_file_finding(finding)) {
            posture.total += 1;
            if finding.kind == FindingKind::GeneratedCode {
                posture.generated += 1;
            }
            *posture
                .by_family
                .entry(
                    finding
                        .family
                        .clone()
                        .unwrap_or_else(|| "unknown".to_string()),
                )
                .or_insert(0) += 1;
        }
        for outcome in outcomes {
            let applies_to_file = outcome
                .finding_index
                .and_then(|idx| findings.get(idx))
                .map(is_file_finding)
                .unwrap_or(false);
            match outcome.status {
                MatchStatus::Matched if applies_to_file => posture.matched += 1,
                MatchStatus::New if applies_to_file => posture.new += 1,
                _ => {}
            }
        }
        posture
    }

    fn has_files(&self) -> bool {
        self.total > 0
    }
}

fn render_non_rust_human(findings: &[Finding], outcomes: &[MatchOutcome], out: &mut String) {
    let posture = FilePosture::from_report(findings, outcomes);
    if !posture.has_files() {
        return;
    }
    out.push('\n');
    out.push_str("Non-Rust file inventory:\n");
    out.push_str(&format!("  files scanned              {}\n", posture.total));
    out.push_str(&format!(
        "  matched                    {}\n",
        posture.matched
    ));
    out.push_str(&format!("  new                        {}\n", posture.new));
    out.push_str(&format!(
        "  generated                  {}\n",
        posture.generated
    ));
    if !posture.by_family.is_empty() {
        out.push_str("  by family:\n");
        for (family, count) in posture.by_family {
            out.push_str(&format!("    {:24} {}\n", family, count));
        }
    }
    let rows = non_rust_file_rows(findings, outcomes);
    if !rows.is_empty() {
        out.push_str("  files:\n");
        for row in rows.into_iter().take(40) {
            out.push_str(&format!(
                "    {:12} {:24} {}\n",
                row.status, row.family, row.path
            ));
        }
    }
}

fn render_non_rust_markdown(findings: &[Finding], outcomes: &[MatchOutcome], out: &mut String) {
    let posture = FilePosture::from_report(findings, outcomes);
    if !posture.has_files() {
        return;
    }
    out.push_str("\n## Non-Rust File Inventory\n\n");
    out.push_str("| Metric | Count |\n|---|---:|\n");
    out.push_str(&format!("| Files scanned | {} |\n", posture.total));
    out.push_str(&format!("| Matched | {} |\n", posture.matched));
    out.push_str(&format!("| New | {} |\n", posture.new));
    out.push_str(&format!("| Generated | {} |\n", posture.generated));
    if !posture.by_family.is_empty() {
        out.push_str("\n| Family | Count |\n|---|---:|\n");
        for (family, count) in posture.by_family {
            out.push_str(&format!("| `{}` | {} |\n", markdown_cell(&family), count));
        }
    }
    let rows = non_rust_file_rows(findings, outcomes);
    if !rows.is_empty() {
        out.push_str("\n| Status | Family | Path |\n|---|---|---|\n");
        for row in rows.into_iter().take(60) {
            out.push_str(&format!(
                "| `{}` | `{}` | `{}` |\n",
                markdown_cell(row.status),
                markdown_cell(&row.family),
                markdown_cell(&row.path)
            ));
        }
    }
}

fn render_non_rust_html(findings: &[Finding], outcomes: &[MatchOutcome], out: &mut String) {
    let posture = FilePosture::from_report(findings, outcomes);
    if !posture.has_files() {
        return;
    }
    out.push_str("<h2>Non-Rust File Inventory</h2>\n");
    out.push_str("<table><thead><tr><th>Metric</th><th>Count</th></tr></thead><tbody>\n");
    for (name, value) in [
        ("Files scanned", posture.total),
        ("Matched", posture.matched),
        ("New", posture.new),
        ("Generated", posture.generated),
    ] {
        out.push_str(&format!(
            "<tr><td>{}</td><td class=\"count\">{}</td></tr>\n",
            html_escape(name),
            value
        ));
    }
    out.push_str("</tbody></table>\n");
    if !posture.by_family.is_empty() {
        out.push_str("<table><thead><tr><th>Family</th><th>Count</th></tr></thead><tbody>\n");
        for (family, count) in posture.by_family {
            out.push_str(&format!(
                "<tr><td><code>{}</code></td><td class=\"count\">{}</td></tr>\n",
                html_escape(&family),
                count
            ));
        }
        out.push_str("</tbody></table>\n");
    }
    let rows = non_rust_file_rows(findings, outcomes);
    if !rows.is_empty() {
        out.push_str(
            "<table><thead><tr><th>Status</th><th>Family</th><th>Path</th></tr></thead><tbody>\n",
        );
        for row in rows.into_iter().take(60) {
            out.push_str(&format!(
                "<tr><td><code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td></tr>\n",
                html_escape(row.status),
                html_escape(&row.family),
                html_escape(&row.path)
            ));
        }
        out.push_str("</tbody></table>\n");
    }
}

fn render_non_matched_html(outcomes: &[MatchOutcome], out: &mut String) {
    let non_matched = outcomes
        .iter()
        .filter(|outcome| outcome.status != MatchStatus::Matched)
        .take(100)
        .collect::<Vec<_>>();
    if non_matched.is_empty() {
        return;
    }
    out.push_str("<h2>Non-matched Outcomes</h2>\n<ul>\n");
    for outcome in non_matched {
        out.push_str(&format!(
            "<li><code>{}</code>: {}</li>\n",
            outcome.status.as_str(),
            html_escape(&outcome.message)
        ));
    }
    out.push_str("</ul>\n");
}

fn markdown_cell(value: &str) -> String {
    value.replace('|', "\\|").replace('`', "\\`")
}

fn html_escape(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

fn is_file_finding(finding: &Finding) -> bool {
    matches!(
        finding.kind,
        FindingKind::NonRustFile | FindingKind::GeneratedCode
    )
}

#[derive(Debug)]
struct FileRow {
    status: &'static str,
    family: String,
    path: String,
}

fn non_rust_file_rows(findings: &[Finding], outcomes: &[MatchOutcome]) -> Vec<FileRow> {
    let mut status_by_index = BTreeMap::new();
    for outcome in outcomes {
        if let Some(index) = outcome.finding_index {
            status_by_index.insert(index, outcome.status.as_str());
        }
    }
    let mut rows = findings
        .iter()
        .enumerate()
        .filter(|(_, finding)| is_file_finding(finding))
        .map(|(index, finding)| FileRow {
            status: status_by_index.get(&index).copied().unwrap_or("unmatched"),
            family: finding
                .family
                .clone()
                .unwrap_or_else(|| "unknown".to_string()),
            path: normalize_path(&finding.path),
        })
        .collect::<Vec<_>>();
    rows.sort_by(|left, right| {
        left.path
            .cmp(&right.path)
            .then_with(|| left.family.cmp(&right.family))
            .then_with(|| left.status.cmp(right.status))
    });
    rows
}

#[cfg(test)]
mod tests {
    use super::*;
    use allow_core::{Finding, FindingKind, Span, StructuralIdentity};
    use std::path::PathBuf;

    #[test]
    fn json_contains_claim_boundary() {
        let json = render_json("audit", &[], &[], false);
        assert!(json.contains("source_tree_inventory"));
        assert!(json.contains("cargo_metadata_not_invoked"));
        assert!(json.contains("cargo_commands_not_invoked"));
        assert!(json.contains("rustc_not_invoked"));
        assert!(json.contains("clippy_not_invoked"));
        assert!(json.contains("build_scripts_not_executed"));
        assert!(json.contains("proc_macros_not_executed"));
        assert!(json.contains("macro_expansion_not_analyzed"));
        assert!(json.contains("macro_token_tree_contents_not_analyzed"));
        assert!(json.contains("repository_code_not_executed"));
    }

    #[test]
    fn json_report_exposes_v1_schema_contract() {
        let json = render_json("audit", &[], &[], false);
        assert!(json.contains("\"schema_version\": 1"));
        assert!(json.contains("\"schema_id\": \"cargo-allow.report.v1\""));
        assert!(json.contains("\"failed\": false"));
        assert!(json.contains("\"scanner_limitations\""));
        assert!(json.contains("\"scope\": \"source_tree\""));
        assert!(json.contains("\"scanner\": \"source_syntax\""));
        assert!(json.contains("\"source\": \"unknown\""));
        assert!(json.contains("\"review_due\": 0"));
        assert!(json.contains("\"baseline_debt\": 0"));
        assert!(json.contains("\"trend\""));
        assert!(json.contains("\"review_items\": 0"));
    }

    #[test]
    fn json_report_exposes_trend_metrics() {
        let outcomes = vec![
            outcome(MatchStatus::New, Some(0)),
            outcome(MatchStatus::EvidenceMissing, Some(1)),
            outcome(MatchStatus::Stale, None),
        ];

        let json = render_json("audit", &[], &outcomes, false);

        assert!(json.contains("\"trend\""));
        assert!(json.contains("\"review_items\": 3"));
        assert!(json.contains("\"new\": 1"));
        assert!(json.contains("\"stale\": 1"));
        assert!(json.contains("\"evidence_missing\": 1"));
        assert!(json.contains("\"baseline_debt\": 0"));
    }

    #[test]
    fn sarif_report_emits_non_matched_results_with_locations() {
        let findings = vec![file_finding(
            FindingKind::NonRustFile,
            "shell_script",
            "scripts/new.sh",
        )];
        let outcomes = vec![
            outcome(MatchStatus::Matched, Some(0)),
            MatchOutcome {
                status: MatchStatus::New,
                allow_id: None,
                finding_index: Some(0),
                message: "unreceipted shell script at scripts/new.sh".to_string(),
                score: 0,
            },
        ];

        let sarif = render_sarif_with_context(
            "check",
            &findings,
            &outcomes,
            true,
            ReportContext {
                inventory_source: "git_tracked",
            },
        );

        assert!(sarif.contains("\"version\": \"2.1.0\""));
        assert!(sarif.contains("\"name\": \"cargo-allow\""));
        assert!(sarif.contains("\"ruleId\": \"cargo-allow/new\""));
        assert!(sarif.contains("\"level\": \"error\""));
        assert!(sarif.contains("\"uri\": \"scripts/new.sh\""));
        assert!(sarif.contains("\"startLine\": 1"));
        assert!(sarif.contains("\"source_tree_inventory\""));
        assert!(sarif.contains("\"cargo_commands_not_invoked\""));
        assert!(!sarif.contains("\"ruleId\": \"cargo-allow/matched\""));
    }

    #[test]
    fn receipt_exposes_v1_schema_contract() {
        let json = render_receipt_with_context(
            "check",
            &[],
            true,
            ReportContext {
                inventory_source: "git_tracked",
            },
        );
        assert!(json.contains("\"schema_version\": 1"));
        assert!(json.contains("\"schema_id\": \"cargo-allow.receipt.v1\""));
        assert!(json.contains("\"failed\": true"));
        assert!(json.contains("\"source\": \"git_tracked\""));
        assert!(json.contains("\"cargo_metadata_not_invoked\""));
        assert!(json.contains("\"cargo_commands_not_invoked\""));
        assert!(json.contains("\"build_output_not_analyzed\""));
        assert!(json.contains("\"macro_token_tree_contents_not_analyzed\""));
        assert!(json.contains("\"missing_required_field\": 0"));
        assert!(json.contains("\"evidence_missing\": 0"));
    }

    #[test]
    fn schemas_reference_current_contract_ids() {
        let report_schema = include_str!("../../../docs/schemas/report.schema.json");
        let receipt_schema = include_str!("../../../docs/schemas/receipt.schema.json");
        assert!(report_schema.contains(REPORT_SCHEMA_ID));
        assert!(receipt_schema.contains(RECEIPT_SCHEMA_ID));
    }

    #[test]
    fn human_report_summarizes_non_rust_inventory() {
        let findings = vec![
            file_finding(FindingKind::NonRustFile, "configuration", ".gitignore"),
            file_finding(
                FindingKind::GeneratedCode,
                "generated_code",
                "schemas/api.yaml",
            ),
        ];
        let outcomes = vec![
            outcome(MatchStatus::Matched, Some(0)),
            outcome(MatchStatus::New, Some(1)),
        ];

        let text = render_human_with_context(
            "audit",
            &findings,
            &outcomes,
            false,
            ReportContext {
                inventory_source: "filesystem_fallback",
            },
        );

        assert!(text.contains("Inventory: source_tree/source_syntax via filesystem_fallback"));
        assert!(text.contains("Non-Rust file inventory:"));
        assert!(text.contains("files scanned              2"));
        assert!(text.contains("new                        1"));
        assert!(text.contains("generated                  1"));
        assert!(text.contains("configuration"));
        assert!(text.contains("generated_code"));
        assert!(text.contains("    matched      configuration            .gitignore"));
        assert!(text.contains("schemas/api.yaml"));
        assert!(text.contains("did not invoke Cargo metadata"));
        assert!(text.contains("repository code"));
    }

    #[test]
    fn markdown_report_summarizes_non_rust_inventory() {
        let findings = vec![file_finding(
            FindingKind::NonRustFile,
            "ci_declarative",
            ".github/workflows/ci.yml",
        )];
        let outcomes = vec![outcome(MatchStatus::Matched, Some(0))];

        let text = render_markdown_with_context(
            "audit",
            &findings,
            &outcomes,
            false,
            ReportContext {
                inventory_source: "git_tracked",
            },
        );

        assert!(text.contains("Inventory: `source_tree` / `source_syntax` via `git_tracked`"));
        assert!(text.contains("## Non-Rust File Inventory"));
        assert!(text.contains("| Files scanned | 1 |"));
        assert!(text.contains("| `ci_declarative` | 1 |"));
        assert!(text.contains("| `matched` | `ci_declarative` | `.github/workflows/ci.yml` |"));
        assert!(!text.contains("## Non-matched outcomes"));
        assert!(text.contains("did not invoke Cargo metadata"));
        assert!(text.contains("proc macros"));
    }

    #[test]
    fn html_report_summarizes_audit_posture() {
        let findings = vec![file_finding(
            FindingKind::NonRustFile,
            "shell_script",
            "scripts/new.sh",
        )];
        let outcomes = vec![MatchOutcome {
            status: MatchStatus::New,
            allow_id: None,
            finding_index: Some(0),
            message: "unreceipted shell script at scripts/new.sh".to_string(),
            score: 0,
        }];

        let html = render_html_with_context(
            "audit",
            &findings,
            &outcomes,
            true,
            ReportContext {
                inventory_source: "git_tracked",
            },
        );

        assert!(html.contains("<!doctype html>"));
        assert!(html.contains("<h1>cargo-allow audit</h1>"));
        assert!(html.contains("Result: failed"));
        assert!(html.contains("<h2>Audit Summary</h2>"));
        assert!(html.contains("<h2>Non-Rust File Inventory</h2>"));
        assert!(html.contains("<code>new</code>"));
        assert!(html.contains("<code>scripts/new.sh</code>"));
        assert!(html.contains("did not invoke Cargo metadata"));
    }

    #[test]
    fn markdown_audit_report_includes_review_summary() {
        let findings = vec![
            file_finding(FindingKind::NonRustFile, "shell_script", "scripts/new.sh"),
            file_finding(FindingKind::Unsafe, "unsafe_block", "src/ffi.rs"),
        ];
        let outcomes = vec![
            MatchOutcome {
                status: MatchStatus::New,
                allow_id: None,
                finding_index: Some(0),
                message: "unreceipted shell script at scripts/new.sh".to_string(),
                score: 0,
            },
            MatchOutcome {
                status: MatchStatus::EvidenceMissing,
                allow_id: Some("allow-unsafe-ffi".to_string()),
                finding_index: Some(1),
                message: "allow-unsafe-ffi matched unsafe finding but has no evidence".to_string(),
                score: 0,
            },
        ];

        let text = render_markdown_with_context(
            "audit",
            &findings,
            &outcomes,
            false,
            ReportContext {
                inventory_source: "git_tracked",
            },
        );

        assert!(text.contains("## Audit Summary"));
        assert!(text.contains("| Match outcomes | 2 |"));
        assert!(text.contains("| Review items | 2 |"));
        assert!(text.contains("| New unreceipted | 1 |"));
        assert!(text.contains("| Evidence gaps | 1 |"));
        assert!(
            text.contains(
                "Recommended next step: review the queue below before tightening policy."
            )
        );
        assert!(text.contains("## Audit Review Queue"));
        assert!(text.contains("- `new`: unreceipted shell script at scripts/new.sh"));
        assert!(text.contains(
            "- `evidence_missing`: allow-unsafe-ffi matched unsafe finding but has no evidence"
        ));
    }

    #[test]
    fn text_reports_include_review_due_and_invalid_selector_counts() {
        let outcomes = vec![
            MatchOutcome {
                status: MatchStatus::ReviewDue,
                allow_id: Some("allow-review".to_string()),
                finding_index: None,
                message: "allow-review is due for review".to_string(),
                score: 0,
            },
            MatchOutcome {
                status: MatchStatus::InvalidSelector,
                allow_id: Some("allow-invalid".to_string()),
                finding_index: None,
                message: "allow-invalid selector is invalid".to_string(),
                score: 0,
            },
        ];

        let human = render_human("check", &[], &outcomes, true);
        let markdown = render_markdown("check", &[], &outcomes, true);

        assert!(human.contains("review_due"));
        assert!(human.contains("invalid_selector"));
        assert!(markdown.contains("| `review_due` | 1 |"));
        assert!(markdown.contains("| `invalid_selector` | 1 |"));
    }

    fn file_finding(kind: FindingKind, family: &str, path: &str) -> Finding {
        Finding {
            kind,
            family: Some(family.to_string()),
            path: PathBuf::from(path),
            span: Some(Span { line: 1, column: 1 }),
            identity: StructuralIdentity::new("file", "tracked_file"),
            message: "tracked non-Rust file".to_string(),
        }
    }

    fn outcome(status: MatchStatus, finding_index: Option<usize>) -> MatchOutcome {
        MatchOutcome {
            status,
            allow_id: None,
            finding_index,
            message: String::new(),
            score: 0,
        }
    }
}
