"""
vsi_viz.py - draw a VSI digital twin (components, ports, signals, interconnect)
from its vsiBuild command file, as a self-contained interactive HTML page.

    python vsi_viz.py <twin.vsi.cmd> [-o out.html] [--title T]

Reads only the command file, so it works on ICD-generated twins, templates and
Cameo-exported twins alike. Nodes are components (shape by language: C++,
Python, FMU, SystemC ...), each listing its ports and typed signals. Edges are:
    connect signals        one edge per signal, coloured by the port's gateway
    add portSocket         server and client(s) sharing an ipPortNum (Ethernet)
    define frame           components sharing a CAN/LIN frame id
    connect ports          direct port links (PWM/AXI/GenericPayload)
The ICD generator's "# bus <name> [<protocol>] n port(s)" comments name the
edges that follow them. Hover a node for its signals; click to isolate its
neighbourhood; the search box filters by name. Uses d3 from cdnjs; no build.
"""

import argparse
import collections
import json
import os
import re

GATEWAY_COLOURS = {
    "Ethernet": "#2f6fdb", "Can": "#e07b00", "Lin": "#b8860b", "Spi": "#7b3fbf",
    "Gpio": "#2a9d8f", "Uart": "#9c4f2e", "Pwm": "#c2185b", "Axi": "#546e7a",
    "GenericPayload": "#3a9d3a", "socket": "#2f6fdb", "frame": "#e07b00", "ports": "#546e7a",
}
TYPE_SHAPES = {"c++": "rect", "python": "circle", "fmu": "diamond", "systemc": "hexagon"}


def parse(path):
    comps, current_bus, current_proto = {}, None, None
    edges, sockets, frames = [], collections.defaultdict(list), collections.defaultdict(list)
    header = []
    text = open(path, encoding="utf-8", errors="replace").read()
    # join continuation lines
    text = re.sub(r"\\\r?\n\s*", " ", text)
    for raw in text.splitlines():
        line = raw.strip()
        if not line:
            continue
        m = re.match(r"#\s*bus\s+(.+?)\s+\[(\w+)\]\s+(\d+)\s+port", line)
        if m:
            current_bus, current_proto = m.group(1), m.group(2)
            continue
        if line.startswith("#"):
            if len(header) < 12:
                header.append(line.lstrip("# ").strip())
            continue
        m = re.match(r"add component -type (\S+) -name (\S+)", line)
        if m:
            comps[m.group(2)] = {"name": m.group(2), "type": m.group(1), "ports": [], "signals": [], "extra": {}}
            fm = re.search(r"-fmuPath (\S+)", line)
            if fm:
                comps[m.group(2)]["extra"]["fmu"] = os.path.basename(fm.group(1))
            continue
        m = re.match(r"add port -componentName (\S+) -name (\S+) -gateway (\S+)(.*)", line)
        if m and m.group(1) in comps:
            gw = m.group(3)
            proto = re.sub(r"^.*?2Dt", "", gw)
            comps[m.group(1)]["ports"].append({"name": m.group(2), "gateway": gw, "protocol": proto,
                                               "nodeType": (re.search(r"-nodeType (\S+)", m.group(4)) or [None, None])[1]})
            continue
        m = re.match(r"define componentSignals -componentName (\S+) -signals \[(.*)\]", line)
        if m and m.group(1) in comps:
            for s in m.group(2).split(","):
                parts = s.strip().split(":")
                if len(parts) == 3:
                    comps[m.group(1)]["signals"].append({"name": parts[0], "type": parts[1], "dir": parts[2]})
            continue
        m = re.match(r"add portSocket -componentName (\S+) -portName (\S+) -ipPortNum (\d+) -socketType (\S+) -protocol (\S+)", line)
        if m:
            sockets[m.group(3)].append({"comp": m.group(1), "port": m.group(2), "role": m.group(4), "tp": m.group(5),
                                        "bus": current_bus})
            continue
        m = re.match(r"connect signals -sourceSignal (\S+)\.(\S+) -destSignal (\S+)\.(\S+)(?: -sourcePortName (\S+))?(?: -destPortName (\S+))?", line)
        if m:
            src, sig, dst = m.group(1), m.group(2), m.group(3)
            proto = None
            for p in comps.get(src, {}).get("ports", []):
                if p["name"] == (m.group(5) or p["name"]):
                    proto = p["protocol"]
                    break
            # target_label: the DESTINATION signal's own name. The grouped
            # generator prefixes signals with their port, so the two ends differ;
            # reusing the source name on both ends produced ten unresolved
            # features in the SysML v2 export (caught by the OMG pilot, 2026-09-06).
            edges.append({"source": src, "target": dst, "label": sig, "target_label": m.group(4), "kind": "signal",
                          "protocol": proto or current_proto or "GenericPayload", "bus": current_bus})
            continue
        m = re.match(r"define frame -componentName (\S+) -portName (\S+) -id (\S+) -signals \[(.*?)\]", line)
        if m:
            frames[m.group(3)].append({"comp": m.group(1), "port": m.group(2), "signals": m.group(4), "bus": current_bus})
            continue
        m = re.match(r"connect ports (\S+)\.(\S+) (\S+)\.(\S+)", line)
        if m:
            edges.append({"source": m.group(1), "target": m.group(3), "label": "{} <-> {}".format(m.group(2), m.group(4)),
                          "kind": "ports", "protocol": "ports", "bus": current_bus})
            continue
    for ipport, members in sockets.items():
        servers = [x for x in members if x["role"] == "server"]
        clients = [x for x in members if x["role"] == "client"]
        for s in servers:
            for c in clients:
                edges.append({"source": s["comp"], "target": c["comp"], "label": "tcp {}".format(ipport) if s["tp"] == "tcpIp" else "udp {}".format(ipport),
                              "kind": "socket", "protocol": "Ethernet", "bus": s["bus"] or c["bus"]})
    for fid, members in frames.items():
        for i in range(len(members)):
            for j in range(i + 1, len(members)):
                edges.append({"source": members[i]["comp"], "target": members[j]["comp"], "label": "frame {}".format(fid),
                              "kind": "frame", "protocol": "Can", "bus": members[i]["bus"], "undirected": True})
    return {"components": list(comps.values()), "edges": edges, "header": header,
            "counts": {"components": len(comps), "edges": len(edges),
                       "signals": sum(len(c["signals"]) for c in comps.values()),
                       "ports": sum(len(c["ports"]) for c in comps.values()),
                       "byProtocol": dict(collections.Counter(e["protocol"] for e in edges)),
                       "byType": dict(collections.Counter(c["type"] for c in comps.values()))}}


HTML = r"""<!doctype html>
<html><head><meta charset="utf-8"><title>__TITLE__</title>
<style>
:root{--bg:#f7f8fa;--panel:#fff;--ink:#1d2430;--mute:#6b7280;--line:#d9dee7}
body{margin:0;font:13px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:var(--ink);background:var(--bg);display:grid;grid-template-columns:300px 1fr;height:100vh}
aside{background:var(--panel);border-right:1px solid var(--line);padding:14px 16px;overflow:auto}
h1{font-size:15px;margin:0 0 4px}h2{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mute);margin:16px 0 6px}
.k{display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px dotted var(--line)}
.leg span{display:inline-block;width:22px;height:3px;margin-right:6px;vertical-align:middle}
input{width:100%;box-sizing:border-box;padding:6px 8px;border:1px solid var(--line);border-radius:6px;font:inherit}
#tip{position:fixed;pointer-events:none;background:#111827;color:#fff;padding:8px 10px;border-radius:6px;font-size:12px;max-width:340px;display:none;z-index:9;white-space:pre-wrap}
svg{width:100%;height:100%;display:block}
.node text{font-size:10px;pointer-events:none}
.node.dim{opacity:.12}.link.dim{opacity:.05}
.link{fill:none;stroke-opacity:.55}
.link-label{font-size:8px;fill:#374151;pointer-events:none}
.hdr{font-size:11px;color:var(--mute);white-space:pre-wrap}
button{font:inherit;padding:5px 9px;border:1px solid var(--line);background:#fff;border-radius:6px;cursor:pointer;margin:2px 2px 2px 0}
</style></head><body>
<aside>
<h1>__TITLE__</h1>
<div class="hdr">__HEADER__</div>
<h2>Counts</h2><div id="counts"></div>
<h2>Interconnect by protocol</h2><div id="proto"></div>
<h2>Find</h2><input id="q" placeholder="component or signal name">
<div style="margin-top:8px"><button id="labels">edge labels</button><button id="reset">reset</button></div>
<h2>Legend</h2><div class="leg" id="leg"></div>
<h2>How to read it</h2>
<div class="hdr">Node shape = component language (square C++, circle Python, diamond FMU, hexagon SystemC). Edge colour = protocol. Arrow = signal direction; a bus with no arrow is a shared frame. Hover a node for its ports and signals; click to isolate its neighbourhood.</div>
</aside>
<svg id="g"></svg><div id="tip"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<script>
const DATA = __DATA__;
const COL = __COLOURS__;
const W = window.innerWidth - 300, H = window.innerHeight;
const svg = d3.select('#g').attr('viewBox', [0,0,W,H]);
const root = svg.append('g');
svg.call(d3.zoom().scaleExtent([0.15, 6]).on('zoom', e => root.attr('transform', e.transform)));
svg.append('defs').selectAll('marker').data(Object.keys(COL)).join('marker')
  .attr('id', d => 'm-' + d).attr('viewBox','0 -4 8 8').attr('refX', 16).attr('markerWidth', 6).attr('markerHeight', 6).attr('orient','auto')
  .append('path').attr('d','M0,-4L8,0L0,4').attr('fill', d => COL[d]);
const nodes = DATA.components.map(c => ({...c, id: c.name}));
const byId = new Map(nodes.map(n => [n.id, n]));
const links = DATA.edges.filter(e => byId.has(e.source) && byId.has(e.target)).map((e, i) => ({...e, i}));
const deg = new Map(); links.forEach(l => { deg.set(l.source, (deg.get(l.source)||0)+1); deg.set(l.target, (deg.get(l.target)||0)+1); });
const sim = d3.forceSimulation(nodes)
  .force('link', d3.forceLink(links).id(d => d.id).distance(l => l.kind === 'frame' ? 60 : 90).strength(0.5))
  .force('charge', d3.forceManyBody().strength(-260))
  .force('center', d3.forceCenter(W/2, H/2))
  .force('collide', d3.forceCollide(30));
const link = root.append('g').selectAll('path').data(links).join('path').attr('class','link')
  .attr('stroke', d => COL[d.protocol] || '#999').attr('stroke-width', d => d.kind === 'socket' ? 2.2 : 1.4)
  .attr('marker-end', d => d.undirected ? null : 'url(#m-' + (COL[d.protocol] ? d.protocol : 'ports') + ')');
const llab = root.append('g').selectAll('text').data(links).join('text').attr('class','link-label').text(d => d.label).style('display','none');
const node = root.append('g').selectAll('g').data(nodes).join('g').attr('class','node').call(d3.drag()
  .on('start', (e,d) => { if (!e.active) sim.alphaTarget(.3).restart(); d.fx = d.x; d.fy = d.y; })
  .on('drag', (e,d) => { d.fx = e.x; d.fy = e.y; })
  .on('end', (e,d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));
function shape(sel) {
  sel.each(function(d) {
    const g = d3.select(this), t = (d.type||'').toLowerCase(), r = 9 + Math.min(8, (deg.get(d.id)||0));
    const fill = t === 'python' ? '#ffd166' : t === 'fmu' ? '#8ecae6' : t === 'systemc' ? '#cdb4db' : '#e5e7eb';
    if (t === 'python') g.append('circle').attr('r', r).attr('fill', fill).attr('stroke','#374151');
    else if (t === 'fmu') g.append('path').attr('d', `M0,${-r-3}L${r+3},0L0,${r+3}L${-r-3},0Z`).attr('fill', fill).attr('stroke','#374151');
    else if (t === 'systemc') g.append('path').attr('d', d3.line()(d3.range(6).map(k => [Math.cos(k*Math.PI/3)*(r+2), Math.sin(k*Math.PI/3)*(r+2)])) + 'Z').attr('fill', fill).attr('stroke','#374151');
    else g.append('rect').attr('x', -r).attr('y', -r).attr('width', 2*r).attr('height', 2*r).attr('rx', 3).attr('fill', fill).attr('stroke','#374151');
    g.append('text').attr('dy', r + 11).attr('text-anchor','middle').text(d.name);
  });
}
node.call(shape);
const tip = d3.select('#tip');
node.on('mouseenter', (e,d) => {
  const sig = d.signals.map(s => `  ${s.dir === 'output' ? '->' : s.dir === 'input' ? '<-' : '<>'} ${s.name}:${s.type}`).join('\n') || '  (no signals)';
  const ports = d.ports.map(p => `  ${p.name}  ${p.gateway}${p.nodeType ? ' ' + p.nodeType : ''}`).join('\n') || '  (no ports)';
  tip.style('display','block').text(`${d.name}  [${d.type}${d.extra.fmu ? ' ' + d.extra.fmu : ''}]\nports:\n${ports}\nsignals:\n${sig}\nlinks: ${deg.get(d.id)||0}`);
}).on('mousemove', e => tip.style('left', (e.clientX + 14) + 'px').style('top', (e.clientY + 14) + 'px'))
  .on('mouseleave', () => tip.style('display','none'))
  .on('click', (e,d) => { const keep = new Set([d.id]); links.forEach(l => { if (l.source.id === d.id) keep.add(l.target.id); if (l.target.id === d.id) keep.add(l.source.id); });
    node.classed('dim', n => !keep.has(n.id)); link.classed('dim', l => !(l.source.id === d.id || l.target.id === d.id)); e.stopPropagation(); });
svg.on('click', () => { node.classed('dim', false); link.classed('dim', false); });
sim.on('tick', () => {
  link.attr('d', d => `M${d.source.x},${d.source.y}L${d.target.x},${d.target.y}`);
  llab.attr('x', d => (d.source.x + d.target.x)/2).attr('y', d => (d.source.y + d.target.y)/2 - 2);
  node.attr('transform', d => `translate(${d.x},${d.y})`);
});
let showLabels = false;
d3.select('#labels').on('click', () => { showLabels = !showLabels; llab.style('display', showLabels ? null : 'none'); });
d3.select('#reset').on('click', () => { node.classed('dim', false); link.classed('dim', false); d3.select('#q').property('value',''); sim.alpha(.8).restart(); });
d3.select('#q').on('input', function() { const q = this.value.toLowerCase(); if (!q) { node.classed('dim', false); link.classed('dim', false); return; }
  const hit = new Set(nodes.filter(n => n.name.toLowerCase().includes(q) || n.signals.some(s => s.name.toLowerCase().includes(q))).map(n => n.id));
  node.classed('dim', n => !hit.has(n.id)); link.classed('dim', l => !(hit.has(l.source.id) && hit.has(l.target.id))); });
const c = DATA.counts;
d3.select('#counts').html(Object.entries({components: c.components, ports: c.ports, signals: c.signals, interconnects: c.edges, ...Object.fromEntries(Object.entries(c.byType).map(([k,v]) => ['  ' + k + ' components', v]))}).map(([k,v]) => `<div class="k"><span>${k}</span><b>${v}</b></div>`).join(''));
d3.select('#proto').html(Object.entries(c.byProtocol).sort((a,b) => b[1]-a[1]).map(([k,v]) => `<div class="k"><span><span style="display:inline-block;width:10px;height:10px;background:${COL[k]||'#999'};margin-right:6px;border-radius:2px"></span>${k}</span><b>${v}</b></div>`).join(''));
d3.select('#leg').html(Object.entries(COL).filter(([k]) => !['socket','frame','ports'].includes(k)).map(([k,v]) => `<div><span style="background:${v}"></span>${k}</div>`).join(''));
</script></body></html>
"""


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("cmd")
    ap.add_argument("-o", "--out")
    ap.add_argument("--title")
    a = ap.parse_args()
    data = parse(a.cmd)
    title = a.title or os.path.basename(a.cmd).replace(".vsi.cmd", "")
    out = a.out or os.path.splitext(a.cmd)[0].replace(".vsi", "") + "_twin.html"
    html = (HTML.replace("__TITLE__", title).replace("__DATA__", json.dumps(data))
            .replace("__COLOURS__", json.dumps(GATEWAY_COLOURS))
            .replace("__HEADER__", "\n".join(data["header"][:8])))
    with open(out, "w", encoding="utf-8", newline="\n") as fh:
        fh.write(html)
    c = data["counts"]
    print("wrote {}: {} components ({}), {} ports, {} signals, {} interconnects {}".format(
        out, c["components"], ", ".join("{} {}".format(v, k) for k, v in c["byType"].items()),
        c["ports"], c["signals"], c["edges"], c["byProtocol"]))


if __name__ == "__main__":
    main()
