//! Browser-viewable HTML rendering of self-test request/response capture.
//!
//! Issue #79 round 24 (Srikanth (d) follow-up). `conformance-self-test-
//! requests.jsonl` is great for `jq` and `grep`, but Srikanth asked for
//! something that can be loaded in a browser without external tooling.
//! This module renders the same `CaseCapture` records into a single
//! self-contained HTML file (no external CSS / JS / images).
//!
//! Round 27 (Srikanth d3) — replaced the prior content-visibility +
//! 1000-card cap approach with proper pagination. The 1000-card cap
//! silently hid the 4xx/5xx probes Srikanth needed to investigate
//! because they sat past the cap. The viewer now embeds the full
//! capture as a JSON array (`window.__captures`), filters that array
//! in JS, then renders only the current page (50 cards) of the
//! filtered subset. Filters span ALL probes regardless of which page
//! is visible, so paging never hides matching probes.

use super::self_test::CaseCapture;

/// Round 27 — page size for the paginated capture viewer. 50 cards
/// per page keeps initial render under 100 ms on a modern browser
/// while still showing enough context to scan a category at a glance.
/// The full dataset is held in memory as JSON; only the slice is in
/// the DOM at any time. Mirrored as `PAGE_SIZE` in the JS handler
/// below; the test `pagination_controls_present` asserts the two
/// stay in sync.
#[allow(dead_code)] // documentation/contract for the JS-side constant
const PAGE_SIZE: usize = 50;

/// Render the full HTML viewer for a slice of captured probes.
/// Bodies are kept verbatim (already truncated upstream to
/// `CAPTURE_BODY_CAP_BYTES`). The whole capture is serialised as a
/// JSON array embedded in a `<script>` tag and rendered on demand by
/// the inline JS handler; cross-page filters work over the full
/// array, not the visible page.
pub fn render_capture_html(entries: &[CaseCapture]) -> String {
    let total = entries.len();
    let mut out = String::with_capacity(total.max(1) * 1024);
    out.push_str(HEAD);
    push_summary(&mut out, entries);
    out.push_str("<div id=\"cards\"></div>\n");
    push_pagination_controls(&mut out);
    push_data_script(&mut out, entries);
    out.push_str(FOOT);
    out
}

fn push_summary(out: &mut String, entries: &[CaseCapture]) {
    let total = entries.len();
    let pass = entries.iter().filter(|e| (200..400).contains(&e.response_status)).count();
    let fail = entries.iter().filter(|e| !(200..400).contains(&e.response_status)).count();
    out.push_str(&format!(
        "<header>\n\
         <h1>Self-Test Request/Response Capture</h1>\n\
         <p class=\"meta\">{total} probe(s); {pass} returned 2xx-3xx, {fail} returned 4xx-5xx or errored. \
         Generated by <code>mockforge bench --conformance-self-test --conformance-self-test-capture</code>.</p>\n\
         <div class=\"toolbar\">\n\
         <input type=\"search\" id=\"q\" placeholder=\"filter by label, method, URL, or status\" oninput=\"scheduleFilter()\" />\n\
         <label><input type=\"checkbox\" id=\"showPass\" checked onchange=\"applyFilter()\"/> 2xx-3xx</label>\n\
         <label><input type=\"checkbox\" id=\"showFail\" checked onchange=\"applyFilter()\"/> 4xx-5xx</label>\n\
         <label><input type=\"checkbox\" id=\"showErr\" checked onchange=\"applyFilter()\"/> transport error</label>\n\
         <label><input type=\"checkbox\" id=\"onlyMismatches\" onchange=\"applyFilter()\"/> only show mismatches</label>\n\
         <span id=\"filterStatus\" class=\"small\"></span>\n\
         </div>\n\
         </header>\n"
    ));
}

fn push_pagination_controls(out: &mut String) {
    out.push_str(
        "<div class=\"pager\">\n\
         <button id=\"firstPage\" onclick=\"gotoPage(0)\">First</button>\n\
         <button id=\"prevPage\" onclick=\"gotoPage(currentPage - 1)\">Prev</button>\n\
         <span id=\"pageNum\" class=\"pageNum\"></span>\n\
         <button id=\"nextPage\" onclick=\"gotoPage(currentPage + 1)\">Next</button>\n\
         <button id=\"lastPage\" onclick=\"gotoPage(totalPages - 1)\">Last</button>\n\
         <label class=\"small\">Jump to page: <input type=\"number\" id=\"jumpPage\" min=\"1\" style=\"width: 5em\" onchange=\"jumpToPage()\" /></label>\n\
         </div>\n",
    );
}

/// Embed the full capture as a JSON array assigned to
/// `window.__captures`. JSON.stringify with default escaping is
/// XSS-safe inside a `<script>` tag as long as we don't have a
/// closing `</script>` substring. `serde_json::to_string` does NOT
/// escape `<`, so we post-process to break any literal `</` so the
/// script element can't be terminated by the payload. The browser
/// JS handler unescapes this back transparently.
fn push_data_script(out: &mut String, entries: &[CaseCapture]) {
    out.push_str("<script id=\"captureData\" type=\"application/json\">\n");
    // Serialise to a single line for compactness. If serialisation
    // somehow fails (shouldn't, the struct is all Serialize), embed
    // an empty array so the viewer still loads.
    let json = serde_json::to_string(entries).unwrap_or_else(|_| "[]".to_string());
    // Defensive: protect against payload bodies containing
    // `</script>`. The JSON `<` becomes `<` which is valid JSON
    // and the JS parser accepts it.
    let safe = json.replace("</", r"<\/");
    out.push_str(&safe);
    out.push_str("\n</script>\n");
}

const HEAD: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MockForge Self-Test Capture</title>
<style>
  body { font-family: -apple-system, system-ui, sans-serif; max-width: 1200px;
         margin: 1rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.45; }
  h1 { font-size: 1.6rem; margin: 0; }
  h3 { margin: 1rem 0 0.3rem; font-size: 0.95rem; color: #374151; }
  header { border-bottom: 1px solid #e5e7eb; padding-bottom: 1rem; margin-bottom: 1rem; }
  .meta { color: #6b7280; font-size: 0.9rem; margin: 0.25rem 0 0.75rem; }
  .toolbar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
  .toolbar input[type=search] { flex: 1; min-width: 240px; padding: 0.4rem 0.6rem;
    border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.9rem; }
  .toolbar label { font-size: 0.85rem; color: #4b5563; cursor: pointer; }
  .pager { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;
    margin: 0.75rem 0; padding: 0.5rem 0; border-top: 1px solid #e5e7eb;
    border-bottom: 1px solid #e5e7eb; }
  .pager button { padding: 0.25rem 0.75rem; font-size: 0.85rem;
    border: 1px solid #d1d5db; background: #fff; border-radius: 4px; cursor: pointer; }
  .pager button:hover:not(:disabled) { background: #f3f4f6; }
  .pager button:disabled { opacity: 0.4; cursor: not-allowed; }
  .pager .pageNum { font-size: 0.85rem; color: #4b5563; min-width: 6em;
    text-align: center; font-variant-numeric: tabular-nums; }
  details.card { border: 1px solid #e5e7eb; border-radius: 6px; margin: 0.4rem 0;
    background: #fff; }
  details.card[open] { box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
  summary { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; gap: 0.5rem;
    align-items: center; flex-wrap: wrap; }
  summary::-webkit-details-marker { color: #9ca3af; }
  .method { background: #f3f4f6; padding: 0.05rem 0.4rem; border-radius: 3px;
    font-size: 0.8rem; }
  .label { color: #374151; font-size: 0.85rem; }
  .url { color: #6b7280; font-size: 0.8rem; word-break: break-all; }
  .body { padding: 0 0.75rem 0.75rem; border-top: 1px solid #f3f4f6; }
  .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px;
    font-size: 0.75rem; font-weight: 600; }
  .badge.pass { background: #d1fae5; color: #047857; }
  .badge.fail { background: #fee2e2; color: #b91c1c; }
  .badge.err  { background: #fef3c7; color: #92400e; }
  .badge.info { background: #dbeafe; color: #1d4ed8; }
  table.kv { width: 100%; font-size: 0.85rem; border-collapse: collapse;
    margin: 0.25rem 0 0.75rem; }
  table.kv td { padding: 0.2rem 0.5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
  table.kv td:first-child { color: #4b5563; width: 30%; max-width: 280px; }
  pre { background: #f9fafb; border: 1px solid #f3f4f6; padding: 0.5rem;
    border-radius: 4px; font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap;
    word-break: break-word; max-height: 320px; overflow-y: auto; }
  pre.err { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
  .small { color: #6b7280; font-size: 0.75rem; }
  code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.88em; }
</style>
</head>
<body>
"#;

const FOOT: &str = r#"
<script>
// Round 27 — pagination + cross-page filter over the JSON-embedded
// full capture. The previous CSS-only show/hide approach silently
// dropped 4xx/5xx probes past the 1000-card cap (Srikanth flagged
// this on 0.3.169). Now the JS holds the full capture in memory,
// filters across the whole array on every input, and only renders
// the current page (PAGE_SIZE entries) of the filtered subset.

const PAGE_SIZE = 50;
let captures = [];
try {
  const raw = document.getElementById('captureData').textContent.trim();
  captures = raw ? JSON.parse(raw) : [];
} catch (e) {
  document.getElementById('cards').innerHTML =
    '<p class="small">Failed to load capture data: ' + e.message + '</p>';
}
let filtered = captures.slice();
let currentPage = 0;
let totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));

function escapeHtml(s) {
  if (s == null) return '';
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

function statusClass(c) {
  if (c.error) return 'err';
  if (c.response_status >= 200 && c.response_status < 400) return 'pass';
  if (c.response_status >= 400 && c.response_status < 600) return 'fail';
  return 'info';
}

function renderKv(title, kv) {
  const keys = kv ? Object.keys(kv).sort() : [];
  if (keys.length === 0) {
    return '<h3>' + escapeHtml(title) + '</h3><p class="small">(none)</p>';
  }
  let html = '<h3>' + escapeHtml(title) + '</h3><table class="kv"><tbody>';
  for (const k of keys) {
    html += '<tr><td><code>' + escapeHtml(k) + '</code></td><td><code>' +
            escapeHtml(kv[k]) + '</code></td></tr>';
  }
  html += '</tbody></table>';
  return html;
}

function renderBody(title, body, truncated) {
  if (body == null) return '';
  const suffix = truncated ? ' <span class="small">(truncated at 16 KiB)</span>' : '';
  return '<h3>' + escapeHtml(title) + suffix + '</h3><pre>' + escapeHtml(body) + '</pre>';
}

// Round 28 (Srikanth) — true when the probe's actual status didn't
// match its expected range. Used by the "only show mismatches" filter
// AND by the summary badge so users can spot misses at a glance.
function isMismatch(c) {
  const expected = c.expected_status_range || '';
  const s = c.response_status;
  if (c.error) return true;
  if (expected === '4xx') return !(s >= 400 && s < 500);
  if (expected === '2xx-3xx') return !(s >= 200 && s < 400);
  return false;
}

function renderCard(c) {
  const cls = statusClass(c);
  const statusText = c.error ? 'ERR' : String(c.response_status);
  let html = '<details class="card">';
  html += '<summary>';
  html += '<span class="badge ' + cls + '">' + escapeHtml(statusText) + '</span> ';
  // Round 28 — show the expected range alongside the actual status so
  // a reader knows what the probe wanted to see without expanding the
  // card.
  if (c.expected_status_range) {
    const matchCls = isMismatch(c) ? 'fail' : 'pass';
    html += '<span class="badge ' + matchCls + '" title="expected status range">exp ' +
            escapeHtml(c.expected_status_range) + '</span> ';
  }
  html += '<code class="method">' + escapeHtml(c.method) + '</code> ';
  html += '<span class="label">' + escapeHtml(c.label) + '</span> ';
  html += '<code class="url">' + escapeHtml(c.url) + '</code>';
  html += '</summary><div class="body">';
  html += renderKv('Request headers', c.request_headers);
  html += renderBody('Request body', c.request_body, c.request_body_truncated);
  html += renderKv('Response headers', c.response_headers);
  html += renderBody('Response body', c.response_body, c.response_body_truncated);
  if (c.error) {
    html += '<h3>Transport error</h3><pre class="err">' + escapeHtml(c.error) + '</pre>';
  }
  if (c.response_schema_error) {
    html += '<h3>Response schema mismatch</h3><pre class="err">' +
            escapeHtml(c.response_schema_error) + '</pre>';
  }
  html += '</div></details>';
  return html;
}

function renderPage() {
  totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
  if (currentPage >= totalPages) currentPage = totalPages - 1;
  if (currentPage < 0) currentPage = 0;
  const start = currentPage * PAGE_SIZE;
  const end = Math.min(start + PAGE_SIZE, filtered.length);
  const slice = filtered.slice(start, end);
  document.getElementById('cards').innerHTML = slice.map(renderCard).join('');
  document.getElementById('pageNum').textContent =
    'Page ' + (currentPage + 1) + ' / ' + totalPages +
    ' (' + (filtered.length === 0 ? 0 : (start + 1)) + '-' + end +
    ' of ' + filtered.length + ' filtered)';
  document.getElementById('firstPage').disabled = currentPage === 0;
  document.getElementById('prevPage').disabled = currentPage === 0;
  document.getElementById('nextPage').disabled = currentPage >= totalPages - 1;
  document.getElementById('lastPage').disabled = currentPage >= totalPages - 1;
  document.getElementById('filterStatus').textContent =
    filtered.length === captures.length ? '' :
    '(' + filtered.length + ' of ' + captures.length + ' shown)';
  document.getElementById('jumpPage').max = totalPages;
  document.getElementById('jumpPage').value = currentPage + 1;
  window.scrollTo({ top: 0, behavior: 'auto' });
}

function gotoPage(p) {
  if (p < 0 || p >= totalPages) return;
  currentPage = p;
  renderPage();
}

function jumpToPage() {
  const v = parseInt(document.getElementById('jumpPage').value, 10);
  if (!isNaN(v)) gotoPage(v - 1);
}

let _filterTimer = null;
function scheduleFilter() {
  if (_filterTimer) clearTimeout(_filterTimer);
  _filterTimer = setTimeout(applyFilter, 200);
}

function applyFilter() {
  const q = document.getElementById('q').value.trim().toLowerCase();
  const showPass = document.getElementById('showPass').checked;
  const showFail = document.getElementById('showFail').checked;
  const showErr = document.getElementById('showErr').checked;
  const onlyMismatches = document.getElementById('onlyMismatches').checked;
  filtered = captures.filter(function(c) {
    // Round 28 — the mismatch filter runs FIRST so it composes
    // naturally with the status checkboxes (e.g. "mismatches that
    // are 4xx-5xx").
    if (onlyMismatches && !isMismatch(c)) return false;
    const cls = statusClass(c);
    const statusOk = (cls === 'pass' && showPass) ||
                     (cls === 'fail' && showFail) ||
                     (cls === 'err' && showErr) ||
                     (cls === 'info' && (showPass || showFail));
    if (!statusOk) return false;
    if (!q) return true;
    const hay = ((c.label || '') + ' ' + (c.method || '') + ' ' +
                 (c.url || '') + ' ' + (c.response_status || '')).toLowerCase();
    return hay.indexOf(q) !== -1;
  });
  currentPage = 0;
  renderPage();
}

// Initial render once the page loads.
renderPage();
</script>
</body>
</html>
"#;

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

    fn sample() -> Vec<CaseCapture> {
        let mut req_h = BTreeMap::new();
        req_h.insert("X-Forwarded-For".to_string(), "203.0.113.0".to_string());
        let mut resp_h = BTreeMap::new();
        resp_h.insert("content-type".to_string(), "application/json".to_string());
        vec![
            CaseCapture {
                label: "positive".to_string(),
                method: "GET".to_string(),
                url: "http://target/users".to_string(),
                request_headers: req_h.clone(),
                request_body: None,
                request_body_truncated: false,
                response_status: 200,
                response_headers: resp_h.clone(),
                response_body: Some("{\"ok\":true}".to_string()),
                response_body_truncated: false,
                error: None,
                response_schema_error: None,
                expected_status_range: "2xx-3xx".to_string(),
                path_template: String::new(),
                spec_label: None,
            },
            CaseCapture {
                label: "owasp:sqli".to_string(),
                method: "GET".to_string(),
                url: "http://target/users?id=' OR 1=1".to_string(),
                request_headers: BTreeMap::new(),
                request_body: None,
                request_body_truncated: false,
                response_status: 500,
                response_headers: resp_h,
                response_body: Some("[]".to_string()),
                response_body_truncated: false,
                error: None,
                response_schema_error: None,
                expected_status_range: "2xx-3xx".to_string(),
                path_template: String::new(),
                spec_label: None,
            },
        ]
    }

    #[test]
    fn embeds_all_probes_as_json() {
        let html = render_capture_html(&sample());
        // The JSON data script holds the full capture.
        assert!(html.contains("id=\"captureData\""));
        // Both labels are present in the JSON payload.
        assert!(html.contains("\"positive\""));
        assert!(html.contains("\"owasp:sqli\""));
    }

    /// Round 27 — Srikanth's d3 follow-up was that the round-25
    /// 1000-card cap hid 4xx/5xx probes past the cap. The fix is
    /// pagination over the full capture. A capture WAY past the
    /// old cap must still appear in the embedded data.
    #[test]
    fn no_silent_cap_past_one_thousand() {
        let mut entries = Vec::with_capacity(1500);
        for i in 0..1500 {
            entries.push(CaseCapture {
                label: format!("probe-{i}"),
                method: "GET".into(),
                url: format!("/path/{i}"),
                request_headers: BTreeMap::new(),
                request_body: None,
                request_body_truncated: false,
                response_status: if i % 4 == 0 { 500 } else { 200 },
                response_headers: BTreeMap::new(),
                response_body: None,
                response_body_truncated: false,
                error: None,
                response_schema_error: None,
                expected_status_range: "2xx-3xx".to_string(),
                path_template: String::new(),
                spec_label: None,
            });
        }
        let html = render_capture_html(&entries);
        // Probe past the old 1000 cap is still embedded.
        assert!(html.contains("\"probe-1234\""));
        assert!(html.contains("\"probe-1499\""));
        // Per-card cap text is gone (we paginate instead).
        assert!(!html.contains("Showing first 1000 of"));
    }

    #[test]
    fn pagination_controls_present() {
        let html = render_capture_html(&sample());
        for id in [
            "firstPage",
            "prevPage",
            "nextPage",
            "lastPage",
            "jumpPage",
            "pageNum",
        ] {
            assert!(html.contains(id), "missing pagination control: {id}");
        }
        assert!(html.contains("PAGE_SIZE = 50"));
    }

    #[test]
    fn empty_capture_still_produces_valid_html() {
        let html = render_capture_html(&[]);
        assert!(html.starts_with("<!doctype html>"));
        assert!(html.contains("0 probe(s)"));
        assert!(html.contains("</html>"));
    }

    #[test]
    fn embedded_json_breaks_inline_script_terminators() {
        // A payload body containing `</script>` could break out of
        // the inline data <script> if we didn't escape it. The
        // serialiser converts `</` to `<\/` so the JSON parser
        // accepts it but no DOM parser sees a closing tag.
        let entry = CaseCapture {
            label: "label".into(),
            method: "GET".into(),
            url: "/x".into(),
            request_headers: BTreeMap::new(),
            request_body: None,
            request_body_truncated: false,
            response_status: 200,
            response_headers: BTreeMap::new(),
            response_body: Some("<script>alert(1)</script>".into()),
            response_body_truncated: false,
            error: None,
            response_schema_error: None,
            expected_status_range: "2xx-3xx".to_string(),
            path_template: String::new(),
            spec_label: None,
        };
        let html = render_capture_html(&[entry]);
        assert!(
            !html.contains("</script>alert(1)</script>"),
            "raw `</script>` snuck into the embedded data"
        );
        assert!(html.contains("<\\/script>"), "missing escaped form");
    }
}
