"""Interactive HTML Report Generator for MCP Security Audit.

Generates a self-contained HTML report with:
- JavaScript-powered filtering by severity (CRITICAL/HIGH/MEDIUM/LOW/INFO)
- Filtering by OWASP Agentic Top 10 category
- Full-text search across all findings
- Sortable columns (severity, file, rule)
- Collapsible code snippets with context
- Dark theme consistent with other mcpaudit reports
- Zero external dependencies (all JS/CSS inline)

CLI: mcpaudit scan --interactive-report output.html

Zero external dependencies. Part of mcpaudit v2.1.0.
"""

from __future__ import annotations

import html
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_SEVERITY_COLORS: dict[str, str] = {
    "CRITICAL": "#dc2626",
    "HIGH": "#f97316",
    "MEDIUM": "#eab308",
    "LOW": "#22c55e",
    "INFO": "#6b7280",
}

_SEVERITY_ORDER: dict[str, int] = {
    "CRITICAL": 0,
    "HIGH": 1,
    "MEDIUM": 2,
    "LOW": 3,
    "INFO": 4,
}

_OWASP_CATEGORIES: dict[str, str] = {
    "CMD": "A02 — Insecure Tool Implementation",
    "SQL": "A02 — Insecure Tool Implementation",
    "PATH": "A02 — Insecure Tool Implementation",
    "DESER": "A02 — Insecure Tool Implementation",
    "MCP": "A08 — Data Exfiltration / A10 — Insecure Config",
    "AUTH": "A05 — Insufficient Access Control",
    "AUTHZ": "A05 — Insufficient Access Control",
    "VAL": "A06 — Improper Input Validation",
    "IDOR": "A06 — Improper Input Validation",
    "SSRF": "A07 — SSRF",
    "SEC": "A08 — Data Exfiltration",
    "FILE": "A08 — Data Exfiltration",
    "LOG": "A08 — Data Exfiltration",
    "RATE": "A09 — Denial of Service",
    "RES": "A09 — Denial of Service",
    "REDOS": "A09 — Denial of Service",
    "TEMP": "A10 — Insecure Configuration",
    "CORS": "A10 — Insecure Configuration",
    "PERM": "A03 — Excessive Agency",
    "PRIV": "A03 — Excessive Agency",
    "INFO": "A04 — Insecure Output Handling",
    "ERR": "A04 — Insecure Output Handling",
}


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------

@dataclass
class InteractiveReportConfig:
    """Configuration for the interactive report."""

    title: str = "MCP Security Audit — Interactive Report"
    show_summary: bool = True
    show_owasp_filter: bool = True
    show_search: bool = True
    timestamp: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "title": self.title,
            "show_summary": self.show_summary,
            "show_owasp_filter": self.show_owasp_filter,
            "show_search": self.show_search,
        }


@dataclass
class ReportFinding:
    """A finding prepared for the interactive report."""

    rule_id: str
    severity: str
    title: str
    description: str
    file: str
    line: int
    code_snippet: str = ""
    recommendation: str = ""
    cwe: str = ""
    owasp_category: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "rule_id": self.rule_id,
            "severity": self.severity,
            "title": self.title,
            "description": self.description,
            "file": self.file,
            "line": self.line,
            "code_snippet": self.code_snippet,
            "recommendation": self.recommendation,
            "cwe": self.cwe,
            "owasp_category": self.owasp_category,
        }


@dataclass
class ReportMetadata:
    """Metadata for the scan report."""

    total_files: int = 0
    total_lines: int = 0
    total_findings: int = 0
    scan_time_ms: float = 0.0
    grade: str = "A+"
    trust_score: int = 100
    severity_counts: dict[str, int] = field(default_factory=dict)
    timestamp: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "total_files": self.total_files,
            "total_lines": self.total_lines,
            "total_findings": self.total_findings,
            "scan_time_ms": round(self.scan_time_ms, 2),
            "grade": self.grade,
            "trust_score": self.trust_score,
            "severity_counts": dict(self.severity_counts),
            "timestamp": self.timestamp,
        }


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _get_owasp_category(rule_id: str) -> str:
    """Extract OWASP category from rule ID prefix."""
    prefix = rule_id.rsplit("-", 1)[0] if "-" in rule_id else rule_id
    return _OWASP_CATEGORIES.get(prefix, "Uncategorized")


def _escape(text: str) -> str:
    """HTML-escape text safely."""
    return html.escape(str(text), quote=True)


def _severity_badge_color(severity: str) -> str:
    """Get the CSS color for a severity badge."""
    return _SEVERITY_COLORS.get(severity, "#6b7280")


def _severity_sort_key(severity: str) -> int:
    """Get numeric sort key for severity ordering."""
    return _SEVERITY_ORDER.get(severity, 99)


# ---------------------------------------------------------------------------
# Finding extraction
# ---------------------------------------------------------------------------

def extract_findings(data: dict[str, Any]) -> list[ReportFinding]:
    """Extract findings from scan data into ReportFinding objects.

    Supports multiple scan output formats:
    - Standard: {"results": [{"findings": [...]}]}
    - Flat: {"findings": [...]}

    Args:
        data: Scan result dict.

    Returns:
        List of ReportFinding objects with OWASP category.
    """
    raw_findings: list[dict[str, Any]] = []

    if "results" in data:
        for r in data["results"]:
            if isinstance(r, dict) and "findings" in r:
                raw_findings.extend(r["findings"])
    elif "findings" in data and isinstance(data["findings"], list):
        raw_findings = data["findings"]

    result: list[ReportFinding] = []
    for f in raw_findings:
        rule_id = f.get("rule_id", "UNKNOWN")
        result.append(ReportFinding(
            rule_id=rule_id,
            severity=f.get("severity", "INFO"),
            title=f.get("title", ""),
            description=f.get("description", ""),
            file=f.get("file", ""),
            line=f.get("line", 0),
            code_snippet=f.get("code_snippet", ""),
            recommendation=f.get("recommendation", ""),
            cwe=f.get("cwe", ""),
            owasp_category=_get_owasp_category(rule_id),
        ))

    return result


def extract_metadata(data: dict[str, Any]) -> ReportMetadata:
    """Extract metadata from scan data.

    Args:
        data: Scan result dict.

    Returns:
        ReportMetadata with aggregated info.
    """
    summary = data.get("summary", {})
    trust = summary.get("trust_score", {})

    score = 100
    grade = "A+"
    if isinstance(trust, dict):
        score = trust.get("score", 100)
        grade = trust.get("grade", "A+")
    elif isinstance(trust, (int, float)):
        score = int(trust)

    sev_counts = summary.get("severity_counts", {})
    if not sev_counts:
        findings = extract_findings(data)
        for f in findings:
            sev_counts[f.severity] = sev_counts.get(f.severity, 0) + 1

    return ReportMetadata(
        total_files=summary.get("files_scanned", 0),
        total_lines=summary.get("lines_scanned", 0),
        total_findings=summary.get("total_findings", 0),
        scan_time_ms=summary.get("scan_time_ms", 0.0),
        grade=summary.get("overall_grade", grade),
        trust_score=score,
        severity_counts=sev_counts,
    )


# ---------------------------------------------------------------------------
# HTML generation (CSS)
# ---------------------------------------------------------------------------

def _generate_css() -> str:
    """Generate all inline CSS for the interactive report."""
    return """
    :root {
      --bg-primary: #0f172a;
      --bg-secondary: #1e293b;
      --bg-card: #1e293b;
      --bg-hover: #334155;
      --text-primary: #e2e8f0;
      --text-secondary: #94a3b8;
      --border-color: #334155;
      --accent: #3b82f6;
      --critical: #dc2626;
      --high: #f97316;
      --medium: #eab308;
      --low: #22c55e;
      --info: #6b7280;
    }
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
                   Roboto, sans-serif;
      background: var(--bg-primary);
      color: var(--text-primary);
      line-height: 1.6;
    }
    .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
    h1 { font-size: 24px; margin-bottom: 8px; }
    h2 { font-size: 18px; margin: 24px 0 12px; color: var(--accent); }
    .subtitle { color: var(--text-secondary); font-size: 14px; }
    .summary-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
      gap: 16px; margin: 16px 0;
    }
    .summary-card {
      background: var(--bg-card);
      border: 1px solid var(--border-color);
      border-radius: 8px; padding: 16px; text-align: center;
    }
    .summary-card .value {
      font-size: 28px; font-weight: 700; color: var(--accent);
    }
    .summary-card .label {
      font-size: 12px; color: var(--text-secondary);
      text-transform: uppercase;
    }
    .controls {
      display: flex; flex-wrap: wrap; gap: 12px;
      margin: 16px 0; align-items: center;
    }
    .controls input[type="text"] {
      background: var(--bg-secondary);
      border: 1px solid var(--border-color);
      color: var(--text-primary); padding: 8px 12px;
      border-radius: 6px; font-size: 14px; min-width: 250px;
    }
    .controls input[type="text"]::placeholder {
      color: var(--text-secondary);
    }
    .filter-btn {
      background: var(--bg-secondary);
      border: 1px solid var(--border-color);
      color: var(--text-secondary); padding: 6px 14px;
      border-radius: 16px; cursor: pointer; font-size: 13px;
      transition: all 0.2s;
    }
    .filter-btn:hover { background: var(--bg-hover); }
    .filter-btn.active {
      border-color: var(--accent);
      color: var(--text-primary); background: var(--bg-hover);
    }
    .filter-btn.sev-CRITICAL.active { border-color: var(--critical); }
    .filter-btn.sev-HIGH.active { border-color: var(--high); }
    .filter-btn.sev-MEDIUM.active { border-color: var(--medium); }
    .filter-btn.sev-LOW.active { border-color: var(--low); }
    .filter-btn.sev-INFO.active { border-color: var(--info); }
    .sort-btn {
      background: var(--bg-secondary);
      border: 1px solid var(--border-color);
      color: var(--text-secondary); padding: 6px 14px;
      border-radius: 6px; cursor: pointer; font-size: 13px;
    }
    .sort-btn:hover { color: var(--text-primary); }
    .sort-btn.active { color: var(--accent); border-color: var(--accent); }
    select {
      background: var(--bg-secondary);
      border: 1px solid var(--border-color);
      color: var(--text-primary); padding: 8px 12px;
      border-radius: 6px; font-size: 14px;
    }
    .findings-count {
      color: var(--text-secondary); font-size: 13px;
      margin: 8px 0;
    }
    .finding-card {
      background: var(--bg-card);
      border: 1px solid var(--border-color);
      border-radius: 8px; margin: 8px 0; overflow: hidden;
    }
    .finding-header {
      display: flex; align-items: center; gap: 12px;
      padding: 12px 16px; cursor: pointer;
    }
    .finding-header:hover { background: var(--bg-hover); }
    .sev-badge {
      padding: 2px 10px; border-radius: 12px;
      font-size: 11px; font-weight: 700; color: #fff;
      text-transform: uppercase; white-space: nowrap;
    }
    .sev-CRITICAL { background: var(--critical); }
    .sev-HIGH { background: var(--high); }
    .sev-MEDIUM { background: var(--medium); }
    .sev-LOW { background: var(--low); }
    .sev-INFO { background: var(--info); }
    .finding-title { flex: 1; font-weight: 500; }
    .finding-rule {
      color: var(--text-secondary); font-size: 12px;
      font-family: monospace;
    }
    .finding-file {
      color: var(--text-secondary); font-size: 12px;
    }
    .finding-details {
      display: none; padding: 0 16px 16px;
      border-top: 1px solid var(--border-color);
    }
    .finding-details.open { display: block; }
    .finding-details p { margin: 8px 0; font-size: 14px; }
    .finding-details .label-inline {
      color: var(--accent); font-weight: 600;
    }
    .code-block {
      background: #0d1117; border: 1px solid var(--border-color);
      border-radius: 6px; padding: 12px; margin: 8px 0;
      font-family: 'SF Mono', 'Fira Code', monospace;
      font-size: 13px; overflow-x: auto; white-space: pre-wrap;
      color: #c9d1d9;
    }
    .footer {
      text-align: center; padding: 24px; margin-top: 32px;
      color: var(--text-secondary); font-size: 12px;
      border-top: 1px solid var(--border-color);
    }
    """


# ---------------------------------------------------------------------------
# HTML generation (JavaScript)
# ---------------------------------------------------------------------------

def _generate_js() -> str:
    """Generate all inline JavaScript for interactivity."""
    return """
    (function() {
      'use strict';

      var activeSeverities = new Set(['CRITICAL','HIGH','MEDIUM','LOW','INFO']);
      var activeCategory = 'all';
      var searchText = '';
      var sortField = 'severity';
      var sortAsc = true;

      function init() {
        document.querySelectorAll('.sev-filter').forEach(function(btn) {
          btn.addEventListener('click', function() {
            var sev = this.getAttribute('data-severity');
            if (activeSeverities.has(sev)) {
              activeSeverities.delete(sev);
              this.classList.remove('active');
            } else {
              activeSeverities.add(sev);
              this.classList.add('active');
            }
            applyFilters();
          });
        });

        var catSelect = document.getElementById('owasp-filter');
        if (catSelect) {
          catSelect.addEventListener('change', function() {
            activeCategory = this.value;
            applyFilters();
          });
        }

        var searchInput = document.getElementById('search-input');
        if (searchInput) {
          searchInput.addEventListener('input', function() {
            searchText = this.value.toLowerCase();
            applyFilters();
          });
        }

        document.querySelectorAll('.sort-btn').forEach(function(btn) {
          btn.addEventListener('click', function() {
            var field = this.getAttribute('data-sort');
            if (sortField === field) {
              sortAsc = !sortAsc;
            } else {
              sortField = field;
              sortAsc = true;
            }
            document.querySelectorAll('.sort-btn').forEach(function(b) {
              b.classList.remove('active');
            });
            this.classList.add('active');
            sortFindings();
          });
        });

        document.querySelectorAll('.finding-header').forEach(function(hdr) {
          hdr.addEventListener('click', function() {
            var details = this.nextElementSibling;
            if (details) details.classList.toggle('open');
          });
        });
      }

      function applyFilters() {
        var cards = document.querySelectorAll('.finding-card');
        var visible = 0;
        cards.forEach(function(card) {
          var sev = card.getAttribute('data-severity');
          var cat = card.getAttribute('data-owasp') || '';
          var text = (card.textContent || '').toLowerCase();

          var show = true;
          if (!activeSeverities.has(sev)) show = false;
          if (activeCategory !== 'all' && cat !== activeCategory) show = false;
          if (searchText && text.indexOf(searchText) === -1) show = false;

          card.style.display = show ? '' : 'none';
          if (show) visible++;
        });
        var counter = document.getElementById('findings-count');
        if (counter) {
          counter.textContent = 'Showing ' + visible + ' of '
            + cards.length + ' findings';
        }
      }

      var sevOrder = {CRITICAL:0, HIGH:1, MEDIUM:2, LOW:3, INFO:4};

      function sortFindings() {
        var container = document.getElementById('findings-list');
        if (!container) return;
        var cards = Array.from(container.querySelectorAll('.finding-card'));
        cards.sort(function(a, b) {
          var va, vb;
          if (sortField === 'severity') {
            va = sevOrder[a.getAttribute('data-severity')] || 99;
            vb = sevOrder[b.getAttribute('data-severity')] || 99;
          } else if (sortField === 'file') {
            va = (a.getAttribute('data-file') || '').toLowerCase();
            vb = (b.getAttribute('data-file') || '').toLowerCase();
          } else if (sortField === 'rule') {
            va = (a.getAttribute('data-rule') || '').toLowerCase();
            vb = (b.getAttribute('data-rule') || '').toLowerCase();
          } else {
            va = 0; vb = 0;
          }
          if (va < vb) return sortAsc ? -1 : 1;
          if (va > vb) return sortAsc ? 1 : -1;
          return 0;
        });
        cards.forEach(function(c) { container.appendChild(c); });
      }

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
      } else {
        init();
      }
    })();
    """


# ---------------------------------------------------------------------------
# HTML generation (body)
# ---------------------------------------------------------------------------

def _render_summary_section(metadata: ReportMetadata) -> str:
    """Render the summary statistics section."""
    grade_color = {
        "A+": "#22c55e", "A": "#4ade80", "B": "#eab308",
        "C": "#f97316", "D": "#ef4444", "F": "#dc2626",
    }.get(metadata.grade, "#6b7280")

    cards = [
        (metadata.grade, "Grade", grade_color),
        (str(metadata.trust_score), "Trust Score", "#3b82f6"),
        (str(metadata.total_findings), "Findings", "#f97316"),
        (str(metadata.total_files), "Files", "#6b7280"),
    ]
    parts = []
    for val, label, color in cards:
        parts.append(
            f'<div class="summary-card">'
            f'<div class="value" style="color:{_escape(color)}">'
            f'{_escape(val)}</div>'
            f'<div class="label">{_escape(label)}</div></div>'
        )
    return '<div class="summary-grid">' + "".join(parts) + "</div>"


def _render_severity_bar(metadata: ReportMetadata) -> str:
    """Render severity distribution bar."""
    total = max(metadata.total_findings, 1)
    parts = []
    for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]:
        count = metadata.severity_counts.get(sev, 0)
        if count == 0:
            continue
        pct = round(count / total * 100, 1)
        color = _SEVERITY_COLORS.get(sev, "#6b7280")
        parts.append(
            f'<div style="width:{pct}%;background:{color};'
            f'height:8px;border-radius:4px" '
            f'title="{sev}: {count}"></div>'
        )
    if not parts:
        return ""
    bar = '<div style="display:flex;gap:2px;margin:12px 0">'
    bar += "".join(parts) + "</div>"
    return bar


def _render_controls(findings: list[ReportFinding]) -> str:
    """Render filter/sort/search controls."""
    owasp_cats: set[str] = set()
    for f in findings:
        if f.owasp_category and f.owasp_category != "Uncategorized":
            owasp_cats.add(f.owasp_category)

    sev_buttons = []
    for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]:
        sev_buttons.append(
            f'<button class="filter-btn sev-filter sev-{sev} active" '
            f'data-severity="{sev}">{sev}</button>'
        )

    owasp_options = ['<option value="all">All categories</option>']
    for cat in sorted(owasp_cats):
        owasp_options.append(
            f'<option value="{_escape(cat)}">{_escape(cat)}</option>'
        )

    sort_buttons = (
        '<button class="sort-btn active" data-sort="severity">'
        'Sort: Severity</button>'
        '<button class="sort-btn" data-sort="file">Sort: File</button>'
        '<button class="sort-btn" data-sort="rule">Sort: Rule</button>'
    )

    return (
        '<div class="controls">'
        '<input type="text" id="search-input" '
        'placeholder="Search findings...">'
        + "".join(sev_buttons)
        + f'<select id="owasp-filter">{"".join(owasp_options)}</select>'
        + sort_buttons
        + "</div>"
    )


def _render_finding_card(finding: ReportFinding) -> str:
    """Render a single finding as a collapsible card."""
    sev = _escape(finding.severity)
    rule = _escape(finding.rule_id)
    title = _escape(finding.title)
    file_loc = _escape(f"{finding.file}:{finding.line}")
    owasp = _escape(finding.owasp_category)

    header = (
        f'<div class="finding-header">'
        f'<span class="sev-badge sev-{sev}">{sev}</span>'
        f'<span class="finding-title">{title}</span>'
        f'<span class="finding-rule">{rule}</span>'
        f'<span class="finding-file">{file_loc}</span>'
        f'</div>'
    )

    detail_parts = []
    if finding.description:
        detail_parts.append(
            f'<p><span class="label-inline">Description:</span> '
            f'{_escape(finding.description)}</p>'
        )
    if finding.recommendation:
        detail_parts.append(
            f'<p><span class="label-inline">Recommendation:</span> '
            f'{_escape(finding.recommendation)}</p>'
        )
    if finding.cwe:
        detail_parts.append(
            f'<p><span class="label-inline">CWE:</span> '
            f'{_escape(finding.cwe)}</p>'
        )
    if finding.owasp_category:
        detail_parts.append(
            f'<p><span class="label-inline">OWASP:</span> {owasp}</p>'
        )
    if finding.code_snippet:
        detail_parts.append(
            f'<div class="code-block">'
            f'{_escape(finding.code_snippet)}</div>'
        )

    details = (
        '<div class="finding-details">' + "".join(detail_parts) + "</div>"
    )

    return (
        f'<div class="finding-card" '
        f'data-severity="{sev}" data-file="{_escape(finding.file)}" '
        f'data-rule="{rule}" data-owasp="{owasp}">'
        f'{header}{details}</div>'
    )


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def generate_interactive_report(
    findings: list[dict[str, Any]],
    metadata: dict[str, Any] | None = None,
    config: InteractiveReportConfig | None = None,
) -> str:
    """Generate a self-contained interactive HTML report.

    Args:
        findings: List of finding dicts (as produced by scanner).
        metadata: Optional metadata dict with summary info.
        config: Optional report configuration.

    Returns:
        Complete HTML string with inline CSS/JS.
    """
    cfg = config or InteractiveReportConfig()
    ts = cfg.timestamp or datetime.now(
        timezone.utc
    ).strftime("%Y-%m-%d %H:%M UTC")

    report_findings = _convert_findings(findings)
    report_meta = _build_metadata(findings, metadata)
    report_meta.timestamp = ts

    return _assemble_html(report_findings, report_meta, cfg)


def generate_interactive_report_from_scan(
    scan_data: dict[str, Any],
    config: InteractiveReportConfig | None = None,
) -> str:
    """Generate interactive report from raw scan output dict.

    Args:
        scan_data: Full scan result dict.
        config: Optional report configuration.

    Returns:
        Complete HTML string.
    """
    cfg = config or InteractiveReportConfig()
    ts = cfg.timestamp or datetime.now(
        timezone.utc
    ).strftime("%Y-%m-%d %H:%M UTC")

    report_findings = extract_findings(scan_data)
    report_meta = extract_metadata(scan_data)
    report_meta.timestamp = ts

    return _assemble_html(report_findings, report_meta, cfg)


def format_interactive_json(
    findings: list[dict[str, Any]],
    metadata: dict[str, Any] | None = None,
) -> str:
    """Export interactive report data as JSON.

    Args:
        findings: List of finding dicts.
        metadata: Optional metadata dict.

    Returns:
        JSON string.
    """
    report_findings = _convert_findings(findings)
    report_meta = _build_metadata(findings, metadata)
    data = {
        "metadata": report_meta.to_dict(),
        "findings": [f.to_dict() for f in report_findings],
    }
    return json.dumps(data, indent=2, ensure_ascii=False)


# ---------------------------------------------------------------------------
# Internal assembly
# ---------------------------------------------------------------------------

def _convert_findings(
    findings: list[dict[str, Any]],
) -> list[ReportFinding]:
    """Convert raw finding dicts to ReportFinding objects."""
    result = []
    for f in findings:
        rule_id = f.get("rule_id", "UNKNOWN")
        result.append(ReportFinding(
            rule_id=rule_id,
            severity=f.get("severity", "INFO"),
            title=f.get("title", ""),
            description=f.get("description", ""),
            file=f.get("file", ""),
            line=f.get("line", 0),
            code_snippet=f.get("code_snippet", ""),
            recommendation=f.get("recommendation", ""),
            cwe=f.get("cwe", ""),
            owasp_category=_get_owasp_category(rule_id),
        ))
    return result


def _build_metadata(
    findings: list[dict[str, Any]],
    metadata: dict[str, Any] | None,
) -> ReportMetadata:
    """Build ReportMetadata from raw data."""
    meta = metadata or {}
    sev_counts: dict[str, int] = {}
    for f in findings:
        sev = f.get("severity", "INFO")
        sev_counts[sev] = sev_counts.get(sev, 0) + 1

    return ReportMetadata(
        total_files=meta.get("total_files", meta.get("files_scanned", 0)),
        total_lines=meta.get("total_lines", meta.get("lines_scanned", 0)),
        total_findings=meta.get("total_findings", len(findings)),
        scan_time_ms=meta.get("scan_time_ms", 0.0),
        grade=meta.get("grade", meta.get("overall_grade", "A+")),
        trust_score=meta.get("trust_score", 100),
        severity_counts=sev_counts,
    )


def _assemble_html(
    findings: list[ReportFinding],
    metadata: ReportMetadata,
    config: InteractiveReportConfig,
) -> str:
    """Assemble the complete HTML document."""
    # Sort findings by severity by default
    sorted_findings = sorted(
        findings,
        key=lambda f: _severity_sort_key(f.severity),
    )

    cards = "".join(_render_finding_card(f) for f in sorted_findings)
    total = len(findings)

    body_parts = [
        f'<h1>{_escape(config.title)}</h1>',
        f'<p class="subtitle">Generated {_escape(metadata.timestamp)}</p>',
    ]

    if config.show_summary:
        body_parts.append(_render_summary_section(metadata))
        body_parts.append(_render_severity_bar(metadata))

    body_parts.append(_render_controls(sorted_findings))
    body_parts.append(
        f'<p class="findings-count" id="findings-count">'
        f'Showing {total} of {total} findings</p>'
    )
    body_parts.append(f'<div id="findings-list">{cards}</div>')
    body_parts.append(
        '<div class="footer">Generated by mcpaudit '
        '— MCP Security Audit Scanner</div>'
    )

    return (
        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
        "<meta charset=\"UTF-8\">\n"
        "<meta name=\"viewport\" "
        "content=\"width=device-width, initial-scale=1.0\">\n"
        f"<title>{_escape(config.title)}</title>\n"
        f"<style>{_generate_css()}</style>\n"
        "</head>\n<body>\n"
        f'<div class="container">{"".join(body_parts)}</div>\n'
        f"<script>{_generate_js()}</script>\n"
        "</body>\n</html>"
    )
