#!/usr/bin/env python3
"""
decode_frame.py - decode one raw IoT frame by hand with a decoder config JSON.

It follows the same steps as the backend decoder worker, so the result can be compared
with what the dashboard shows. Standard library only (Python 3.8+).

  python3 decode_frame.py --config decoder-full_snapshot_bundle-v5.json --hex 0101...
  python3 decode_frame.py --config all-decoder-configs.json --hex-file frame.txt
  python3 decode_frame.py --config cfg.json --hex 0101... --json
  python3 decode_frame.py --config cfg.json --hex 0101... --expect event.json
  python3 decode_frame.py --config all-decoder-configs.json --list

--config accepts any file downloaded from the dashboard: one decoder config, the
"all decoder configs" file, a per-device config bundle, or a single database row.
If the file holds several configs the right one is picked from the frame header
(protocol version, packet type, schema version).

--hex is the frame exactly as the device sends it: the hex string shown under
"Raw Payload (Hex)" on the packet page. Spaces, 0x and line breaks are ignored.

--expect compares the result with a stored event (the "Full JSON" of an event, or any
JSON with "decodedRaw" / "normalized") and lists every difference.
"""
import argparse
import base64
import datetime
import json
import sys
import zlib

PACKET_NAMES = {
    1: "heartbeat", 2: "location", 3: "battery_summary", 4: "vehicle_telemetry",
    5: "cell_detail", 6: "fault_event", 7: "diagnostic", 8: "config_ack",
    20: "batch_location", 21: "batch_telemetry", 22: "combined_telemetry",
    30: "can_frame_bundle", 31: "realtime_telemetry_bundle", 32: "extended_data_bundle",
    33: "full_snapshot_bundle", 34: "accident_snapshot_bundle", 35: "logfile_meta",
}
SUPPORTED_PROTOCOL_VERSIONS = (1, 13)
HEADER_LEN, CRC_LEN, HMAC_LEN = 20, 4, 32
INT_TYPES = {
    "uint8": (1, False), "int8": (1, True), "uint16": (2, False), "int16": (2, True),
    "uint32": (4, False), "int32": (4, True), "uint64": (8, False), "int64": (8, True),
}
HEADER_LAYOUT = [
    (0, 1, "protocolVersion"), (1, 1, "headerVersion"), (2, 1, "schemaVersion"),
    (3, 1, "packetTypeCode"), (4, 1, "deviceTypeCode"), (5, 4, "deviceNumericId"),
    (9, 4, "sequenceNo"), (13, 4, "eventTimestamp"), (17, 1, "firmwareVersion"),
    (18, 2, "reserved"),
]


class FrameError(Exception):
    """The frame is rejected before decoding (the pipeline would send it to the DLQ)."""

    def __init__(self, reason, message):
        super().__init__(message)
        self.reason = reason


class FieldError(Exception):
    def __init__(self, kind, message):
        super().__init__(message)
        self.kind = kind  # "OUT_OF_BOUNDS" or "OTHER"


class FlagMap(dict):
    """Decoded 'flags' value. Kept separate so dot-path expansion leaves it alone."""


# ---------------------------------------------------------------- input helpers

def parse_hex(text):
    cleaned = text.replace("0x", "").replace("0X", "").replace("\\x", "")
    cleaned = "".join(ch for ch in cleaned if not ch.isspace() and ch not in ",:-")
    if not cleaned:
        raise SystemExit("error: the hex string is empty")
    if len(cleaned) % 2:
        raise SystemExit("error: the hex string has an odd number of characters (%d)" % len(cleaned))
    try:
        return bytes.fromhex(cleaned)
    except ValueError as exc:
        raise SystemExit("error: not a valid hex string: %s" % exc)


def merge_row(row):
    """A database row (with configJson) becomes one config; the JSON wins over the columns.

    The worker looks a config up by the database COLUMNS (packetType, protocolVersion,
    schemaVersion), not by what is written inside the JSON, so the columns are kept
    in "_key" and used for matching.
    """
    cfg = dict(row.get("configJson") or {})
    for key in ("configVersion", "deviceType", "packetType", "protocolVersion",
                "schemaVersion", "decoderStrategy", "endianness"):
        if not cfg.get(key) and row.get(key):
            cfg[key] = row[key]
    cfg["_key"] = {k: str(row[k]) for k in ("deviceType", "packetType", "protocolVersion", "schemaVersion")
                   if row.get(k) is not None}
    if row.get("createdAt"):
        cfg["_createdAt"] = str(row["createdAt"])
    return cfg


def lookup_key(cfg):
    key = cfg.get("_key") or {}
    return (key.get("packetType", cfg.get("packetType")),
            str(key.get("protocolVersion", cfg.get("protocolVersion"))),
            key.get("schemaVersion", cfg.get("schemaVersion")))


def collect_configs(obj):
    if isinstance(obj, list):
        out = []
        for item in obj:
            out.extend(collect_configs(item))
        return out
    if not isinstance(obj, dict):
        return []
    if isinstance(obj.get("configs"), list):
        return collect_configs(obj["configs"])
    if isinstance(obj.get("packetDecodingConfigs"), list):
        return collect_configs(obj["packetDecodingConfigs"])
    if isinstance(obj.get("configJson"), dict):
        return [merge_row(obj)]
    if isinstance(obj.get("fields"), list):
        return [obj]
    return []


def describe(cfg):
    packet, proto, schema = lookup_key(cfg)
    return "%s / %s / protocol %s / %s / config %s / %d fields" % (
        (cfg.get("_key") or {}).get("deviceType", cfg.get("deviceType", "?")), packet or "?", proto,
        schema or "?", cfg.get("configVersion", "?"), len(cfg.get("fields", [])))


def pick_config(configs, header, packet_name, fallback_schema):
    """Same lookup key as the worker: protocol version + packet type + schema version.
    When several rows share the key the newest one wins, like the worker's ORDER BY createdAt DESC."""
    proto, schema = str(header["protocolVersion"]), "v%d" % header["schemaVersion"]

    def find(sv):
        hits = [c for c in configs if lookup_key(c) == (packet_name, proto, sv)]
        return sorted(hits, key=lambda c: c.get("_createdAt", ""), reverse=True)

    notes = []
    hits = find(schema)
    if not hits and fallback_schema and fallback_schema != schema:
        hits = find(fallback_schema)
        if hits:
            notes.append("no %s config for schema %s, used the %s config (fallback, like the worker)"
                         % (packet_name, schema, fallback_schema))
    if hits:
        if len(hits) > 1:
            notes.append("%d configs match, using the first (%s)" % (len(hits), describe(hits[0])))
        return hits[0], notes
    if len(configs) == 1:
        notes.append("the config does not match this frame (%s, schema %s, protocol %s); decoding "
                     "with it anyway" % (packet_name, schema, proto))
        return configs[0], notes
    raise SystemExit("error: no config for packetType=%s schemaVersion=%s protocolVersion=%s.\n"
                     "configs in the file:\n  %s" % (packet_name, schema, proto,
                                                    "\n  ".join(describe(c) for c in configs)))


# ---------------------------------------------------------------- frame layer

def parse_frame(frame):
    if len(frame) < 2:
        raise FrameError("PACKET_TOO_SHORT", "need at least 2 bytes, have %d" % len(frame))
    if frame[0] not in SUPPORTED_PROTOCOL_VERSIONS:
        raise FrameError("UNSUPPORTED_PROTOCOL_VERSION", "protocol version %d, supported %s"
                         % (frame[0], list(SUPPORTED_PROTOCOL_VERSIONS)))
    if frame[1] != 1:
        raise FrameError("UNSUPPORTED_HEADER_VERSION", "header version %d, supported 1" % frame[1])
    if len(frame) < HEADER_LEN:
        raise FrameError("PACKET_TOO_SHORT", "have %d bytes, header needs %d" % (len(frame), HEADER_LEN))
    # The payload length is not on the wire: frame = header + payload + CRC(4) + HMAC slot(32).
    payload_len = len(frame) - HEADER_LEN - CRC_LEN - HMAC_LEN if len(frame) >= HEADER_LEN + CRC_LEN + HMAC_LEN else 0
    le = lambda a, n: int.from_bytes(frame[a:a + n], "little")
    return {
        "protocolVersion": frame[0], "headerVersion": frame[1], "schemaVersion": frame[2],
        "packetTypeCode": frame[3], "deviceTypeCode": frame[4], "deviceNumericId": le(5, 4),
        "sequenceNo": le(9, 4), "eventTimestamp": le(13, 4), "firmwareVersion": frame[17],
        "payloadLength": payload_len,
    }


def check_crc(frame, header):
    start = HEADER_LEN + header["payloadLength"]
    if start + CRC_LEN > len(frame):
        return "FAIL", "the frame ends before the 4-byte CRC", None
    wire = int.from_bytes(frame[start:start + CRC_LEN], "little")
    if wire == 0:
        return "SKIPPED", "the device sends a zero CRC (not signing yet)", wire
    actual = zlib.crc32(frame[:start]) & 0xFFFFFFFF
    if wire != actual:
        return "FAIL", "expected %08x, calculated %08x" % (wire, actual), wire
    return "PASS", "CRC32 over header + payload matches", wire


# ---------------------------------------------------------------- field decoding

def go_num(v):
    """Number formatting like Go's %v, used in warning texts."""
    if isinstance(v, float):
        if v == int(v) and abs(v) < 1e21:
            return str(int(v))
        return repr(v)
    return str(v)


def check_bounds(value, spec):
    if not isinstance(value, (int, float)) or isinstance(value, bool):
        return
    if spec.get("min") is not None and value < spec["min"]:
        raise FieldError("OTHER", "value %s below min %s" % (go_num(float(value)), go_num(float(spec["min"]))))
    if spec.get("max") is not None and value > spec["max"]:
        raise FieldError("OTHER", "value %s above max %s" % (go_num(float(value)), go_num(float(spec["max"]))))


def decode_numeric(region, spec, cfg, order):
    kind = spec["type"]
    width, signed = INT_TYPES[kind]
    if len(region) != width:
        raise FieldError("OTHER", "type %s needs %d bytes, the field has length %d" % (kind, width, len(region)))
    raw = int.from_bytes(region, order, signed=signed)
    factor = spec.get("conversionFactor")
    if factor:
        divisor = (cfg.get("unitDivisors") or {}).get(factor)
        if divisor is None:
            raise FieldError("OTHER", "unknown conversionFactor %r (not in unitDivisors)" % factor)
        if divisor == 0:
            raise FieldError("OTHER", "conversionFactor %r has divisor 0" % factor)
        value = raw / divisor
    else:
        value = raw
    if cfg.get("validationMode") != "WARN":
        check_bounds(value, spec)
    return value, raw


def decode_field(payload, spec, cfg, order):
    """Returns (value, raw_integer_or_None)."""
    index, length = spec.get("index", 0), spec.get("length", 0)
    if index < 0 or length < 0:
        raise FieldError("OTHER", "invalid index=%d length=%d" % (index, length))
    end = index + length
    if end > len(payload):
        raise FieldError("OUT_OF_BOUNDS", "bytes %d..%d are outside the payload (%d bytes)"
                         % (index, end - 1, len(payload)))
    region = payload[index:end]
    kind = spec.get("type")

    if spec.get("subLength", 0) > 0 or kind == "array":
        sub = spec.get("subLength", 0)
        if sub <= 0:
            raise FieldError("OTHER", "array needs subLength > 0")
        if len(region) % sub:
            raise FieldError("OTHER", "length %d is not a multiple of subLength %d" % (len(region), sub))
        if kind == "array" or kind not in INT_TYPES:
            raise FieldError("OTHER", "array element type %r must be a fixed-width integer type" % kind)
        if INT_TYPES[kind][0] != sub:
            raise FieldError("OTHER", "subLength %d does not match the %s width %d" % (sub, kind, INT_TYPES[kind][0]))
        elem = dict(spec, length=sub)
        out = [decode_numeric(region[i:i + sub], elem, cfg, order)[0] for i in range(0, len(region), sub)]
        return out, None
    if kind in INT_TYPES:
        return decode_numeric(region, spec, cfg, order)
    if kind == "ascii":
        return region.rstrip(b"\x00").decode("utf-8", "replace"), None
    if kind == "bytes":
        return base64.b64encode(region).decode("ascii"), None  # the pipeline stores bytes as base64
    if kind == "flags":
        if len(region) not in (1, 2, 4, 8):
            raise FieldError("OTHER", "flags length %d must be 1, 2, 4 or 8" % len(region))
        bits = int.from_bytes(region, order)
        flags = FlagMap()
        for name, bit in (spec.get("flags") or {}).items():
            if bit < 0 or bit > 63:
                raise FieldError("OTHER", "flag %r bit %d is outside 0..63" % (name, bit))
            flags[name] = bool((bits >> bit) & 1)
        return flags, bits
    raise FieldError("OTHER", "unsupported field type %r" % kind)


def decode_payload(payload, cfg):
    """Walk the config fields. Returns (decoded_raw, normalized, rows, fatal_errors)."""
    endianness = cfg.get("endianness") or "LE"
    if endianness not in ("LE", "BE"):
        raise SystemExit("error: unsupported endianness %r (use LE or BE)" % endianness)
    order = "little" if endianness == "LE" else "big"
    decoded_raw, normalized, rows, fatal = {}, {}, [], []
    for spec in cfg.get("fields", []):
        row = {"spec": spec, "value": None, "raw": None, "error": None}
        try:
            row["value"], row["raw"] = decode_field(payload, spec, cfg, order)
        except FieldError as err:
            row["error"] = str(err)
            if cfg.get("validationMode") == "WARN" and err.kind == "OUT_OF_BOUNDS":
                row["note"] = "left empty (WARN mode)"
            else:
                fatal.append("field %s: %s" % (spec.get("name"), err))
                row["fatal"] = True
        rows.append(row)
        if row.get("fatal"):
            continue
        decoded_raw[spec["name"]] = row["value"]
        if spec.get("normalizedName"):
            normalized[spec["normalizedName"]] = row["value"]
    return decoded_raw, normalized, rows, fatal


def expand_dot_paths(flat):
    out = {}
    for key, value in flat.items():
        dot = key.find(".")
        if dot < 0:
            out[key] = value
            continue
        prefix, rest = key[:dot], key[dot + 1:]
        if prefix not in out:
            out[prefix] = {}
        nested = out[prefix]
        if not isinstance(nested, dict) or isinstance(nested, FlagMap):
            continue
        nested[rest] = value
    for key, value in list(out.items()):
        if isinstance(value, dict) and not isinstance(value, FlagMap):
            out[key] = expand_dot_paths(value)
    return out


def bounds_warning(value, spec):
    if not isinstance(value, (int, float)) or isinstance(value, bool):
        return ""
    if spec.get("min") is not None and value < spec["min"]:
        return "value %s below min %s" % (go_num(float(value)), go_num(float(spec["min"])))
    if spec.get("max") is not None and value > spec["max"]:
        return "value %s exceeds max %s" % (go_num(float(value)), go_num(float(spec["max"])))
    return ""


def validation_summary(payload, cfg, decoded_raw):
    summary = {"payloadLength": len(payload)}
    if cfg.get("expectedPayloadLength", 0) > 0:
        summary["expectedPayloadLength"] = cfg["expectedPayloadLength"]
    summary["packetLayoutVersion"] = cfg.get("configVersion", "")
    warnings = []
    for spec in cfg.get("fields", []):
        if spec.get("min") is None and spec.get("max") is None:
            continue
        if spec.get("type") in ("ascii", "bytes", "flags"):
            continue
        if spec["name"] not in decoded_raw:
            continue
        value = decoded_raw[spec["name"]]
        if spec.get("subLength", 0) > 0 or spec.get("type") == "array":
            if isinstance(value, list):
                for i, item in enumerate(value):
                    w = bounds_warning(item, spec)
                    if w:
                        warnings.append("%s[%d] %s" % (spec["name"], i, w))
        else:
            w = bounds_warning(value, spec)
            if w:
                warnings.append("%s %s" % (spec["name"], w))
    if warnings:
        summary["warnings"] = warnings
    return summary


def trim_cell_temperatures(cfg, decoded_raw, normalized):
    """realtime / full snapshot: keep only CELL_TEMP_COUNT cell temperatures (max 24)."""
    spec = next((f for f in cfg.get("fields", []) if f.get("name") == "CELL_TEMP_COUNT"), None)
    count = decoded_raw.get("CELL_TEMP_COUNT")
    if (spec is None or spec.get("type") not in ("uint8", "uint16", "uint32", "uint64")
            or spec.get("conversionFactor") or not isinstance(count, int)):
        return
    original, clamped = count, False
    if count > 24:
        count, clamped = 24, True
    batt = normalized.get("battery")
    if not isinstance(batt, dict) or not isinstance(batt.get("cellTemperatures"), list):
        return
    temps = batt["cellTemperatures"]
    batt["cellTemperatures"] = temps[:min(count, len(temps))]
    summary = decoded_raw.get("validationSummary")
    if clamped and isinstance(summary, dict):
        summary.setdefault("warnings", []).append(
            "CELL_TEMP_COUNT %d exceeds max 24, clamped to 24" % original)


# ---------------------------------------------------------------- compare

def is_number(v):
    return isinstance(v, (int, float)) and not isinstance(v, bool)


def short(v):
    text = json.dumps(v, sort_keys=True)
    return text if len(text) <= 80 else text[:77] + "..."


def diff(mine, stored, path, out):
    if isinstance(mine, dict) and isinstance(stored, dict):
        for key in sorted(set(mine) | set(stored)):
            sub = "%s.%s" % (path, key)
            if key not in mine:
                out.append("%s: only in the stored event = %s" % (sub, short(stored[key])))
            elif key not in stored:
                out.append("%s: only in this script's result = %s" % (sub, short(mine[key])))
            else:
                diff(mine[key], stored[key], sub, out)
    elif isinstance(mine, list) and isinstance(stored, list):
        if len(mine) != len(stored):
            out.append("%s: list length %d here, %d stored" % (path, len(mine), len(stored)))
        for i, (a, b) in enumerate(zip(mine, stored)):
            diff(a, b, "%s[%d]" % (path, i), out)
    elif is_number(mine) and is_number(stored):
        if abs(mine - stored) > 1e-9 * max(1.0, abs(stored)):
            out.append("%s: %s here, %s stored" % (path, go_num(mine), go_num(stored)))
    elif mine != stored:
        out.append("%s: %s here, %s stored" % (path, short(mine), short(stored)))


def compare_with(expect_path, result):
    with open(expect_path, "r", encoding="utf-8") as fh:
        stored = json.load(fh)
    if isinstance(stored, dict) and isinstance(stored.get("dataJson"), dict):
        stored = stored["dataJson"]
    problems = []
    for section in ("decodedRaw", "normalized"):
        if section not in stored:
            problems.append("%s: missing in the expected file" % section)
            continue
        diff(result[section], stored[section], section, problems)
    return problems


# ---------------------------------------------------------------- output

def hexs(b, limit=16):
    text = " ".join("%02x" % x for x in b[:limit])
    return text + (" ..." if len(b) > limit else "")


def show(value, limit=10):
    if isinstance(value, list):
        head = ", ".join(go_num(v) if isinstance(v, float) else str(v) for v in value[:limit])
        return "[%s%s]" % (head, ", ... (%d values)" % len(value) if len(value) > limit else "")
    if isinstance(value, float):
        return go_num(value)
    if value is None:
        return "(empty)"
    return json.dumps(value) if isinstance(value, (dict, str)) else str(value)


def range_text(start, length):
    return "%d" % start if length == 1 else "%d-%d" % (start, start + length - 1)


def render(frame, header, packet_name, crc, cfg, notes, rows, payload):
    p = print
    p("FRAME  %d bytes  =  header %d  +  payload %d  +  CRC %d  +  HMAC slot %d"
      % (len(frame), HEADER_LEN, header["payloadLength"], CRC_LEN, HMAC_LEN))
    p("")
    p("HEADER (frame bytes 0-19, numbers are little-endian)")
    p("  %-7s %-12s %-18s %s" % ("byte", "hex", "field", "value"))
    for start, length, name in HEADER_LAYOUT:
        raw = frame[start:start + length]
        value = header.get(name)
        if name == "schemaVersion":
            value = "%d  (config schema v%d)" % (value, value)
        elif name == "packetTypeCode":
            value = "%d  (%s)" % (value, packet_name)
        elif name == "eventTimestamp":
            when = datetime.datetime.fromtimestamp(value, datetime.timezone.utc)
            value = "%d  (%s UTC)" % (value, when.strftime("%Y-%m-%d %H:%M:%S"))
        elif name == "reserved":
            value = "-"
        p("  %-7s %-12s %-18s %s" % (range_text(start, length), hexs(raw), name, value))
    p("")
    p("INTEGRITY")
    p("  CRC32   %s  (%s)" % (crc[0], crc[1]))
    p("  HMAC    NOT CHECKED here (needs the device key; the pipeline has HMAC off)")
    p("")
    p("CONFIG  " + describe(cfg))
    for note in notes:
        p("  note: " + note)
    p("")
    p("PAYLOAD (frame byte = payload byte + %d)" % HEADER_LEN)
    p("  %-9s %-9s %-6s %-26s %-28s %-8s %s" % ("frame", "payload", "len", "bytes", "field", "type", "value"))
    covered = [False] * len(payload)
    for row in rows:
        s = row["spec"]
        index, length = s.get("index", 0), s.get("length", 0)
        for i in range(max(index, 0), min(index + length, len(payload))):
            covered[i] = True
        region = payload[index:index + length] if 0 <= index <= len(payload) else b""
        value = show(row["value"]) if not row["error"] else "ERROR: " + row["error"]
        if row["error"] and row.get("note"):
            value += "  [%s]" % row["note"]
        elif row["raw"] is not None and s.get("conversionFactor"):
            value += "  (raw %s / %s)" % (row["raw"], s["conversionFactor"])
        if s.get("unit") and not row["error"]:
            value += " " + s["unit"]
        p("  %-9s %-9s %-6s %-26s %-28s %-8s %s" % (
            range_text(HEADER_LEN + index, max(length, 1)), range_text(index, max(length, 1)),
            length, hexs(region, 8), s.get("name", "?")[:28], s.get("type", "?"), value))
    gaps, i = [], 0
    while i < len(payload):
        if not covered[i]:
            j = i
            while j < len(payload) and not covered[j]:
                j += 1
            gaps.append((i, j - i))
            i = j
        else:
            i += 1
    if gaps:
        p("")
        p("  payload bytes NOT used by any field in this config:")
        for start, length in gaps:
            p("    payload %s (frame %s): %s" % (range_text(start, length),
                                                 range_text(HEADER_LEN + start, length),
                                                 hexs(payload[start:start + length], 24)))
    start = HEADER_LEN + header["payloadLength"]
    p("")
    p("TRAILER")
    p("  frame %-9s CRC32       %s" % (range_text(start, CRC_LEN), hexs(frame[start:start + CRC_LEN])))
    tail = frame[start + CRC_LEN:start + CRC_LEN + HMAC_LEN]
    p("  frame %-9s HMAC slot   %s" % (range_text(start + CRC_LEN, max(len(tail), 1)), hexs(tail, 12) if tail else "(missing)"))


def main():
    ap = argparse.ArgumentParser(description="Decode one raw IoT frame with a decoder config JSON.")
    ap.add_argument("-c", "--config", required=True, help="decoder config JSON downloaded from the dashboard")
    src = ap.add_mutually_exclusive_group()
    src.add_argument("-x", "--hex", help="the frame as a hex string")
    src.add_argument("-f", "--hex-file", help="a text file holding the hex string, or - for stdin")
    ap.add_argument("--fallback-schema", default="", metavar="vN",
                    help="when no config matches the frame's schema, try this one (production uses v2)")
    ap.add_argument("--json", action="store_true", help="print the decoded result as JSON only")
    ap.add_argument("--expect", metavar="FILE", help="compare with a stored event JSON and list differences")
    ap.add_argument("--list", action="store_true", help="list the configs in the file and exit")
    args = ap.parse_args()

    with open(args.config, "r", encoding="utf-8") as fh:
        configs = collect_configs(json.load(fh))
    if not configs:
        raise SystemExit("error: no decoder config found in %s" % args.config)
    if args.list:
        for cfg in configs:
            print(describe(cfg) + ("  expectedPayloadLength=%s" % cfg["expectedPayloadLength"]
                                   if cfg.get("expectedPayloadLength") else ""))
        return 0

    if args.hex:
        text = args.hex
    elif args.hex_file:
        if args.hex_file == "-":
            text = sys.stdin.read()
        else:
            with open(args.hex_file, "r", encoding="utf-8") as fh:
                text = fh.read()
    else:
        raise SystemExit("error: give the frame with --hex or --hex-file (or use --list)")
    frame = parse_hex(text)

    try:
        header = parse_frame(frame)
    except FrameError as err:
        print("The pipeline would reject this frame: %s (%s)" % (err.reason, err))
        return 1
    packet_name = PACKET_NAMES.get(header["packetTypeCode"], "unknown_%d" % header["packetTypeCode"])
    crc = check_crc(frame, header)
    cfg, notes = pick_config(configs, header, packet_name, args.fallback_schema)
    payload = frame[HEADER_LEN:HEADER_LEN + header["payloadLength"]]

    rejections = []
    if crc[0] == "FAIL":
        rejections.append("CRC_FAILED: " + crc[1])
    expected = cfg.get("expectedPayloadLength", 0)
    if expected > 0 and len(payload) != expected:
        rejections.append("PAYLOAD_LENGTH_MISMATCH: config expects %d payload bytes, the frame has %d"
                          % (expected, len(payload)))

    decoded_raw, normalized, rows, fatal = decode_payload(payload, cfg)
    rejections += ["DECODE_ERROR: " + f for f in fatal]
    if cfg.get("expandDotPaths"):
        normalized = expand_dot_paths(normalized)
    if cfg.get("validationMode") == "WARN":
        decoded_raw["validationSummary"] = validation_summary(payload, cfg, decoded_raw)
    if packet_name in ("realtime_telemetry_bundle", "full_snapshot_bundle"):
        trim_cell_temperatures(cfg, decoded_raw, normalized)

    result = {
        "header": {k: header[k] for k in ("protocolVersion", "headerVersion", "schemaVersion", "packetTypeCode",
                                          "deviceTypeCode", "deviceNumericId", "sequenceNo", "eventTimestamp",
                                          "firmwareVersion", "payloadLength")},
        "packetType": packet_name,
        "integrity": {"crcStatus": crc[0], "hmacStatus": "NOT_CHECKED"},
        "decode": {"configVersion": cfg.get("configVersion"), "decoderStrategy": cfg.get("decoderStrategy")},
        "decodedRaw": decoded_raw,
        "normalized": normalized,
    }

    if args.json:
        print(json.dumps(result, indent=2, sort_keys=True))
    else:
        render(frame, header, packet_name, crc, cfg, notes, rows, payload)
        print("")
        if rejections:
            print("RESULT  the pipeline would NOT store this frame:")
            for r in rejections:
                print("  - " + r)
        else:
            print("RESULT  decoded fine; the pipeline would store this packet")
        print("DECODED RAW")
        print(json.dumps(decoded_raw, indent=2, sort_keys=True))
        print("NORMALIZED")
        print(json.dumps(normalized, indent=2, sort_keys=True))

    status = 1 if rejections else 0
    if args.expect:
        out = sys.stderr if args.json else sys.stdout
        problems = compare_with(args.expect, result)
        print("", file=out)
        if problems:
            print("COMPARE with %s: %d difference(s)" % (args.expect, len(problems)), file=out)
            for line in problems:
                print("  - " + line, file=out)
            status = 1
        else:
            print("COMPARE with %s: MATCH (decodedRaw and normalized are identical)" % args.expect, file=out)
    return status


if __name__ == "__main__":
    sys.exit(main())
