#!/usr/bin/env python3
"""
icd_to_vsi.py - turn an extracted ICD into an Innexis VSI Builder command file.

    python icd_to_vsi.py -i wildfire-icd.json -m gateway_map.json \
                         -o wildfire.vsi.cmd --twin WildfireDT

Reads nothing from Teamcenter. Pure, deterministic transform, so it can be
diffed and re-run without touching the tier or holding a VSI licence.

MODEL
-----
component = a Teamcenter PORT (Fnd0LogicIntrfce) by default. Ports are the
things that sit on a bus and carry a direction, so they map onto VSI clients
directly. With --owners (owners.json from port_owners.py, the BOM-structure
read) a component is the owning Fnd0LogicalBlock instead: a block's Ethernet
ports merge into one VSI port, other gateways stay one port per component
(second ones become <Block>__<port> satellites), and blocks are folded into
their parent, best saving first, until --group-cap fits. EXERCISED 2026-09-06:
the full Wildfire ICD, 30 buses / 152 ports, generates as 63 components.

bus       = the Seg0Interface a port is assigned to, via Seg0InterfaceAssignment
            (a real uid reference, never a name match).

CONNECTIVITY IS PROTOCOL-SPECIFIC. vsiBuild does not use one command
(grammar taken from vsiBuild 2026.2's own `help`, EXERCISED 2026-09-05, not
from the PDF, which differs in three places):
    Ethernet          add portSocket  (server + client on one ipPortNum)
    Can / Lin         define frame    (frame id + bit layout, no braces)
    Gpio/Uart/Spi     connect signals (source.signal -> dest.signal)
    Pwm/Axi/GenPayld  connect ports   (component.port <-> component.port)
Every component signal is declared as name:type:direction. There is no
c++2DtUart gateway in this build, so Uart buses ride c++2DtGenericPayload.

WHAT IT WILL NOT INVENT
-----------------------
A bus with fewer than two assigned ports gets no connectivity, and is reported.
An interface with no port assigned to it at all is reported. Nothing is
silently dropped, and no topology is guessed.
"""

import argparse
import json
import re
import sys
from collections import Counter, defaultdict

VALID_PROTOCOLS = {"Ethernet", "Can", "Lin", "Spi", "Uart",
                   "Pwm", "Gpio", "Axi", "GenericPayload"}

COMPONENT_TYPE = "C++"
GATEWAY_PREFIX = "c++2Dt"
MAX_SOFT_COMPONENTS = 64          # user guide 3-6
BASE_IP_PORT = 8800               # first TCP socket number handed out
CAN_BITS_PER_SIGNAL = 8           # ASSUMED layout, see assumptions report

# Gateways this build offers for C++ components (vsiBuild 2026.2 `help`):
# Ethernet, Can, Gpio, Lin, Pwm, Spi, GenericPayload. Two protocols cannot
# join two generated C++ components (EXERCISED 2026-09-05):
#   Uart: there is NO c++2DtUart (only vpUart2DtUart / physicalUart2DtUart)
#   Gpio: c++2DtGpio exists but "connecting signals of ports that use GPIO
#         protocol is only allowed between Virtual platform and other
#         supported components"
# so both ride GenericPayload between C++ components.
GATEWAY_FOR = {"Uart": "GenericPayload", "Gpio": "GenericPayload"}
NODE_TYPED = {"Spi", "Lin"}       # `add port` requires -nodeType master/slave
# Protocols whose buses are wired signal-by-signal with `connect signals`. Ethernet is
# wired by portSocket alone because Teamcenter records no signal-level mapping for it
# (rm-xq7x89ob is the open item). This is the ONE place that says so: the roles pass,
# the connectivity pass and the --python-blocks demotion all read it, so adding
# "Ethernet" here the day the mapping exists changes all three together.
SIGNAL_WIRED = ("Gpio", "Uart", "Spi")
SIGNAL_TYPE = "int"               # ASSUMED: Teamcenter records no signal data type
DIRECTION = {"Output": "output", "Input": "input"}   # fnd0Direction -> vsiBuild
DIRECTION_DEFAULT = "in_out"      # Bi-Directional, or nothing recorded


def assert_vocabulary(gmap):
    """Fail before a run rather than mis-grade during one."""
    classes = set(gmap["interface_classes"])
    problems = []
    for p in gmap["vsi_protocols"]:
        if p not in VALID_PROTOCOLS:
            problems.append("vsi_protocols has {!r}; valid: {}".format(
                p, sorted(VALID_PROTOCOLS)))
    for key in gmap["class_carries_protocol"]:
        if key not in classes:
            problems.append("class_carries_protocol has unknown class {!r}".format(key))
    for cls in classes:
        if cls not in gmap["class_carries_protocol"]:
            problems.append("class {!r} has no class_carries_protocol entry".format(cls))
    for i, rule in enumerate(gmap["rules"]):
        if rule["class"] not in classes:
            problems.append("rule[{}] unknown class {!r}".format(i, rule["class"]))
        if rule["protocol"] is not None and rule["protocol"] not in VALID_PROTOCOLS:
            problems.append("rule[{}] unknown protocol {!r}".format(i, rule["protocol"]))
        try:
            re.compile(rule["match"])
        except re.error as e:
            problems.append("rule[{}] regex {!r}: {}".format(i, rule["match"], e))
    if problems:
        sys.exit("gateway_map.json is not usable:\n  - " + "\n  - ".join(problems))


def sanitize(name):
    s = re.sub(r"[^A-Za-z0-9_]", "_", name or "")
    s = re.sub(r"_+", "_", s).strip("_")
    if s and s[0].isdigit():
        s = "n" + s
    return s or "unnamed"


def classify(iface, gmap):
    """(status, class, protocol, why) from the declared rules."""
    name = iface["name"]
    for rule in gmap["rules"]:
        if re.search(rule["match"], name):
            cls = rule["class"]
            if not gmap["class_carries_protocol"][cls]:
                return ("excluded", cls, None,
                        gmap["exclusion_reasons"].get(cls, "carries no protocol"))
            return ("classified", cls, rule["protocol"], rule["why"])
    return ("unresolved", None, None,
            "no rule matched {!r}".format(name))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("-i", "--icd", required=True)
    ap.add_argument("-m", "--map", default="gateway_map.json")
    ap.add_argument("-o", "--out", required=True)
    ap.add_argument("--twin", default="IcdDigitalTwin")
    ap.add_argument("--workspace", default="./workspace")
    ap.add_argument("--sim-time", default="10000000")
    ap.add_argument("--step", default="100000")
    ap.add_argument("--max-components", type=int, default=0,
                    help="DIAGNOSTIC ONLY: keep whole buses in order until this "
                         "many components would be exceeded, drop the rest and "
                         "say so loudly. vsiBuild caps local C++ components at 64.")
    ap.add_argument("--build", action="store_true",
                    help="let vsiBuild compile the twin after generating "
                         "(default: generate the sources only, -no-build)")
    ap.add_argument("--owners", help="owners.json from port_owners.py: group ports under their "
                                     "owning logical block, one VSI component per block")
    ap.add_argument("--exclude-ports", default="",
                    help="regex; Teamcenter ports whose NAME matches are dropped from every bus before "
                         "anything else, and listed in the assumptions (probe and duplicate ports)")
    ap.add_argument("--exclude-blocks", default="",
                    help="regex, needs --owners; ports owned by a block whose NAME matches are dropped "
                         "(e.g. one design candidate of two), and listed in the assumptions")
    ap.add_argument("--python-blocks", default="",
                    help="regex; components whose name matches are emitted as vsiBuild "
                         "Python components (-type Python, python2Dt* gateways) instead of C++. "
                         "Every other block stays C++. Listed in the assumptions.")
    ap.add_argument("--group-cap", type=int, default=MAX_SOFT_COMPONENTS - 1,
                    help="with --owners: if more blocks than this own ports, fold the deepest "
                         "(then smallest) blocks into their parent block until the count fits. "
                         "Default 63: vsiBuild's cap of 64 less the fabric slot. 0 disables.")
    args = ap.parse_args()

    icd = json.load(open(args.icd, encoding="utf-8"))
    gmap = json.load(open(args.map, encoding="utf-8"))
    assert_vocabulary(gmap)

    # ---- ports grouped onto the interface they are ASSIGNED to (by uid) ----
    ports_on = defaultdict(list)
    for p in icd.get("ports", []):
        for ref in (p.get("interface_uids") or p.get("implements_uids") or []):
            ports_on[ref].append(p)

    dropped_ports = []
    dropped_by_block = []
    if args.exclude_blocks:
        if not args.owners:
            sys.exit("--exclude-blocks needs --owners")
        _own = json.load(open(args.owners, encoding="utf-8"))["ports"]
        rxb = re.compile(args.exclude_blocks)
        for ref in list(ports_on):
            keep = []
            for p in ports_on[ref]:
                o = _own.get(p["uid"])
                if o and rxb.search(o.get("block_name") or ""):
                    dropped_by_block.append("{} ({})".format(p["name"], o.get("block_name")))
                else:
                    keep.append(p)
            ports_on[ref] = keep
    if args.exclude_ports:
        rx = re.compile(args.exclude_ports)
        for ref in list(ports_on):
            keep = [p for p in ports_on[ref] if not rx.search(p["name"] or "")]
            dropped_ports += [p["name"] for p in ports_on[ref] if rx.search(p["name"] or "")]
            ports_on[ref] = keep

    buses, excluded, unresolved, unattached = [], [], [], []
    for iface in icd["interfaces"]:
        status, cls, proto, why = classify(iface, gmap)
        attached = ports_on.get(iface["uid"], [])
        rec = {"iface": iface, "class": cls, "protocol": proto,
               "why": why, "ports": attached}
        if status == "excluded":
            excluded.append(rec)
        elif status == "unresolved":
            unresolved.append(rec)
        elif not attached:
            unattached.append(rec)
        else:
            buses.append(rec)

    truncated = []
    if args.max_components:
        kept, n = [], 0
        for b in buses:
            if n + len(b["ports"]) > args.max_components:
                truncated.append(b)
                continue
            kept.append(b)
            n += len(b["ports"])
        buses = kept

    # ---- components: one per port, or one per owning block with --owners ----
    owners, blocks, folded = {}, {}, {}
    if args.owners:
        oj = json.load(open(args.owners, encoding="utf-8"))
        owners, blocks = oj["ports"], oj.get("blocks", {})

    def rep(buid):
        """Block that stands for buid after folding (follows the fold chain)."""
        while buid in folded:
            buid = folded[buid]
        return buid

    # vsiBuild 2026.2 rule, EXERCISED 2026-09-06 (probes/merge_probe.vsi.cmd):
    # a component may hold ONE port per gateway protocol, except AXI and GPIO.
    # One Ethernet port may carry any number of tcpIp sockets of mixed role,
    # so a block's Ethernet ports MERGE into one VSI port. A CAN frame is
    # capped at 64 bits (start bit 64 is refused), so CAN ports do NOT merge:
    # every gateway other than Ethernet/AXI/GPIO is STRICT, and a block's
    # second port on such a gateway spawns a satellite component.
    MERGE_GATEWAYS = {"Ethernet"}
    FREE_GATEWAYS = {"Axi", "Gpio"}

    bus_ports = [(b, p) for b in buses for p in b["ports"]]
    gateway_of = {p["uid"]: GATEWAY_FOR.get(b["protocol"], b["protocol"]) for b, p in bus_ports}
    owner_of = {p["uid"]: owners[p["uid"]]["block_uid"] for _, p in bus_ports if p["uid"] in owners}

    def group_cost(members):
        """VSI components a set of blocks costs once merged into one group."""
        if not members:
            return 0
        strict = Counter(gateway_of[u] for u, o in owner_of.items()
                         if o in members and gateway_of[u] not in MERGE_GATEWAYS | FREE_GATEWAYS)
        return 1 + sum(n - 1 for n in strict.values())

    fold_log = []
    if owners and args.group_cap:
        used = set(owner_of.values())

        def members_of(r):
            return {u for u in used if rep(u) == r}

        def depth(buid):
            d = 0
            while blocks.get(buid, {}).get("parent_uid"):
                buid, d = blocks[buid]["parent_uid"], d + 1
            return d

        def total():
            return sum(group_cost(members_of(r)) for r in {rep(u) for u in used})

        while total() > args.group_cap:
            reps = {rep(u) for u in used}
            best = None
            for r in reps:
                parent = blocks.get(r, {}).get("parent_uid")
                if not parent or rep(parent) == r:
                    continue
                pr = rep(parent)
                mine, theirs = members_of(r), members_of(pr)
                saving = group_cost(mine) + group_cost(theirs) - group_cost(mine | theirs)
                if not theirs:
                    # the parent owns no port: this fold only opens a group under
                    # its name, worth doing when a sibling can follow it in
                    siblings = [s for s in reps if s != r and blocks.get(s, {}).get("parent_uid")
                                and rep(blocks[s]["parent_uid"]) == pr]
                    saving = 0.5 if siblings else 0
                key = (saving, depth(r), -len(mine), blocks[r]["name"])
                if saving > 0 and (best is None or key > best[0]):
                    best = (key, r, pr, saving)
            if best is None:
                break
            _, r, pr, saving = best
            folded[r] = pr
            fold_log.append((blocks[r]["name"], blocks[pr]["name"], saving))

    # ---- one VSI component and port per Teamcenter port -------------------
    comp_name, port_name = {}, {}
    merged_ports = defaultdict(list)      # (component, vsi port) -> [TC port names]
    satellites = []                       # (component, group, TC port, gateway)
    groups = defaultdict(list)            # rep block -> [(bus, port)] in bus order
    for b, p in bus_ports:
        if p["uid"] in owner_of:
            groups[rep(owner_of[p["uid"]])].append((b, p))
        else:
            comp_name[p["uid"]] = sanitize(p["name"])
            port_name[p["uid"]] = sanitize(p["name"]) + "_p"
    for g, bps in groups.items():
        gname = sanitize(blocks[g]["name"]) if g in blocks else sanitize(owners[bps[0][1]["uid"]]["block_name"])
        n_eth = sum(1 for _, p in bps if gateway_of[p["uid"]] in MERGE_GATEWAYS)
        seen_strict = set()
        for b, p in bps:
            gw = gateway_of[p["uid"]]
            if gw in MERGE_GATEWAYS and n_eth > 1:
                comp_name[p["uid"]], port_name[p["uid"]] = gname, gw.lower() + "_p"
                merged_ports[(gname, gw.lower() + "_p")].append(p["name"])
            elif gw in MERGE_GATEWAYS or gw in FREE_GATEWAYS or gw not in seen_strict:
                seen_strict.add(gw)
                comp_name[p["uid"]], port_name[p["uid"]] = gname, sanitize(p["name"]) + "_p"
            else:
                cname = gname + "__" + sanitize(p["name"])
                comp_name[p["uid"]], port_name[p["uid"]] = cname, sanitize(p["name"]) + "_p"
                satellites.append((cname, gname, p["name"], gw))

    def comp_of(p):
        """VSI component that hosts this Teamcenter port."""
        return comp_name[p["uid"]]

    def pn(p):
        """VSI port name (unique within its component; merged Ethernet ports share one)."""
        return port_name[p["uid"]]

    def sn(p, s):
        """VSI signal name: prefixed by the port when several ports share a component."""
        return (sanitize(p["name"]) + "_" + sanitize(s)) if owners else sanitize(s)

    components = defaultdict(list)   # component name -> [(protocol, bus, port record)]
    for b, p in bus_ports:
        components[comp_of(p)].append((b["protocol"], b, p))
    all_triples = [t for ts in components.values() for t in ts]
    unowned = sorted({p["name"] for _, _, p in all_triples if owners and p["uid"] not in owners})

    assumptions = []
    if dropped_ports:
        assumptions.append("{} port(s) dropped by --exclude-ports {!r}: {}".format(
            len(dropped_ports), args.exclude_ports, ", ".join(sorted(dropped_ports))))
    if dropped_by_block:
        assumptions.append("{} port(s) dropped by --exclude-blocks {!r} (their owning block is excluded): {}".format(
            len(dropped_by_block), args.exclude_blocks, ", ".join(sorted(dropped_by_block))))
    if owners:
        assumptions.append(
            "ports grouped under their owning Fnd0LogicalBlock from {} ({} block components); "
            "signal names carry their port as a prefix".format(
                args.owners, sum(1 for c in components if any(p["uid"] in owners for _, _, p in components[c]))))
        if unowned:
            assumptions.append(
                "{} port(s) have no owning block in the owners file and stand as their own "
                "component: {}".format(len(unowned), ", ".join(unowned)))
        if merged_ports:
            assumptions.append(
                "{} merged Ethernet port(s): a block's Ethernet ports share ONE VSI port (one socket "
                "per Teamcenter port, vsiBuild allows one port per protocol per component): {}".format(
                    len(merged_ports),
                    "; ".join("{}.{} <- {}".format(c, vp, ", ".join(tcps))
                              for (c, vp), tcps in sorted(merged_ports.items()))))
        if satellites:
            assumptions.append(
                "{} satellite component(s): a block's second port on a non-Ethernet gateway cannot "
                "share its component, so it stands beside it as <block>__<port>: {}".format(
                    len(satellites), ", ".join("{} ({})".format(c, gw) for c, _, _, gw in satellites)))
        if fold_log:
            assumptions.append(
                "{} block(s) folded into their parent block to fit --group-cap {} (largest saving, "
                "then deepest, then smallest, first); their ports keep their own names inside the "
                "parent component: {}".format(
                    len(fold_log), args.group_cap,
                    "; ".join("{} -> {}".format(c, p) for c, p, _ in fold_log)))
        if len(components) > args.group_cap > 0:
            assumptions.append(
                "STILL OVER THE CAP: {} components after every fold that saves anything; "
                "vsiBuild will refuse this file".format(len(components)))
        intra = sum(1 for b in buses if len({comp_of(p) for p in b["ports"]}) < len(b["ports"]))
        if intra:
            assumptions.append(
                "{} bus(es) now join two ports of the SAME component (after grouping or folding); "
                "emitted as written, vsiBuild is the judge of whether it accepts them".format(intra))
    L = []
    w = L.append

    w("# Innexis VSI Builder command file")
    w("# GENERATED by icd_to_vsi.py - do not hand-edit, regenerate.")
    w("# source ICD : {}".format(args.icd))
    w("# extracted  : {}".format(icd.get("extracted_at", "?")))
    w("# tier       : {}   scope: {}".format(icd.get("profile", "?"), icd.get("scope", "?")))
    w("#")
    w("# interfaces selected     : {}".format(len(icd["interfaces"])))
    w("#   buses with >=1 port   : {}".format(len(buses)))
    w("#   excluded (no gateway) : {}".format(len(excluded)))
    w("#   classified, no port   : {}".format(len(unattached)))
    w("#   unresolved            : {}".format(len(unresolved)))
    w("# components {:<12}: {}".format("(blocks)" if owners else "(ports)", len(components)))
    if truncated:
        w("#")
        w("# *** DIAGNOSTIC SUBSET, NOT THE DELIVERABLE: --max-components {} ***".format(
            args.max_components))
        w("# *** {} bus(es) with {} port(s) OMITTED: {}".format(
            len(truncated), sum(len(b["ports"]) for b in truncated),
            ", ".join(b["iface"]["name"] for b in truncated)))
        assumptions.append(
            "DIAGNOSTIC SUBSET: {} bus(es) / {} port(s) omitted by --max-components {}; "
            "this file is for proving the toolchain, not for the demonstration".format(
                len(truncated), sum(len(b["ports"]) for b in truncated),
                args.max_components))
    w("#")
    w("set digitalTwinName {}".format(sanitize(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("")

    if len(components) > MAX_SOFT_COMPONENTS:
        w("# WARNING: {} C++ components exceeds the vsiBuild cap of {}.".format(
            len(components), MAX_SOFT_COMPONENTS))
        w("# Narrow the scope, or group ports under their owning logical block.")
        w("")

    # Per-block language. Default C++; --python-blocks names the blocks vsiBuild
    # should emit as Python components (same skeleton regions, python2Dt* gateways:
    # Can, Ethernet, GenericPayload, Gpio, Lin exist in lib/pythonGateways). The
    # Uart/Gpio -> GenericPayload fold applies to both languages.
    py_re = re.compile(args.python_blocks) if args.python_blocks else None
    PREFIX_FOR_TYPE = {"C++": GATEWAY_PREFIX, "Python": "python2Dt"}

    # vsiBuild 2026.2 generates a Python component whose Ethernet socket carries no
    # connected signals with an EMPTY `def establishTcpUdpConnection(self):`, an
    # IndentationError before any user code (measured 2026-09-08 on
    # Gimbal_Video_Aggregator; reproduced with controls in runs/evidence/: a socket
    # WITH connected signals gets a real body, one WITHOUT gets none). This generator
    # never connects signals over Ethernet sockets, because Teamcenter records no
    # signal-level mapping for them, so every Ethernet-bearing block would hit it.
    # Such a block is kept C++ and the demotion is reported, never silent. The test is
    # the measured condition, "a socket that gets no connect signals", read from
    # SIGNAL_WIRED rather than "has an Ethernet port": the day Ethernet joins that set
    # the demotion stops by itself instead of quietly holding blocks at C++.
    def has_unsignalled_socket(cname):
        return any(proto == "Ethernet" and proto not in SIGNAL_WIRED
                   for proto, _, _ in components[cname])

    def ctype(cname):
        if py_re and py_re.search(cname) and not has_unsignalled_socket(cname):
            return "Python"
        return COMPONENT_TYPE

    python_blocks = [c for c in sorted(components) if ctype(c) == "Python"]
    demoted = [c for c in sorted(components) if py_re and py_re.search(c) and has_unsignalled_socket(c)]
    if py_re:
        assumptions.append(
            "--python-blocks {!r}: {} block(s) emitted as Python components: {}".format(
                args.python_blocks, len(python_blocks), ", ".join(python_blocks) or "NONE MATCHED"))
        if demoted:
            assumptions.append(
                "{} matched block(s) KEPT C++ because they carry an Ethernet port: {} "
                "(vsiBuild 2026.2 emits an empty establishTcpUdpConnection for a Python "
                "component whose sockets carry no connected signals, and this generator "
                "connects none over Ethernet)".format(len(demoted), ", ".join(demoted)))
            print("WARN: --python-blocks: kept C++ (Ethernet port, vsiBuild Python defect): "
                  + ", ".join(demoted), file=sys.stderr)

    w("# --- components and ports ---------------------------------------------")
    for cname in sorted(components):
        w("add component -type {} -name {}".format(ctype(cname), cname))
        emitted = set()
        for proto, b, port in components[cname]:
            if pn(port) in emitted:       # a merged Ethernet port is declared once
                continue
            emitted.add(pn(port))
            gateway = GATEWAY_FOR.get(proto, proto)
            line = "add port -componentName {} -name {} -gateway {}{}".format(
                cname, pn(port), PREFIX_FOR_TYPE[ctype(cname)], gateway)
            if proto in NODE_TYPED:
                # first port on the bus is the master, every other one a slave
                line += " -nodeType " + ("master" if b["ports"][0] is port else "slave")
            w(line)
            if (cname, pn(port)) in merged_ports:
                w("#   {} stands for Teamcenter ports: {}".format(
                    pn(port), ", ".join(merged_ports[(cname, pn(port))])))
    w("")
    for proto, gateway in sorted(GATEWAY_FOR.items()):
        n = sum(1 for p, _, _ in all_triples if p == proto)
        if n:
            assumptions.append(
                "{} {} port(s) emitted as {}{}: vsiBuild 2026.2 cannot join two "
                "C++ components over {} (no c++2DtUart gateway; GPIO signals only "
                "connect to a virtual platform)".format(
                    n, proto, GATEWAY_PREFIX, gateway, proto))
    if any(p in NODE_TYPED for p, _, _ in all_triples):
        assumptions.append(
            "Spi/Lin -nodeType: first port on each bus taken as master; "
            "Teamcenter records no master/slave role")

    # Buses wired with `connect signals` need OPPOSITE directions at the two
    # ends (vsiBuild rejects output->output and in_out->in_out), so decide the
    # roles first and declare the signals to match.
    def split_roles(b):
        ps = b["ports"]
        src = [p for p in ps if p.get("direction") == "Output"]
        dst = [p for p in ps if p.get("direction") == "Input"]
        guessed = False
        if not src or not dst:
            src, dst, guessed = [ps[0]], ps[1:], True
        return src, dst, guessed

    roles = {}   # component -> "output" | "input"
    for b in buses:
        if b["protocol"] in SIGNAL_WIRED and len(b["ports"]) >= 2:
            src, dst, guessed = split_roles(b)
            b["_roles"] = (src, dst, guessed)
            for p in src:
                roles[p["uid"]] = "output"      # keyed by uid: port NAMES recur across blocks
            for p in dst:
                roles[p["uid"]] = "input"

    w("# --- signals (from Seg0ExchangeAllocation) -----------------------------")
    comp_sigs = defaultdict(list)     # one componentSignals line per component
    for b in buses:
        sigs = b["iface"]["signals"]
        for p in b["ports"]:
            pname = sanitize(p["name"])
            if not sigs:
                w("# {}.{}: bus {} has no signals allocated in Teamcenter".format(
                    comp_of(p), pn(p), b["iface"]["name"]))
                continue
            direction = DIRECTION.get(p.get("direction"))
            if p["uid"] in roles:
                if direction != roles[p["uid"]]:
                    assumptions.append(
                        "{}: signals declared {} for connect signals although "
                        "fnd0Direction is {!r}".format(pname, roles[p["uid"]],
                                                       p.get("direction")))
                direction = roles[p["uid"]]
            elif direction is None:
                direction = DIRECTION_DEFAULT
                assumptions.append(
                    "{}: fnd0Direction is {!r}, signals declared {}".format(
                        pname, p.get("direction"), DIRECTION_DEFAULT))
            for s in sigs:
                comp_sigs[comp_of(p)].append("{}:{}:{}".format(sn(p, s), SIGNAL_TYPE, direction))
    for cname in sorted(comp_sigs):
        w("define componentSignals -componentName {} -signals [{}]".format(
            cname, ",".join(comp_sigs[cname])))   # no spaces: vsiBuild rejects them
    assumptions.append(
        "every signal typed {!r}: Teamcenter records no signal data type".format(
            SIGNAL_TYPE))
    w("")

    w("# --- connectivity ------------------------------------------------------")
    ip_port = BASE_IP_PORT
    conn_counts = Counter()
    lonely = []
    self_spokes = []
    for b in buses:
        proto = b["protocol"]
        ps = b["ports"]
        names = [sanitize(p["name"]) for p in ps]
        w("# bus {} [{}] {} port(s)".format(b["iface"]["name"], proto, len(ps)))

        if len(ps) < 2:
            lonely.append((b["iface"]["name"], proto, names))
            w("#   only one port assigned; no connection can be formed.")
            w("")
            continue

        if proto == "Ethernet":
            # vsiBuild: a tcpIp portSocket takes at most TWO VSI ports, so a
            # bus with N ports becomes hub-and-spoke: the first port is the hub
            # and opens one server socket per client, each on its own ip port.
            hub = ps[0]
            first_port = ip_port
            for cp in ps[1:]:
                if (comp_of(cp), pn(cp)) == (comp_of(hub), pn(hub)):
                    # both Teamcenter ports landed on the same merged VSI port:
                    # vsiBuild refuses a server and a client of one ip port on
                    # one port ("port socket number already exists"), and the
                    # link would be a component talking to itself anyway.
                    w("#   spoke {} -> {} omitted: both ends are {}.{}".format(
                        sanitize(hub["name"]), sanitize(cp["name"]), comp_of(hub), pn(hub)))
                    self_spokes.append((b["iface"]["name"], hub["name"], cp["name"]))
                    continue
                w("add portSocket -componentName {} -portName {} -ipPortNum {} "
                  "-socketType server -protocol tcpIp".format(comp_of(hub), pn(hub), ip_port))
                w("add portSocket -componentName {} -portName {} -ipPortNum {} "
                  "-socketType client -protocol tcpIp".format(comp_of(cp), pn(cp), ip_port))
                ip_port += 1
            conn_counts["Ethernet"] += 1
            if ip_port > first_port:
                assumptions.append(
                    "bus {}: hub-and-spoke TCP with hub={} and ip ports {}..{} chosen by "
                    "the generator; Teamcenter records no socket roles or port numbers, "
                    "and vsiBuild allows at most two VSI ports per tcpIp socket".format(
                        b["iface"]["name"], names[0], first_port, ip_port - 1))
            else:
                assumptions.append(
                    "bus {}: NO socket emitted; every spoke from hub={} was omitted as a "
                    "self-link on one merged port (see the spoke list)".format(
                        b["iface"]["name"], names[0]))

        elif proto in ("Can", "Lin"):
            frame_id = "0x{:03X}".format(0x100 + conn_counts["Can"])
            for p in ps:
                layout = ",".join("{}:{}:{}".format(sn(p, s), i * CAN_BITS_PER_SIGNAL,
                                                    CAN_BITS_PER_SIGNAL)
                                  for i, s in enumerate(b["iface"]["signals"]))   # no spaces
                w("define frame -componentName {} -portName {} -id {} "
                  "-signals [{}] -idType standard".format(comp_of(p), pn(p), frame_id, layout))
            conn_counts["Can"] += 1
            assumptions.append(
                "bus {}: frame id {} and a flat {}-bit-per-signal layout are "
                "generator defaults; Teamcenter records no frame id or bit "
                "layout".format(b["iface"]["name"], frame_id, CAN_BITS_PER_SIGNAL))

        elif proto in SIGNAL_WIRED:
            # roles were fixed above so the signal declarations match
            src, dst, guessed = b["_roles"]
            if guessed:
                assumptions.append(
                    "bus {}: no Output/Input pair in fnd0Direction, so {} was "
                    "taken as source".format(b["iface"]["name"], sanitize(ps[0]["name"])))
            for s_port in src:
                for d_port in dst:
                    for sig in b["iface"]["signals"]:
                        w("connect signals -sourceSignal {}.{} -destSignal {}.{} "
                          "-sourcePortName {} -destPortName {}".format(
                              comp_of(s_port), sn(s_port, sig), comp_of(d_port), sn(d_port, sig),
                              pn(s_port), pn(d_port)))
            conn_counts[proto] += 1

        else:  # Pwm, Axi, GenericPayload
            for cp in ps[1:]:
                w("connect ports {}.{} {}.{}".format(comp_of(ps[0]), pn(ps[0]), comp_of(cp), pn(cp)))
            conn_counts[proto] += 1
        w("")

    if self_spokes:
        assumptions.append(
            "{} Ethernet spoke(s) omitted because both Teamcenter ports sit on the same merged "
            "VSI port (a server and a client of one ip port on one port is refused): {}".format(
                len(self_spokes), "; ".join("{}: {} -> {}".format(*s) for s in self_spokes)))

    # -overwrite so a re-run over an existing workspace regenerates instead of
    # silently doing nothing (vsiBuild's documented behaviour without it).
    w("generate -overwrite" + ("" if args.build else " -no-build"))
    w("quit")

    with open(args.out, "w", encoding="utf-8", newline="\n") as fh:
        fh.write("\n".join(L) + "\n")

    # ------------------------- report -------------------------
    tot = len(icd["interfaces"])
    accounted = len(buses) + len(excluded) + len(unattached) + len(unresolved)
    print("wrote {}".format(args.out))
    print("")
    print("  interfaces selected     : {}".format(tot))
    print("    buses (>=1 port)      : {}".format(len(buses)))
    print("    excluded, no gateway  : {}".format(len(excluded)))
    print("    classified, no port   : {}".format(len(unattached)))
    print("    unresolved            : {}".format(len(unresolved)))
    print("    ---- accounted for    : {} of {}{}".format(
        accounted, tot, "  OK" if accounted == tot else "  *** MISMATCH ***"))
    print("  components emitted      : {}".format(len(components)))
    print("  buses wired             : {}".format(sum(conn_counts.values())))
    for k in sorted(conn_counts):
        print("    {:<20}: {}".format(k, conn_counts[k]))
    print("")

    if lonely:
        print("  {} bus(es) have only ONE assigned port, so no connection was".format(len(lonely)))
        print("  formed. In Teamcenter these interfaces are attached at one end only:")
        for name, proto, names in lonely:
            print("    {:<32} [{}] {}".format(name, proto, names[0]))
        print("")

    if unattached:
        print("  {} interface(s) carry a protocol but NO port is assigned to them,".format(
            len(unattached)))
        print("  so they are absent from the twin:")
        for r in unattached:
            print("    {:<32} [{}]".format(r["iface"]["name"], r["protocol"]))
        print("")

    if unresolved:
        print("  {} interface(s) could not be classified and are ABSENT:".format(len(unresolved)))
        for r in unresolved:
            print("    {:<32} {}".format(r["iface"]["name"], r["why"]))
        print("")

    if excluded:
        by_class = defaultdict(list)
        for r in excluded:
            by_class[r["class"]].append(r["iface"]["name"])
        print("  Excluded on purpose ({} - no VSI gateway exists):".format(len(excluded)))
        for cls, names in sorted(by_class.items()):
            print("    {:<18} {}".format(cls, ", ".join(sorted(names))))
        print("")

    if assumptions:
        print("  *** {} GENERATOR ASSUMPTION(S) - not Teamcenter data: ***".format(
            len(assumptions)))
        for a in assumptions:
            print("    - {}".format(a))
        # The same list beside the twin, so the record outlives the console.
        side = args.out + ".assumptions.md"
        with open(side, "w", encoding="utf-8", newline="\n") as fh:
            fh.write("# Generator assumptions for {}\n\n".format(args.out))
            fh.write("Decisions the generator made that are NOT Teamcenter data. "
                     "{} components in the file.\n\n".format(len(components)))
            for a in assumptions:
                fh.write("- {}\n".format(a))
        print("  written to {}".format(side))


if __name__ == "__main__":
    main()
