<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">
	<External>null</External>
	<External>nil</External>
	<Item class="Script" referent="RBX6a46a7b443b348b7b34315a5ea52a57a">
		<Properties>
			<ProtectedString name="Source"><![CDATA[--[[
  RoGen Studio Plugin v2.0.0
  AI-powered Luau code generator for Roblox Studio

  Website: https://rogen-ai.com
  Support: support@rogen-ai.com
  Terms: https://rogen-ai.com/terms

  This plugin connects to external servers to provide AI code generation.
  See the disclosure notice shown on first launch for details.
--]]

-- ============================================================
-- Services
-- ============================================================

local HttpService = game:GetService("HttpService")
local InsertService = game:GetService("InsertService")
local LogService = game:GetService("LogService")
local ServerScriptService = game:GetService("ServerScriptService")
local Selection = game:GetService("Selection")
local TweenService = game:GetService("TweenService")

-- Guarded: ScriptEditorService/StudioService are real Studio services, but
-- wrapping the lookup avoids a hard crash on load if either is ever
-- unavailable (older Studio build, etc.) — insertCode falls back gracefully.
local ScriptEditorService
do
	local ok, service = pcall(function()
		return game:GetService("ScriptEditorService")
	end)
	if ok then
		ScriptEditorService = service
	end
end

local StudioService
do
	local ok, service = pcall(function()
		return game:GetService("StudioService")
	end)
	if ok then
		StudioService = service
	end
end

-- ============================================================
-- Constants
-- ============================================================

-- Both URLs are domain names over HTTPS, never a raw IP or plain HTTP.
local BACKEND_URL = "https://robloxaiapp-production.up.railway.app"
-- Full scheme, not just the bare domain — the actual login link the user
-- copies is built server-side from this same domain (see backend's own
-- WEB_URL env var); this local copy is just for display text below.
local WEB_URL = "https://rogen-ai.com"

-- ============================================================
-- Centralized HTTP layer
-- ============================================================
-- Every outbound request goes through rogenRequest() so a failure is
-- classified ONCE, consistently, instead of each of the ~15 call sites
-- re-deriving "what went wrong" from a raw Roblox error (the actual bug that
-- made connecting unreliable — users saw a generic error with no idea it
-- was, say, the HTTP-permission toggle). rogenRequest returns:
--   { ok=bool, status=number?, class=string, body=table?, raw=string?, note=string? }
-- where class is exactly one of:
--   "ok"            2xx; JSON body (if any) decoded into .body (nil for 204)
--   "http_disabled" RequestAsync threw and the error looks like Studio's
--                   HTTP permission being off (place- or plugin-level)
--   "network"       RequestAsync threw otherwise (timeout, DNS, backend
--                   unreachable) — retried with backoff
--   "unauthorized"  401 — token is dead; onUnauthorized() is invoked
--   "rate_limited"  429 — retried with backoff
--   "server_error"  5xx — retried with backoff
--   "client_error"  other 4xx — not retried; .body may hold { error = ... }
--   "bad_response"  2xx but the body wasn't valid JSON

local DEBUG_LOG_MAX = 50
-- Ring buffer powering the collapsible debug panel — so a failure is
-- diagnosable from inside the widget instead of only via warn() to Output.
local debugLog = {}
local debugLogListeners = {}
-- os.time() of the last request that came back ok — powers the "synced Ns
-- ago" on the status indicator and the debug panel's last-sync line.
local lastSuccessfulSyncAt = nil

local function pushDebugLog(entry)
	entry.t = os.date("%H:%M:%S")
	table.insert(debugLog, entry)
	if #debugLog > DEBUG_LOG_MAX then
		table.remove(debugLog, 1)
	end
	for _, fn in ipairs(debugLogListeners) do
		pcall(fn, entry)
	end
end

-- Plugin HTTP permission is separate from (and not reflected by) the place-
-- level HttpService.HttpEnabled property — Roblox gates it per-plugin via
-- Manage Plugins, checkable only by attempting a request and inspecting the
-- failure. Roblox's documented error text for the place-level toggle is
-- "Http requests are not enabled. Enable via game settings"; the plugin-
-- permission-denial wording isn't publicly documented, so this matches
-- broadly (case-insensitive "http" + one of a few likely words) rather than
-- one exact string, and simply classifies as generic "network" if a failure
-- doesn't match — best-effort message selection, not a correctness gate.
local function isHttpDisabledError(err)
	local message = string.lower(tostring(err or ""))
	if not string.find(message, "http") then
		return false
	end
	return string.find(message, "not enabled") ~= nil
		or string.find(message, "not allowed") ~= nil
		or string.find(message, "permission") ~= nil
end

-- Assigned once the signed-out UI helpers exist (see near initializePlugin).
-- Forward-declared so rogenRequest, defined up here before any UI, can still
-- call it on a 401 — Lua resolves this local at call time, not definition
-- time, so the later assignment is what actually runs.
local onUnauthorized

local RETRYABLE = { network = true, rate_limited = true, server_error = true }

-- One attempt, no retry — the retry/backoff loop lives in rogenRequest.
local function attemptRequest(opts)
	local headers = {}
	if opts.body ~= nil then
		headers["Content-Type"] = "application/json"
	end
	if opts.auth then
		local token = plugin:GetSetting("rogen_auth_token")
		if token and token ~= "" then
			headers["Authorization"] = "Bearer " .. token
		end
	end

	local ok, response = pcall(function()
		return HttpService:RequestAsync({
			Url = BACKEND_URL .. opts.path,
			Method = opts.method or "GET",
			Headers = headers,
			Body = opts.body ~= nil and HttpService:JSONEncode(opts.body) or nil,
		})
	end)

	if not ok then
		local class = isHttpDisabledError(response) and "http_disabled" or "network"
		return { ok = false, class = class, note = tostring(response) }
	end

	local status = response.StatusCode
	if status == 401 then
		return { ok = false, status = status, class = "unauthorized" }
	elseif status == 429 then
		return { ok = false, status = status, class = "rate_limited" }
	elseif status >= 500 then
		return { ok = false, status = status, class = "server_error", raw = response.Body }
	elseif status == 204 then
		return { ok = true, status = status, class = "ok" }
	elseif status >= 200 and status < 300 then
		local decodeOk, decoded = pcall(function()
			return HttpService:JSONDecode(response.Body)
		end)
		if decodeOk then
			return { ok = true, status = status, class = "ok", body = decoded }
		end
		return { ok = false, status = status, class = "bad_response", raw = response.Body }
	end

	-- Any other 4xx — surface the server's own JSON error message if present.
	local decodeOk, decoded = pcall(function()
		return HttpService:JSONDecode(response.Body)
	end)
	return {
		ok = false,
		status = status,
		class = "client_error",
		body = decodeOk and decoded or nil,
		raw = response.Body,
	}
end

-- opts: { method, path, body?, auth?, retries? }
-- retries defaults to 3; callers on a tight loop (the 2s poll) pass 1 since
-- they re-run so soon that in-request backoff just adds latency.
local function rogenRequest(opts)
	local maxAttempts = opts.retries or 3
	local result
	for attempt = 1, maxAttempts do
		result = attemptRequest(opts)
		pushDebugLog({
			method = opts.method or "GET",
			path = opts.path,
			status = result.status,
			class = result.class,
			note = result.note,
		})
		if result.ok or not RETRYABLE[result.class] then
			break
		end
		if attempt < maxAttempts then
			-- Exponential-ish: 0.5s, 1s, 2s...
			task.wait(0.5 * (2 ^ (attempt - 1)))
		end
	end
	if result.ok then
		lastSuccessfulSyncAt = os.time()
	end
	if result.class == "unauthorized" and onUnauthorized then
		pcall(onUnauthorized)
	end
	return result
end
local WEB_DOMAIN_DISPLAY = WEB_URL:gsub("^https?://", "")
local PLUGIN_VERSION = "v2.3.0"

-- Colors — matches shared.css exactly (orange-primary, same as the rest of
-- the website). Kept in sync by hand; there's no shared source of truth
-- across the Luau plugin and the CSS.
local BG = Color3.fromRGB(10, 10, 10) -- #0a0a0a
local PANEL = Color3.fromRGB(17, 17, 17) -- #111111
local BORDER = Color3.fromRGB(30, 30, 30) -- #1e1e1e
local ACCENT = Color3.fromRGB(249, 115, 22) -- #f97316 — primary (buttons, active states)
local TEXT_PRIMARY = Color3.fromRGB(255, 255, 255) -- #ffffff
local TEXT_SECONDARY = Color3.fromRGB(102, 102, 102) -- #666666
local VERSION_COLOR = Color3.fromRGB(68, 68, 68) -- #444444
local SUCCESS = Color3.fromRGB(34, 197, 94) -- #22c55e
local ERROR_COLOR = Color3.fromRGB(239, 68, 68) -- #ef4444
local PENDING = Color3.fromRGB(245, 158, 11) -- #f59e0b — receiving/checking

-- Tier badge styling — mirrors dashboard.html's TIER_BADGE_STYLES exactly,
-- not this task's original spec (which said purple for Builder): the site
-- moved Builder to blue a while back specifically so it wouldn't collide
-- with Studio's orange, and this plugin should match what's actually live.
local TIER_BADGES = {
	none = { bg = Color3.fromRGB(63, 63, 70), text = TEXT_PRIMARY, label = "No Plan" },
	starter = { bg = Color3.fromRGB(63, 63, 70), text = TEXT_PRIMARY, label = "Code Assistant" },
	builder = { bg = Color3.fromRGB(59, 130, 246), text = TEXT_PRIMARY, label = "Game Builder" },
	studio = { bg = ACCENT, text = TEXT_PRIMARY, label = "Full AI Studio" },
	developer = { bg = Color3.fromRGB(251, 191, 36), text = Color3.fromRGB(26, 26, 26), label = "Developer" },
}

local FONT_REGULAR = Enum.Font.Gotham
local FONT_BOLD = Enum.Font.GothamBold
local FONT_CODE = Enum.Font.Code

-- "Ro" in white (default TextColor3), "Gen" in orange.
local WORDMARK_RICH = 'Ro<font color="#F97316">Gen</font>'

local PAD = 12
local HEADER_HEIGHT = 40
local SESSION_HEIGHT = 54
-- The first connectivity check after launch is a real, live round trip
-- (not a cached/remembered status), but on a fast connection it can
-- resolve fast enough that "Checking..." flashes by almost invisibly,
-- making a stored-token relaunch look like it's claiming "Connected"
-- without actually verifying anything. This floors how long "Checking..."
-- stays on screen that one time, so the (already-real) verification is
-- perceptible. Later polls (every 2s while running) are not held back —
-- only this first one.
local MIN_FIRST_CHECK_SECONDS = 0.6

-- Toolbar
-- NOTE before Marketplace submission: rbxassetid://4458901886 below is a
-- placeholder icon. Upload the real RoGen icon via create.roblox.com ->
-- Creations -> Images to get an asset ID you own, then replace this ID
-- with it — see MARKETPLACE_LISTING.md for the exact upload steps.

local toolbar = plugin:CreateToolbar("RoGen")
local toggleButton = toolbar:CreateButton(
	"RoGen",
	"AI code for Roblox developers",
	"rbxassetid://4458901886"
)

-- Compact status widget, not a full app — 280x200 is the practical floor
-- for the header + status card + session footer to all stay legible.
local widgetInfo = DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Right,
	true,
	false,
	300,
	240,
	280,
	200
)

local widget = plugin:CreateDockWidgetPluginGui("RoGenWidget", widgetInfo)
widget.Title = "RoGen"

-- Small helpers for the UI construction below — this file has a lot of
-- near-identical Instance.new blocks, and these cut the repetition without
-- hiding what's actually being built.

local function corner(radius, parentInstance)
	local c = Instance.new("UICorner")
	c.CornerRadius = UDim.new(0, radius)
	c.Parent = parentInstance
	return c
end

local function stroke(color, thickness, parentInstance)
	local s = Instance.new("UIStroke")
	s.Color = color
	s.Thickness = thickness
	s.Parent = parentInstance
	return s
end

local function makeLabel(props)
	local label = Instance.new("TextLabel")
	label.BackgroundTransparency = 1
	label.Font = props.font or FONT_REGULAR
	label.TextSize = props.size or 12
	label.TextColor3 = props.color or TEXT_PRIMARY
	label.TextXAlignment = props.xAlign or Enum.TextXAlignment.Center
	label.TextYAlignment = props.yAlign or Enum.TextYAlignment.Center
	label.TextWrapped = props.wrapped or false
	label.RichText = props.richText or false
	label.Text = props.text or ""
	label.Position = props.position or UDim2.new(0, 0, 0, 0)
	label.Size = props.sizeUDim or UDim2.new(1, 0, 0, 20)
	label.LayoutOrder = props.layoutOrder or 0
	label.Visible = props.visible ~= false
	label.Parent = props.parent
	return label
end

-- ============================================================
-- UI Creation
-- ============================================================
-- Every visible element lives inside this one DockWidgetPluginGui (the
-- `widget` created above) — nothing is ever parented to game.CoreGui,
-- PlayerGui, or a standalone ScreenGui.

-- Root

local root = Instance.new("Frame")
root.Size = UDim2.new(1, 0, 1, 0)
root.BackgroundColor3 = BG
root.BorderSizePixel = 0
root.Parent = widget

-- One-time external-connection disclosure. Covers the entire widget (high
-- ZIndex, not a separate ScreenGui — still inside the same
-- DockWidgetPluginGui as everything else) until the user agrees or
-- declines. Nothing else in the plugin initializes until this is resolved;
-- see initializePlugin() near the bottom of the file.

local disclosureOverlay = Instance.new("Frame")
disclosureOverlay.Size = UDim2.new(1, 0, 1, 0)
disclosureOverlay.BackgroundColor3 = BG
disclosureOverlay.BorderSizePixel = 0
disclosureOverlay.ZIndex = 100
disclosureOverlay.Visible = false
disclosureOverlay.Parent = root

local disclosurePadding = Instance.new("UIPadding")
disclosurePadding.PaddingLeft = UDim.new(0, PAD)
disclosurePadding.PaddingRight = UDim.new(0, PAD)
disclosurePadding.PaddingTop = UDim.new(0, PAD)
disclosurePadding.PaddingBottom = UDim.new(0, PAD)
disclosurePadding.Parent = disclosureOverlay

local disclosureContent = Instance.new("Frame")
disclosureContent.AnchorPoint = Vector2.new(0.5, 0.5)
disclosureContent.Position = UDim2.new(0.5, 0, 0.5, 0)
disclosureContent.Size = UDim2.new(1, 0, 0, 0)
disclosureContent.AutomaticSize = Enum.AutomaticSize.Y
disclosureContent.BackgroundTransparency = 1
disclosureContent.ZIndex = 101
disclosureContent.Parent = disclosureOverlay

local disclosureLayout = Instance.new("UIListLayout")
disclosureLayout.SortOrder = Enum.SortOrder.LayoutOrder
disclosureLayout.FillDirection = Enum.FillDirection.Vertical
disclosureLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
disclosureLayout.Padding = UDim.new(0, 8)
disclosureLayout.Parent = disclosureContent

local disclosureTitleLabel = makeLabel({
	parent = disclosureContent,
	layoutOrder = 1,
	sizeUDim = UDim2.new(1, 0, 0, 0),
	font = FONT_BOLD,
	size = 13,
	wrapped = true,
	text = "RoGen \226\128\148 External Connection Notice",
})
disclosureTitleLabel.AutomaticSize = Enum.AutomaticSize.Y
disclosureTitleLabel.ZIndex = 101

local disclosureBodyLabel = makeLabel({
	parent = disclosureContent,
	layoutOrder = 2,
	sizeUDim = UDim2.new(1, 0, 0, 0),
	size = 11,
	color = TEXT_SECONDARY,
	wrapped = true,
	text = "RoGen connects to external servers (rogen-ai.com) to generate AI code. Your text prompts are sent, plus a lightweight summary of your game's structure (instance names and types only \226\128\148 never script contents or game data) so the AI can write code that fits your project. By continuing you agree to our Terms of Service at rogen-ai.com/terms",
})
disclosureBodyLabel.AutomaticSize = Enum.AutomaticSize.Y
disclosureBodyLabel.ZIndex = 101

local disclosureButtonRow = Instance.new("Frame")
disclosureButtonRow.LayoutOrder = 3
disclosureButtonRow.Size = UDim2.new(1, 0, 0, 30)
disclosureButtonRow.BackgroundTransparency = 1
disclosureButtonRow.ZIndex = 101
disclosureButtonRow.Parent = disclosureContent

local disclosureButtonLayout = Instance.new("UIListLayout")
disclosureButtonLayout.SortOrder = Enum.SortOrder.LayoutOrder
disclosureButtonLayout.FillDirection = Enum.FillDirection.Horizontal
disclosureButtonLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
disclosureButtonLayout.VerticalAlignment = Enum.VerticalAlignment.Center
disclosureButtonLayout.Padding = UDim.new(0, 8)
disclosureButtonLayout.Parent = disclosureButtonRow

local disclosureAgreeButton = Instance.new("TextButton")
disclosureAgreeButton.LayoutOrder = 1
disclosureAgreeButton.Size = UDim2.new(0, 128, 0, 30)
disclosureAgreeButton.BackgroundColor3 = ACCENT
disclosureAgreeButton.AutoButtonColor = true
disclosureAgreeButton.Font = FONT_BOLD
disclosureAgreeButton.TextSize = 11
disclosureAgreeButton.TextColor3 = TEXT_PRIMARY
disclosureAgreeButton.Text = "I Agree \226\128\148 Continue"
disclosureAgreeButton.ZIndex = 101
disclosureAgreeButton.Parent = disclosureButtonRow
corner(8, disclosureAgreeButton)

local disclosureDeclineButton = Instance.new("TextButton")
disclosureDeclineButton.LayoutOrder = 2
disclosureDeclineButton.Size = UDim2.new(0, 80, 0, 30)
disclosureDeclineButton.BackgroundColor3 = PANEL
disclosureDeclineButton.AutoButtonColor = true
disclosureDeclineButton.Font = FONT_REGULAR
disclosureDeclineButton.TextSize = 11
disclosureDeclineButton.TextColor3 = TEXT_SECONDARY
disclosureDeclineButton.Text = "Decline"
disclosureDeclineButton.ZIndex = 101
disclosureDeclineButton.Parent = disclosureButtonRow
corner(8, disclosureDeclineButton)
stroke(BORDER, 1, disclosureDeclineButton)

-- HTTP-disabled popup (phase 2). Full-widget modal overlay — same pattern as
-- the disclosure above — surfaced by the preflight and by a failed Connect
-- when the request class is "http_disabled". The steps live in a selectable
-- TextBox because plugins have NO clipboard-write API (see loginUrlBox's note
-- below); the "Copy" button focuses+selects that box so the user's one action
-- is Ctrl+C, which is the honest closest-to-copy a plugin can offer.
local HTTP_STEPS_TEXT =
	"1. File \226\134\146 Game Settings\n2. Security tab\n3. Turn on \"Allow HTTP Requests\"\n4. Save, then click Retry"

local httpPopupOverlay = Instance.new("Frame")
httpPopupOverlay.Size = UDim2.new(1, 0, 1, 0)
httpPopupOverlay.BackgroundColor3 = BG
httpPopupOverlay.BorderSizePixel = 0
httpPopupOverlay.ZIndex = 90
httpPopupOverlay.Visible = false
httpPopupOverlay.Parent = root

local httpPopupPadding = Instance.new("UIPadding")
httpPopupPadding.PaddingLeft = UDim.new(0, PAD)
httpPopupPadding.PaddingRight = UDim.new(0, PAD)
httpPopupPadding.PaddingTop = UDim.new(0, PAD)
httpPopupPadding.PaddingBottom = UDim.new(0, PAD)
httpPopupPadding.Parent = httpPopupOverlay

local httpPopupContent = Instance.new("Frame")
httpPopupContent.AnchorPoint = Vector2.new(0.5, 0.5)
httpPopupContent.Position = UDim2.new(0.5, 0, 0.5, 0)
httpPopupContent.Size = UDim2.new(1, 0, 0, 0)
httpPopupContent.AutomaticSize = Enum.AutomaticSize.Y
httpPopupContent.BackgroundTransparency = 1
httpPopupContent.ZIndex = 91
httpPopupContent.Parent = httpPopupOverlay

local httpPopupLayout = Instance.new("UIListLayout")
httpPopupLayout.SortOrder = Enum.SortOrder.LayoutOrder
httpPopupLayout.FillDirection = Enum.FillDirection.Vertical
httpPopupLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
httpPopupLayout.Padding = UDim.new(0, 8)
httpPopupLayout.Parent = httpPopupContent

local httpPopupTitle = makeLabel({
	parent = httpPopupContent,
	layoutOrder = 1,
	sizeUDim = UDim2.new(1, 0, 0, 0),
	font = FONT_BOLD,
	size = 13,
	wrapped = true,
	text = "Turn on HTTP Requests",
})
httpPopupTitle.AutomaticSize = Enum.AutomaticSize.Y
httpPopupTitle.ZIndex = 91

local httpPopupBody = makeLabel({
	parent = httpPopupContent,
	layoutOrder = 2,
	sizeUDim = UDim2.new(1, 0, 0, 0),
	size = 11,
	color = TEXT_SECONDARY,
	wrapped = true,
	text = "RoGen needs Studio's permission to reach the internet. It's off by default \226\128\148 turn it on (once per place):",
})
httpPopupBody.AutomaticSize = Enum.AutomaticSize.Y
httpPopupBody.ZIndex = 91

local httpStepsBox = Instance.new("TextBox")
httpStepsBox.LayoutOrder = 3
httpStepsBox.Size = UDim2.new(1, 0, 0, 66)
httpStepsBox.BackgroundColor3 = PANEL
httpStepsBox.TextColor3 = TEXT_PRIMARY
httpStepsBox.Font = FONT_CODE
httpStepsBox.TextSize = 10
httpStepsBox.TextXAlignment = Enum.TextXAlignment.Left
httpStepsBox.TextYAlignment = Enum.TextYAlignment.Top
httpStepsBox.MultiLine = true
httpStepsBox.ClearTextOnFocus = false
httpStepsBox.TextWrapped = true
httpStepsBox.Text = HTTP_STEPS_TEXT
httpStepsBox.ZIndex = 91
httpStepsBox.Parent = httpPopupContent
corner(6, httpStepsBox)
stroke(BORDER, 1, httpStepsBox)
local httpStepsPadding = Instance.new("UIPadding")
httpStepsPadding.PaddingLeft = UDim.new(0, 8)
httpStepsPadding.PaddingRight = UDim.new(0, 8)
httpStepsPadding.PaddingTop = UDim.new(0, 6)
httpStepsPadding.Parent = httpStepsBox

local httpPopupButtonRow = Instance.new("Frame")
httpPopupButtonRow.LayoutOrder = 4
httpPopupButtonRow.Size = UDim2.new(1, 0, 0, 30)
httpPopupButtonRow.BackgroundTransparency = 1
httpPopupButtonRow.ZIndex = 91
httpPopupButtonRow.Parent = httpPopupContent

local httpPopupButtonLayout = Instance.new("UIListLayout")
httpPopupButtonLayout.SortOrder = Enum.SortOrder.LayoutOrder
httpPopupButtonLayout.FillDirection = Enum.FillDirection.Horizontal
httpPopupButtonLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
httpPopupButtonLayout.VerticalAlignment = Enum.VerticalAlignment.Center
httpPopupButtonLayout.Padding = UDim.new(0, 8)
httpPopupButtonLayout.Parent = httpPopupButtonRow

local httpCopyButton = Instance.new("TextButton")
httpCopyButton.LayoutOrder = 1
httpCopyButton.Size = UDim2.new(0, 120, 0, 30)
httpCopyButton.BackgroundColor3 = PANEL
httpCopyButton.AutoButtonColor = true
httpCopyButton.Font = FONT_REGULAR
httpCopyButton.TextSize = 11
httpCopyButton.TextColor3 = TEXT_PRIMARY
httpCopyButton.Text = "Select steps (Ctrl+C)"
httpCopyButton.ZIndex = 91
httpCopyButton.Parent = httpPopupButtonRow
corner(8, httpCopyButton)
stroke(BORDER, 1, httpCopyButton)

local httpRetryButton = Instance.new("TextButton")
httpRetryButton.LayoutOrder = 2
httpRetryButton.Size = UDim2.new(0, 90, 0, 30)
httpRetryButton.BackgroundColor3 = ACCENT
httpRetryButton.AutoButtonColor = true
httpRetryButton.Font = FONT_BOLD
httpRetryButton.TextSize = 11
httpRetryButton.TextColor3 = TEXT_PRIMARY
httpRetryButton.Text = "Retry"
httpRetryButton.ZIndex = 91
httpRetryButton.Parent = httpPopupButtonRow
corner(8, httpRetryButton)

-- Debug log panel (phase 2). Full-widget overlay listing the classified
-- request history the wrapper records, so a failure is diagnosable from
-- inside the widget instead of only via warn() to Output. The log is a
-- selectable TextBox for the same Ctrl+C-copy reason as everything else.
local debugOverlay = Instance.new("Frame")
debugOverlay.Size = UDim2.new(1, 0, 1, 0)
debugOverlay.BackgroundColor3 = BG
debugOverlay.BorderSizePixel = 0
debugOverlay.ZIndex = 90
debugOverlay.Visible = false
debugOverlay.Parent = root

local debugPadding = Instance.new("UIPadding")
debugPadding.PaddingLeft = UDim.new(0, PAD)
debugPadding.PaddingRight = UDim.new(0, PAD)
debugPadding.PaddingTop = UDim.new(0, PAD)
debugPadding.PaddingBottom = UDim.new(0, PAD)
debugPadding.Parent = debugOverlay

local debugTitle = makeLabel({
	parent = debugOverlay,
	position = UDim2.new(0, 0, 0, 0),
	sizeUDim = UDim2.new(1, -60, 0, 16),
	font = FONT_BOLD,
	size = 12,
	xAlign = Enum.TextXAlignment.Left,
	text = "Debug log",
})
debugTitle.ZIndex = 91

local debugCloseButton = Instance.new("TextButton")
debugCloseButton.AnchorPoint = Vector2.new(1, 0)
debugCloseButton.Position = UDim2.new(1, 0, 0, 0)
debugCloseButton.Size = UDim2.new(0, 52, 0, 18)
debugCloseButton.BackgroundColor3 = PANEL
debugCloseButton.AutoButtonColor = true
debugCloseButton.Font = FONT_REGULAR
debugCloseButton.TextSize = 10
debugCloseButton.TextColor3 = TEXT_PRIMARY
debugCloseButton.Text = "Close"
debugCloseButton.ZIndex = 91
debugCloseButton.Parent = debugOverlay
corner(6, debugCloseButton)
stroke(BORDER, 1, debugCloseButton)

local debugSyncLabel = makeLabel({
	parent = debugOverlay,
	position = UDim2.new(0, 0, 0, 20),
	sizeUDim = UDim2.new(1, 0, 0, 14),
	size = 10,
	color = TEXT_SECONDARY,
	xAlign = Enum.TextXAlignment.Left,
	text = "Last successful sync: never",
})
debugSyncLabel.ZIndex = 91

local debugLogBox = Instance.new("TextBox")
debugLogBox.Position = UDim2.new(0, 0, 0, 38)
debugLogBox.Size = UDim2.new(1, 0, 1, -76)
debugLogBox.BackgroundColor3 = PANEL
debugLogBox.TextColor3 = TEXT_SECONDARY
debugLogBox.Font = FONT_CODE
debugLogBox.TextSize = 9
debugLogBox.TextXAlignment = Enum.TextXAlignment.Left
debugLogBox.TextYAlignment = Enum.TextYAlignment.Top
debugLogBox.MultiLine = true
debugLogBox.TextWrapped = true
debugLogBox.ClearTextOnFocus = false
debugLogBox.Text = "No requests yet."
debugLogBox.ZIndex = 91
debugLogBox.Parent = debugOverlay
corner(6, debugLogBox)
stroke(BORDER, 1, debugLogBox)
local debugLogBoxPadding = Instance.new("UIPadding")
debugLogBoxPadding.PaddingLeft = UDim.new(0, 6)
debugLogBoxPadding.PaddingRight = UDim.new(0, 6)
debugLogBoxPadding.PaddingTop = UDim.new(0, 4)
debugLogBoxPadding.Parent = debugLogBox

local debugCopyButton = Instance.new("TextButton")
debugCopyButton.AnchorPoint = Vector2.new(0.5, 1)
debugCopyButton.Position = UDim2.new(0.5, 0, 1, 0)
debugCopyButton.Size = UDim2.new(0, 150, 0, 26)
debugCopyButton.BackgroundColor3 = PANEL
debugCopyButton.AutoButtonColor = true
debugCopyButton.Font = FONT_REGULAR
debugCopyButton.TextSize = 10
debugCopyButton.TextColor3 = TEXT_PRIMARY
debugCopyButton.Text = "Select all (Ctrl+C)"
debugCopyButton.ZIndex = 91
debugCopyButton.Parent = debugOverlay
corner(6, debugCopyButton)
stroke(BORDER, 1, debugCopyButton)

-- Overlay drivers (phase 2). Defined here, right after their UI objects, so
-- they're in scope for the button wiring in the Event Handlers section and
-- for showHttpDisabledPopup's callers (startConnect / the preflight).

-- The honest "copy": there's no clipboard-write API for plugins, so this
-- focuses a TextBox and selects all of it, leaving the user one keystroke
-- (Ctrl+C) away from a real copy. task.defer so the focus lands after the
-- overlay is actually visible.
local function focusAndSelectAll(textBox)
	task.defer(function()
		textBox:CaptureFocus()
		pcall(function()
			textBox.CursorPosition = #textBox.Text + 1
			textBox.SelectionStart = 1
		end)
	end)
end

local function renderDebugLog()
	if #debugLog == 0 then
		debugLogBox.Text = "No requests yet."
	else
		local lines = {}
		for _, e in ipairs(debugLog) do
			local status = e.status and tostring(e.status) or "-"
			local line = string.format("%s  %s %s  [%s %s]", e.t or "", e.method or "", e.path or "", status, e.class or "")
			if e.note and e.note ~= "" then
				line = line .. "  " .. e.note
			end
			table.insert(lines, line)
		end
		debugLogBox.Text = table.concat(lines, "\n")
	end
	if lastSuccessfulSyncAt then
		debugSyncLabel.Text = "Last successful sync: " .. os.date("%H:%M:%S", lastSuccessfulSyncAt)
	else
		debugSyncLabel.Text = "Last successful sync: never"
	end
end

local debugOpen = false

-- Live-refresh the panel while it's open (see pushDebugLog, which calls every
-- registered listener on each new entry).
table.insert(debugLogListeners, function()
	if debugOpen then
		renderDebugLog()
	end
end)

local function showDebugPanel()
	debugOpen = true
	renderDebugLog()
	debugOverlay.Visible = true
end

local function hideDebugPanel()
	debugOpen = false
	debugOverlay.Visible = false
end

local function showHttpDisabledPopup()
	httpPopupOverlay.Visible = true
	focusAndSelectAll(httpStepsBox)
end

local function hideHttpDisabledPopup()
	httpPopupOverlay.Visible = false
end

-- Header: wordmark + version on the left, a compact connection dot+text on
-- the right (separate from the larger status circle in the main card below
-- — the header one is the always-visible-at-a-glance indicator).

local header = Instance.new("Frame")
header.Size = UDim2.new(1, 0, 0, HEADER_HEIGHT)
header.BackgroundColor3 = BG
header.BorderSizePixel = 0
header.Parent = root

local brandLabel = makeLabel({
	parent = header,
	position = UDim2.new(0, PAD, 0, 0),
	sizeUDim = UDim2.new(0, 70, 1, 0),
	font = FONT_BOLD,
	size = 17,
	xAlign = Enum.TextXAlignment.Left,
	richText = true,
	text = WORDMARK_RICH,
})

local versionLabel = makeLabel({
	parent = header,
	position = UDim2.new(0, PAD + 62, 0, 0),
	sizeUDim = UDim2.new(0, 40, 1, 0),
	size = 10,
	color = VERSION_COLOR,
	xAlign = Enum.TextXAlignment.Left,
	text = PLUGIN_VERSION,
})

-- Small, unobtrusive debug-panel toggle centered in the header — opens the
-- request log (phase 2). Anchored to center so it stays put as the widget
-- resizes, between the wordmark on the left and the status readout on the
-- right.
local debugToggleButton = Instance.new("TextButton")
debugToggleButton.AnchorPoint = Vector2.new(0.5, 0.5)
debugToggleButton.Position = UDim2.new(0.5, 6, 0.5, 0)
debugToggleButton.Size = UDim2.new(0, 20, 0, 16)
debugToggleButton.BackgroundTransparency = 1
debugToggleButton.AutoButtonColor = false
debugToggleButton.Font = FONT_REGULAR
debugToggleButton.TextSize = 12
debugToggleButton.TextColor3 = VERSION_COLOR
debugToggleButton.Text = "\226\139\175"
debugToggleButton.Parent = header

local headerStatusText = makeLabel({
	parent = header,
	position = UDim2.new(1, -108, 0, 0),
	sizeUDim = UDim2.new(0, 80, 1, 0),
	size = 11,
	font = FONT_REGULAR,
	color = TEXT_SECONDARY,
	xAlign = Enum.TextXAlignment.Right,
	text = "Checking...",
})

local headerDot = Instance.new("Frame")
headerDot.AnchorPoint = Vector2.new(0.5, 0.5)
headerDot.Position = UDim2.new(1, -14, 0.5, 0)
headerDot.Size = UDim2.new(0, 8, 0, 8)
headerDot.BackgroundColor3 = PENDING
headerDot.BorderSizePixel = 0
headerDot.Parent = header
corner(4, headerDot)
local headerDotScale = Instance.new("UIScale")
headerDotScale.Parent = headerDot

local headerDivider = Instance.new("Frame")
headerDivider.Size = UDim2.new(1, 0, 0, 1)
headerDivider.Position = UDim2.new(0, 0, 0, HEADER_HEIGHT)
headerDivider.BackgroundColor3 = BORDER
headerDivider.BorderSizePixel = 0
headerDivider.Parent = root

-- Status card: the entire main content area, styled as an actual card
-- (background + border) to match the website's card treatment, rather than
-- floating text on the bare background like before.

local statusCardArea = Instance.new("Frame")
statusCardArea.Position = UDim2.new(0, PAD, 0, HEADER_HEIGHT + 1 + PAD)
statusCardArea.Size = UDim2.new(1, -PAD * 2, 1, -(HEADER_HEIGHT + 1 + SESSION_HEIGHT + PAD * 2))
statusCardArea.BackgroundColor3 = PANEL
statusCardArea.BorderSizePixel = 0
statusCardArea.Parent = root
corner(10, statusCardArea)
local statusCardStroke = stroke(BORDER, 1, statusCardArea)

local statusCardPadding = Instance.new("UIPadding")
statusCardPadding.PaddingLeft = UDim.new(0, PAD)
statusCardPadding.PaddingRight = UDim.new(0, PAD)
statusCardPadding.PaddingTop = UDim.new(0, PAD)
statusCardPadding.PaddingBottom = UDim.new(0, PAD)
statusCardPadding.Parent = statusCardArea

-- Signed-in content: dot + message + sub-message, centered as a group.

local statusContent = Instance.new("Frame")
statusContent.AnchorPoint = Vector2.new(0.5, 0.5)
statusContent.Position = UDim2.new(0.5, 0, 0.5, 0)
statusContent.Size = UDim2.new(1, 0, 0, 0)
statusContent.AutomaticSize = Enum.AutomaticSize.Y
statusContent.BackgroundTransparency = 1
statusContent.Parent = statusCardArea

local statusContentLayout = Instance.new("UIListLayout")
statusContentLayout.SortOrder = Enum.SortOrder.LayoutOrder
statusContentLayout.FillDirection = Enum.FillDirection.Vertical
statusContentLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
statusContentLayout.Padding = UDim.new(0, 8)
statusContentLayout.Parent = statusContent

local statusDot = Instance.new("Frame")
statusDot.LayoutOrder = 1
statusDot.Size = UDim2.new(0, 40, 0, 40)
statusDot.BackgroundColor3 = ERROR_COLOR
statusDot.BorderSizePixel = 0
statusDot.Parent = statusContent
corner(20, statusDot)
local statusDotScale = Instance.new("UIScale")
statusDotScale.Parent = statusDot

local statusLabel = makeLabel({
	parent = statusContent,
	layoutOrder = 2,
	sizeUDim = UDim2.new(1, 0, 0, 0),
	font = FONT_BOLD,
	size = 14,
	wrapped = true,
	text = "Disconnected",
})
statusLabel.AutomaticSize = Enum.AutomaticSize.Y

local lastActivityLabel = makeLabel({
	parent = statusContent,
	layoutOrder = 3,
	sizeUDim = UDim2.new(1, 0, 0, 14),
	size = 11,
	color = TEXT_SECONDARY,
	text = "Last code received: Never",
})

-- Indeterminate progress bar — pinned to the bottom of the card, only
-- shown while receiving/inserting.

local progressTrack = Instance.new("Frame")
progressTrack.AnchorPoint = Vector2.new(0.5, 1)
progressTrack.Position = UDim2.new(0.5, 0, 1, 0)
progressTrack.Size = UDim2.new(1, 0, 0, 3)
progressTrack.BackgroundColor3 = BORDER
progressTrack.BorderSizePixel = 0
progressTrack.Visible = false
progressTrack.Parent = statusCardArea
corner(2, progressTrack)
progressTrack.ClipsDescendants = true

local progressThumb = Instance.new("Frame")
progressThumb.Position = UDim2.new(-0.3, 0, 0, 0)
progressThumb.Size = UDim2.new(0.3, 0, 1, 0)
progressThumb.BackgroundColor3 = ACCENT
progressThumb.BorderSizePixel = 0
progressThumb.Parent = progressTrack
corner(2, progressThumb)

-- Not-signed-in content: lock icon, prompt, and either the "Open RoGen"
-- button (idle) or the copyable login link (mid sign-in).

local signedOutContent = Instance.new("Frame")
signedOutContent.AnchorPoint = Vector2.new(0.5, 0.5)
signedOutContent.Position = UDim2.new(0.5, 0, 0.5, 0)
signedOutContent.Size = UDim2.new(1, 0, 0, 0)
signedOutContent.AutomaticSize = Enum.AutomaticSize.Y
signedOutContent.BackgroundTransparency = 1
signedOutContent.Visible = false
signedOutContent.Parent = statusCardArea

local signedOutLayout = Instance.new("UIListLayout")
signedOutLayout.SortOrder = Enum.SortOrder.LayoutOrder
signedOutLayout.FillDirection = Enum.FillDirection.Vertical
signedOutLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
signedOutLayout.Padding = UDim.new(0, 8)
signedOutLayout.Parent = signedOutContent

local lockLabel = makeLabel({
	parent = signedOutContent,
	layoutOrder = 1,
	sizeUDim = UDim2.new(1, 0, 0, 28),
	size = 24,
	text = "\240\159\148\146", -- 🔒
})

local signInTitleLabel = makeLabel({
	parent = signedOutContent,
	layoutOrder = 2,
	sizeUDim = UDim2.new(1, 0, 0, 0),
	font = FONT_BOLD,
	size = 13,
	wrapped = true,
	text = "Sign in to use RoGen",
})
signInTitleLabel.AutomaticSize = Enum.AutomaticSize.Y

local signInSubtextLabel = makeLabel({
	parent = signedOutContent,
	layoutOrder = 3,
	sizeUDim = UDim2.new(1, 0, 0, 0),
	size = 11,
	color = TEXT_SECONDARY,
	wrapped = true,
	text = "Connect your account to start receiving AI-generated code",
})
signInSubtextLabel.AutomaticSize = Enum.AutomaticSize.Y

local openRoGenButton = Instance.new("TextButton")
openRoGenButton.LayoutOrder = 4
openRoGenButton.Size = UDim2.new(0, 160, 0, 32)
openRoGenButton.BackgroundColor3 = ACCENT
openRoGenButton.AutoButtonColor = true
openRoGenButton.Font = FONT_BOLD
openRoGenButton.TextSize = 13
openRoGenButton.TextColor3 = TEXT_PRIMARY
openRoGenButton.Text = "Connect to RoGen"
openRoGenButton.Parent = signedOutContent
corner(8, openRoGenButton)

-- Large, readable fallback for when the pasted-link path doesn't pan out
-- (clipboard managers that don't cooperate, switching to a different
-- device's browser, etc.) — rogen-ai.com/connect also accepts typing this
-- in by hand. Monospace so visually similar characters (0/O, 1/I/l) are
-- easier to tell apart; the server's own code alphabet already excludes
-- the worst offenders.
local deviceCodeLabel = makeLabel({
	parent = signedOutContent,
	layoutOrder = 5,
	sizeUDim = UDim2.new(1, 0, 0, 24),
	font = FONT_CODE,
	size = 20,
	wrapped = false,
	text = "",
	visible = false,
})

-- Copyable login link — the actual mechanism that works from a plugin.
-- Studio plugins can't call GuiService:OpenBrowserWindow (RobloxScriptSecurity,
-- above the PluginSecurity plugins run under) and there is no clipboard-write
-- API for plugins at all (confirmed by Roblox's own open feature request for
-- one). This box auto-focuses and auto-selects its text the moment it
-- appears, so the user's only action is Ctrl+C then paste into their browser.
local loginUrlBox = Instance.new("TextBox")
loginUrlBox.LayoutOrder = 6
loginUrlBox.Size = UDim2.new(1, 0, 0, 26)
loginUrlBox.BackgroundColor3 = BG
loginUrlBox.TextColor3 = TEXT_PRIMARY
loginUrlBox.Font = FONT_CODE
loginUrlBox.TextSize = 10
loginUrlBox.ClearTextOnFocus = false
loginUrlBox.TextTruncate = Enum.TextTruncate.AtEnd
loginUrlBox.Text = ""
loginUrlBox.Visible = false
loginUrlBox.Parent = signedOutContent
corner(6, loginUrlBox)
stroke(BORDER, 1, loginUrlBox)
local loginUrlBoxPadding = Instance.new("UIPadding")
loginUrlBoxPadding.PaddingLeft = UDim.new(0, 8)
loginUrlBoxPadding.PaddingRight = UDim.new(0, 8)
loginUrlBoxPadding.Parent = loginUrlBox

-- Session info footer

local sessionInfo = Instance.new("Frame")
sessionInfo.Position = UDim2.new(0, 0, 1, -SESSION_HEIGHT)
sessionInfo.Size = UDim2.new(1, 0, 0, SESSION_HEIGHT)
sessionInfo.BackgroundColor3 = PANEL
sessionInfo.BorderSizePixel = 0
sessionInfo.Parent = root

local sessionInfoDivider = Instance.new("Frame")
sessionInfoDivider.Size = UDim2.new(1, 0, 0, 1)
sessionInfoDivider.BackgroundColor3 = BORDER
sessionInfoDivider.BorderSizePixel = 0
sessionInfoDivider.Parent = sessionInfo

-- Signed-in row: avatar + email (left), tier badge (right), sign out
-- (small link, second row).

local avatarCircle = Instance.new("Frame")
avatarCircle.Position = UDim2.new(0, PAD, 0, 8)
avatarCircle.Size = UDim2.new(0, 18, 0, 18)
avatarCircle.BackgroundColor3 = ACCENT
avatarCircle.BorderSizePixel = 0
avatarCircle.Parent = sessionInfo
corner(9, avatarCircle)

local avatarLabel = makeLabel({
	parent = avatarCircle,
	sizeUDim = UDim2.new(1, 0, 1, 0),
	font = FONT_BOLD,
	size = 9,
	text = "?",
})

local emailLabel = makeLabel({
	parent = sessionInfo,
	position = UDim2.new(0, PAD + 24, 0, 8),
	sizeUDim = UDim2.new(1, -150, 0, 18),
	size = 11,
	color = TEXT_SECONDARY,
	xAlign = Enum.TextXAlignment.Left,
	text = "",
})
emailLabel.TextTruncate = Enum.TextTruncate.AtEnd

local tierBadge = Instance.new("TextButton")
tierBadge.AnchorPoint = Vector2.new(1, 0)
tierBadge.Position = UDim2.new(1, -PAD, 0, 7)
tierBadge.Size = UDim2.new(0, 96, 0, 18)
tierBadge.AutoButtonColor = false
tierBadge.Active = false
tierBadge.BackgroundColor3 = TEXT_SECONDARY
tierBadge.Font = FONT_BOLD
tierBadge.TextSize = 9
tierBadge.TextColor3 = TEXT_PRIMARY
tierBadge.Text = ""
tierBadge.Parent = sessionInfo
corner(9, tierBadge)

local signOutButton = Instance.new("TextButton")
signOutButton.AnchorPoint = Vector2.new(1, 0)
signOutButton.Position = UDim2.new(1, -PAD, 0, 30)
signOutButton.Size = UDim2.new(0, 70, 0, 14)
signOutButton.AutoButtonColor = false
signOutButton.BackgroundTransparency = 1
signOutButton.Font = FONT_REGULAR
signOutButton.TextSize = 10
signOutButton.TextColor3 = TEXT_SECONDARY
signOutButton.TextXAlignment = Enum.TextXAlignment.Right
signOutButton.Text = "Sign out"
signOutButton.Parent = sessionInfo

-- Not-signed-in footer: just the bare domain, centered.

local signedOutFooterLabel = makeLabel({
	parent = sessionInfo,
	sizeUDim = UDim2.new(1, 0, 1, 0),
	size = 11,
	color = TEXT_SECONDARY,
	text = WEB_DOMAIN_DISPLAY,
	visible = false,
})

-- Helpers

local function getSelectedScript()
	for _, item in ipairs(Selection:Get()) do
		if item:IsA("LuaSourceContainer") then
			return item
		end
	end
	return nil
end

local function countLines(text)
	return #text:split("\n")
end

local VALID_SCRIPT_TYPES = { Script = true, LocalScript = true, ModuleScript = true }

-- Finds an existing Tool in Workspace or StarterPack (checked in that
-- order), or creates a blank one in StarterPack if neither has one — used
-- for the "Script/LocalScript in Tool" placement case.
local function findOrCreateTool()
	local workspaceService = game:GetService("Workspace")
	local starterPack = game:GetService("StarterPack")

	for _, obj in ipairs(workspaceService:GetChildren()) do
		if obj:IsA("Tool") then
			return obj
		end
	end
	for _, obj in ipairs(starterPack:GetChildren()) do
		if obj:IsA("Tool") then
			return obj
		end
	end

	local tool = Instance.new("Tool")
	tool.Name = "RoGenTool"
	tool.Parent = starterPack
	return tool
end

-- Maps a generated script's scriptType + the AI's own free-text placement
-- description to the actual Explorer Instance it should be created under.
-- placement is matched case-insensitively by substring (checked in the
-- priority order below) since the model doesn't always phrase it
-- identically — e.g. "a LocalScript in StarterPlayerScripts" and
-- "StarterPlayerScripts" both need to match. Falls back to a sensible
-- per-type default when placement is missing or matches nothing.
local function getTargetParent(scriptType, placement)
	local text = string.lower(placement or "")

	if scriptType == "LocalScript" then
		local starterPlayer = game:GetService("StarterPlayer")
		if string.find(text, "starterplayerscripts") then
			return starterPlayer.StarterPlayerScripts
		elseif string.find(text, "startercharacterscripts") then
			return starterPlayer.StarterCharacterScripts
		elseif string.find(text, "startergui") or string.find(text, "screengui") then
			return game:GetService("StarterGui")
		elseif string.find(text, "tool") then
			return findOrCreateTool()
		end
		return starterPlayer.StarterPlayerScripts
	elseif scriptType == "ModuleScript" then
		if string.find(text, "replicatedstorage") then
			return game:GetService("ReplicatedStorage")
		elseif string.find(text, "serverscriptservice") then
			return ServerScriptService
		elseif string.find(text, "starterplayerscripts") then
			return game:GetService("StarterPlayer").StarterPlayerScripts
		end
		return game:GetService("ReplicatedStorage")
	else
		-- "Script" and any unrecognized scriptType default here.
		if string.find(text, "serverscriptservice") then
			return ServerScriptService
		elseif string.find(text, "serverstorage") then
			return game:GetService("ServerStorage")
		elseif string.find(text, "workspace") then
			return game:GetService("Workspace")
		elseif string.find(text, "tool") then
			return findOrCreateTool()
		end
		return ServerScriptService
	end
end

-- Priority: (1) a script selected in the Explorer, (2) the script open and
-- focused in the editor (inserted at the cursor/selection), (3) a brand new
-- script created with the correct class in the correct service, based on
-- scriptType + placement — see getTargetParent above. Returns
-- (targetScript, lineCount) on success, or raises a descriptive error the
-- caller can show in the status.
-- isEdit is true when this generation was informed by prior conversation
-- turns (see backend/server.js's history/isEdit) — meaning the returned
-- code is a complete revision of something already discussed, not a fresh
-- unrelated snippet. Tier 1/2 normally APPEND (the safe default for "add
-- this too" one-shot requests with no context), but appending a full
-- self-contained revision underneath the original would just duplicate
-- everything, so an edit REPLACES the target's source instead.
local function insertCode(code, scriptType, scriptName, placement, isEdit)
	local lineCount = countLines(code)

	local selected = getSelectedScript()
	if selected then
		if isEdit then
			warn("[RoGen] Tier 1 (edit): replacing source of selected script " .. selected:GetFullName())
			local ok, err = pcall(function()
				selected.Source = code
			end)
			if not ok then
				error("Could not write to " .. selected.Name .. ": " .. tostring(err), 0)
			end
		else
			warn("[RoGen] Tier 1: appending to selected script " .. selected:GetFullName())
			local ok, err = pcall(function()
				selected.Source = selected.Source .. "\n\n" .. code
			end)
			if not ok then
				error("Could not write to " .. selected.Name .. ": " .. tostring(err), 0)
			end
		end
		pcall(function()
			plugin:OpenScript(selected)
		end)
		return selected, lineCount
	end

	local activeScript = StudioService and StudioService.ActiveScript
	if activeScript and ScriptEditorService then
		local document = ScriptEditorService:FindScriptDocument(activeScript)
		if document then
			if isEdit then
				warn("[RoGen] Tier 2 (edit): replacing source of active editor script " .. activeScript:GetFullName())
				local ok = pcall(function()
					activeScript.Source = code
				end)
				if ok then
					pcall(function()
						plugin:OpenScript(activeScript)
					end)
					return activeScript, lineCount
				end
				warn("[RoGen] Tier 2 (edit): Source replace failed on " .. activeScript:GetFullName() .. ", falling back to Tier 3")
			else
				warn("[RoGen] Tier 2: editing active editor script " .. activeScript:GetFullName())
				local ok = pcall(function()
					local startLine, startChar, endLine, endChar = document:GetSelection()
					document:EditTextAsync(code, startLine, startChar, endLine, endChar)
				end)
				if ok then
					pcall(function()
						plugin:OpenScript(activeScript)
					end)
					return activeScript, lineCount
				end
				warn("[RoGen] Tier 2: EditTextAsync failed on " .. activeScript:GetFullName() .. ", falling back to Tier 3")
			end
		end
	end

	local validType = VALID_SCRIPT_TYPES[scriptType] and scriptType or "Script"
	local targetParent = getTargetParent(validType, placement)
	warn(
		"[RoGen] Tier 3: creating new "
			.. validType
			.. " named '"
			.. tostring(scriptName)
			.. "' under "
			.. targetParent:GetFullName()
	)

	local newScript = Instance.new(validType)
	newScript.Name = (scriptName and scriptName ~= "") and scriptName or "RoGenScript"
	newScript.Source = code
	-- ModuleScript has no Disabled property — only Script/LocalScript do.
	if validType ~= "ModuleScript" then
		newScript.Disabled = false
	end
	newScript.Parent = targetParent
	warn("[RoGen] Tier 3: created " .. newScript:GetFullName() .. " (parent set: " .. tostring(newScript.Parent ~= nil) .. ")")
	pcall(function()
		plugin:OpenScript(newScript)
	end)
	return newScript, lineCount
end

-- Maps a modelSpec part's "shape" string to the correct Instance class/Shape.
-- Wedge and CornerWedge are their own Instance classes in Roblox (there is
-- no Enum.PartType value for them) — everything else is a plain Part with
-- its Shape property set.
local function createPartInstance(shape)
	if shape == "Wedge" then
		return Instance.new("WedgePart")
	elseif shape == "CornerWedge" then
		return Instance.new("CornerWedgePart")
	end

	local part = Instance.new("Part")
	if shape == "Cylinder" then
		part.Shape = Enum.PartType.Cylinder
	elseif shape == "Ball" then
		part.Shape = Enum.PartType.Ball
	else
		part.Shape = Enum.PartType.Block
	end
	return part
end

-- Builds a modelSpec (see backend/ai-provider.js's normalizeModelSpec)
-- directly into real Instances, parented into Workspace immediately — unlike
-- the old approach of generating a Script that built the model at runtime,
-- this runs synchronously in Edit mode, so the model is visible without
-- ever hitting Play. Positions in the spec are offsets relative to the
-- PrimaryPart, anchored to wherever the Studio camera is currently looking
-- (or a safe spot above the baseplate if the camera is unavailable).
-- isEdit mirrors insertCode's flag: true when this modelSpec revises one
-- generated earlier in the conversation (see backend/ai-provider.js's
-- "EDITING A PREVIOUSLY GENERATED MODEL" instruction, which tells the model
-- to keep the same modelName when editing). When true, any existing model
-- with that same name in the RoGen folder is replaced rather than left
-- behind as a duplicate.
local function buildModelFromSpec(modelSpec, isEdit)
	local model = Instance.new("Model")
	model.Name = (modelSpec.modelName and modelSpec.modelName ~= "") and modelSpec.modelName or "RoGenModel"

	local basePosition = Vector3.new(0, 10, 0)
	local camera = workspace.CurrentCamera
	if camera then
		local ok, pos = pcall(function()
			return camera.CFrame.Position + camera.CFrame.LookVector * 10
		end)
		if ok and typeof(pos) == "Vector3" then
			basePosition = pos
		end
	end

	local partsByName = {}

	for _, partSpec in ipairs(modelSpec.parts or {}) do
		local ok, err = pcall(function()
			local newPart = createPartInstance(partSpec.shape)
			newPart.Name = (partSpec.name and partSpec.name ~= "") and partSpec.name or "Part"

			local size = partSpec.size or {}
			newPart.Size = Vector3.new(size[1] or 1, size[2] or 1, size[3] or 1)

			local position = partSpec.position or {}
			newPart.Position = basePosition + Vector3.new(position[1] or 0, position[2] or 0, position[3] or 0)

			local color = partSpec.color or {}
			newPart.Color = Color3.fromRGB(color[1] or 163, color[2] or 162, color[3] or 165)

			local materialOk, materialEnum = pcall(function()
				return Enum.Material[partSpec.material]
			end)
			newPart.Material = (materialOk and materialEnum) and materialEnum or Enum.Material.Plastic

			newPart.Anchored = partSpec.anchored == true
			newPart.CanCollide = partSpec.canCollide == true
			-- Parent set last, matching the same rule generated scripts must
			-- follow — properties should be set before a part joins the tree.
			newPart.Parent = model

			partsByName[newPart.Name] = newPart
		end)
		if not ok then
			warn("[RoGen] failed to build part '" .. tostring(partSpec and partSpec.name) .. "': " .. tostring(err))
		end
	end

	local primaryPart = partsByName[modelSpec.primaryPartName]
	if primaryPart then
		model.PrimaryPart = primaryPart
	end

	for _, weld in ipairs(modelSpec.welds or {}) do
		local part1 = partsByName[weld.part1]
		local part2 = partsByName[weld.part2]
		if part1 and part2 then
			local weldConstraint = Instance.new("WeldConstraint")
			weldConstraint.Part0 = part1
			weldConstraint.Part1 = part2
			weldConstraint.Parent = part1
		end
	end

	local workspaceService = game:GetService("Workspace")
	local roGenFolder = workspaceService:FindFirstChild("RoGen")
	if not (roGenFolder and roGenFolder:IsA("Folder")) then
		roGenFolder = Instance.new("Folder")
		roGenFolder.Name = "RoGen"
		roGenFolder.Parent = workspaceService
	end

	if isEdit then
		local existing = roGenFolder:FindFirstChild(model.Name)
		if existing and existing:IsA("Model") then
			warn("[RoGen] (edit): replacing existing model '" .. model.Name .. "' in Workspace.RoGen")
			existing:Destroy()
		end
	end

	model.Parent = roGenFolder

	Selection:Set({ model })

	return model
end

-- ============================================================
-- Game Context Gathering
-- ============================================================
-- A lightweight structural snapshot (instance names + ClassNames, 1-2 levels
-- deep — never script source, never property values) sent to the backend
-- every ~30s so a prompt typed on the website can reference what's actually
-- in the game instead of guessing blind. See the disclosure notice above for
-- what this contains; see backend/ai-provider.js's formatGameContext for how
-- it turns into text the AI actually reads.

-- Global across the whole gather, not per-service — a scene with many
-- services populated should degrade gracefully (list less of everything)
-- rather than fully listing the first few services and skipping the rest.
local GAME_CONTEXT_ITEM_CAP = 100

local CONTEXT_SERVICES = {
	{ name = "Workspace", getter = function() return game:GetService("Workspace") end },
	{ name = "ServerScriptService", getter = function() return ServerScriptService end },
	{ name = "ServerStorage", getter = function() return game:GetService("ServerStorage") end },
	{ name = "ReplicatedStorage", getter = function() return game:GetService("ReplicatedStorage") end },
	{ name = "StarterGui", getter = function() return game:GetService("StarterGui") end },
	{
		name = "StarterPlayerScripts",
		getter = function() return game:GetService("StarterPlayer").StarterPlayerScripts end,
	},
}

-- Only these ClassNames get a second (depth-2) level listed — common
-- organizational containers (a "RoGen" folder from a past generation, a
-- "Modules" folder, etc). Descending into every Script/Part's own children
-- would blow past "1-2 levels deep" for no benefit.
local CONTEXT_CONTAINER_CLASSES = { Folder = true, Model = true }

-- Builds a lightweight Explorer summary. Every real Instance lookup here
-- goes through gatherGameContext's own pcall wrapper (see sendGameContext
-- below), so a scene with something unusual in it degrades to "smaller
-- context" rather than breaking the poll loop.
local function gatherGameContext()
	local budget = GAME_CONTEXT_ITEM_CAP
	local truncated = false

	-- Every place an item gets added to the payload goes through this, so
	-- `truncated` and the cap are enforced in exactly one spot.
	local function tryConsume()
		if budget <= 0 then
			truncated = true
			return false
		end
		budget -= 1
		return true
	end

	local services = {}
	for _, entry in ipairs(CONTEXT_SERVICES) do
		local ok, container = pcall(entry.getter)
		if ok and container then
			local allChildren = container:GetChildren()
			local listed = {}
			local roGenFolderExists = false

			for _, child in ipairs(allChildren) do
				if child.Name == "RoGen" and child:IsA("Folder") then
					roGenFolderExists = true
				end

				if not tryConsume() then
					break
				end

				local item = { name = child.Name, className = child.ClassName }
				if CONTEXT_CONTAINER_CLASSES[child.ClassName] then
					local okGc, grandkids = pcall(function()
						return child:GetChildren()
					end)
					if okGc then
						local nested = {}
						for _, grandchild in ipairs(grandkids) do
							if not tryConsume() then
								break
							end
							table.insert(nested, { name = grandchild.Name, className = grandchild.ClassName })
						end
						if #nested > 0 then
							item.children = nested
						end
					end
				end
				table.insert(listed, item)
			end

			local serviceEntry = { children = listed, roGenFolderExists = roGenFolderExists }
			if #allChildren > #listed then
				serviceEntry.omittedCount = #allChildren - #listed
			end
			services[entry.name] = serviceEntry
		end
	end

	-- Called out as their own flat lists (in addition to whatever showed up
	-- in ReplicatedStorage's shallow children above) since these are
	-- specifically what new code should reuse instead of duplicating.
	local remoteEvents, remoteFunctions, moduleScripts = {}, {}, {}
	local okRepl, replicatedStorage = pcall(function()
		return game:GetService("ReplicatedStorage")
	end)
	if okRepl and replicatedStorage then
		local function scan(container)
			for _, child in ipairs(container:GetChildren()) do
				if child:IsA("RemoteEvent") then
					if tryConsume() then
						table.insert(remoteEvents, child.Name)
					end
				elseif child:IsA("RemoteFunction") then
					if tryConsume() then
						table.insert(remoteFunctions, child.Name)
					end
				elseif child:IsA("ModuleScript") then
					if tryConsume() then
						table.insert(moduleScripts, child.Name)
					end
				end
			end
		end
		scan(replicatedStorage)
		for _, child in ipairs(replicatedStorage:GetChildren()) do
			if child:IsA("Folder") then
				scan(child)
			end
		end
	end

	return {
		services = services,
		remoteEvents = remoteEvents,
		remoteFunctions = remoteFunctions,
		moduleScripts = moduleScripts,
		truncated = truncated,
	}
end

-- Gathers and POSTs in one pcall-wrapped step — a failure anywhere (a
-- gather error, a network blip) just skips this cycle silently rather than
-- surfacing as a status/error state, since this is a background sync, not
-- something the user is waiting on.
local function sendGameContext()
	local token = plugin:GetSetting("rogen_auth_token")
	if not token or token == "" then
		return
	end

	local gatherOk, context = pcall(gatherGameContext)
	if not gatherOk then
		warn("[RoGen] gatherGameContext failed: " .. tostring(context))
		return
	end

	rogenRequest({ method = "POST", path = "/game-context", body = { context = context }, auth = true, retries = 1 })
end

-- ============================================================
-- Full Script Context Gathering (Part 2 — agentic editing)
-- ============================================================
-- Unlike gatherGameContext above (names/ClassNames only, sent every ~30s
-- regardless of what's being asked), this sends real script SOURCE — only
-- when the backend explicitly asks for it via /context-request, because a
-- prompt referenced existing code the AI has no other way to see. Capped at
-- MAX_CONTEXT_SCRIPTS scripts / MAX_CONTEXT_SCRIPT_CHARS characters each to
-- keep the payload small; server.js enforces the same caps again itself
-- rather than trusting this client-side limit.

local MAX_CONTEXT_SCRIPTS = 5
local MAX_CONTEXT_SCRIPT_CHARS = 3000

local function isSourceScript(inst)
	return inst ~= nil and (inst:IsA("Script") or inst:IsA("LocalScript") or inst:IsA("ModuleScript"))
end

local function truncateSource(source)
	if #source > MAX_CONTEXT_SCRIPT_CHARS then
		return source:sub(1, MAX_CONTEXT_SCRIPT_CHARS)
	end
	return source
end

-- Gathers full source for up to MAX_CONTEXT_SCRIPTS candidate scripts, in
-- priority order: (1) the script currently selected in Explorer, (2) the
-- script open/focused in the editor, (3) any script inside a "RoGen" folder
-- in Workspace (from a past generation), (4) any script anywhere in the
-- usual services whose Name appears in the prompt text. Source is truncated
-- per-script, never omitted for being too long.
local function gatherFullScriptContext(prompt)
	local candidates = {}
	local seen = {}

	local function addCandidate(inst)
		if #candidates >= MAX_CONTEXT_SCRIPTS or not inst or seen[inst] or not isSourceScript(inst) then
			return
		end
		seen[inst] = true
		local ok, source = pcall(function()
			return inst.Source
		end)
		if ok and type(source) == "string" and #source > 0 then
			table.insert(candidates, { name = inst.Name, source = truncateSource(source) })
		end
	end

	addCandidate(getSelectedScript())

	if StudioService then
		local ok, activeScript = pcall(function()
			return StudioService.ActiveScript
		end)
		if ok then
			addCandidate(activeScript)
		end
	end

	local okWs, workspaceService = pcall(function()
		return game:GetService("Workspace")
	end)
	if okWs and #candidates < MAX_CONTEXT_SCRIPTS then
		local roGenFolder = workspaceService:FindFirstChild("RoGen")
		if roGenFolder and roGenFolder:IsA("Folder") then
			for _, descendant in ipairs(roGenFolder:GetDescendants()) do
				addCandidate(descendant)
			end
		end
	end

	if #candidates < MAX_CONTEXT_SCRIPTS then
		local lowerPrompt = string.lower(prompt or "")
		for _, entry in ipairs(CONTEXT_SERVICES) do
			if #candidates >= MAX_CONTEXT_SCRIPTS then
				break
			end
			local ok, container = pcall(entry.getter)
			if ok and container then
				for _, descendant in ipairs(container:GetDescendants()) do
					if #candidates >= MAX_CONTEXT_SCRIPTS then
						break
					end
					if isSourceScript(descendant) and not seen[descendant] then
						local lowerName = string.lower(descendant.Name)
						if #lowerName >= 3 and string.find(lowerPrompt, lowerName, 1, true) then
							addCandidate(descendant)
						end
					end
				end
			end
		end
	end

	return candidates
end

-- ============================================================
-- Polling Logic
-- ============================================================
-- Everything from here down drives the connection-status state machine and
-- the 2-second poll loop at the bottom of the file that actually talks to
-- the backend.

-- Animation helpers — TweenService stand-ins for the CSS pulse/slide/flash
-- effects described in the design: UIScale.Scale tweened for "pulsing"
-- circles, a sliding Frame for the indeterminate progress bar, and a
-- Color tween for the card border flash.

local STATUS_COLORS = {
	checking = PENDING,
	disconnected = ERROR_COLOR,
	sessionExpired = ERROR_COLOR,
	errorState = ERROR_COLOR,
	httpDisabled = ERROR_COLOR,
	connected = SUCCESS,
	done = SUCCESS,
	receiving = PENDING,
	inserting = PENDING,
	-- Reuses PENDING (amber) rather than a new color — distinct from
	-- disconnected (red) and connected (green), matching the spec's "amber/
	-- yellow, distinct from disconnected or connected" requirement.
	noPlan = PENDING,
}

local STATUS_HEADER_TEXT = {
	checking = "Checking...",
	disconnected = "Disconnected",
	sessionExpired = "Disconnected",
	errorState = "Error",
	httpDisabled = "HTTP Disabled",
	connected = "Connected",
	done = "Connected",
	receiving = "Receiving...",
	inserting = "Receiving...",
	noPlan = "No Plan",
}

-- Set from the tier the /usage endpoint returns (see refreshSessionInfo
-- below) — there's no dedicated 402 in the plugin's own request flow to key
-- off of, since the plugin never calls /generate or /continue-generation
-- itself (only the website does; the plugin just polls /pending-code for
-- whatever the website successfully queued). /usage already carries tier on
-- every call, so that's the natural signal for "signed in, but no plan."
local noActivePlan = false

-- Proactive preflight, run once at startup before any other request — pings
-- the health endpoint (no auth needed) purely to learn whether HTTP works at
-- all, so the HTTP-permission failure is surfaced up front (with the fix
-- popup) instead of mid-workflow. Returns the request class so the caller can
-- distinguish "HTTP disabled" (show the popup) from "can't reach the server"
-- (transient — don't block). isHttpDisabledError and rogenRequest both live
-- in the Centralized HTTP layer section near the top of this file.
local function checkHttpEnabled()
	local result = rogenRequest({ method = "GET", path = "/health", auth = false, retries = 2 })
	return result.class
end

-- Every pulse loop this widget can have running at once — cancelled and
-- replaced wholesale on each state change so two never overlap on the same
-- dot.
local activeTweens = {}

local function stopTween(key)
	local tween = activeTweens[key]
	if tween then
		tween:Cancel()
		activeTweens[key] = nil
	end
end

local function startPulse(key, uiScale, cycleSeconds)
	stopTween(key)
	uiScale.Scale = 1
	local info = TweenInfo.new(cycleSeconds / 2, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true)
	local tween = TweenService:Create(uiScale, info, { Scale = 1.2 })
	activeTweens[key] = tween
	tween:Play()
end

local function stopPulse(key, uiScale)
	stopTween(key)
	uiScale.Scale = 1
end

local progressPlaying = false
local function startProgressBar()
	if progressPlaying then
		return
	end
	progressPlaying = true
	progressTrack.Visible = true
	local function loop()
		while progressPlaying do
			progressThumb.Position = UDim2.new(-0.3, 0, 0, 0)
			local info = TweenInfo.new(1.1, Enum.EasingStyle.Linear, Enum.EasingDirection.InOut)
			local tween = TweenService:Create(progressThumb, info, { Position = UDim2.new(1, 0, 0, 0) })
			activeTweens.progress = tween
			tween:Play()
			tween.Completed:Wait()
		end
	end
	task.spawn(loop)
end

local function stopProgressBar()
	progressPlaying = false
	stopTween("progress")
	progressTrack.Visible = false
end

-- Brief green flash on the card border after a successful insert, then a
-- smooth fade back to the normal border color.
local function flashCardBorderSuccess()
	statusCardStroke.Color = SUCCESS
	task.delay(0.5, function()
		local info = TweenInfo.new(0.3, Enum.EasingStyle.Sine, Enum.EasingDirection.Out)
		TweenService:Create(statusCardStroke, info, { Color = BORDER }):Play()
	end)
end

local function setCardBorder(color)
	stopTween("border")
	local info = TweenInfo.new(0.2, Enum.EasingStyle.Sine, Enum.EasingDirection.Out)
	local tween = TweenService:Create(statusCardStroke, info, { Color = color })
	activeTweens.border = tween
	tween:Play()
end

local connectionState = "checking"
local lastReceivedAt = nil
-- Tracks whether the very first post-launch connectivity check has
-- resolved yet — see the MIN_FIRST_CHECK_SECONDS note in pollOnce below.
local firstCheckResolved = false

local function setStatus(state, text)
	connectionState = state
	local color = STATUS_COLORS[state] or ERROR_COLOR

	local dotInfo = TweenInfo.new(0.2, Enum.EasingStyle.Sine, Enum.EasingDirection.Out)
	TweenService:Create(statusDot, dotInfo, { BackgroundColor3 = color }):Play()
	TweenService:Create(headerDot, dotInfo, { BackgroundColor3 = color }):Play()
	statusLabel.Text = text
	headerStatusText.Text = STATUS_HEADER_TEXT[state] or "Disconnected"

	if state == "connected" then
		startPulse("statusDot", statusDotScale, 2)
		startPulse("headerDot", headerDotScale, 2)
	else
		stopPulse("statusDot", statusDotScale)
		stopPulse("headerDot", headerDotScale)
	end

	if state == "receiving" or state == "inserting" then
		startPulse("headerDot", headerDotScale, 0.8)
		startProgressBar()
		setCardBorder(ACCENT)
	else
		stopProgressBar()
		if state ~= "done" then
			setCardBorder(BORDER)
		end
	end

	if state == "done" then
		flashCardBorderSuccess()
	end
end

local function formatRelativeTime(timestamp)
	if not timestamp then
		return "Never"
	end

	local seconds = os.time() - timestamp
	if seconds < 60 then
		return "Just now"
	elseif seconds < 3600 then
		local minutes = math.floor(seconds / 60)
		return string.format("%d min%s ago", minutes, minutes == 1 and "" or "s")
	else
		local hours = math.floor(seconds / 3600)
		return string.format("%d hr%s ago", hours, hours == 1 and "" or "s")
	end
end

local function updateLastActivity()
	lastActivityLabel.Text = "Last code received: " .. formatRelativeTime(lastReceivedAt)
end

-- Derives a 1-2 letter avatar initial from the local part of an email
-- address — the only identity info /usage returns, so there's no real
-- first/last name to initialize from.
local function initialsFromEmail(email)
	local localPart = email:match("^[^@]+") or email
	local letters = localPart:gsub("[^%a]", "")
	if #letters == 0 then
		return "?"
	elseif #letters == 1 then
		return letters:upper()
	end
	return letters:sub(1, 2):upper()
end

local function applyTierBadge(tier)
	local badge = TIER_BADGES[tier] or TIER_BADGES.none
	tierBadge.BackgroundColor3 = badge.bg
	tierBadge.TextColor3 = badge.text
	tierBadge.Text = tier == "developer" and (badge.label .. " \226\136\158") or badge.label
end

-- Toggles between the signed-in status UI and the signed-out (lock icon +
-- Open RoGen) UI. Called whenever auth state changes.
local function setSignedInUI(signedIn)
	statusContent.Visible = signedIn
	progressTrack.Visible = signedIn and progressPlaying
	signedOutContent.Visible = not signedIn

	avatarCircle.Visible = signedIn
	emailLabel.Visible = signedIn
	tierBadge.Visible = signedIn
	signOutButton.Visible = signedIn
	signedOutFooterLabel.Visible = not signedIn
end

-- Resets the signed-out card back to its idle (not currently connecting)
-- appearance: lock icon + button, no login link.
local function resetSignedOutIdle()
	lockLabel.Visible = true
	signInTitleLabel.Text = "Sign in to use RoGen"
	signInSubtextLabel.Visible = true
	signInSubtextLabel.Text = "Connect your account to start receiving AI-generated code"
	openRoGenButton.Visible = true
	deviceCodeLabel.Visible = false
	loginUrlBox.Visible = false
end

-- Pulls the signed-in email + tier for the session footer. Separate from
-- the connection poll below since it only needs to run on load and right
-- after a fresh sign-in, not every 2 seconds.
local function refreshSessionInfo()
	local token = plugin:GetSetting("rogen_auth_token")
	if not token or token == "" then
		setSignedInUI(false)
		resetSignedOutIdle()
		return
	end

	local result = rogenRequest({ method = "GET", path = "/usage", auth = true, retries = 2 })

	-- A 401 is fully handled by onUnauthorized (invoked inside rogenRequest):
	-- it clears the token and flips to the signed-out UI, so there's nothing
	-- left to do here but bail.
	if result.class == "unauthorized" then
		return
	end

	if not result.ok then
		-- Reachability/permission problem — stay optimistic about the
		-- session itself (the token may still be valid) rather than signing
		-- the user out over a transient blip.
		setSignedInUI(true)
		emailLabel.Text = "Signed in"
		avatarLabel.Text = "?"
		return
	end

	local decoded = result.body
	setSignedInUI(true)
	if decoded and decoded.email then
		emailLabel.Text = decoded.email
		avatarLabel.Text = initialsFromEmail(decoded.email)
	else
		emailLabel.Text = "Signed in"
		avatarLabel.Text = "?"
	end

	if decoded and decoded.tier then
		applyTierBadge(decoded.tier)
		noActivePlan = decoded.tier == "none"
	end
end

-- Assigns the forward-declared onUnauthorized from the Centralized HTTP layer
-- (see near the top of this file). rogenRequest calls this the moment ANY
-- request comes back 401, so every call site gets uniform "token died"
-- handling for free instead of each one re-implementing it. Deliberately does
-- NOT itself make a request (no refreshSessionInfo call) — that would risk
-- recursing back through rogenRequest on another 401. Just clears the dead
-- token, tells the user why, and returns them to the Connect screen.
onUnauthorized = function()
	plugin:SetSetting("rogen_auth_token", nil)
	setSignedInUI(false)
	resetSignedOutIdle()
	setStatus("sessionExpired", "Session expired \226\128\148 click Connect to sign in again")
end

-- Browser-popup sign-in bridge: the plugin starts a pairing, shows the
-- one-time link (plus a short human code as a manual-entry fallback) for
-- the user to bring into their own browser, and polls until the website
-- (after a real login) marks it complete with a minted, non-expiring API
-- key stored as rogen_auth_token.
--
-- Status text cycles through three tiers based on real elapsed time, and an
-- animated "..." runs on its own faster ticker independent of the 2s poll
-- cadence, so the wait doesn't look frozen between network round trips.
local function pollDeviceLogin(deviceId)
	local elapsed = 0
	local animating = true
	local dotFrames = { "", ".", "..", "..." }
	local dotIndex = 0

	task.spawn(function()
		while animating do
			dotIndex = (dotIndex % #dotFrames) + 1
			local base
			if elapsed < 10 then
				base = "Waiting for browser"
			elseif elapsed < 30 then
				base = "Still waiting \226\128\148 check your browser tab"
			else
				base = "Taking a while \226\128\148 make sure you clicked \"Yes, Connect Studio\" on the page that opened"
			end
			signInSubtextLabel.Text = base .. dotFrames[dotIndex]
			task.wait(0.5)
		end
	end)

	for _ = 1, 150 do
		task.wait(2)
		elapsed += 2

		-- retries=1: this already re-runs every 2s, so in-request backoff
		-- would just add latency to a loop that's inherently a retry loop.
		local result = rogenRequest({
			method = "GET",
			path = "/auth/device/poll?deviceId=" .. deviceId,
			auth = false,
			retries = 1,
		})

		-- 404 = the backend never heard of this deviceId (expired, or wiped
		-- by a backend restart — see the in-memory-state caveat).
		if result.status == 404 then
			animating = false
			return false, "expired"
		end

		if result.ok and result.body then
			if result.body.status == "complete" and result.body.apiKey then
				animating = false
				return true, result.body.apiKey
			elseif result.body.status == "expired" then
				animating = false
				return false, "expired"
			end
		end
	end

	animating = false
	return false, "timeout"
end

local function startConnect()
	local existingToken = plugin:GetSetting("rogen_auth_token")
	if existingToken and existingToken ~= "" then
		return
	end

	openRoGenButton.Visible = false
	lockLabel.Visible = false
	signInSubtextLabel.Visible = false
	deviceCodeLabel.Visible = false
	loginUrlBox.Visible = false
	signInTitleLabel.Text = "Starting sign-in..."

	local result = rogenRequest({ method = "POST", path = "/auth/device/start", auth = false, retries = 3 })
	local decoded = result.body

	if not (result.ok and decoded and decoded.deviceId and decoded.connectUrl and decoded.code) then
		-- Every failure mode gets its own plain-English message + next
		-- action, instead of one generic error that looks identical whether
		-- HTTP is off, the server is down, or we're rate limited. This is
		-- the exact spot the "always errors on Connect" bug surfaced.
		if result.class == "http_disabled" then
			showHttpDisabledPopup()
			signInTitleLabel.Text = "Turn on HTTP Requests to connect"
			signInSubtextLabel.Text =
				"See the steps that just opened, then click below"
		elseif result.class == "network" then
			warn("[RoGen] sign-in request failed (network): " .. tostring(result.note))
			signInTitleLabel.Text = "Can't reach RoGen"
			signInSubtextLabel.Text = "Check your internet connection, then click below to retry"
		elseif result.class == "rate_limited" then
			signInTitleLabel.Text = "Too many attempts"
			signInSubtextLabel.Text = "Wait a moment, then click below to try again"
		elseif result.class == "server_error" then
			signInTitleLabel.Text = "RoGen's server had an issue"
			signInSubtextLabel.Text = "This is on our end \226\128\148 please try again in a minute"
		elseif decoded and decoded.error then
			signInTitleLabel.Text = "Could not start sign-in"
			signInSubtextLabel.Text = tostring(decoded.error)
		else
			warn("[RoGen] sign-in response missing expected fields: " .. tostring(result.raw))
			signInTitleLabel.Text = "Could not start sign-in"
			signInSubtextLabel.Text = "Click below to retry"
		end
		signInSubtextLabel.Visible = true
		openRoGenButton.Visible = true
		return
	end

	signInTitleLabel.Text = "Press Ctrl+C to copy, then paste in your browser"
	deviceCodeLabel.Text = decoded.code
	deviceCodeLabel.Visible = true
	loginUrlBox.Text = decoded.connectUrl
	loginUrlBox.Visible = true
	-- pollDeviceLogin's animated status ticker writes into this label —
	-- shown now so that text is actually visible during the wait, not just
	-- during the idle/error states this label was previously reserved for.
	signInSubtextLabel.Visible = true

	-- Auto-focus and select-all so the very next thing the user does is
	-- Ctrl+C — the closest a plugin can get to "auto-copy" (see the
	-- comment above loginUrlBox for why true clipboard access isn't
	-- available). This copies the full link; the large code above is the
	-- fallback for when that doesn't make it into the browser.
	task.defer(function()
		loginUrlBox:CaptureFocus()
		pcall(function()
			loginUrlBox.CursorPosition = #decoded.connectUrl + 1
			loginUrlBox.SelectionStart = 1
		end)
	end)

	task.spawn(function()
		local success, result = pollDeviceLogin(decoded.deviceId)

		if success then
			plugin:SetSetting("rogen_auth_token", result)
			refreshSessionInfo()
		else
			deviceCodeLabel.Visible = false
			loginUrlBox.Visible = false
			signInTitleLabel.Text = (result == "expired") and "Code expired" or "Sign-in timed out"
			signInSubtextLabel.Text = "Click below to try again"
			signInSubtextLabel.Visible = true
			openRoGenButton.Visible = true
			lockLabel.Visible = true
		end
	end)
end

-- Forward-declared: Part 4's error-watch/self-correction loop is defined
-- much further down (it needs LogService and the auto-fix attempt-tracking
-- state, alongside findTargetScript from Part 3), but insertReceivedCode
-- right below needs to call it after every successful insert, and Lua
-- resolves locals lexically — a variable declared later isn't visible to
-- code written earlier. Standard forward-declaration idiom: this local gets
-- assigned its real function value later; every closure that captures it
-- (like insertReceivedCode below) sees whatever it holds at CALL time, not
-- definition time, so this is safe.
local watchForErrorsAndAutoFix

-- Handles one code delivery: insertion, confirmation, and the
-- receiving -> inserting -> done/error -> connected status sequence.
local function insertReceivedCode(entry)
	setStatus("receiving", "Receiving code...")
	task.wait(0.3)
	setStatus("inserting", "Inserting into Studio...")

	local ok, scriptOrError, lineCount = pcall(insertCode, entry.code, entry.scriptType, entry.scriptName, entry.placement, entry.isEdit)

	if ok then
		local targetScript = scriptOrError
		lastReceivedAt = os.time()
		updateLastActivity()
		setStatus("done", "Done \226\156\147")
		lastActivityLabel.Text = string.format("%d lines inserted into %s", lineCount, targetScript.Name)

		rogenRequest({ method = "POST", path = "/confirm-received", body = { id = entry.id }, auth = true, retries = 2 })

		task.delay(3, function()
			if connectionState == "done" then
				setStatus("connected", "Connected \226\128\148 Ready to receive code")
				updateLastActivity()
			end
		end)

		watchForErrorsAndAutoFix(entry, targetScript.Name)
	else
		warn("[RoGen] insert failed: " .. tostring(scriptOrError))
		setStatus("errorState", "Insert failed \226\128\148 check Output for details")
		setCardBorder(ERROR_COLOR)

		task.delay(5, function()
			if connectionState == "errorState" then
				setStatus("connected", "Connected \226\128\148 Ready to receive code")
				updateLastActivity()
			end
		end)
	end
end

-- Same receiving -> inserting -> done/error -> connected sequence as
-- insertReceivedCode above, but builds real Instances from a modelSpec
-- instead of writing Luau source into a script.
local function insertReceivedModel(entry)
	setStatus("receiving", "Receiving model...")
	task.wait(0.3)
	setStatus("inserting", "Building model...")

	local ok, modelOrError = pcall(buildModelFromSpec, entry.modelSpec, entry.isEdit)

	if ok then
		local model = modelOrError
		lastReceivedAt = os.time()
		updateLastActivity()
		setStatus("done", "Done \226\156\147")
		lastActivityLabel.Text = string.format("%s created and visible in Workspace", model.Name)

		rogenRequest({ method = "POST", path = "/confirm-received", body = { id = entry.id }, auth = true, retries = 2 })

		task.delay(3, function()
			if connectionState == "done" then
				setStatus("connected", "Connected \226\128\148 Ready to receive code")
				updateLastActivity()
			end
		end)
	else
		warn("[RoGen] model build failed: " .. tostring(modelOrError))
		setStatus("errorState", "Model build failed \226\128\148 check Output for details")
		setCardBorder(ERROR_COLOR)

		task.delay(5, function()
			if connectionState == "errorState" then
				setStatus("connected", "Connected \226\128\148 Ready to receive code")
				updateLastActivity()
			end
		end)
	end
end

-- Loads a curated Toolbox asset by ID and parents it into Workspace.RoGen,
-- mirroring buildModelFromSpec's folder-creation/edit-replace pattern above.
-- Errors (invalid ID, removed asset, empty container) propagate via error()
-- so the caller's pcall can catch them and fall back to procedural
-- generation instead.
local function loadToolboxAsset(assetId, isEdit)
	local ok, container = pcall(function()
		return InsertService:LoadAsset(assetId)
	end)
	if not ok then
		error("LoadAsset failed for asset " .. tostring(assetId) .. ": " .. tostring(container), 0)
	end

	local children = container:GetChildren()
	if #children == 0 then
		container:Destroy()
		error("Asset " .. tostring(assetId) .. " loaded but contained nothing", 0)
	end

	-- LoadAsset always wraps the real content in a fresh Model — unwrap it
	-- when there's exactly one real child so Explorer shows the actual asset
	-- instead of a generic wrapper; multi-part assets stay grouped as-is.
	local result = container
	if #children == 1 then
		result = children[1]
		result.Parent = nil
		container:Destroy()
	end

	local workspaceService = game:GetService("Workspace")
	local roGenFolder = workspaceService:FindFirstChild("RoGen")
	if not (roGenFolder and roGenFolder:IsA("Folder")) then
		roGenFolder = Instance.new("Folder")
		roGenFolder.Name = "RoGen"
		roGenFolder.Parent = workspaceService
	end

	if isEdit then
		local existing = roGenFolder:FindFirstChild(result.Name)
		if existing then
			existing:Destroy()
		end
	end

	result.Parent = roGenFolder
	Selection:Set({ result })

	return result
end

-- Called when a queued toolbox asset fails to load (bad/removed asset ID).
-- Prompts only ever originate from the dashboard, so this plugin has no
-- prompt text to retry with locally — /regenerate-procedural recovers the
-- original prompt server-side (stored on the pendingCode entry) and
-- generates a procedural modelSpec instead, which is fed straight into
-- insertReceivedModel to reuse its existing build/confirm logic.
local function fallbackToProceduralModel(entry)
	setStatus("inserting", "Toolbox asset unavailable \226\128\148 generating a model instead...")

	local result = rogenRequest({ method = "POST", path = "/regenerate-procedural", body = { id = entry.id }, auth = true, retries = 2 })

	if not result.ok or not result.body or not result.body.modelSpec then
		warn("[RoGen] fallback regeneration failed: class=" .. tostring(result.class) .. " raw=" .. tostring(result.raw))
		setStatus("errorState", "Toolbox asset failed and fallback generation also failed \226\128\148 check Output")
		setCardBorder(ERROR_COLOR)
		task.delay(5, function()
			if connectionState == "errorState" then
				setStatus("connected", "Connected \226\128\148 Ready to receive code")
				updateLastActivity()
			end
		end)
		return
	end

	warn("[RoGen] toolbox asset load failed \226\128\148 falling back to procedural model for id=" .. tostring(entry.id))
	insertReceivedModel({ id = entry.id, modelSpec = result.body.modelSpec, isEdit = false })
end

-- Same receiving -> inserting -> done/error sequence as insertReceivedCode
-- and insertReceivedModel above, but inserts a real Toolbox asset via
-- InsertService. On failure, falls back to procedural generation instead of
-- just erroring out (see fallbackToProceduralModel above).
local function insertReceivedToolboxAsset(entry)
	setStatus("receiving", "Receiving asset...")
	task.wait(0.3)
	setStatus("inserting", "Inserting Toolbox asset...")

	local ok, modelOrError = pcall(loadToolboxAsset, entry.assetId, entry.isEdit)

	if ok then
		local model = modelOrError
		lastReceivedAt = os.time()
		updateLastActivity()
		setStatus("done", "Done \226\156\147")
		lastActivityLabel.Text = string.format("%s inserted from Toolbox and visible in Workspace", model.Name)

		rogenRequest({ method = "POST", path = "/confirm-received", body = { id = entry.id }, auth = true, retries = 2 })

		task.delay(3, function()
			if connectionState == "done" then
				setStatus("connected", "Connected \226\128\148 Ready to receive code")
				updateLastActivity()
			end
		end)
	else
		warn("[RoGen] toolbox asset load failed: " .. tostring(modelOrError))
		fallbackToProceduralModel(entry)
	end
end

-- ============================================================
-- Editing Existing Scripts (Part 3 — agentic editing)
-- ============================================================

-- Recursively searches "container" for a Script/LocalScript/ModuleScript
-- named exactly "name" (case-sensitive, matching how Roblox instance names
-- actually work) — resolves an edit action's targetScriptName back to a
-- real Instance before applying anything to it.
local function findScriptByName(container, name)
	if not container then
		return nil
	end
	for _, descendant in ipairs(container:GetDescendants()) do
		if isSourceScript(descendant) and descendant.Name == name then
			return descendant
		end
	end
	return nil
end

-- Where applyEdit looks for targetScriptName, in priority order: the RoGen
-- folder first (most likely home for anything RoGen itself created), then
-- every service a generated script could plausibly have been placed in
-- (see getTargetParent/CONTEXT_SERVICES), then StarterPack for Tool-based
-- scripts. Also reused by Part 4's watchForErrorsAndAutoFix to re-read a
-- script's current source before reporting an error.
local function findTargetScript(name)
	local okWs, workspaceService = pcall(function()
		return game:GetService("Workspace")
	end)
	if okWs then
		local roGenFolder = workspaceService:FindFirstChild("RoGen")
		if roGenFolder and roGenFolder:IsA("Folder") then
			local found = findScriptByName(roGenFolder, name)
			if found then
				return found
			end
		end
	end

	for _, entry in ipairs(CONTEXT_SERVICES) do
		local ok, container = pcall(entry.getter)
		if ok and container then
			local found = findScriptByName(container, name)
			if found then
				return found
			end
		end
	end

	local okPack, starterPack = pcall(function()
		return game:GetService("StarterPack")
	end)
	if okPack then
		local found = findScriptByName(starterPack, name)
		if found then
			return found
		end
	end

	return nil
end

-- Splices new lines into an existing Source string, inserting BEFORE the
-- given 1-indexed line number (matching EDIT_MODE_PROMPT's own description
-- of "insert_at_line") — the reliable fallback when ScriptEditorService
-- can't provide a live document for the target, which only ever happens for
-- a script that's actually open in an editor tab.
local function spliceSourceAtLine(source, lineNumber, newCode)
	local lines = source:split("\n")
	local insertAt = math.clamp(lineNumber, 1, #lines + 1)
	local newLines = newCode:split("\n")
	for i = #newLines, 1, -1 do
		table.insert(lines, insertAt, newLines[i])
	end
	return table.concat(lines, "\n")
end

-- Applies an edit-action response (see backend/ai-provider.js's
-- EDIT_MODE_PROMPT) to an existing script found by name. Falls back to
-- creating it fresh via insertCode if no script with that name exists
-- anywhere searched — per spec, a missing target degrades to a normal
-- insert rather than erroring out. Returns (targetInstance, lineCount,
-- wasActuallyEdited) — the third value tells the caller whether this went
-- through the edit path or the insert-fallback path, for status messaging.
local function applyEdit(entry)
	local target = findTargetScript(entry.targetScriptName)

	if not target then
		warn("[RoGen] edit target '" .. tostring(entry.targetScriptName) .. "' not found \226\128\148 falling back to insert")
		local newScript, lineCount =
			insertCode(entry.code, entry.scriptType, entry.targetScriptName, entry.placement, entry.isEdit)
		return newScript, lineCount, false
	end

	local lineCount = countLines(entry.code)

	if entry.editType == "append" then
		local ok, err = pcall(function()
			target.Source = target.Source .. "\n\n-- RoGen addition:\n" .. entry.code
		end)
		if not ok then
			error("Could not append to " .. target.Name .. ": " .. tostring(err), 0)
		end
	elseif entry.editType == "insert_at_line" and entry.lineNumber then
		local spliced = false
		if ScriptEditorService then
			pcall(function()
				plugin:OpenScript(target)
			end)
			local document = ScriptEditorService:FindScriptDocument(target)
			if document then
				local editOk = pcall(function()
					document:EditTextAsync(entry.code .. "\n", entry.lineNumber, 0, entry.lineNumber, 0)
				end)
				spliced = editOk
			end
		end
		if not spliced then
			local ok, err = pcall(function()
				target.Source = spliceSourceAtLine(target.Source, entry.lineNumber, entry.code)
			end)
			if not ok then
				error(
					"Could not insert into " .. target.Name .. " at line " .. tostring(entry.lineNumber) .. ": " .. tostring(err),
					0
				)
			end
		end
	else
		-- "replace", or an unrecognized editType — replace is the safe
		-- default per EDIT_MODE_PROMPT's own guidance to the model.
		local ok, err = pcall(function()
			target.Source = entry.code
		end)
		if not ok then
			error("Could not write to " .. target.Name .. ": " .. tostring(err), 0)
		end
	end

	pcall(function()
		plugin:OpenScript(target)
	end)

	return target, lineCount, true
end

-- Same receiving -> inserting -> done/error sequence as the other
-- insertReceived* functions, but applies an edit-action response via
-- applyEdit above.
local function insertReceivedEdit(entry)
	setStatus("receiving", "Receiving edit...")
	task.wait(0.3)
	setStatus("inserting", "\226\156\143\239\184\143 Editing " .. tostring(entry.targetScriptName) .. "...")

	local ok, targetOrError, lineCount, wasEdit = pcall(applyEdit, entry)

	if ok then
		local target = targetOrError
		lastReceivedAt = os.time()
		updateLastActivity()
		setStatus("done", "Done \226\156\147")
		lastActivityLabel.Text = wasEdit
			and string.format("%s modified (%s)", target.Name, entry.editType)
			or string.format("%s created (edit target not found)", target.Name)

		rogenRequest({ method = "POST", path = "/confirm-received", body = { id = entry.id }, auth = true, retries = 2 })

		task.delay(3, function()
			if connectionState == "done" then
				setStatus("connected", "Connected \226\128\148 Ready to receive code")
				updateLastActivity()
			end
		end)

		watchForErrorsAndAutoFix(entry, target.Name)
	else
		warn("[RoGen] edit failed: " .. tostring(targetOrError))
		setStatus("errorState", "Edit failed \226\128\148 check Output for details")
		setCardBorder(ERROR_COLOR)

		task.delay(5, function()
			if connectionState == "errorState" then
				setStatus("connected", "Connected \226\128\148 Ready to receive code")
				updateLastActivity()
			end
		end)
	end
end

-- ============================================================
-- Error Detection and Self-Correction (Part 4 — agentic editing)
-- ============================================================

-- Attempt counter per ORIGINAL generation id — MAX_AUTO_FIX_ATTEMPTS is
-- enforced independently here too (server.js enforces its own copy of the
-- same cap; never trust only one side of a cap like this to hold).
local autoFixAttempts = {}
local MAX_AUTO_FIX_ATTEMPTS = 2
local ERROR_WATCH_SECONDS = 3

-- Listens for new Studio error-level log messages for a fixed window
-- starting right now. GetLogHistory() doesn't expose per-entry timestamps
-- to filter by "after insertion" the way the feature really wants — a live
-- LogService.MessageOut listener is the only way to reliably catch only
-- NEW errors instead of re-detecting something that was already in the log
-- before this insertion ever happened.
local function collectErrorsFor(durationSeconds)
	local errors = {}
	local connection
	connection = LogService.MessageOut:Connect(function(message, messageType)
		if messageType == Enum.MessageType.MessageError then
			table.insert(errors, message)
		end
	end)
	task.wait(durationSeconds)
	if connection then
		connection:Disconnect()
	end
	return errors
end

-- Reports the final fixed/failed verdict for the ORIGINAL generation id, so
-- the dashboard (polling /delivery-status) can show it — see server.js's
-- /autofix-result. Only the plugin ever knows this verdict, since only the
-- plugin is watching LogService.
local function reportAutoFixResult(id, outcome, errorMessage)
	rogenRequest({
		method = "POST",
		path = "/autofix-result",
		body = { id = id, outcome = outcome, errorMessage = errorMessage },
		auth = true,
		retries = 2,
	})
end

-- Called right after a successful insert/edit (see insertReceivedCode and
-- insertReceivedEdit above). Watches for a new Studio error mentioning the
-- script that was just written; if found, reports it to /report-error so
-- the AI can attempt a fix — capped at MAX_AUTO_FIX_ATTEMPTS per original
-- generation. entry.fixAttempt/entry.originalGenerationId (see
-- server.js's /report-error) identify a fix-in-progress; their absence
-- means this is watching a fresh (attempt 0) insert/edit.
watchForErrorsAndAutoFix = function(entry, scriptName)
	local originalId = entry.originalGenerationId or entry.id
	local currentAttempt = entry.fixAttempt or autoFixAttempts[originalId] or 0

	task.spawn(function()
		local errors = collectErrorsFor(ERROR_WATCH_SECONDS)

		local matchingError = nil
		for _, message in ipairs(errors) do
			if string.find(message, scriptName, 1, true) then
				matchingError = message
				break
			end
		end

		if not matchingError then
			if currentAttempt > 0 then
				-- This watch followed an auto-fix attempt, and it's clean now.
				autoFixAttempts[originalId] = nil
				reportAutoFixResult(originalId, "fixed", nil)
			end
			return
		end

		if currentAttempt >= MAX_AUTO_FIX_ATTEMPTS then
			autoFixAttempts[originalId] = nil
			reportAutoFixResult(originalId, "failed", matchingError)
			setStatus("errorState", "Couldn't auto-fix \226\128\148 see Output for the error")
			setCardBorder(ERROR_COLOR)
			task.delay(5, function()
				if connectionState == "errorState" then
					setStatus("connected", "Connected \226\128\148 Ready to receive code")
					updateLastActivity()
				end
			end)
			return
		end

		local nextAttempt = currentAttempt + 1
		autoFixAttempts[originalId] = nextAttempt
		setStatus("inserting", "\240\159\148\167 Detected an error \226\128\148 asking RoGen to fix it...")

		local currentSource = ""
		pcall(function()
			local target = findTargetScript(scriptName)
			if target then
				currentSource = target.Source
			end
		end)

		rogenRequest({
			method = "POST",
			path = "/report-error",
			body = {
				id = originalId,
				scriptName = scriptName,
				errorMessage = matchingError,
				code = currentSource,
				attempt = nextAttempt,
			},
			auth = true,
			retries = 2,
		})
	end)
end

-- Polled alongside /pending-code (see pollOnce below) — if the backend
-- deferred a /generate call for full script context (see server.js's
-- looksLikeExistingCodeReference), this gathers and sends it, then gets out
-- of the way: the actual generated result arrives later through the normal
-- /pending-code poll, same as any other queued response.
local function pollContextRequest()
	local token = plugin:GetSetting("rogen_auth_token")
	if not token or token == "" then
		return
	end

	local result = rogenRequest({ method = "GET", path = "/context-request", auth = true, retries = 1 })
	local decoded = result.body
	if not (result.ok and decoded and decoded.requestId) then
		return
	end

	local wasConnected = connectionState == "connected"
	setStatus("receiving", "\240\159\148\141 Reading your project...")

	local gatherOk, scripts = pcall(gatherFullScriptContext, decoded.prompt or "")
	if not gatherOk then
		warn("[RoGen] gatherFullScriptContext failed: " .. tostring(scripts))
		scripts = {}
	end

	rogenRequest({
		method = "POST",
		path = "/context-response",
		body = { requestId = decoded.requestId, scripts = scripts },
		auth = true,
		retries = 2,
	})

	if wasConnected and connectionState == "receiving" then
		setStatus("connected", "Connected \226\128\148 Ready to receive code")
	end
end

-- Never allowed to crash: a bad response, a network blip, or a malformed
-- payload should degrade to "disconnected," not take the loop down. Every
-- HTTP call in this function (and everywhere else in the file) is wrapped
-- in pcall for exactly that reason.
local function pollOnce()
	local token = plugin:GetSetting("rogen_auth_token")

	if not token or token == "" then
		return
	end

	local isFirstCheck = not firstCheckResolved
	local checkStartedAt = os.clock()

	-- retries=1: the loop itself re-polls every 2s, so in-request backoff
	-- would only stall the loop.
	local result = rogenRequest({ method = "GET", path = "/pending-code", auth = true, retries = 1 })

	if isFirstCheck then
		firstCheckResolved = true
		local elapsed = os.clock() - checkStartedAt
		if elapsed < MIN_FIRST_CHECK_SECONDS then
			task.wait(MIN_FIRST_CHECK_SECONDS - elapsed)
		end
	end

	-- 401 is fully handled by onUnauthorized (invoked inside rogenRequest):
	-- token cleared, signed-out UI, "session expired" status. Nothing else.
	if result.class == "unauthorized" then
		return
	end

	if not result.ok then
		if connectionState ~= "receiving" and connectionState ~= "inserting" then
			if result.class == "http_disabled" then
				setStatus("httpDisabled", "Enable HTTP Requests in Studio Settings \226\134\146 Security to use RoGen")
			else
				setStatus("disconnected", "Disconnected")
			end
		end
		return
	end

	-- 204, or a 200 with no JSON body: nothing queued for this user.
	if result.status == 204 or result.body == nil then
		if connectionState ~= "receiving" and connectionState ~= "inserting" then
			if noActivePlan then
				setStatus("noPlan", "Signed in \226\128\148 choose a plan at rogen-ai.com/pricing to start generating")
			else
				setStatus("connected", "Connected \226\128\148 Ready to receive code")
			end
		end
		return
	end

	do
		local decoded = result.body
		if decoded and decoded.id and decoded.action == "edit" and decoded.targetScriptName then
			warn(
				"[RoGen] received pending edit: id="
					.. tostring(decoded.id)
					.. " targetScriptName="
					.. tostring(decoded.targetScriptName)
					.. " editType="
					.. tostring(decoded.editType)
			)
			insertReceivedEdit(decoded)
		elseif decoded and decoded.id and decoded.assetSource == "toolbox" and decoded.assetId then
			warn(
				"[RoGen] received pending toolbox asset: id="
					.. tostring(decoded.id)
					.. " assetId="
					.. tostring(decoded.assetId)
			)
			insertReceivedToolboxAsset(decoded)
		elseif decoded and decoded.id and decoded.modelSpec then
			warn(
				"[RoGen] received pending model: id="
					.. tostring(decoded.id)
					.. " modelName="
					.. tostring(decoded.modelSpec.modelName)
					.. " parts="
					.. tostring(decoded.modelSpec.parts and #decoded.modelSpec.parts or 0)
			)
			insertReceivedModel(decoded)
		elseif decoded and decoded.code and decoded.id then
			warn(
				"[RoGen] received pending code: id="
					.. tostring(decoded.id)
					.. " scriptType="
					.. tostring(decoded.scriptType)
					.. " scriptName="
					.. tostring(decoded.scriptName)
					.. " placement="
					.. tostring(decoded.placement)
					.. " codeLen="
					.. tostring(decoded.code and #decoded.code or 0)
			)
			insertReceivedCode(decoded)
		elseif decoded then
			warn(
				"[RoGen] 200 response missing code/id — hasCode="
					.. tostring(decoded.code ~= nil)
					.. " hasId="
					.. tostring(decoded.id ~= nil)
			)
		end
	end
end

-- ============================================================
-- Event Handlers
-- ============================================================

local running = true

-- Everything that actually talks to the network or starts the poll loop —
-- deferred behind the disclosure gate below, so nothing here runs until the
-- user has agreed (or had already agreed in a past session).
local function initializePlugin()
	refreshSessionInfo()
	updateLastActivity()
	-- Sent once immediately (rather than waiting for the first 30s tick
	-- below) so the dashboard's sync indicator doesn't sit on "not synced"
	-- for half a minute after every Studio launch.
	pcall(sendGameContext)

	-- checkHttpEnabled now returns a request CLASS, not a boolean — only the
	-- specific "http_disabled" case should surface the permission guidance up
	-- front. Any other non-ok class (network/server) is transient and
	-- shouldn't scare the user before they've even done anything; the poll
	-- loop below recovers on its own once things are reachable.
	if checkHttpEnabled() == "http_disabled" then
		setStatus("httpDisabled", "Turn on HTTP Requests: File \226\134\146 Game Settings \226\134\146 Security \226\134\146 Allow HTTP Requests")
		showHttpDisabledPopup()
	else
		-- Brief "Checking..." window from load until the first poll below
		-- resolves to a real connected/disconnected result — otherwise the
		-- dots/text would start in whatever hardcoded color they were
		-- constructed with.
		setStatus("checking", "Checking connection...")
	end

	task.spawn(function()
		-- refreshSessionInfo and sendGameContext (10s and 30s respectively)
		-- both run far less often than the 2s /pending-code poll — neither
		-- needs to be caught immediately (a tier change or a scene edit is
		-- rare/tolerant of a short delay), so this trades a little staleness
		-- for not tripling network traffic on every tick.
		local tickCount = 0
		while running do
			updateLastActivity()
			pcall(pollOnce)
			pcall(pollContextRequest)

			-- "Last successful sync" on the indicator (phase 2): while
			-- connected, keep the main status line showing how fresh the
			-- connection is. Only touches the label in the steady connected
			-- state — receiving/inserting/noPlan/error states own their own
			-- text and are left alone.
			if connectionState == "connected" and lastSuccessfulSyncAt then
				local age = os.time() - lastSuccessfulSyncAt
				statusLabel.Text = string.format("Connected \226\128\148 synced %ds ago", age)
			end

			tickCount += 1
			if tickCount % 5 == 0 then
				pcall(refreshSessionInfo)
			end
			if tickCount % 15 == 0 then
				pcall(sendGameContext)
			end
			task.wait(2)
		end
	end)
end

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

local openRoGenConnection = openRoGenButton.MouseButton1Click:Connect(startConnect)

-- Phase 2 overlay wiring. Connected here (after startConnect exists) rather
-- than inline at construction, so the Retry button can call startConnect.
local debugToggleConnection = debugToggleButton.MouseButton1Click:Connect(showDebugPanel)
local debugCloseConnection = debugCloseButton.MouseButton1Click:Connect(hideDebugPanel)
local debugCopyConnection = debugCopyButton.MouseButton1Click:Connect(function()
	focusAndSelectAll(debugLogBox)
end)
local httpCopyConnection = httpCopyButton.MouseButton1Click:Connect(function()
	focusAndSelectAll(httpStepsBox)
end)
local httpRetryConnection = httpRetryButton.MouseButton1Click:Connect(function()
	hideHttpDisabledPopup()
	startConnect()
end)

-- Double-tap confirmation: first click asks for a second one within 3
-- seconds, otherwise reverts to the plain "Sign out" label.
local signOutArmed = false
local signOutArmedToken = 0

local function performSignOut()
	plugin:SetSetting("rogen_auth_token", nil)
	setSignedInUI(false)
	resetSignedOutIdle()
	setStatus("disconnected", "Disconnected")
end

local signOutConnection = signOutButton.MouseButton1Click:Connect(function()
	if signOutArmed then
		signOutArmed = false
		signOutButton.Text = "Sign out"
		performSignOut()
		return
	end

	signOutArmed = true
	signOutButton.Text = "Confirm?"
	signOutArmedToken += 1
	local myToken = signOutArmedToken
	task.delay(3, function()
		if myToken == signOutArmedToken and signOutArmed then
			signOutArmed = false
			signOutButton.Text = "Sign out"
		end
	end)
end)

-- One-time external-connection disclosure (Rejection Reason 1): agreeing
-- persists the flag and starts the plugin normally; declining leaves the
-- plugin inert for this Studio session and makes no network calls at all,
-- respecting the user's choice rather than just gating the UI cosmetically.
--
-- The setting key is versioned ("_v2") because the text changed to disclose
-- game-structure syncing (see gatherGameContext below) — someone who agreed
-- to the OLD wording never consented to that, so this key deliberately
-- doesn't carry over their old "shown_disclosure" flag; everyone sees the
-- updated notice once and re-agrees under accurate terms.
local disclosureAgreeConnection = disclosureAgreeButton.MouseButton1Click:Connect(function()
	plugin:SetSetting("shown_disclosure_v2", true)
	disclosureOverlay.Visible = false
	initializePlugin()
end)

local disclosureDeclineConnection = disclosureDeclineButton.MouseButton1Click:Connect(function()
	disclosureTitleLabel.Text = "RoGen is disabled"
	disclosureBodyLabel.Text = "Enable RoGen by restarting Studio and accepting the connection notice."
	disclosureButtonRow.Visible = false
end)

plugin.Unloading:Connect(function()
	running = false

	toggleButtonConnection:Disconnect()
	openRoGenConnection:Disconnect()
	signOutConnection:Disconnect()
	disclosureAgreeConnection:Disconnect()
	disclosureDeclineConnection:Disconnect()
	debugToggleConnection:Disconnect()
	debugCloseConnection:Disconnect()
	debugCopyConnection:Disconnect()
	httpCopyConnection:Disconnect()
	httpRetryConnection:Disconnect()

	stopProgressBar()
	for key in pairs(activeTweens) do
		stopTween(key)
	end
end)

if plugin:GetSetting("shown_disclosure_v2") then
	initializePlugin()
else
	disclosureOverlay.Visible = true
end
]]></ProtectedString>
			<bool name="Disabled">false</bool>
			<Content name="LinkedSource"><null></null></Content>
			<token name="RunContext">0</token>
			<string name="ScriptGuid">{5b26da91-ef9d-46f5-a64d-7680fba87eb0}</string>
			<BinaryString name="AttributesSerialize"></BinaryString>
			<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
			<bool name="DefinesCapabilities">false</bool>
			<string name="Name">RoGen</string>
			<int64 name="SourceAssetId">-1</int64>
			<SharedString name="Tags">1B2M2Y8AsgTpgAmY7PhCfg==</SharedString>
		</Properties>
	</Item>
	<SharedStrings>
		<SharedString md5="1B2M2Y8AsgTpgAmY7PhCfg=="></SharedString>
	</SharedStrings>
</roblox>
