"""
sysml2vsi.py - a SysML v2 textual model drives an Innexis VSI digital twin.

    python sysml2vsi.py -i <model.sysml> -o <twin.vsi.cmd> [--twin NAME] [--workspace DIR]
                        [--sim-time NS] [--step NS] [--package NAME]

The reverse of vsi2sysml.py, and the transform that lets a block exist ONLY in SysML v2
(no Teamcenter port, no ICD row) and still land in the twin as a vsiBuild component
wired to the others. It reads the subset of SysML v2 the exporter writes, plus the two
extensions a hand-authored block needs, and nothing else:

    port def <Protocol>Port :> VsiPort ...            one per VSI gateway protocol
    abstract part def CppComponent | PythonComponent :> VsiComponent
    part def <Name>Def :> CppComponent|PythonComponent {
        port <p> : <Protocol>Port;                    -> add port ... -gateway <lang>2Dt<Protocol>
        in|out|inout attribute <s> : Integer;         -> define componentSignals (input|output|in_out)
    }
    part def Twin {
        part <name> : <Name>Def;                      -> add component -type C++|Python
        flow <f> from <a>.<s> to <b>.<t>;             -> connect signals (ports by the <port>_<signal> convention)
        connection <c> connect <a>.<p> to <b>.<q>;    -> Ethernet: add portSocket server(a)/client(b)
                                                         CAN/LIN: one shared frame per connected group
    }

Trailing `// ... tcp N`, `// frame 0xNN`, `// bus NAME` comments, which the exporter writes,
are read as hints (socket number, frame id, bus label). Absent hints are DERIVED and every
derivation is written to <out>.assumptions.md, the same contract as icd_to_vsi.py. The
model text is parsed by this file's own line grammar, not by the OMG pilot; validate the
model with validate_sysml.py first, this transform assumes it is well formed.

Exit 1, naming the line, on anything it does not understand that would change the twin
(an unknown base definition, a port protocol without a port def, a flow whose signal
matches no port); comments and doc blocks are skipped.
"""

import argparse
import os
import re
import sys
from collections import OrderedDict, defaultdict

LANG = {"CppComponent": "C++", "PythonComponent": "Python"}
GATEWAY_PREFIX = {"C++": "c++2Dt", "Python": "python2Dt"}
DIRECTION = {"in": "input", "out": "output", "inout": "in_out"}
FRAME_PROTOCOLS = ("Can", "Lin")
BASE_IP_PORT = 8800
CAN_BITS_PER_SIGNAL = 8
FRAME_ID_BASE = 0x100

RE_PORT_DEF = re.compile(r"^\s*port def (\w+)Port\b")
RE_LANG_DEF = re.compile(r"^\s*abstract part def (\w+) :> VsiComponent\s*;")
RE_PART_DEF = re.compile(r"^\s*part def (\w+) :> (\w+)\s*\{")
RE_PORT = re.compile(r"^\s*port (\w+) : (\w+)Port\s*;")
RE_ATTR = re.compile(r"^\s*(in|out|inout) attribute (\w+) : (\w+)\s*;")
RE_PART = re.compile(r"^\s*part (\w+) : (\w+)\s*;")
RE_FLOW = re.compile(r"^\s*flow (\w+) from (\w+)\.(\w+) to (\w+)\.(\w+)\s*;(.*)$")
RE_CONN = re.compile(r"^\s*connection (\w+) connect (\w+)\.(\w+) to (\w+)\.(\w+)\s*;(.*)$")
RE_TWIN = re.compile(r"^\s*part def (\w+)\s*\{")
RE_HINT_TCP = re.compile(r"tcp (\d+)")
RE_HINT_FRAME = re.compile(r"frame (0x[0-9A-Fa-f]+|\d+)")
RE_HINT_BUS = re.compile(r"bus (\S.*?)\s*$")


def bus_label(hint, default):
    """the bus name for a `# bus <name> [<protocol>]` comment. apply_glue_generic.py keys
    its per-bus periods on that comment and reads the name as one token, so spaces in a
    hint ("MSP2 CardDiscretes") become underscores; the generator's names have none."""
    m = RE_HINT_BUS.search(hint) if hint else None
    return re.sub(r"\s+", "_", (m.group(1) if m else default).strip())


def strip_comments(text):
    """drop /* */ blocks (doc) and return (code, trailing // comment) per line"""
    text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
    out = []
    for n, line in enumerate(text.split("\n"), 1):
        code, _, comment = line.partition("//")
        out.append((n, code, comment.strip()))
    return out


def parse(path):
    lines = strip_comments(open(path, encoding="utf-8").read())
    protocols = set()
    langs = {}                      # base def name -> C++ | Python
    defs = OrderedDict()            # def name -> {"lang", "ports": [(name, protocol)], "signals": [(name, dir, type)]}
    parts = OrderedDict()           # part name -> def name
    flows, conns = [], []
    problems = []
    cur = None                      # current part def being filled
    in_twin = False
    for n, code, comment in lines:
        s = code.strip()
        if not s:
            continue
        m = RE_PORT_DEF.match(code)
        if m:
            protocols.add(m.group(1)); continue
        m = RE_LANG_DEF.match(code)
        if m:
            name = m.group(1)
            if name not in LANG:
                problems.append("line {}: unknown component language definition {!r} (know {})".format(n, name, ", ".join(LANG)))
            else:
                langs[name] = LANG[name]
            continue
        m = RE_PART_DEF.match(code)
        if m and m.group(2) in langs:
            cur = m.group(1)
            defs[cur] = {"lang": langs[m.group(2)], "ports": [], "signals": []}
            continue
        if m and m.group(2) not in langs:
            problems.append("line {}: part def {} specialises {!r}, not a declared component language".format(n, m.group(1), m.group(2)))
            cur = None; continue
        m = RE_TWIN.match(code)
        if m:
            in_twin = True; cur = None; continue
        if cur:
            m = RE_PORT.match(code)
            if m:
                if m.group(2) not in protocols:
                    problems.append("line {}: port {} typed {}Port but no such port def".format(n, m.group(1), m.group(2)))
                defs[cur]["ports"].append((m.group(1), m.group(2))); continue
            m = RE_ATTR.match(code)
            if m:
                defs[cur]["signals"].append((m.group(2), m.group(1), m.group(3))); continue
            if s == "}":
                cur = None; continue
        if in_twin:
            m = RE_PART.match(code)
            if m:
                if m.group(2) not in defs:
                    problems.append("line {}: part {} typed {} which is not a component definition".format(n, m.group(1), m.group(2)))
                else:
                    parts[m.group(1)] = m.group(2)
                continue
            m = RE_FLOW.match(code)
            if m:
                flows.append({"line": n, "name": m.group(1), "src": m.group(2), "ssig": m.group(3),
                              "dst": m.group(4), "dsig": m.group(5), "hint": comment}); continue
            m = RE_CONN.match(code)
            if m:
                conns.append({"line": n, "name": m.group(1), "a": m.group(2), "ap": m.group(3),
                              "b": m.group(4), "bp": m.group(5), "hint": comment}); continue
            if s == "}":
                in_twin = False; continue
    return {"protocols": protocols, "defs": defs, "parts": parts, "flows": flows, "conns": conns, "problems": problems}


def port_of(defn, signal):
    """the port a signal belongs to, by the exporter's <port>_<signal> naming (port <x>_p)"""
    best = None
    for pname, proto in defn["ports"]:
        base = pname[:-2] if pname.endswith("_p") else pname
        if signal.startswith(base + "_") and (best is None or len(base) > len(best[0])):
            best = (base, pname, proto)
    return best[1] if best else None


def emit(model, args):
    L, assumptions = [], []
    w = L.append
    parts, defs = model["parts"], model["defs"]

    w("# Innexis VSI Builder command file")
    w("# GENERATED by sysml2vsi.py from {} - do not hand-edit, regenerate.".format(os.path.basename(args.model)))
    w("# components: {}   flows: {}   connections: {}".format(len(parts), len(model["flows"]), len(model["conns"])))
    w("#")
    w("set digitalTwinName {}".format(args.twin))
    w("set workspaceDir {}".format(args.workspace))
    w("set digitalTwinPlatform mingw64")
    w("set simulationStep {}".format(args.step))
    w("set totalSimTime {}".format(args.sim_time))
    w("")
    w("# --- components and ports ---------------------------------------------")
    for name, dname in parts.items():
        d = defs[dname]
        w("add component -type {} -name {}".format(d["lang"], name))
        for pname, proto in d["ports"]:
            w("add port -componentName {} -name {} -gateway {}{}".format(name, pname, GATEWAY_PREFIX[d["lang"]], proto))
    w("")
    w("# --- signals -------------------------------------------------------------")
    for name, dname in parts.items():
        d = defs[dname]
        if not d["signals"]:
            assumptions.append("{}: no signals declared; vsiBuild refuses a component without signals".format(name))
            continue
        w("define componentSignals -componentName {} -signals [{}]".format(
            name, ",".join("{}:int:{}".format(s, DIRECTION[dr]) for s, dr, _ in d["signals"])))
    types = {t for d in defs.values() for _, _, t in d["signals"]}
    if types - {"Integer"}:
        assumptions.append("attribute types {} emitted as int: the twin carries int signals only".format(sorted(types - {"Integer"})))
    w("")
    w("# --- connectivity ------------------------------------------------------")
    problems = []
    # Ethernet connections -> portSocket pairs; CAN/LIN connections -> frames
    ip_port = BASE_IP_PORT
    frame_groups = defaultdict(list)     # (frame id or None, bus) -> [(comp, port)]
    for c in model["conns"]:
        pa = dict(defs[parts[c["a"]]]["ports"]).get(c["ap"]) if c["a"] in parts else None
        pb = dict(defs[parts[c["b"]]]["ports"]).get(c["bp"]) if c["b"] in parts else None
        if pa is None or pb is None:
            problems.append("line {}: connection {} references an unknown part or port".format(c["line"], c["name"])); continue
        bus = bus_label(c["hint"], c["name"])
        if pa == "Ethernet" and pb == "Ethernet":
            m = RE_HINT_TCP.search(c["hint"])
            port = int(m.group(1)) if m else ip_port
            if not m:
                assumptions.append("connection {}: no tcp hint, ip port {} assigned in order".format(c["name"], port))
            ip_port = max(ip_port, port + 1)
            w("# bus {} [Ethernet] connection {}".format(bus, c["name"]))
            w("add portSocket -componentName {} -portName {} -ipPortNum {} -socketType server -protocol tcpIp".format(c["a"], c["ap"], port))
            w("add portSocket -componentName {} -portName {} -ipPortNum {} -socketType client -protocol tcpIp".format(c["b"], c["bp"], port))
            assumptions.append("connection {}: {} taken as the tcp server and {} as the client (the exporter writes server first)".format(c["name"], c["a"], c["b"]))
        elif pa in FRAME_PROTOCOLS and pb in FRAME_PROTOCOLS:
            m = RE_HINT_FRAME.search(c["hint"])
            fid = m.group(1) if m else None
            key = (fid, bus)
            for end in ((c["a"], c["ap"]), (c["b"], c["bp"])):
                if end not in frame_groups[key]:
                    frame_groups[key].append(end)
        else:
            problems.append("line {}: connection {} joins {} and {} ports; only Ethernet-Ethernet and Can/Lin pairs are understood".format(c["line"], c["name"], pa, pb))
    for i, ((fid, bus), members) in enumerate(frame_groups.items()):
        if fid is None:
            fid = hex(FRAME_ID_BASE + i)
            assumptions.append("bus {}: no frame hint, frame id {} assigned".format(bus, fid))
        w("# bus {} [Can] frame {} on {} port(s)".format(bus, fid, len(members)))
        for comp, port in members:
            sigs = [s for s, _, _ in defs[parts[comp]]["signals"] if port_of(defs[parts[comp]], s) == port]
            layout = ",".join("{}:{}:{}".format(s, k * CAN_BITS_PER_SIGNAL, CAN_BITS_PER_SIGNAL) for k, s in enumerate(sigs))
            w("define frame -componentName {} -portName {} -id {} -signals [{}] -idType standard".format(comp, port, fid, layout))
        assumptions.append("bus {}: frame layout {} bits per signal in declaration order (the model carries no bit layout)".format(bus, CAN_BITS_PER_SIGNAL))
    # flows -> connect signals, ports by naming convention
    by_bus = OrderedDict()
    for f in model["flows"]:
        if f["src"] not in parts or f["dst"] not in parts:
            problems.append("line {}: flow {} references an unknown part".format(f["line"], f["name"])); continue
        sp = port_of(defs[parts[f["src"]]], f["ssig"])
        dp = port_of(defs[parts[f["dst"]]], f["dsig"])
        if not sp or not dp:
            problems.append("line {}: flow {}: signal {} or {} matches no port by the <port>_<signal> convention".format(
                f["line"], f["name"], f["ssig"], f["dsig"])); continue
        bus = bus_label(f["hint"], f["name"])
        by_bus.setdefault(bus, []).append((f, sp, dp))
    for bus, items in by_bus.items():
        proto = dict(defs[parts[items[0][0]["src"]]]["ports"])[items[0][1]]
        w("# bus {} [{}] {} flow(s)".format(bus, proto, len(items)))
        for f, sp, dp in items:
            w("connect signals -sourceSignal {}.{} -destSignal {}.{} -sourcePortName {} -destPortName {}".format(
                f["src"], f["ssig"], f["dst"], f["dsig"], sp, dp))
    w("")
    w("generate -overwrite -no-build")
    w("quit")
    return L, assumptions, problems


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("-i", "--model", required=True)
    ap.add_argument("-o", "--out", required=True)
    ap.add_argument("--twin", default="SysmlDigitalTwin")
    ap.add_argument("--workspace", default="./workspace")
    ap.add_argument("--sim-time", default="10000000")
    ap.add_argument("--step", default="100000")
    args = ap.parse_args()
    model = parse(args.model)
    lines, assumptions, problems = emit(model, args)
    problems = model["problems"] + problems
    if problems:
        print("sysml2vsi: {} problem(s), nothing written:".format(len(problems)), file=sys.stderr)
        for p in problems:
            print("  " + p, file=sys.stderr)
        sys.exit(1)
    with open(args.out, "w", encoding="utf-8", newline="\n") as fh:
        fh.write("\n".join(lines) + "\n")
    with open(args.out + ".assumptions.md", "w", encoding="utf-8", newline="\n") as fh:
        fh.write("# Assumptions made by sysml2vsi.py for {}\n\n".format(os.path.basename(args.out)))
        fh.write("Source model: {}\n\n".format(args.model))
        for a in assumptions:
            fh.write("- {}\n".format(a))
    langs = defaultdict(int)
    for dname in model["parts"].values():
        langs[model["defs"][dname]["lang"]] += 1
    print("sysml2vsi: {} component(s) ({}), {} port(s), {} flow(s), {} connection(s) -> {}; {} assumption(s) in {}".format(
        len(model["parts"]), ", ".join("{} {}".format(v, k) for k, v in sorted(langs.items())),
        sum(len(model["defs"][d]["ports"]) for d in model["parts"].values()),
        len(model["flows"]), len(model["conns"]), args.out, len(assumptions), args.out + ".assumptions.md"))


if __name__ == "__main__":
    main()
