"""Rich and markdown reporting helpers."""

from __future__ import annotations

import json
from collections import Counter, defaultdict
from html import escape
from pathlib import Path

from rich.panel import Panel
from rich.table import Table

from sentra.console import console
from sentra.models.artifacts import (
    CorrelationArtifact,
    FirmwareEvidence,
    NetworkScanArtifact,
    ReportArtifact,
)


def build_report(
    network_scan: NetworkScanArtifact | None,
    firmware_scan: FirmwareEvidence | None,
    correlation: CorrelationArtifact | None,
    output_format: str,
) -> tuple[ReportArtifact, str]:
    summary = dict(sorted((correlation.summary if correlation else {}).items()))
    graph_overview = _build_graph_overview(correlation)
    highlights: list[str] = []
    devices = _build_device_summaries(network_scan)
    risks = _build_risk_summaries(network_scan, firmware_scan)
    top_risky_devices = _build_top_risky_devices(devices, risks)
    grouped_findings = _build_grouped_findings(network_scan, firmware_scan)
    weak_paths = _build_weak_paths(correlation)
    recommended_checks = _build_recommended_checks(risks, weak_paths, graph_overview)
    firmware_analysis = _build_firmware_analysis(firmware_scan)
    intelligence_summary = _build_intelligence_summary(network_scan, firmware_scan, risks)

    if network_scan:
        highlights.append(f"Discovered {len(network_scan.devices)} device(s) in target scope {network_scan.target}.")
    if firmware_scan:
        highlights.append(f"Analyzed firmware image {firmware_scan.path} ({firmware_scan.size_bytes} bytes).")
        if firmware_scan.extracted_artifacts:
            highlights.append(f"Recovered {len(firmware_scan.extracted_artifacts)} extracted firmware file(s) for analysis.")
    if correlation:
        highlights.append(
            f"Correlated {correlation.summary.get('findings', 0)} finding node(s) across the evidence graph."
        )
        if correlation.insights.get("correlated_firmware_signals"):
            highlights.append(
                f"Graph links {correlation.insights['correlated_firmware_signals']} firmware-derived signal node(s) into the evidence model."
            )
        top_path = correlation.insights.get("top_weak_path", {})
        if isinstance(top_path, dict) and top_path.get("path"):
            highlights.append(f"Top weak path: {top_path['path']}.")
    if graph_overview["node_kinds"]:
        dominant_kind = graph_overview["node_kinds"][0]["kind"]
        highlights.append(f"Graph density is currently driven by {dominant_kind} nodes and their linked evidence.")

    if risks:
        severity_counts = Counter(risk["severity"] for risk in risks)
        top_severity = max(severity_counts, key=lambda item: _severity_rank(item))
        highlights.append(f"Highest observed risk severity: {top_severity}.")

    if top_risky_devices:
        highlights.append(
            f"Most exposed device: {top_risky_devices[0]['host']} ({top_risky_devices[0]['risk_count']} findings)."
        )

    artifact = ReportArtifact(
        format=output_format,
        summary=summary,
        highlights=highlights,
        devices=devices,
        risks=risks,
        top_risky_devices=top_risky_devices,
        grouped_findings=grouped_findings,
        weak_paths=weak_paths,
        recommended_checks=recommended_checks,
        firmware_analysis=firmware_analysis,
        graph_insights=correlation.insights if correlation else {},
        intelligence_summary=intelligence_summary,
    )
    if output_format == "markdown":
        body = _to_markdown(artifact, graph_overview)
    elif output_format == "html":
        body = _to_html(artifact, graph_overview)
    elif output_format == "json":
        body = json.dumps(
            {
                **artifact.model_dump(mode="json"),
                "graph_overview": graph_overview,
            },
            indent=2,
            sort_keys=True,
        )
    else:
        body = _to_terminal_text(artifact, graph_overview)
    return artifact, body


def render_report(artifact: ReportArtifact) -> None:
    summary_table = Table(title=artifact.title)
    summary_table.add_column("Metric")
    summary_table.add_column("Value", justify="right")
    for key, value in artifact.summary.items():
        summary_table.add_row(key.replace("_", " ").title(), str(value))

    console.print(Panel("\n".join(artifact.highlights) or "No evidence artifacts available yet.", title="Executive Summary"))
    console.print(summary_table)

    if artifact.top_risky_devices:
        risky_table = Table(title="Top Risky Devices")
        risky_table.add_column("Host")
        risky_table.add_column("IP")
        risky_table.add_column("Top Severity")
        risky_table.add_column("Risk Score", justify="right")
        risky_table.add_column("Risk Count", justify="right")
        risky_table.add_column("Why Ranked")
        for device in artifact.top_risky_devices:
            risky_table.add_row(
                str(device["host"]),
                str(device["ip_address"]),
                str(device["top_severity"]),
                str(device["risk_score"]),
                str(device["risk_count"]),
                str(device["risk_explanation"]),
            )
        console.print(risky_table)

    if artifact.grouped_findings:
        findings_table = Table(title="Findings by Host and Service")
        findings_table.add_column("Host")
        findings_table.add_column("Service")
        findings_table.add_column("Findings")
        for group in artifact.grouped_findings:
            for service in group["services"]:
                findings_table.add_row(
                    str(group["host"]),
                    str(service["service"]),
                    ", ".join(str(title) for title in service["findings"]) or "None",
                )
        console.print(findings_table)

    if artifact.weak_paths:
        path_table = Table(title="Weak Paths")
        path_table.add_column("Rank", justify="right")
        path_table.add_column("Severity")
        path_table.add_column("Score", justify="right")
        path_table.add_column("Path")
        path_table.add_column("Why It Matters")
        for path in artifact.weak_paths:
            path_table.add_row(
                str(path["rank"]),
                str(path["severity"]),
                str(path["score"]),
                str(path["path"]),
                str(path["explanation"]),
            )
        console.print(path_table)

    if artifact.recommended_checks:
        console.print(Panel("\n".join(f"- {item}" for item in artifact.recommended_checks), title="Recommended Next Checks"))

    if artifact.risks:
        risk_table = Table(title="Observed Risks")
        risk_table.add_column("Severity")
        risk_table.add_column("Asset")
        risk_table.add_column("Title")
        for risk in artifact.risks:
            risk_table.add_row(str(risk["severity"]), str(risk["asset"]), str(risk["title"]))
        console.print(risk_table)

    if artifact.intelligence_summary:
        intel_lines: list[str] = []
        for item in artifact.intelligence_summary.get("highest_confidence_fingerprints", [])[:3]:
            intel_lines.append(f"Fingerprint: {item['host']} -> {item['match']} ({item['confidence']})")
        for item in artifact.intelligence_summary.get("strongest_vulnerability_correlations", [])[:3]:
            intel_lines.append(f"Vuln: {item['asset']} -> {item['cve_id']} [{item['category']}] ({item['confidence']})")
        for item in artifact.intelligence_summary.get("most_informative_findings", [])[:3]:
            intel_lines.append(f"Finding: {item['asset']} -> {item['title']} ({item['severity']})")
        if intel_lines:
            console.print(Panel("\n".join(intel_lines), title="Intelligence Summary"))


def write_report(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")


def _to_markdown(artifact: ReportArtifact, graph_overview: dict[str, object]) -> str:
    lines = [f"# {artifact.title}", "", "## Executive Summary", ""]
    if artifact.highlights:
        for item in artifact.highlights:
            lines.append(f"- {item}")
    else:
        lines.append("- No evidence artifacts available yet.")

    lines.extend(["", "## Summary", ""])
    for key, value in artifact.summary.items():
        lines.append(f"- {key}: {value}")
    lines.extend(["", "## Highlights", ""])
    for item in artifact.highlights:
        lines.append(f"- {item}")

    lines.extend(["", "## Graph Overview", ""])
    for item in graph_overview["node_kinds"]:
        lines.append(f"- node kind `{item['kind']}`: {item['count']}")
    for item in graph_overview["edge_relations"]:
        lines.append(f"- edge relation `{item['relation']}`: {item['count']}")

    lines.extend(["", "## Top Risky Devices", ""])
    if artifact.top_risky_devices:
        lines.append("| Host | IP | Top Severity | Risk Count |")
        lines.append("| --- | --- | --- | --- |")
        for device in artifact.top_risky_devices:
            lines.append(
                f"| {device['host']} | {device['ip_address']} | {device['top_severity']} | {device['risk_count']} |"
            )
    else:
        lines.append("- No risky devices ranked.")

    lines.extend(["", "## Discovered Assets", ""])
    if artifact.devices:
        lines.append("| Host | IP | Services | Protocols | Risk Score | Why Ranked |")
        lines.append("| --- | --- | --- | --- | --- | --- |")
        for device in artifact.devices:
            lines.append(
                f"| {device['host']} | {device['ip_address']} | {', '.join(device['services']) or 'None'} | {', '.join(device['protocols']) or 'None'} | {device['risk_score']} | {device['risk_explanation']} |"
            )
    else:
        lines.append("- No devices discovered.")

    lines.extend(["", "## Findings by Host and Service", ""])
    if artifact.grouped_findings:
        for group in artifact.grouped_findings:
            lines.append(f"### {group['host']}")
            for service in group["services"]:
                lines.append(f"- **{service['service']}**: {', '.join(service['findings']) or 'None'}")
    else:
        lines.append("- No grouped findings available.")

    lines.extend(["", "## Weak Paths", ""])
    if artifact.weak_paths:
        for path in artifact.weak_paths:
            lines.append(
                f"- `{path['rank']}` {path['severity'].upper()}: {path['path']}. Why it matters: {path['explanation']}"
            )
    else:
        lines.append("- No weak paths identified.")

    lines.extend(["", "## Top Risky Paths", ""])
    if artifact.weak_paths:
        lines.append("| Rank | Severity | Score | Confidence | Path |")
        lines.append("| --- | --- | --- | --- | --- |")
        for path in artifact.weak_paths:
            lines.append(
                f"| {path['rank']} | {path['severity']} | {path['score']} | {path['confidence']} | {path['path']} |"
            )
    else:
        lines.append("- No risky paths ranked.")

    lines.extend(["", "## Firmware Analysis", ""])
    if artifact.firmware_analysis:
        for item in artifact.firmware_analysis:
            lines.append(f"- **{item['label']}**: {item['value']}")
    else:
        lines.append("- No firmware extraction or deep-analysis signals available.")

    lines.extend(["", "## Firmware Evidence", ""])
    if artifact.firmware_analysis:
        for item in artifact.firmware_analysis:
            lines.append(f"- **{item['label']}**: {item['value']}")
    else:
        lines.append("- No firmware evidence summarized.")

    lines.extend(["", "## Recommended Next Checks", ""])
    if artifact.recommended_checks:
        for item in artifact.recommended_checks:
            lines.append(f"- {item}")
    else:
        lines.append("- No immediate follow-up checks suggested.")

    lines.extend(["", "## Intelligence Summary", ""])
    if artifact.intelligence_summary:
        for item in artifact.intelligence_summary.get("highest_confidence_fingerprints", []):
            lines.append(f"- fingerprint `{item['host']}`: {item['match']} ({item['confidence']})")
        for item in artifact.intelligence_summary.get("strongest_vulnerability_correlations", []):
            lines.append(
                f"- vulnerability `{item['asset']}`: {item['cve_id']} [{item['category']}] ({item['severity']}, {item['confidence']})"
            )
        for item in artifact.intelligence_summary.get("most_informative_findings", []):
            lines.append(f"- finding `{item['asset']}`: {item['title']} ({item['severity']})")
    else:
        lines.append("- No intelligence summary available.")

    lines.extend(["", "## Vulnerability Intelligence", ""])
    if artifact.intelligence_summary.get("strongest_vulnerability_correlations"):
        for item in artifact.intelligence_summary["strongest_vulnerability_correlations"]:
            lines.append(
                f"- `{item['asset']}`: {item['cve_id']} [{item['category']}] ({item['severity']}, {item['confidence']})"
            )
    else:
        lines.append("- No vulnerability correlations available.")

    lines.extend(["", "## Devices", ""])
    if artifact.devices:
        lines.append("| Host | IP | Services | Risks |")
        lines.append("| --- | --- | --- | --- |")
        for device in artifact.devices:
            lines.append(
                f"| {device['host']} | {device['ip_address']} | {', '.join(device['services']) or 'None'} | {device['risk_count']} |"
            )
    else:
        lines.append("- No devices discovered.")

    lines.extend(["", "## Risks", ""])
    if artifact.risks:
        for risk in artifact.risks:
            lines.append(
                f"- **{risk['severity'].upper()}** {risk['title']} on {risk['asset']}: {risk['description']}"
            )
    else:
        lines.append("- No risks identified.")
    return "\n".join(lines) + "\n"


def _to_terminal_text(artifact: ReportArtifact, graph_overview: dict[str, object]) -> str:
    lines = [artifact.title]
    lines.extend(f"{key}: {value}" for key, value in artifact.summary.items())
    lines.extend(artifact.highlights)
    lines.append(f"top_risky_devices: {len(artifact.top_risky_devices)}")
    lines.append(f"weak_paths: {len(artifact.weak_paths)}")
    lines.append(f"recommended_checks: {len(artifact.recommended_checks)}")
    lines.append(f"firmware_analysis: {len(artifact.firmware_analysis)}")
    lines.append(f"intelligence_summary: {len(artifact.intelligence_summary)}")
    lines.append(f"graph_node_kinds: {len(graph_overview['node_kinds'])}")
    return "\n".join(lines)


def _to_html(artifact: ReportArtifact, graph_overview: dict[str, object]) -> str:
    graph_shell = _render_graph_svg(graph_overview)
    graph_json = json.dumps(graph_overview["visualization"], indent=2, sort_keys=True)
    summary_cards = "".join(
        "<div class='metric-card'>"
        f"<div class='metric-label'>{escape(str(key).replace('_', ' ').title())}</div>"
        f"<div class='metric-value'>{escape(str(value))}</div>"
        "</div>"
        for key, value in artifact.summary.items()
    )
    executive_cards = "".join(
        "<div class='metric-card metric-card-highlight'>"
        f"<div class='metric-label'>{escape(label)}</div>"
        f"<div class='metric-value'>{value}</div>"
        "</div>"
        for label, value in [
            ("Devices", len(artifact.devices)),
            ("Risks", len(artifact.risks)),
            ("Risky Paths", len(artifact.weak_paths)),
            ("Firmware Signals", len(artifact.firmware_analysis)),
        ]
    )
    highlights = "".join(f"<li>{escape(str(item))}</li>" for item in artifact.highlights) or "<li>No evidence artifacts available yet.</li>"
    checks = "".join(f"<li>{escape(str(item))}</li>" for item in artifact.recommended_checks) or "<li>No immediate follow-up checks suggested.</li>"
    overview_items = "".join(f"<li><strong>{escape(item['kind'])}</strong>: {escape(str(item['count']))}</li>" for item in graph_overview["node_kinds"])
    relation_items = "".join(f"<li><strong>{escape(item['relation'])}</strong>: {escape(str(item['count']))}</li>" for item in graph_overview["edge_relations"])
    asset_rows = "".join(
        "<tr>"
        f"<td>{escape(str(device['host']))}</td><td>{escape(str(device['ip_address']))}</td>"
        f"<td>{escape(', '.join(device['services']) or 'None')}</td>"
        f"<td>{escape(', '.join(device['protocols']) or 'None')}</td>"
        f"<td>{escape(str(device['risk_score']))}</td></tr>"
        for device in artifact.devices
    ) or "<tr><td colspan='5'>No devices discovered.</td></tr>"
    grouped_rows = "".join(
        "<tr>"
        f"<td>{escape(str(group['host']))}</td><td>{escape(str(service['service']))}</td>"
        f"<td>{escape(', '.join(service['findings']) or 'None')}</td></tr>"
        for group in artifact.grouped_findings
        for service in group["services"]
    ) or "<tr><td colspan='3'>No grouped findings available.</td></tr>"
    risky_rows = "".join(
        "<tr>"
        f"<td>{escape(str(device['host']))}</td><td>{escape(str(device['ip_address']))}</td>"
        f"<td><span class='sev sev-{escape(str(device['top_severity']))}'>{escape(str(device['top_severity']).upper())}</span></td>"
        f"<td>{escape(str(device['risk_score']))}</td><td>{escape(str(device['risk_count']))}</td>"
        f"<td>{escape(str(device['risk_explanation']))}</td></tr>"
        for device in artifact.top_risky_devices
    ) or "<tr><td colspan='6'>No risky devices ranked.</td></tr>"
    weak_path_cards = "".join(
        "<article class='path-card'>"
        f"<div class='path-rank'>#{escape(str(path['rank']))}</div>"
        f"<div class='path-sev sev sev-{escape(str(path['severity']))}'>{escape(str(path['severity']).upper())}</div>"
        f"<div class='path-line'>{escape(str(path['path']))}</div>"
        f"<div class='path-meta'>Score {escape(str(path['score']))} · Confidence {escape(str(path['confidence']))}</div>"
        f"<p>{escape(str(path['explanation']))}</p></article>"
        for path in artifact.weak_paths
    ) or "<article class='path-card'><p>No weak paths identified.</p></article>"
    risk_rows = "".join(
        "<tr>"
        f"<td><span class='sev sev-{escape(str(risk['severity']))}'>{escape(str(risk['severity']).upper())}</span></td>"
        f"<td>{escape(str(risk['asset']))}</td><td>{escape(str(risk['title']))}</td><td>{escape(str(risk['service']))}</td></tr>"
        for risk in artifact.risks
    ) or "<tr><td colspan='4'>No risks identified.</td></tr>"
    firmware_rows = "".join(
        "<tr><td>{}</td><td>{}</td></tr>".format(escape(str(item["label"])), escape(str(item["value"])))
        for item in artifact.firmware_analysis
    ) or "<tr><td colspan='2'>No firmware evidence summarized.</td></tr>"
    vuln_rows = "".join(
        "<tr>"
        f"<td>{escape(str(item['asset']))}</td><td>{escape(str(item['cve_id']))}</td>"
        f"<td><span class='sev sev-{escape(str(item['severity']))}'>{escape(str(item['severity']).upper())}</span></td>"
        f"<td>{escape(str(item['category']))}</td><td>{escape(str(item['confidence']))}</td></tr>"
        for item in artifact.intelligence_summary.get("strongest_vulnerability_correlations", [])
    ) or "<tr><td colspan='5'>No vulnerability correlations available.</td></tr>"
    fingerprint_rows = "".join(
        "<tr><td>{}</td><td>{}</td><td>{}</td></tr>".format(
            escape(str(item["host"])), escape(str(item["match"])), escape(str(item["confidence"]))
        )
        for item in artifact.intelligence_summary.get("highest_confidence_fingerprints", [])
    ) or "<tr><td colspan='3'>No fingerprint matches available.</td></tr>"
    graph_payload = escape(graph_json)
    graph_script_payload = graph_json.replace("</", "<\\/")
    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{escape(artifact.title)}</title>
  <style>
    :root {{ --bg:#f5f3ee; --panel:#fffdfa; --ink:#1d241f; --muted:#5f6b63; --line:#d4d0c7; --accent:#174c3c; --critical:#7f1d1d; --high:#b45309; --medium:#2563eb; --low:#2f6f3e; }}
    * {{ box-sizing:border-box; }} body {{ margin:0; background:linear-gradient(180deg,#faf7f1 0%,var(--bg) 100%); color:var(--ink); font-family:"Segoe UI","Aptos",Arial,sans-serif; line-height:1.5; }}
    nav {{ position:sticky; top:0; background:rgba(245,243,238,.92); border-bottom:1px solid var(--line); }} nav div {{ max-width:1180px; margin:0 auto; padding:.75rem 1.25rem; display:flex; gap:.8rem; flex-wrap:wrap; }} nav a {{ color:var(--accent); text-decoration:none; font-size:.92rem; }}
    main {{ max-width:1180px; margin:0 auto; padding:2rem 1.25rem 4rem; }} section {{ background:var(--panel); border:1px solid var(--line); border-radius:18px; padding:1.25rem; margin-bottom:1rem; }} h1,h2,h3 {{ color:var(--accent); margin:0 0 .75rem; }}
    .metric-grid {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:.75rem; }} .metric-card {{ background:linear-gradient(180deg,#fff,#f7f6f2); border:1px solid var(--line); border-radius:14px; padding:.9rem; }} .metric-card-highlight {{ background:linear-gradient(180deg,#f7fbf8,#edf5ef); }} .metric-label {{ color:var(--muted); font-size:.82rem; }} .metric-value {{ font-size:1.4rem; font-weight:700; color:var(--accent); }}
    .two-col,.graph-layout {{ display:grid; grid-template-columns:1.1fr .9fr; gap:1rem; }} .graph-shell {{ background:linear-gradient(180deg,#f8fbfa,#eef5f1); border:1px solid var(--line); border-radius:16px; padding:.75rem; min-height:520px; }} .graph-shell svg {{ width:100%; height:480px; display:block; }}
    .legend {{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; margin-top:.75rem; color:var(--muted); font-size:.9rem; }} .legend span::before {{ content:""; display:inline-block; width:.8rem; height:.8rem; border-radius:999px; margin-right:.35rem; vertical-align:middle; }} .legend .device::before {{ background:#174c3c; }} .legend .service::before {{ background:#1d4ed8; }} .legend .protocol::before {{ background:#0f766e; }} .legend .firmware::before {{ background:#6b21a8; }} .legend .intelligence::before {{ background:#b45309; }}
    .info-card {{ border:1px solid var(--line); border-radius:14px; background:linear-gradient(180deg,#fff,#fbfaf6); padding:1rem; }} .path-grid {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:.9rem; }} .path-card {{ border:1px solid var(--line); border-radius:14px; padding:1rem; background:linear-gradient(180deg,#fff,#fbfaf6); }} .path-rank,.path-meta {{ color:var(--muted); font-size:.82rem; }} .path-line {{ font-weight:600; margin-bottom:.4rem; }}
    table {{ border-collapse:collapse; width:100%; }} th,td {{ border-bottom:1px solid var(--line); padding:.7rem .5rem; text-align:left; vertical-align:top; }} th {{ color:var(--muted); font-size:.85rem; text-transform:uppercase; letter-spacing:.04em; }}
    .sev {{ display:inline-block; border-radius:999px; padding:.12rem .5rem; font-size:.8rem; font-weight:700; }} .sev-critical {{ background:rgba(127,29,29,.12); color:var(--critical); }} .sev-high {{ background:rgba(180,83,9,.12); color:var(--high); }} .sev-medium {{ background:rgba(37,99,235,.12); color:var(--medium); }} .sev-low {{ background:rgba(47,111,62,.12); color:var(--low); }}
    .group-label {{ font-size:11px; fill:#5f6b63; letter-spacing:.08em; }} .node-label {{ font-size:11px; fill:#20312a; pointer-events:none; }} .node-badge {{ font-size:9px; fill:#fff; pointer-events:none; font-weight:700; }} .edge-path {{ stroke:rgba(62,92,78,.25); stroke-width:2; }} .edge-path.highlight {{ stroke:rgba(180,83,9,.6); stroke-width:3; }} .edge-label {{ font-size:9px; fill:#6b7280; }}
    pre {{ background:#f6f3ee; border:1px solid var(--line); border-radius:12px; padding:1rem; overflow:auto; white-space:pre-wrap; word-break:break-word; }}
    @media (max-width:980px) {{ .two-col,.graph-layout {{ grid-template-columns:1fr; }} }}
  </style>
</head>
<body>
  <nav><div><a href="#executive-summary">Executive Summary</a><a href="#graph-overview">Graph Overview</a><a href="#assets">Assets</a><a href="#findings">Findings</a><a href="#risky-devices">Risky Devices</a><a href="#risky-paths">Risky Paths</a><a href="#firmware-evidence">Firmware Evidence</a><a href="#vulnerability-intelligence">Vuln Intelligence</a></div></nav>
  <main>
    <section id="executive-summary"><h1>{escape(artifact.title)}</h1><p>A self-contained Sentra report for static sharing and review.</p><div class="metric-grid">{executive_cards}</div><ul>{highlights}</ul></section>
    <section><h2>Summary</h2><div class="metric-grid">{summary_cards}</div></section>
    <section id="graph-overview" class="two-col"><div><h2>Evidence graph overview</h2><div class="graph-shell">{graph_shell}</div><div class="legend"><span class="device">Device</span><span class="service">Service</span><span class="protocol">Protocol</span><span class="firmware">Firmware</span><span class="intelligence">Intelligence</span></div></div><div><div class="info-card"><h3>Graph Coverage</h3><ul>{overview_items}</ul></div><div class="info-card" style="margin-top:1rem;"><h3>Edge Relations</h3><ul>{relation_items}</ul></div></div></section>
    <section id="assets"><h2>Discovered Assets</h2><table><thead><tr><th>Host</th><th>IP</th><th>Services</th><th>Protocols</th><th>Risk Score</th></tr></thead><tbody>{asset_rows}</tbody></table></section>
    <section id="findings"><h2>Grouped Findings</h2><table><thead><tr><th>Host</th><th>Service</th><th>Findings</th></tr></thead><tbody>{grouped_rows}</tbody></table></section>
    <section id="risky-devices"><h2>Top Risky Devices</h2><table><thead><tr><th>Host</th><th>IP</th><th>Top Severity</th><th>Risk Score</th><th>Risk Count</th><th>Why Ranked</th></tr></thead><tbody>{risky_rows}</tbody></table></section>
    <section id="risky-paths"><h2>Top Risky Paths</h2><div class="path-grid">{weak_path_cards}</div></section>
    <section><h2>Observed Risks</h2><table><thead><tr><th>Severity</th><th>Asset</th><th>Title</th><th>Context</th></tr></thead><tbody>{risk_rows}</tbody></table></section>
    <section id="firmware-evidence"><h2>Firmware Evidence</h2><table><thead><tr><th>Signal</th><th>Value</th></tr></thead><tbody>{firmware_rows}</tbody></table></section>
    <section id="vulnerability-intelligence" class="two-col"><div><h2>Vulnerability Intelligence</h2><table><thead><tr><th>Asset</th><th>CVE</th><th>Severity</th><th>Category</th><th>Confidence</th></tr></thead><tbody>{vuln_rows}</tbody></table></div><div><h2>Fingerprint Summary</h2><table><thead><tr><th>Host</th><th>Match</th><th>Confidence</th></tr></thead><tbody>{fingerprint_rows}</tbody></table></div></section>
    <section><h2>Recommended Next Checks</h2><ul>{checks}</ul></section>
    <section><h2>Visualization Data</h2><pre id="graph-data">{graph_payload}</pre></section>
  </main>
  <script id="graph-visualization-data" type="application/json">{graph_script_payload}</script>
  <script>
    (() => {{
      const dataNode = document.getElementById("graph-visualization-data");
      const root = document.getElementById("graph-canvas");
      if (!dataNode || !root) return;
      let payload = {{}};
      try {{ payload = JSON.parse(dataNode.textContent || "{{}}"); }} catch (_error) {{ return; }}
      const nodes = Array.isArray(payload.nodes) ? payload.nodes : [];
      const edges = Array.isArray(payload.edges) ? payload.edges : [];
      const groups = Array.isArray(payload.groups) ? payload.groups : [];
      if (!nodes.length) {{ root.innerHTML = "<text x='24' y='40'>No graph evidence available yet.</text>"; return; }}
      const ns = "http://www.w3.org/2000/svg";
      const cols = Object.fromEntries(groups.map((group, index) => [group.id, 100 + index * 190]));
      const buckets = new Map();
      for (const node of nodes) {{ const bucket = buckets.get(node.group) || []; bucket.push(node); buckets.set(node.group, bucket); }}
      const points = new Map();
      for (const group of groups) {{
        const bucket = (buckets.get(group.id) || []).sort((a, b) => String(a.label).localeCompare(String(b.label)) || String(a.id).localeCompare(String(b.id)));
        bucket.forEach((node, index) => points.set(node.id, {{ x: cols[group.id] || 120, y: 76 + index * 62 }}));
        const label = document.createElementNS(ns, "text");
        label.setAttribute("x", String((cols[group.id] || 120) - 48));
        label.setAttribute("y", "28");
        label.setAttribute("class", "group-label");
        label.textContent = group.label;
        root.appendChild(label);
      }}
      edges.forEach((edge) => {{
        const source = points.get(edge.source);
        const target = points.get(edge.target);
        if (!source || !target) return;
        const path = document.createElementNS(ns, "path");
        const mid = (source.x + target.x) / 2;
        path.setAttribute("d", `M ${{source.x}} ${{source.y}} C ${{mid}} ${{source.y}}, ${{mid}} ${{target.y}}, ${{target.x}} ${{target.y}}`);
        path.setAttribute("class", `edge-path${{edge.highlight ? " highlight" : ""}}`);
        path.setAttribute("fill", "none");
        root.appendChild(path);
      }});
      nodes.forEach((node) => {{
        const point = points.get(node.id);
        if (!point) return;
        const rect = document.createElementNS(ns, "rect");
        rect.setAttribute("x", String(point.x - 54));
        rect.setAttribute("y", String(point.y - 18));
        rect.setAttribute("width", "108");
        rect.setAttribute("height", "36");
        rect.setAttribute("rx", "12");
        rect.setAttribute("fill", node.color || "#64748b");
        rect.setAttribute("opacity", node.highlight ? "0.96" : "0.88");
        root.appendChild(rect);
        const badge = document.createElementNS(ns, "text");
        badge.setAttribute("x", String(point.x));
        badge.setAttribute("y", String(point.y - 4));
        badge.setAttribute("text-anchor", "middle");
        badge.setAttribute("class", "node-badge");
        badge.textContent = String(node.kind_abbrev || node.kind || "").slice(0, 4).toUpperCase();
        root.appendChild(badge);
        const text = document.createElementNS(ns, "text");
        text.setAttribute("x", String(point.x));
        text.setAttribute("y", String(point.y + 12));
        text.setAttribute("text-anchor", "middle");
        text.setAttribute("class", "node-label");
        text.textContent = String(node.label || "").slice(0, 19);
        root.appendChild(text);
      }});
    }})();
  </script>
</body>
</html>
"""


def _build_device_summaries(network_scan: NetworkScanArtifact | None) -> list[dict[str, object]]:
    if not network_scan:
        return []

    devices: list[dict[str, object]] = []
    for device in sorted(network_scan.devices, key=lambda item: (item.host, item.ip_address)):
        services = [f"{service.service}:{service.port}/{service.protocol}" for service in device.services]
        top_severity = max((finding.severity for finding in device.findings), key=_severity_rank, default="low")
        device_score, explanation = _device_risk_score(device)
        devices.append(
            {
                "host": device.host,
                "ip_address": device.ip_address,
                "vendor": device.vendor,
                "protocols": sorted(device.protocols),
                "services": sorted(services),
                "risk_count": len(device.findings),
                "top_severity": top_severity,
                "fingerprint_confidence": device.fingerprint_confidence,
                "vulnerability_count": len(device.vulnerability_matches),
                "risk_score": round(device_score, 3),
                "risk_explanation": explanation,
            }
        )
    return devices


def _build_risk_summaries(
    network_scan: NetworkScanArtifact | None,
    firmware_scan: FirmwareEvidence | None,
) -> list[dict[str, object]]:
    risks: list[dict[str, object]] = []

    if network_scan:
        for device in sorted(network_scan.devices, key=lambda item: (item.host, item.ip_address)):
            asset = f"{device.host} ({device.ip_address})"
            for finding in sorted(device.findings, key=lambda item: (-_severity_rank(item.severity), item.title)):
                service_context = _service_for_finding(device, finding)
                risks.append(
                    {
                        "title": finding.title,
                        "severity": finding.severity,
                        "confidence": finding.confidence,
                        "description": finding.description,
                        "asset": asset,
                        "host": device.host,
                        "ip_address": device.ip_address,
                        "service": service_context,
                        "tags": sorted(finding.tags),
                    }
                )
            for match in sorted(device.vulnerability_matches, key=lambda item: (-_severity_rank(item.severity), -item.confidence, item.cve_id)):
                risks.append(
                    {
                        "title": f"Vulnerability correlation: {match.cve_id}",
                        "severity": match.severity,
                        "confidence": match.confidence,
                        "description": match.rationale or match.summary,
                        "asset": asset,
                        "host": device.host,
                        "ip_address": device.ip_address,
                        "service": "vulnerability-intel",
                        "tags": ["vulnerability", match.category, *(["inference"] if match.inference else ["confirmed"])],
                    }
                )
            if device.fingerprint_matches:
                risks.append(
                    {
                        "title": f"Fingerprint intelligence: {device.fingerprint_matches[0]}",
                        "severity": "medium" if device.fingerprint_confidence >= 0.75 else "low",
                        "confidence": device.fingerprint_confidence,
                        "description": f"Highest-confidence fingerprint match is {device.fingerprint_matches[0]} with confidence {device.fingerprint_confidence:.2f}.",
                        "asset": asset,
                        "host": device.host,
                        "ip_address": device.ip_address,
                        "service": "fingerprint-intel",
                        "tags": ["fingerprint", "intelligence"],
                    }
                )

    if firmware_scan:
        for finding in sorted(firmware_scan.findings, key=lambda item: (-_severity_rank(item.severity), item.title)):
            risks.append(
                {
                    "title": finding.title,
                    "severity": finding.severity,
                    "confidence": finding.confidence,
                    "description": finding.description,
                    "asset": firmware_scan.path,
                    "host": firmware_scan.path,
                    "ip_address": "",
                    "service": "firmware",
                    "tags": sorted(finding.tags),
                }
            )
        for hint in firmware_scan.config_hints:
            risks.append(
                {
                    "title": f"Config hint: {hint.key}",
                    "severity": "high" if hint.category == "credentials" else "medium",
                    "confidence": hint.confidence,
                    "description": f"Extracted config hint from {hint.path}: {hint.value_preview}",
                    "asset": firmware_scan.path,
                    "host": firmware_scan.path,
                    "ip_address": "",
                    "service": "firmware-config",
                    "tags": ["firmware", hint.category, "config-hint"],
                }
            )
        for certificate in firmware_scan.certificates:
            risks.append(
                {
                    "title": "Embedded certificate material",
                    "severity": "medium",
                    "confidence": certificate.confidence,
                    "description": f"Certificate {certificate.common_name or certificate.subject or 'certificate'} found in extracted firmware content.",
                    "asset": firmware_scan.path,
                    "host": firmware_scan.path,
                    "ip_address": "",
                    "service": "firmware-certificate",
                    "tags": ["firmware", "certificate"],
                }
            )
        if not firmware_scan.certificates:
            for certificate in firmware_scan.certificate_findings:
                risks.append(
                    {
                        "title": "Embedded certificate material",
                        "severity": "medium",
                        "confidence": certificate.confidence,
                        "description": f"Certificate {certificate.common_name or certificate.subject or 'certificate'} found in extracted firmware content at {certificate.path}.",
                        "asset": firmware_scan.path,
                        "host": firmware_scan.path,
                        "ip_address": "",
                        "service": "firmware-certificate",
                        "tags": ["firmware", "certificate"],
                    }
                )
        for match in sorted(firmware_scan.vulnerability_matches, key=lambda item: (-_severity_rank(item.severity), -item.confidence, item.cve_id)):
            risks.append(
                {
                    "title": f"Firmware vulnerability correlation: {match.cve_id}",
                    "severity": match.severity,
                    "confidence": match.confidence,
                    "description": match.rationale or match.summary,
                    "asset": firmware_scan.path,
                    "host": firmware_scan.path,
                    "ip_address": "",
                    "service": "firmware-vulnerability",
                    "tags": ["firmware", "vulnerability", match.category],
                }
            )

    risks.sort(
        key=lambda risk: (
            -_severity_rank(str(risk["severity"])),
            str(risk["host"]),
            str(risk["service"]),
            str(risk["title"]),
        )
    )
    return risks


def _build_top_risky_devices(devices: list[dict[str, object]], risks: list[dict[str, object]]) -> list[dict[str, object]]:
    by_host: dict[str, dict[str, object]] = {}
    for device in devices:
        by_host[str(device["host"])] = {
            "host": device["host"],
            "ip_address": device["ip_address"],
            "risk_count": int(device["risk_count"]),
            "top_severity": device["top_severity"],
            "risk_score": float(device.get("risk_score", 0.0)),
            "risk_explanation": str(device.get("risk_explanation", "")),
            "fingerprint_confidence": float(device.get("fingerprint_confidence", 0.0)),
            "vulnerability_count": int(device.get("vulnerability_count", 0)),
        }

    ranked = sorted(
        by_host.values(),
        key=lambda item: (
            -_severity_rank(str(item["top_severity"])),
            -float(item["risk_score"]),
            -int(item["risk_count"]),
            -int(item["vulnerability_count"]),
            str(item["host"]),
        ),
    )
    return ranked[:5]


def _build_grouped_findings(
    network_scan: NetworkScanArtifact | None,
    firmware_scan: FirmwareEvidence | None,
) -> list[dict[str, object]]:
    grouped: list[dict[str, object]] = []

    if network_scan:
        for device in sorted(network_scan.devices, key=lambda item: (item.host, item.ip_address)):
            service_map: dict[str, list[str]] = defaultdict(list)
            for service in sorted(device.services, key=lambda item: (item.port, item.service, item.protocol)):
                service_map[f"{service.service}:{service.port}/{service.protocol}"] = []

            for finding in sorted(device.findings, key=lambda item: (-_severity_rank(item.severity), item.title)):
                service_label = _service_for_finding(device, finding)
                service_map[service_label].append(finding.title)

            grouped.append(
                {
                    "host": device.host,
                    "ip_address": device.ip_address,
                    "services": [
                        {"service": key, "findings": sorted(values)}
                        for key, values in sorted(service_map.items(), key=lambda item: item[0])
                        if values or key == "host"
                    ],
                }
            )

    if firmware_scan:
        grouped.append(
            {
                "host": firmware_scan.path,
                "ip_address": "",
                "services": [
                    {"service": "firmware", "findings": sorted(finding.title for finding in firmware_scan.findings)},
                    {"service": "config-hints", "findings": sorted(f"{hint.key}:{hint.path}" for hint in firmware_scan.config_hints)},
                    {"service": "service-hints", "findings": sorted(hint.service for hint in firmware_scan.service_hints)},
                ],
            }
        )

    return grouped


def _build_weak_paths(correlation: CorrelationArtifact | None) -> list[dict[str, object]]:
    if not correlation:
        return []

    nodes = {node.id: node for node in correlation.nodes}
    outgoing: dict[str, list[tuple[str, str]]] = defaultdict(list)
    for edge in sorted(correlation.edges, key=lambda item: (item.source, item.target, item.relation)):
        outgoing[edge.source].append((edge.target, edge.relation))

    weak_paths: list[dict[str, object]] = []
    starts = sorted(node.id for node in correlation.nodes if node.kind in {"device", "service", "firmware"})
    for start in starts:
        for path, relations in _collect_paths(start, outgoing, nodes, max_depth=4):
            end_node = nodes[path[-1]]
            severity = _node_severity(end_node.kind, end_node.attributes)
            confidence = _path_confidence(path, nodes)
            score = _path_score(severity, len(path), confidence, relations, end_node.attributes)
            weak_paths.append(
                {
                    "severity": severity,
                    "path": " -> ".join(nodes[node_id].label for node_id in path),
                    "length": len(path),
                    "score": round(score, 3),
                    "confidence": round(confidence, 3),
                    "terminal_finding": end_node.label,
                    "relations": relations,
                    "explanation": _explain_weak_path(path, relations, nodes),
                }
            )

    weak_paths.sort(
        key=lambda item: (
            -_severity_rank(str(item["severity"])),
            -float(item["score"]),
            int(item["length"]),
            str(item["path"]),
        )
    )
    return [
        {
            "rank": index + 1,
            "severity": item["severity"],
            "path": item["path"],
            "terminal_finding": item["terminal_finding"],
            "relations": item["relations"],
            "score": item["score"],
            "confidence": item["confidence"],
            "explanation": item["explanation"],
        }
        for index, item in enumerate(weak_paths[:5])
    ]


def _collect_paths(
    start: str,
    outgoing: dict[str, list[tuple[str, str]]],
    nodes: dict[str, object],
    max_depth: int,
) -> list[tuple[list[str], list[str]]]:
    collected: list[tuple[list[str], list[str]]] = []
    stack: list[tuple[str, list[str], list[str]]] = [(start, [start], [])]
    while stack:
        current, path, relations = stack.pop()
        if len(path) > max_depth:
            continue
        current_node = nodes[current]
        if current_node.kind in {"finding", "vulnerability_reference", "config_hint", "service_hint", "certificate"} and len(path) > 1:
            collected.append((path, relations))
            continue
        for target, relation in reversed(outgoing.get(current, [])):
            if target not in path:
                stack.append((target, [*path, target], [*relations, relation]))
    return collected


def _build_recommended_checks(
    risks: list[dict[str, object]],
    weak_paths: list[dict[str, object]],
    graph_overview: dict[str, object],
) -> list[str]:
    checks: list[str] = []
    tags = {tag for risk in risks for tag in risk.get("tags", [])}
    relations = {item["relation"] for item in graph_overview["edge_relations"]}
    node_kinds = {item["kind"] for item in graph_overview["node_kinds"]}

    if any(risk["severity"] in {"high", "critical"} for risk in risks):
        checks.append("Validate whether the highest-severity findings are reachable from the exposed services.")
    if "credentials" in tags:
        checks.append("Review firmware and configuration exports for hardcoded credentials or shared secrets.")
    if any("telnet" in str(risk["title"]).lower() or "telnet" in risk.get("tags", []) for risk in risks):
        checks.append("Confirm whether Telnet can be disabled and whether an authenticated replacement is available.")
    if any("http" in str(path["path"]).lower() or "web" in str(path["path"]).lower() for path in weak_paths):
        checks.append("Manually verify the web management path for authentication, authorization, and default credentials.")
    if "certificate" in node_kinds or "observed_in" in relations:
        checks.append("Review TLS certificates for issuer trust, SAN coverage, and unexpected reuse across hosts.")
    if "software_component" in node_kinds or "depends_on" in relations:
        checks.append("Cross-check exposed software components against patch baselines and local vulnerability feeds.")
    if "config_hint" in node_kinds:
        checks.append("Inspect extracted configuration hints for secrets, management endpoints, and inherited defaults.")
    if "service_hint" in node_kinds:
        checks.append("Verify whether firmware-derived service hints still align with the currently exposed device posture.")
    if "firmware_inference" in node_kinds or "matches_firmware_signal" in relations:
        checks.append("Review which firmware inferences are supported by live device evidence versus extracted hints alone.")
    if "vulnerability_reference" in node_kinds:
        checks.append("Prioritize the highest-confidence vulnerability correlations and confirm version scope on live assets.")
    if "fingerprint_inference" in node_kinds:
        checks.append("Review high-confidence fingerprint matches for conflicts before using them to prioritize remediation.")
    if any("affects" in path.get("relations", []) for path in weak_paths):
        checks.append("Confirm whether the ranked weak paths reach software or vulnerability evidence that is still present on the device.")
    if weak_paths:
        checks.append("Trace the ranked weak paths to confirm which graph links are observable in live systems.")
    if not checks:
        checks.append("Expand collection with additional scans or firmware samples to improve graph coverage.")
    return sorted(dict.fromkeys(checks))


def _build_intelligence_summary(
    network_scan: NetworkScanArtifact | None,
    firmware_scan: FirmwareEvidence | None,
    risks: list[dict[str, object]],
) -> dict[str, object]:
    fingerprint_matches: list[dict[str, object]] = []
    vulnerability_correlations: list[dict[str, object]] = []
    informative_findings: list[dict[str, object]] = []

    if network_scan:
        for device in network_scan.devices:
            if device.fingerprint_matches:
                fingerprint_matches.append(
                    {
                        "host": device.host,
                        "match": device.fingerprint_matches[0],
                        "confidence": round(device.fingerprint_confidence, 2),
                    }
                )
            for match in device.vulnerability_matches:
                vulnerability_correlations.append(
                    {
                        "asset": device.host,
                        "cve_id": match.cve_id,
                        "severity": match.severity,
                        "category": match.category,
                        "confidence": match.confidence,
                    }
                )
            for finding in device.findings:
                informative_findings.append(
                    {
                        "asset": device.host,
                        "title": finding.title,
                        "severity": finding.severity,
                        "confidence": finding.confidence,
                    }
                )
    if firmware_scan:
        for match in firmware_scan.vulnerability_matches:
            vulnerability_correlations.append(
                {
                    "asset": firmware_scan.path,
                    "cve_id": match.cve_id,
                    "severity": match.severity,
                    "category": match.category,
                    "confidence": match.confidence,
                }
            )
        for finding in firmware_scan.findings:
            informative_findings.append(
                {
                    "asset": firmware_scan.path,
                    "title": finding.title,
                    "severity": finding.severity,
                    "confidence": finding.confidence,
                }
            )

    fingerprint_matches.sort(key=lambda item: (-float(item["confidence"]), str(item["host"]), str(item["match"])))
    vulnerability_correlations.sort(
        key=lambda item: (-_severity_rank(str(item["severity"])), -float(item["confidence"]), str(item["cve_id"]))
    )
    informative_findings.sort(
        key=lambda item: (-_severity_rank(str(item["severity"])), -float(item["confidence"]), str(item["title"]))
    )
    return {
        "top_risky_devices_count": len({str(risk["host"]) for risk in risks if risk.get("ip_address")}),
        "most_informative_findings": informative_findings[:5],
        "highest_confidence_fingerprints": fingerprint_matches[:5],
        "strongest_vulnerability_correlations": vulnerability_correlations[:5],
    }


def _build_graph_overview(correlation: CorrelationArtifact | None) -> dict[str, object]:
    if not correlation:
        return {
            "node_kinds": [],
            "edge_relations": [],
            "vulnerability_categories": [],
            "visualization": {"nodes": [], "edges": [], "groups": [], "paths": []},
        }

    node_counts = Counter(node.kind for node in correlation.nodes)
    edge_counts = Counter(edge.relation for edge in correlation.edges)
    vuln_category_counts = Counter(
        str(node.attributes.get("category", "unknown"))
        for node in correlation.nodes
        if node.kind == "vulnerability_reference"
    )
    highlighted_paths = correlation.insights.get("ranked_weak_paths", []) if correlation.insights else []
    highlighted_labels = {
        label.strip()
        for item in highlighted_paths[:4]
        if isinstance(item, dict)
        for label in str(item.get("path", "")).split(" -> ")
        if label.strip()
    }
    visual_nodes = []
    prioritized_nodes = sorted(
        correlation.nodes,
        key=lambda item: (
            -_visualization_priority(item, highlighted_labels),
            -_severity_rank(_node_severity(item.kind, item.attributes)),
            item.kind,
            item.label,
            item.id,
        ),
    )
    for node in prioritized_nodes[:24]:
        visual_nodes.append(
            {
                "id": node.id,
                "kind": node.kind,
                "kind_abbrev": node.kind[:4],
                "label": node.label,
                "group": _visualization_group(node.kind),
                "severity": _node_severity(node.kind, node.attributes),
                "confidence": round(float(node.attributes.get("confidence", 0.0)), 2)
                if isinstance(node.attributes.get("confidence"), (int, float))
                else None,
                "highlight": node.label in highlighted_labels,
                "color": _node_color(node.kind),
            }
        )
    visual_node_ids = {node["id"] for node in visual_nodes}
    visual_edges = [
        {
            "source": edge.source,
            "target": edge.target,
            "relation": edge.relation,
            "short_relation": _short_relation(edge.relation),
            "highlight": _edge_matches_highlight(edge.source, edge.target, visual_nodes, highlighted_labels),
        }
        for edge in sorted(correlation.edges, key=lambda item: (item.relation, item.source, item.target))
        if edge.source in visual_node_ids and edge.target in visual_node_ids
    ][:36]
    return {
        "node_kinds": [
            {"kind": kind, "count": count}
            for kind, count in sorted(node_counts.items(), key=lambda item: (-item[1], item[0]))
        ],
        "edge_relations": [
            {"relation": relation, "count": count}
            for relation, count in sorted(edge_counts.items(), key=lambda item: (-item[1], item[0]))
        ],
        "vulnerability_categories": [
            {"category": category, "count": count}
            for category, count in sorted(vuln_category_counts.items(), key=lambda item: (-item[1], item[0]))
        ],
        "visualization": {
            "nodes": visual_nodes,
            "edges": visual_edges,
            "groups": [
                {"id": "device", "label": "Devices"},
                {"id": "service", "label": "Services"},
                {"id": "protocol", "label": "Protocols"},
                {"id": "firmware", "label": "Firmware"},
                {"id": "intelligence", "label": "Intelligence"},
            ],
            "paths": [
                {
                    "rank": item.get("rank"),
                    "severity": item.get("severity"),
                    "score": item.get("score"),
                    "path": item.get("path"),
                    "explanation": item.get("explanation"),
                }
                for item in highlighted_paths[:5]
                if isinstance(item, dict)
            ],
        },
    }


def _render_graph_svg(graph_overview: dict[str, object]) -> str:
    if not graph_overview["visualization"]["nodes"]:
        return "<svg id='graph-canvas' viewBox='0 0 960 480' role='img' aria-label='No graph data'><text x='24' y='40'>No graph evidence available yet.</text></svg>"
    path_items = "".join(
        f"<li>{escape(str(item.get('path', '')))}</li>"
        for item in graph_overview["visualization"].get("paths", [])
    ) or "<li>No ranked paths available.</li>"
    return (
        "<div class='graph-layout'>"
        "<svg id='graph-canvas' viewBox='0 0 960 480' role='img' aria-label='Evidence graph overview'></svg>"
        "<div><div class='info-card'><h3>Highlighted Paths</h3><ul>"
        f"{path_items}"
        "</ul></div><div class='info-card' style='margin-top:1rem;'><h3>Layout</h3><p>Devices, services, protocols, firmware evidence, and intelligence nodes are grouped into separate lanes for quick triage.</p></div></div>"
        "</div>"
    )


def _node_color(kind: str) -> str:
    return {
        "device": "#174c3c",
        "service": "#1d4ed8",
        "protocol": "#0f766e",
        "finding": "#b45309",
        "firmware": "#6b21a8",
        "software_component": "#475569",
        "certificate": "#0f766e",
        "endpoint": "#4b5563",
        "config_hint": "#92400e",
        "service_hint": "#0f766e",
        "firmware_inference": "#0f766e",
        "component_evidence": "#7c3aed",
        "vulnerability_reference": "#7f1d1d",
    }.get(kind, "#64748b")


def _visualization_group(kind: str) -> str:
    return {
        "device": "device",
        "service": "service",
        "endpoint": "service",
        "protocol": "protocol",
        "firmware": "firmware",
        "extracted_artifact": "firmware",
        "software_component": "firmware",
        "component_evidence": "firmware",
    }.get(kind, "intelligence")


def _visualization_priority(node, highlighted_labels: set[str]) -> int:  # type: ignore[no-untyped-def]
    base = {
        "device": 9,
        "service": 8,
        "protocol": 7,
        "firmware": 8,
        "finding": 9,
        "vulnerability_reference": 10,
        "config_hint": 7,
        "service_hint": 7,
        "certificate": 7,
        "software_component": 6,
        "fingerprint_inference": 6,
        "component_evidence": 5,
        "extracted_artifact": 4,
    }.get(node.kind, 3)
    if node.label in highlighted_labels:
        base += 5
    return base


def _short_relation(relation: str) -> str:
    return {
        "matches_firmware_signal": "fw-match",
        "contains_evidence": "evidence",
        "likely_runs": "likely",
        "affects": "affects",
        "hosts": "hosts",
        "exposes": "exposes",
        "depends_on": "depends",
        "observed_in": "observed",
        "uses_protocol": "protocol",
        "has_finding": "finding",
    }.get(relation, relation.replace("_", "-"))


def _edge_matches_highlight(
    source: str,
    target: str,
    visual_nodes: list[dict[str, object]],
    highlighted_labels: set[str],
) -> bool:
    labels_by_id = {str(node["id"]): str(node["label"]) for node in visual_nodes}
    return labels_by_id.get(source, "") in highlighted_labels and labels_by_id.get(target, "") in highlighted_labels


def _explain_weak_path(path: list[str], relations: list[str], nodes: dict[str, object]) -> str:
    start = nodes[path[0]].label
    end = nodes[path[-1]].label
    relation_text = ", ".join(relations) if relations else "directly linked"
    if any(relation == "affects" for relation in relations):
        category = str(nodes[path[-1]].attributes.get("category", "")).replace("-", " ")
        category_text = f" with a {category}" if category else ""
        return f"{start} reaches {end} through vulnerability-linked evidence{category_text} ({relation_text})."
    if any(relation == "matches_firmware_signal" for relation in relations):
        return f"{start} aligns with firmware-derived evidence that leads to {end} ({relation_text})."
    if any(relation == "likely_runs" for relation in relations):
        return f"{start} is correlated with firmware-derived service or deployment hints that lead to {end} ({relation_text})."
    if any(relation == "observed_in" for relation in relations) and nodes[path[-1]].kind == "certificate":
        return f"{start} links to embedded certificate evidence ending in {end} ({relation_text})."
    if any(relation == "contains_evidence" for relation in relations):
        return f"{start} reaches {end} through extracted-content evidence preserved in the firmware graph ({relation_text})."
    if any(relation in {"exposes", "hosts"} for relation in relations):
        return f"{start} exposes a chain of evidence ending in {end} ({relation_text})."
    return f"{start} connects to {end} through {relation_text}."


def _path_confidence(path: list[str], nodes: dict[str, object]) -> float:
    confidences: list[float] = []
    for node_id in path:
        node = nodes[node_id]
        confidence = node.attributes.get("confidence")
        if isinstance(confidence, (int, float)):
            confidences.append(float(confidence))
    if not confidences:
        return 0.5
    return sum(confidences) / len(confidences)


def _path_score(
    severity: str,
    length: int,
    confidence: float,
    relations: list[str],
    attributes: dict[str, object],
) -> float:
    severity_weight = float(_severity_rank(severity))
    relation_bonus = 0.0
    if "affects" in relations:
        relation_bonus += 0.6
    if "matches_firmware_signal" in relations:
        relation_bonus += 0.35
    if "likely_runs" in relations:
        relation_bonus += 0.2
    if "contains_evidence" in relations:
        relation_bonus += 0.15
    vuln_category = str(attributes.get("category", ""))
    if vuln_category == "confirmed-match":
        relation_bonus += 0.45
    elif vuln_category == "probable-match":
        relation_bonus += 0.22
    elif vuln_category == "weak-hint":
        relation_bonus += 0.08
    return severity_weight + confidence + relation_bonus - (max(length, 1) - 1) * 0.08


def _build_firmware_analysis(firmware_scan: FirmwareEvidence | None) -> list[dict[str, object]]:
    if not firmware_scan:
        return []
    items = [
        {"label": "Extracted Files", "value": len(firmware_scan.extracted_artifacts)},
        {"label": "Nested Archives Seen", "value": firmware_scan.extraction_metadata.nested_archives_seen},
        {"label": "Config Hints", "value": len(firmware_scan.config_hints)},
        {"label": "Service Hints", "value": len(firmware_scan.service_hints)},
        {"label": "Certificates", "value": len(firmware_scan.certificates)},
        {"label": "Certificate Findings", "value": len(firmware_scan.certificate_findings)},
        {"label": "Software Components", "value": len(firmware_scan.software_components)},
        {"label": "Component Evidence", "value": len(firmware_scan.component_evidence)},
        {"label": "Inference Hints", "value": len(firmware_scan.inference_hints)},
    ]
    provider = firmware_scan.extraction_metadata.provider.strip()
    if provider:
        items.append({"label": "Extraction Provider", "value": provider})
    if firmware_scan.extraction_metadata.detected_type:
        items.append({"label": "Detected Type", "value": firmware_scan.extraction_metadata.detected_type})
    if firmware_scan.extraction_metadata.truncated:
        items.append({"label": "Extraction Bounded", "value": "yes"})
    for note in firmware_scan.extraction_metadata.extraction_notes[:3]:
        items.append({"label": "Extraction Note", "value": note})
    for vendor in firmware_scan.suspected_vendors[:3]:
        items.append({"label": "Vendor Hint", "value": vendor})
    for product in firmware_scan.suspected_products[:3]:
        items.append({"label": "Product Hint", "value": product})
    for component in firmware_scan.software_components[:3]:
        value = component.normalized_name if not component.version else f"{component.normalized_name} {component.version}"
        items.append({"label": "Component Hint", "value": value})
    for match in firmware_scan.vulnerability_matches[:3]:
        items.append({"label": "Vuln Correlation", "value": f"{match.cve_id} ({match.category})"})
    return items


def _device_risk_score(device) -> tuple[float, str]:  # type: ignore[no-untyped-def]
    score = 0.0
    reasons: list[str] = []
    risky_services = {"telnet", "http", "rtsp", "modbus", "snmp"}
    exposed = sorted({service.service.lower() for service in device.services if service.service.lower() in risky_services})
    if exposed:
        score += len(exposed) * 0.35
        reasons.append(f"exposed protocols: {', '.join(exposed)}")
    if device.findings:
        max_sev = max((_severity_rank(finding.severity) for finding in device.findings), default=0)
        score += max_sev * 0.45
        reasons.append(f"{len(device.findings)} finding(s)")
    if device.vulnerability_matches:
        vuln_bonus = sum(match.confidence for match in device.vulnerability_matches[:3])
        score += vuln_bonus
        reasons.append(f"{len(device.vulnerability_matches)} vulnerability correlation(s)")
    if device.certificates:
        score += 0.3
        reasons.append("certificate material observed")
    if device.fingerprint_confidence >= 0.75:
        score += device.fingerprint_confidence * 0.4
        reasons.append(f"strong fingerprint confidence {device.fingerprint_confidence:.2f}")
    if "web" in {protocol.lower() for protocol in device.protocols}:
        score += 0.18
    return round(score, 3), "; ".join(reasons) or "limited intelligence signals"


def _node_severity(kind: str, attributes: dict[str, object]) -> str:
    explicit = str(attributes.get("severity", "")).lower()
    if explicit in {"critical", "high", "medium", "low"}:
        return explicit
    return {
        "vulnerability_reference": "high",
        "config_hint": "high",
        "service_hint": "medium",
        "certificate": "medium",
        "finding": "medium",
    }.get(kind, "low")


def _service_for_finding(device, finding) -> str:  # type: ignore[no-untyped-def]
    lowered_title = finding.title.lower()
    tags = {tag.lower() for tag in finding.tags}
    for service in sorted(device.services, key=lambda item: (item.port, item.service, item.protocol)):
        identifiers = {service.service.lower(), service.protocol.lower(), str(service.port)}
        if service.service.lower() in {"http", "https"}:
            identifiers.add("web")
        if tags.intersection(identifiers) or any(identifier in lowered_title for identifier in identifiers):
            return f"{service.service}:{service.port}/{service.protocol}"
    return "host"


def _severity_rank(severity: str) -> int:
    return {
        "critical": 4,
        "high": 3,
        "medium": 2,
        "low": 1,
    }.get(severity.lower(), 0)

