//! Self-contained HTML report rendering.
//!
//! Hand-rolled rather than template-engine-backed: it's a single static
//! layout, and avoiding a templating dependency sidesteps the toolchain
//! skew that already forced version pins elsewhere. Every interpolated
//! string is HTML-escaped, so hostile bios / names / URLs can't break the
//! document or inject script.
//!
//! CSS is inlined so the file opens standalone. Avatar images are referenced
//! by their origin URL (not embedded), so opening the report fetches them
//! from the network.

use std::fmt::Write as _;
use std::time::Duration;

use adler_core::{CheckOutcome, CorrelationReport, MatchKind};

const STYLE: &str = "<style>\
:root{color-scheme:light dark}\
body{font:15px/1.5 system-ui,sans-serif;max-width:900px;margin:2rem auto;padding:0 1rem}\
h1{margin:0 0 .2rem}.meta{color:#888;margin:0 0 1.5rem}\
.cards{display:grid;gap:.75rem}\
.card{display:flex;gap:.75rem;border:1px solid #8884;border-radius:8px;padding:.6rem .8rem}\
.avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;flex:0 0 auto;background:#8882}\
.info a{font-weight:600;text-decoration:none}\
.field{font-size:.9em;color:#aaa}.field .k{color:#888;text-transform:uppercase;font-size:.8em}\
.cluster{border-left:3px solid #4a9;padding:.3rem .7rem;margin:.4rem 0;background:#4a91}\
details{margin-top:1.5rem}summary{cursor:pointer;color:#888}\
table{border-collapse:collapse;width:100%;margin-top:.5rem;font-size:.9em}\
td{padding:.2rem .5rem;border-bottom:1px solid #8882}\
footer{margin-top:2rem;color:#aaa;font-size:.85em}\
</style>";

/// Render a complete self-contained HTML report.
///
/// The report is always complete: the not-found / uncertain rows live in a
/// collapsed `<details>` section, so they add no visual noise yet keep the
/// artifact self-contained for later review.
pub(crate) fn render_html(
    username: &str,
    outcomes: &[CheckOutcome],
    correlation: Option<&CorrelationReport>,
    elapsed: Duration,
) -> String {
    let mut found: Vec<&CheckOutcome> = Vec::new();
    let mut others: Vec<&CheckOutcome> = Vec::new();
    for o in outcomes {
        match o.kind {
            MatchKind::Found => found.push(o),
            MatchKind::NotFound | MatchKind::Uncertain => others.push(o),
        }
    }
    found.sort_by(|a, b| a.site.cmp(&b.site));
    others.sort_by(|a, b| a.site.cmp(&b.site));
    let not_found = others
        .iter()
        .filter(|o| o.kind == MatchKind::NotFound)
        .count();
    let uncertain = others.len() - not_found;

    let mut s = String::with_capacity(4096);
    push_head(&mut s, username);
    let _ = writeln!(
        s,
        "<p class=\"meta\">username <strong>{}</strong> · {} found · {not_found} not found · {uncertain} uncertain · {:.2}s</p>",
        esc(username),
        found.len(),
        elapsed.as_secs_f64(),
    );
    push_found(&mut s, &found);
    if let Some(report) = correlation {
        push_correlation(&mut s, report);
    }
    push_others(&mut s, &others);
    s.push_str("<footer>generated by adler</footer>\n</body>\n</html>\n");
    s
}

fn push_head(s: &mut String, username: &str) {
    s.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
    s.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n");
    let _ = writeln!(s, "<title>Adler report — {}</title>", esc(username));
    s.push_str(STYLE);
    s.push_str("\n</head>\n<body>\n<h1>Adler report</h1>\n");
}

fn push_found(s: &mut String, found: &[&CheckOutcome]) {
    let _ = writeln!(s, "<h2>Found ({})</h2>", found.len());
    if found.is_empty() {
        s.push_str("<p>None.</p>\n");
        return;
    }
    s.push_str("<div class=\"cards\">\n");
    for o in found {
        s.push_str("<div class=\"card\">\n");
        if let Some(avatar) = o.enrichment.get("avatar") {
            let _ = writeln!(
                s,
                "<img class=\"avatar\" src=\"{}\" alt=\"\" loading=\"lazy\">",
                esc(avatar)
            );
        }
        let _ = writeln!(
            s,
            "<div class=\"info\"><a href=\"{}\" rel=\"noopener noreferrer\">{}</a>",
            esc(&o.url),
            esc(&o.site)
        );
        for (key, value) in &o.enrichment {
            if key == "avatar" {
                continue;
            }
            let _ = writeln!(
                s,
                "<div class=\"field\"><span class=\"k\">{}</span> {}</div>",
                esc(key),
                esc(value)
            );
        }
        s.push_str("</div>\n</div>\n");
    }
    s.push_str("</div>\n");
}

fn push_correlation(s: &mut String, report: &CorrelationReport) {
    s.push_str("<h2>Correlation</h2>\n");
    if report.clusters.is_empty() {
        s.push_str("<p>No cross-site links found.</p>\n");
    }
    for cluster in &report.clusters {
        let _ = write!(
            s,
            "<div class=\"cluster\"><strong>{}</strong> — {:.0}% confidence",
            esc(&cluster.members.join(", ")),
            cluster.confidence * 100.0,
        );
        if let Some(name) = &cluster.shared_name {
            let _ = write!(s, " · shared name: {}", esc(name));
        }
        s.push_str("</div>\n");
    }
}

fn push_others(s: &mut String, others: &[&CheckOutcome]) {
    if others.is_empty() {
        return;
    }
    let _ = writeln!(
        s,
        "<details>\n<summary>Not found / uncertain ({})</summary>\n<table>",
        others.len()
    );
    for o in others {
        let verdict = match o.kind {
            MatchKind::NotFound => "not found",
            _ => "uncertain",
        };
        let _ = writeln!(
            s,
            "<tr><td>{}</td><td>{verdict}</td><td><a href=\"{}\" rel=\"noopener noreferrer\">{}</a></td></tr>",
            esc(&o.site),
            esc(&o.url),
            esc(&o.url),
        );
    }
    s.push_str("</table>\n</details>\n");
}

/// Escape a string for safe inclusion in HTML text and double-quoted
/// attributes.
fn esc(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(c),
        }
    }
    out
}

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

    fn outcome(site: &str, kind: MatchKind, fields: &[(&str, &str)]) -> CheckOutcome {
        let mut enrichment = BTreeMap::new();
        for (k, v) in fields {
            enrichment.insert((*k).to_owned(), (*v).to_owned());
        }
        CheckOutcome {
            site: site.into(),
            url: format!("https://{site}.example/u"),
            kind,
            reason: None,
            elapsed_ms: 1,
            enrichment,
            evidence: Vec::new(),
            profile_evidence: Vec::new(),
            confidence: adler_core::ConfidenceScore::default(),
            transport: None,
            escalations: 0,
        }
    }

    #[test]
    fn renders_skeleton_and_found_card() {
        let outcomes = vec![outcome(
            "GitHub",
            MatchKind::Found,
            &[("name", "Alice"), ("avatar", "https://cdn/a.png")],
        )];
        let html = render_html("alice", &outcomes, None, Duration::from_secs(1));
        assert!(html.starts_with("<!DOCTYPE html>"));
        assert!(html.contains("<title>Adler report — alice</title>"));
        assert!(html.contains("Found (1)"));
        assert!(html.contains(">GitHub</a>"));
        assert!(html.contains("name</span> Alice"));
        assert!(html.contains("<img class=\"avatar\" src=\"https://cdn/a.png\""));
    }

    #[test]
    fn escapes_hostile_strings() {
        let outcomes = vec![outcome(
            "Evil",
            MatchKind::Found,
            &[("bio", "<script>alert(1)</script>")],
        )];
        let html = render_html("u", &outcomes, None, Duration::from_secs(1));
        assert!(!html.contains("<script>alert(1)</script>"));
        assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
    }

    #[test]
    fn includes_correlation_section() {
        let outcomes = vec![outcome("GitHub", MatchKind::Found, &[("name", "Alice")])];
        let report = adler_core::correlate(&outcomes);
        let html = render_html("alice", &outcomes, Some(&report), Duration::from_secs(1));
        assert!(html.contains("<h2>Correlation</h2>"));
    }

    #[test]
    fn report_always_includes_collapsed_others_section() {
        let outcomes = vec![
            outcome("A", MatchKind::Found, &[]),
            outcome("B", MatchKind::NotFound, &[]),
        ];
        let html = render_html("u", &outcomes, None, Duration::from_secs(1));
        assert!(html.contains("<details>"));
        assert!(html.contains("Not found / uncertain (1)"));
    }
}
