// Split from BlueprintHandlers.cpp to keep that file under 3k lines.
// All functions below are still members of FBlueprintHandlers - this file is a
// translation-unit partition, not a new class. Handler registration
// stays in BlueprintHandlers.cpp::RegisterHandlers.

#include "BlueprintHandlers.h"
#include "BlueprintHandlers_Internal.h"
#include "HandlerRegistry.h"
#include "HandlerUtils.h"
#include "HandlerJsonProperty.h"
#include "HandlerPropertyText.h"
#include "JsonSerializer.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "BlueprintEditorLibrary.h"
#include "Engine/Blueprint.h"
#include "Engine/SimpleConstructionScript.h"
#include "Engine/SCS_Node.h"
#include "Engine/InheritableComponentHandler.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "K2Node.h"
#include "K2Node_FunctionEntry.h"
#include "K2Node_EditablePinBase.h"
#include "UObject/UnrealType.h"
#include "UObject/UObjectGlobals.h"
#include "UObject/Package.h"
#include "UObject/TopLevelAssetPath.h"
#include "Components/ActorComponent.h"
#include "Components/PrimitiveComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "Components/MeshComponent.h"
#include "Materials/MaterialInterface.h"
#include "Engine/TimelineTemplate.h"
#include "Curves/CurveFloat.h"
#include "Curves/CurveVector.h"
#include "Curves/CurveLinearColor.h"
#include "Components/CapsuleComponent.h"
#include "GameFramework/Actor.h"
#include "Engine/StaticMesh.h"
#include "Engine/SkeletalMesh.h"
#include "EditorAssetLibrary.h"
#include "Editor.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"


TSharedPtr<FJsonValue> FBlueprintHandlers::SetVariableProperties(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;

	FString VarName;
	if (auto Err = RequireString(Params, TEXT("name"), VarName)) return Err;

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint)
	{
		return BlueprintNotFoundError(AssetPath);
	}

	// Find the variable
	FBPVariableDescription* FoundVar = nullptr;
	for (FBPVariableDescription& Var : Blueprint->NewVariables)
	{
		if (Var.VarName.ToString() == VarName)
		{
			FoundVar = &Var;
			break;
		}
	}

	if (!FoundVar)
	{
		return MCPError(FString::Printf(TEXT("Variable not found: %s"), *VarName));
	}

	// Capture previous values for rollback
	// Must match list_variables' definition (#744): CPF_Edit alone is also set
	// by EditDefaultsOnly, so the distinguishing flag is CPF_DisableEditOnInstance.
	// MD_Private is a Blueprint-GRAPH access flag - it hides the variable from
	// other Blueprints' graphs, not from a placed instance's details panel - so
	// it is deliberately NOT folded in here. Doing so made the no-op check
	// report "already not instance editable" for a variable that still was.
	const bool bPrevInstanceEditable =
		(FoundVar->PropertyFlags & CPF_Edit) != 0 &&
		(FoundVar->PropertyFlags & CPF_DisableEditOnInstance) == 0;
	FString PrevCategory;
	if (FoundVar->HasMetaData(FBlueprintMetadata::MD_FunctionCategory))
	{
		PrevCategory = FoundVar->GetMetaData(FBlueprintMetadata::MD_FunctionCategory);
	}
	FString PrevTooltip;
	if (FoundVar->HasMetaData(FBlueprintMetadata::MD_Tooltip))
	{
		PrevTooltip = FoundVar->GetMetaData(FBlueprintMetadata::MD_Tooltip);
	}

	// Read every request and capture every previous value FIRST. Nothing below
	// this point writes until the no-op check has run.
	bool bExposeOnSpawn = false;
	const bool bHasExposeOnSpawn = Params->TryGetBoolField(TEXT("exposeOnSpawn"), bExposeOnSpawn);
	// Compare the effective state, not merely the presence of the metadata key:
	// the key can exist with value "false", or exist while CPF_ExposeOnSpawn is
	// clear, and treating that as "already true" hid a real change.
	const bool bPrevExposeOnSpawn =
		(FoundVar->PropertyFlags & CPF_ExposeOnSpawn) != 0 ||
		(FoundVar->HasMetaData(FBlueprintMetadata::MD_ExposeOnSpawn) &&
		 FoundVar->GetMetaData(FBlueprintMetadata::MD_ExposeOnSpawn).ToBool());

	bool bInstanceEditable = false;
	const bool bHasInstanceEditable = Params->TryGetBoolField(TEXT("instanceEditable"), bInstanceEditable);
	const bool bWasEditableAtAll = (FoundVar->PropertyFlags & CPF_Edit) != 0;
	// Read the VALUE, not just presence - the engine's own readers use it, so a
	// key present with "false" is NOT private. GetMetaData asserts outright
	// when the key is absent (FBPVariableDescription::GetMetaData ->
	// check(EntryIndex != INDEX_NONE)), so it must stay behind HasMetaData.
	const bool bWasPrivate =
		FoundVar->HasMetaData(FBlueprintMetadata::MD_Private) &&
		FoundVar->GetMetaData(FBlueprintMetadata::MD_Private).ToBool();

	// `instanceEditable` is a boolean over a three-state setting, so it cannot
	// express (and therefore cannot restore) every state a variable can be in:
	// a variable that was EditAnywhere AND private, or one that was not exposed
	// at all, both round-trip wrong. `editFlag` mirrors exactly what
	// list_variables reports, which makes read -> write -> read closed, and
	// makes the rollback below able to name the state it is restoring.
	const FString PrevEditFlag =
		!bWasEditableAtAll                                            ? TEXT("none") :
		(FoundVar->PropertyFlags & CPF_DisableEditOnInstance) != 0    ? TEXT("EditDefaultsOnly") :
		(FoundVar->PropertyFlags & CPF_DisableEditOnTemplate) != 0    ? TEXT("EditInstanceOnly") :
		                                                                TEXT("EditAnywhere");
	// MD_Private is orthogonal to the edit flags - the Blueprint editor's
	// "Private" checkbox does not clear EditAnywhere - so it is its own param
	// rather than a fifth enum value. Folding it in would lose a bit, which is
	// exactly what made the old rollback unable to restore the original state.
	bool bPrivate = false;
	const bool bHasPrivate = Params->TryGetBoolField(TEXT("private"), bPrivate);
	FString EditFlag = OptionalString(Params, TEXT("editFlag"));
	if (!EditFlag.IsEmpty())
	{
		const FString L = EditFlag.ToLower();
		if      (L == TEXT("editanywhere"))      EditFlag = TEXT("EditAnywhere");
		else if (L == TEXT("editdefaultsonly"))  EditFlag = TEXT("EditDefaultsOnly");
		else if (L == TEXT("editinstanceonly"))  EditFlag = TEXT("EditInstanceOnly");
		else if (L == TEXT("none"))              EditFlag = TEXT("none");
		else
		{
			return MCPError(FString::Printf(
				TEXT("Unknown editFlag '%s'. Expected EditAnywhere, EditDefaultsOnly, EditInstanceOnly or none (the values list_variables reports)."),
				*EditFlag));
		}
	}
	const bool bHasEditFlag = !EditFlag.IsEmpty();
	if (bHasEditFlag && bHasInstanceEditable)
	{
		return MCPError(TEXT("Pass editFlag or instanceEditable, not both - instanceEditable is the two-state shorthand for editFlag."));
	}

	FString CategoryStr;
	const bool bHasCategory = Params->TryGetStringField(TEXT("category"), CategoryStr);
	FString TooltipStr;
	const bool bHasTooltip = Params->TryGetStringField(TEXT("tooltip"), TooltipStr);

	// Detect no-op BEFORE writing anything. Previously every branch above had
	// already mutated PropertyFlags by the time this ran, so a "nothing
	// changed" return still left the in-memory Blueprint altered and never
	// compiled or saved - divergence from disk with no record of it.
	const bool bAnyChanged =
		(bHasExposeOnSpawn && bExposeOnSpawn != bPrevExposeOnSpawn) ||
		(bHasInstanceEditable && bInstanceEditable != bPrevInstanceEditable) ||
		(bHasEditFlag && EditFlag != PrevEditFlag) ||
		(bHasPrivate && bPrivate != bWasPrivate) ||
		(bHasCategory && CategoryStr != PrevCategory) ||
		(bHasTooltip && TooltipStr != PrevTooltip);
	if (!bAnyChanged)
	{
		auto Noop = MCPSuccess();
		MCPSetExisted(Noop);
		Noop->SetStringField(TEXT("path"), AssetPath);
		Noop->SetStringField(TEXT("name"), VarName);
		return MCPResult(Noop);
	}

	if (bHasExposeOnSpawn)
	{
		if (bExposeOnSpawn)
		{
			FoundVar->SetMetaData(FBlueprintMetadata::MD_ExposeOnSpawn, TEXT("true"));
			FoundVar->PropertyFlags |= CPF_ExposeOnSpawn;
		}
		else
		{
			FoundVar->RemoveMetaData(FBlueprintMetadata::MD_ExposeOnSpawn);
			FoundVar->PropertyFlags &= ~CPF_ExposeOnSpawn;
		}
	}
	if (bHasCategory)
	{
		FoundVar->SetMetaData(FBlueprintMetadata::MD_FunctionCategory, *CategoryStr);
	}
	if (bHasTooltip)
	{
		FoundVar->SetMetaData(FBlueprintMetadata::MD_Tooltip, *TooltipStr);
	}
	if (bHasEditFlag)
	{
		// Write the flag trio directly. Every state is reachable from every
		// other, which is what makes the rollback exact. MD_Private is left
		// alone - it is the `private` param's business, not this one's.
		FoundVar->PropertyFlags &= ~(CPF_Edit | CPF_DisableEditOnInstance | CPF_DisableEditOnTemplate);
		if (EditFlag == TEXT("EditAnywhere"))
		{
			FoundVar->PropertyFlags |= CPF_Edit;
		}
		else if (EditFlag == TEXT("EditDefaultsOnly"))
		{
			FoundVar->PropertyFlags |= CPF_Edit | CPF_DisableEditOnInstance;
		}
		else if (EditFlag == TEXT("EditInstanceOnly"))
		{
			FoundVar->PropertyFlags |= CPF_Edit | CPF_DisableEditOnTemplate;
		}
		// "none" leaves every edit flag clear: the variable is not exposed on
		// the details panel at all.
	}
	// instanceEditable runs BEFORE private, and no longer touches MD_Private at
	// all: it used to clear it as a side effect, so (instanceEditable=true,
	// private=true) wrote private and then silently undid it in the next block.
	// The two settings are orthogonal and are now applied as such.
	if (bHasInstanceEditable)
	{
		if (bInstanceEditable)
		{
			// Clear BOTH disable flags. OR-ing DisableEditOnInstance on top of
			// an existing DisableEditOnTemplate produced a variable hidden from
			// the class-defaults AND the instance panel, which no editFlag
			// value can describe and which list_variables mis-reported as
			// EditDefaultsOnly.
			FoundVar->PropertyFlags |= CPF_Edit;
			FoundVar->PropertyFlags &= ~(CPF_DisableEditOnInstance | CPF_DisableEditOnTemplate);
		}
		else if (bWasEditableAtAll)
		{
			// "Not instance editable" on an editable variable means
			// EditDefaultsOnly. Only do this when it was already editable -
			// otherwise a non-editable variable would be silently PROMOTED into
			// the class-defaults panel, and the rollback would re-apply the
			// promotion rather than undo it.
			FoundVar->PropertyFlags &= ~CPF_DisableEditOnTemplate;
			FoundVar->PropertyFlags |= CPF_DisableEditOnInstance;
		}
	}
	if (bHasPrivate)
	{
		if (bPrivate) FoundVar->SetMetaData(FBlueprintMetadata::MD_Private, TEXT("true"));
		else          FoundVar->RemoveMetaData(FBlueprintMetadata::MD_Private);
	}

	// Compile and save
	FKismetEditorUtilities::CompileBlueprint(Blueprint);
	SaveAssetPackage(Blueprint);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetStringField(TEXT("variableName"), VarName);

	// Rollback: call set_variable_properties with previous values
	TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
	Payload->SetStringField(TEXT("path"), AssetPath);
	Payload->SetStringField(TEXT("name"), VarName);
	if (bHasExposeOnSpawn) Payload->SetBoolField(TEXT("exposeOnSpawn"), bPrevExposeOnSpawn);
	// Always restore the edit state via editFlag, never via instanceEditable:
	// the boolean cannot express "was not exposed at all", so replaying it left
	// the variable promoted into the class-defaults panel. private is restored
	// independently because the two are orthogonal.
	if (bHasInstanceEditable || bHasEditFlag)
	{
		Payload->SetStringField(TEXT("editFlag"), PrevEditFlag);
	}
	if (bHasPrivate)
	{
		Payload->SetBoolField(TEXT("private"), bWasPrivate);
	}
	if (bHasCategory) Payload->SetStringField(TEXT("category"), PrevCategory);
	if (bHasTooltip) Payload->SetStringField(TEXT("tooltip"), PrevTooltip);
	MCPSetRollback(Result, TEXT("set_variable_properties"), Payload);

	return MCPResult(Result);
}

TSharedPtr<FJsonValue> FBlueprintHandlers::SetComponentProperty(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;

	FString ComponentName;
	if (auto Err = RequireString(Params, TEXT("componentName"), ComponentName)) return Err;

	FString PropertyName;
	if (auto Err = RequireString(Params, TEXT("propertyName"), PropertyName)) return Err;

	// #152: accept any JSON value type - scalars, numbers, booleans, or structured
	// objects like {x,y,z} for FVector. Previous impl only accepted strings, so
	// RelativeLocation etc. couldn't be set without pre-formatting "(X=1,Y=2,Z=3)".
	TSharedPtr<FJsonValue> ValueField = Params->TryGetField(TEXT("value"));
	if (!ValueField.IsValid())
	{
		return MCPError(TEXT("Missing 'value' parameter"));
	}

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint)
	{
		return BlueprintNotFoundError(AssetPath);
	}

	bool bIsInherited = false;
	TArray<FString> Available;
	UActorComponent* Template = ResolveComponentTemplate(
		Blueprint, ComponentName, /*bForWrite=*/true, bIsInherited, Available);

	if (!Template)
	{
		return MCPError(FString::Printf(
			TEXT("Component '%s' not found. Available: [%s]"),
			*ComponentName, *FString::Join(Available, TEXT(", "))));
	}

	// #680: writing SkeletalMeshAsset via raw reflection leaves the base
	// USkinnedMeshComponent::SkinnedAsset pointer untouched, so the template
	// renders nothing at runtime. Route mesh assignment through the native
	// setter (SetSkeletalMeshAsset) which updates SkinnedAsset + the animation
	// instance. Only the single-segment property name is special-cased.
	if (!PropertyName.Contains(TEXT(".")))
	{
		const bool bIsMeshProp =
			PropertyName.Equals(TEXT("SkeletalMesh"), ESearchCase::IgnoreCase) ||
			PropertyName.Equals(TEXT("SkeletalMeshAsset"), ESearchCase::IgnoreCase) ||
			PropertyName.Equals(TEXT("SkinnedAsset"), ESearchCase::IgnoreCase);
		if (bIsMeshProp)
		{
			if (USkeletalMeshComponent* SkelComp = Cast<USkeletalMeshComponent>(Template))
			{
				FString MeshPath;
				if (!ValueField->TryGetString(MeshPath))
				{
					const TSharedPtr<FJsonObject>* VObj = nullptr;
					if (ValueField->TryGetObject(VObj) && VObj)
					{
						(*VObj)->TryGetStringField(TEXT("path"), MeshPath);
						if (MeshPath.IsEmpty()) (*VObj)->TryGetStringField(TEXT("assetPath"), MeshPath);
					}
				}
				USkeletalMesh* NewMesh = nullptr;
				if (!MeshPath.IsEmpty() && MeshPath != TEXT("None"))
				{
					NewMesh = LoadAssetByPath<USkeletalMesh>(MeshPath);
					if (!NewMesh)
					{
						return MCPError(FString::Printf(TEXT("SkeletalMesh not found: %s"), *MeshPath));
					}
				}
				SkelComp->Modify();
				SkelComp->SetSkeletalMeshAsset(NewMesh);
				SkelComp->PostEditChange();
				FKismetEditorUtilities::CompileBlueprint(Blueprint);
				SaveAssetPackage(Blueprint);

				auto Result = MCPSuccess();
				MCPSetUpdated(Result);
				Result->SetStringField(TEXT("path"), AssetPath);
				Result->SetStringField(TEXT("componentName"), ComponentName);
				Result->SetStringField(TEXT("propertyName"), PropertyName);
				Result->SetStringField(TEXT("value"), NewMesh ? NewMesh->GetPathName() : TEXT("None"));
				Result->SetStringField(TEXT("skinnedAsset"), SkelComp->GetSkinnedAsset() ? SkelComp->GetSkinnedAsset()->GetPathName() : TEXT("None"));
				Result->SetStringField(TEXT("note"), TEXT("Routed through SetSkeletalMeshAsset so SkinnedAsset is updated (#680)"));
				return MCPResult(Result);
			}
		}
	}

	// Walk dotted path to the final property. The helper from HandlerJsonProperty.h
	// does the assignment (handles FVector objects, object refs, arrays, etc.);
	// here we duplicate the walk once so we can also capture PrevValue for rollback.
	TArray<FString> PathParts;
	PropertyName.ParseIntoArray(PathParts, TEXT("."));

	UStruct* CurrentStruct = Template->GetClass();
	void* CurrentContainer = Template;
	FProperty* FinalProp = nullptr;

	for (int32 i = 0; i < PathParts.Num(); i++)
	{
		FProperty* Prop = CurrentStruct->FindPropertyByName(FName(*PathParts[i]));
		if (!Prop) break;

		if (i < PathParts.Num() - 1)
		{
			FStructProperty* StructProp = CastField<FStructProperty>(Prop);
			if (!StructProp) break;
			CurrentContainer = StructProp->ContainerPtrToValuePtr<void>(CurrentContainer);
			CurrentStruct = StructProp->Struct;
		}
		else
		{
			FinalProp = Prop;
		}
	}

	if (!FinalProp)
	{
		TArray<FString> PropNames;
		for (TFieldIterator<FProperty> It(Template->GetClass()); It; ++It)
		{
			PropNames.Add(It->GetName());
		}
		return MCPError(FString::Printf(
			TEXT("Property '%s' not found on %s. Properties: [%s]"),
			*PropertyName, *Template->GetClass()->GetName(),
			*FString::Join(PropNames, TEXT(", "))));
	}

	void* ValuePtr = FinalProp->ContainerPtrToValuePtr<void>(CurrentContainer);

	// Capture previous value for rollback via ExportText (always a string).
	FString PrevValue;
	FinalProp->ExportText_Direct(PrevValue, ValuePtr, ValuePtr, nullptr, PPF_None);

	// #820: export text cannot carry a struct-keyed TMap back, so a rollback
	// built from it would replay as an empty map. Keep the structured value for
	// map-bearing properties and roll back with that instead.
	const bool bMapBearing = MCPPropertyText::ContainsMap(FinalProp);
	TSharedPtr<FJsonValue> PrevStructured;
	if (bMapBearing)
	{
		PrevStructured = FMCPJsonSerializer::SerializeValue(ValuePtr, FinalProp);
	}

	Template->Modify();
	FString SetErr;
	if (!MCPJsonProperty::SetJsonOnProperty(FinalProp, ValuePtr, ValueField, SetErr))
	{
		return MCPError(FString::Printf(TEXT("Failed to set '%s': %s"), *PropertyName, *SetErr));
	}

	// Re-export for the rollback payload and no-op detection.
	FString NewValue;
	FinalProp->ExportText_Direct(NewValue, ValuePtr, ValuePtr, nullptr, PPF_None);
	if (NewValue == PrevValue)
	{
		auto Noop = MCPSuccess();
		MCPSetExisted(Noop);
		Noop->SetStringField(TEXT("path"), AssetPath);
		Noop->SetStringField(TEXT("componentName"), ComponentName);
		Noop->SetStringField(TEXT("propertyName"), PropertyName);
		Noop->SetStringField(TEXT("value"), NewValue);
		return MCPResult(Noop);
	}

	Template->PostEditChange();

	// Compile and save
	FKismetEditorUtilities::CompileBlueprint(Blueprint);
	SaveAssetPackage(Blueprint);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetStringField(TEXT("componentName"), ComponentName);
	Result->SetStringField(TEXT("propertyName"), PropertyName);
	Result->SetStringField(TEXT("value"), NewValue);
	Result->SetBoolField(TEXT("inherited"), bIsInherited);
	if (bMapBearing)
	{
		// #820: `value` is export text and cannot show a struct-keyed map
		// faithfully. Report what landed, and the structured form the setter
		// reads back.
		Result->SetNumberField(TEXT("mapPairCount"), MCPPropertyText::CountMapPairs(FinalProp, ValuePtr));
		Result->SetField(TEXT("valueJson"), FMCPJsonSerializer::SerializeValue(ValuePtr, FinalProp));
	}

	// Rollback: self-inverse with previous value
	TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
	Payload->SetStringField(TEXT("path"), AssetPath);
	Payload->SetStringField(TEXT("componentName"), ComponentName);
	Payload->SetStringField(TEXT("propertyName"), PropertyName);
	if (PrevStructured.IsValid())
	{
		Payload->SetField(TEXT("value"), PrevStructured);
	}
	else
	{
		Payload->SetStringField(TEXT("value"), PrevValue);
	}
	MCPSetRollback(Result, TEXT("set_component_property"), Payload);

	return MCPResult(Result);
}

// ---------------------------------------------------------------------------
// set_class_default -- Set a UPROPERTY on a Blueprint's Class Default Object
// Params: assetPath, propertyName, value
// ---------------------------------------------------------------------------


// ---------------------------------------------------------------------------
// set_class_default -- Set a UPROPERTY on a Blueprint's Class Default Object
// Params: assetPath, propertyName, value
// ---------------------------------------------------------------------------
TSharedPtr<FJsonValue> FBlueprintHandlers::SetClassDefault(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;

	FString PropertyName;
	if (auto Err = RequireString(Params, TEXT("propertyName"), PropertyName)) return Err;

	// Accept value as any JSON type (string, number, bool, object, array).
	// This enables setting TArray<FStruct> with nested UObject refs via JSON
	// instead of requiring arcane ImportText format strings (#196, #199).
	TSharedPtr<FJsonValue> ValueField = Params->TryGetField(TEXT("value"));
	if (!ValueField.IsValid())
	{
		return MCPError(TEXT("Missing 'value' parameter"));
	}

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint)
	{
		return BlueprintNotFoundError(AssetPath);
	}

	UClass* GenClass = Blueprint->GeneratedClass;
	if (!GenClass)
	{
		return MCPError(TEXT("Blueprint has no GeneratedClass (needs compilation first?)"));
	}

	UObject* CDO = GenClass->GetDefaultObject();
	if (!CDO)
	{
		return MCPError(TEXT("Could not get Class Default Object"));
	}

	// Navigate dotted property paths (e.g. "EjectConfigs.Cork.Force")
	TArray<FString> PathParts;
	PropertyName.ParseIntoArray(PathParts, TEXT("."));

	UStruct* CurrentStruct = GenClass;
	void* CurrentContainer = CDO;
	FProperty* FinalProp = nullptr;

	for (int32 i = 0; i < PathParts.Num(); i++)
	{
		FProperty* Prop = CurrentStruct->FindPropertyByName(FName(*PathParts[i]));
		if (!Prop) break;

		if (i < PathParts.Num() - 1)
		{
			FStructProperty* StructProp = CastField<FStructProperty>(Prop);
			if (!StructProp) break;
			CurrentContainer = StructProp->ContainerPtrToValuePtr<void>(CurrentContainer);
			CurrentStruct = StructProp->Struct;
		}
		else
		{
			FinalProp = Prop;
		}
	}

	if (!FinalProp)
	{
		TArray<FString> PropNames;
		for (TFieldIterator<FProperty> It(GenClass); It; ++It)
		{
			PropNames.Add(It->GetName());
		}
		return MCPError(FString::Printf(
			TEXT("Property '%s' not found on %s. Properties: [%s]"),
			*PropertyName, *GenClass->GetName(),
			*FString::Join(PropNames, TEXT(", "))));
	}

	void* ValuePtr = FinalProp->ContainerPtrToValuePtr<void>(CurrentContainer);

	// Capture previous value for rollback and idempotency
	FString PrevValue;
	FinalProp->ExportText_Direct(PrevValue, ValuePtr, ValuePtr, nullptr, PPF_None);

	CDO->Modify();
	FString SetErr;
	if (!MCPJsonProperty::SetJsonOnProperty(FinalProp, ValuePtr, ValueField, SetErr))
	{
		return MCPError(FString::Printf(TEXT("Failed to set '%s': %s"), *PropertyName, *SetErr));
	}

	// Re-export for rollback payload and no-op detection
	FString NewValue;
	FinalProp->ExportText_Direct(NewValue, ValuePtr, ValuePtr, nullptr, PPF_None);
	if (NewValue == PrevValue)
	{
		auto Noop = MCPSuccess();
		MCPSetExisted(Noop);
		Noop->SetStringField(TEXT("path"), AssetPath);
		Noop->SetStringField(TEXT("propertyName"), PropertyName);
		Noop->SetStringField(TEXT("value"), NewValue);
		return MCPResult(Noop);
	}

	CDO->PostEditChange();

	// Save
	SaveAssetPackage(Blueprint);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetStringField(TEXT("propertyName"), PropertyName);
	Result->SetStringField(TEXT("value"), NewValue);

	// Rollback: self-inverse with previous value
	TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
	Payload->SetStringField(TEXT("path"), AssetPath);
	Payload->SetStringField(TEXT("propertyName"), PropertyName);
	Payload->SetStringField(TEXT("value"), PrevValue);
	MCPSetRollback(Result, TEXT("set_class_default"), Payload);

	return MCPResult(Result);
}

// ---------------------------------------------------------------------------
// remove_component -- Remove an SCS component from a Blueprint
// Params: assetPath, componentName
// ---------------------------------------------------------------------------


// ---------------------------------------------------------------------------
// add_function_parameter -- Add an input or output parameter to a Blueprint function
// Params: assetPath, functionName, parameterName, parameterType, isOutput?
// ---------------------------------------------------------------------------
TSharedPtr<FJsonValue> FBlueprintHandlers::AddFunctionParameter(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;

	FString FunctionName;
	if (auto Err = RequireString(Params, TEXT("functionName"), FunctionName)) return Err;

	FString ParamName;
	if (auto Err = RequireString(Params, TEXT("parameterName"), ParamName)) return Err;

	FString ParamType = OptionalString(Params, TEXT("parameterType"), TEXT("Float"));

	bool bIsOutput = OptionalBool(Params, TEXT("isOutput"), false);

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint)
	{
		return BlueprintNotFoundError(AssetPath);
	}

	// Find the function graph
	UEdGraph* FuncGraph = nullptr;
	for (UEdGraph* Graph : Blueprint->FunctionGraphs)
	{
		if (Graph && Graph->GetName() == FunctionName)
		{
			FuncGraph = Graph;
			break;
		}
	}

	if (!FuncGraph)
	{
		return MCPError(FString::Printf(TEXT("Function not found: %s"), *FunctionName));
	}

	// Find the function entry node (K2Node_FunctionEntry) or result node
	UK2Node_FunctionEntry* EntryNode = nullptr;
	for (UEdGraphNode* Node : FuncGraph->Nodes)
	{
		if (UK2Node_FunctionEntry* Entry = Cast<UK2Node_FunctionEntry>(Node))
		{
			EntryNode = Entry;
			break;
		}
	}

	if (!EntryNode)
	{
		return MCPError(TEXT("Function entry node not found in graph"));
	}

	// Idempotency: check if parameter already exists
	const FName ParamFName(*ParamName);
	if (!bIsOutput)
	{
		for (const TSharedPtr<FUserPinInfo>& Info : EntryNode->UserDefinedPins)
		{
			if (Info.IsValid() && Info->PinName == ParamFName)
			{
				auto Existed = MCPSuccess();
				MCPSetExisted(Existed);
				Existed->SetStringField(TEXT("path"), AssetPath);
				Existed->SetStringField(TEXT("functionName"), FunctionName);
				Existed->SetStringField(TEXT("parameterName"), ParamName);
				Existed->SetBoolField(TEXT("isOutput"), false);
				return MCPResult(Existed);
			}
		}
	}
	else
	{
		for (UEdGraphNode* Node : FuncGraph->Nodes)
		{
			if (Node && Node->GetClass()->GetName() == TEXT("K2Node_FunctionResult"))
			{
				if (UK2Node_EditablePinBase* R = Cast<UK2Node_EditablePinBase>(Node))
				{
					for (const TSharedPtr<FUserPinInfo>& Info : R->UserDefinedPins)
					{
						if (Info.IsValid() && Info->PinName == ParamFName)
						{
							auto Existed = MCPSuccess();
							MCPSetExisted(Existed);
							Existed->SetStringField(TEXT("path"), AssetPath);
							Existed->SetStringField(TEXT("functionName"), FunctionName);
							Existed->SetStringField(TEXT("parameterName"), ParamName);
							Existed->SetBoolField(TEXT("isOutput"), true);
							return MCPResult(Existed);
						}
					}
				}
			}
		}
	}

	FEdGraphPinType PinType = MakePinType(ParamType);

	if (PinType.PinCategory == NAME_None)
	{
		return MCPError(FString::Printf(TEXT("Unrecognized parameter type: '%s'. Use a known type (Bool, Int, Float, String, Name, Text, Byte, Object, Vector, Rotator, Transform, GameplayTag, etc.) or a full class/struct path."), *ParamType));
	}

	if (bIsOutput)
	{
		// For output parameters, find or create the function result node
		UK2Node_FunctionEntry* ResultAsEntry = nullptr; // K2Node_FunctionResult also inherits UK2Node_EditablePinBase
		UK2Node_EditablePinBase* ResultNode = nullptr;
		for (UEdGraphNode* Node : FuncGraph->Nodes)
		{
			if (Node->GetClass()->GetName() == TEXT("K2Node_FunctionResult"))
			{
				ResultNode = Cast<UK2Node_EditablePinBase>(Node);
				break;
			}
		}

		if (!ResultNode)
		{
			// Create a result node
			UClass* ResultNodeClass = nullptr;
			for (TObjectIterator<UClass> It; It; ++It)
			{
				if (It->GetName() == TEXT("K2Node_FunctionResult") && It->IsChildOf(UEdGraphNode::StaticClass()))
				{
					ResultNodeClass = *It;
					break;
				}
			}
			if (ResultNodeClass)
			{
				UEdGraphNode* NewResultNode = NewObject<UEdGraphNode>(FuncGraph, ResultNodeClass);
				FuncGraph->AddNode(NewResultNode, false, false);
				NewResultNode->CreateNewGuid();
				NewResultNode->PostPlacedNewNode();
				NewResultNode->AllocateDefaultPins();
				ResultNode = Cast<UK2Node_EditablePinBase>(NewResultNode);
			}
		}

		if (ResultNode)
		{
			TSharedPtr<FUserPinInfo> PinInfo = MakeShared<FUserPinInfo>();
			PinInfo->PinName = FName(*ParamName);
			PinInfo->PinType = PinType;
			PinInfo->DesiredPinDirection = EGPD_Input;
			ResultNode->UserDefinedPins.Add(PinInfo);
			ResultNode->ReconstructNode();
		}
	}
	else
	{
		// Input parameter: add a user-defined pin to the function entry node
		TSharedPtr<FUserPinInfo> PinInfo = MakeShared<FUserPinInfo>();
		PinInfo->PinName = FName(*ParamName);
		PinInfo->PinType = PinType;
		PinInfo->DesiredPinDirection = EGPD_Output; // Entry outputs are function inputs
		EntryNode->UserDefinedPins.Add(PinInfo);
		EntryNode->ReconstructNode();
	}

	// Compile and save
	FKismetEditorUtilities::CompileBlueprint(Blueprint);
	SaveAssetPackage(Blueprint);

	auto Result = MCPSuccess();
	MCPSetCreated(Result);
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetStringField(TEXT("functionName"), FunctionName);
	Result->SetStringField(TEXT("parameterName"), ParamName);
	Result->SetStringField(TEXT("parameterType"), ParamType);
	Result->SetBoolField(TEXT("isOutput"), bIsOutput);

	// edit_graph_parameters op=remove is the paired remove this handler used to
	// say it did not have. It drops the user-defined pin from the same entry or
	// return node this call added it to, and reports alreadyRemoved on replay.
	TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
	Payload->SetStringField(TEXT("assetPath"), AssetPath);
	Payload->SetStringField(TEXT("functionName"), FunctionName);
	Payload->SetStringField(TEXT("op"), TEXT("remove"));
	Payload->SetStringField(TEXT("parameterName"), ParamName);
	Payload->SetBoolField(TEXT("isOutput"), bIsOutput);
	MCPSetRollback(Result, TEXT("edit_graph_parameters"), Payload);
	if (bIsOutput)
	{
		// The first output parameter on a function mints its return node. The
		// inverse drops the parameter, not the node it arrived with.
		Result->SetBoolField(TEXT("rollbackLossy"), true);
		Result->SetStringField(TEXT("rollbackNote"), TEXT(
			"Removing the parameter does not remove the function's return node. If this call was the one that minted "
			"it, an empty return node is left behind in the graph after the rollback."));
	}
	return MCPResult(Result);
}

// ---------------------------------------------------------------------------
// set_variable_default -- Set the default value of a Blueprint variable
// Bypasses CDO restrictions by setting via FBlueprintEditorUtils on the BP variable description
// Params: assetPath, name, value
// ---------------------------------------------------------------------------


// ---------------------------------------------------------------------------
// set_variable_default -- Set the default value of a Blueprint variable
// Bypasses CDO restrictions by setting via FBlueprintEditorUtils on the BP variable description
// Params: assetPath, name, value
// ---------------------------------------------------------------------------
TSharedPtr<FJsonValue> FBlueprintHandlers::SetVariableDefault(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;

	FString VarName;
	if (auto Err = RequireString(Params, TEXT("name"), VarName)) return Err;

	FString Value;
	if (auto Err = RequireString(Params, TEXT("value"), Value)) return Err;

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint)
	{
		return BlueprintNotFoundError(AssetPath);
	}

	// Find the variable description
	FBPVariableDescription* FoundVar = nullptr;
	for (FBPVariableDescription& Var : Blueprint->NewVariables)
	{
		if (Var.VarName.ToString() == VarName)
		{
			FoundVar = &Var;
			break;
		}
	}

	if (!FoundVar)
	{
		TArray<FString> Names;
		for (const FBPVariableDescription& Var : Blueprint->NewVariables)
		{
			Names.Add(Var.VarName.ToString());
		}
		return MCPError(FString::Printf(
			TEXT("Variable '%s' not found. Available: [%s]"),
			*VarName, *FString::Join(Names, TEXT(", "))));
	}

	// Capture previous value for rollback and idempotency
	const FString PrevValue = FoundVar->DefaultValue;
	if (PrevValue == Value)
	{
		auto Noop = MCPSuccess();
		MCPSetExisted(Noop);
		Noop->SetStringField(TEXT("path"), AssetPath);
		Noop->SetStringField(TEXT("variableName"), VarName);
		Noop->SetStringField(TEXT("value"), Value);
		return MCPResult(Noop);
	}

	// Set default value string on the variable description.
	// This is the text representation that the BP serialization system uses.
	FoundVar->DefaultValue = Value;

	// Also try to set it on the CDO property if possible (for immediate reflection)
	if (Blueprint->GeneratedClass)
	{
		UObject* CDO = Blueprint->GeneratedClass->GetDefaultObject();
		if (CDO)
		{
			FProperty* Prop = Blueprint->GeneratedClass->FindPropertyByName(FName(*VarName));
			if (Prop)
			{
				void* ValuePtr = Prop->ContainerPtrToValuePtr<void>(CDO);

				if (FClassProperty* ClassProp = CastField<FClassProperty>(Prop))
				{
					UClass* ClassVal = LoadObject<UClass>(nullptr, *Value);
					if (!ClassVal) ClassVal = FindClassByShortName(Value);
					if (ClassVal) ClassProp->SetObjectPropertyValue(ValuePtr, ClassVal);
				}
				else if (FObjectPropertyBase* ObjProp = CastField<FObjectPropertyBase>(Prop))
				{
					UObject* LoadedObj = LoadObject<UObject>(nullptr, *Value);
					if (LoadedObj) ObjProp->SetObjectPropertyValue(ValuePtr, LoadedObj);
				}
				else if (FArrayProperty* ArrayProp = CastField<FArrayProperty>(Prop))
				{
					// For arrays (including TArray<TSubclassOf<>>), use ImportText.
					// A parse failure here just means the CDO mirror did not take;
					// the authoritative string default set on FoundVar->DefaultValue
					// above still applies on the next compile, so we do not reject
					// the whole request, but the caller gets a warning.
					if (!Prop->ImportText_Direct(*Value, ValuePtr, CDO, PPF_None))
					{
						UE_LOG(LogTemp, Warning, TEXT("set_blueprint_variable_default_value: ImportText_Direct failed for array property '%s' value '%s' - default string was still written and will take effect on recompile."), *VarName, *Value);
					}
				}
				else
				{
					if (!Prop->ImportText_Direct(*Value, ValuePtr, CDO, PPF_None))
					{
						UE_LOG(LogTemp, Warning, TEXT("set_blueprint_variable_default_value: ImportText_Direct failed for property '%s' value '%s' - default string was still written and will take effect on recompile."), *VarName, *Value);
					}
				}

				CDO->PostEditChange();
			}
		}
	}

	// Compile and save
	FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint);
	FKismetEditorUtilities::CompileBlueprint(Blueprint);
	SaveAssetPackage(Blueprint);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetStringField(TEXT("variableName"), VarName);
	Result->SetStringField(TEXT("value"), Value);

	// Rollback: self-inverse with previous value
	TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
	Payload->SetStringField(TEXT("path"), AssetPath);
	Payload->SetStringField(TEXT("name"), VarName);
	Payload->SetStringField(TEXT("value"), PrevValue);
	MCPSetRollback(Result, TEXT("set_variable_default"), Payload);

	return MCPResult(Result);
}

// ===========================================================================
// v0.7.11 - Blueprint authoring depth
// ===========================================================================


// ─── #105 read_component_properties ─────────────────────────────────
TSharedPtr<FJsonValue> FBlueprintHandlers::ReadComponentProperties(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;

	FString ComponentName;
	if (auto Err = RequireString(Params, TEXT("componentName"), ComponentName)) return Err;

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint) return BlueprintNotFoundError(AssetPath);

	bool bIsInherited = false;
	TArray<FString> Available;
	UActorComponent* Template = ResolveComponentTemplate(
		Blueprint, ComponentName, /*bForWrite=*/false, bIsInherited, Available);
	if (!Template)
	{
		return MCPError(FString::Printf(
			TEXT("Component '%s' not found. Available: [%s]"),
			*ComponentName, *FString::Join(Available, TEXT(", "))));
	}

	auto Result = MCPSuccess();
	Result->SetStringField(TEXT("componentName"), Template->GetName());
	Result->SetStringField(TEXT("componentClass"), Template->GetClass()->GetName());
	Result->SetBoolField(TEXT("inherited"), bIsInherited);

	TArray<TSharedPtr<FJsonValue>> PropsArr;
	for (TFieldIterator<FProperty> It(Template->GetClass()); It; ++It)
	{
		FProperty* Prop = *It;
		if (!Prop) continue;

		TSharedPtr<FJsonObject> P = MakeShared<FJsonObject>();
		P->SetStringField(TEXT("name"), Prop->GetName());
		P->SetStringField(TEXT("type"), Prop->GetCPPType());

		FString ValueStr;
		const void* ValPtr = Prop->ContainerPtrToValuePtr<void>(Template);
		Prop->ExportText_Direct(ValueStr, ValPtr, ValPtr, Template, PPF_None);
		P->SetStringField(TEXT("value"), ValueStr);

		if (FArrayProperty* ArrProp = CastField<FArrayProperty>(Prop))
		{
			FScriptArrayHelper Helper(ArrProp, ValPtr);
			P->SetNumberField(TEXT("count"), Helper.Num());
			TArray<TSharedPtr<FJsonValue>> Elems;
			for (int32 i = 0; i < Helper.Num(); ++i)
			{
				FString ElemStr;
				ArrProp->Inner->ExportText_Direct(ElemStr, Helper.GetRawPtr(i), Helper.GetRawPtr(i), Template, PPF_None);
				Elems.Add(MakeShared<FJsonValueString>(ElemStr));
			}
			P->SetArrayField(TEXT("elements"), Elems);
		}
		PropsArr.Add(MakeShared<FJsonValueObject>(P));
	}
	Result->SetArrayField(TEXT("properties"), PropsArr);
	Result->SetNumberField(TEXT("propertyCount"), PropsArr.Num());
	return MCPResult(Result);
}


// ─── #116 set_actor_tick_settings ───────────────────────────────────
TSharedPtr<FJsonValue> FBlueprintHandlers::SetActorTickSettings(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint || !Blueprint->GeneratedClass) return MCPError(TEXT("Blueprint not found or not compiled"));
	AActor* CDO = Cast<AActor>(Blueprint->GeneratedClass->GetDefaultObject(true));
	if (!CDO) return MCPError(TEXT("Blueprint is not an Actor"));

	// The previous triple, which is both the idempotency answer and the exact
	// inverse: this handler writes all three fields every call, so replaying it
	// with these values restores exactly what was there.
	const bool bPrevCanEverTick = CDO->PrimaryActorTick.bCanEverTick;
	const bool bPrevStartWithTickEnabled = CDO->PrimaryActorTick.bStartWithTickEnabled;
	// float, not double: FTickFunction::TickInterval is a float, and holding the
	// previous value at wider precision is what made the equality test below
	// unable to recognise a value it had just written.
	const float PrevTickInterval = CDO->PrimaryActorTick.TickInterval;

	bool bCanEverTick = bPrevCanEverTick;
	bool bStartWithTickEnabled = bPrevStartWithTickEnabled;
	double TickInterval = PrevTickInterval;

	Params->TryGetBoolField(TEXT("bCanEverTick"), bCanEverTick);
	Params->TryGetBoolField(TEXT("bStartWithTickEnabled"), bStartWithTickEnabled);
	Params->TryGetNumberField(TEXT("TickInterval"), TickInterval);

	// The stored field is a float, so the comparison has to happen in float.
	// Comparing the caller's double 0.1 against a float-widened 0.1f is never
	// equal, which made `unchanged` false for every call that in fact wrote the
	// same value back.
	const float NewTickInterval = (float)TickInterval;
	const bool bNoChange =
		bCanEverTick == bPrevCanEverTick
		&& bStartWithTickEnabled == bPrevStartWithTickEnabled
		&& NewTickInterval == PrevTickInterval;

	// A no-op does not dirty the package and does not save it. Reporting
	// unchanged:true beside an unconditional MarkPackageDirty and SaveAsset was
	// the claim and the contradiction in the same result.
	if (!bNoChange)
	{
		CDO->PrimaryActorTick.bCanEverTick = bCanEverTick;
		CDO->PrimaryActorTick.bStartWithTickEnabled = bStartWithTickEnabled;
		CDO->PrimaryActorTick.TickInterval = NewTickInterval;

		Blueprint->MarkPackageDirty();
		UEditorAssetLibrary::SaveAsset(AssetPath);
	}

	auto Result = MCPSuccess();
	if (bNoChange) MCPSetExisted(Result); else MCPSetUpdated(Result);
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetBoolField(TEXT("bCanEverTick"), bCanEverTick);
	Result->SetBoolField(TEXT("bStartWithTickEnabled"), bStartWithTickEnabled);
	Result->SetNumberField(TEXT("TickInterval"), NewTickInterval);
	Result->SetBoolField(TEXT("previousCanEverTick"), bPrevCanEverTick);
	Result->SetBoolField(TEXT("previousStartWithTickEnabled"), bPrevStartWithTickEnabled);
	Result->SetNumberField(TEXT("previousTickInterval"), PrevTickInterval);
	Result->SetBoolField(TEXT("unchanged"), bNoChange);

	if (bNoChange)
	{
		Result->SetBoolField(TEXT("rollbackPossible"), false);
		Result->SetStringField(TEXT("rollbackNote"),
			TEXT("The tick settings already held these values, so nothing was written or saved and there is "
			     "nothing to undo."));
	}
	else
	{
		TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
		Payload->SetStringField(TEXT("path"), AssetPath);
		Payload->SetBoolField(TEXT("bCanEverTick"), bPrevCanEverTick);
		Payload->SetBoolField(TEXT("bStartWithTickEnabled"), bPrevStartWithTickEnabled);
		Payload->SetNumberField(TEXT("TickInterval"), PrevTickInterval);
		MCPSetRollback(Result, TEXT("set_actor_tick_settings"), Payload);
	}
	return MCPResult(Result);
}

// ─── #128 get_component_property - inherited-aware single-prop read ──
// Params: assetPath, componentName, propertyName
// Returns the effective default for the given child BP: the ICH override
// if one exists, otherwise the parent template value. Supports dotted
// property paths ("RelativeLocation.X").


// ─── #128 get_component_property - inherited-aware single-prop read ──
// Params: assetPath, componentName, propertyName
// Returns the effective default for the given child BP: the ICH override
// if one exists, otherwise the parent template value. Supports dotted
// property paths ("RelativeLocation.X").
TSharedPtr<FJsonValue> FBlueprintHandlers::GetComponentProperty(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;
	FString ComponentName;
	if (auto Err = RequireString(Params, TEXT("componentName"), ComponentName)) return Err;
	FString PropertyName;
	if (auto Err = RequireString(Params, TEXT("propertyName"), PropertyName)) return Err;

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint) return BlueprintNotFoundError(AssetPath);

	bool bIsInherited = false;
	TArray<FString> Available;
	UActorComponent* Template = ResolveComponentTemplate(
		Blueprint, ComponentName, /*bForWrite=*/false, bIsInherited, Available);

	if (!Template)
	{
		return MCPError(FString::Printf(
			TEXT("Component '%s' not found. Available: [%s]"),
			*ComponentName, *FString::Join(Available, TEXT(", "))));
	}

	TArray<FString> PathParts;
	PropertyName.ParseIntoArray(PathParts, TEXT("."));

	UStruct* CurrentStruct = Template->GetClass();
	void* CurrentContainer = Template;
	FProperty* FinalProp = nullptr;

	for (int32 i = 0; i < PathParts.Num(); ++i)
	{
		FProperty* Prop = CurrentStruct->FindPropertyByName(FName(*PathParts[i]));
		if (!Prop)
		{
			return MCPError(FString::Printf(
				TEXT("Property '%s' not found on %s"), *PathParts[i], *CurrentStruct->GetName()));
		}
		if (i < PathParts.Num() - 1)
		{
			FStructProperty* StructProp = CastField<FStructProperty>(Prop);
			if (!StructProp)
			{
				return MCPError(FString::Printf(TEXT("Not a struct: %s"), *PathParts[i]));
			}
			CurrentContainer = StructProp->ContainerPtrToValuePtr<void>(CurrentContainer);
			CurrentStruct = StructProp->Struct;
		}
		else
		{
			FinalProp = Prop;
		}
	}
	if (!FinalProp) return MCPError(TEXT("Property path unresolved"));

	FString ValueStr;
	const void* ValPtr = FinalProp->ContainerPtrToValuePtr<void>(CurrentContainer);
	FinalProp->ExportText_Direct(ValueStr, ValPtr, ValPtr, Template, PPF_None);

	auto Result = MCPSuccess();
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetStringField(TEXT("componentName"), ComponentName);
	Result->SetStringField(TEXT("propertyName"), PropertyName);
	Result->SetStringField(TEXT("type"), FinalProp->GetCPPType());
	Result->SetStringField(TEXT("value"), ValueStr);
	Result->SetBoolField(TEXT("inherited"), bIsInherited);
	Result->SetStringField(TEXT("templateClass"), Template->GetClass()->GetName());
	return MCPResult(Result);
}

TSharedPtr<FJsonValue> FBlueprintHandlers::SetCdoProperty(const TSharedPtr<FJsonObject>& Params)
{
	FString ClassName;
	if (auto Err = RequireString(Params, TEXT("className"), ClassName)) return Err;

	FString PropertyName;
	if (auto Err = RequireString(Params, TEXT("propertyName"), PropertyName)) return Err;

	// Accept value as any JSON type (string, number, bool, object, array).
	// Enables setting TArray<FStruct> with nested UObject refs via JSON (#196, #199).
	TSharedPtr<FJsonValue> ValueField = Params->TryGetField(TEXT("value"));
	if (!ValueField.IsValid())
	{
		return MCPError(TEXT("Missing 'value' parameter"));
	}

	// Resolve UClass: try full path first (e.g. "/Script/Engine.Actor"), then short name
	UClass* Class = LoadObject<UClass>(nullptr, *ClassName);
	if (!Class)
	{
		Class = FindClassByShortName(ClassName);
	}
	if (!Class)
	{
		return MCPError(FString::Printf(TEXT("Class not found: %s"), *ClassName));
	}

	UObject* CDO = Class->GetDefaultObject();
	if (!CDO)
	{
		return MCPError(FString::Printf(TEXT("Could not get CDO for class: %s"), *Class->GetName()));
	}

	// Navigate dotted property paths (e.g. "SomeStruct.Field")
	TArray<FString> PathParts;
	PropertyName.ParseIntoArray(PathParts, TEXT("."));

	UStruct* CurrentStruct = Class;
	void* CurrentContainer = CDO;
	FProperty* FinalProp = nullptr;

	for (int32 i = 0; i < PathParts.Num(); i++)
	{
		FProperty* Prop = CurrentStruct->FindPropertyByName(FName(*PathParts[i]));
		if (!Prop) break;

		if (i < PathParts.Num() - 1)
		{
			FStructProperty* StructProp = CastField<FStructProperty>(Prop);
			if (!StructProp) break;
			CurrentContainer = StructProp->ContainerPtrToValuePtr<void>(CurrentContainer);
			CurrentStruct = StructProp->Struct;
		}
		else
		{
			FinalProp = Prop;
		}
	}

	if (!FinalProp)
	{
		TArray<FString> PropNames;
		for (TFieldIterator<FProperty> It(Class); It; ++It)
		{
			PropNames.Add(It->GetName());
		}
		return MCPError(FString::Printf(
			TEXT("Property '%s' not found on %s. Properties: [%s]"),
			*PropertyName, *Class->GetName(),
			*FString::Join(PropNames, TEXT(", "))));
	}

	void* ValuePtr = FinalProp->ContainerPtrToValuePtr<void>(CurrentContainer);

	// Capture previous value for rollback / idempotency
	FString PrevValue;
	FinalProp->ExportText_Direct(PrevValue, ValuePtr, ValuePtr, nullptr, PPF_None);

	CDO->Modify();
	FString SetErr;
	if (!MCPJsonProperty::SetJsonOnProperty(FinalProp, ValuePtr, ValueField, SetErr))
	{
		return MCPError(FString::Printf(TEXT("Failed to set '%s': %s"), *PropertyName, *SetErr));
	}

	// Re-export for rollback payload and no-op detection
	FString NewValue;
	FinalProp->ExportText_Direct(NewValue, ValuePtr, ValuePtr, nullptr, PPF_None);
	if (NewValue == PrevValue)
	{
		auto Noop = MCPSuccess();
		MCPSetExisted(Noop);
		Noop->SetStringField(TEXT("className"), ClassName);
		Noop->SetStringField(TEXT("propertyName"), PropertyName);
		Noop->SetStringField(TEXT("value"), NewValue);
		return MCPResult(Noop);
	}

	CDO->PostEditChange();

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("className"), ClassName);
	Result->SetStringField(TEXT("propertyName"), PropertyName);
	Result->SetStringField(TEXT("value"), NewValue);

	// Rollback: restore the previous value
	TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
	Payload->SetStringField(TEXT("className"), ClassName);
	Payload->SetStringField(TEXT("propertyName"), PropertyName);
	Payload->SetStringField(TEXT("value"), PrevValue);
	MCPSetRollback(Result, TEXT("set_cdo_property"), Payload);

	return MCPResult(Result);
}

// ---------------------------------------------------------------------------
// get_cdo_properties -- Read properties from any C++ class CDO
// Params: className (required), propertyNames (optional array of strings)
// Issue #183
// ---------------------------------------------------------------------------


// ---------------------------------------------------------------------------
// get_cdo_properties -- Read properties from any C++ class CDO
// Params: className (required), propertyNames (optional array of strings)
// Issue #183
// ---------------------------------------------------------------------------
TSharedPtr<FJsonValue> FBlueprintHandlers::GetCdoProperties(const TSharedPtr<FJsonObject>& Params)
{
	FString ClassName;
	if (auto Err = RequireString(Params, TEXT("className"), ClassName)) return Err;

	// Resolve UClass
	UClass* Class = LoadObject<UClass>(nullptr, *ClassName);
	if (!Class)
	{
		Class = FindClassByShortName(ClassName);
	}
	if (!Class)
	{
		return MCPError(FString::Printf(TEXT("Class not found: %s"), *ClassName));
	}

	UObject* CDO = Class->GetDefaultObject();
	if (!CDO)
	{
		return MCPError(FString::Printf(TEXT("Could not get CDO for class: %s"), *Class->GetName()));
	}

	// Optional filter: specific property names
	TSet<FString> Filter;
	const TArray<TSharedPtr<FJsonValue>>* PropNamesArr = nullptr;
	if (Params->TryGetArrayField(TEXT("propertyNames"), PropNamesArr) && PropNamesArr)
	{
		for (const auto& V : *PropNamesArr)
		{
			FString S;
			if (V->TryGetString(S))
			{
				Filter.Add(S);
			}
		}
	}

	TSharedPtr<FJsonObject> PropsObj = MakeShared<FJsonObject>();
	for (TFieldIterator<FProperty> It(Class); It; ++It)
	{
		FProperty* Prop = *It;
		if (!Prop) continue;

		const FString PropName = Prop->GetName();
		if (Filter.Num() > 0 && !Filter.Contains(PropName))
		{
			continue;
		}

		void* ValuePtr = Prop->ContainerPtrToValuePtr<void>(CDO);
		FString ExportedValue;
		Prop->ExportText_Direct(ExportedValue, ValuePtr, ValuePtr, nullptr, PPF_None);
		PropsObj->SetStringField(PropName, ExportedValue);
	}

	auto Result = MCPSuccess();
	Result->SetStringField(TEXT("className"), Class->GetPathName());
	Result->SetStringField(TEXT("classShortName"), Class->GetName());
	Result->SetObjectField(TEXT("properties"), PropsObj);
	Result->SetNumberField(TEXT("count"), PropsObj->Values.Num());

	return MCPResult(Result);
}

// #442: dedicated OverrideMaterials writer for mesh-component templates
// (StaticMeshComponent, SkeletalMeshComponent, anything deriving from
// UMeshComponent). Takes materialPaths directly so the caller never has to
// route a TArray through the generic set_component_property path.
//
// Params: assetPath, componentName, materialPaths (string[]). Pass an empty
// array to clear the override list.
TSharedPtr<FJsonValue> FBlueprintHandlers::SetComponentOverrideMaterials(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;
	FString ComponentName;
	if (auto Err = RequireString(Params, TEXT("componentName"), ComponentName)) return Err;

	const TArray<TSharedPtr<FJsonValue>>* PathsArr = nullptr;
	if (!Params->TryGetArrayField(TEXT("materialPaths"), PathsArr) || !PathsArr)
	{
		return MCPError(TEXT("Missing 'materialPaths' (string array)"));
	}

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint) return BlueprintNotFoundError(AssetPath);

	bool bIsInherited = false;
	TArray<FString> Available;
	UActorComponent* Template = ResolveComponentTemplate(
		Blueprint, ComponentName, /*bForWrite*/ true, bIsInherited, Available);
	if (!Template)
	{
		return MCPError(FString::Printf(TEXT("Component '%s' not found. Available: [%s]"),
			*ComponentName, *FString::Join(Available, TEXT(", "))));
	}

	UMeshComponent* MeshComp = Cast<UMeshComponent>(Template);
	if (!MeshComp)
	{
		return MCPError(FString::Printf(TEXT("Component '%s' is %s, not a MeshComponent"),
			*ComponentName, *Template->GetClass()->GetName()));
	}

	TArray<UMaterialInterface*> Loaded;
	TArray<FString> ResolvedPaths;
	for (const TSharedPtr<FJsonValue>& V : *PathsArr)
	{
		FString Path;
		if (!V->TryGetString(Path) || Path.IsEmpty())
		{
			Loaded.Add(nullptr);
			ResolvedPaths.Add(TEXT("None"));
			continue;
		}
		UMaterialInterface* M = Cast<UMaterialInterface>(StaticLoadObject(UMaterialInterface::StaticClass(), nullptr, *Path));
		if (!M) return MCPError(FString::Printf(TEXT("Material not found: %s"), *Path));
		Loaded.Add(M);
		ResolvedPaths.Add(M->GetPathName());
	}

	// Capture the prior OverrideMaterials list for rollback.
	TArray<TSharedPtr<FJsonValue>> PrevPaths;
	for (UMaterialInterface* Prev : MeshComp->OverrideMaterials)
	{
		PrevPaths.Add(MakeShared<FJsonValueString>(Prev ? Prev->GetPathName() : FString(TEXT("None"))));
	}

	Template->Modify();
	MeshComp->OverrideMaterials = Loaded;
	Template->PostEditChange();

	FKismetEditorUtilities::CompileBlueprint(Blueprint);
	SaveAssetPackage(Blueprint);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetStringField(TEXT("componentName"), ComponentName);
	Result->SetNumberField(TEXT("materialCount"), Loaded.Num());
	TArray<TSharedPtr<FJsonValue>> ResolvedArr;
	for (const FString& P : ResolvedPaths) ResolvedArr.Add(MakeShared<FJsonValueString>(P));
	Result->SetArrayField(TEXT("materialPaths"), ResolvedArr);
	Result->SetBoolField(TEXT("inherited"), bIsInherited);

	TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
	Payload->SetStringField(TEXT("path"), AssetPath);
	Payload->SetStringField(TEXT("componentName"), ComponentName);
	Payload->SetArrayField(TEXT("materialPaths"), PrevPaths);
	MCPSetRollback(Result, TEXT("set_component_override_materials"), Payload);

	return MCPResult(Result);
}

// #457: Timeline track authoring. Create or look up a UTimelineTemplate by
// timeline name (matching the K2Node_Timeline TimelineName), append a
// float/vector/linear-color/event track with the given keys, and recompile
// the Blueprint so the K2Node_Timeline regenerates its output pins.
//
// Params:
//   assetPath, timelineName, trackName,
//   trackType ("float" | "vector" | "color" | "event"),
//   keyframes ([{time, value}]) - value: number for float/event,
//     {x,y,z} for vector, {r,g,b,a} for color.
TSharedPtr<FJsonValue> FBlueprintHandlers::AddTimelineTrack(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;
	FString TimelineName;
	if (auto Err = RequireString(Params, TEXT("timelineName"), TimelineName)) return Err;
	FString TrackName;
	if (auto Err = RequireString(Params, TEXT("trackName"), TrackName)) return Err;
	const FString TrackType = OptionalString(Params, TEXT("trackType"), TEXT("float")).ToLower();

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint) return BlueprintNotFoundError(AssetPath);

	// Find or create the UTimelineTemplate that backs the K2Node_Timeline.
	const FName TimelineFName(*TimelineName);
	UTimelineTemplate* Timeline = nullptr;
	for (UTimelineTemplate* T : Blueprint->Timelines)
	{
		if (T && T->GetVariableName() == TimelineFName) { Timeline = T; break; }
	}
	if (!Timeline)
	{
		// Mirror what FBlueprintEditorUtils does for new timelines: name the
		// template "<TimelineName>_Template" so K2Node_Timeline lookup matches.
		const FString TemplateName = UTimelineTemplate::TimelineVariableNameToTemplateName(TimelineFName);
		Timeline = NewObject<UTimelineTemplate>(Blueprint, FName(*TemplateName), RF_Transactional);
		Blueprint->Timelines.Add(Timeline);
	}

	// Read keyframes array. Float/event keys carry numeric values; vector
	// keys are {x,y,z}; color keys are {r,g,b,a}.
	const TArray<TSharedPtr<FJsonValue>>* Keys = nullptr;
	Params->TryGetArrayField(TEXT("keyframes"), Keys);

	const FName TrackFName(*TrackName);

	// Idempotency, and the reason it matters here more than usual: this handler
	// has no inverse, so a replayed call that appends a SECOND track of the same
	// name leaves a timeline nothing in the bridge can clean up. Track names are
	// what K2Node_Timeline turns into output pins, so two of a name is a broken
	// node rather than a harmless duplicate. Every track list on the template is
	// checked, not just the one matching trackType, because the pin name
	// collides across types too.
	auto TrackNameTaken = [Timeline, TrackFName]() -> const TCHAR*
	{
		for (const FTTFloatTrack& T : Timeline->FloatTracks)       { if (T.GetTrackName() == TrackFName) return TEXT("float"); }
		for (const FTTVectorTrack& T : Timeline->VectorTracks)      { if (T.GetTrackName() == TrackFName) return TEXT("vector"); }
		for (const FTTLinearColorTrack& T : Timeline->LinearColorTracks) { if (T.GetTrackName() == TrackFName) return TEXT("color"); }
		for (const FTTEventTrack& T : Timeline->EventTracks)        { if (T.GetTrackName() == TrackFName) return TEXT("event"); }
		return nullptr;
	};
	if (const TCHAR* ExistingType = TrackNameTaken())
	{
		auto Existed = MCPSuccess();
		MCPSetExisted(Existed);
		Existed->SetBoolField(TEXT("alreadyExists"), true);
		Existed->SetBoolField(TEXT("unchanged"), true);
		Existed->SetStringField(TEXT("path"), AssetPath);
		Existed->SetStringField(TEXT("timelineName"), TimelineName);
		Existed->SetStringField(TEXT("trackName"), TrackName);
		Existed->SetStringField(TEXT("trackType"), TrackType);
		Existed->SetStringField(TEXT("existingTrackType"), ExistingType);
		Existed->SetStringField(TEXT("reason"), FString::Printf(TEXT(
			"Timeline '%s' already has a %s track named '%s', and nothing was added. Track names become "
			"K2Node_Timeline output pins, so a second track of this name would break the node, and no action "
			"removes a timeline track once it exists. Pick a different trackName."),
			*TimelineName, ExistingType, *TrackName));
		Existed->SetBoolField(TEXT("rollbackPossible"), false);
		Existed->SetStringField(TEXT("rollbackNote"), TEXT("Nothing was added, so there is nothing to undo."));
		return MCPResult(Existed);
	}

	Timeline->Modify();

	auto BumpLength = [&](float Time)
	{
		if (Time > Timeline->TimelineLength) Timeline->TimelineLength = Time;
	};

	if (TrackType == TEXT("float"))
	{
		// Build a CurveFloat asset bound to the timeline.
		UCurveFloat* Curve = NewObject<UCurveFloat>(Timeline, NAME_None, RF_Transactional);
		if (Keys)
		{
			for (const TSharedPtr<FJsonValue>& KV : *Keys)
			{
				const TSharedPtr<FJsonObject>* KObj = nullptr;
				if (!KV->TryGetObject(KObj) || !*KObj) continue;
				double T = 0, V = 0;
				(*KObj)->TryGetNumberField(TEXT("time"), T);
				(*KObj)->TryGetNumberField(TEXT("value"), V);
				Curve->FloatCurve.UpdateOrAddKey((float)T, (float)V);
				BumpLength((float)T);
			}
		}
		FTTFloatTrack Track;
		Track.SetTrackName(TrackFName, Timeline);
		Track.CurveFloat = Curve;
		Timeline->FloatTracks.Add(Track);
	}
	else if (TrackType == TEXT("vector"))
	{
		UCurveVector* Curve = NewObject<UCurveVector>(Timeline, NAME_None, RF_Transactional);
		if (Keys)
		{
			for (const TSharedPtr<FJsonValue>& KV : *Keys)
			{
				const TSharedPtr<FJsonObject>* KObj = nullptr;
				if (!KV->TryGetObject(KObj) || !*KObj) continue;
				double T = 0;
				(*KObj)->TryGetNumberField(TEXT("time"), T);
				double X = 0, Y = 0, Z = 0;
				const TSharedPtr<FJsonObject>* VObj = nullptr;
				if ((*KObj)->TryGetObjectField(TEXT("value"), VObj) && *VObj)
				{
					(*VObj)->TryGetNumberField(TEXT("x"), X);
					(*VObj)->TryGetNumberField(TEXT("y"), Y);
					(*VObj)->TryGetNumberField(TEXT("z"), Z);
				}
				Curve->FloatCurves[0].UpdateOrAddKey((float)T, (float)X);
				Curve->FloatCurves[1].UpdateOrAddKey((float)T, (float)Y);
				Curve->FloatCurves[2].UpdateOrAddKey((float)T, (float)Z);
				BumpLength((float)T);
			}
		}
		FTTVectorTrack Track;
		Track.SetTrackName(TrackFName, Timeline);
		Track.CurveVector = Curve;
		Timeline->VectorTracks.Add(Track);
	}
	else if (TrackType == TEXT("color"))
	{
		UCurveLinearColor* Curve = NewObject<UCurveLinearColor>(Timeline, NAME_None, RF_Transactional);
		if (Keys)
		{
			for (const TSharedPtr<FJsonValue>& KV : *Keys)
			{
				const TSharedPtr<FJsonObject>* KObj = nullptr;
				if (!KV->TryGetObject(KObj) || !*KObj) continue;
				double T = 0;
				(*KObj)->TryGetNumberField(TEXT("time"), T);
				double R = 0, G = 0, B = 0, A = 1;
				const TSharedPtr<FJsonObject>* VObj = nullptr;
				if ((*KObj)->TryGetObjectField(TEXT("value"), VObj) && *VObj)
				{
					(*VObj)->TryGetNumberField(TEXT("r"), R);
					(*VObj)->TryGetNumberField(TEXT("g"), G);
					(*VObj)->TryGetNumberField(TEXT("b"), B);
					(*VObj)->TryGetNumberField(TEXT("a"), A);
				}
				Curve->FloatCurves[0].UpdateOrAddKey((float)T, (float)R);
				Curve->FloatCurves[1].UpdateOrAddKey((float)T, (float)G);
				Curve->FloatCurves[2].UpdateOrAddKey((float)T, (float)B);
				Curve->FloatCurves[3].UpdateOrAddKey((float)T, (float)A);
				BumpLength((float)T);
			}
		}
		FTTLinearColorTrack Track;
		Track.SetTrackName(TrackFName, Timeline);
		Track.CurveLinearColor = Curve;
		Timeline->LinearColorTracks.Add(Track);
	}
	else if (TrackType == TEXT("event"))
	{
		FTTEventTrack EventTrack;
		EventTrack.SetTrackName(TrackFName, Timeline);
		UCurveFloat* Curve = NewObject<UCurveFloat>(Timeline, NAME_None, RF_Transactional);
		if (Keys)
		{
			for (const TSharedPtr<FJsonValue>& KV : *Keys)
			{
				const TSharedPtr<FJsonObject>* KObj = nullptr;
				if (!KV->TryGetObject(KObj) || !*KObj) continue;
				double T = 0;
				(*KObj)->TryGetNumberField(TEXT("time"), T);
				Curve->FloatCurve.UpdateOrAddKey((float)T, 1.0f);
				BumpLength((float)T);
			}
		}
		EventTrack.CurveKeys = Curve;
		Timeline->EventTracks.Add(EventTrack);
	}
	else
	{
		return MCPError(FString::Printf(TEXT("Unknown trackType '%s'. Use 'float' | 'vector' | 'color' | 'event'."), *TrackType));
	}

	// Recompile so K2Node_Timeline regenerates its output pins for the new track.
	FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint);
	FKismetEditorUtilities::CompileBlueprint(Blueprint);
	SaveAssetPackage(Blueprint);

	auto Result = MCPSuccess();
	MCPSetCreated(Result);
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetStringField(TEXT("timelineName"), TimelineName);
	Result->SetStringField(TEXT("trackName"), TrackName);
	Result->SetStringField(TEXT("trackType"), TrackType);
	Result->SetNumberField(TEXT("keyCount"), Keys ? Keys->Num() : 0);
	Result->SetNumberField(TEXT("timelineLength"), Timeline->TimelineLength);

	// add_timeline_track is the only timeline action the bridge registers. There
	// is no remove_timeline_track and no editor for a timeline's track list, so
	// no inverse is named here rather than one that does not exist.
	Result->SetBoolField(TEXT("rollbackPossible"), false);
	Result->SetStringField(TEXT("rollbackNote"), TEXT(
		"No action removes a timeline track: add_timeline_track is the only timeline action registered, so there is "
		"nothing to name as an inverse. The track, its generated curve object and any TimelineLength this call "
		"raised all stay."));
	return MCPResult(Result);
}

// ---------------------------------------------------------------------------
// set_capsule_size -- Call UCapsuleComponent::SetCapsuleSize on a BP capsule
// component template. Property writes alone leave the visualizer stale; the
// UFUNCTION setter propagates scaled/unscaled state correctly (#419).
// ---------------------------------------------------------------------------
TSharedPtr<FJsonValue> FBlueprintHandlers::SetCapsuleSize(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;
	FString ComponentName;
	if (auto Err = RequireString(Params, TEXT("componentName"), ComponentName)) return Err;

	const bool bHasHalfHeight = Params->HasField(TEXT("halfHeight"));
	const bool bHasRadius = Params->HasField(TEXT("radius"));
	if (!bHasHalfHeight && !bHasRadius)
	{
		return MCPError(TEXT("Pass at least one of 'halfHeight' or 'radius'"));
	}

	UBlueprint* Blueprint = LoadBlueprint(AssetPath);
	if (!Blueprint) return BlueprintNotFoundError(AssetPath);

	bool bIsInherited = false;
	TArray<FString> Available;
	UActorComponent* Template = ResolveComponentTemplate(
		Blueprint, ComponentName, /*bForWrite*/ true, bIsInherited, Available);
	if (!Template)
	{
		return MCPError(FString::Printf(TEXT("Component '%s' not found. Available: [%s]"),
			*ComponentName, *FString::Join(Available, TEXT(", "))));
	}

	UCapsuleComponent* Capsule = Cast<UCapsuleComponent>(Template);
	if (!Capsule)
	{
		return MCPError(FString::Printf(TEXT("Component '%s' is %s, not a CapsuleComponent"),
			*ComponentName, *Template->GetClass()->GetName()));
	}

	const float PrevHalfHeight = Capsule->GetUnscaledCapsuleHalfHeight();
	const float PrevRadius = Capsule->GetUnscaledCapsuleRadius();

	float NewHalfHeight = PrevHalfHeight;
	float NewRadius = PrevRadius;
	if (bHasHalfHeight)
	{
		double H = PrevHalfHeight;
		Params->TryGetNumberField(TEXT("halfHeight"), H);
		NewHalfHeight = (float)H;
	}
	if (bHasRadius)
	{
		double R = PrevRadius;
		Params->TryGetNumberField(TEXT("radius"), R);
		NewRadius = (float)R;
	}

	Capsule->Modify();
	Capsule->SetCapsuleSize(NewRadius, NewHalfHeight, /*bUpdateOverlaps*/ true);
	FKismetEditorUtilities::CompileBlueprint(Blueprint);
	SaveAssetPackage(Blueprint);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("path"), AssetPath);
	Result->SetStringField(TEXT("componentName"), ComponentName);
	Result->SetNumberField(TEXT("halfHeight"), NewHalfHeight);
	Result->SetNumberField(TEXT("radius"), NewRadius);
	Result->SetNumberField(TEXT("previousHalfHeight"), PrevHalfHeight);
	Result->SetNumberField(TEXT("previousRadius"), PrevRadius);
	Result->SetBoolField(TEXT("inherited"), bIsInherited);

	TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
	Payload->SetStringField(TEXT("path"), AssetPath);
	Payload->SetStringField(TEXT("componentName"), ComponentName);
	Payload->SetNumberField(TEXT("halfHeight"), PrevHalfHeight);
	Payload->SetNumberField(TEXT("radius"), PrevRadius);
	MCPSetRollback(Result, TEXT("set_capsule_size"), Payload);
	return MCPResult(Result);
}

// ---------------------------------------------------------------------------
// run_construction_script -- Spawn a temp actor from a Blueprint, run its
// construction script, collect resulting component info, then destroy it.
// Params: assetPath (required), location (optional {x,y,z})
// Issue #195
// ---------------------------------------------------------------------------
