#!/usr/bin/env python3
"""Build WRPS_TagTest.xml - a bare diagnostic display, no graphics.

Why this exists
---------------
Round 1 (shared <data ref> vs inline <data type="data">) came back with both
columns live for all 49 items, which ruled out the binding form, the derived
itemIds and the CI Server side.  The remaining difference was <format>: the
generated displays carried DecimalFormat patterns ("0", "0.0"), and CI View
writes <format> as a DIGIT MASK ("99.99").  A one-digit-wide mask has nowhere
to render a four-digit level.

Round 2, this build, tests the masks in point_format.py before they go
anywhere near the operator screens:

  * every item in the project, one row each, numbered - nothing else on
    the screen, so nothing else can be blamed
  * each item is bound TWICE, side by side:
      PLAIN  - no <format> at all.  The control: this is exactly what was
               live in round 1, so it must stay live.
      MASKED - the same binding plus <format> from point_format.py, written
               where CI View puts it (straight after <htmlId>, no <value>)
  * so each row answers, independently: does the mask render the value, is
    it wide enough for the real range, and does it round to the right
    number of decimals

Read it as: PLAIN live + MASKED blank  -> that mask is too narrow or wrong.
            both live, MASKED rounded  -> the mask is good.
            both blank                 -> that item, not the mask.

    python 04-scada/hmi/build_tagtest.py
"""

import datetime
import re
from pathlib import Path

import point_format as pf

HERE = Path(__file__).resolve().parent
OUT = HERE / "out" / "WRPS_TagTest.xml"
ITEM_FILE = HERE.parents[1] / "04-scada" / "modbus_points" / "wrps_item_df.qli"
IDS_FILE = HERE / "item-ids.csv"

W, H = 1920, 1080

# This display is the BOOTSTRAP: it binds every project item, so saving it
# once in CI View measures all 49 ids in a single pass.  It is therefore
# the one display allowed to carry ids that are not measured yet - it has
# to be buildable before anything has been measured, or there is no way to
# measure anything.  CI View resolves each connection by NAME on save, so
# a placeholder id is enough for the harvest to work.
#
# It used to carry its own copy of the id rule, with the base hardcoded at
# 136 - which silently went a whole generation stale when the items were
# re-imported.  Ids now come from build_display, the single source, and
# anything unmeasured is an obvious placeholder rather than a plausible
# wrong number.
PLACEHOLDER = "1.1.0.0.0.1.1.%d"

# ------------------------------------------------------------------ items


def items():
    """Project items in creation order, with their itemId.

    (index, name, representation, itemId, measured?)
    """
    import build_display as bd
    measured = bd.measured_ids()
    reps = bd.item_reps()

    q = ITEM_FILE.read_text(encoding="utf-8")
    order, seen = [], set()
    for m in re.finditer(r'^"(AID\.WRPS\.[A-Z0-9_.]+)"', q, re.M):
        if m.group(1) not in seen:
            seen.add(m.group(1))
            order.append(m.group(1))

    out = []
    for k, name in enumerate(order):
        rep = reps.get(name, "Real")
        iid = measured.get(name, PLACEHOLDER % (1 if rep == "Boolean" else 7))
        out.append((k, name, rep, iid, name in measured))
    return out


# ------------------------------------------------------------------ layout
ROWS_PER_COL = 25
ROW_PITCH = 38
TOP = 92
COL_X = (40, 990)           # left edge of each page column
W_IDX, W_NAME, W_REF = 34, 470, 150

LAB = "Segoe UI"
VAL = "Consolas"


def esc(s):
    return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))


def half_w(text, size, font):
    """Half the rendered width.  Approximate - the editor normalises on save."""
    per = 0.60 if font == VAL else 0.52
    return round(len(text) * size * per / 2.0, 4)


class Doc:
    def __init__(self):
        self.refs = []          # (id, xml) in globalSection
        self.parts = []         # component xml
        self._next_ref = 10
        self._next_html = 100
        self._cache = {}

    def ref(self, key, xml):
        if key in self._cache:
            return self._cache[key]
        rid = self._next_ref
        self._next_ref += 1
        self.refs.append((rid, xml))
        self._cache[key] = rid
        return rid

    def hid(self):
        self._next_html += 1
        return self._next_html

    def paint(self, rgb):
        return self.ref(("paint", rgb),
                        '<sharedObject type="paint">\n'
                        '        <paint type="colorSet" r="%d" g="%d" b="%d"/>\n'
                        '      </sharedObject>' % rgb)

    def filt(self, value="0.0"):
        return self.ref(("filter", value),
                        '<sharedObject type="filter">\n'
                        '        <value>%s</value>\n'
                        '      </sharedObject>' % value)

    # --- components ----------------------------------------------------
    def text(self, x, y, value, size=14, colour=(14, 22, 33), bold=False,
             font=LAB):
        """x is the LEFT edge; CI View stores centre + half extents."""
        hw = half_w(value, size, font)
        hh = round(size * 0.62 / 2.0 + 2.0, 4)
        self.parts.append(
            '    <text type="componentData">\n'
            '      <htmlId>%d</htmlId>\n'
            '      <value>%s</value>\n'
            '      <font type="font" name="%s"%s size="%d" underline="false" '
            'strikethrough="false"/>\n'
            '      <fillPaint ref="%d"/>\n'
            '      <stroke type="stroke" width="1.0"/>\n'
            '      <keepOriginalSize>true</keepOriginalSize>\n'
            '      <x>%s</x>\n      <y>%s</y>\n'
            '      <top>%s</top>\n      <bottom>%s</bottom>\n'
            '      <left>%s</left>\n      <right>%s</right>\n'
            '      <rotation>0.0</rotation>\n      <shear>0.0</shear>\n'
            '    </text>'
            % (self.hid(), esc(value), font, ' bold="true"' if bold else '',
               size, self.paint(colour), round(x + hw, 4), float(y),
               hh, hh, hw, hw))

    def action(self, item, iid, filter_ref):
        """The actionConnectTo block, byte-for-byte as CI View saved it."""
        return ('<action type="actionConnectTo">\n'
                '          <property type="property" name="number.value"/>\n'
                '          <filter ref="%d"/>\n'
                '          <connection type="connection">\n'
                '            <direction>1</direction>\n'
                '            <itemName>%s</itemName>\n'
                '            <itemId>%s</itemId>\n'
                '          </connection>\n'
                '        </action>' % (filter_ref, item, iid))

    def number(self, x, y, item, iid, mask=None, size=14,
               colour=(14, 22, 33)):
        """A bound number.  mask=None -> no <format> (the control).

        Element order copies what CI View wrote when the FIT-201 INLET value
        was formatted by hand: <htmlId>, <format>, <font>, ... , <data>.
        A masked number carries no <value>.
        """
        rid = self.ref(("data", item),
                       '<sharedObject type="data">\n        %s\n      '
                       '</sharedObject>' % self.action(item, iid, self.filt("0.0")))
        if mask:
            head = '      <format>%s</format>\n' % mask
            sizer = mask
        else:
            head = '      <value>0</value>\n'
            sizer = "0000000"
        hw = half_w(sizer, size, VAL)
        hh = round(size * 0.62 / 2.0 + 2.0, 4)
        self.parts.append(
            '    <number type="componentData">\n'
            '      <htmlId>%d</htmlId>\n'
            '%s'
            '      <font type="font" name="%s" size="%d" underline="false" '
            'strikethrough="false"/>\n'
            '      <fillPaint ref="%d"/>\n'
            '      <stroke type="stroke" width="1.0"/>\n'
            '      <keepOriginalSize>true</keepOriginalSize>\n'
            '      <x>%s</x>\n      <y>%s</y>\n'
            '      <top>%s</top>\n      <bottom>%s</bottom>\n'
            '      <left>%s</left>\n      <right>%s</right>\n'
            '      <rotation>0.0</rotation>\n      <shear>0.0</shear>\n'
            '      <data ref="%d"/>\n'
            '    </number>'
            % (self.hid(), head, VAL, size, self.paint(colour),
               round(x + hw, 4), float(y), hh, hh, hw, hw, rid))

    # --- serialise ------------------------------------------------------
    def xml(self, title):
        when = datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S.0 AEST")
        g = "\n".join('    <intRef id="%d">\n      %s\n    </intRef>'
                      % (rid, x) for rid, x in self.refs)
        vis = "".join(
            '    <visibilityGroup type="componentData">\n'
            '      <htmlId>%d</htmlId>\n      <name>%s</name>\n'
            '      <description>%s</description>\n'
            '      <minimumZoomEnabled>true</minimumZoomEnabled>\n'
            '      <minimumZoomFactor>%s</minimumZoomFactor>\n'
            '    </visibilityGroup>\n'
            % (h, n, d, z) for h, n, d, z in (
                (2, "Overview", "Always shown", "10.0"),
                (3, "Rough", "Shown when viewing a large area", "25.0"),
                (4, "Standard", "Shown when using the default view setting", "100.0"),
                (5, "Detailed", "Shown when zoomed in", "200.0"),
                (6, "Fine", "Shown when zoomed in closely", "400.0"),
            ))
        return (
            '<?xml version="1.0" encoding="utf-8" ?>\n'
            '<visualization protocolVersion="1.3.0.0">\n'
            '  <globalSection>\n%s\n  </globalSection>\n'
            '  <coreObjectDefinition type="displayDefinition">\n'
            '    <version type="version" value="1.3.0.0"/>\n'
            '    <width>%d</width>\n    <height>%d</height>\n'
            '    <referenceCheck>5</referenceCheck>\n'
            '    <defaultBgColor type="colorSet" r="255" g="255" b="255"/>\n'
            '    <defaultFgColor type="colorSet" r="14" g="22" b="33"/>\n'
            '    <defaultFont type="font" name="Segoe UI" size="14" '
            'underline="false" strikethrough="false"/>\n'
            '    <defaultStroke type="stroke" width="1.0"/>\n'
            '    <grid type="grid" gridVisible="true" snappingActive="true" '
            'verticalSnapInterval="4" horizontalSnapInterval="4" onTop="false">\n'
            '      <color type="colorSet" r="0" g="0" b="0"/>\n'
            '    </grid>\n'
            '    <revisionHistory type="revisionHistory">\n'
            '      <revision type="revision" who="ADMIN" when="%s" '
            'what="%s" where="PXiSEDev"/>\n'
            '    </revisionHistory>\n'
            '    <blinkDelay>700</blinkDelay>\n'
            '    <blinkEnabled>true</blinkEnabled>\n'
            '    <mousePassThrough>false</mousePassThrough>\n'
            '%s'
            '    <visualizationLayer type="componentData">\n'
            '      <htmlId>1</htmlId>\n      <name>Layer1</name>\n'
            '    </visualizationLayer>\n'
            '    <componentCountHint>%d</componentCountHint>\n'
            '%s\n'
            '  </coreObjectDefinition>\n'
            '</visualization>\n'
            % (g, W, H, when, esc(title), vis,
               len(self.parts) + 6, "\n".join(self.parts)))


# ------------------------------------------------------------------ build
def build():
    d = Doc()
    rows = items()

    grey = (70, 84, 104)
    d.text(40, 30, "WRPS TAG TEST 2  -  format masks, every project item",
           size=20, bold=True)
    d.text(40, 56, "left number = PLAIN, no <format> (this was live last time, "
                   "so it must stay live)   |   right number = the same binding "
                   "MASKED with the mask printed beside it   |   masked blank "
                   "while plain is live means that mask is wrong",
           size=12, colour=grey)

    for k, name, rep, iid, measured in rows:
        col, row = divmod(k, ROWS_PER_COL)
        x = COL_X[col]
        y = TOP + row * ROW_PITCH
        short = name[len("AID.WRPS."):]
        mask = pf.mask(name)

        d.text(x, y, "%02d" % (k + 1), size=13, colour=grey, font=VAL)
        d.text(x + W_IDX, y, short, size=13, font=VAL)
        d.text(x + W_IDX + 210, y, "%s%s" % (rep[0], "" if measured else " *"),
               size=11, colour=grey, font=VAL)

        nx = x + W_IDX + 250
        d.number(nx, y, name, iid, mask=None)
        d.text(nx + 120, y, mask.rjust(5), size=11, colour=grey, font=VAL)
        d.number(nx + 190, y, name, iid, mask=mask)

    unmeasured = [r for r in rows if not r[4]]
    d.text(40, 1050,
           "* itemId NOT measured - placeholder. Save this display in CI View "
           "and harvest it to measure all %d. "
           "%d items, %d bindings, %d distinct masks."
           % (len(rows), len(rows), len(rows) * 2, len(set(pf.MASKS.values()))),
           size=12, colour=grey)

    OUT.parent.mkdir(exist_ok=True)
    OUT.write_text(d.xml("Generated by build_tagtest.py"), encoding="utf-8")
    print("wrote %s  (%d items, %d numbers, %d shared objects)"
          % (OUT, len(rows), len(rows) * 2, len(d.refs)))
    if unmeasured:
        print("    %d of %d ids are PLACEHOLDERS and will read 0: %s"
              % (len(unmeasured), len(rows),
                 ", ".join(r[1].replace("AID.WRPS.", "") for r in unmeasured[:6])
                 + (" ..." if len(unmeasured) > 6 else "")))
        print("    That is expected before a harvest - this display exists to "
              "measure them.\n"
              "    Deploy it, open it in CI View, link one value, save, then:\n"
              "        python build_display.py --harvest <the saved file>")
    else:
        print("    all %d ids measured" % len(rows))


if __name__ == "__main__":
    build()
