# =============================================================================
# Project Library script: alarmBuffer
#
# Decodes the PLC alarm buffer (~580 JSON alarm records delivered on a single
# OPC UA node, shuffle-up list, oldest first) into individual Ignition UDT
# instances, one per alarm code.
#
# Source tag: one OPC tag (BUFFER_TAG) subscribed to the buffer node.
#   Data type: String Array if each element is a JSON string,
#              String if the whole buffer is one JSON array string,
#              Document / Document Array also handled.
#
# Trigger: Gateway Events > Tag Change
#   Tag path: BUFFER_TAG, change type: Value (and Quality)
#   Script:   alarmBuffer.scan(newValue)
#
# Jython 2.7 / Ignition 8.1+
# =============================================================================
from java.lang import System as JSystem

# ---- Configuration ----------------------------------------------------------
BUFFER_TAG  = "[default]PackageAlarms/_Config/AlarmBufferRaw"  # the OPC tag on the buffer node
TAG_ROOT    = "[default]PackageAlarms"     # folder holding the PkgAlarm UDT instances
CLEAR_DEBOUNCE_SCANS = 1                   # snapshot is atomic and change-triggered: keep at 1
MAP_REFRESH_MS = 0                         # 0 = build the code->tag map once per gateway start, never refresh

# JSON key names in each record -> confirm against a real sample
KEY_CODE = "AlarmCode"

# JSON key -> UDT member it is written to (keys not listed are not written)
MEMBER = {
    "Severity":        "Severity",
    "Priority":        "Priority",
    "Source":          "Source",
    "TimestampActive": "PlcTsActive",
    "Acknowledged":    "PlcAcked",
}

log = system.util.getLogger("AlarmBuffer")


# ---- Helpers ----------------------------------------------------------------
def _state():
    """State survives project-library reloads via gateway globals."""
    g = system.util.getGlobals()
    st = g.get("alarmBufferState")
    if st is None:
        st = {"active": {}, "missing": {}, "map": None, "mapTime": 0,
              "initialized": False, "unmappedLogged": set()}
        g["alarmBufferState"] = st
    return st


def _convert(key, value):
    """Convert raw JSON values to what the UDT members expect.
    Adjust TimestampActive to match the PLC format
    (e.g. ISO string -> system.date.parse(value, "yyyy-MM-dd'T'HH:mm:ss.SSS"),
     or epoch microseconds -> system.date.fromMillis(long(value) / 1000))."""
    return value


def _toRecord(item):
    """One buffer element -> dict, or None if the slot is empty."""
    if item is None:
        return None
    if not isinstance(item, dict):
        s = unicode(item).strip()   # JSON string, or a Document whose toString() is JSON
        if not s or s in ("{}", "null"):
            return None
        item = system.util.jsonDecode(s)
    code = item.get(KEY_CODE) if item else None
    if code in (None, 0, "", "0"):
        return None
    return item


def _parse(value):
    """Whole buffer value -> {alarmCode: record}. Stops at the first empty slot."""
    if value is None:
        return {}
    if isinstance(value, basestring):
        items = system.util.jsonDecode(value) or []      # whole buffer as one JSON array string
    elif hasattr(value, "isJsonArray") and value.isJsonArray():
        items = list(value)                              # Document holding an array
    else:
        items = list(value)                              # String Array / Document Array
    out = {}
    for item in items:
        rec = _toRecord(item)
        if rec is None:
            break
        out[int(rec[KEY_CODE])] = rec
    return out


def _buildMap(st):
    """AlarmCode -> UDT instance path, read from each instance's Code member."""
    m = {}
    res = system.tag.browse(TAG_ROOT, {"tagType": "UdtInstance", "recursive": True})
    insts = [str(r["fullPath"]) for r in res.getResults()]
    if insts:
        qvs = system.tag.readBlocking([p + "/Code" for p in insts])
        for p, qv in zip(insts, qvs):
            if qv.quality.isGood() and qv.value is not None:
                m[int(qv.value)] = p
    st["map"] = m
    st["mapTime"] = JSystem.currentTimeMillis()
    log.info("Alarm map built: %d alarm tags" % len(m))


def _path(st, code):
    p = st["map"].get(code)
    if p is None and code not in st["unmappedLogged"]:
        st["unmappedLogged"].add(code)
        log.warn("AlarmCode %s is in the PLC buffer but has no Ignition tag" % code)
    return p


def _seed(st):
    """First run after gateway start: anything Active in Ignition is treated as
    previously known, so stale alarms get cleared and live ones get refreshed."""
    codes = list(st["map"].keys())
    if codes:
        qvs = system.tag.readBlocking([st["map"][c] + "/Active" for c in codes])
        for c, qv in zip(codes, qvs):
            if qv.value:
                st["active"][c] = {"_seed": True}
    st["initialized"] = True


# ---- Main -------------------------------------------------------------------
def scan(qv=None):
    """qv: the buffer tag's QualifiedValue (newValue from the tag change script).
    If omitted, the tag is read directly (handy for timers or testing)."""
    if qv is None:
        qv = system.tag.readBlocking([BUFFER_TAG])[0]

    # Never act on bad data; never clear alarms because of a comms problem.
    if not qv.quality.isGood():
        log.warn("Alarm buffer quality %s, skipping" % qv.quality)
        system.tag.writeBlocking([TAG_ROOT + "/_BufferHealthy"], [False])
        return

    st = _state()
    now_ms = JSystem.currentTimeMillis()
    if st["map"] is None or (MAP_REFRESH_MS > 0 and now_ms - st["mapTime"] > MAP_REFRESH_MS):
        _buildMap(st)
    if not st["initialized"]:
        _seed(st)

    try:
        current = _parse(qv.value)
    except Exception as e:
        log.warn("Alarm buffer parse failed, skipping: %s" % e)
        system.tag.writeBlocking([TAG_ROOT + "/_BufferHealthy"], [False])
        return

    prev = st["active"]
    detailPaths, detailVals = [], []   # written first
    statePaths,  stateVals  = [], []   # Active written second so the alarm sees fresh details
    now = system.date.now()

    # New alarms and changes to existing ones
    for code, rec in current.items():
        st["missing"].pop(code, None)
        base = _path(st, code)
        old = prev.get(code)
        prev[code] = rec
        if base is None:
            continue
        isNew = old is None or old.get("_seed")
        for key, member in MEMBER.items():
            if isNew or old.get(key) != rec.get(key):
                detailPaths.append(base + "/" + member)
                detailVals.append(_convert(key, rec.get(key)))
        if isNew:
            statePaths.append(base + "/Active")
            stateVals.append(True)

    # Cleared alarms
    for code in list(prev.keys()):
        if code in current:
            continue
        cnt = st["missing"].get(code, 0) + 1
        if cnt < CLEAR_DEBOUNCE_SCANS:
            st["missing"][code] = cnt
            continue
        base = _path(st, code)
        if base is not None:
            detailPaths.append(base + "/ResetTime")
            detailVals.append(now)
            statePaths.append(base + "/Active")
            stateVals.append(False)
        del prev[code]
        st["missing"].pop(code, None)

    if detailPaths:
        system.tag.writeBlocking(detailPaths, detailVals)
    if statePaths:
        system.tag.writeBlocking(statePaths, stateVals)
    system.tag.writeBlocking([TAG_ROOT + "/_BufferHealthy", TAG_ROOT + "/_ActiveCount"],
                             [True, len(current)])
