#!/usr/bin/env python3
"""Overlap audit for the generated displays.

    python check_layout.py [display.xml ...]

Reads the built XML and reports text that collides with other text, or
that escapes its panel.  Written after a units relabel pushed the wet
well caption into the top ladder label and nobody noticed until the
screens were on a monitor - the generator places components by
arithmetic, so a collision is arithmetic too, and worth catching here
rather than in CI View.

Text width is estimated, not measured: the value font is Consolas, which
is monospaced at ~0.55 em, and the label font is close enough at this
size.  So the check is approximate by construction - it is a net for
gross collisions, not a typesetter.

Some components no longer HAVE one position.  A level mark bound to its
setpoint through `<kind>.y` sits wherever the operator last typed, so
checking it where the generator happened to draw it checks the one
arrangement that is guaranteed not to be the problem.  Those are swept
across their whole input range instead - see `sweep`.
"""

import re
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
CHAR_W = 0.55          # em, Consolas advance width
PAD = 2.0              # px of slack before two boxes count as colliding


def components(xml):
    """(kind, x1, y1, x2, y2, value, size) for everything positioned."""
    out = []
    for kind, body in re.findall(
            r'<(rectangle|text|number|ellipse) type="componentData">(.*?)</\1>',
            xml, re.S):
        def num(tag):
            m = re.search(r"<%s>(-?[\d.]+)</%s>" % (tag, tag), body)
            return float(m.group(1)) if m else None

        cx, cy = num("x"), num("y")
        top, bot, left, right = num("top"), num("bottom"), num("left"), num("right")
        if None in (cx, cy, top, bot, left, right):
            continue
        val = re.search(r"<value>(.*?)</value>", body)
        size = re.search(r'size="(\d+)"', body)
        out.append((kind, cx - left, cy - top, cx + right, cy + bot,
                    val.group(1) if val else "", int(size.group(1)) if size else 0,
                    "<data ref=" in body, round(cx, 1), round(cy, 1)))
    return out


def stacked(a, b):
    """True if two texts are alternative states of one thing.

    A state stack - OFF / IDLE / PUMPING - is deliberately drawn at ONE
    position with one member visible at a time.  That is not a collision,
    and reporting it as one buries the real ones.  The signature is:
    both carry a visibility binding, and both sit at the same anchor.
    """
    return (a[7] and b[7]
            and round(a[1], 0) == round(b[1], 0)      # same left edge
            and round(a[9], 0) == round(b[9], 0))     # same centre line


def text_box(c):
    """A text component's *drawn* extent, which is not its stored box.

    The stored left/right on a text component is its anchor box, not the
    glyph run - a right-anchored label stores a wide box and draws inside
    the right edge of it.  Estimate the run instead.
    """
    kind, x1, y1, x2, y2, val, size = c[:7]
    w = len(val) * size * CHAR_W
    # right-anchored labels store the anchor at their right edge
    if x2 - x1 > w * 1.5:
        x1 = x2 - w
    else:
        x2 = x1 + w
    return x1, y1, x2, y2


def overlaps(a, b):
    ax1, ay1, ax2, ay2 = text_box(a)
    bx1, by1, bx2, by2 = text_box(b)
    return not (ax2 - PAD <= bx1 or bx2 - PAD <= ax1
                or ay2 - PAD <= by1 or by2 - PAD <= ay1)


def line_boxes(xml):
    """Process lines as thin boxes - the obstacles text must not sit on.

    THE GAP THIS CLOSES.  The first version of this checker compared text
    against text only, so it passed a screen whose top row of values was
    sitting on the inlet pipe and whose data column ran across the
    discharge manifold.  It measured what was easy, not what was wrong.
    """
    out = []
    for body in re.findall(r'<line type="componentData">(.*?)</line>', xml, re.S):
        pts = [float(v) for v in re.findall(r"<(?:x|y)>(-?[\d.]+)</(?:x|y)>", body)]
        if len(pts) < 2:
            continue
        cx, cy = pts[0], pts[1]
        def num(tag):
            m = re.search(r"<%s>(-?[\d.]+)</%s>" % (tag, tag), body)
            return float(m.group(1)) if m else 0.0
        l, r_, t, b = num("left"), num("right"), num("top"), num("bottom")
        out.append(("line", cx - l, cy - t, cx + r_, cy + b, "", 0, False, 0, 0))
    return out


def shared_data(xml):
    """globalSection data objects by id - where a component's actions live."""
    return dict(re.findall(
        r'<intRef id="(\d+)"><sharedObject type="data">(.*?)</sharedObject></intRef>',
        xml, re.S))


def movers(xml):
    """Components whose y is bound to an item, with the law that moves them.

    Yields (component, y_at_input_0, y_at_input_end, input_end, item).  The
    component tuple is the one `components()` produces, so it drops straight
    into the same overlap test.
    """
    shared = shared_data(xml)
    out = []
    for kind, body in re.findall(
            r'<(rectangle|text|number) type="componentData">(.*?)</\1>', xml, re.S):
        ref = re.search(r'<data ref="(\d+)"/>', body)
        if not ref or ref.group(1) not in shared:
            continue
        m = re.search(r'name="%s\.y"/>.*?<inputValueEnd>([\d.]+)</inputValueEnd>'
                      r"<outputValueStart>(-?[\d.]+)</outputValueStart>"
                      r"<outputValueEnd>(-?[\d.]+)</outputValueEnd>.*?"
                      r"<itemName>([^<]+)</itemName>" % kind,
                      shared[ref.group(1)], re.S)
        if not m:
            continue
        end, y0, y1 = float(m.group(1)), float(m.group(2)), float(m.group(3))
        # a number carries no <value>; its mask is what it will be as wide as
        val = re.search(r"<value>(.*?)</value>", body)
        fmt = re.search(r"<format>([^<]*)</format>", body)
        text = val.group(1) if val else (fmt.group(1) if fmt else "")
        for c in components("<%s type=\"componentData\">%s</%s>" % (kind, body, kind)):
            out.append((c[:5] + (text,) + c[6:], y0, y1, end, m.group(4)))
    return out


def sweep(xml, statics, step=0.5):
    """Drive every moving component across its range against what stays put.

    The invariant being enforced is that a mark which can be moved and a
    mark which cannot never share a column - because a mark that moves will
    eventually be driven to the height of one that does not.  Checking the
    positions the generator drew proves nothing about that; only the sweep
    does.

    Moving-against-moving is deliberately NOT reported.  Two setpoints set
    close together really do put their marks close together, and that is the
    screen telling the truth about the thresholds, not a layout fault.
    """
    hits = {}
    for c, y0, y1, end, item in movers(xml):
        cy = (c[2] + c[4]) / 2.0
        v = 0.0
        while v <= end + 1e-9:
            dy = (y0 + (y1 - y0) * v / end) - cy
            moved = (c[0], c[1], c[2] + dy, c[3], c[4] + dy) + c[5:]
            for s in statics:
                if overlaps(moved, s):
                    key = (item, c[5] or c[0], s[5] or s[0])
                    lo, hi = hits.get(key, (v, v))
                    hits[key] = (min(lo, v), max(hi, v))
            v += step
    return hits


def audit(path):
    xml = path.read_text(encoding="utf-8")
    comps = components(xml)
    texts = [c for c in comps if c[0] == "text" and c[5].strip()]
    lines = line_boxes(xml)
    hits = []
    for i, a in enumerate(texts):
        for b in texts[i + 1:]:
            if overlaps(a, b) and not stacked(a, b):
                hits.append((a[5], b[5]))
    for a in texts:
        for b in lines:
            ax1, ay1, ax2, ay2 = text_box(a)
            if not (ax2 <= b[1] or b[3] <= ax1 or ay2 <= b[2] or b[4] <= ay1):
                hits.append((a[5], "<process line at %d,%d>" % (b[1], b[2])))
    # things that stay put, which is what a moving mark must never reach
    moving_at = {(round(c[1], 1), round(c[2], 1)) for c, _, _, _, _ in movers(xml)}
    statics = [t for t in texts if (round(t[1], 1), round(t[2], 1)) not in moving_at]
    swept = sweep(xml, statics + lines)

    n = len(hits) + len(swept)
    print("%-28s %3d components, %3d text, %2d moving"
          % (path.name, len(comps), len(texts), len(moving_at)), end="")
    if n:
        print("  -> %d COLLISIONS" % n)
        for a, b in hits:
            print("      %-40s  x  %s" % (a[:40], b[:40]))
        for (item, a, b), (lo, hi) in sorted(swept.items()):
            print("      %-22s %-16s x %-22s at %g-%g%%"
                  % (item.replace("AID.WRPS.", ""), a[:16], b[:22], lo, hi))
    else:
        print("  -> clear")
    return n


def main():
    args = sys.argv[1:]
    files = [Path(a) for a in args] if args else sorted((HERE / "out").glob("WRPS_*.xml"))
    bad = sum(audit(f) for f in files if f.is_file())
    sys.exit(1 if bad else 0)


if __name__ == "__main__":
    main()
