Editor/BlenderBridge/BlenderBridgeDispatcher.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Editor;
using Editor.MeshEditor;
using HalfEdgeMesh;
using Sandbox;

namespace BlenderBridge
{
	/// <summary>
	/// Handles incoming bridge messages from Blender v2 and applies them to the s&amp;box scene.
	/// Sequence-based echo prevention, idempotent creates, light support, chunked mesh, hierarchy grouping.
	/// </summary>
	internal static class BlenderBridgeDispatcher
	{
		// ── Sequence state (echo prevention) ──────────────────────────────────

		/// <summary>Highest Blender seq we have processed.</summary>
		private static int _lastBlenderSeqProcessed = 0;

		/// <summary>bridgeId -> Blender seq that caused the last write. Used to suppress echo in PollForChanges.</summary>
		private static Dictionary<string, int> _lastWriteSeq = new();

		// ── Object caches ─────────────────────────────────────────────────────

		/// <summary>O(1) lookup cache: bridgeId -> GameObject. Rebuilt on cache miss via tree walk.</summary>
		private static Dictionary<string, GameObject> _bridgeObjectCache = new();

		/// <summary>Idempotency keys: key -> bridgeId. Prevents duplicate creation on network retry.</summary>
		private static Dictionary<string, string> _idempotencyKeys = new();

		/// <summary>Last-known transforms for change detection.</summary>
		private static Dictionary<string, (Vector3 pos, Rotation rot)> _lastKnown = new();

		/// <summary>Last-known mesh hash for geometry change detection.</summary>
		private static Dictionary<string, int> _lastMeshHash = new();

		/// <summary>Last-known light property hash for change detection.</summary>
		private static Dictionary<string, int> _lastLightHash = new();

		// ── Chunked mesh accumulator ──────────────────────────────────────────

		private static Dictionary<string, MeshAccumulator> _pendingChunks = new();

		private struct MeshAccumulator
		{
			public List<float> Vertices;
			public int TotalVertices;
			public int ChunksReceived;
			public int ChunkCount;
			public DateTime StartTime;
		}

		// ── Play mode ─────────────────────────────────────────────────────────

		private static bool _wasPlaying = false;

		/// <summary>Reset all state. Called on server start and hot reload.</summary>
		internal static void ResetState()
		{
			_lastBlenderSeqProcessed = 0;
			_lastWriteSeq.Clear();
			_bridgeObjectCache.Clear();
			_idempotencyKeys.Clear();
			_lastKnown.Clear();
			_lastMeshHash.Clear();
			_lastLightHash.Clear();
			_pendingChunks.Clear();
			_wasPlaying = false;
		}

		// ── Dispatch ──────────────────────────────────────────────────────────

		/// <summary>Handle an incoming message. Returns a JSON response string. Must be called on main thread.</summary>
		internal static string Dispatch( JsonElement root )
		{
			var type = root.TryGetProperty( "type", out var t ) ? t.GetString() : null;

			// Extract and update sequence tracking
			if ( root.TryGetProperty( "seq", out var seqEl ) && seqEl.ValueKind == JsonValueKind.Number )
			{
				var seq = seqEl.GetInt32();
				if ( seq > _lastBlenderSeqProcessed )
					_lastBlenderSeqProcessed = seq;
			}

			try
			{
				return type switch
				{
					"create" => HandleCreate( root ),
					"update_transform" => HandleUpdateTransform( root ),
					"update_mesh" => HandleUpdateMesh( root ),
					"delete" => HandleDelete( root ),
					"sync" => HandleSync( root ),
					"update_scene_transform" => HandleUpdateSceneTransform( root ),
					"create_light" => HandleCreateLight( root ),
					"update_light" => HandleUpdateLight( root ),
					"mesh_begin" => HandleMeshBegin( root ),
					"mesh_chunk" => HandleMeshChunk( root ),
					"mesh_end" => HandleMeshEnd( root ),
					_ => "{\"ok\":true}"
				};
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogError( $"Dispatch error ({type}): {ex.Message}" );
				return $"{{\"error\":\"{ex.Message}\"}}";
			}
		}

		// ── create ────────────────────────────────────────────────────────────

		private static string HandleCreate( JsonElement root )
		{
			var name = GetString( root, "name", "Blender Object" );
			var blenderSeq = GetInt( root, "seq", 0 );

			// Idempotency check: if this key was already used, return existing bridgeId
			var idemKey = GetString( root, "idempotencyKey" );
			if ( !string.IsNullOrEmpty( idemKey ) && _idempotencyKeys.TryGetValue( idemKey, out var existingId ) )
			{
				// Verify the object still exists
				var existingGo = FindByBridgeTag( existingId );
				if ( existingGo != null )
				{
					if ( BridgeLockPolicy.AllowsInbound( existingGo ) )
					{
						if ( BridgeLockPolicy.AllowsTransformChange( existingGo ) )
							ApplyTransform( existingGo, root );
						if ( BridgeLockPolicy.AllowsGeometryChange( existingGo ) && root.TryGetProperty( "meshData", out var md ) )
							ApplyMeshData( existingGo, md );
					}
					_lastWriteSeq[existingId] = blenderSeq;
					return JsonSerializer.Serialize( new { bridgeId = existingId }, BlenderBridgeServer.JsonOptions );
				}
				_idempotencyKeys.Remove( idemKey );
			}

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return "{\"error\":\"no scene\"}";

			// Anti-duplication guard: if a GameObject in this scene already
			// shares this name AND is locked from inbound Blender updates
			// (e.g. a Terrain that was sent as a one-way proxy), assume Blender
			// lost the bridge_id on round-trip and is trying to re-create it.
			// Quietly map the new request onto the existing object instead of
			// spawning a duplicate.
			var nameClash = BridgeSceneHelper
				.WalkAll( scene )
				.FirstOrDefault( g => g.Name == name && !BridgeLockPolicy.AllowsInbound( g ) );
			if ( nameClash != null )
			{
				var existingTag = nameClash.Tags.TryGetAll().FirstOrDefault( t =>
					t.StartsWith( "bridge_" ) && t != "bridge_group"
					&& t != BridgeLockPolicy.LegacyLockTag
					&& !t.StartsWith( BridgeLockPolicy.FlagTagPrefix ) );
				var resolvedId = existingTag != null ? existingTag.Substring( 7 ) : "locked";
				BlenderBridgeServer.LogInfo( $"Suppressed duplicate create for locked '{name}' (re-binding to {resolvedId})" );
				_lastWriteSeq[resolvedId] = blenderSeq;
				if ( !string.IsNullOrEmpty( idemKey ) )
					_idempotencyKeys[idemKey] = resolvedId;
				return JsonSerializer.Serialize( new { bridgeId = resolvedId, locked = true }, BlenderBridgeServer.JsonOptions );
			}

			var bridgeId = "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );

			// Resolve hierarchy: Blender Bridge > [collection path] > object
			var parent = GetOrCreateBridgeGroup( scene );
			if ( root.TryGetProperty( "hierarchy", out var hierEl ) && hierEl.ValueKind == JsonValueKind.Array )
			{
				foreach ( var h in hierEl.EnumerateArray() )
				{
					var hName = h.GetString();
					if ( !string.IsNullOrEmpty( hName ) )
						parent = GetOrCreateChild( parent, hName );
				}
			}

			var go = scene.CreateObject();
			go.Name = name;
			go.Parent = parent;
			ApplyTransform( go, root );

			if ( root.TryGetProperty( "meshData", out var meshData ) )
				ApplyMeshData( go, meshData );

			go.Tags.Add( $"bridge_{bridgeId}" );
			_bridgeObjectCache[bridgeId] = go;
			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
			if ( !string.IsNullOrEmpty( idemKey ) )
				_idempotencyKeys[idemKey] = bridgeId;

			BlenderBridgeServer.LogInfo( $"Created '{name}' as {bridgeId}" );
			BridgePersistence.SaveAfterChange( scene, bridgeId, go );
			return JsonSerializer.Serialize( new { bridgeId }, BlenderBridgeServer.JsonOptions );
		}

		// ── update_transform ──────────────────────────────────────────────────

		private static string HandleUpdateTransform( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			var go = FindByBridgeTag( bridgeId );
			if ( go == null ) return "{\"error\":\"not found\"}";

			if ( !BridgeLockPolicy.AllowsTransformChange( go ) )
			{
				_lastWriteSeq[bridgeId] = blenderSeq;
				return "{\"ok\":true,\"locked\":true}";
			}

			ApplyTransform( go, root );
			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);

			return "{\"ok\":true}";
		}

		// ── update_mesh ───────────────────────────────────────────────────────

		// All lock decisions go through BridgeLockPolicy. The legacy bridge_locked
		// tag is still recognized there for backward compatibility, and Terrain
		// components are auto-locked (Inbound | Geometry | Materials) so the
		// one-way proxy mesh never clobbers the heightmap-driven terrain.
		internal const string LockTag = BridgeLockPolicy.LegacyLockTag;

		internal static bool IsLockedFromBlender( GameObject go )
			=> !BridgeLockPolicy.AllowsInbound( go );

		private static string HandleUpdateMesh( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			var go = FindByBridgeTag( bridgeId );
			if ( go == null ) return "{\"error\":\"not found\"}";

			if ( !BridgeLockPolicy.AllowsInbound( go ) )
			{
				_lastWriteSeq[bridgeId] = blenderSeq;
				return "{\"ok\":true,\"locked\":true}";
			}

			if ( BridgeLockPolicy.AllowsTransformChange( go ) )
				ApplyTransform( go, root );

			if ( BridgeLockPolicy.AllowsGeometryChange( go ) && root.TryGetProperty( "meshData", out var meshData ) )
				ApplyMeshData( go, meshData );

			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene != null )
				BridgePersistence.SaveAfterChange( scene, bridgeId, go );

			return "{\"ok\":true}";
		}

		// ── delete ────────────────────────────────────────────────────────────

		private static string HandleDelete( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";

			var go = FindByBridgeTag( bridgeId );
			if ( go != null )
				go.Destroy();

			_bridgeObjectCache.Remove( bridgeId );
			_lastKnown.Remove( bridgeId );
			_lastWriteSeq.Remove( bridgeId );
			_lastMeshHash.Remove( bridgeId );
			_idempotencyKeys.Where( kv => kv.Value == bridgeId ).Select( kv => kv.Key ).ToList()
				.ForEach( k => _idempotencyKeys.Remove( k ) );

			BridgePersistence.RemoveFromCache( bridgeId );
			BlenderBridgeServer.LogInfo( $"Deleted {bridgeId}" );
			return "{\"ok\":true}";
		}

		// ── create_light ──────────────────────────────────────────────────────

		private static string HandleCreateLight( JsonElement root )
		{
			var name = GetString( root, "name", "Blender Light" );
			var lightType = GetString( root, "lightType", "point" );
			var blenderSeq = GetInt( root, "seq", 0 );

			// Idempotency
			var idemKey = GetString( root, "idempotencyKey" );
			if ( !string.IsNullOrEmpty( idemKey ) && _idempotencyKeys.TryGetValue( idemKey, out var existingId ) )
			{
				var existingGo = FindByBridgeTag( existingId );
				if ( existingGo != null )
				{
					ApplyTransform( existingGo, root );
					ApplyLightProperties( existingGo, root );
					_lastWriteSeq[existingId] = blenderSeq;
					return JsonSerializer.Serialize( new { bridgeId = existingId }, BlenderBridgeServer.JsonOptions );
				}
				_idempotencyKeys.Remove( idemKey );
			}

			var bridgeId = "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return "{\"error\":\"no scene\"}";

			var parent = GetOrCreateBridgeGroup( scene );
			var go = scene.CreateObject();
			go.Name = name;
			go.Parent = parent;
			ApplyTransform( go, root );

			// Create appropriate light component
			switch ( lightType )
			{
				case "spot":
					go.Components.Create<SpotLight>();
					break;
				case "directional":
					go.Components.Create<DirectionalLight>();
					break;
				default:
					go.Components.Create<PointLight>();
					break;
			}

			ApplyLightProperties( go, root );

			go.Tags.Add( $"bridge_{bridgeId}" );
			_bridgeObjectCache[bridgeId] = go;
			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
			if ( !string.IsNullOrEmpty( idemKey ) )
				_idempotencyKeys[idemKey] = bridgeId;

			BlenderBridgeServer.LogInfo( $"Created light '{name}' as {bridgeId} ({lightType})" );
			return JsonSerializer.Serialize( new { bridgeId }, BlenderBridgeServer.JsonOptions );
		}

		// ── update_light ──────────────────────────────────────────────────────

		private static string HandleUpdateLight( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			var go = FindByBridgeTag( bridgeId );
			if ( go == null ) return "{\"error\":\"not found\"}";

			ApplyTransform( go, root );
			ApplyLightProperties( go, root );
			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);

			return "{\"ok\":true}";
		}

		// ── Chunked mesh handlers ─────────────────────────────────────────────

		private static string HandleMeshBegin( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";

			var totalVerts = GetInt( root, "totalVertices", 0 );
			var chunkCount = GetInt( root, "chunkCount", 0 );

			_pendingChunks[bridgeId] = new MeshAccumulator
			{
				Vertices = new List<float>( totalVerts * 3 ),
				TotalVertices = totalVerts,
				ChunksReceived = 0,
				ChunkCount = chunkCount,
				StartTime = DateTime.UtcNow,
			};

			return "{\"ok\":true}";
		}

		private static string HandleMeshChunk( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";

			if ( !_pendingChunks.TryGetValue( bridgeId, out var accum ) )
				return "{\"error\":\"no pending mesh_begin\"}";

			if ( root.TryGetProperty( "vertices", out var vertsEl ) )
			{
				foreach ( var v in vertsEl.EnumerateArray() )
					accum.Vertices.Add( v.GetSingle() );
			}

			accum.ChunksReceived++;
			_pendingChunks[bridgeId] = accum;
			return "{\"ok\":true}";
		}

		private static string HandleMeshEnd( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			if ( !_pendingChunks.TryGetValue( bridgeId, out var accum ) )
				return "{\"error\":\"no pending mesh_begin\"}";

			_pendingChunks.Remove( bridgeId );

			// Build a complete meshData JsonElement from accumulated vertices + face data from this message
			var vertArray = accum.Vertices;

			// Parse faces from this message
			var faces = new List<int>();
			if ( root.TryGetProperty( "faces", out var facesEl ) )
				foreach ( var f in facesEl.EnumerateArray() )
					faces.Add( f.GetInt32() );

			int[] faceMaterials = null;
			if ( root.TryGetProperty( "faceMaterials", out var fmEl ) )
			{
				var fmList = new List<int>();
				foreach ( var fm in fmEl.EnumerateArray() )
					fmList.Add( fm.GetInt32() );
				faceMaterials = fmList.ToArray();
			}

			List<MaterialDef> materials = null;
			if ( root.TryGetProperty( "materials", out var matsEl ) )
				materials = ParseMaterialDefs( matsEl );

			// Build ParsedMesh
			var vertCount = vertArray.Count / 3;
			var vertices = new Vector3[vertCount];
			for ( int i = 0; i < vertCount; i++ )
				vertices[i] = new Vector3( vertArray[i * 3], vertArray[i * 3 + 1], vertArray[i * 3 + 2] );

			var faceGroups = new List<int[]>();
			int idx = 0;
			while ( idx < faces.Count )
			{
				int fvc = faces[idx++];
				if ( idx + fvc > faces.Count ) break;
				var face = new int[fvc];
				for ( int i = 0; i < fvc; i++ )
					face[i] = faces[idx++];
				faceGroups.Add( face );
			}

			var parsed = new ParsedMesh
			{
				Vertices = vertices,
				FaceGroups = faceGroups,
				FaceMaterials = faceMaterials,
				Materials = materials
			};

			// Find or create the object and apply mesh
			var go = FindByBridgeTag( bridgeId );
			if ( go == null )
				return "{\"error\":\"not found\"}";

			ApplyTransform( go, root );
			ApplyParsedMeshData( go, parsed );

			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene != null )
				BridgePersistence.SaveAfterChange( scene, bridgeId, go );

			BlenderBridgeServer.LogInfo( $"Chunked mesh assembled for {bridgeId} ({vertCount} verts)" );
			return "{\"ok\":true}";
		}

		// ── update_scene_transform ────────────────────────────────────────────

		private static string HandleUpdateSceneTransform( JsonElement root )
		{
			var sceneId = GetString( root, "sceneId" );
			if ( string.IsNullOrEmpty( sceneId ) ) return "{\"error\":\"missing sceneId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			if ( !Guid.TryParse( sceneId, out var guid ) ) return "{\"error\":\"invalid sceneId\"}";

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return "{\"error\":\"no scene\"}";

			GameObject go = null;
			foreach ( var root2 in scene.Children )
			{
				go = SearchTree( root2, g => g.Id == guid );
				if ( go != null ) break;
			}
			if ( go == null ) return "{\"error\":\"not found\"}";

			ApplyTransform( go, root );

			var key = $"scene_{sceneId}";
			_lastWriteSeq[key] = blenderSeq;
			_lastKnown[key] = (go.WorldPosition, go.WorldRotation);

			return "{\"ok\":true}";
		}

		// ── sync ──────────────────────────────────────────────────────────────

		private static string HandleSync( JsonElement root )
		{
			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return "{\"ok\":true}";

			// Parse what Blender knows about
			var blenderKnown = new HashSet<string>();
			if ( root.TryGetProperty( "knownObjects", out var known ) )
			{
				foreach ( var item in known.EnumerateArray() )
				{
					var bid = item.TryGetProperty( "bridgeId", out var b ) ? b.GetString() : null;
					if ( bid != null ) blenderKnown.Add( bid );
				}
			}

			var bridgeObjects = FindAllBridgeObjects( scene );
			var sboxIds = new HashSet<string>( bridgeObjects.Select( x => x.bridgeId ) );
			var objects = new List<object>();

			foreach ( var (bridgeId, go) in bridgeObjects )
			{
				_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
				if ( !BridgeLockPolicy.AllowsOutbound( go ) ) continue;
				objects.Add( BuildObjectPayload( bridgeId, go ) );
			}

			// Include scene lights, models, and native MeshComponents
			foreach ( var go in BridgeSceneHelper.WalkAll( scene, true ) )
			{
				if ( go.Tags.TryGetAll().Any( tag => tag.StartsWith( "bridge_" ) ) )
					continue;

				var light = go.Components.GetAll().FirstOrDefault( c => c is Light ) as Light;
				if ( light != null )
				{
					objects.Add( BuildLightPayload( go, light ) );
					continue;
				}

				// Native MeshComponents placed in s&box — adopt as bridge objects
				var meshComp = go.Components.Get<MeshComponent>();
				if ( meshComp?.Mesh != null )
				{
					var adoptId = "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
					go.Tags.Add( $"bridge_{adoptId}" );
					_bridgeObjectCache[adoptId] = go;
					_lastKnown[adoptId] = (go.WorldPosition, go.WorldRotation);
					objects.Add( BuildObjectPayload( adoptId, go ) );
					sboxIds.Add( adoptId );
					continue;
				}

				var anyModel = go.Components.GetAll()
					.FirstOrDefault( c => c.GetType().Name.Contains( "ModelRenderer" ) );
				if ( anyModel != null )
					objects.Add( BuildModelPayload( go, anyModel ) );
			}

			// Tell Blender to remove objects it has that s&box doesn't
			var staleInBlender = blenderKnown.Except( sboxIds );
			foreach ( var staleId in staleInBlender )
				objects.Add( new { type = "deleted", bridgeId = staleId } );

			BlenderBridgeServer.BroadcastWithSeq( new { type = "sync_response", objects } );
			BlenderBridgeServer.LogInfo( $"Sync: {bridgeObjects.Count} bridge, {objects.Count} total" );
			return "{\"ok\":true}";
		}

		// ── Poll for s&box-side changes ───────────────────────────────────────

		internal static void PollForChanges()
		{
			try
			{
				PollForChangesInternal();
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogInfo( $"Poll cycle skipped: {ex.Message}" );
			}
		}

		private static void PollForChangesInternal()
		{
			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return;

			// Play mode detection
			bool isPlaying = Game.IsPlaying;
			if ( isPlaying != _wasPlaying )
			{
				_wasPlaying = isPlaying;
				BlenderBridgeServer.BroadcastWithSeq( new
				{
					type = "play_mode",
					state = isPlaying ? "started" : "stopped"
				} );

				if ( !isPlaying )
				{
					// Exiting play mode — restore cached meshes
					try { BridgePersistence.RestoreFromCache( scene ); }
					catch { }
				}
			}

			// Clean up timed-out chunk accumulators
			var timedOut = _pendingChunks
				.Where( kv => (DateTime.UtcNow - kv.Value.StartTime).TotalSeconds > 30 )
				.Select( kv => kv.Key ).ToList();
			foreach ( var key in timedOut )
				_pendingChunks.Remove( key );

			var bridgeObjects = FindAllBridgeObjects( scene );
			var currentIds = new HashSet<string>();

			foreach ( var (bridgeId, go) in bridgeObjects )
			{
				if ( go == null || !go.IsValid ) continue;
				currentIds.Add( bridgeId );

				// Outbound lock: skip pushing changes to Blender for this object,
				// but keep tracking it (so deletes still propagate, etc).
				if ( !BridgeLockPolicy.AllowsOutbound( go ) )
				{
					_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
					continue;
				}

				var pos = go.WorldPosition;
				var rot = go.WorldRotation;

				bool posChanged = false;
				bool rotChanged = false;

				if ( _lastKnown.TryGetValue( bridgeId, out var prev ) )
				{
					posChanged = MathF.Abs( pos.x - prev.pos.x ) > 0.01f
						|| MathF.Abs( pos.y - prev.pos.y ) > 0.01f
						|| MathF.Abs( pos.z - prev.pos.z ) > 0.01f;
					rotChanged = !rot.Equals( prev.rot );
				}

				// Check mesh changes — hash vertex positions so moves/pulls are detected
				bool meshChanged = false;
				var meshComp = go.Components.Get<MeshComponent>();
				if ( meshComp?.Mesh != null )
				{
					var meshHash = ComputeMeshGeometryHash( meshComp.Mesh );
					if ( _lastMeshHash.TryGetValue( bridgeId, out var prevHash ) && prevHash != meshHash )
						meshChanged = true;
					_lastMeshHash[bridgeId] = meshHash;
				}

				if ( (posChanged || rotChanged || meshChanged) )
				{
					// Echo suppression: if we recently applied a Blender write, suppress
					if ( _lastWriteSeq.ContainsKey( bridgeId ) )
					{
						_lastWriteSeq.Remove( bridgeId );
					}
					else
					{
						var angles = rot.Angles();
						if ( meshChanged )
						{
							var extracted = ExtractMeshData( meshComp.Mesh );
							object md = null;
							if ( extracted != null )
								md = new { vertices = extracted.Value.Vertices, faces = extracted.Value.Faces };

							BlenderBridgeServer.BroadcastWithSeq( new
							{
								type = "mesh_updated",
								bridgeId,
								position = new { x = pos.x, y = pos.y, z = pos.z },
								rotation = new { pitch = angles.pitch, yaw = angles.yaw, roll = angles.roll },
								meshData = md
							} );
						}
						else
						{
							BlenderBridgeServer.BroadcastWithSeq( new
							{
								type = "updated",
								bridgeId,
								position = new { x = pos.x, y = pos.y, z = pos.z },
								rotation = new { pitch = angles.pitch, yaw = angles.yaw, roll = angles.roll }
							} );
						}
					}
				}

				_lastKnown[bridgeId] = (pos, rot);
			}

			// Detect deletions
			foreach ( var oldId in _lastKnown.Keys.ToList() )
			{
				if ( oldId.StartsWith( "scene_" ) ) continue;
				if ( !currentIds.Contains( oldId ) )
				{
					_lastKnown.Remove( oldId );
					_lastWriteSeq.Remove( oldId );
					_lastMeshHash.Remove( oldId );
					_bridgeObjectCache.Remove( oldId );
					BlenderBridgeServer.BroadcastWithSeq( new { type = "deleted", bridgeId = oldId } );
				}
			}

			// Track scene objects (models/lights) and auto-adopt native MeshComponents
			foreach ( var go in BridgeSceneHelper.WalkAll( scene, true ) )
			{
				if ( go == null || !go.IsValid ) continue;
				if ( go.Tags.TryGetAll().Any( tag => tag.StartsWith( "bridge_" ) ) )
					continue;

				// Auto-adopt native MeshComponents that aren't bridge-tagged yet
				var nativeMesh = go.Components.Get<MeshComponent>();
				if ( nativeMesh?.Mesh != null )
				{
					var adoptId = "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
					go.Tags.Add( $"bridge_{adoptId}" );
					_bridgeObjectCache[adoptId] = go;
					_lastKnown[adoptId] = (go.WorldPosition, go.WorldRotation);
					_lastMeshHash[adoptId] = ComputeMeshGeometryHash( nativeMesh.Mesh );

					// Broadcast the new object to Blender as a creation event
					var adoptPos = go.WorldPosition;
					var adoptRot = go.WorldRotation.Angles();
					var extracted = ExtractMeshData( nativeMesh.Mesh );
					object meshData = null;
					if ( extracted != null )
						meshData = new { vertices = extracted.Value.Vertices, faces = extracted.Value.Faces };

					BlenderBridgeServer.BroadcastWithSeq( new
					{
						type = "object_created",
						bridgeId = adoptId,
						name = go.Name,
						position = new { x = adoptPos.x, y = adoptPos.y, z = adoptPos.z },
						rotation = new { pitch = adoptRot.pitch, yaw = adoptRot.yaw, roll = adoptRot.roll },
						meshData
					} );
					BlenderBridgeServer.LogInfo( $"Auto-adopted native mesh '{go.Name}' as {adoptId}" );
					continue;
				}

				var hasModel = go.Components.GetAll().Any( c => c.GetType().Name.Contains( "ModelRenderer" ) );
				var hasLight = go.Components.GetAll().Any( c => c is Light );
				if ( !hasModel && !hasLight ) continue;

				var key = $"scene_{go.Id}";
				var pos = go.WorldPosition;
				var rot = go.WorldRotation;

				if ( _lastKnown.TryGetValue( key, out var prevScene ) )
				{
					bool sceneChanged = MathF.Abs( pos.x - prevScene.pos.x ) > 0.01f
						|| MathF.Abs( pos.y - prevScene.pos.y ) > 0.01f
						|| MathF.Abs( pos.z - prevScene.pos.z ) > 0.01f;

					if ( sceneChanged && !_lastWriteSeq.ContainsKey( key ) )
					{
						var angles = rot.Angles();
						BlenderBridgeServer.BroadcastWithSeq( new
						{
							type = "scene_updated",
							sceneId = go.Id.ToString(),
							position = new { x = pos.x, y = pos.y, z = pos.z },
							rotation = new { pitch = angles.pitch, yaw = angles.yaw, roll = angles.roll }
						} );
					}
					else if ( _lastWriteSeq.ContainsKey( key ) )
					{
						_lastWriteSeq.Remove( key );
					}
				}

				_lastKnown[key] = (pos, rot);
			}
		}

		// ── Hierarchy grouping ────────────────────────────────────────────────

		/// <summary>Find or create the "Blender Bridge" parent object identified by tag.</summary>
		private static GameObject GetOrCreateBridgeGroup( Scene scene )
		{
			// Search by tag (survives renames)
			foreach ( var root in scene.Children )
			{
				var found = SearchTree( root, g => g.Tags.Has( "bridge_group" ) );
				if ( found != null ) return found;
			}

			// Create new group
			var go = scene.CreateObject();
			go.Name = "Blender Bridge";
			go.Tags.Add( "bridge_group" );
			return go;
		}

		/// <summary>Find or create a child GameObject by name under a parent.
		/// Used to build hierarchy from Blender collection paths.</summary>
		private static GameObject GetOrCreateChild( GameObject parent, string name )
		{
			// Search existing children
			foreach ( var child in parent.Children )
			{
				if ( child.Name == name )
					return child;
			}

			// Create new empty child
			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return parent;

			var go = scene.CreateObject();
			go.Name = name;
			go.Parent = parent;
			return go;
		}

		// ── Object finders ────────────────────────────────────────────────────

		/// <summary>Find a bridge object by ID. Uses cache, falls back to tree walk.</summary>
		private static GameObject FindByBridgeTag( string bridgeId )
		{
			// Cache hit
			if ( _bridgeObjectCache.TryGetValue( bridgeId, out var cached ) && cached != null && cached.IsValid )
				return cached;

			// Cache miss — walk the scene
			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return null;

			var tag = $"bridge_{bridgeId}";
			foreach ( var root in scene.Children )
			{
				var found = SearchTree( root, tag );
				if ( found != null )
				{
					_bridgeObjectCache[bridgeId] = found;
					return found;
				}
			}
			return null;
		}

		private static GameObject SearchTree( GameObject node, string tag )
		{
			if ( node.Tags.Has( tag ) ) return node;
			foreach ( var child in node.Children )
			{
				var found = SearchTree( child, tag );
				if ( found != null ) return found;
			}
			return null;
		}

		private static GameObject SearchTree( GameObject node, Func<GameObject, bool> predicate )
		{
			if ( predicate( node ) ) return node;
			foreach ( var child in node.Children )
			{
				var found = SearchTree( child, predicate );
				if ( found != null ) return found;
			}
			return null;
		}

		private static List<(string bridgeId, GameObject go)> FindAllBridgeObjects( Scene scene )
		{
			var result = new List<(string, GameObject)>();
			foreach ( var root in scene.Children )
				CollectBridgeObjects( root, result );
			return result;
		}

		private static void CollectBridgeObjects( GameObject node, List<(string, GameObject)> result )
		{
			foreach ( var tag in node.Tags.TryGetAll() )
			{
				if ( tag.StartsWith( "bridge_" ) && tag != "bridge_group" )
				{
					var bridgeId = tag.Substring( 7 );
					result.Add( (bridgeId, node) );
					_bridgeObjectCache[bridgeId] = node; // Keep cache warm
					break;
				}
			}
			foreach ( var child in node.Children )
				CollectBridgeObjects( child, result );
		}

		// ── Light properties ──────────────────────────────────────────────────

		private static void ApplyLightProperties( GameObject go, JsonElement root )
		{
			if ( !root.TryGetProperty( "properties", out var propsEl ) )
				return;

			var light = go.Components.GetAll().FirstOrDefault( c => c is Light ) as Light;
			if ( light == null ) return;

			if ( propsEl.TryGetProperty( "color", out var colorEl ) )
			{
				var r = GetFloat( colorEl, "r", 1f );
				var g = GetFloat( colorEl, "g", 1f );
				var b = GetFloat( colorEl, "b", 1f );
				light.LightColor = new Color( r, g, b );
			}

			if ( light is PointLight point )
			{
				if ( propsEl.TryGetProperty( "radius", out var radiusEl ) )
					point.Radius = radiusEl.GetSingle();
			}
			else if ( light is SpotLight spot )
			{
				if ( propsEl.TryGetProperty( "radius", out var radiusEl ) )
					spot.Radius = radiusEl.GetSingle();
				if ( propsEl.TryGetProperty( "coneOuter", out var outerEl ) )
					spot.ConeOuter = outerEl.GetSingle();
				if ( propsEl.TryGetProperty( "coneInner", out var innerEl ) )
					spot.ConeInner = innerEl.GetSingle();
			}
		}

		// ── Mesh handling ─────────────────────────────────────────────────────

		private struct ParsedMesh
		{
			public Vector3[] Vertices;
			public List<int[]> FaceGroups;
			public int[] FaceMaterials;
			public List<MaterialDef> Materials;
			/// <summary>Per-face Blender-authored UVs, parallel to FaceGroups.
			/// Each entry has one Vector2 per face vertex. Null when Blender
			/// didn't send a UV layer; the apply path falls back to grid-align.</summary>
			public List<Vector2[]> FaceUVs;
		}

		private struct MaterialDef
		{
			public string Name;
			public float[] BaseColor;
			public float Metallic;
			public float Roughness;
			public string BaseColorTexture;
			public string RoughnessTexture;
			public string MetallicTexture;
			public string NormalTexture;
			public float NormalStrength;
			public float[] EmissionColor;
			public float EmissionStrength;
			public string VmatPath;
		}

		private static void ApplyMeshData( GameObject go, JsonElement meshData )
		{
			var parsed = ParseMeshData( meshData );
			if ( parsed == null ) return;
			ApplyParsedMeshData( go, parsed.Value );
		}

		/// <summary>
		/// Pulls the material off the first face of the GameObject's existing
		/// mesh. Used to preserve manually-authored materials (water shaders,
		/// custom vmats) when Blender resyncs without supplying material info.
		/// Returns null if there's no mesh, no faces, or only the dev default
		/// material is present (in which case the caller's fallback is fine).
		/// </summary>
		private static Material TryGetExistingDominantMaterial( GameObject go )
		{
			try
			{
				var mesh = go?.Components.Get<MeshComponent>()?.Mesh;
				if ( mesh == null ) return null;

				var getFaceMatMeth = mesh.GetType().GetMethod( "GetFaceMaterial", new[] { typeof( FaceHandle ) } );
				if ( getFaceMatMeth == null ) return null;

				foreach ( var fh in mesh.FaceHandles )
				{
					var result = getFaceMatMeth.Invoke( mesh, new object[] { fh } );
					if ( result is Material m && m != null )
					{
						// Skip the dev placeholder — using it as the "preserve"
						// fallback means we'd never escape it once it's been set.
						var path = m.ResourcePath ?? "";
						if ( path.EndsWith( "reflectivity_30.vmat", StringComparison.OrdinalIgnoreCase ) ||
							 path.EndsWith( "reflectivity_30.vmat_c", StringComparison.OrdinalIgnoreCase ) )
							return null;
						return m;
					}
					break;
				}
			}
			catch { }
			return null;
		}

		private static void ApplyParsedMeshData( GameObject go, ParsedMesh parsed )
		{
			var faceMaterials = new Material[parsed.FaceGroups.Count];
			var defaultMaterial = LoadMaterialSafe( "materials/dev/reflectivity_30.vmat" );

			// Materials lock: preserve whatever's already on the object regardless
			// of what Blender supplied. Skips the entire material-resolution path
			// and falls through to the existing-material preservation branch.
			bool forceMaterialPreserve = !BridgeLockPolicy.AllowsMaterialChange( go );

			if ( !forceMaterialPreserve && parsed.Materials != null && parsed.FaceMaterials != null )
			{
				var materialCache = new Dictionary<int, Material>();
				for ( int fi = 0; fi < parsed.FaceGroups.Count && fi < parsed.FaceMaterials.Length; fi++ )
				{
					var matIdx = parsed.FaceMaterials[fi];
					if ( !materialCache.ContainsKey( matIdx ) )
					{
						Material mat = null;
						if ( matIdx < parsed.Materials.Count )
						{
							var vmatPath = parsed.Materials[matIdx].VmatPath;
							if ( !string.IsNullOrEmpty( vmatPath ) )
								mat = LoadMaterialSafe( vmatPath );
							if ( mat == null )
								mat = GenerateOrLoadMaterial( parsed.Materials[matIdx] );
						}
						materialCache[matIdx] = mat ?? defaultMaterial;
					}
					faceMaterials[fi] = materialCache[matIdx];
				}
			}
			else
			{
				// Blender sent geometry but no material info. Before stamping
				// the dev placeholder over every face, try to preserve whatever
				// material is already on this GameObject's mesh — water shaders,
				// custom vmats, anything authored on the s&box side.
				var preserved = TryGetExistingDominantMaterial( go );
				var fallback = preserved ?? defaultMaterial;
				for ( int i = 0; i < faceMaterials.Length; i++ )
					faceMaterials[i] = fallback;
				if ( preserved != null )
					BlenderBridgeServer.LogInfo( $"Preserved existing material on '{go.Name}' (Blender sent no materials)" );
			}

			var mesh = new PolygonMesh();
			var hVertices = mesh.AddVertices( parsed.Vertices );

			bool hasBlenderUVs = parsed.FaceUVs != null && parsed.FaceUVs.Count == parsed.FaceGroups.Count;

			int faceIdx = 0;
			foreach ( var faceGroup in parsed.FaceGroups )
			{
				var faceVerts = faceGroup
					.Where( fi => fi >= 0 && fi < hVertices.Length )
					.Select( fi => hVertices[fi] )
					.ToArray();
				if ( faceVerts.Length >= 3 )
				{
					var hFace = mesh.AddFace( faceVerts );
					mesh.SetFaceMaterial( hFace, faceIdx < faceMaterials.Length ? faceMaterials[faceIdx] : defaultMaterial );
					if ( hasBlenderUVs && faceIdx < parsed.FaceUVs.Count )
					{
						// Use Blender-authored UVs verbatim. SetFaceTextureCoords
						// also recomputes the face's texture parameters from the
						// new coords, so the planar projection follows the UV
						// layout instead of fighting it.
						mesh.SetFaceTextureCoords( hFace, parsed.FaceUVs[faceIdx] );
					}
				}
				faceIdx++;
			}

			// Only fall back to grid-aligned auto-UVs if Blender didn't send a
			// UV layer. Calling TextureAlignToGrid when we have real UVs would
			// stomp them.
			if ( !hasBlenderUVs )
				mesh.TextureAlignToGrid( mesh.Transform );
			mesh.SetSmoothingAngle( 40.0f );

			// Defense in depth: never replace a Terrain component with a
			// MeshComponent. If the lock check earlier in the dispatch flow was
			// bypassed (e.g. an addon bug, a missing tag), this still blocks the
			// destructive path.
			if ( go.Components.Get<Terrain>() != null )
			{
				BlenderBridgeServer.LogInfo( $"Skipped ApplyMeshData on '{go.Name}' — has Terrain component" );
				return;
			}

			var existingMr = go.Components.Get<ModelRenderer>();
			if ( existingMr != null )
				existingMr.Destroy();

			var meshComp = go.Components.Get<MeshComponent>();
			if ( meshComp == null )
				meshComp = go.Components.Create<MeshComponent>();

			meshComp.Mesh = mesh;
		}

		// ── Material generation ───────────────────────────────────────────────

		private static Material GenerateOrLoadMaterial( MaterialDef def )
		{
			var safeName = System.Text.RegularExpressions.Regex.Replace(
				def.Name ?? "default", @"[^a-zA-Z0-9_\-]", "_" ).ToLower();
			var vmatRelPath = $"materials/blender_bridge/{safeName}.vmat";

			var assetsDir = GetProjectAssetsDir();
			if ( assetsDir == null ) return null;

			var bridgeMatDir = System.IO.Path.Combine( assetsDir, "materials", "blender_bridge" );
			System.IO.Directory.CreateDirectory( bridgeMatDir );

			var colorTexRef = CopyTextureToAssets( def.BaseColorTexture, safeName, "color", assetsDir );
			var roughTexRef = CopyTextureToAssets( def.RoughnessTexture, safeName, "rough", assetsDir );
			var metalTexRef = CopyTextureToAssets( def.MetallicTexture, safeName, "metal", assetsDir );
			var normalTexRef = CopyTextureToAssets( def.NormalTexture, safeName, "normal", assetsDir );

			var sb = new System.Text.StringBuilder();
			sb.AppendLine( "// AUTO-GENERATED BY BLENDER BRIDGE" );
			sb.AppendLine();
			sb.AppendLine( "Layer0" );
			sb.AppendLine( "{" );
			sb.AppendLine( "\tshader \"shaders/complex.shader\"" );
			sb.AppendLine();
			if ( metalTexRef != null ) sb.AppendLine( "\tF_METALNESS_TEXTURE 1" );
			sb.AppendLine( "\tF_SPECULAR 1" );
			sb.AppendLine();

			var r = def.BaseColor?.Length >= 3 ? def.BaseColor[0] : 0.8f;
			var g = def.BaseColor?.Length >= 3 ? def.BaseColor[1] : 0.8f;
			var b = def.BaseColor?.Length >= 3 ? def.BaseColor[2] : 0.8f;
			sb.AppendLine( $"\tg_flModelTintAmount \"1.000\"" );
			sb.AppendLine( $"\tg_vColorTint \"[{r:F6} {g:F6} {b:F6} 0.000000]\"" );
			if ( colorTexRef != null ) sb.AppendLine( $"\tTextureColor \"{colorTexRef}\"" );
			sb.AppendLine();
			sb.AppendLine( $"\tg_flMetalness \"{def.Metallic:F3}\"" );
			if ( metalTexRef != null ) sb.AppendLine( $"\tTextureMetalness \"{metalTexRef}\"" );
			sb.AppendLine();
			sb.AppendLine( $"\tg_flRoughnessScaleFactor \"{def.Roughness:F3}\"" );
			if ( roughTexRef != null ) sb.AppendLine( $"\tTextureRoughness \"{roughTexRef}\"" );
			sb.AppendLine();
			if ( normalTexRef != null ) { sb.AppendLine( $"\tTextureNormal \"{normalTexRef}\"" ); sb.AppendLine(); }
			if ( def.EmissionStrength > 0.001f )
			{
				var er = def.EmissionColor?.Length >= 3 ? def.EmissionColor[0] : 0f;
				var eg = def.EmissionColor?.Length >= 3 ? def.EmissionColor[1] : 0f;
				var eb = def.EmissionColor?.Length >= 3 ? def.EmissionColor[2] : 0f;
				sb.AppendLine( $"\tg_vSelfIllumTint \"[{er:F6} {eg:F6} {eb:F6} 0.000000]\"" );
				sb.AppendLine( $"\tg_flSelfIllumScale \"{def.EmissionStrength:F3}\"" );
				sb.AppendLine();
			}
			sb.AppendLine( "\tg_vTexCoordScale \"[1.000 1.000]\"" );
			sb.AppendLine( "\tg_vTexCoordOffset \"[0.000 0.000]\"" );
			sb.AppendLine( "}" );

			var vmatPath = System.IO.Path.Combine( assetsDir, vmatRelPath.Replace( "/", "\\" ) );
			System.IO.File.WriteAllText( vmatPath, sb.ToString() );

			// Register AND compile, otherwise Material.Load returns null on the
			// first call (no .vmat_c on disk yet) and we silently fall through
			// to the dev placeholder. Compile(false) is incremental and
			// synchronous — by the time it returns, the compiled resource
			// exists and Material.Load can resolve it. Re-registering an
			// already-known path is a no-op.
			try
			{
				var asset = AssetSystem.RegisterFile( vmatPath );
				asset?.Compile( full: false );
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogInfo( $"Vmat register/compile failed for {vmatPath}: {ex.Message}" );
			}

			return LoadMaterialSafe( vmatRelPath ) ?? LoadMaterialSafe( "materials/dev/reflectivity_30.vmat" );
		}

		private static string GenerateVmatPath( MaterialDef def )
		{
			var safeName = System.Text.RegularExpressions.Regex.Replace(
				def.Name ?? "default", @"[^a-zA-Z0-9_\-]", "_" ).ToLower();
			return $"materials/blender_bridge/{safeName}.vmat";
		}

		private static string CopyTextureToAssets( string srcPath, string matName, string suffix, string assetsDir )
		{
			if ( string.IsNullOrEmpty( srcPath ) || !System.IO.File.Exists( srcPath ) )
				return null;
			var ext = System.IO.Path.GetExtension( srcPath );
			var destName = $"{matName}_{suffix}{ext}";
			var destRelPath = $"materials/blender_bridge/{destName}";
			var destAbsPath = System.IO.Path.Combine( assetsDir, destRelPath.Replace( "/", "\\" ) );
			try
			{
				System.IO.File.Copy( srcPath, destAbsPath, overwrite: true );
				// Register + compile so the vmat that references this texture
				// can find a resolved .vtex_c on disk. Without compile, the
				// material loads but renders untextured.
				try
				{
					var asset = AssetSystem.RegisterFile( destAbsPath );
					asset?.Compile( full: false );
				}
				catch ( Exception cex )
				{
					BlenderBridgeServer.LogInfo( $"Texture register/compile failed for {destAbsPath}: {cex.Message}" );
				}
				return destRelPath;
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogInfo( $"Texture copy failed ({srcPath}): {ex.Message}" );
				return null;
			}
		}

		// ── Mesh parsing ──────────────────────────────────────────────────────

		private static ParsedMesh? ParseMeshData( JsonElement meshData )
		{
			if ( !meshData.TryGetProperty( "vertices", out var vertsEl ) ) return null;
			if ( !meshData.TryGetProperty( "faces", out var facesEl ) ) return null;

			var vertFloats = new List<float>();
			foreach ( var v in vertsEl.EnumerateArray() ) vertFloats.Add( v.GetSingle() );
			if ( vertFloats.Count < 9 ) return null;

			var vertCount = vertFloats.Count / 3;
			var vertices = new Vector3[vertCount];
			for ( int i = 0; i < vertCount; i++ )
				vertices[i] = new Vector3( vertFloats[i * 3], vertFloats[i * 3 + 1], vertFloats[i * 3 + 2] );

			var rawFaces = new List<int>();
			foreach ( var f in facesEl.EnumerateArray() ) rawFaces.Add( f.GetInt32() );

			var faceGroups = new List<int[]>();
			int idx = 0;
			while ( idx < rawFaces.Count )
			{
				int faceVertCount = rawFaces[idx++];
				if ( idx + faceVertCount > rawFaces.Count ) break;
				var face = new int[faceVertCount];
				for ( int i = 0; i < faceVertCount; i++ ) face[i] = rawFaces[idx++];
				faceGroups.Add( face );
			}

			int[] faceMaterials = null;
			if ( meshData.TryGetProperty( "faceMaterials", out var fmEl ) )
			{
				var fmList = new List<int>();
				foreach ( var fm in fmEl.EnumerateArray() ) fmList.Add( fm.GetInt32() );
				faceMaterials = fmList.ToArray();
			}

			List<MaterialDef> materials = null;
			if ( meshData.TryGetProperty( "materials", out var matsEl ) )
				materials = ParseMaterialDefs( matsEl );

			// Optional per-face-corner UVs. Flat [u,v,u,v,...] aligned with
			// faceGroups: face N's UVs are the next (face.Length) pairs.
			List<Vector2[]> faceUVs = null;
			if ( meshData.TryGetProperty( "faceUVs", out var uvEl ) )
			{
				var uvFloats = new List<float>();
				foreach ( var u in uvEl.EnumerateArray() ) uvFloats.Add( u.GetSingle() );
				faceUVs = new List<Vector2[]>( faceGroups.Count );
				int uvIdx = 0;
				foreach ( var face in faceGroups )
				{
					var uvs = new Vector2[face.Length];
					for ( int i = 0; i < face.Length && uvIdx + 1 < uvFloats.Count; i++ )
					{
						uvs[i] = new Vector2( uvFloats[uvIdx], uvFloats[uvIdx + 1] );
						uvIdx += 2;
					}
					faceUVs.Add( uvs );
				}
			}

			return new ParsedMesh
			{
				Vertices = vertices,
				FaceGroups = faceGroups,
				FaceMaterials = faceMaterials,
				Materials = materials,
				FaceUVs = faceUVs
			};
		}

		private static List<MaterialDef> ParseMaterialDefs( JsonElement matsEl )
		{
			var materials = new List<MaterialDef>();
			foreach ( var matEl in matsEl.EnumerateArray() )
			{
				var def = new MaterialDef
				{
					Name = matEl.TryGetProperty( "name", out var n ) ? n.GetString() : "default",
					Metallic = matEl.TryGetProperty( "metallic", out var met ) ? met.GetSingle() : 0f,
					Roughness = matEl.TryGetProperty( "roughness", out var rough ) ? rough.GetSingle() : 0.5f,
					NormalStrength = matEl.TryGetProperty( "normalStrength", out var ns ) ? ns.GetSingle() : 1f,
					EmissionStrength = matEl.TryGetProperty( "emissionStrength", out var es ) ? es.GetSingle() : 0f,
					BaseColorTexture = matEl.TryGetProperty( "baseColorTexture", out var bct ) && bct.ValueKind == JsonValueKind.String ? bct.GetString() : null,
					RoughnessTexture = matEl.TryGetProperty( "roughnessTexture", out var rt ) && rt.ValueKind == JsonValueKind.String ? rt.GetString() : null,
					MetallicTexture = matEl.TryGetProperty( "metallicTexture", out var mt ) && mt.ValueKind == JsonValueKind.String ? mt.GetString() : null,
					NormalTexture = matEl.TryGetProperty( "normalTexture", out var nt ) && nt.ValueKind == JsonValueKind.String ? nt.GetString() : null,
					VmatPath = matEl.TryGetProperty( "vmatPath", out var vp ) && vp.ValueKind == JsonValueKind.String ? vp.GetString() : null,
				};

				if ( matEl.TryGetProperty( "baseColor", out var bcEl ) && bcEl.ValueKind == JsonValueKind.Array )
				{
					var arr = new List<float>();
					foreach ( var c in bcEl.EnumerateArray() ) arr.Add( c.GetSingle() );
					def.BaseColor = arr.ToArray();
				}

				if ( matEl.TryGetProperty( "emissionColor", out var ecEl ) && ecEl.ValueKind == JsonValueKind.Array )
				{
					var arr = new List<float>();
					foreach ( var c in ecEl.EnumerateArray() ) arr.Add( c.GetSingle() );
					def.EmissionColor = arr.ToArray();
				}

				materials.Add( def );
			}
			return materials;
		}

		/// <summary>
		/// Compute a geometry hash that includes vertex positions,
		/// so moving/pulling vertices is detected as a change.
		/// </summary>
		private static int ComputeMeshGeometryHash( PolygonMesh mesh )
		{
			unchecked
			{
				int hash = 17;
				var vertHandles = mesh.VertexHandles;
				if ( vertHandles == null ) return 0;

				hash = hash * 31 + vertHandles.Count();

				var getPosMeth = mesh.GetType().GetMethod( "GetVertexPosition", new[] { typeof( VertexHandle ) } );
				if ( getPosMeth != null )
				{
					foreach ( var vh in vertHandles )
					{
						try
						{
							var result = getPosMeth.Invoke( mesh, new object[] { vh } );
							if ( result is Vector3 pos )
							{
								// Quantize to 0.001 precision to avoid float noise
								hash = hash * 31 + (int)( pos.x * 1000 );
								hash = hash * 31 + (int)( pos.y * 1000 );
								hash = hash * 31 + (int)( pos.z * 1000 );
							}
						}
						catch { break; }
					}
				}

				var faceHandles = mesh.FaceHandles;
				if ( faceHandles != null )
					hash = hash * 31 + faceHandles.Count();

				return hash;
			}
		}

		// ── Mesh extraction (s&box -> Blender) ────────────────────────────────

		internal struct ExtractedMesh
		{
			public float[] Vertices;
			public int[] Faces;
		}

		internal static ExtractedMesh? ExtractMeshData( PolygonMesh mesh )
		{
			try
			{
				var vertHandles = mesh.VertexHandles.ToList();
				if ( vertHandles.Count < 3 ) return null;

				var vertMap = new Dictionary<VertexHandle, int>();
				int vertIdx = 0;
				foreach ( var vh in vertHandles ) vertMap[vh] = vertIdx++;

				var verts = new List<float>();
				var getPosMeth = mesh.GetType().GetMethod( "GetVertexPosition", new[] { typeof( VertexHandle ) } );
				if ( getPosMeth != null )
				{
					foreach ( var vh in vertHandles )
					{
						try
						{
							var result = getPosMeth.Invoke( mesh, new object[] { vh } );
							if ( result is Vector3 pos )
							{
								verts.Add( pos.x );
								verts.Add( pos.y );
								verts.Add( pos.z );
							}
						}
						catch { break; }
					}
				}

				if ( verts.Count < 9 ) return null;

				var faces = new List<int>();
				var getFaceVertsMeth = mesh.GetType().GetMethod( "GetFaceVertices", new[] { typeof( FaceHandle ) } );
				if ( getFaceVertsMeth != null )
				{
					foreach ( var fh in mesh.FaceHandles )
					{
						var result = getFaceVertsMeth.Invoke( mesh, new object[] { fh } );
						if ( result is VertexHandle[] faceVerts )
						{
							faces.Add( faceVerts.Length );
							foreach ( var fv in faceVerts )
								faces.Add( vertMap.TryGetValue( fv, out var i ) ? i : 0 );
						}
						else if ( result is IEnumerable<VertexHandle> faceVertsEnum )
						{
							var fvList = faceVertsEnum.ToList();
							faces.Add( fvList.Count );
							foreach ( var fv in fvList )
								faces.Add( vertMap.TryGetValue( fv, out var i ) ? i : 0 );
						}
					}
				}

				if ( faces.Count == 0 ) return null;
				return new ExtractedMesh { Vertices = verts.ToArray(), Faces = faces.ToArray() };
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogError( $"ExtractMeshData failed: {ex.Message}" );
				return null;
			}
		}

		// ── Terrain proxy extraction (s&box -> Blender) ──────────────────────
		//
		// The Sandbox.Terrain component isn't a PolygonMesh — its data lives in
		// a TerrainStorage with a Resolution² ushort[] heightmap. Sending the
		// full heightmap to Blender is impractical (a 1024² terrain = 1M verts),
		// so we downsample to a fixed PROXY_RESOLUTION grid for a low-poly
		// reference mesh in mesh-local space. Triangulation: each cell is a
		// quad face (the wire format already supports n-gons).

		private const int TerrainProxyResolution = 128;

		internal static ExtractedMesh? ExtractTerrainProxyMesh( Terrain terrain )
		{
			try
			{
				var storage = terrain?.Storage;
				if ( storage == null ) return null;
				if ( storage.HeightMap == null || storage.HeightMap.Length == 0 ) return null;

				int srcRes = storage.Resolution;
				if ( srcRes <= 0 || storage.HeightMap.Length < srcRes * srcRes ) return null;

				int proxyRes = Math.Min( TerrainProxyResolution, srcRes );
				int vertsPerSide = proxyRes + 1;

				float terrainSize = storage.TerrainSize;
				float terrainHeight = storage.TerrainHeight;
				float cellSize = terrainSize / proxyRes;
				float invMaxHeight = 1.0f / 65535f;

				var verts = new float[vertsPerSide * vertsPerSide * 3];
				for ( int gy = 0; gy < vertsPerSide; gy++ )
				{
					int srcY = (int)((long)gy * (srcRes - 1) / proxyRes);
					for ( int gx = 0; gx < vertsPerSide; gx++ )
					{
						int srcX = (int)((long)gx * (srcRes - 1) / proxyRes);
						ushort h = storage.HeightMap[srcY * srcRes + srcX];

						int vi = (gy * vertsPerSide + gx) * 3;
						verts[vi + 0] = gx * cellSize;
						verts[vi + 1] = gy * cellSize;
						verts[vi + 2] = h * invMaxHeight * terrainHeight;
					}
				}

				// Faces: one quad per cell, [4, v00, v10, v11, v01].
				var faces = new int[proxyRes * proxyRes * 5];
				int fi = 0;
				for ( int gy = 0; gy < proxyRes; gy++ )
				{
					int row0 = gy * vertsPerSide;
					int row1 = (gy + 1) * vertsPerSide;
					for ( int gx = 0; gx < proxyRes; gx++ )
					{
						faces[fi++] = 4;
						faces[fi++] = row0 + gx;
						faces[fi++] = row0 + gx + 1;
						faces[fi++] = row1 + gx + 1;
						faces[fi++] = row1 + gx;
					}
				}

				return new ExtractedMesh { Vertices = verts, Faces = faces };
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogError( $"ExtractTerrainProxyMesh failed: {ex.Message}" );
				return null;
			}
		}

		/// <summary>
		/// Pulls mesh data out of either a MeshComponent or (as a downsampled
		/// proxy) a Terrain. Used by the payload builders so terrain shows up
		/// in Blender alongside regular meshes.
		/// </summary>
		private static object TryBuildMeshData( GameObject go )
		{
			var meshComp = go.Components.Get<MeshComponent>();
			if ( meshComp?.Mesh != null )
			{
				var extracted = ExtractMeshData( meshComp.Mesh );
				if ( extracted != null )
					return new { vertices = extracted.Value.Vertices, faces = extracted.Value.Faces };
			}

			var terrain = go.Components.Get<Terrain>();
			if ( terrain?.Storage != null )
			{
				var extracted = ExtractTerrainProxyMesh( terrain );
				if ( extracted != null )
					return new { vertices = extracted.Value.Vertices, faces = extracted.Value.Faces };
			}

			return null;
		}

		// ── Payload builders ──────────────────────────────────────────────────

		/// <summary>Public access to BuildObjectPayload for the editor window's Send to Blender button.</summary>
		internal static object BuildObjectPayloadPublic( string bridgeId, GameObject go )
		{
			return BuildObjectPayload( bridgeId, go );
		}

		/// <summary>Build a complete object_created message with type field for broadcasting.</summary>
		internal static object BuildObjectCreatedMessage( string bridgeId, GameObject go )
		{
			var pos = go.WorldPosition;
			var rot = go.WorldRotation.Angles();
			var meshData = TryBuildMeshData( go );

			return new
			{
				type = "object_created",
				bridgeId,
				name = go.Name,
				position = new { x = pos.x, y = pos.y, z = pos.z },
				rotation = new { pitch = rot.pitch, yaw = rot.yaw, roll = rot.roll },
				meshData
			};
		}

		private static object BuildObjectPayload( string bridgeId, GameObject go )
		{
			var pos = go.WorldPosition;
			var rot = go.WorldRotation.Angles();
			var meshData = TryBuildMeshData( go );

			// Build hierarchy path from parent chain (excluding bridge group root)
			var hierarchy = new List<string>();
			var parent = go.Parent;
			while ( parent != null && !parent.Tags.Has( "bridge_group" ) )
			{
				hierarchy.Insert( 0, parent.Name );
				parent = parent.Parent;
			}

			return new
			{
				bridgeId,
				name = go.Name,
				position = new { x = pos.x, y = pos.y, z = pos.z },
				rotation = new { pitch = rot.pitch, yaw = rot.yaw, roll = rot.roll },
				meshData,
				hierarchy
			};
		}

		private static object BuildLightPayload( GameObject go, Light light )
		{
			var pos = go.WorldPosition;
			var rot = go.WorldRotation.Angles();
			string lightType = "point";
			if ( light is SpotLight ) lightType = "spot";
			else if ( light is DirectionalLight ) lightType = "directional";

			float radius = 0f;
			float coneOuter = 0f;
			float coneInner = 0f;
			if ( light is PointLight pl ) radius = pl.Radius;
			if ( light is SpotLight spl ) { radius = spl.Radius; coneOuter = spl.ConeOuter; coneInner = spl.ConeInner; }

			object properties = new
			{
				color = new { r = light.LightColor.r, g = light.LightColor.g, b = light.LightColor.b },
				radius,
				coneOuter,
				coneInner,
			};

			return new
			{
				objectType = "light",
				lightType,
				name = go.Name,
				sceneId = go.Id.ToString(),
				position = new { x = pos.x, y = pos.y, z = pos.z },
				rotation = new { pitch = rot.pitch, yaw = rot.yaw, roll = rot.roll },
				properties
			};
		}

		private static object BuildModelPayload( GameObject go, Component modelComp )
		{
			var pos = go.WorldPosition;
			var rot = go.WorldRotation.Angles();
			var modelProp = modelComp.GetType().GetProperty( "Model" );
			var model = modelProp?.GetValue( modelComp ) as Model;
			var modelPath = model?.ResourcePath ?? "unknown";

			object bounds = null;
			if ( model != null )
			{
				try
				{
					bounds = new
					{
						mins = new { x = model.Bounds.Mins.x, y = model.Bounds.Mins.y, z = model.Bounds.Mins.z },
						maxs = new { x = model.Bounds.Maxs.x, y = model.Bounds.Maxs.y, z = model.Bounds.Maxs.z }
					};
				}
				catch { }
			}

			string fbxSourcePath = null;
			try
			{
				if ( modelPath != "unknown" )
					fbxSourcePath = ResolveFbxPath( modelPath );
			}
			catch { }

			return new
			{
				objectType = "model",
				name = go.Name,
				sceneId = go.Id.ToString(),
				modelPath,
				fbxSourcePath,
				bounds,
				position = new { x = pos.x, y = pos.y, z = pos.z },
				rotation = new { pitch = rot.pitch, yaw = rot.yaw, roll = rot.roll }
			};
		}

		private static string ResolveFbxPath( string modelPath )
		{
			try
			{
				var assetsDir = GetProjectAssetsDir();
				if ( assetsDir == null ) return null;
				var projectRoot = System.IO.Path.GetDirectoryName( assetsDir );
				var basePaths = new List<string> { assetsDir, projectRoot };
				var relativePath = modelPath.Replace( "\\", "/" );
				foreach ( var prefix in new[] { "assets/", "models/" } )
					if ( relativePath.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) )
					{ relativePath = relativePath.Substring( prefix.Length ); break; }

				string vmdlPath = null;
				string vmdlDir = null;
				foreach ( var basePath in basePaths )
				{
					if ( string.IsNullOrEmpty( basePath ) ) continue;
					var candidate = System.IO.Path.Combine( basePath, relativePath );
					if ( System.IO.File.Exists( candidate ) ) { vmdlPath = candidate; vmdlDir = System.IO.Path.GetDirectoryName( candidate ); break; }
					candidate = System.IO.Path.Combine( basePath, modelPath.Replace( "/", "\\" ) );
					if ( System.IO.File.Exists( candidate ) ) { vmdlPath = candidate; vmdlDir = System.IO.Path.GetDirectoryName( candidate ); break; }
				}
				if ( vmdlPath == null ) return null;

				var content = System.IO.File.ReadAllText( vmdlPath );
				var match = System.Text.RegularExpressions.Regex.Match( content,
					@"_class\s*=\s*""RenderMeshFile""[\s\S]*?filename\s*=\s*""([^""]+)""" );
				if ( !match.Success ) return null;

				var fbxRelative = match.Groups[1].Value.Replace( "\\", "/" );
				var fbxPath = System.IO.Path.GetFullPath( System.IO.Path.Combine( vmdlDir, fbxRelative ) );
				if ( System.IO.File.Exists( fbxPath ) ) return fbxPath;
				foreach ( var basePath in basePaths )
				{
					if ( string.IsNullOrEmpty( basePath ) ) continue;
					fbxPath = System.IO.Path.GetFullPath( System.IO.Path.Combine( basePath, fbxRelative ) );
					if ( System.IO.File.Exists( fbxPath ) ) return fbxPath;
				}
				return null;
			}
			catch { return null; }
		}

		// ── Helpers ───────────────────────────────────────────────────────────

		private static void ApplyTransform( GameObject go, JsonElement root )
		{
			if ( root.TryGetProperty( "position", out var pos ) )
			{
				go.WorldPosition = new Vector3(
					GetFloat( pos, "x", 0f ),
					GetFloat( pos, "y", 0f ),
					GetFloat( pos, "z", 0f )
				);
			}
			if ( root.TryGetProperty( "rotation", out var rot ) )
			{
				go.WorldRotation = Rotation.From(
					GetFloat( rot, "pitch", 0f ),
					GetFloat( rot, "yaw", 0f ),
					GetFloat( rot, "roll", 0f )
				);
			}
		}

		private static string GetString( JsonElement el, string prop, string fallback = null )
		{
			return el.TryGetProperty( prop, out var v ) && v.ValueKind == JsonValueKind.String ? v.GetString() : fallback;
		}

		private static float GetFloat( JsonElement el, string prop, float fallback )
		{
			if ( el.TryGetProperty( prop, out var v ) && v.ValueKind == JsonValueKind.Number ) return v.GetSingle();
			return fallback;
		}

		private static int GetInt( JsonElement el, string prop, int fallback )
		{
			if ( el.TryGetProperty( prop, out var v ) && v.ValueKind == JsonValueKind.Number ) return v.GetInt32();
			return fallback;
		}

		internal static string GetProjectAssetsDir()
		{
			try
			{
				var session = SceneEditorSession.Active;
				if ( session?.Scene != null )
				{
					var scenePath = session.Scene.Source?.ResourcePath;
					if ( !string.IsNullOrEmpty( scenePath ) )
					{
						var fullScenePath = Sandbox.FileSystem.Mounted.GetFullPath( scenePath );
						if ( !string.IsNullOrEmpty( fullScenePath ) )
						{
							var dir = System.IO.Path.GetDirectoryName( fullScenePath );
							while ( dir != null )
							{
								var candidate = System.IO.Path.Combine( dir, "Assets" );
								if ( System.IO.Directory.Exists( candidate ) ) return candidate;
								dir = System.IO.Path.GetDirectoryName( dir );
							}
						}
					}
				}
			}
			catch { }

			try
			{
				var sboxProjectsDir = System.IO.Path.Combine(
					System.Environment.GetFolderPath( System.Environment.SpecialFolder.MyDocuments ), "s&box projects" );
				if ( System.IO.Directory.Exists( sboxProjectsDir ) )
				{
					foreach ( var projDir in System.IO.Directory.GetDirectories( sboxProjectsDir ) )
					{
						var libDir = System.IO.Path.Combine( projDir, "Libraries", "ozmium.oz_mcp" );
						if ( System.IO.Directory.Exists( libDir ) )
						{
							var assetsCandidate = System.IO.Path.Combine( projDir, "Assets" );
							if ( System.IO.Directory.Exists( assetsCandidate ) ) return assetsCandidate;
						}
					}
				}
			}
			catch { }

			return null;
		}

		private static Material LoadMaterialSafe( string path )
		{
			if ( string.IsNullOrEmpty( path ) ) return null;
			try { return Material.Load( path ); }
			catch { return null; }
		}
	}
}