"""
hwh_check.py -- catch dangling connections in a Vivado block design before you
spend an afternoon debugging them in Python.

Vivado will happily synthesize a design with an unconnected AXI4-Stream
interface: it emits a critical warning, not an error, so the .bit builds and
downloads fine.  The symptom on the PYNQ side is a DMA that arms correctly,
reports no errors, and then simply never completes -- there is nothing to
distinguish it from a slow source.

The .hwh file records this explicitly.  Every BUSINTERFACE carries a BUSNAME
attribute; when nothing is attached, Vivado writes the literal string
"__NOC__" (no connection).  Individual PORT elements likewise drop their
SIGNAME attribute when the pin is floating.  Both are cheap to grep for.

Typical use, inside Gen.__init__:

    from hwh_check import check_hwh
    ...
    Overlay.__init__(self, firmware)
    check_hwh(self.bitfile_name)          # .hwh is found alongside the .bit

or before the download, if you would rather not program a broken bitstream:

    check_hwh(firmware)

Returns a dict; also prints a report unless verbose=False.  It never raises on
a finding (pass strict=True if you want it to), so it is safe to leave wired
into the constructor permanently.
"""

import os
import xml.etree.ElementTree as ET

# ---------------------------------------------------------------- defaults --
#
# Interface classes worth checking.  A dangling AXI4-Stream or memory-mapped
# AXI link is nearly always a mistake.  GPIO interfaces are a different story:
# in this design VERSION, RX_FIFO_RESET and RX_FIFO_TLAST all show __NOC__ on
# their GPIO interface simply because the pins leave via ordinary signals
# rather than a bus, so including them would bury the real findings in noise.
#
CHECKED_VLNV = ("interface:axis", "interface:aximm")

#
# Known-harmless dangling interfaces, as "INSTANCE/INTERFACE".  M_AXIS_CNTRL is
# the AXI DMA's optional control stream; it is unused in both Loopback builds.
#
DEFAULT_IGNORE_BUSES = (
    "AXI_DMA_SG/M_AXIS_CNTRL",
    "AXI_DMA_NOSG/M_AXIS_CNTRL",
)

#
# Floating *inputs* are the second check.  Outputs are left unconnected all the
# time (interrupt lines, status pins), so only inputs are interesting.  Even
# then a handful are floating by design in every Zynq block design, and the
# proc_sys_reset optional inputs tie themselves off internally.
#
DEFAULT_IGNORE_INPUTS = (
    "ZYNQ/emio_gpio_i",
    "ZYNQ/pl_ps_irq0",
    "ZYNQ/pl_ps_irq1",
    "AXI_DMA_SG/m_axis_mm2s_cntrl_tready",
    "AXI_DMA_NOSG/m_axis_mm2s_cntrl_tready",
    "*/aux_reset_in",
    "*/mb_debug_sys_rst",
    "*/dcm_locked",
)


def _matches(key, patterns):
    """key is 'INSTANCE/PIN'; patterns may use '*' for the instance half."""
    inst, _, pin = key.partition("/")
    for p in patterns:
        if p == key:
            return True
        pi, _, pp = p.partition("/")
        if pi == "*" and pp == pin:
            return True
    return False


def hwh_for(path):
    """Accept a .bit or a .hwh and return the .hwh path."""
    root, ext = os.path.splitext(path)
    return path if ext == ".hwh" else root + ".hwh"


def check_hwh(path,
              ignore_buses=DEFAULT_IGNORE_BUSES,
              ignore_inputs=DEFAULT_IGNORE_INPUTS,
              check_inputs=True,
              verbose=True,
              strict=False):
    """Scan a .hwh for unconnected bus interfaces and floating input pins.

    Returns {'hwh': path, 'buses': [...], 'inputs': [...]}, where each entry is
    a dict describing one finding.  With strict=True, raises RuntimeError if
    anything was found.
    """
    hwh = hwh_for(path)
    result = {"hwh": hwh, "buses": [], "inputs": []}

    if not os.path.exists(hwh):
        if verbose:
            print(f"hwh_check: no .hwh found at {hwh} -- skipping")
        return result

    root = ET.parse(hwh).getroot()

    for mod in root.iter("MODULE"):
        inst = (mod.get("FULLNAME") or "").lstrip("/")
        if not inst:
            continue

        for bus in mod.iter("BUSINTERFACE"):
            if bus.get("BUSNAME") != "__NOC__":
                continue
            vlnv = bus.get("VLNV") or ""
            if not any(c in vlnv for c in CHECKED_VLNV):
                continue
            key = f"{inst}/{bus.get('NAME')}"
            if _matches(key, ignore_buses):
                continue
            result["buses"].append({
                "key":   key,
                "type":  bus.get("TYPE"),          # INITIATOR / SLAVE / etc
                "vlnv":  vlnv,
                "width": bus.get("DATAWIDTH"),
            })

        if check_inputs:
            for port in mod.iter("PORT"):
                if port.get("DIR") != "I" or port.get("SIGNAME") is not None:
                    continue
                key = f"{inst}/{port.get('NAME')}"
                if _matches(key, ignore_inputs):
                    continue
                result["inputs"].append({"key": key})

    if verbose:
        n = len(result["buses"]) + len(result["inputs"])
        if n == 0:
            print(f"hwh_check: {os.path.basename(hwh)} -- no dangling "
                  f"interfaces or floating inputs")
        else:
            print(f"hwh_check: {os.path.basename(hwh)} -- {n} FINDING(S)")
            for b in result["buses"]:
                print(f"    unconnected {b['type']:9s} {b['key']}"
                      f"   ({b['vlnv']}, {b['width']} bit)")
            for i in result["inputs"]:
                print(f"    floating input       {i['key']}")

    if strict and (result["buses"] or result["inputs"]):
        raise RuntimeError(f"hwh_check: unconnected logic in {hwh}")

    return result


def diff_hwh(good, suspect, **kw):
    """Compare two .hwh files and report findings present in `suspect` only.

    Useful when you have a known-good reference build: it suppresses the
    dangling pins that were always there and shows only what the rebuild lost.
    """
    a = check_hwh(good, verbose=False, **kw)
    b = check_hwh(suspect, verbose=False, **kw)
    base = {x["key"] for x in a["buses"]} | {x["key"] for x in a["inputs"]}
    new = [x for x in b["buses"] + b["inputs"] if x["key"] not in base]

    print(f"hwh_check: {os.path.basename(hwh_for(suspect))} vs "
          f"{os.path.basename(hwh_for(good))}")
    if not new:
        print("    no new unconnected logic")
    for x in new:
        print(f"    NEW: {x['key']}")
    return new


if __name__ == "__main__":
    import sys
    if len(sys.argv) == 3:
        diff_hwh(sys.argv[1], sys.argv[2])
    elif len(sys.argv) == 2:
        check_hwh(sys.argv[1])
    else:
        print("usage: hwh_check.py <design.hwh|.bit> [suspect.hwh|.bit]")