#!/usr/bin/env python3
"""Emit CI Server .qli imports for the WRPS points.

    python gen_scada_points.py        # first: refresh ci-server-points.csv
    python gen_ciserver_qli.py

Writes, alongside this script:

    wrps_section_df.qli        @SECTION_DF      - the AID.WRPS.* sections
    wrps_modbus_point_df.qli   @MODBUS_POINT_DF - the Modbus point definitions
    wrps_item_df.qli           @ITEM_DF         - the items in those sections

Import them in that order: CI Server derives its hierarchy from the dots
in a name, and a section must exist before an item can be created in it.

The chain is  ST sources -> 03-plc/register-map.csv -> ci-server-points.csv ->
these files, so a register change propagates by re-running the three
scripts rather than by hand-editing anything.

Field layouts and every constant here were copied from the user's own
exports in 99-reference/ciserver-qli-exports/ (section_df.qli,
modbus_point_df.qli, item_df.qli), not invented.

NSIDs
-----
The NSID space is shared by sections and items.  In the reference
exports items occupy 3-166 and sections 1-170, so 171 is the first free
id: sections take 171-176 and items 177 onward (--nsid-base).

AID (169) and AID.WRPS (170) already exist, so this script emits only
their six children and hangs them off 170 (--parent-nsid).  Pass
--include-root to emit AID and AID.WRPS as well, for a system that does
not have them yet.

THE ONE THING TO VERIFY ON A SMALL TEST IMPORT
----------------------------------------------
**Address base** (--address-base, default 1).  The reference export maps
point AI_01 to IO_ADDRESS "RI:01".  Whether CI Server's 01 means
protocol address 0 or 1 cannot be told from the file.  Default here is
1-based, i.e. IO_ADDRESS = modbus address + 1, so holding register 0
(%QW0, wet well level) becomes "RO:01".  If the level shows up where the
inflow should be, it is 0-based: re-run with --address-base 0.
"""

import argparse
import csv
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
POINTS = HERE / "ci-server-points.csv"

STATION = "WRPS_PLC"
ROOT = "AID.WRPS"

# IO_ADDRESS prefixes, from the reference export:
#   RI = register input (FC04)   RO = register output (FC03/06)
#   DI = digital input (FC02)    DO = digital output (FC01)
PREFIX = {"FC01": "DO", "FC02": "DI", "FC03": "RO", "FC03/FC06": "RO", "FC04": "RI"}

# Section per poll group; PS_PUBLISHED and PS_STATUS_BITS are split by
# equipment so each pump gets its own folder.
GROUP_SECTION = {"PS_SETPOINTS": "SP", "PS_SIM_CONTROL": "SIM"}

SECTION_ORDER = ["STN", "PU301", "PU302", "PU303", "SP", "SIM"]

SECTION_DESC = {
    "STN": "Waterloo Road PS - station wide measurements and status",
    "PU301": "Pump PU-301",
    "PU302": "Pump PU-302",
    "PU303": "Pump PU-303",
    "SP": "Operator setpoints and commands",
    "SIM": "Simulation control - simulation build only",
}

SECTION_FIELDS = [
    "NAME", "SECTION_PATH", "SECTION_NAME", "BLOCKED", "PARENT_BLOCKED",
    "ALARM_INHIBIT", "PARENT_ALARM_INHIBIT", "OPC_VISIBLE", "PARENT_OPC_VISIBLE", "NSID", "PARENT_NSID",
    "DESCRIPTION", "CREATED_BY",
]

# Short, stable leaf names.  Keyed on IEC address so they never drift
# with a description edit.
LEAF = {
    "%QX0.0": ("PU301", "RUN_CMD"), "%QX0.1": ("PU302", "RUN_CMD"),
    "%QX0.2": ("PU303", "RUN_CMD"), "%QX0.3": ("PU301", "RUNNING"),
    "%QX0.4": ("PU302", "RUNNING"), "%QX0.5": ("PU303", "RUNNING"),
    "%QX0.6": ("PU301", "AVAILABLE"), "%QX0.7": ("PU302", "AVAILABLE"),
    "%QX1.0": ("PU303", "AVAILABLE"), "%QX1.1": ("STN", "IN_AUTO"),
    "%QX1.2": ("STN", "HIGH_LEVEL"), "%QX1.3": ("STN", "SPILL_ACTIVE"),
    "%QX1.4": ("PU301", "TRIPPED"), "%QX1.5": ("PU302", "TRIPPED"),
    "%QX1.6": ("PU303", "TRIPPED"),
    "%QW0": ("STN", "LEVEL"), "%QW1": ("STN", "INFLOW"),
    "%QW2": ("STN", "DISCHARGE"), "%QW3": ("STN", "PUMPS_RUNNING"),
    "%QW4": ("STN", "SPEED"), "%QW5": ("STN", "TIME_TO_SPILL"),
    "%QW6": ("STN", "TIME_TO_LSHH"), "%QW7": ("STN", "NET_ACCUM"),
    "%QW8": ("PU301", "RUN_HOURS"), "%QW9": ("PU302", "RUN_HOURS"),
    "%QW10": ("PU303", "RUN_HOURS"), "%QW11": ("STN", "VOL_TO_SPILL"),
    "%QW12": ("STN", "STATE"), "%QW13": ("PU301", "STATE"),
    "%QW14": ("PU302", "STATE"), "%QW15": ("PU303", "STATE"),
    "%QW16": ("STN", "DUTY_PUMP"), "%QW17": ("STN", "ALARM_WORD"),
    "%QW20": ("STN", "CMD_ACK"),
    "%MW0": ("SP", "MODE"), "%MW1": ("SP", "CMD_WORD"),
    "%MW2": ("SP", "CMD_PARAM"), "%MW3": ("SP", "LEVEL_SP"),
    "%MW4": ("SP", "START_DUTY"), "%MW5": ("SP", "START_P2"),
    "%MW6": ("SP", "START_P3"), "%MW7": ("SP", "STOP_ALL"),
    "%MW8": ("SP", "HIGH_ALARM"), "%MW9": ("SP", "MIN_SPEED"),
    "%MW10": ("SP", "SERVICE_HRS"),
    "%MW20": ("SIM", "INFLOW"), "%MW21": ("SIM", "SCENARIO"),
    "%MW22": ("SIM", "RESET"), "%MW23": ("SIM", "TIME_SCALE"),
}


def q(v):
    """Quote a .qli value the way the reference exports do."""
    return '"%s"' % ("" if v is None else str(v))


def wrap(values):
    """Split into the reference exports' line shape: 5, then 6 per line.

    Both reference files wrap this way, in the @FIELDS header and in
    every record.  Reproduced exactly rather than assuming the importer
    tolerates a different grouping.
    """
    out = [list(values[:5])]
    rest = list(values[5:])
    for i in range(0, len(rest), 6):
        out.append(rest[i:i + 6])
    return out


def record(rows):
    """Format one record, backslash continuations between lines."""
    lines = [",".join(str(v) for v in chunk) for chunk in wrap(rows)]
    return (",\\\n".join(lines)) + "\n"


def header(version, fields, tag):
    lines = ["", "@LANGUAGE", "ENGLISH", "", "", "@VERSION", version, "", "", "!" + "=" * 130, "", "@FIELDS"]
    lines += [",".join(chunk) for chunk in wrap(fields)]
    lines += ["", tag]
    return "\n".join(lines) + "\n"


POINT_FIELDS = [
    "NAME", "STATION", "POINT", "DESCRIPTION", "IO_ADDRESS",
    "EXTERNAL_RELATION", "SCAN_TYPE", "CONV_TYPE", "DELTA_LIMIT", "MAX_INSENS", "OFFSET",
    "PHYS_LOW", "PHYS_HIGH", "STEP", "INVERS", "HAS_SIGN", "OVERFL_DET",
    "SWAP_BYTES", "SWAP_WORDS", "ELEC_LOW", "ELEC_HIGH", "TMO_NONE", "TMO_BOTH",
    "AVE_UPD_INTERVAL", "NO_ZERO", "BURST_LIMIT", "BITS", "CHARS", "DIGITS",
    "FLOAT_TYPE", "TIME_ZONE", "TIME_REPRES", "WLS_VAL_TYPE",
]

ITEM_FIELDS = [
    "NAME", "NSID", "PARENT_NSID", "SECTION_PATH", "DESCRIPTION",
    "DEADBAND", "LOW_LIMIT", "HIGH_LIMIT", "LOW_LOW_LIMIT", "HIGH_HIGH_LIMIT", "T_VALUE",
    "P_VALUE", "I_VALUE", "TREND_UP_LIMIT", "TREND_LOW_LIMIT", "SCALE_HIGH_LIMIT", "SCALE_LOW_LIMIT",
    "DUMMY2", "COMMENT_1", "COMMENT_2", "VALUE_FORMAT", "POSITIONED", "LONGITUDE",
    "LATITUDE", "ALARMING", "DIAG", "NAME_IN_ITM_TAB", "STORAGE", "AUDIT_INFO",
    "FO_ITEM", "BLOCKED", "PARENT_BLOCKED", "ALARM_INHIBIT", "PARENT_ALARM_INHIBIT", "OPC_VISIBLE",
    "PARENT_OPC_VISIBLE", "OPC_READ", "OPC_WRITE", "OPC_ALARM_DETECTION", "HAS_SUB", "STRING_LENGTH",
    "DELAY", "REPEAT", "ID_GROUP", "ID_NUMBER", "ITEM_REP", "ITEM_TYPE",
    "ITEM_SPECIAL", "ACKN_TYPE", "ALARM_GROUP", "FRONT_END_NODE", "DISTR_TYPE", "INSTALL",
    "UNIT", "TAG", "LIMIT_CLAMP", "OUT_OF_RANGE", "COL_GROUP", "STATION",
    "POINT", "FO_GROUP", "ITEM_STAT_1", "ITEM_STAT_2", "ITEM_STAT_3", "ITEM_STAT_4",
    "ITEM_STAT_5", "ITEM_STAT_6", "ALARM_STATE_1", "ALARM_STATE_2", "ALARM_STATE_3", "ALARM_STATE_4",
    "ALARM_STATE_5", "ALARM_STATE_6", "PRIORITY_1", "PRIORITY_2", "PRIORITY_3", "PRIORITY_4",
    "PRIORITY_5", "PRIORITY_6", "ALARM_TEXT_1", "ALARM_TEXT_2", "ALARM_TEXT_3", "ALARM_TEXT_4",
    "ALARM_TEXT_5", "ALARM_TEXT_6", "ALARM_COLOR_1", "ALARM_COLOR_2", "ALARM_COLOR_3", "ALARM_COLOR_4",
    "ALARM_COLOR_5", "ALARM_COLOR_6", "MNEMONIC_1", "MNEMONIC_2", "MNEMONIC_3", "MNEMONIC_4",
    "MNEMONIC_5", "MNEMONIC_6", "AOI_1", "AOI_2", "AOI_3", "AOI_4",
    "AOI_5", "AOI_6", "AOI_7", "AOI_8", "AOI_9", "AOI_10",
    "AOI_11", "AOI_12", "AOI_13", "AOI_14", "AOI_15", "AOI_16",
    "ENG_UNIT", "PROCESS_LIST", "POINT_NAME", "OPC_AE_STATION_NAME", "OPC_EVENT_SOURCE", "OPC_EVENT_SOURCE_NAME",
    "CREATED_BY", "SHELVE_ENABLED_1", "SHELVE_ENABLED_2", "SHELVE_ENABLED_3", "SHELVE_ENABLED_4", "SHELVE_ENABLED_5",
    "SHELVE_ENABLED_6", "AGG_INTERVAL",
]


def build_section(name, parent_path, leaf, nsid, parent_nsid, description):
    """One @SECTION_DF record, shaped like the reference export.

    PARENT_OPC_VISIBLE is 1 for a root section and 0 for a child, which
    is what the reference shows for AID (root) versus AID.WRPS (child).
    """
    return record([
        q(name), q(parent_path), q(leaf), 0, 0,
        0, 0, 0, 1 if parent_path == "" else 0, nsid, parent_nsid,
        q(description), q("unknown"),
    ])


def trimmed_span(gain, signed):
    """The ELEC range to map from, trimmed so PHYS lands on exact decimals.

    CI Server stores the conversion as two endpoints, not as a gain, so
    it recovers the gain by dividing.  With a gain of 1/60 the full-scale
    endpoint 32767/60 = 546.11666... has to be written rounded, and the
    recovered gain is then slightly off: setpoint 4200 came back as
    70.00003333333336 rather than 70.

    So when the gain is 1/n, trim the span to the nearest multiple of n
    inside it - 32760 instead of 32767, giving PHYS exactly +/-546 and a
    gain CI Server recovers exactly.  Nothing is lost: the trimmed 7
    counts are 0.4 mm of a level register that tops out at 7000.

    Gains that already terminate (0.1, 0.2, 0.36) keep the full span.
    """
    lo, hi = (-32768, 32767) if signed else (0, 65535)
    inv = 1.0 / gain
    n = int(round(inv))
    if n > 1 and abs(inv - n) < 1e-9:
        lo, hi = -((-lo) // n) * n, (hi // n) * n
    return lo, hi


def build_point(row, io_addr):
    digital = row["data_type"] == "Boolean"
    writable = row["access"] == "Read/Write"
    gain = float(row["eng_gain"])
    signed = row["data_type"] == "Signed 16-bit"

    if digital:
        conv, bits = "Digital", 16
        elec_lo, elec_hi, phys_lo, phys_hi = 0, 100, 0, 100
    else:
        conv, bits = "Linear", 16
        # Full-scale linear mapping.  Exact, and invents no plant range;
        # display ranges and alarm limits are set per item in CI Server.
        # The engineering unit is carried entirely by the gain (see UNITS
        # in gen_scada_points.py), so PHYS is ELEC scaled by it - which is
        # why the offset must stay zero on both ends.
        elec_lo, elec_hi = trimmed_span(gain, signed)
        phys_lo, phys_hi = elec_lo * gain, elec_hi * gain

    external = "Input + Output" if writable else "Input"
    scan = "MOD_SCAN"

    name = "%s:%s" % (STATION, row["point"])
    return record([
        q(name), q(STATION), q(row["point"]), q(row["description"][:60]), q(io_addr),
        q(external), q(scan), q(conv), 0, 0, 0,
        _num(phys_lo), _num(phys_hi), 1, 0, 1 if signed else 0, 0,
        0, 0, _num(elec_lo), _num(elec_hi), 0, 0,
        0, 0, 0, bits, 16, 4,
        q("Intel"), q("Date+time GMT"), q("7 bytes IEC"), q("Float value"),
    ])


def _num(v):
    """Whole numbers stay whole; fractions keep enough digits to be exact.

    Four decimals, not one: a gain of 0.001 puts PHYS_HIGH at 32.767, and
    rounding that to 32.8 would bend the conversion by 0.1%.
    """
    return int(v) if float(v) == int(float(v)) else round(float(v), 4)


def build_item(row, nsid, parent_nsid, id_number):
    digital = row["data_type"] == "Boolean"
    section = row["section"]
    path = "%s.%s" % (ROOT, section)
    name = "%s.%s" % (path, row["leaf"])

    item_rep = "Boolean" if digital else "Real"
    value_format = "" if digital else row["format_mask"]
    eng_unit = row["eng_units"]

    f = {k: 0 for k in ITEM_FIELDS}
    f.update({k: q("") for k in ITEM_FIELDS if k.startswith(
        ("COMMENT", "ITEM_STAT", "ALARM_TEXT", "ALARM_COLOR", "MNEMONIC", "AOI"))})

    f["NAME"] = q(name)
    f["NSID"] = nsid
    f["PARENT_NSID"] = parent_nsid
    f["SECTION_PATH"] = q(path)
    f["DESCRIPTION"] = q(row["description"][:60])
    f["VALUE_FORMAT"] = q(value_format)
    f["NAME_IN_ITM_TAB"] = 1
    f["STRING_LENGTH"] = 1
    f["ID_GROUP"] = 1
    f["ID_NUMBER"] = id_number
    f["ITEM_REP"] = q(item_rep)
    f["ITEM_TYPE"] = q("")
    f["ITEM_SPECIAL"] = q("")
    f["ACKN_TYPE"] = q("")
    f["ALARM_GROUP"] = q("")
    f["FRONT_END_NODE"] = q("UNLICENCED")
    f["DISTR_TYPE"] = q("Local Host")
    f["INSTALL"] = q("WRPS")
    f["UNIT"] = q(section)
    f["TAG"] = q(row["leaf"])
    f["LIMIT_CLAMP"] = q("")
    f["COL_GROUP"] = q("")
    f["STATION"] = q(STATION)
    f["POINT"] = q(row["point"])
    f["FO_GROUP"] = q("")
    for i in range(1, 7):
        f["ALARM_STATE_%d" % i] = q("Normal")
    if digital:
        f["ITEM_STAT_1"] = q("BOOLEAN 0")
        f["ITEM_STAT_2"] = q("BOOLEAN 1")
    f["ENG_UNIT"] = q(eng_unit)
    f["PROCESS_LIST"] = q("")
    f["POINT_NAME"] = q("%s:%s" % (STATION, row["point"]))
    f["OPC_AE_STATION_NAME"] = q("")
    f["OPC_EVENT_SOURCE"] = q("")
    f["OPC_EVENT_SOURCE_NAME"] = q(":")
    f["CREATED_BY"] = q("unknown")
    for i in range(1, 7):
        f["SHELVE_ENABLED_%d" % i] = 1
    f["AGG_INTERVAL"] = q("")

    return record([f[k] for k in ITEM_FIELDS])


def main():
    ap = argparse.ArgumentParser(description="Emit CI Server .qli imports.")
    ap.add_argument("--address-base", type=int, choices=(0, 1), default=1,
                    help="IO_ADDRESS numbering: 1 = protocol address + 1 (default)")
    ap.add_argument("--nsid-base", type=int, default=171,
                    help="first free NSID; sections take six from here, items follow")
    ap.add_argument("--parent-nsid", type=int, default=170,
                    help="NSID of AID.WRPS, which the six sections hang off")
    ap.add_argument("--include-root", action="store_true",
                    help="also emit AID and AID.WRPS sections (they already exist here)")
    ap.add_argument("--section-nsid", action="append", default=[],
                    metavar="SECTION=NSID", help="pin a section's NSID, e.g. STN=41")
    ap.add_argument("--version", default="1.03.00", help="@VERSION written into both files")
    args = ap.parse_args()

    if not POINTS.is_file():
        sys.exit("missing ci-server-points.csv - run gen_scada_points.py first")

    pinned = {}
    for spec in args.section_nsid:
        k, _, v = spec.partition("=")
        pinned[k.strip().upper()] = int(v)

    rows = list(csv.DictReader(open(POINTS, newline="", encoding="utf-8")))
    prepared = []
    for r in rows:
        iec = r["iec_address"]
        if iec not in LEAF:
            sys.exit("no leaf name defined for %s - add it to LEAF" % iec)
        section, leaf = LEAF[iec]
        r["section"] = section
        r["leaf"] = leaf
        r["point"] = ("%s_%s" % (section, leaf))[:24]
        prepared.append(r)

    # NSIDs: one per section, then one per item, from --nsid-base.
    next_id = args.nsid_base
    section_nsid = {}
    for s in SECTION_ORDER:
        if s in pinned:
            section_nsid[s] = pinned[s]
        else:
            section_nsid[s] = next_id
            next_id += 1

    section_out = [header(args.version, SECTION_FIELDS, "@SECTION_DF")]
    if args.include_root:
        root, _, wrps_leaf = ROOT.partition(".")
        section_out.append(build_section(root, "", root, args.parent_nsid - 1, 0,
                                         "Waterloo Road Pump Station demo"))
        section_out.append(build_section(ROOT, root, wrps_leaf, args.parent_nsid,
                                         args.parent_nsid - 1,
                                         "Waterloo Road Pump Station"))
    for s in SECTION_ORDER:
        section_out.append(build_section("%s.%s" % (ROOT, s), ROOT, s,
                                         section_nsid[s], args.parent_nsid,
                                         SECTION_DESC[s]))

    point_out = [header(args.version, POINT_FIELDS, "@MODBUS_POINT_DF")]
    item_out = [header(args.version, ITEM_FIELDS, "@ITEM_DF")]

    # NSID and ID_NUMBER follow register order, which is also the order the
    # items were first imported in.  Keep it stable: a re-import must update
    # the existing items in place, not renumber them - the HMI's item ids in
    # 05-scada/hmi/item-ids.csv are derived from that creation order.
    for n, r in enumerate(prepared):
        addr = int(r["modbus_address"]) + args.address_base
        # Zero-padded to at least two digits, as the reference export does
        # ("RI:01", not "RI:1").  Wider addresses keep their own width.
        io_addr = "%s:%02d" % (PREFIX[r["function_code"]], addr)
        point_out.append(build_point(r, io_addr))
        item_out.append(build_item(r, next_id + n, section_nsid[r["section"]], n + 1))

    # The header already ends in a newline, so the first record follows it
    # directly; records are then separated by one blank line, exactly as
    # the reference exports are.
    def assemble(parts):
        return parts[0] + "\n".join(parts[1:])

    (HERE / "wrps_section_df.qli").write_text(assemble(section_out), encoding="utf-8")
    (HERE / "wrps_modbus_point_df.qli").write_text(assemble(point_out), encoding="utf-8")
    (HERE / "wrps_item_df.qli").write_text(assemble(item_out), encoding="utf-8")

    n_sections = len(SECTION_ORDER) + (2 if args.include_root else 0)
    print("OK  wrps_section_df.qli       (%d sections under %s)" % (n_sections, ROOT))
    print("OK  wrps_modbus_point_df.qli  (%d points, station %s)" % (len(prepared), STATION))
    print("OK  wrps_item_df.qli          (%d items under %s.*)" % (len(prepared), ROOT))
    print("    address base : %d  (holding register 0 -> RO:%02d)" % (args.address_base, args.address_base))
    print("    section NSIDs: " + ", ".join("%s=%d" % (s, section_nsid[s]) for s in SECTION_ORDER))
    print("    item NSIDs   : %d-%d" % (next_id, next_id + len(prepared) - 1))
    by_sec = {}
    for r in prepared:
        by_sec.setdefault(r["section"], 0)
        by_sec[r["section"]] += 1
    print("    per section  : " + ", ".join("%s=%d" % (s, by_sec.get(s, 0)) for s in SECTION_ORDER))


if __name__ == "__main__":
    main()
