# Re:Form Re:Link receiver for Blender.
#
# Install: Edit > Preferences > Add-ons > Install..., pick this file, and tick "Re:Form Re:Link receiver".
# While it is ticked, Blender listens on this computer only (the loopback address, port 47800). In Re:Form, File > Re:Link sends
# the active layer; it arrives in the open scene as an object named after the layer. Sending the same layer again replaces that
# object and keeps where you put it: its place, turn and size, and its collection.
#
# Nothing here reaches the internet, and only pages of Re:Form are answered.
#
# The conversation (version 1):
#   GET  /reform-relink/status                        -> {"reformRelink": 1, "scene": "...", "formats": ["glb", "obj"]}
#   POST /reform-relink/model?name=...&format=...     -> {"reformRelink": 1, "received": true, "name": "...", "note": "..."}

bl_info = {
    "name": "Re:Form Re:Link receiver",
    "author": "Frame Sixty Labs",
    "version": (1, 0, 0),
    "blender": (3, 6, 0),
    "location": "Runs while enabled. Send from Re:Form with File > Re:Link",
    "description": "Receives models sent from Re:Form and puts them in the open scene",
    "category": "Import-Export",
}

import json
import os
import queue
import re
import tempfile
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import bpy

RELINK_PROTOCOL_VERSION = 1
RELINK_PORT = 47800
RELINK_FORMATS = ["glb", "obj"]  # (Blender does not read the text kind of FBX that Re:Form writes)
RELINK_NAME_PROPERTY = "reform_relink_name"
RELINK_LARGEST_MODEL_BYTES = 2 * 1024 * 1024 * 1024
RELINK_SECONDS_TO_WAIT_FOR_IMPORT = 300.0
ALLOWED_ORIGIN_PATTERN = re.compile(r"^(https://(www\.)?reformsculpt\.com|http://(localhost|127\.0\.0\.1)(:\d+)?)$")

waiting_models = queue.Queue()
receiver_state = {"server": None, "thread": None, "problem": ""}


def origin_is_allowed(origin):
    return (not origin) or bool(ALLOWED_ORIGIN_PATTERN.match(origin))


def safe_model_name(name):
    cleaned = re.sub(r"[^A-Za-z0-9 _-]", "_", name or "").strip()[:48].strip()
    return cleaned if cleaned and not re.match(r"^[_ -]+$", cleaned) else "Sculpt"


class RelinkRequestHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, format_text, *arguments):
        pass  # nothing is written to Blender's console for each ask

    def send_answer(self, status_code, body):
        payload = json.dumps(body).encode("utf-8")
        self.send_response(status_code)
        origin = self.headers.get("Origin", "")
        if origin:
            self.send_header("Access-Control-Allow-Origin", origin)
            self.send_header("Vary", "Origin")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.send_header("Access-Control-Allow-Private-Network", "true")
        self.send_header("Access-Control-Max-Age", "600")
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def refuse_other_pages(self):
        if origin_is_allowed(self.headers.get("Origin", "")):
            return False
        self.send_response(403)
        self.send_header("Content-Length", "0")
        self.end_headers()
        return True

    def do_OPTIONS(self):
        if self.refuse_other_pages():
            return
        self.send_answer(200, {"reformRelink": RELINK_PROTOCOL_VERSION})

    def do_GET(self):
        if self.refuse_other_pages():
            return
        address = urllib.parse.urlparse(self.path)
        if address.path != "/reform-relink/status":
            self.send_answer(404, {"reformRelink": RELINK_PROTOCOL_VERSION, "received": False, "note": "no such path"})
            return
        scene_name = os.path.basename(bpy.data.filepath) if bpy.data.filepath else "Untitled"
        self.send_answer(200, {"reformRelink": RELINK_PROTOCOL_VERSION, "scene": scene_name, "formats": RELINK_FORMATS})

    def do_POST(self):
        if self.refuse_other_pages():
            return
        address = urllib.parse.urlparse(self.path)
        if address.path != "/reform-relink/model":
            self.send_answer(404, {"reformRelink": RELINK_PROTOCOL_VERSION, "received": False, "note": "no such path"})
            return
        query = urllib.parse.parse_qs(address.query)
        model_name = safe_model_name(query.get("name", [""])[0])
        format_name = query.get("format", [""])[0].lower()
        byte_count = int(self.headers.get("Content-Length", "0") or "0")
        if format_name not in RELINK_FORMATS or byte_count <= 0 or byte_count > RELINK_LARGEST_MODEL_BYTES:
            self.send_answer(200, {"reformRelink": RELINK_PROTOCOL_VERSION, "received": False, "name": model_name, "note": "this receiver takes GLB or OBJ files"})
            return
        file_bytes = self.rfile.read(byte_count)
        waiting = {"name": model_name, "format": format_name, "bytes": file_bytes, "done": threading.Event(), "note": "", "received": False}
        waiting_models.put(waiting)
        # the import runs on Blender's own thread (the timer below); this thread waits for it so the answer is the truth
        if not waiting["done"].wait(RELINK_SECONDS_TO_WAIT_FOR_IMPORT):
            self.send_answer(200, {"reformRelink": RELINK_PROTOCOL_VERSION, "received": False, "name": model_name, "note": "Blender was busy and did not take the model in time"})
            return
        self.send_answer(200, {"reformRelink": RELINK_PROTOCOL_VERSION, "received": waiting["received"], "name": model_name, "note": waiting["note"]})


def import_model_file(file_path, format_name):
    if format_name == "glb":
        bpy.ops.import_scene.gltf(filepath=file_path)
    elif hasattr(bpy.ops.wm, "obj_import"):
        bpy.ops.wm.obj_import(filepath=file_path)
    else:
        bpy.ops.import_scene.obj(filepath=file_path)


def place_model(waiting):
    """Imports one waiting model into the open scene. Runs on Blender's own thread."""
    model_name = waiting["name"]
    temporary_directory = tempfile.mkdtemp(prefix="reform_relink_")
    file_path = os.path.join(temporary_directory, model_name + "." + waiting["format"])
    with open(file_path, "wb") as model_file:
        model_file.write(waiting["bytes"])
    objects_before = set(bpy.data.objects)
    earlier_objects = [scene_object for scene_object in bpy.data.objects if scene_object.get(RELINK_NAME_PROPERTY) == model_name]
    try:
        import_model_file(file_path, waiting["format"])
    finally:
        try:
            os.remove(file_path)
            os.rmdir(temporary_directory)
        except OSError:
            pass
    arrived_objects = [scene_object for scene_object in bpy.data.objects if scene_object not in objects_before]
    arrived_meshes = [scene_object for scene_object in arrived_objects if scene_object.type == "MESH"]
    if not arrived_meshes:
        for scene_object in arrived_objects:
            bpy.data.objects.remove(scene_object, do_unlink=True)
        raise RuntimeError("the file held no mesh")
    # one object per model: the mesh; anything else the importer made beside it (an empty parent) is taken away again
    model_object = arrived_meshes[0]
    world_matrix = model_object.matrix_world.copy()
    for scene_object in arrived_objects:
        if scene_object is not model_object:
            if scene_object.type == "MESH":
                continue
            bpy.data.objects.remove(scene_object, do_unlink=True)
    model_object.parent = None
    model_object.matrix_world = world_matrix
    replaced = False
    if earlier_objects:
        earlier = earlier_objects[0]
        # the new one stands where the person put the one before: its place, turn and size, its parent and its collections
        model_object.parent = earlier.parent
        model_object.matrix_world = earlier.matrix_world.copy()
        earlier_collections = list(earlier.users_collection)
        if earlier_collections:
            for collection in list(model_object.users_collection):
                collection.objects.unlink(model_object)
            for collection in earlier_collections:
                collection.objects.link(model_object)
        if len(earlier.material_slots) and not len(model_object.material_slots):
            for slot in earlier.material_slots:
                model_object.data.materials.append(slot.material)
        for earlier_object in earlier_objects:
            earlier_mesh = earlier_object.data if earlier_object.type == "MESH" else None
            bpy.data.objects.remove(earlier_object, do_unlink=True)
            if earlier_mesh is not None and not any(scene_object.data is earlier_mesh for scene_object in bpy.data.objects):
                bpy.data.meshes.remove(earlier_mesh)
        replaced = True
    model_object.name = model_name
    model_object.data.name = model_name
    model_object[RELINK_NAME_PROPERTY] = model_name
    triangle_count = sum(len(polygon.vertices) - 2 for polygon in model_object.data.polygons)
    return ("Replaced the one sent before" if replaced else "Added to the scene") + " (" + format(triangle_count, ",") + " triangles)."


def take_waiting_models():
    """Blender's timer: imports whatever has arrived since the last look. Returns the seconds until the next look."""
    while True:
        try:
            waiting = waiting_models.get_nowait()
        except queue.Empty:
            break
        try:
            waiting["note"] = place_model(waiting)
            waiting["received"] = True
        except Exception as import_error:  # the reason goes back to Re:Form in the person's words
            waiting["note"] = "Blender could not import the file: " + " ".join(str(import_error).split())[:160]
            waiting["received"] = False
        waiting["done"].set()
    return 0.25


def start_receiver():
    if receiver_state["server"] is not None:
        return
    try:
        server = ThreadingHTTPServer(("127.0.0.1", RELINK_PORT), RelinkRequestHandler)
    except OSError as listen_error:
        receiver_state["problem"] = "Port " + str(RELINK_PORT) + " is in use (another Re:Link receiver may be running): " + str(listen_error)
        print("Re:Link receiver: " + receiver_state["problem"])
        return
    server.daemon_threads = True
    thread = threading.Thread(target=server.serve_forever, name="Re:Link receiver", daemon=True)
    thread.start()
    receiver_state["server"] = server
    receiver_state["thread"] = thread
    receiver_state["problem"] = ""


def stop_receiver():
    server = receiver_state["server"]
    if server is not None:
        server.shutdown()
        server.server_close()
    receiver_state["server"] = None
    receiver_state["thread"] = None


class RelinkReceiverPreferences(bpy.types.AddonPreferences):
    bl_idname = __name__

    def draw(self, context):
        layout = self.layout
        if receiver_state["server"] is not None:
            layout.label(text="Listening on this computer only, port " + str(RELINK_PORT) + ". In Re:Form: File > Re:Link.")
        else:
            layout.label(text=receiver_state["problem"] or "Not listening.", icon="ERROR")


def register():
    bpy.utils.register_class(RelinkReceiverPreferences)
    start_receiver()
    if not bpy.app.timers.is_registered(take_waiting_models):
        bpy.app.timers.register(take_waiting_models, first_interval=0.25, persistent=True)


def unregister():
    if bpy.app.timers.is_registered(take_waiting_models):
        bpy.app.timers.unregister(take_waiting_models)
    stop_receiver()
    bpy.utils.unregister_class(RelinkReceiverPreferences)


if __name__ == "__main__":
    register()
