#! python3
# Bioclimatic Explorer: the page's view back into this Rhino 8 model. On the page press Send the view back (site card),
# then run this in Rhino's Script Editor: a coloured mesh and its legend on the layer Bioclimatic results::<view>.
# Generated by tools/build-relay.py from relay/src/; edit there.

# ---- Bioclimatic Explorer link: the common part (relay/src/core.py; built into each script by tools/build-relay.py) ----
# Python 3 only, standard library only: AES-256-GCM written out here (NIST FIPS 197 and SP 800-38D), so nothing has
# to be installed in Rhino's or Dynamo's Python. The model is compressed (gzip), encrypted with the link's key and
# written to the link's room; the server keeps only ciphertext. The page that made the link decrypts it.
import base64, gzip, hashlib, json, os, struct, sys, time

# AES (FIPS 197): the S-box built from the field inverse and the affine map, then four T-tables for the rounds.
def _aes_tables():
    exp, log = [0] * 512, [0] * 256
    x = 1
    for i in range(255):
        exp[i] = x; log[x] = i
        x ^= (x << 1) ^ (0x11B if x & 0x80 else 0)
        x &= 0xFF
    for i in range(255, 512): exp[i] = exp[i - 255]
    sbox = [0] * 256
    for i in range(256):
        inv = 0 if i == 0 else exp[255 - log[i]]
        s = inv
        for k in range(1, 5): s ^= ((inv << k) | (inv >> (8 - k))) & 0xFF
        sbox[i] = s ^ 0x63
    xt = lambda a: ((a << 1) ^ (0x1B if a & 0x80 else 0)) & 0xFF
    T0 = [0] * 256
    for i in range(256):
        s = sbox[i]; s2 = xt(s); s3 = s2 ^ s
        T0[i] = (s2 << 24) | (s << 16) | (s << 8) | s3
    ror = lambda w, n: ((w >> n) | (w << (32 - n))) & 0xFFFFFFFF
    return sbox, T0, [ror(t, 8) for t in T0], [ror(t, 16) for t in T0], [ror(t, 24) for t in T0]
_SBOX, _T0, _T1, _T2, _T3 = _aes_tables()

def _expand(key):
    nk, nr = len(key) // 4, len(key) // 4 + 6
    w = list(struct.unpack(">%dI" % nk, key))
    rcon = 1
    for i in range(nk, 4 * (nr + 1)):
        t = w[i - 1]
        if i % nk == 0:
            t = ((t << 8) | (t >> 24)) & 0xFFFFFFFF
            t = (_SBOX[t >> 24] << 24) | (_SBOX[(t >> 16) & 255] << 16) | (_SBOX[(t >> 8) & 255] << 8) | _SBOX[t & 255]
            t ^= rcon << 24
            rcon = ((rcon << 1) ^ (0x1B if rcon & 0x80 else 0)) & 0xFF
        elif nk > 6 and i % nk == 4:
            t = (_SBOX[t >> 24] << 24) | (_SBOX[(t >> 16) & 255] << 16) | (_SBOX[(t >> 8) & 255] << 8) | _SBOX[t & 255]
        w.append(w[i - nk] ^ t)
    return w, nr

def _block(w, nr, s0, s1, s2, s3):
    T0, T1, T2, T3, S = _T0, _T1, _T2, _T3, _SBOX
    s0 ^= w[0]; s1 ^= w[1]; s2 ^= w[2]; s3 ^= w[3]
    k = 4
    for _ in range(nr - 1):
        t0 = T0[s0 >> 24] ^ T1[(s1 >> 16) & 255] ^ T2[(s2 >> 8) & 255] ^ T3[s3 & 255] ^ w[k]
        t1 = T0[s1 >> 24] ^ T1[(s2 >> 16) & 255] ^ T2[(s3 >> 8) & 255] ^ T3[s0 & 255] ^ w[k + 1]
        t2 = T0[s2 >> 24] ^ T1[(s3 >> 16) & 255] ^ T2[(s0 >> 8) & 255] ^ T3[s1 & 255] ^ w[k + 2]
        t3 = T0[s3 >> 24] ^ T1[(s0 >> 16) & 255] ^ T2[(s1 >> 8) & 255] ^ T3[s2 & 255] ^ w[k + 3]
        s0, s1, s2, s3 = t0, t1, t2, t3
        k += 4
    o0 = ((S[s0 >> 24] << 24) | (S[(s1 >> 16) & 255] << 16) | (S[(s2 >> 8) & 255] << 8) | S[s3 & 255]) ^ w[k]
    o1 = ((S[s1 >> 24] << 24) | (S[(s2 >> 16) & 255] << 16) | (S[(s3 >> 8) & 255] << 8) | S[s0 & 255]) ^ w[k + 1]
    o2 = ((S[s2 >> 24] << 24) | (S[(s3 >> 16) & 255] << 16) | (S[(s0 >> 8) & 255] << 8) | S[s1 & 255]) ^ w[k + 2]
    o3 = ((S[s3 >> 24] << 24) | (S[(s0 >> 16) & 255] << 16) | (S[(s1 >> 8) & 255] << 8) | S[s2 & 255]) ^ w[k + 3]
    return (o0 << 96) | (o1 << 64) | (o2 << 32) | o3

def _enc_int(w, nr, x):
    return _block(w, nr, (x >> 96) & 0xFFFFFFFF, (x >> 64) & 0xFFFFFFFF, (x >> 32) & 0xFFFFFFFF, x & 0xFFFFFFFF)

# GCM (SP 800-38D): the hash key's products tabled byte by byte (GHASH is linear), the counter mode from inc32(J0).
_R = 0xE1 << 120
def _gmul(x, y):
    z, v = 0, y
    for i in range(127, -1, -1):
        if (x >> i) & 1: z ^= v
        v = (v >> 1) ^ _R if v & 1 else v >> 1
    return z

def aes_gcm_encrypt(key, iv, plain):
    """AES-GCM with a 96-bit IV and no additional data: ciphertext followed by the 16-byte tag (WebCrypto's form)."""
    return _gcm(key, iv, plain, False)

# The OpenSSL that the host Python's own ssl module loads (Unreal's, Rhino's, a python.org build), found beside the
# interpreter and called through ctypes: a hundred times faster than the code above. Never macOS's /usr/lib/libcrypto,
# a stub that ends the process when loaded. None where there is none; the code above is then used.
_EVP = []
def _openssl():
    if _EVP: return _EVP[0]
    _EVP.append(None)
    try:
        import ctypes, glob, sys
        try:
            import _ssl
            dirs = [os.path.dirname(_ssl.__file__)]
        except Exception: dirs = []
        for b in {sys.base_prefix, sys.prefix, os.path.dirname(sys.executable)}:
            dirs += [b, os.path.join(b, "lib"), os.path.join(b, "DLLs"), os.path.join(b, "..", "lib")]
        names = []
        for d in dirs:
            for pat in ("libcrypto*.dylib", "libcrypto*.so*", "libcrypto*.dll"):
                names += sorted(glob.glob(os.path.join(d, pat)), reverse=True)
        for n in names:
            if n.startswith("/usr/lib/") or n.startswith("/System/"): continue
            try: L = ctypes.CDLL(n)
            except OSError: continue
            if not hasattr(L, "EVP_aes_256_gcm"): continue
            P = ctypes.c_void_p
            L.EVP_CIPHER_CTX_new.restype = P; L.EVP_aes_256_gcm.restype = P
            L.EVP_DecryptInit_ex.argtypes = [P, P, P, ctypes.c_char_p, ctypes.c_char_p]
            L.EVP_CIPHER_CTX_ctrl.argtypes = [P, ctypes.c_int, ctypes.c_int, P]
            L.EVP_DecryptUpdate.argtypes = [P, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int), ctypes.c_char_p, ctypes.c_int]
            L.EVP_DecryptFinal_ex.argtypes = [P, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)]
            L.EVP_CIPHER_CTX_free.argtypes = [P]
            _EVP[0] = L
            break
    except Exception: pass
    return _EVP[0]

def _gcm_openssl(L, key, iv, data):
    import ctypes
    ct, tag = data[:-16], data[-16:]
    ctx = L.EVP_CIPHER_CTX_new()
    try:
        if L.EVP_DecryptInit_ex(ctx, L.EVP_aes_256_gcm(), None, None, None) != 1: return None
        L.EVP_CIPHER_CTX_ctrl(ctx, 0x9, 12, None)   # EVP_CTRL_GCM_SET_IVLEN
        if L.EVP_DecryptInit_ex(ctx, None, None, key, iv) != 1: return None
        out, n = ctypes.create_string_buffer(len(ct) + 16), ctypes.c_int(0)
        if L.EVP_DecryptUpdate(ctx, out, ctypes.byref(n), ct, len(ct)) != 1: return None
        tb = ctypes.create_string_buffer(tag, 16)
        L.EVP_CIPHER_CTX_ctrl(ctx, 0x11, 16, ctypes.cast(tb, ctypes.c_void_p))   # EVP_CTRL_GCM_SET_TAG
        m = ctypes.c_int(0)
        if L.EVP_DecryptFinal_ex(ctx, ctypes.cast(ctypes.addressof(out) + n.value, ctypes.c_char_p), ctypes.byref(m)) != 1:
            raise ValueError("the message does not open with this link's key")
        return out.raw[:n.value + m.value]
    finally: L.EVP_CIPHER_CTX_free(ctx)

def aes_gcm_decrypt(key, iv, data):
    """The inverse: data is the ciphertext followed by its tag; refused (ValueError) when the tag does not match.
    The cryptography package where the host's Python has it, else the interpreter's OpenSSL, else the code above."""
    try:
        from cryptography.hazmat.primitives.ciphers.aead import AESGCM
        try: return AESGCM(key).decrypt(iv, data, None)
        except Exception: raise ValueError("the message does not open with this link's key")
    except ImportError: pass
    L = _openssl()
    if L is not None and os.environ.get("CC_PURE_AES") != "1":
        r = _gcm_openssl(L, key, iv, data)
        if r is not None: return r
    plain, tag = _gcm(key, iv, data[:-16], True)
    if tag != data[-16:]: raise ValueError("the message does not open with this link's key")
    return plain

def _gcm(key, iv, plain, back):
    w, nr = _expand(key)
    H = _enc_int(w, nr, 0)
    bit = [_gmul(1 << s, H) for s in range(128)]
    tab = []
    for j in range(16):
        t = [0] * 256
        for b in range(256):
            acc = 0
            for k in range(8):
                if b >> k & 1: acc ^= bit[8 * (15 - j) + k]
            t[b] = acc
        tab.append(t)
    def ghash_mul(x):
        z = 0
        for j in range(16): z ^= tab[j][(x >> (8 * (15 - j))) & 255]
        return z
    j0 = (int.from_bytes(iv, "big") << 32) | 1
    n = len(plain)
    parts = []
    hi, lo = j0 & ~0xFFFFFFFF, j0 & 0xFFFFFFFF
    y = 0
    for i in range(0, n, 16):
        lo = (lo + 1) & 0xFFFFFFFF
        ks = _enc_int(w, nr, hi | lo)
        chunk = plain[i:i + 16]
        m = len(chunk)
        if m == 16:
            c = int.from_bytes(chunk, "big") ^ ks
            cb = c.to_bytes(16, "big")
        else:
            cb = (int.from_bytes(chunk, "big") ^ (ks >> (8 * (16 - m)))).to_bytes(m, "big")
            c = int.from_bytes(cb.ljust(16, b"\0"), "big")
        parts.append(cb)
        if back: c = int.from_bytes(chunk.ljust(16, b"\0"), "big")   # decrypting: the hash runs over the ciphertext read
        y = ghash_mul(y ^ c)
    out = b"".join(parts)
    y = ghash_mul(y ^ (n * 8))   # len(A) = 0 in the high half, len(C) in bits in the low half
    tag = (_enc_int(w, nr, j0) ^ y).to_bytes(16, "big")
    return (out, tag) if back else out + tag

def _b64u(b): return base64.urlsafe_b64encode(b).decode().rstrip("=")
def _unb64u(t): return base64.urlsafe_b64decode(t + "=" * (-len(t) % 4))

def parse_code(code):
    """The page's link code: <server>/rooms/<id>#<key>.<token>."""
    code = (code or "").strip()
    try:
        url, frag = code.split("#", 1)
        base, rid = url.rstrip("/").rsplit("/rooms/", 1)
        key, token = frag.split(".", 1)
        k = _unb64u(key)
        if len(k) != 32 or len(rid) != 12: raise ValueError()
        return {"base": base, "id": rid, "key": k, "token": token}
    except Exception:
        raise ValueError("not a link code: copy it from the page (Link a program), it reads <server>/rooms/<id>#<key>.<token>")

def mesh_object(name, verts, tris, layer=None, closed=None, user=None):
    """One object: verts a flat list x, y, z (model units), tris a flat list of vertex indices, three a triangle.
    layer (its full path), closed (a solid) and user (its bc.* user text) tell the page what it is: rooms, windows,
    site surfaces (js/modelfile.js, by layer or bc.type)."""
    o = {"name": name, "v": base64.b64encode(struct.pack("<%df" % len(verts), *verts)).decode(),
         "f": base64.b64encode(struct.pack("<%dI" % len(tris), *tris)).decode()}
    if layer: o["layer"] = layer
    if closed is not None: o["closed"] = bool(closed)
    if user: o["user"] = user
    return o

MAX = 1400000   # the room keeps a document under 1.5 MB of text

def pack(code, objects, app, name="", unit=1.0, up="z", lat=None, lon=None, north=None, unit_name=""):
    """The message, compressed and encrypted: the text to write to the room."""
    c = parse_code(code)
    msg = {"cc": "model", "v": 1, "app": app, "name": name, "unit": unit, "unitName": unit_name, "up": up,
           "lat": lat, "lon": lon, "north": north, "t": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "objects": objects}
    plain = gzip.compress(json.dumps(msg, separators=(",", ":")).encode(), 6)
    iv = os.urandom(12)
    doc = _b64u(iv + aes_gcm_encrypt(c["key"], iv, plain))
    if len(doc) > MAX:
        raise ValueError("the model is too large to send (%.1f MB compressed and encrypted; the limit is 1.4 MB): send fewer objects, or simpler meshes" % (len(doc) / 1e6))
    return c, doc

def _http(method, url, body=None):
    data = json.dumps(body).encode() if body is not None else None
    try:
        import urllib.request
        # a name of its own: the site's host refuses Python's default user agent (403 from Cloudflare, found 2026-11-01)
        req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json", "User-Agent": "BioclimaticExplorer-link/1"})
        try:
            with urllib.request.urlopen(req, timeout=60) as r: return r.status, json.loads(r.read() or b"{}")
        except urllib.error.HTTPError as e: return e.code, json.loads(e.read() or b"{}")
    except Exception as err:
        # Python without certificates (some hosts' embedded Python): .NET's HTTP client, where .NET is there
        if "CERTIFICATE" not in str(err).upper() and "SSL" not in str(err).upper(): raise
        import clr
        clr.AddReference("System.Net.Http")
        from System.Net.Http import HttpClient, StringContent, HttpMethod, HttpRequestMessage
        from System.Text import Encoding
        cl = HttpClient()
        cl.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "BioclimaticExplorer-link/1")
        rq = HttpRequestMessage(HttpMethod(method), url)
        if data is not None: rq.Content = StringContent(data.decode(), Encoding.UTF8, "application/json")
        rs = cl.SendAsync(rq).Result
        return int(rs.StatusCode), json.loads(rs.Content.ReadAsStringAsync().Result or "{}")

def send(code, objects, app, **meta):
    """Packs the model and writes it to the link's room (the version read first, a conflict retried)."""
    c, doc = pack(code, objects, app, **meta)
    room = "%s/rooms/%s" % (c["base"], c["id"])
    for _ in range(4):
        st, o = _http("GET", room + "?since=-2&pv=-2")
        if st == 404: raise ValueError("this link no longer exists: make a new one on the page")
        st, o = _http("POST", room, {"w": c["token"], "base": o.get("v", 0), "doc": doc})
        if st == 200: return {"version": o.get("v"), "bytes": len(doc)}
        if st == 403: raise ValueError("the link code's token is wrong: copy the code again")
        if st != 409: raise ValueError("the server answered %s: %s" % (st, o.get("error", "")))
    raise ValueError("the room kept changing; try again")
def open_doc(key, doc):
    """A room's document back: decrypted and inflated (the bytes)."""
    raw = _unb64u(doc)
    plain = aes_gcm_decrypt(key, raw[:12], raw[12:])
    return gzip.decompress(plain) if plain[:2] == b"\x1f\x8b" else plain

def receive(code, since=-1, pv=-1):
    """One long poll of the link's room (up to 25 s): { v, pv, doc (bytes or None), notes ([objects]) }."""
    c = parse_code(code)
    st, o = _http("GET", "%s/rooms/%s?since=%d&pv=%d" % (c["base"], c["id"], since, pv))
    if st == 404: raise ValueError("this link no longer exists: make a new one on the page")
    if st != 200: raise ValueError("the server answered %s" % st)
    out = {"v": o.get("v", 0), "pv": o.get("pv", 0), "doc": None, "notes": []}
    if o.get("doc"): out["doc"] = open_doc(c["key"], o["doc"])
    for p in o.get("presence") or []:
        try: out["notes"].append(json.loads(open_doc(c["key"], p.get("d", ""))))
        except Exception: pass
    return out
# ---- end of the common part ------------------------------------------------------------------------------------------

def get_results(code):
    """The view the page sent back ({ cc: "results" }): the message, and its mesh as flat lists in the model's units:
    V (x, y, z of four corners a quad), F (two triangles a quad), C (r, g, b a corner)."""
    import array
    r = receive(code)
    if not r["doc"]: raise ValueError("the link holds nothing yet")
    msg = json.loads(r["doc"].decode() if isinstance(r["doc"], bytes) else r["doc"])
    if msg.get("cc") != "results": raise ValueError("the link holds a model, not results: on the page, press Send the view back")
    m = msg["mesh"]
    V = array.array("f"); V.frombytes(base64.b64decode(m["v"]))
    F = array.array("I"); F.frombytes(base64.b64decode(m["f"]))
    C = list(base64.b64decode(m["c"]))
    if sys.byteorder != "little": V.byteswap(); F.byteswap()
    return msg, list(V), list(F), C

# ---- Rhino 8: the page's view back into the model ----------------------------------------------------------------------
import Rhino, scriptcontext as sc, System

def _layer(doc, path):
    i = doc.Layers.FindByFullPath(path, -1)
    if i >= 0: return i
    parent = -1
    for part in path.split("::"):
        full = part if parent < 0 else doc.Layers[parent].FullPath + "::" + part
        j = doc.Layers.FindByFullPath(full, -1)
        if j < 0:
            L = Rhino.DocObjects.Layer(); L.Name = part
            if parent >= 0: L.ParentLayerId = doc.Layers[parent].Id
            j = doc.Layers.Add(L)
        parent = j
    return parent

def run(code=None):
    doc = sc.doc
    if not code:
        saved = doc.Strings.GetValue("cc.link") or ""
        ok, code = Rhino.UI.Dialogs.ShowEditBox("Bioclimatic Explorer", "The link code from the page (the same as for sending the model):", saved, False)
        if not ok or not code: return
    doc.Strings.SetString("cc.link", code.strip())
    msg, V, F, C = get_results(code)
    title = (msg.get("title") or msg.get("kind") or "view").replace("::", " ").strip()
    path = "Bioclimatic results::" + title
    li = _layer(doc, path)
    for o in list(doc.Objects.FindByLayer(doc.Layers[li])): doc.Objects.Delete(o, True)
    mesh = Rhino.Geometry.Mesh()
    for i in range(0, len(V), 3): mesh.Vertices.Add(V[i], V[i + 1], V[i + 2])
    for i in range(0, len(F), 6): mesh.Faces.AddFace(F[i], F[i + 1], F[i + 2], F[i + 5])
    for i in range(0, len(C), 3): mesh.VertexColors.Add(System.Drawing.Color.FromArgb(C[i], C[i + 1], C[i + 2]))
    mesh.Normals.ComputeNormals(); mesh.Compact()
    a = Rhino.DocObjects.ObjectAttributes(); a.LayerIndex = li; a.Name = title
    doc.Objects.AddMesh(mesh, a)
    # the legend: a dot per band beside the mesh, in its colour
    bb = mesh.GetBoundingBox(True); x = bb.Max.X + 0.05 * (bb.Max.X - bb.Min.X + 1); step = (bb.Max.Z - bb.Min.Z + 1) / max(1, len(msg.get("legend") or []))
    for k, s in enumerate(reversed(msg.get("legend") or [])):
        d = Rhino.Geometry.TextDot(s["label"], Rhino.Geometry.Point3d(x, bb.Min.Y, bb.Min.Z + k * step))
        da = Rhino.DocObjects.ObjectAttributes(); da.LayerIndex = li; da.ColorSource = Rhino.DocObjects.ObjectColorSource.ColorFromObject
        da.ObjectColor = System.Drawing.Color.FromArgb(*s["rgb"]); doc.Objects.AddTextDot(d, da)
    doc.Views.Redraw()
    Rhino.RhinoApp.WriteLine("Bioclimatic Explorer: %s, %d pieces, on the layer %s" % (title, len(F) // 6, path))
    return {"title": title, "quads": len(F) // 6, "layer": path}

if globals().get("CC_LINK_CODE"):
    LAST = run(CC_LINK_CODE)
else:
    try:
        run()
    except ValueError as err:
        Rhino.UI.Dialogs.ShowMessage(str(err), "Bioclimatic Explorer")
