#!/usr/bin/env python3
"""JSON silently mangles integers above 2^53, DDIA 2e Ch. 5.

JSON "distinguishes strings and numbers, but doesn't distinguish integers and
floating-point numbers, and doesn't specify a precision." So a JSON parser is
free to read numbers as IEEE-754 doubles -- and JavaScript's JSON.parse does
exactly that. Any integer above 2^53 then loses its low bits on the way in. A
64-bit ID (an X/Twitter snowflake) round-tripped through such a parser comes
back CHANGED. A typed encoding (Protobuf int64) round-trips it exactly.

Run it:
    uv run --with grpcio-tools --with protobuf python3 json_integer_precision.py
(Deterministic -> reproduces exactly.)
"""
import json
import os
import sys
import tempfile

BIG_ID = 1234567890123456789   # a 64-bit ID, e.g. a tweet snowflake; > 2^53


def protobuf_roundtrip(value):
    work = tempfile.mkdtemp()
    proto = os.path.join(work, "idmsg.proto")
    with open(proto, "w") as f:
        f.write('syntax = "proto3";\nmessage IdMsg { int64 id = 1; }\n')
    from grpc_tools import protoc
    protoc.main(["protoc", f"-I{work}", f"--python_out={work}", proto])
    sys.path.insert(0, work)
    import idmsg_pb2
    m = idmsg_pb2.IdMsg(id=value)
    m2 = idmsg_pb2.IdMsg()
    m2.ParseFromString(m.SerializeToString())
    return m2.id


def main():
    boundary = 2 ** 53
    print(f"a 64-bit ID: {BIG_ID}   (> 2^53 = {boundary})\n")

    print("the IEEE-754 double boundary (a double has 53 bits of integer precision):")
    print(f"    2^53     = {boundary}   -> as a double -> {int(float(boundary))}   (exact)")
    print(f"    2^53 + 1 = {boundary + 1}   -> as a double -> {int(float(boundary + 1))}   "
          f"(off by {boundary + 1 - int(float(boundary + 1))})\n")

    payload = json.dumps({"id": BIG_ID})
    # A parser that reads JSON numbers as doubles -- which JavaScript's JSON.parse
    # does, and which JSON permits. We mimic it with parse_int=float.
    got = int(json.loads(payload, parse_int=float)["id"])
    print("round-tripping the ID through JSON:")
    print(f"    JSON text sent:  {payload}")
    print(f"    parsed as doubles (JavaScript, or json.loads parse_int=float):")
    print(f"        got:    {got}")
    print(f"        off by: {BIG_ID - got}    <- silently wrong; the ID no longer matches\n")
    strict = json.loads(payload)["id"]
    print(f"    (Python's DEFAULT json happens to keep it: {strict == BIG_ID} -- but JSON")
    print(f"     doesn't guarantee this, and JavaScript's JSON.parse does not.)\n")

    decoded = protobuf_roundtrip(BIG_ID)
    print("the typed fix -- Protobuf int64 round-trips exactly:")
    print(f"    sent {BIG_ID} -> decoded {decoded}   (exact: {decoded == BIG_ID})")
    print("\nwhich is why X/Twitter's API returns the ID twice: id (number) and id_str (string).")


if __name__ == "__main__":
    main()
