-- Ripple Studio Plugin
-- Connects Roblox Studio to a Ripple project through a short-lived pairing code.
-- No master API keys are stored in this plugin.

local HttpService = game:GetService("HttpService")
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local LogService = game:GetService("LogService")
local Selection = game:GetService("Selection")

local toolbar = plugin:CreateToolbar("Ripple")
local toggleButton = toolbar:CreateButton("Ripple", "Open the Ripple sync panel", "")

local widgetInfo = DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Right,
	false,
	false,
	360,
	540,
	300,
	360
)
local widget = plugin:CreateDockWidgetPluginGui("RippleStudio", widgetInfo)
widget.Title = "Ripple"

local SETTINGS_KEY = "RipplePlugin.v1"
local state = {
	baseUrl = "",
	token = "",
	projectId = "",
	projectTitle = "",
	connectionId = "",
	lastPull = nil,
	pendingChanges = {},
	errors = {},
	status = "Disconnected",
}

local function loadSettings()
	local ok, saved = pcall(function()
		return plugin:GetSetting(SETTINGS_KEY)
	end)
	if ok and typeof(saved) == "table" then
		state.baseUrl = saved.baseUrl or ""
		state.token = saved.token or ""
		state.projectId = saved.projectId or ""
		state.projectTitle = saved.projectTitle or ""
		state.connectionId = saved.connectionId or ""
	end
end

local function saveSettings()
	plugin:SetSetting(SETTINGS_KEY, {
		baseUrl = state.baseUrl,
		token = state.token,
		projectId = state.projectId,
		projectTitle = state.projectTitle,
		connectionId = state.connectionId,
	})
end

loadSettings()

local function trim(s)
	return (string.gsub(s or "", "^%s*(.-)%s*$", "%1"))
end

local function joinUrl(base, path)
	base = trim(base)
	base = string.gsub(base, "^https?://https?://", "https://")
	if not string.find(base, "^https?://") then
		base = "https://" .. base
	end
	base = string.gsub(base, "/+$", "")
	return base .. path
end

local function request(method, path, body)
	if trim(state.baseUrl) == "" then
		return false, "Set the Ripple URL in Settings first."
	end
	local url = joinUrl(state.baseUrl, path)
	local headers = { ["Content-Type"] = "application/json" }
	if state.token ~= "" then
		headers.Authorization = "Bearer " .. state.token
	end
	local function send(useMethod, useUrl, useBody)
		local req = {
			Url = useUrl,
			Method = useMethod,
			Headers = headers,
		}
		if useBody ~= nil and useMethod ~= "GET" then
			req.Body = HttpService:JSONEncode(useBody)
		end
		local ok, response = pcall(function()
			return HttpService:RequestAsync(req)
		end)
		if not ok then
			return false, tostring(response), nil, 0
		end
		if typeof(response) ~= "table" then
			return false, "Bad HTTP response", nil, 0
		end
		local decoded = nil
		if typeof(response.Body) == "string" and string.sub(response.Body, 1, 1) == "{" then
			pcall(function()
				decoded = HttpService:JSONDecode(response.Body)
			end)
		end
		local status = tonumber(response.StatusCode) or 0
		if not response.Success or status < 200 or status >= 300 then
			local message = (typeof(decoded) == "table" and decoded.error)
				or ("HTTP " .. tostring(status))
			return false, message, decoded, status
		end
		if typeof(decoded) ~= "table" then
			return false, "Ripple did not return JSON. Check the site URL.", nil, status
		end
		if decoded.ok == false then
			return false, tostring(decoded.error or "Request failed"), decoded, status
		end
		return true, decoded, nil, status
	end
	local ok, result, extra, status = send(method, url, body)
	if (not ok) and method == "POST" and (status == 405 or string.find(tostring(result), "405", 1, true)) then
		local qs = {}
		if typeof(body) == "table" then
			for k, v in pairs(body) do
				if typeof(v) ~= "table" then
					table.insert(qs, HttpService:UrlEncode(tostring(k)) .. "=" .. HttpService:UrlEncode(tostring(v)))
				end
			end
		end
		local getUrl = url
		if #qs > 0 then
			getUrl = url .. "?" .. table.concat(qs, "&")
		end
		ok, result, extra, status = send("GET", getUrl, nil)
	end
	return ok, result
end

local SERVICE_MAP = {
	Workspace = game:GetService("Workspace"),
	ReplicatedStorage = game:GetService("ReplicatedStorage"),
	ReplicatedFirst = game:GetService("ReplicatedFirst"),
	ServerScriptService = game:GetService("ServerScriptService"),
	ServerStorage = game:GetService("ServerStorage"),
	StarterGui = game:GetService("StarterGui"),
	StarterPack = game:GetService("StarterPack"),
	StarterPlayer = game:GetService("StarterPlayer"),
	Lighting = game:GetService("Lighting"),
	SoundService = game:GetService("SoundService"),
}

local function resolveParent(path)
	local parts = string.split(path, "/")
	if #parts == 0 then
		return nil
	end
	local current = SERVICE_MAP[parts[1]]
	if not current then
		return nil
	end
	for i = 2, #parts - 1 do
		local child = current:FindFirstChild(parts[i])
		if not child then
			child = Instance.new("Folder")
			child.Name = parts[i]
			child.Parent = current
		end
		current = child
	end
	return current, parts[#parts]
end

local function applyProperties(inst, props)
	if typeof(props) ~= "table" then
		return
	end
	for key, value in pairs(props) do
		pcall(function()
			if typeof(value) == "table" then
				if typeof(value.Color3) == "table" then
					inst[key] = Color3.new(value.Color3[1] or 0, value.Color3[2] or 0, value.Color3[3] or 0)
				elseif typeof(value.UDim2) == "table" then
					local t = value.UDim2
					inst[key] = UDim2.new(t[1] or 0, t[2] or 0, t[3] or 0, t[4] or 0)
				elseif typeof(value.UDim) == "table" then
					inst[key] = UDim.new(value.UDim[1] or 0, value.UDim[2] or 0)
				elseif typeof(value.Vector2) == "table" then
					inst[key] = Vector2.new(value.Vector2[1] or 0, value.Vector2[2] or 0)
				end
			else
				inst[key] = value
			end
		end)
	end
end

local function applyInstance(entry)
	local parent, name = resolveParent(entry.path)
	if not parent or not name then
		return false, "Unknown path " .. tostring(entry.path)
	end
	local existing = parent:FindFirstChild(name)
	local className = entry.class_name or entry.className or "Folder"
	if existing and existing.ClassName ~= className then
		existing:Destroy()
		existing = nil
	end
	if not existing then
		local ok, created = pcall(function()
			return Instance.new(className)
		end)
		if not ok or not created then
			return false, "Cannot create " .. className
		end
		created.Name = name
		created.Parent = parent
		existing = created
	end
	if (className == "Script" or className == "LocalScript" or className == "ModuleScript") and entry.source then
		existing.Source = entry.source
	end
	applyProperties(existing, entry.properties or entry.props)
	return true
end

local function collectScripts(root, prefix, bucket)
	for _, child in ipairs(root:GetChildren()) do
		local path = prefix .. "/" .. child.Name
		if child:IsA("LuaSourceContainer") then
			table.insert(bucket, {
				path = path,
				className = child.ClassName,
				source = child.Source,
			})
		end
		collectScripts(child, path, bucket)
	end
end

local function collectProjectFiles()
	local files = {}
	for name, service in pairs(SERVICE_MAP) do
		collectScripts(service, name, files)
	end
	local starterPlayer = game:GetService("StarterPlayer")
	if starterPlayer:FindFirstChild("StarterPlayerScripts") then
		collectScripts(starterPlayer.StarterPlayerScripts, "StarterPlayer/StarterPlayerScripts", files)
	end
	if starterPlayer:FindFirstChild("StarterCharacterScripts") then
		collectScripts(starterPlayer.StarterCharacterScripts, "StarterPlayer/StarterCharacterScripts", files)
	end
	return files
end

-- UI
local root = Instance.new("Frame")
root.BackgroundColor3 = Color3.fromRGB(244, 246, 247)
root.BorderSizePixel = 0
root.Size = UDim2.fromScale(1, 1)
root.Parent = widget

local pad = Instance.new("UIPadding")
pad.PaddingTop = UDim.new(0, 12)
pad.PaddingBottom = UDim.new(0, 12)
pad.PaddingLeft = UDim.new(0, 12)
pad.PaddingRight = UDim.new(0, 12)
pad.Parent = root

local layout = Instance.new("UIListLayout")
layout.Padding = UDim.new(0, 8)
layout.SortOrder = Enum.SortOrder.LayoutOrder
layout.Parent = root

local function makeLabel(text, order, bold)
	local label = Instance.new("TextLabel")
	label.BackgroundTransparency = 1
	label.Font = bold and Enum.Font.GothamMedium or Enum.Font.Gotham
	label.TextSize = bold and 16 or 13
	label.TextXAlignment = Enum.TextXAlignment.Left
	label.TextColor3 = Color3.fromRGB(26, 29, 33)
	label.TextWrapped = true
	label.AutomaticSize = Enum.AutomaticSize.Y
	label.Size = UDim2.new(1, 0, 0, 18)
	label.Text = text
	label.LayoutOrder = order
	label.Parent = root
	return label
end

local function makeInput(placeholder, order)
	local box = Instance.new("TextBox")
	box.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
	box.TextColor3 = Color3.fromRGB(26, 29, 33)
	box.PlaceholderColor3 = Color3.fromRGB(139, 147, 156)
	box.PlaceholderText = placeholder
	box.Font = Enum.Font.Gotham
	box.TextSize = 13
	box.ClearTextOnFocus = false
	box.Size = UDim2.new(1, 0, 0, 32)
	box.LayoutOrder = order
	box.Text = ""
	local corner = Instance.new("UICorner")
	corner.CornerRadius = UDim.new(0, 8)
	corner.Parent = box
	local stroke = Instance.new("UIStroke")
	stroke.Color = Color3.fromRGB(226, 231, 234)
	stroke.Parent = box
	box.Parent = root
	return box
end

local function makeButton(text, order, primary)
	local button = Instance.new("TextButton")
	button.AutoButtonColor = true
	button.Font = Enum.Font.GothamMedium
	button.TextSize = 13
	button.Size = UDim2.new(1, 0, 0, 34)
	button.LayoutOrder = order
	button.Text = text
	button.TextColor3 = primary and Color3.new(1, 1, 1) or Color3.fromRGB(26, 29, 33)
	button.BackgroundColor3 = primary and Color3.fromRGB(14, 138, 147) or Color3.fromRGB(255, 255, 255)
	local corner = Instance.new("UICorner")
	corner.CornerRadius = UDim.new(0, 8)
	corner.Parent = button
	if not primary then
		local stroke = Instance.new("UIStroke")
		stroke.Color = Color3.fromRGB(226, 231, 234)
		stroke.Parent = button
	end
	button.Parent = root
	return button
end

local title = makeLabel("Ripple", 1, true)
local statusLabel = makeLabel("Disconnected", 2, false)
statusLabel.TextColor3 = Color3.fromRGB(94, 103, 114)

local tabs = Instance.new("Frame")
tabs.BackgroundTransparency = 1
tabs.Size = UDim2.new(1, 0, 0, 28)
tabs.LayoutOrder = 3
tabs.Parent = root
local tabLayout = Instance.new("UIListLayout")
tabLayout.FillDirection = Enum.FillDirection.Horizontal
tabLayout.Padding = UDim.new(0, 6)
tabLayout.Parent = tabs

local currentTab = "Project"
local tabButtons = {}
local function setTab(name)
	currentTab = name
	for tabName, btn in pairs(tabButtons) do
		btn.TextColor3 = tabName == name and Color3.fromRGB(14, 138, 147) or Color3.fromRGB(94, 103, 114)
	end
	refreshBody()
end

for i, name in ipairs({ "Project", "Sync", "Changes", "Errors", "Settings" }) do
	local btn = Instance.new("TextButton")
	btn.BackgroundTransparency = 1
	btn.Font = Enum.Font.GothamMedium
	btn.TextSize = 12
	btn.Text = name
	btn.Size = UDim2.new(0, 62, 1, 0)
	btn.TextColor3 = Color3.fromRGB(94, 103, 114)
	btn.Parent = tabs
	btn.MouseButton1Click:Connect(function()
		setTab(name)
	end)
	tabButtons[name] = btn
end

local body = makeLabel("Load a pairing code from the Ripple website.", 4, false)
body.TextYAlignment = Enum.TextYAlignment.Top
body.Size = UDim2.new(1, 0, 0, 120)

local urlBox = makeInput("Ripple URL  (https://your-ripple-host)", 5)
urlBox.Text = state.baseUrl
local codeBox = makeInput("Pairing code", 6)
local connectBtn = makeButton("Connect", 7, true)
local pullBtn = makeButton("Pull from Ripple", 8, false)
local pushBtn = makeButton("Push to Ripple", 9, false)
local syncBtn = makeButton("Sync", 10, true)
local disconnectBtn = makeButton("Disconnect", 11, false)

local function setStatus(text)
	state.status = text
	statusLabel.Text = text
end

function refreshBody()
	if currentTab == "Project" then
		body.Text = string.format(
			"Project: %s\nId: %s\nConnection: %s\nStatus: %s",
			state.projectTitle ~= "" and state.projectTitle or "—",
			state.projectId ~= "" and state.projectId or "—",
			state.connectionId ~= "" and state.connectionId or "not paired",
			state.status
		)
	elseif currentTab == "Sync" then
		body.Text = "Pull applies the latest Ripple revision.\nPush sends Studio scripts back.\nSync = pull then show the change list first."
	elseif currentTab == "Changes" then
		if #state.pendingChanges == 0 then
			body.Text = "No pending changes. Pull to preview a revision."
		else
			local lines = {}
			for _, change in ipairs(state.pendingChanges) do
				table.insert(lines, (change.changeType or "?") .. "  " .. (change.path or ""))
			end
			body.Text = table.concat(lines, "\n")
		end
	elseif currentTab == "Errors" then
		if #state.errors == 0 then
			body.Text = "No captured Studio errors yet."
		else
			local lines = {}
			for _, err in ipairs(state.errors) do
				table.insert(lines, err)
			end
			body.Text = table.concat(lines, "\n")
		end
	else
		body.Text = "HttpService must be enabled.\nPaste the public Ripple URL, then enter the pairing code from Connect Studio."
	end
end

local function applyPayload(payload)
	if typeof(payload) ~= "table" then
		return
	end
	local files = payload.files or {}
	local instances = payload.instances or {}
	state.pendingChanges = {}
	for _, inst in ipairs(instances) do
		table.insert(state.pendingChanges, {
			path = inst.path,
			changeType = "created",
			className = inst.class_name or inst.className,
		})
	end
	ChangeHistoryService:SetWaypoint("Ripple sync start")
	for _, inst in ipairs(instances) do
		local source = nil
		for _, file in ipairs(files) do
			if file.path == inst.path then
				source = file.source
			end
		end
		applyInstance({
			path = inst.path,
			class_name = inst.class_name or inst.className,
			source = source,
			properties = inst.properties,
		})
	end
	for _, file in ipairs(files) do
		applyInstance({
			path = file.path,
			class_name = file.script_type or file.scriptType or "ModuleScript",
			source = file.source,
		})
	end
	ChangeHistoryService:SetWaypoint("Ripple sync applied")
	state.lastPull = payload.revision
end

connectBtn.MouseButton1Click:Connect(function()
	state.baseUrl = trim(urlBox.Text)
	saveSettings()
	setStatus("Pairing…")
	local ok, result = request("POST", "/api/studio/pair", {
		code = string.upper(trim(codeBox.Text)),
		pluginVersion = "1.0.0",
		studioFingerprint = HttpService:GenerateGUID(false),
	})
	if not ok or typeof(result) ~= "table" or not result.token then
		setStatus("Pair failed: " .. tostring((typeof(result) == "table" and result.error) or result or "no token"))
		refreshBody()
		return
	end
	state.token = result.token
	state.projectId = result.projectId
	state.projectTitle = result.projectTitle or ""
	state.connectionId = result.connectionId or ""
	saveSettings()
	setStatus("Connected to " .. state.projectTitle)
	setTab("Project")
end)

local function pullFromRipple()
	setStatus("Pulling…")
	local ok, result = request("GET", "/api/studio/pull")
	if not ok or typeof(result) ~= "table" then
		setStatus("Pull failed: " .. tostring(result))
		return
	end
	state.pendingChanges = {}
	local files = result.files or {}
	for _, file in ipairs(files) do
		table.insert(state.pendingChanges, { path = file.path, changeType = "modified" })
	end
	refreshBody()
	applyPayload(result)
	setStatus(result.revision and ("Pulled revision " .. tostring(result.revision.number)) or "Pulled current project")
end

pullBtn.MouseButton1Click:Connect(function()
	pullFromRipple()
	setTab("Changes")
end)

pushBtn.MouseButton1Click:Connect(function()
	setStatus("Pushing…")
	local files = collectProjectFiles()
	local ok, result = request("POST", "/api/studio/push", {
		files = files,
		errors = state.errors,
	})
	if not ok or typeof(result) ~= "table" then
		setStatus("Push failed: " .. tostring(result))
		return
	end
	setStatus(result.revision and ("Pushed revision " .. tostring(result.revision.number)) or "Pushed")
end)

syncBtn.MouseButton1Click:Connect(function()
	pullFromRipple()
	setTab("Changes")
end)

disconnectBtn.MouseButton1Click:Connect(function()
	state.token = ""
	state.projectId = ""
	state.projectTitle = ""
	state.connectionId = ""
	state.pendingChanges = {}
	saveSettings()
	setStatus("Disconnected")
	refreshBody()
end)

LogService.MessageOut:Connect(function(message, messageType)
	if messageType == Enum.MessageType.MessageError or messageType == Enum.MessageType.MessageWarning then
		table.insert(state.errors, 1, message)
		if #state.errors > 40 then
			table.remove(state.errors)
		end
		if state.token ~= "" then
			request("POST", "/api/studio/errors", {
				errors = {
					{
						message = message,
						kind = messageType == Enum.MessageType.MessageError and "error" or "warning",
					},
				},
			})
		end
	end
end)

toggleButton.Click:Connect(function()
	widget.Enabled = not widget.Enabled
end)

setTab("Project")
if state.token ~= "" then
	setStatus("Saved session — " .. (state.projectTitle ~= "" and state.projectTitle or "connected"))
end
