"""
vsi2sysml.py - a SysML v2 textual model of a VSI digital twin's interconnect,
from its vsiBuild command file, plus (optionally) the SMSv2 diagram file for it.

    python vsi2sysml.py <twin.vsi.cmd> [-o out.sysml] [--package NAME] [--views out_views.sysml]

Mapping (VSI -> SysML v2):
    component language (C++, Python, Fmu, SystemC)  -> abstract part def per language
    component                                       -> part def <Name>Def :> <LanguageDef>, and a part usage in the twin
    VSI port + gateway                              -> port usage typed by a port def per protocol (EthernetPort, CanPort ...)
    typed, directed signal                          -> in / out / inout attribute on the part def, typed from ScalarValues
    connect signals                                 -> NAMED flow: `flow f_<n> from <src>.<sig> to <dst>.<sig>`
    add portSocket (server/client on one ipPortNum) -> NAMED connection: `connection c_<n> connect <server>.<port> to <client>.<port>`
    define frame (CAN/LIN)                          -> named connection between every pair of ports sharing the frame id
    connect ports                                   -> named connection <a>.<port> to <b>.<port>
    ICD generator's "# bus" comments                -> line comments on the connections

Every flow and connection is NAMED because an SMSv2 edge view must `expose` it.
Validated by the OMG pilot (validate_sysml.py): `private import` (a bare import
is refused), `flow <name> from ... to ...;` and `connection <name> connect ...;`
are the accepted forms (EXERCISED 2026-09-06).

--views writes a second file, package <NAME>Views, in the shape of the QX-250
diagram file that SMSv2 renders (IBM view library: InterconnectionView, NodeView,
EdgeView, asNodeUsage/asNodePort/asNodeDefinition, asEdgeConnector /
asEdgeFlowConnection, React-Flow graphicalData). Import it TOGETHER with the model
file; do not run the pilot on it (IBM library elements are not in the standard
library). The importer adds IBMViewsImport itself: declare no view imports.
"""

import argparse
import json
import os
import re

from vsi_cmd import parse

SCALAR = {"int": "Integer", "double": "Real", "float": "Real", "string": "String", "bool": "Boolean",
          "char": "Integer", "short": "Integer", "long": "Integer", "unsigned": "Natural"}
LANG_DEF = {"c++": "CppComponent", "python": "PythonComponent", "fmu": "FmuComponent", "systemc": "SystemCComponent",
            "ros": "RosComponent", "simulink": "SimulinkComponent"}

NODE_W, NODE_H, COL_GAP, ROW_GAP, COLS = 260, 110, 320, 170, 3


def ident(name):
    if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
        return name
    return "'" + name.replace("'", "") + "'"


def _comp(data, name):
    return next(c for c in data["components"] if c["name"] == name)


class Names:
    """Unique names for flows and connections, shared by the model and the views."""
    def __init__(self):
        self.used = set()

    def make(self, prefix, *parts):
        base = prefix + "_" + "_".join(re.sub(r"[^A-Za-z0-9_]", "_", p) for p in parts)
        base = re.sub(r"_+", "_", base)[:80]
        n, cand = 1, base
        while cand in self.used:
            n += 1
            cand = "{}_{}".format(base, n)
        self.used.add(cand)
        return cand


def render(data, package):
    """-> (model text, links) where links = [(kind, name, src comp, src feature, tgt comp, tgt feature, bus)]"""
    L = []
    w = L.append
    names = Names()
    links = []
    w("package {} {{".format(ident(package)))
    w("    doc /* Interconnect of an Innexis VSI digital twin, generated by vsi2sysml.py.")
    for h in data["header"][:6]:
        w("         {}".format(h.replace("*/", "* /")))
    w("       */")
    # 'private import': the OMG pilot 2026-07 (kernel 0.61.0) rejects a bare
    # 'import' inside a package ("mismatched input 'import' expecting '}'"),
    # EXERCISED 2026-09-06 with validate_sysml.py.
    w("    private import ScalarValues::*;")
    w("")
    protocols = sorted({p["protocol"] for c in data["components"] for p in c["ports"]} | {e["protocol"] for e in data["edges"] if e["protocol"] not in ("socket", "frame", "ports")})
    w("    // one port definition per VSI gateway protocol")
    w("    abstract port def VsiPort;")
    for p in protocols:
        if p == "Ethernet":
            w("    port def EthernetPort :> VsiPort { attribute ipPort : Natural; attribute transport : String; }")
        elif p in ("Can", "Lin"):
            w("    port def {}Port :> VsiPort {{ attribute frameId : String; }}".format(p))
        else:
            w("    port def {}Port :> VsiPort;".format(p))
    w("")
    langs = sorted({c["type"].lower() for c in data["components"]})
    w("    // one abstract definition per component language")
    w("    abstract part def VsiComponent;")
    for l in langs:
        w("    abstract part def {} :> VsiComponent;".format(LANG_DEF.get(l, ident(l.capitalize() + "Component"))))
    w("")
    w("    // component definitions: ports and typed, directed signals")
    for c in data["components"]:
        base = LANG_DEF.get(c["type"].lower(), ident(c["type"].capitalize() + "Component"))
        w("    part def {} :> {} {{".format(ident(c["name"] + "Def"), base))
        if c["extra"].get("fmu"):
            w("        attribute fmu : String = \"{}\";".format(c["extra"]["fmu"]))
        for p in c["ports"]:
            w("        port {} : {}Port;{}".format(ident(p["name"]), p["protocol"],
                                                    "  // nodeType " + p["nodeType"] if p.get("nodeType") else ""))
        for s in c["signals"]:
            d = {"output": "out", "input": "in"}.get(s["dir"], "inout")
            w("        {} attribute {} : {};".format(d, ident(s["name"]), SCALAR.get(s["type"], "Integer")))
        w("    }")
    w("")
    w("    part def Twin {")
    for c in data["components"]:
        w("        part {} : {};".format(ident(c["name"]), ident(c["name"] + "Def")))
    w("")
    w("        // interconnect (every flow and connection named, so a diagram edge can expose it)")
    seen = set()
    for e in data["edges"]:
        bus = "  // bus {}".format(e["bus"]) if e.get("bus") else ""
        if e["kind"] == "signal":
            tl = e.get("target_label") or e["label"]
            n = names.make("f", e["source"], e["label"], e["target"])
            w("        flow {} from {}.{} to {}.{};{}".format(n, ident(e["source"]), ident(e["label"]), ident(e["target"]), ident(tl), bus))
            links.append(("flow", n, e["source"], e["label"], e["target"], tl, e.get("bus")))
        elif e["kind"] == "socket":
            key = (e["source"], e["target"], e["label"])
            if key in seen:
                continue
            seen.add(key)
            sp = next((p["name"] for p in _comp(data, e["source"])["ports"] if p["protocol"] == "Ethernet"), None)
            tp = next((p["name"] for p in _comp(data, e["target"])["ports"] if p["protocol"] == "Ethernet"), None)
            if sp and tp:
                n = names.make("c", e["source"], e["target"])
                w("        connection {} connect {}.{} to {}.{};  // {}{}".format(n, ident(e["source"]), ident(sp), ident(e["target"]), ident(tp), e["label"], bus.replace("  //", ",")))
                links.append(("connection", n, e["source"], sp, e["target"], tp, e.get("bus")))
        elif e["kind"] == "frame":
            sp = next((p["name"] for p in _comp(data, e["source"])["ports"] if p["protocol"] in ("Can", "Lin")), None)
            tp = next((p["name"] for p in _comp(data, e["target"])["ports"] if p["protocol"] in ("Can", "Lin")), None)
            if sp and tp:
                n = names.make("c", e["source"], e["target"])
                w("        connection {} connect {}.{} to {}.{};  // {}{}".format(n, ident(e["source"]), ident(sp), ident(e["target"]), ident(tp), e["label"], bus.replace("  //", ",")))
                links.append(("connection", n, e["source"], sp, e["target"], tp, e.get("bus")))
        elif e["kind"] == "ports":
            a, b = e["label"].split(" <-> ")
            n = names.make("c", e["source"], e["target"])
            w("        connection {} connect {}.{} to {}.{};{}".format(n, ident(e["source"]), ident(a), ident(e["target"]), ident(b), bus))
            links.append(("connection", n, e["source"], a, e["target"], b, e.get("bus")))
    w("    }")
    w("")
    w("    part twin : Twin;")
    w("}")
    return "\n".join(L) + "\n", links


# ---- views ---------------------------------------------------------------------

def gd(obj):
    return 'rep graphicalData language "JSON"\n            /* {} */'.format(json.dumps(obj, separators=(",", ":")))


def group_links(data, links, per_bus):
    """[(title, doc, components, links)] : one diagram, or one per bus for large twins."""
    comps = data["components"]
    if not per_bus:
        return [(None, None, comps, links)]
    by_bus = {}
    for l in links:
        by_bus.setdefault(l[6] or "unassigned", []).append(l)
    out = []
    for bus, ls in by_bus.items():
        conns = [l for l in ls if l[0] == "connection"]
        # a CAN/LIN frame bus arrives as every pair: keep the first source's links only
        if conns and len({(l[2], l[4]) for l in conns}) > len({l[2] for l in conns}) + 1:
            owner = conns[0][2]
            ls = [l for l in ls if l[0] != "connection" or l[2] == owner]
        names = {l[2] for l in ls} | {l[4] for l in ls}
        gcomps = [c for c in comps if c["name"] in names]
        out.append((bus, "Bus {}: {} component(s), {} link(s) of the twin's interconnect.".format(bus, len(gcomps), len(ls)), gcomps, ls))
    return out


def emit_interconnect(w, ident, package, root, gtitle, gdoc, comps, links, prefix=""):
    # prefix: per-bus diagrams share components, and a view name reused across
    # diagrams made `dependency relation from A to B` resolve into ANOTHER
    # diagram's views (PayloadEthernet rendered 0 of 10 edges, PayloadControlBus
    # 12 of 21; EXERCISED 2026-09-06). Every view name is unique per diagram now.
    title = gtitle if gtitle else None
    w("    view {} : InterconnectionView {{".format(ident(title if title else package + " Interconnect")))
    w("        doc /* {} */".format(gdoc or "Every component of the {} digital twin with its VSI ports; connections are Ethernet sockets, CAN frames or port links, flows are the connected signals. Generated from the vsiBuild command file.".format(package)))
    w("        render asInterconnectionDiagram;")
    w("        expose {};".format(root))
    w("")
    # layout: sources (components that only send) left, sinks right, others middle, by column
    outdeg = {c["name"]: 0 for c in comps}
    indeg = {c["name"]: 0 for c in comps}
    for kind, n, sc, sf, tc, tf, bus in links:
        outdeg[sc] += 1
        indeg[tc] += 1
    def col_of(c):
        o, i = outdeg[c["name"]], indeg[c["name"]]
        return 0 if (o and not i) else 2 if (i and not o) else 1
    columns = {0: [], 1: [], 2: []}
    for c in comps:
        columns[col_of(c)].append(c)
    pos = {}
    for col, lst in columns.items():
        for row, c in enumerate(lst):
            pos[c["name"]] = (40 + col * (NODE_W + 120), 40 + row * ROW_GAP)
    # port view names
    pv = {}
    for c in comps:
        cname = c["name"]
        x, y = pos[cname]
        vn = ident(prefix + cname + "View")
        w("        view {} : NodeView {{".format(vn))
        w("            render asNodeUsage;")
        w("            expose {}::{};".format(root, ident(cname)))
        w("            " + gd({"position": {"x": x, "y": y}, "type": "interconnectNode", "width": NODE_W, "height": NODE_H,
                              "graphicalRepresentation": {}, "textualRepresentation": {}}))
        ports = c["ports"]
        for i, p in enumerate(ports):
            side = "right" if col_of(c) < 2 else "left"
            ratio = round(100.0 * (i + 1) / (len(ports) + 1), 3)
            py = int(NODE_H * (i + 1) / (len(ports) + 1)) - 6
            px = NODE_W - 6 if side == "right" else -6
            pvn = ident(prefix + cname + "View_" + p["name"])
            pv[(cname, p["name"])] = pvn
            w("")
            w("            view {} : NodeView {{".format(pvn))
            w("                render asNodePort;")
            w("                expose {}::{}::{};".format(root, ident(cname), ident(p["name"])))
            w("                " + gd({"width": 12, "height": 12, "type": "portNode",
                                      "portNode": {"relativePosition": ratio, "edgePosition": side, "lockState": "unlocked", "manuallyPlaced": False},
                                      "graphicalRepresentation": {}, "textualRepresentation": {"width": 12, "height": 12},
                                      "position": {"x": px, "y": py}}))
            w("            }")
        w("        }")
        w("")
    for kind, n, sc, sf, tc, tf, bus in links:
        w("        view {} : EdgeView {{".format(ident(prefix + n + "View")))
        if kind == "connection":
            w("            render asEdgeConnector;")
            src_v, tgt_v = pv[(sc, sf)], pv[(tc, tf)]
        else:
            w("            render asEdgeFlowConnection;")
            src_v, tgt_v = ident(prefix + sc + "View"), ident(prefix + tc + "View")   # flows join attributes, drawn node to node
        w("            expose {}::{};".format(root, n))
        w("            " + gd({"sourceSide": "right", "sourceRatio": 0.5, "targetSide": "left", "targetRatio": 0.5}))
        w("            dependency relation from {} to {};".format(src_v, tgt_v))
        w("        }")
        w("")
    w("    }")
    w("")


def render_views(data, package, links, title, per_bus=False):
    """The SMSv2 diagram file: one interconnection diagram of the twin, one of the definitions."""
    V = []
    w = V.append
    comps = data["components"]
    vpkg = ident(package + "Views")
    root = "{}::Twin".format(ident(package))
    w("/*")
    w(" * Diagrams for the {} twin, generated by vsi2sysml.py --views. Edit the generator, not this file.".format(package))
    w(" * Not for the OMG pilot kernel: the view definitions and renderings are IBM library")
    w(" * elements with no counterpart in the standard library. The SMSv2 import is this file's oracle.")
    w(" */")
    w("package {} {{".format(vpkg))
    # Large twins get ONE DIAGRAM PER BUS: the SMSv2 renderer's headless Chrome
    # times out (60 s navigation) on a single 63-node / 320-edge diagram
    # (EXERCISED 2026-09-06 on the Wildfire twin), and nobody could read it anyway.
    # A CAN bus is drawn from its frame owner (the first definer) to every other
    # node, not as every pair, so 22 nodes give 21 edges instead of 231.
    groups = group_links(data, links, per_bus)
    for gtitle, gdoc, gcomps, glinks in groups:
        emit_interconnect(w, ident, package, root, gtitle if gtitle else title, gdoc, gcomps, glinks,
                          prefix=(re.sub(r"[^A-Za-z0-9_]", "_", gtitle) + "_") if gtitle else "")
    # ---- diagram 2: definitions ----
    w("    view {} : InterconnectionView {{".format(ident(title + " Definitions")))
    w("        doc /* The part definitions the twin instantiates (one per VSI component, specialising the language definition) and the port definitions, one per VSI gateway protocol. */")
    w("        render asInterconnectionDiagram;")
    w("        expose {};".format(ident(package)))
    defs = [c["name"] + "Def" for c in comps]
    for i, d in enumerate(defs):
        w("        view {} : NodeView {{".format(ident(d + "View")))
        w("            render asNodeDefinition;")
        w("            expose {}::{};".format(ident(package), ident(d)))
        w("            " + gd({"position": {"x": 40 + (i % 4) * 290, "y": 40 + (i // 4) * 140}, "type": "interconnectNode",
                              "width": NODE_W, "height": 100, "graphicalRepresentation": {}, "textualRepresentation": {}}))
        w("        }")
    protocols = sorted({p["protocol"] for c in comps for p in c["ports"]})
    for i, p in enumerate(protocols):
        w("        view {} : NodeView {{".format(ident(p + "PortDefView")))
        w("            render asNodeDefinition;")
        w("            expose {}::{}Port;".format(ident(package), p))
        w("            " + gd({"position": {"x": 40 + i * 290, "y": 40 + ((len(defs) + 3) // 4) * 140 + 60}, "type": "interconnectNode",
                              "width": NODE_W, "height": 100, "graphicalRepresentation": {}, "textualRepresentation": {}}))
        w("        }")
    w("    }")
    w("}")
    return "\n".join(V) + "\n"


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("cmd")
    ap.add_argument("-o", "--out")
    ap.add_argument("--package")
    ap.add_argument("--views", help="also write the SMSv2 diagram file here")
    ap.add_argument("--title", help="diagram title (default '<package> Interconnect')")
    ap.add_argument("--views-per-bus", action="store_true", help="one interconnection diagram per bus (large twins)")
    a = ap.parse_args()
    data = parse(a.cmd)
    pkg = a.package or re.sub(r"[^A-Za-z0-9_]", "_", os.path.basename(a.cmd).replace(".vsi.cmd", "")) + "_Twin"
    out = a.out or os.path.splitext(a.cmd)[0].replace(".vsi", "") + ".sysml"
    text, links = render(data, pkg)
    with open(out, "w", encoding="utf-8", newline="\n") as fh:
        fh.write(text)
    c = data["counts"]
    print("wrote {}: package {}, {} part defs, {} ports, {} signals, {} named flow/connection(s)".format(
        out, pkg, c["components"], c["ports"], c["signals"], len(links)))
    if a.views:
        vt = render_views(data, pkg, links, a.title or (pkg + " Interconnect"), per_bus=a.views_per_bus)
        with open(a.views, "w", encoding="utf-8", newline="\n") as fh:
            fh.write(vt)
        print("wrote {}: {} node views, {} port views, {} edge views, plus a definitions diagram".format(
            a.views, c["components"], c["ports"], len(links)))


if __name__ == "__main__":
    main()
