#!/usr/bin/env python3
"""Render built CI Server display XML back to a browser preview.

    python render_preview.py                 -> out/preview.html (all displays)
    python render_preview.py WRPS_FP_Pump    -> just that one

WHY THIS EXISTS
---------------
Every preview up to now was a drawing of what I *meant* to build, made
alongside the generator rather than from it.  That is the same mistake as
a test that checks the intent instead of the artefact, and it has already
cost this project a round: a script that failed before writing its file,
while a checker with hard-coded coordinates cheerfully reported the file
was fine.

This reads the XML that was actually generated and draws it.  If a
component is in the file it appears here; if it is not, it does not.

It also resolves what a static picture normally cannot:

  live values     read over Modbus, formatted through the point's own
                  gain and display mask, so a number shows what the
                  operator would see rather than "9999.9"
  visibility      a component bound to a Boolean item is drawn only if
                  that item is true; one bound to a display parameter is
                  resolved through the genericFunctions equality that
                  writes the parameter.  Without this, every state stack
                  renders at once and the preview is a smear.

Approximate by construction: browser font metrics are not Java's, so
text width is estimated.  It is a faithful reading of the geometry, not
a pixel-exact simulation of CI View.
"""

import csv
import html
import importlib.util
import math
import re
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
OUT = HERE / "out"
POINTS = HERE.parent / "modbus_points" / "ci-server-points.csv"
QLI_GEN = HERE.parent / "modbus_points" / "gen_ciserver_qli.py"
PLC_HOST, PLC_PORT, PLC_UNIT = "192.168.153.192", 502, 1


# ------------------------------------------------------------------ data
def leaf_to_iec():
    spec = importlib.util.spec_from_file_location("q", QLI_GEN)
    q = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(q)
    return {"AID.WRPS.%s.%s" % v: k for k, v in q.LEAF.items()}


def live_values():
    """{itemName: (raw, formatted_string)} - or {} if the PLC is unreachable."""
    rows = {r["iec_address"]: r for r in csv.DictReader(POINTS.open(newline="", encoding="utf-8"))}
    names = leaf_to_iec()
    try:
        from pymodbus.client import ModbusTcpClient
        c = ModbusTcpClient(PLC_HOST, port=PLC_PORT, timeout=3)
        if not c.connect():
            return {}
        hr = c.read_holding_registers(address=0, count=21, device_id=PLC_UNIT).registers
        sp = c.read_holding_registers(address=1024, count=11, device_id=PLC_UNIT).registers
        sim = c.read_holding_registers(address=1044, count=4, device_id=PLC_UNIT).registers
        co = [int(b) for b in c.read_coils(address=0, count=15, device_id=PLC_UNIT).bits[:15]]
        c.close()
    except Exception:
        return {}

    raw = {"%%QW%d" % i: v for i, v in enumerate(hr)}
    raw.update({"%%MW%d" % i: v for i, v in enumerate(sp)})
    raw.update({"%%MW%d" % (20 + i): v for i, v in enumerate(sim)})
    raw.update({"%%QX0.%d" % i: co[i] for i in range(8)})
    raw.update({"%%QX1.%d" % i: co[8 + i] for i in range(7)})

    out = {}
    for name, iec in names.items():
        if iec not in raw or iec not in rows:
            continue
        r, v = rows[iec], raw[iec]
        if r["data_type"] == "Boolean":
            out[name] = (v, str(v))
            continue
        if v > 32767 and iec != "%QW17":
            v -= 65536
        dec = len(r["format_mask"].split(".")[1]) if "." in r["format_mask"] else 0
        out[name] = (v, "%.*f" % (dec, v * float(r["eng_gain"])))
    return out


# ------------------------------------------------------------------ parse
def num(body, tag, default=None):
    m = re.search(r"<%s>(-?[\d.]+)</%s>" % (tag, tag), body)
    return float(m.group(1)) if m else default


def attr(tag_text, name, default=None):
    m = re.search(r'%s="(-?[\d.]+)"' % name, tag_text)
    return float(m.group(1)) if m else default


class Doc:
    def __init__(self, path):
        self.xml = path.read_text(encoding="utf-8")
        self.name = path.stem
        self.w = int(num(self.xml, "width", 1920) or 1920)
        self.h = int(num(self.xml, "height", 1080) or 1080)
        self.paints, self.datas = {}, {}
        for rid, body in re.findall(r'<intRef id="(\d+)">(.*?)</intRef>', self.xml, re.S):
            p = re.search(r'colorSet r="(\d+)" g="(\d+)" b="(\d+)"', body)
            if p:
                self.paints[rid] = "rgb(%s,%s,%s)" % p.groups()
            else:
                self.datas[rid] = body
        # genericFunctions: parameter name -> (item, constant)
        self.funcs = {}
        for body in re.findall(r"<genericFunctions type=\"componentData\">(.*?)</genericFunctions>",
                               self.xml, re.S):
            pname = re.search(r"<name>([^<]+)</name>", body)
            const = num(body, "numberArgument2")
            item = re.search(r"<itemName>([^<]+)</itemName>", body)
            if pname and const is not None and item:
                self.funcs[pname.group(1)] = (item.group(1), const)

    def paint(self, body, tag):
        m = re.search(r'<%s ref="(\d+)"/>' % tag, body)
        if m:
            return self.paints.get(m.group(1), "none")
        m = re.search(r'<%s type="paint"><paint type="colorSet" r="(\d+)" g="(\d+)" b="(\d+)"' % tag, body)
        return "rgb(%s,%s,%s)" % m.groups() if m else "none"

    def bound_item(self, body):
        m = re.search(r"<itemName>([^<]+)</itemName>", body)
        if m:
            return m.group(1)
        m = re.search(r'<data ref="(\d+)"/>', body)
        if m:
            d = self.datas.get(m.group(1), "")
            mm = re.search(r"<itemName>([^<]+)</itemName>", d)
            if mm:
                return mm.group(1)
        return None

    def visible(self, body, live):
        """Resolve a .visible binding.  Unbound components are always drawn."""
        refs = re.findall(r'<data ref="(\d+)"/>', body)
        blocks = [self.datas.get(r, "") for r in refs] + [body]
        for blk in blocks:
            if ".visible" not in blk:
                continue
            p = re.search(r"<parameter>([^<]+)</parameter>", blk)
            if p:
                item, const = self.funcs.get(p.group(1), (None, None))
                if item is None or item not in live:
                    return None
                return abs(live[item][0] - const) < 0.001
            i = re.search(r"<itemName>([^<]+)</itemName>", blk)
            if i:
                if i.group(1) not in live:
                    return None
                return bool(live[i.group(1)][0])
        return True


def render(doc, live):
    """SVG for one display, components in document order."""
    svg = ['<svg viewBox="0 0 %d %d" width="%d" height="%d" xmlns="http://www.w3.org/2000/svg">'
           % (doc.w, doc.h, doc.w, doc.h),
           '<rect width="%d" height="%d" fill="rgb(237,240,244)"/>' % (doc.w, doc.h)]
    unknown = 0

    pattern = (r'<(rectangle|ellipse|line|text|number|numberField|dataBar|button|microTrend)'
               r'( type="componentData"[^>]*)>(.*?)</\1>')
    for kind, head, body in re.findall(pattern, doc.xml, re.S):
        vis = doc.visible(body, live)
        if vis is False:
            continue
        if vis is None:
            unknown += 1
        op = ' opacity="0.35"' if vis is None else ""

        # geometry: child elements, or attributes on the tag (numberField/dataBar/button)
        cx = num(body, "x", attr(head, "x"))
        cy = num(body, "y", attr(head, "y"))
        if cx is None or cy is None:
            continue
        t = num(body, "top", attr(head, "top", 0)) or 0
        b = num(body, "bottom", attr(head, "bottom", 0)) or 0
        l = num(body, "left", attr(head, "left", 0)) or 0
        r = num(body, "right", attr(head, "right", 0)) or 0
        x, y, w, h = cx - l, cy - t, l + r, t + b

        if kind == "rectangle":
            svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>'
                       % (x, y, w, h, doc.paint(body, "fillPaint"), doc.paint(body, "strokePaint"),
                          num(body, "width", 1) or attr(body, "width", 1) or 1, op))
        elif kind == "ellipse":
            svg.append('<ellipse cx="%.1f" cy="%.1f" rx="%.1f" ry="%.1f" fill="%s" stroke="%s" stroke-width="2"%s/>'
                       % (cx, cy, l, t, doc.paint(body, "fillPaint"), doc.paint(body, "strokePaint"), op))
        elif kind == "line":
            pts = re.findall(r"<point><x>(-?[\d.]+)</x><y>(-?[\d.]+)</y></point>", body)
            if len(pts) >= 2:
                svg.append('<polyline points="%s" fill="none" stroke="%s" stroke-width="%.1f"%s/>'
                           % (" ".join("%s,%s" % p for p in pts), doc.paint(body, "strokePaint"),
                              float(re.search(r'stroke" width="([\d.]+)"', body).group(1)) if re.search(r'stroke" width="([\d.]+)"', body) else 2, op))
            else:
                svg.append('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="2"%s/>'
                           % (x, y, x + w, y + h, doc.paint(body, "strokePaint"), op))
        elif kind == "text":
            v = re.search(r"<value>(.*?)</value>", body, re.S)
            f = re.search(r'name="([^"]+)" bold="(\w+)"[^>]*size="(\d+)"', body)
            fam, bold, size = (f.group(1), f.group(2), int(f.group(3))) if f else ("Segoe UI", "false", 13)
            svg.append('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="%s" font-size="%d" '
                       'font-weight="%s" fill="%s"%s>%s</text>'
                       % (cx, cy + size * 0.35, fam, size, "700" if bold == "true" else "400",
                          doc.paint(body, "fillPaint"), op, html.escape(v.group(1) if v else "")))
        elif kind == "number":
            item = doc.bound_item(body)
            mask = re.search(r"<format>([^<]*)</format>", body)
            shown = live.get(item, (None, mask.group(1) if mask else "?"))[1]
            f = re.search(r'size="(\d+)"', body)
            size = int(f.group(1)) if f else 20
            svg.append('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="Consolas,monospace" '
                       'font-size="%d" font-weight="700" fill="%s"%s>%s</text>'
                       % (cx, cy + size * 0.35, size, doc.paint(body, "fillPaint"), op,
                          html.escape(str(shown))))
        elif kind == "numberField":
            item = doc.bound_item(body)
            shown = live.get(item, (None, ""))[1]
            svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#fff" stroke="rgb(11,125,168)"%s/>'
                       % (x, y, w, h, op))
            svg.append('<text x="%.1f" y="%.1f" font-family="Consolas,monospace" font-size="14" fill="#0e1621"%s>%s</text>'
                       % (x + 8, cy + 5, op, html.escape(str(shown))))
        elif kind == "dataBar":
            item = doc.bound_item(body)
            lo = num(body, "lowLimit", 0) or 0
            hi = num(body, "highLimit", 100) or 100
            val = live.get(item, (lo, ""))[0]
            try:
                pct = max(0.0, min(1.0, (float(live[item][1]) - lo) / (hi - lo)))
            except Exception:
                pct = 0.0
            svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"%s/>'
                       % (x, y, w, h, doc.paint(body, "background"), op))
            svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"%s/>'
                       % (x, y + h * (1 - pct), w, h * pct, doc.paint(body, "foreground"), op))
        elif kind == "microTrend":
            # the trace itself is CI Server's; what the preview has to show is
            # the FOOTPRINT, so a trend that lands on a pipe is visible here
            # rather than after deployment
            item = doc.bound_item(body)
            hi = num(body, "value", 100) or 100      # scaleMax[0]
            svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="rgb(220,220,220)" '
                       'stroke="rgb(195,204,216)"%s/>' % (x, y, w, h, op))
            try:
                pct = max(0.0, min(1.0, float(live[item][1]) / hi))
            except Exception:
                pct = 0.5
            svg.append('<polyline points="%s" fill="none" stroke="rgb(11,125,168)" stroke-width="1.5"%s/>'
                       % (" ".join("%.1f,%.1f" % (x + w * k / 24.0,
                                                  y + h * (1 - pct * (0.55 + 0.45 * math.sin(k / 2.4))))
                                   for k in range(25)), op))
            svg.append('<text x="%.1f" y="%.1f" font-family="Consolas,monospace" font-size="9" '
                       'fill="rgb(70,84,104)"%s>%s</text>'
                       % (x + 4, y + 11, op, html.escape((item or "").replace("AID.WRPS.", ""))))
        elif kind == "button":
            lab = re.search(r"<label>([^<]*)</label>", body)
            fg = re.search(r'foregroundColor type="colorSet" r="(\d+)" g="(\d+)" b="(\d+)"', body)
            bg = re.search(r'backgroundColor type="colorSet" r="(\d+)" g="(\d+)" b="(\d+)"', body)
            svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" stroke="rgb(195,204,216)"%s/>'
                       % (x, y, w, h, "rgb(%s,%s,%s)" % bg.groups() if bg else "#fff", op))
            svg.append('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="Segoe UI" font-size="15" '
                       'font-weight="700" fill="%s"%s>%s</text>'
                       % (cx, cy + 5, "rgb(%s,%s,%s)" % fg.groups() if fg else "#0e1621", op,
                          html.escape(lab.group(1) if lab else "")))
    svg.append("</svg>")
    return "\n".join(svg), unknown


def main():
    want = sys.argv[1:]
    files = sorted(OUT.glob("WRPS_*.xml"))
    if want:
        files = [f for f in files if f.stem in want]
    if not files:
        sys.exit("nothing to render in %s" % OUT)

    live = live_values()
    parts = ['<!doctype html><meta charset="utf-8"><title>WRPS displays — rendered from the built XML</title>',
             '<style>body{margin:0;background:#eef1f5;font-family:"Segoe UI",sans-serif;color:#0e1621}'
             '.w{padding:20px}h1{font-size:15px;margin:0 0 2px}p.s{font-size:12px;color:#465468;margin:0 0 16px}'
             'h2{font-size:13px;margin:22px 0 6px}.box{background:#fff;border:1px solid #c3ccd8;overflow:auto;display:inline-block}'
             '</style><div class="w">',
             "<h1>Rendered from the generated XML in <code>out/</code></h1>",
             '<p class="s">%s. Components are drawn only if the file contains them; '
             "values and visibility resolved against the live PLC.</p>"
             % ("Live values read from the PLC" if live else
                "PLC unreachable - numbers show their format mask, state-bound shapes are dimmed")]
    for f in files:
        doc = Doc(f)
        svg, unknown = render(doc, live)
        note = "  <span style='color:#b45309'>%d component(s) whose visibility could not be resolved, shown dimmed</span>" % unknown if unknown else ""
        parts.append("<h2>%s &nbsp;<span style='font-weight:400;color:#465468'>%d x %d</span>%s</h2>"
                     % (doc.name, doc.w, doc.h, note))
        parts.append('<div class="box">%s</div>' % svg)
        print("rendered %-22s %d x %d" % (doc.name, doc.w, doc.h))
    parts.append("</div>")
    dest = OUT / "preview.html"
    dest.write_text("\n".join(parts), encoding="utf-8")
    print("wrote %s" % dest)


if __name__ == "__main__":
    main()
