# Bioclimatic Explorer: send this SketchUp model to the page. Nothing is installed: in SketchUp open Window > Ruby
# Console and type   load "/path/to/sketchup.rb"   (or paste this file's text). It asks for the link code shown on the
# page (site card, Link a program) and sends the selection, or the whole model when nothing is selected: each top-level
# group or component as one object, loose faces as one more. The model is compressed and encrypted with the link's key
# (AES-GCM through Ruby's OpenSSL) before it leaves SketchUp; the server keeps only ciphertext.
# Units: SketchUp's inches. Place: Model Info > Geo-location (shadow_info's latitude and longitude); north from its
# north angle, taken as clockwise from the model's +y (from memory, unchecked: the page lets north be set by hand).
require "json"
require "base64"
require "zlib"
require "stringio"
require "openssl"
require "net/http"
require "uri"

module CCLink
  MAX = 1_400_000

  def self.parse_code(code)
    m = /\A\s*(https?:\/\/\S+?)\/rooms\/([a-z0-9]{12})#([A-Za-z0-9_-]{43})\.([A-Za-z0-9_-]+)\s*\z/.match(code.to_s)
    raise ArgumentError, "not a link code: copy it from the page (Link a program); it reads <server>/rooms/<id>#<key>.<token>" unless m
    key = Base64.urlsafe_decode64(m[3] + "=" * ((4 - m[3].length % 4) % 4))
    { base: m[1], id: m[2], key: key, token: m[4] }
  end

  def self.mesh_object(name, verts, tris)
    { "name" => name, "v" => Base64.strict_encode64(verts.pack("e*")), "f" => Base64.strict_encode64(tris.pack("V*")) }
  end

  def self.gzip(text)
    io = StringIO.new("".b)
    gz = Zlib::GzipWriter.new(io, 6); gz.write(text); gz.close
    io.string
  end

  # AES-256-GCM written out (FIPS 197, SP 800-38D), as relay/src/core.py does in Python: used where OpenSSL's GCM fails
  # (Apple's system Ruby, built against LibreSSL, refuses it). The result is the ciphertext followed by the tag.
  module GCM
    def self.tables
      return @t if @t
      exp, log = Array.new(512, 0), Array.new(256, 0)
      x = 1
      255.times { |i| exp[i] = x; log[x] = i; x ^= (x << 1) ^ (x & 0x80 != 0 ? 0x11B : 0); x &= 0xFF }
      (255...512).each { |i| exp[i] = exp[i - 255] }
      sbox = (0...256).map do |i|
        inv = i.zero? ? 0 : exp[255 - log[i]]
        s = inv
        (1..4).each { |k| s ^= ((inv << k) | (inv >> (8 - k))) & 0xFF }
        s ^ 0x63
      end
      xt = ->(a) { ((a << 1) ^ (a & 0x80 != 0 ? 0x1B : 0)) & 0xFF }
      t0 = sbox.map { |s| s2 = xt.(s); (s2 << 24) | (s << 16) | (s << 8) | (s2 ^ s) }
      ror = ->(w, n) { ((w >> n) | (w << (32 - n))) & 0xFFFFFFFF }
      @t = [sbox, t0, t0.map { |w| ror.(w, 8) }, t0.map { |w| ror.(w, 16) }, t0.map { |w| ror.(w, 24) }]
    end
    def self.expand(key)
      s = tables[0]
      w = key.unpack("N8")
      rcon = 1
      sub = ->(t) { (s[t >> 24] << 24) | (s[(t >> 16) & 255] << 16) | (s[(t >> 8) & 255] << 8) | s[t & 255] }
      (8...60).each do |i|
        t = w[i - 1]
        if i % 8 == 0
          t = sub.(((t << 8) | (t >> 24)) & 0xFFFFFFFF) ^ (rcon << 24)
          rcon = ((rcon << 1) ^ (rcon & 0x80 != 0 ? 0x1B : 0)) & 0xFF
        elsif i % 8 == 4
          t = sub.(t)
        end
        w << (w[i - 8] ^ t)
      end
      w
    end
    def self.block(w, x)
      s, t0, t1, t2, t3 = tables
      a = ((x >> 96) & 0xFFFFFFFF) ^ w[0]; b = ((x >> 64) & 0xFFFFFFFF) ^ w[1]; c = ((x >> 32) & 0xFFFFFFFF) ^ w[2]; d = (x & 0xFFFFFFFF) ^ w[3]
      k = 4
      13.times do
        a, b, c, d = t0[a >> 24] ^ t1[(b >> 16) & 255] ^ t2[(c >> 8) & 255] ^ t3[d & 255] ^ w[k],
                     t0[b >> 24] ^ t1[(c >> 16) & 255] ^ t2[(d >> 8) & 255] ^ t3[a & 255] ^ w[k + 1],
                     t0[c >> 24] ^ t1[(d >> 16) & 255] ^ t2[(a >> 8) & 255] ^ t3[b & 255] ^ w[k + 2],
                     t0[d >> 24] ^ t1[(a >> 16) & 255] ^ t2[(b >> 8) & 255] ^ t3[c & 255] ^ w[k + 3]
        k += 4
      end
      o = [[a, b, c, d], [b, c, d, a], [c, d, a, b], [d, a, b, c]].each_with_index.map { |(p, q, r, u), j| ((s[p >> 24] << 24) | (s[(q >> 16) & 255] << 16) | (s[(r >> 8) & 255] << 8) | s[u & 255]) ^ w[k + j] }
      (o[0] << 96) | (o[1] << 64) | (o[2] << 32) | o[3]
    end
    def self.encrypt(key, iv, plain)
      w = expand(key)
      h = block(w, 0)
      r = 0xE1 << 120
      gmul = lambda do |x, y|
        z, v = 0, y
        127.downto(0) { |i| z ^= v if (x >> i) & 1 == 1; v = v & 1 == 1 ? (v >> 1) ^ r : v >> 1 }
        z
      end
      bit = (0...128).map { |s| gmul.(1 << s, h) }
      tab = (0...16).map { |j| (0...256).map { |b| acc = 0; 8.times { |k| acc ^= bit[8 * (15 - j) + k] if (b >> k) & 1 == 1 }; acc } }
      gh = ->(x) { z = 0; 16.times { |j| z ^= tab[j][(x >> (8 * (15 - j))) & 255] }; z }
      toi = ->(s) { s.unpack1("H*").to_i(16) }
      j0 = (toi.(iv) << 32) | 1
      hi, lo = j0 & ~0xFFFFFFFF, j0 & 0xFFFFFFFF
      out = "".b
      y = 0
      plain = plain.b
      (0...plain.bytesize).step(16) do |i|
        lo = (lo + 1) & 0xFFFFFFFF
        ks = block(w, hi | lo)
        chunk = plain.byteslice(i, 16)
        m = chunk.bytesize
        c = toi.(chunk) ^ (ks >> (8 * (16 - m)))
        cb = [c.to_s(16).rjust(2 * m, "0")].pack("H*")
        out << cb
        y = gh.(y ^ (c << (8 * (16 - m))))
      end
      y = gh.(y ^ (plain.bytesize * 8))
      tag = block(w, j0) ^ y
      out + [tag.to_s(16).rjust(32, "0")].pack("H*")
    end
  end

  def self.encrypt(key, iv, plain)
    cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
    cipher.key = key
    cipher.iv = iv
    ct = cipher.update(plain) + cipher.final
    ct + cipher.auth_tag(16)
  rescue OpenSSL::Cipher::CipherError
    GCM.encrypt(key, iv, plain)
  end

  # The message compressed and encrypted: base64url of the IV, the ciphertext and the 16-byte tag (WebCrypto's form).
  def self.pack(code, objects, meta)
    c = parse_code(code)
    msg = { "cc" => "model", "v" => 1, "objects" => objects, "t" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") }.merge(meta)
    iv = OpenSSL::Random.random_bytes(12)
    doc = Base64.urlsafe_encode64(iv + encrypt(c[:key], iv, gzip(JSON.generate(msg)))).delete("=")
    raise ArgumentError, format("the model is too large to send (%.1f MB; the limit is 1.4 MB): send less, or simpler geometry", doc.length / 1e6) if doc.length > MAX
    [c, doc]
  end

  def self.http(method, url, body = nil)
    uri = URI(url)
    h = Net::HTTP.new(uri.host, uri.port)
    h.use_ssl = uri.scheme == "https"
    h.read_timeout = 60
    req = method == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
    req.body = JSON.generate(body) if body
    r = h.request(req)
    [r.code.to_i, (JSON.parse(r.body) rescue {})]
  end

  def self.send_model(code, objects, meta)
    c, doc = pack(code, objects, meta)
    room = "#{c[:base]}/rooms/#{c[:id]}"
    4.times do
      st, o = http(:get, "#{room}?since=-2&pv=-2")
      raise ArgumentError, "this link no longer exists: make a new one on the page" if st == 404
      st, o2 = http(:post, room, { "w" => c[:token], "base" => o["v"] || 0, "doc" => doc })
      return { "version" => o2["v"], "bytes" => doc.length } if st == 200
      raise ArgumentError, "the link code's token is wrong: copy the code again" if st == 403
      raise ArgumentError, "the server answered #{st}: #{o2['error']}" unless st == 409
    end
    raise ArgumentError, "the room kept changing; try again"
  end

  # ---- SketchUp ----------------------------------------------------------------------------------------------------
  def self.faces(ents, tr, verts, tris)
    ents.each do |e|
      if e.is_a?(Sketchup::Face)
        m = e.mesh(0)
        o = verts.length / 3
        (1..m.count_points).each { |i| p = tr * m.point_at(i); verts.push(p.x.to_f, p.y.to_f, p.z.to_f) }
        m.polygons.each { |poly| idx = poly.map { |i| i.abs - 1 + o }; (1...idx.length - 1).each { |k| tris.push(idx[0], idx[k], idx[k + 1]) } }
      elsif e.is_a?(Sketchup::Group) || e.is_a?(Sketchup::ComponentInstance)
        next unless e.visible? && e.layer.visible?
        faces(e.definition.entities, tr * e.transformation, verts, tris)
      end
    end
  end

  def self.run
    model = Sketchup.active_model
    saved = model.get_attribute("cc", "link", "")
    input = UI.inputbox(["Link code from the page"], [saved], "Bioclimatic Explorer")
    return unless input && !input[0].to_s.strip.empty?
    code = input[0].strip
    parse_code(code)
    model.set_attribute("cc", "link", code)
    ents = model.selection.empty? ? model.entities.to_a : model.selection.to_a
    objects, loose = [], []
    ents.each do |e|
      if e.is_a?(Sketchup::Group) || e.is_a?(Sketchup::ComponentInstance)
        next unless e.visible? && e.layer.visible?
        v, t = [], []
        faces([e], Geom::Transformation.new, v, t)
        name = e.name.to_s.empty? ? (e.is_a?(Sketchup::ComponentInstance) ? e.definition.name : "Group") : e.name
        objects << mesh_object(name, v, t) unless t.empty?
      elsif e.is_a?(Sketchup::Face)
        loose << e
      end
    end
    unless loose.empty?
      v, t = [], []
      faces(loose, Geom::Transformation.new, v, t)
      objects << mesh_object("Loose geometry", v, t) unless t.empty?
    end
    return UI.messagebox("Nothing to send: no faces in the model or the selection.") if objects.empty?
    si = model.shadow_info
    lat, lon = si["Latitude"], si["Longitude"]
    lat = lon = nil unless model.georeferenced? || (lat.to_f != 0 || lon.to_f != 0)
    na = si["NorthAngle"].to_f
    meta = { "app" => "SketchUp", "name" => model.title.to_s, "unit" => 0.0254, "unitName" => "inches", "up" => "z",
             "lat" => lat, "lon" => lon, "north" => (na.zero? ? nil : (360 - na) % 360) }
    r = send_model(code, objects, meta)
    puts "Bioclimatic Explorer: sent #{objects.length} objects (#{r['bytes'] / 1000} kB); the page shows them on the site card."
  rescue ArgumentError => err
    UI.messagebox(err.message)
  end
end

CCLink.run if defined?(Sketchup)
