--!nocheck
-- Roblox Studio local plugin. Connects only to the desktop app on 127.0.0.1.

assert(plugin, "Install this Script as a local Studio plugin")
local HttpService = game:GetService("HttpService")
local Selection = game:GetService("Selection")
local ScriptEditorService = game:GetService("ScriptEditorService")
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local AssetService = game:GetService("AssetService")

local BASE = "http://127.0.0.1:8765"
local toolbar = plugin:CreateToolbar("Asset Bridge")
local toggle = toolbar:CreateButton("AssetBridge", "Connect to local Roblox Asset Manager", "", "Asset Bridge")
local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 400, 560, 320, 440)
local widget = plugin:CreateDockWidgetPluginGuiAsync("RobloxAssetBridgeV1", info)
widget.Title = "Asset Bridge"

local function create(class, props, parent)
	local object = Instance.new(class)
	for key, value in pairs(props) do object[key] = value end
	object.Parent = parent
	return object
end

local root = create("Frame", {Size = UDim2.fromScale(1, 1), BackgroundColor3 = Color3.fromRGB(27, 31, 37), BorderSizePixel = 0}, widget)
create("UIPadding", {PaddingTop = UDim.new(0, 12), PaddingBottom = UDim.new(0, 12), PaddingLeft = UDim.new(0, 12), PaddingRight = UDim.new(0, 12)}, root)
create("UIListLayout", {Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder}, root)

local function label(text, height, order)
	return create("TextLabel", {Size = UDim2.new(1, 0, 0, height), BackgroundTransparency = 1, Text = text,
		TextColor3 = Color3.fromRGB(234, 238, 244), Font = Enum.Font.SourceSans, TextSize = 15,
		TextWrapped = true, TextXAlignment = Enum.TextXAlignment.Left, LayoutOrder = order}, root)
end

local function button(text, order)
	return create("TextButton", {Size = UDim2.new(1, 0, 0, 34), BackgroundColor3 = Color3.fromRGB(67, 121, 202),
		BorderSizePixel = 0, Text = text, TextColor3 = Color3.new(1, 1, 1), Font = Enum.Font.SourceSansSemibold,
		TextSize = 15, LayoutOrder = order}, root)
end

label("Scan this place, send references to the desktop app, then pull completed uploads.", 44, 1)
local tokenBox = create("TextBox", {Size = UDim2.new(1, 0, 0, 32), BackgroundColor3 = Color3.fromRGB(42, 47, 56),
	BorderSizePixel = 0, Text = "", PlaceholderText = "Paste bridge token from desktop app", TextColor3 = Color3.new(1, 1, 1),
	TextSize = 14, ClearTextOnFocus = false, LayoutOrder = 2}, root)
local scope = button("Scope: whole place", 3)
local send = button("Scan and send to desktop", 4)
local pull = button("Pull completed uploads", 5)
local countLabel = label("No mappings pulled", 22, 6)
local mappingList = create("ScrollingFrame", {Size = UDim2.new(1, 0, 1, -330), BackgroundColor3 = Color3.fromRGB(37, 41, 49),
	BorderSizePixel = 0, ScrollBarThickness = 5, CanvasSize = UDim2.fromOffset(0, 0),
	AutomaticCanvasSize = Enum.AutomaticSize.Y, LayoutOrder = 7}, root)
create("UIListLayout", {Padding = UDim.new(0, 4), SortOrder = Enum.SortOrder.LayoutOrder}, mappingList)
local apply = button("Apply pulled mappings in Studio", 8)
local status = label("", 62, 9)

local selectedScope = false
local mappings = {}

local function report(message)
	status.Text = message
	print("[Asset Bridge] " .. message)
end

local function request(method, route, body)
	local token = tokenBox.Text
	if token == "" then error("Paste the desktop bridge token first") end
	local options = {Url = BASE .. route, Method = method, Headers = { ["X-Bridge-Token"] = token }}
	if body then
		options.Headers["Content-Type"] = "application/json"
		options.Body = HttpService:JSONEncode(body)
	end
	local response = HttpService:RequestAsync(options)
	if not response.Success then error("Bridge HTTP " .. response.StatusCode .. ": " .. response.Body) end
	return HttpService:JSONDecode(response.Body)
end

local function scopeInstances()
	if not selectedScope then return game:GetDescendants() end
	local instances = {}
	for _, selected in ipairs(Selection:Get()) do
		table.insert(instances, selected)
		for _, descendant in ipairs(selected:GetDescendants()) do table.insert(instances, descendant) end
	end
	return instances
end

local function idFromString(value)
	if type(value) ~= "string" then return nil end
	return value:match("^rbxassetid://(%d+)$") or value:match("^%d+$")
		or value:match("[?&]id=(%d+)")
end

local function inferType(hint)
	hint = string.lower(hint or "")
	if hint:find("anim", 1, true) then return "animation" end
	if hint:find("sound", 1, true) or hint:find("audio", 1, true) then return "audio" end
	if hint:find("mesh", 1, true) then return "mesh" end
	return "unknown"
end

local propertyCatalog = {
	{class = "Animation", property = "AnimationId", kind = "animation"},
	{class = "Sound", property = "SoundId", kind = "audio"},
	{class = "SpecialMesh", property = "MeshId", kind = "mesh"},
	{class = "FileMesh", property = "MeshId", kind = "mesh"},
	{class = "MeshPart", property = "MeshId", kind = "mesh"},
	{class = "StringValue", property = "Value", kind = "unknown"},
	{class = "Decal", property = "Texture", kind = "unknown"},
	{class = "Texture", property = "Texture", kind = "unknown"},
	{class = "ImageLabel", property = "Image", kind = "unknown"},
	{class = "ImageButton", property = "Image", kind = "unknown"},
}

local function scanAssets()
	local found = {}
	local seen = {}
	local function add(kind, id, source)
		if not id then return end
		local key = kind .. ":" .. id .. ":" .. source
		if seen[key] then return end
		seen[key] = true
		table.insert(found, {type = kind, id = id, source = source})
	end
	for _, instance in ipairs(scopeInstances()) do
		local path = instance:GetFullName()
		for _, entry in ipairs(propertyCatalog) do
			if instance:IsA(entry.class) then
				local ok, value = pcall(function() return instance[entry.property] end)
				if ok then
					local kind = entry.kind == "unknown" and inferType(instance.Name .. " " .. entry.property) or entry.kind
					add(kind, idFromString(value), path .. "." .. entry.property)
				end
			end
		end
		for name, value in pairs(instance:GetAttributes()) do
			if type(value) == "string" then
				add(inferType(name .. " " .. instance.Name), idFromString(value), path .. "[" .. name .. "]")
			end
		end
		if instance:IsA("LuaSourceContainer") then
			local ok, source = pcall(function() return ScriptEditorService:GetEditorSource(instance) end)
			if ok then
				local lineNumber = 0
				for line in (source .. "\n"):gmatch("(.-)\n") do
					lineNumber += 1
					local hint = line .. " " .. instance.Name
					for id in line:gmatch("rbxassetid://(%d+)") do
						add(inferType(hint), id, path .. ":" .. lineNumber)
					end
					for id in line:gmatch("[?&]id=(%d+)") do
						add(inferType(hint), id, path .. ":" .. lineNumber)
					end
					for property, id in line:gmatch("([%w_]+Id)%s*=%s*['\"](%d+)['\"]") do
						add(inferType(property), id, path .. ":" .. lineNumber)
					end
				end
			end
		end
	end
	return found
end

local function replaceText(value, oldId, newId, kind)
	if value == oldId then return newId end
	local changed = value
	changed = changed:gsub("rbxassetid://" .. oldId .. "%f[^%d]", "rbxassetid://" .. newId)
	changed = changed:gsub("([?&]id=)" .. oldId .. "%f[^%d]", "%1" .. newId)
	local field = if kind == "animation" then "AnimationId" elseif kind == "audio" then "SoundId" else "MeshId"
	changed = changed:gsub("(" .. field .. "%s*=%s*['\"])" .. oldId .. "(['\"])", "%1" .. newId .. "%2")
	return changed
end

local function applyMappings()
	if #mappings == 0 then report("Pull mappings from the desktop app first.") return end
	local instances = scopeInstances()
	local recording = ChangeHistoryService:TryBeginRecording("Apply asset bridge mappings")
	if not recording then report("Studio could not start an Undo record.") return end
	local changed, failed = 0, 0
	for _, mapping in ipairs(mappings) do
		local oldId, newId, kind = mapping.oldId, mapping.newId, mapping.type
		local newUri = "rbxassetid://" .. newId
		local template = nil
		if kind == "mesh" then
			local ok, result = pcall(function() return AssetService:CreateMeshPartAsync(Content.fromUri(newUri), {}) end)
			if ok then template = result end
		end
		for _, instance in ipairs(instances) do
			local ok, didChange = pcall(function()
				if kind == "animation" and instance:IsA("Animation") and idFromString(instance.AnimationId) == oldId then
					instance.AnimationId = newUri return true
				elseif kind == "audio" and instance:IsA("Sound") and idFromString(instance.SoundId) == oldId then
					instance.SoundId = newUri return true
				elseif kind == "mesh" and (instance:IsA("SpecialMesh") or instance:IsA("FileMesh")) and idFromString(instance.MeshId) == oldId then
					instance.MeshId = newUri return true
				elseif kind == "mesh" and instance:IsA("MeshPart") and template and idFromString(instance.MeshId) == oldId then
					local texture = instance.TextureID
					instance:ApplyMesh(template)
					instance.TextureID = texture return true
				end
				for _, entry in ipairs(propertyCatalog) do
					if entry.kind == "unknown" and instance:IsA(entry.class) then
						local value = instance[entry.property]
						if type(value) == "string" then
							local updated = replaceText(value, oldId, newId, kind)
							if updated ~= value then instance[entry.property] = updated return true end
						end
					end
				end
				for name, value in pairs(instance:GetAttributes()) do
					if type(value) == "string" then
						local updated = replaceText(value, oldId, newId, kind)
						if updated ~= value then instance:SetAttribute(name, updated) return true end
					end
				end
				if instance:IsA("LuaSourceContainer") then
					local source = ScriptEditorService:GetEditorSource(instance)
					if replaceText(source, oldId, newId, kind) ~= source then
						ScriptEditorService:UpdateSourceAsync(instance, function(current)
							return replaceText(current, oldId, newId, kind)
						end)
						return true
					end
				end
				return false
			end)
			if ok and didChange then changed += 1 elseif not ok then failed += 1 end
		end
	end
	ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
	report(string.format("Changed %d references; %d errors. Review scripts and use Undo if needed.", changed, failed))
end

toggle.Click:Connect(function() widget.Enabled = not widget.Enabled end)
widget:GetPropertyChangedSignal("Enabled"):Connect(function() toggle:SetActive(widget.Enabled) end)
scope.MouseButton1Click:Connect(function()
	selectedScope = not selectedScope
	scope.Text = if selectedScope then "Scope: selection and descendants" else "Scope: whole place"
end)
send.MouseButton1Click:Connect(function()
	local ok, result = pcall(function()
		local assets = scanAssets()
		local response = request("POST", "/scan", {assets = assets})
		return string.format("Sent %d references (%d accepted).", #assets, response.accepted or 0)
	end)
	report(if ok then result else "Send failed: " .. tostring(result))
end)
pull.MouseButton1Click:Connect(function()
	local ok, result = pcall(function() return request("GET", "/mapping") end)
	if not ok then report("Pull failed: " .. tostring(result)) return end
	mappings = result.mapping or {}
	for _, child in ipairs(mappingList:GetChildren()) do
		if child:IsA("TextLabel") then child:Destroy() end
	end
	for index, item in ipairs(mappings) do
		create("TextLabel", {Size = UDim2.new(1, 0, 0, 26), BackgroundTransparency = 1,
			Text = item.type .. "  " .. item.oldId .. " → " .. item.newId,
			TextColor3 = Color3.new(1, 1, 1), TextSize = 14, Font = Enum.Font.SourceSans,
			TextXAlignment = Enum.TextXAlignment.Left, LayoutOrder = index}, mappingList)
	end
	countLabel.Text = string.format("%d upload mappings", #mappings)
	report("Mappings pulled. Review IDs before applying.")
end)
apply.MouseButton1Click:Connect(applyMappings)
