-- Copyright (c) 2025-2026 Georgii Mishchenko. All Rights Reserved.
local HttpService = game:GetService("HttpService")
local ChangeHistoryService = game:GetService("ChangeHistoryService")

local BRIDGE_URL = "http://127.0.0.1:28821"
local POLL_INTERVAL = 0.5

-- A plugin installed as a local file cannot replace itself, so the most it can
-- do is say a newer one exists. The server reads this same number out of this
-- same file, so there is one place to bump.
local PLUGIN_VERSION = 2

-- The IMAGE id (asset type 1), not the decal that wraps it: a toolbar button
-- given a decal id draws nothing at all, which is why this button was blank.
local PLUGIN_ICON = plugin:GetSetting("borisIconAssetId") or "rbxassetid://130798140865442"

local toolbar = plugin:CreateToolbar("BloxForge AI")
local toggleButton = toolbar:CreateButton("BloxForge AI", "Build worlds and games with BloxForge", PLUGIN_ICON)

local active = false
local pollConnection = nil

-- The project — what this place was last built from — lives INSIDE the place
-- file, not on our server and not on this computer. Copy the .rbxl to another
-- machine and the edits still work; open a different place and it has its own.
local PROJECT_NAME = "BorisProject"

local function projectStore()
    local storage = game:GetService("ServerStorage")
    local existing = storage:FindFirstChild(PROJECT_NAME)
    if existing and existing:IsA("StringValue") then return existing end
    local value = Instance.new("StringValue")
    value.Name = PROJECT_NAME
    value.Value = ""
    value.Parent = storage
    return value
end

local function loadProject()
    local store = projectStore()
    if store.Value == "" then return nil end
    local ok, decoded = pcall(function() return HttpService:JSONDecode(store.Value) end)
    if ok and type(decoded) == "table" then return decoded end
    return nil
end

local function clearProject()
    projectStore().Value = ""
end

local function saveProject(project)
    if type(project) ~= "table" then return end
    local ok, encoded = pcall(function() return HttpService:JSONEncode(project) end)
    if ok then projectStore().Value = encoded end
end

local function borisFolder()
    local existing = workspace:FindFirstChild("BorisBuild")
    if existing then return existing end
    local folder = Instance.new("Folder")
    folder.Name = "BorisBuild"
    folder.Parent = workspace
    return folder
end

local function collectPackItems(parent, prefix, out, counters)
    for _, child in ipairs(parent:GetChildren()) do
        if child:IsA("Model") or child:IsA("BasePart") then
            local key = prefix .. child.Name
            counters[key] = (counters[key] or 0) + 1
            local pathKey = key
            if counters[key] > 1 then pathKey = key .. "#" .. counters[key] end
            table.insert(out, { instance = child, path = pathKey })
        elseif child:IsA("Folder") then
            collectPackItems(child, prefix .. child.Name .. "/", out, counters)
        end
    end
end

local function packCandidates(container)
    local root = container:FindFirstChildWhichIsA("Model") or container
    local out = {}
    collectPackItems(root, "", out, {})
    if #out <= 1 and root ~= container then
        local alt = {}
        collectPackItems(container, "", alt, {})
        if #alt > #out then out = alt end
    end
    return out
end

local function applyTransform(instance, cmd)
    local target = instance
    if instance:IsA("Model") then
        if not instance.PrimaryPart then
            local firstPart = instance:FindFirstChildWhichIsA("BasePart", true)
            if firstPart then instance.PrimaryPart = firstPart end
        end
    end
    if cmd.position then
        local pos = Vector3.new(cmd.position.x or 0, cmd.position.y or 0, cmd.position.z or 0)
        if instance:IsA("Model") then
            instance:MoveTo(pos)
        else
            instance.Position = pos
        end
    end
    if cmd.rotation then
        if instance:IsA("BasePart") then
            instance.Orientation = Vector3.new(0, cmd.rotation, 0)
        elseif instance:IsA("Model") then
            local pivot = instance:GetPivot()
            instance:PivotTo(CFrame.new(pivot.Position) * CFrame.Angles(0, math.rad(cmd.rotation), 0))
        end
    end
    if cmd.scale and target:IsA("BasePart") then
        target.Size = target.Size * cmd.scale
    end
    if cmd.color and target:IsA("BasePart") then
        target.Color = Color3.fromRGB(cmd.color.r or 255, cmd.color.g or 255, cmd.color.b or 255)
    end
end

local KART_DRIVE_SOURCE = [[
local model = script.Parent
local seat = model:WaitForChild("VehicleSeat")
local chassis = model:WaitForChild("Chassis")
local driveAtt = chassis:WaitForChild("BorisDriveAtt")
local upAtt = chassis:WaitForChild("BorisUpAtt")

local lv = Instance.new("LinearVelocity")
lv.Attachment0 = driveAtt
lv.RelativeTo = Enum.ActuatorRelativeTo.Attachment0
lv.ForceLimitMode = Enum.ForceLimitMode.PerAxis
-- Sized from the vehicle's own mass: a hard-coded limit that moves a kart
-- leaves a heavier car unable to pull away from the grid.
local mass = math.max(chassis.AssemblyMass, 1)
lv.MaxAxesForce = Vector3.new(mass * 260, 0, mass * 260)
lv.VectorVelocity = Vector3.zero
lv.Parent = chassis

local av = Instance.new("AngularVelocity")
av.Attachment0 = driveAtt
av.RelativeTo = Enum.ActuatorRelativeTo.Attachment0
av.MaxTorque = mass * 520
av.AngularVelocity = Vector3.zero
av.Parent = chassis

local ao = Instance.new("AlignOrientation")
ao.Mode = Enum.OrientationAlignmentMode.OneAttachment
ao.Attachment0 = upAtt
ao.PrimaryAxisOnly = true
ao.MaxTorque = mass * 1750
ao.Responsiveness = 50
ao.CFrame = CFrame.Angles(0, 0, math.rad(90))
ao.Parent = chassis

local MAX_SPEED = seat.MaxSpeed
local TURN_RATE = 2.4
local speed = 0
game:GetService("RunService").Heartbeat:Connect(function(dt)
    if seat.Occupant then
        local target = seat.ThrottleFloat * MAX_SPEED
        local rate = (math.abs(target) > math.abs(speed)) and 55 or 90
        if target > speed then speed = math.min(speed + rate * dt, target)
        else speed = math.max(speed - rate * dt, target) end
        lv.VectorVelocity = Vector3.new(0, 0, -speed)
        local steerScale = math.clamp(math.abs(speed) / 18, 0, 1)
        av.AngularVelocity = Vector3.new(0, -seat.SteerFloat * TURN_RATE * steerScale * (speed >= 0 and 1 or -1), 0)
    else
        speed = 0
        lv.VectorVelocity = Vector3.zero
        av.AngularVelocity = Vector3.zero
    end
end)
]]

-- Nothing may be placed at less than this share of the size it was planned for,
-- and nothing may be grown by more than this: the two limits together keep a
-- scene honest about scale, in every genre and on every pipeline.
local MIN_SIZE_SHARE = 0.35
local MAX_GROWTH = 3

-- A Roblox character is about 5 studs tall, so a vehicle under 8 studs long
-- reads as a toy and one over 20 swallows the road.
local KART_MIN_LENGTH = 8
local KART_MAX_LENGTH = 20

local attachKartBody  -- forward-declared; defined after insertAssetObjects/wrapObjects below

local function createKart(position, rotationDeg, color, bodyAssetId)
    local pos = Vector3.new(
        (position and position.x) or 0,
        ((position and position.y) or 0) + 1.8,
        (position and position.z) or 0
    )
    local bodyColor = (color and Color3.fromRGB(color.r or 200, color.g or 50, color.b or 50)) or Color3.fromRGB(200, 50, 50)
    local kart = Instance.new("Model")
    kart.Name = "BorisKart"

    local chassis = Instance.new("Part")
    chassis.Name = "Chassis"
    chassis.Size = Vector3.new(5.5, 1.6, 8.5)
    chassis.Color = bodyColor
    chassis.Material = Enum.Material.SmoothPlastic
    chassis.CustomPhysicalProperties = PhysicalProperties.new(3, 0.25, 0.2)
    chassis.CFrame = CFrame.new(pos)
    chassis.Parent = kart
    kart.PrimaryPart = chassis

    local skid = Instance.new("Part")
    skid.Name = "Skid"
    skid.Size = Vector3.new(5.5, 1.1, 8.5)
    skid.Transparency = 1
    skid.CustomPhysicalProperties = PhysicalProperties.new(3, 0.05, 0.2)
    skid.CFrame = chassis.CFrame * CFrame.new(0, -1.35, 0)
    skid.Parent = kart
    local skidWeld = Instance.new("WeldConstraint")
    skidWeld.Part0 = chassis
    skidWeld.Part1 = skid
    skidWeld.Parent = skid

    local driveAtt = Instance.new("Attachment")
    driveAtt.Name = "BorisDriveAtt"
    driveAtt.Parent = chassis
    local upAtt = Instance.new("Attachment")
    upAtt.Name = "BorisUpAtt"
    upAtt.Orientation = Vector3.new(0, 0, 90)
    upAtt.Parent = chassis

    local function deco(name, size, offset, decoColor, material, shape)
        local part = Instance.new("Part")
        part.Name = name
        if shape then part.Shape = shape end
        part.Size = size
        part.Color = decoColor
        part.Material = material or Enum.Material.SmoothPlastic
        part.CanCollide = false
        part.Massless = true
        part.CFrame = chassis.CFrame * offset
        part.Parent = kart
        local weld = Instance.new("WeldConstraint")
        weld.Part0 = chassis
        weld.Part1 = part
        weld.Parent = part
        return part
    end

    local dark = Color3.fromRGB(25, 25, 28)
    if not bodyAssetId then
        deco("Nose", Vector3.new(3.4, 0.9, 2.6), CFrame.new(0, 0.35, -5.2), bodyColor)
        deco("NoseTip", Vector3.new(1.6, 0.7, 1.2), CFrame.new(0, 0.2, -6.6), dark)
        deco("Spoiler", Vector3.new(5.8, 0.35, 1.6), CFrame.new(0, 1.9, 4.4), bodyColor)
        deco("SpoilerStrutL", Vector3.new(0.4, 1.1, 0.4), CFrame.new(-2, 1.2, 4.4), dark)
        deco("SpoilerStrutR", Vector3.new(0.4, 1.1, 0.4), CFrame.new(2, 1.2, 4.4), dark)
        deco("SteeringColumn", Vector3.new(0.35, 1.6, 0.35), CFrame.new(0, 1.4, -2.1) * CFrame.Angles(math.rad(-30), 0, 0), dark)
        local wheelShape = Enum.PartType.Cylinder
        for _, off in ipairs({
            { x = -3.1, z = -2.9 }, { x = 3.1, z = -2.9 },
            { x = -3.1, z = 2.9 }, { x = 3.1, z = 2.9 }
        }) do
            deco("WheelDecor", Vector3.new(1.4, 2.6, 2.6),
                CFrame.new(off.x, -0.55, off.z), dark, Enum.Material.Rubber, wheelShape)
            deco("Hub", Vector3.new(1.45, 1.1, 1.1), CFrame.new(off.x, -0.55, off.z), Color3.fromRGB(200, 200, 205), Enum.Material.Metal, wheelShape)
        end
    end

    local seat = Instance.new("VehicleSeat")
    seat.Name = "VehicleSeat"
    seat.Size = Vector3.new(2.4, 0.6, 2.4)
    seat.Color = dark
    seat.Material = Enum.Material.Fabric
    seat.MaxSpeed = 55
    seat.CanCollide = false
    seat.Massless = true
    seat.CFrame = chassis.CFrame * CFrame.new(0, 1.1, 1)
    seat.Parent = kart
    local seatWeld = Instance.new("WeldConstraint")
    seatWeld.Part0 = chassis
    seatWeld.Part1 = seat
    seatWeld.Parent = seat
    local backrest = deco("SeatBack", Vector3.new(2.4, 1.8, 0.5), CFrame.new(0, 1.9, 2.3), dark, Enum.Material.Fabric)

    local drive = Instance.new("Script")
    drive.Name = "BorisKartDrive"
    drive.Source = KART_DRIVE_SOURCE
    drive.Parent = kart

    if bodyAssetId then
        pcall(attachKartBody, kart, chassis, seat, bodyAssetId)
    end

    kart.Parent = borisFolder()
    kart:PivotTo(CFrame.new(pos) * CFrame.Angles(0, math.rad(rotationDeg or 0), 0))
    return kart
end

local function stripScripts(root)
    if root:IsA("LuaSourceContainer") then root:Destroy(); return end
    for _, d in ipairs(root:GetDescendants()) do
        if d:IsA("LuaSourceContainer") then d:Destroy() end
    end
end

local function insertAssetObjects(assetId, keepScripts)
    local url = "rbxassetid://" .. assetId
    local ok, result = pcall(function() return game:GetObjects(url) end)
    if ok and result and #result > 0 then
        if not keepScripts then
            for _, obj in ipairs(result) do stripScripts(obj) end
        end
        return result
    end
    local ok2, container = pcall(function()
        return game:GetService("InsertService"):LoadAsset(assetId)
    end)
    if ok2 and container then
        local children = container:GetChildren()
        if not keepScripts then
            for _, obj in ipairs(children) do stripScripts(obj) end
        end
        return children
    end
    return nil
end

local function wrapObjects(objects, name)
    if #objects == 1 and objects[1]:IsA("Model") then
        objects[1].Name = name
        return objects[1]
    end
    local model = Instance.new("Model")
    model.Name = name
    for _, obj in ipairs(objects) do obj.Parent = model end
    return model
end

local function ensurePrimary(model)
    if model:IsA("Model") and not model.PrimaryPart then
        local part = model:FindFirstChildWhichIsA("BasePart", true)
        if part then model.PrimaryPart = part end
    end
end

local function anchorAll(root, decor)
    if root:IsA("BasePart") then
        root.Anchored = true
        if decor then root.CanCollide = false end
    end
    for _, d in ipairs(root:GetDescendants()) do
        if d:IsA("BasePart") then
            d.Anchored = true
            if decor then d.CanCollide = false end
        end
    end
end

local CORNER_SIGNS = { -0.5, 0.5 }

local function eachCorner(part, callback)
    local cf, size = part.CFrame, part.Size
    for _, ax in ipairs(CORNER_SIGNS) do
        for _, ay in ipairs(CORNER_SIGNS) do
            for _, az in ipairs(CORNER_SIGNS) do
                callback((cf * CFrame.new(size.X * ax, size.Y * ay, size.Z * az)).Position)
            end
        end
    end
end

-- True axis-aligned world box of the VISIBLE geometry, independent of how the
-- model's pivot happens to be rotated.
local function visibleWorldBox(model)
    local min, max = nil, nil
    local function consider(part)
        if part:IsA("BasePart") and part.Transparency < 1 then
            eachCorner(part, function(p)
                if not min then
                    min, max = p, p
                else
                    min = Vector3.new(math.min(min.X, p.X), math.min(min.Y, p.Y), math.min(min.Z, p.Z))
                    max = Vector3.new(math.max(max.X, p.X), math.max(max.Y, p.Y), math.max(max.Z, p.Z))
                end
            end)
        end
    end
    if model:IsA("BasePart") then consider(model) end
    for _, d in ipairs(model:GetDescendants()) do consider(d) end
    if not min then return nil end
    return (min + max) / 2, max - min
end

local function measureCenterExtents(model)
    local center, size = visibleWorldBox(model)
    if center then return center, size end
    if model:IsA("Model") then
        return model:GetBoundingBox().Position, model:GetExtentsSize()
    end
    return model.Position, model.Size
end

-- A catalog model's pivot points wherever its author (or our PrimaryPart guess)
-- left it, often rotated relative to the geometry. Placing by that pivot tips
-- models on their side and measures them in a tilted frame. Re-seating the pivot
-- upright at the geometry's centre moves nothing and makes both correct.
local function normalizePivot(model)
    if not model:IsA("Model") then return end
    local center = visibleWorldBox(model)
    if center then model.WorldPivot = CFrame.new(center) end
end

-- Lowest world-Y of the VISIBLE geometry (ignores fully-transparent collision/hitbox parts,
-- which often extend below the mesh and make AABB ground-snap float the model).
local function lowestVisibleBottom(model)
    local center, size = visibleWorldBox(model)
    if not center then return nil end
    return center.Y - size.Y / 2
end

local function groundSnap(model, groundY)
    local bottom = lowestVisibleBottom(model)
    if not bottom then
        local center, ext = measureCenterExtents(model)
        bottom = center.Y - ext.Y / 2
    end
    local dy = groundY - bottom
    if model:IsA("Model") then
        model:PivotTo(model:GetPivot() + Vector3.new(0, dy, 0))
    else
        model.Position = model.Position + Vector3.new(0, dy, 0)
    end
end

local function orientXZ(model, tx, tz, rotDeg)
    local currentY
    if model:IsA("Model") then currentY = model:GetPivot().Position.Y else currentY = model.Position.Y end
    local cf = CFrame.new(tx, currentY, tz) * CFrame.Angles(0, math.rad(rotDeg or 0), 0)
    if model:IsA("Model") then model:PivotTo(cf) else model.CFrame = cf end
end

-- Welds a sourced catalog car MODEL onto the drivable chassis as pure cosmetics
-- (CanCollide off, Massless), scaled to kart size and centred over the chassis.
-- The invisible chassis keeps the physics; the model just makes it look good.
attachKartBody = function(kart, chassis, seat, bodyAssetId)
    local id = tonumber(bodyAssetId)
    if not id then return false end
    local objects = insertAssetObjects(id)
    if not objects or #objects == 0 then return false end
    local body = wrapObjects(objects, "KartBody")
    ensurePrimary(body)
    local function cosmetic(p)
        if p:IsA("BasePart") then p.CanCollide = false; p.Massless = true; p.Anchored = false end
    end
    if body:IsA("BasePart") then cosmetic(body) end
    for _, d in ipairs(body:GetDescendants()) do cosmetic(d) end

    -- The vehicle is as big as the model says it is. Forcing every body to one
    -- kart length is what made a Range Rover come out smaller than the driver;
    -- the limits only stop a toy being unsittable or a bus filling the track.
    local ext = body:IsA("Model") and body:GetExtentsSize() or body.Size
    local longest = math.max(ext.X, ext.Z, 0.1)
    local target = math.clamp(longest, KART_MIN_LENGTH, KART_MAX_LENGTH)
    if body:IsA("Model") then
        local factor = target / longest
        if math.abs(factor - 1) > 0.02 then body:ScaleTo(body:GetScale() * factor) end
        ext = body:GetExtentsSize()
    end

    -- The frame underneath follows the body, so a car rides on a car-sized
    -- chassis and the driver sits inside it rather than beside it. Welds hold
    -- the offset they were made with, so they are released while things move.
    local welds = {}
    for _, d in ipairs(kart:GetDescendants()) do
        if d:IsA("WeldConstraint") and d.Enabled then
            d.Enabled = false
            table.insert(welds, d)
        end
    end
    local bodyLength = math.max(ext.X, ext.Z)
    local bodyWidth = math.min(ext.X, ext.Z)
    chassis.Size = Vector3.new(math.clamp(bodyWidth * 0.8, 3, 10), chassis.Size.Y, math.clamp(bodyLength * 0.9, 6, KART_MAX_LENGTH))
    local skid = kart:FindFirstChild("Skid")
    if skid then
        skid.Size = Vector3.new(chassis.Size.X, skid.Size.Y, chassis.Size.Z)
        skid.CFrame = chassis.CFrame * CFrame.new(0, -1.35, 0)
    end
    seat.CFrame = chassis.CFrame * CFrame.new(0, chassis.Size.Y / 2 + 0.4, chassis.Size.Z * 0.1)
    local backrest = kart:FindFirstChild("SeatBack")
    if backrest then backrest.CFrame = chassis.CFrame * CFrame.new(0, chassis.Size.Y / 2 + 1.2, chassis.Size.Z * 0.1 + 1.3) end

    for _, w in ipairs(welds) do w.Enabled = true end

    local rot = (ext.X > ext.Z * 1.15) and 90 or 0
    local up = ext.Y / 2 - chassis.Size.Y / 2
    local targetCF = CFrame.new(chassis.CFrame.Position + Vector3.new(0, up, 0)) * CFrame.Angles(0, math.rad(rot), 0)
    if body:IsA("Model") then body:PivotTo(targetCF) else body.CFrame = targetCF end

    local function weld(p)
        if p:IsA("BasePart") then
            local w = Instance.new("WeldConstraint")
            w.Part0 = chassis; w.Part1 = p; w.Parent = p
        end
    end
    if body:IsA("BasePart") then weld(body) end
    for _, d in ipairs(body:GetDescendants()) do weld(d) end

    body.Parent = kart
    chassis.Transparency = 1
    seat.Transparency = 1
    return true
end

local function placeAssetCore(assetId, opts)
    opts = opts or {}
    local id = tonumber(assetId)
    if not id then error("placeAsset: invalid assetId") end
    local objects = insertAssetObjects(id, opts.keepScripts)
    if not objects or #objects == 0 then error("placeAsset: insert failed for " .. tostring(assetId)) end
    local model = wrapObjects(objects, opts.name or ("asset_" .. id))
    if not opts.keepScripts then
        for _, d in ipairs(model:GetDescendants()) do
            if d:IsA("LuaSourceContainer") then d:Destroy() end
        end
    end
    ensurePrimary(model)
    normalizePivot(model)

    local pos = opts.position or {}
    orientXZ(model, pos.x or 0, pos.z or 0, opts.rotation)

    if opts.fit and model:IsA("Model") then
        local ext = model:GetExtentsSize()
        if ext.X > 0 and ext.Z > 0 then
            local factor = math.min((opts.fit.x or ext.X) / ext.X, (opts.fit.z or ext.Z) / ext.Z, 1)
            if factor > 0.01 and factor < 0.98 then
                model:ScaleTo(model:GetScale() * math.max(factor, 0.2))
            end
        end
    end

    groundSnap(model, pos.y or 0)
    anchorAll(model, opts.decor)
    model.Parent = borisFolder()
    local _, ext = measureCenterExtents(model)
    return model, ext
end

local function cloneToCore(source, placements)
    local clones = {}
    for _, p in ipairs(placements or {}) do
        local clone = source:Clone()
        local pos = p.position or {}
        orientXZ(clone, pos.x or 0, pos.z or 0, p.rotation)
        groundSnap(clone, pos.y or 0)
        clone.Parent = borisFolder()
        table.insert(clones, clone)
    end
    return clones
end

local function round1(v)
    return math.floor(v * 10 + 0.5) / 10
end

local function auditScene()
    local root = workspace:FindFirstChild("BorisBuild")
    local report = { audit = true, totalParts = 0, unanchored = 0, spawns = {}, vehicleSeats = 0, karts = 0, parts = {}, models = {}, scripts = {}, truncated = false }
    if root then
        for _, d in ipairs(root:GetDescendants()) do
            if d:IsA("BasePart") then
                report.totalParts += 1
                if not d.Anchored and not d:FindFirstAncestorOfClass("Model") then
                    report.unanchored += 1
                end
                if d:IsA("SpawnLocation") then
                    table.insert(report.spawns, { x = round1(d.Position.X), y = round1(d.Position.Y), z = round1(d.Position.Z) })
                elseif d:IsA("VehicleSeat") then
                    report.vehicleSeats += 1
                end
            end
        end
        for _, c in ipairs(root:GetChildren()) do
            if c:IsA("BasePart") then
                if #report.parts < 220 then
                    table.insert(report.parts, {
                        n = c.Name, c = c.ClassName,
                        x = round1(c.Position.X), y = round1(c.Position.Y), z = round1(c.Position.Z),
                        sx = round1(c.Size.X), sy = round1(c.Size.Y), sz = round1(c.Size.Z),
                        a = c.Anchored
                    })
                else
                    report.truncated = true
                end
            elseif c:IsA("Model") then
                if c.Name == "BorisKart" then report.karts += 1 end
                local cf, size = c:GetBoundingBox()
                if #report.models < 120 then
                    table.insert(report.models, {
                        n = c.Name,
                        x = round1(cf.Position.X), y = round1(cf.Position.Y), z = round1(cf.Position.Z),
                        sx = round1(size.X), sy = round1(size.Y), sz = round1(size.Z),
                        parts = #c:GetDescendants()
                    })
                else
                    report.truncated = true
                end
            end
        end
    else
        report.empty = true
    end
    for _, s in ipairs(game:GetService("ServerScriptService"):GetChildren()) do
        if s.Name:sub(1, 6) == "Boris_" and s:IsA("Script") then
            table.insert(report.scripts, { name = s.Name, len = #s.Source })
        end
    end
    return HttpService:JSONEncode(report)
end

local function executeCommand(cmd)
    if cmd.action == "clearScene" then
        local existing = workspace:FindFirstChild("BorisBuild")
        if existing then existing:Destroy() end
        for _, child in ipairs(game:GetService("ServerScriptService"):GetChildren()) do
            if child.Name:sub(1, 6) == "Boris_" then child:Destroy() end
        end
        -- Terrain lives outside BorisBuild, so without this the hills of every
        -- previous build would pile up on the next one.
        workspace.Terrain:Clear()
        ChangeHistoryService:SetWaypoint("Boris cleared scene")
        return "Cleared scene"

    elseif cmd.action == "createPart" then
        local part = Instance.new("Part")
        part.Name = cmd.name or "BorisPart"
        part.Size = Vector3.new(
            cmd.size and cmd.size.x or 4,
            cmd.size and cmd.size.y or 4,
            cmd.size and cmd.size.z or 4
        )
        part.Position = Vector3.new(
            cmd.position and cmd.position.x or 0,
            cmd.position and cmd.position.y or 10,
            cmd.position and cmd.position.z or 0
        )
        if cmd.orientation then
            part.Orientation = Vector3.new(cmd.orientation.x or 0, cmd.orientation.y or 0, cmd.orientation.z or 0)
        elseif cmd.rotation then
            part.Orientation = Vector3.new(0, cmd.rotation, 0)
        end
        if cmd.color then
            part.Color = Color3.fromRGB(cmd.color.r or 255, cmd.color.g or 255, cmd.color.b or 255)
        end
        if cmd.material then
            local material = Enum.Material[cmd.material]
            if material then part.Material = material end
        end
        if cmd.transparency then part.Transparency = cmd.transparency end
        if cmd.shape then
            local shape = Enum.PartType[cmd.shape]
            if shape then part.Shape = shape end
        end
        part.Anchored = true
        part.Parent = borisFolder()
        ChangeHistoryService:SetWaypoint("Boris created part")
        return "Created part: " .. part.Name

    elseif cmd.action == "createScript" then
        local newScript = Instance.new("Script")
        newScript.Name = cmd.name or "BorisScript"
        newScript.Source = cmd.source or ""
        local parentName = cmd.parent or "ServerScriptService"
        local parent = game:GetService("ServerScriptService")
        if parentName == "Workspace" then
            parent = workspace
        elseif parentName == "StarterPlayerScripts" then
            parent = game:GetService("StarterPlayer"):FindFirstChild("StarterPlayerScripts")
                or game:GetService("StarterPlayer")
        end
        newScript.Parent = parent
        ChangeHistoryService:SetWaypoint("Boris created script")
        return "Created script: " .. newScript.Name .. " in " .. parentName

    elseif cmd.action == "insertAsset" then
        local assetId = tonumber(cmd.assetId)
        if not assetId then return "insertAsset error: invalid assetId" end
        local objects = insertAssetObjects(assetId, cmd.keepScripts)
        if not objects or #objects == 0 then
            return "insertAsset error: could not load asset " .. assetId
        end
        local container = Instance.new("Folder")
        for _, obj in ipairs(objects) do obj.Parent = container end
        local model = container:FindFirstChildWhichIsA("Model") or container:FindFirstChildWhichIsA("BasePart") or container
        if cmd.childPath then
            for _, entry in ipairs(packCandidates(container)) do
                if entry.path == cmd.childPath then
                    model = entry.instance
                    break
                end
            end
        end
        if model == container then
            model = wrapObjects(container:GetChildren(), cmd.name or ("asset_" .. assetId))
        end
        model.Name = cmd.name or ("asset_" .. assetId)
        ensurePrimary(model)
        normalizePivot(model)

        local _, rawExt = measureCenterExtents(model)
        local rawX, rawY, rawZ = round1(rawExt.X), round1(rawExt.Y), round1(rawExt.Z)

        -- SHRINK ONLY. Blowing a small model up to a requested footprint is how a
        -- bush became a mountain; a model that is smaller than planned just looks
        -- small, which is never a disaster. Only oversized models are cut down,
        -- and never below a fifth of their real size.
        local target = tonumber(cmd.targetFootprint)
        if target and target > 0 and model:IsA("Model") then
            local widest = math.max(rawExt.X, rawExt.Z, rawExt.Y * 0.6)
            if widest > 0.01 then
                -- Both ways, or the scene lies about scale. Too big was fixed
                -- first (a bush blown up 8x became a white mountain), but too
                -- small was left alone, which is how catalog cars arrived as
                -- toys the driver towered over. Growth is capped hard, so
                -- correcting a toy can never recreate the mountain.
                local factor = target / widest
                if factor < 0.75 then
                    model:ScaleTo(model:GetScale() * math.max(factor, 0.2))
                elseif factor > 1 / MIN_SIZE_SHARE then
                    model:ScaleTo(model:GetScale() * math.min(factor, MAX_GROWTH))
                end
            end
        end

        local pos = cmd.position or {}
        orientXZ(model, pos.x or 0, pos.z or 0, cmd.rotation)
        groundSnap(model, pos.y or 0)
        if cmd.sink then
            local _, ext = measureCenterExtents(model)
            if ext.Y > 12 then
                local drop = math.min(ext.Y * 0.06, 4)
                if model:IsA("Model") then
                    model:PivotTo(model:GetPivot() - Vector3.new(0, drop, 0))
                else
                    model.Position = model.Position - Vector3.new(0, drop, 0)
                end
            end
        end
        anchorAll(model, false)
        model.Parent = borisFolder()
        container:Destroy()
        ChangeHistoryService:SetWaypoint("Boris inserted asset")
        return HttpService:JSONEncode({
            inserted = assetId, name = model.Name,
            x = rawX, y = rawY, z = rawZ,
            childPath = cmd.childPath
        })

    elseif cmd.action == "sculptTerrain" then
        local terrain = workspace.Terrain
        local material = Enum.Material[cmd.material] or Enum.Material.Grass
        local cell = cmd.cell or 12
        local cols = cmd.cols or 0
        local heights = cmd.heights or {}
        local filled = 0
        for index, height in ipairs(heights) do
            if height and height > 1 then
                local col = (index - 1) % cols
                local row = math.floor((index - 1) / cols)
                local x = (cmd.originX or 0) + col * cell
                local z = (cmd.originZ or 0) + row * cell
                terrain:FillBlock(
                    CFrame.new(x, height / 2, z),
                    Vector3.new(cell + 1, height, cell + 1),
                    material
                )
                filled = filled + 1
            end
        end
        ChangeHistoryService:SetWaypoint("Boris sculpted terrain")
        return "Sculpted terrain: " .. filled .. " hills"

    elseif cmd.action == "createKart" then
        local ok, kart = pcall(createKart, cmd.position, cmd.rotation, cmd.color, cmd.bodyAssetId)
        if not ok or not kart then return "createKart error: " .. tostring(kart) end
        kart.Name = cmd.name or kart.Name
        ChangeHistoryService:SetWaypoint("Boris created kart")
        return "Created kart: " .. kart.Name

    elseif cmd.action == "setLighting" then
        local Lighting = game:GetService("Lighting")
        if cmd.clockTime then Lighting.ClockTime = cmd.clockTime end
        if cmd.brightness then Lighting.Brightness = cmd.brightness end
        if cmd.ambient then
            Lighting.Ambient = Color3.fromRGB(cmd.ambient.r or 70, cmd.ambient.g or 70, cmd.ambient.b or 70)
        end
        if cmd.outdoorAmbient then
            Lighting.OutdoorAmbient = Color3.fromRGB(cmd.outdoorAmbient.r or 128, cmd.outdoorAmbient.g or 128, cmd.outdoorAmbient.b or 128)
        end
        local atmosphere = Lighting:FindFirstChildOfClass("Atmosphere")
        if not atmosphere then
            atmosphere = Instance.new("Atmosphere")
            atmosphere.Parent = Lighting
        end
        if cmd.haze then atmosphere.Haze = cmd.haze end
        if cmd.density then atmosphere.Density = cmd.density end
        if cmd.atmosphereColor then
            atmosphere.Color = Color3.fromRGB(cmd.atmosphereColor.r or 199, cmd.atmosphereColor.g or 199, cmd.atmosphereColor.b or 199)
        end
        ChangeHistoryService:SetWaypoint("Boris set lighting")
        return "Lighting set"

    elseif cmd.action == "transformInstance" then
        local folder = workspace:FindFirstChild("BorisBuild")
        local instance = folder and folder:FindFirstChild(cmd.name)
        if not instance then return "transformInstance error: not found " .. tostring(cmd.name) end
        applyTransform(instance, cmd)
        ChangeHistoryService:SetWaypoint("Boris transformed instance")
        return "Transformed " .. tostring(cmd.name)

    elseif cmd.action == "probePack" then
        local InsertService = game:GetService("InsertService")
        local assetId = tonumber(cmd.assetId)
        if not assetId then return HttpService:JSONEncode({ pack = 0, ok = false, err = "invalid assetId" }) end
        local ok, result = pcall(function()
            return InsertService:LoadAsset(assetId)
        end)
        if not ok then
            return HttpService:JSONEncode({ pack = assetId, ok = false, err = tostring(result) })
        end
        local container = result
        local items = {}
        for _, entry in ipairs(packCandidates(container)) do
            local size
            if entry.instance:IsA("Model") then
                size = entry.instance:GetExtentsSize()
            else
                size = entry.instance.Size
            end
            table.insert(items, { path = entry.path, x = size.X, y = size.Y, z = size.Z })
        end
        container:Destroy()
        return HttpService:JSONEncode({ pack = assetId, ok = true, items = items })

    elseif cmd.action == "placeAsset" then
        local ok, model, ext = pcall(placeAssetCore, cmd.assetId, cmd)
        if not ok then return HttpService:JSONEncode({ placed = false, err = tostring(model) }) end
        ChangeHistoryService:SetWaypoint("Boris placed asset")
        return HttpService:JSONEncode({ placed = true, name = model.Name, x = round1(ext.X), y = round1(ext.Y), z = round1(ext.Z) })

    elseif cmd.action == "cloneTo" then
        local folder = workspace:FindFirstChild("BorisBuild")
        local source = folder and folder:FindFirstChild(cmd.name)
        if not source then return "cloneTo error: not found " .. tostring(cmd.name) end
        local clones = cloneToCore(source, cmd.placements)
        ChangeHistoryService:SetWaypoint("Boris cloned instance")
        return "Cloned " .. tostring(cmd.name) .. " x" .. #clones

    elseif cmd.action == "probeGetObjects" then
        local assetId = tonumber(cmd.assetId)
        if not assetId then return HttpService:JSONEncode({ getObjects = 0, ok = false, err = "invalid assetId" }) end
        local ok, result = pcall(function()
            return game:GetObjects("rbxassetid://" .. assetId)
        end)
        if not ok then
            return HttpService:JSONEncode({ getObjects = assetId, ok = false, err = tostring(result) })
        end
        local objects = result
        local count = #objects
        local size = nil
        local descendants = 0
        for _, obj in ipairs(objects) do
            descendants = descendants + #obj:GetDescendants() + 1
            if not size then
                if obj:IsA("Model") then
                    size = obj:GetExtentsSize()
                elseif obj:IsA("BasePart") then
                    size = obj.Size
                end
            end
        end
        if cmd.keep then
            for _, obj in ipairs(objects) do obj.Parent = borisFolder() end
            ChangeHistoryService:SetWaypoint("Boris GetObjects probe kept")
        else
            for _, obj in ipairs(objects) do obj:Destroy() end
        end
        local report = { getObjects = assetId, ok = true, objects = count, instances = descendants }
        if size then
            report.x = size.X
            report.y = size.Y
            report.z = size.Z
        end
        return HttpService:JSONEncode(report)

    elseif cmd.action == "probeParts" then
        local assetId = tonumber(cmd.assetId)
        if not assetId then return HttpService:JSONEncode({ ok = false, err = "invalid assetId" }) end
        local objects = insertAssetObjects(assetId)
        if not objects or #objects == 0 then return HttpService:JSONEncode({ ok = false, err = "insert failed" }) end
        local model = wrapObjects(objects, "probe_" .. assetId)
        local aabbBottom, visBottom
        if model:IsA("Model") then
            local cf = model:GetBoundingBox()
            local ext = model:GetExtentsSize()
            aabbBottom = round1(cf.Position.Y - ext.Y / 2)
        else
            aabbBottom = round1(model.Position.Y - model.Size.Y / 2)
        end
        visBottom = lowestVisibleBottom(model)
        local parts = {}
        local nParts = 0
        local function record(p)
            if p:IsA("BasePart") then
                nParts = nParts + 1
                if #parts < 40 then
                    table.insert(parts, {
                        n = p.Name, c = p.ClassName, tr = round1(p.Transparency),
                        cc = p.CanCollide, sy = round1(p.Size.Y), lowY = round1(partLowestY(p))
                    })
                end
            end
        end
        if model:IsA("BasePart") then record(model) end
        for _, d in ipairs(model:GetDescendants()) do record(d) end
        model:Destroy()
        return HttpService:JSONEncode({
            ok = true, assetId = assetId, totalParts = nParts,
            aabbBottom = aabbBottom, visibleBottom = visBottom and round1(visBottom) or nil,
            gap = (visBottom and round1(visBottom - aabbBottom)) or nil, parts = parts
        })

    elseif cmd.action == "probeAsset" then
        local InsertService = game:GetService("InsertService")
        local assetId = tonumber(cmd.assetId)
        if not assetId then return HttpService:JSONEncode({ probe = 0, ok = false, err = "invalid assetId" }) end
        local ok, result = pcall(function()
            return InsertService:LoadAsset(assetId)
        end)
        if not ok then
            return HttpService:JSONEncode({ probe = assetId, ok = false, err = tostring(result) })
        end
        local container = result
        local size = nil
        local model = container:FindFirstChildWhichIsA("Model")
        if model then
            size = model:GetExtentsSize()
        else
            local part = container:FindFirstChildWhichIsA("BasePart", true)
            if part then size = part.Size end
        end
        container:Destroy()
        if not size then
            return HttpService:JSONEncode({ probe = assetId, ok = false, err = "no measurable parts" })
        end
        return HttpService:JSONEncode({ probe = assetId, ok = true, x = size.X, y = size.Y, z = size.Z })

    elseif cmd.action == "probeScripts" then
        local assetId = tonumber(cmd.assetId)
        if not assetId then return HttpService:JSONEncode({ probeScripts = 0, ok = false, err = "invalid assetId" }) end
        local ok, result = pcall(function() return game:GetObjects("rbxassetid://" .. assetId) end)
        if not ok or not result or #result == 0 then
            return HttpService:JSONEncode({ probeScripts = assetId, ok = false, err = tostring(result) })
        end
        local holder = Instance.new("Folder")
        for _, obj in ipairs(result) do obj.Parent = holder end
        local scripts = {}
        local budget = 60000
        for _, d in ipairs(holder:GetDescendants()) do
            if d:IsA("LuaSourceContainer") then
                local srcOk, src = pcall(function() return d.Source end)
                local text = (srcOk and type(src) == "string") and src or ""
                if #text > budget then text = string.sub(text, 1, budget) end
                budget = budget - #text
                table.insert(scripts, {
                    path = d:GetFullName(),
                    className = d.ClassName,
                    source = text
                })
                if budget <= 0 then break end
            end
        end
        holder:Destroy()
        return HttpService:JSONEncode({ probeScripts = assetId, ok = true, count = #scripts, scripts = scripts })

    elseif cmd.action == "runScript" then
        if type(cmd.source) ~= "string" or #cmd.source == 0 then
            return "runScript error: empty source"
        end
        local fn, compileErr = loadstring(cmd.source, "BorisBuildScript")
        if not fn then
            return "runScript compile error: " .. tostring(compileErr)
        end
        local InsertService = game:GetService("InsertService")
        local env = {
            math = math, table = table, string = string,
            pairs = pairs, ipairs = ipairs, next = next, select = select,
            tonumber = tonumber, tostring = tostring, type = type, typeof = typeof,
            pcall = pcall, error = error, assert = assert, print = print,
            Vector3 = Vector3, Vector2 = Vector2, CFrame = CFrame, Color3 = Color3,
            BrickColor = BrickColor, Enum = Enum, UDim = UDim, UDim2 = UDim2,
            NumberSequence = NumberSequence, ColorSequence = ColorSequence, NumberRange = NumberRange,
            Instance = { new = function(className) return Instance.new(className) end },
            task = { wait = task.wait },
            getRoot = borisFolder,
            insertAsset = function(assetId, childPath)
                local id = tonumber(assetId)
                if not id then error("insertAsset: invalid assetId") end
                local container = InsertService:LoadAsset(id)
                stripScripts(container)
                local model = container:FindFirstChildWhichIsA("Model")
                    or container:FindFirstChildWhichIsA("BasePart")
                    or container
                if childPath then
                    for _, entry in ipairs(packCandidates(container)) do
                        if entry.path == childPath then
                            model = entry.instance
                            break
                        end
                    end
                end
                if model:IsA("BasePart") then
                    model.Anchored = true
                elseif model:IsA("Model") then
                    for _, d in ipairs(model:GetDescendants()) do
                        if d:IsA("BasePart") then d.Anchored = true end
                    end
                end
                model.Parent = borisFolder()
                if childPath then container:Destroy() end
                return model
            end,
            placeAsset = function(assetId, opts) return placeAssetCore(assetId, opts) end,
            cloneTo = function(source, placements) return cloneToCore(source, placements) end,
            createKart = createKart,
            createGameScript = function(name, source)
                local gameScript = Instance.new("Script")
                gameScript.Name = "Boris_" .. tostring(name)
                gameScript.Source = tostring(source)
                gameScript.Parent = game:GetService("ServerScriptService")
                return gameScript
            end
        }
        setfenv(fn, env)
        local ok, runErr = pcall(fn)
        ChangeHistoryService:SetWaypoint("Boris ran build script")
        if not ok then
            return "runScript runtime error: " .. tostring(runErr)
        end
        return "Script executed, " .. #borisFolder():GetDescendants() .. " instances in BorisBuild"

    elseif cmd.action == "auditScene" then
        return auditScene()
    end
    return "Unknown action: " .. tostring(cmd.action)
end

local SERVER_URL = plugin:GetSetting("borisServerUrl") or "https://bloxforge.ai"
local JOB_POLL_INTERVAL = 2
local JOB_MAX_WAIT = 300

local BG = Color3.fromRGB(0, 8, 16)
local PANEL = Color3.fromRGB(12, 24, 36)
local GREEN = Color3.fromRGB(136, 208, 112)
local BLUE = Color3.fromRGB(64, 208, 248)
local TEXT = Color3.fromRGB(232, 240, 245)
local MUTED = Color3.fromRGB(140, 160, 175)

local widget = nil
local chatUi = nil
local licenseToken = plugin:GetSetting("borisToken")
local busy = false

local function httpJson(method, urlPath, body)
    local ok, response = pcall(function()
        return HttpService:RequestAsync({
            Url = SERVER_URL .. urlPath,
            Method = method,
            Headers = { ["Content-Type"] = "application/json" },
            Body = body and HttpService:JSONEncode(body) or nil
        })
    end)
    if not ok then return nil, tostring(response) end
    local parsed
    local parseOk = pcall(function() parsed = HttpService:JSONDecode(response.Body) end)
    if not parseOk then return nil, "server returned an unreadable response" end
    if not response.Success and not (parsed and parsed.error) then
        return nil, "server error " .. tostring(response.StatusCode)
    end
    return parsed, nil
end

local function makeCorner(parent, radius)
    local c = Instance.new("UICorner")
    c.CornerRadius = UDim.new(0, radius or 6)
    c.Parent = parent
    return c
end

local function makeLabel(parent, text, size, color, position)
    local label = Instance.new("TextLabel")
    label.BackgroundTransparency = 1
    label.Size = size
    label.Position = position or UDim2.new(0, 0, 0, 0)
    label.Font = Enum.Font.Gotham
    label.TextSize = 13
    label.TextColor3 = color or TEXT
    label.TextXAlignment = Enum.TextXAlignment.Left
    label.TextWrapped = true
    label.Text = text
    label.Parent = parent
    return label
end

local function makeButton(parent, text, size, position)
    local button = Instance.new("TextButton")
    button.Size = size
    button.Position = position
    button.BackgroundColor3 = GREEN
    button.BorderSizePixel = 0
    button.Font = Enum.Font.GothamBold
    button.TextSize = 14
    button.TextColor3 = Color3.fromRGB(0, 20, 10)
    button.Text = text
    button.AutoButtonColor = true
    button.Parent = parent
    makeCorner(button)
    return button
end

local function makeInput(parent, placeholder, size, position, masked)
    local box = Instance.new("TextBox")
    box.Size = size
    box.Position = position
    box.BackgroundColor3 = PANEL
    box.BorderSizePixel = 0
    box.Font = Enum.Font.Gotham
    box.TextSize = 13
    box.TextColor3 = TEXT
    box.PlaceholderText = placeholder
    box.PlaceholderColor3 = MUTED
    box.Text = ""
    box.ClearTextOnFocus = false
    box.TextXAlignment = Enum.TextXAlignment.Left
    box.TextEditable = true
    if masked then box.Font = Enum.Font.Code end
    box.Parent = parent
    makeCorner(box)
    local pad = Instance.new("UIPadding")
    pad.PaddingLeft = UDim.new(0, 8)
    pad.PaddingRight = UDim.new(0, 8)
    pad.Parent = box
    return box
end

local function addChatLine(who, text)
    if not chatUi or not chatUi.log then return end
    local entry = Instance.new("TextLabel")
    entry.BackgroundTransparency = 1
    entry.Size = UDim2.new(1, -8, 0, 0)
    entry.AutomaticSize = Enum.AutomaticSize.Y
    entry.Font = who == "you" and Enum.Font.GothamMedium or Enum.Font.Gotham
    entry.TextSize = 13
    entry.TextColor3 = who == "you" and BLUE or (who == "error" and Color3.fromRGB(240, 120, 120) or TEXT)
    entry.TextXAlignment = Enum.TextXAlignment.Left
    entry.TextYAlignment = Enum.TextYAlignment.Top
    entry.TextWrapped = true
    entry.Text = (who == "you" and "You: " or (who == "error" and "" or "Forge: ")) .. text
    entry.LayoutOrder = #chatUi.log:GetChildren()
    entry.Parent = chatUi.log
    task.defer(function()
        if chatUi and chatUi.layout and chatUi.scroll then
            chatUi.scroll.CanvasPosition = Vector2.new(0, chatUi.layout.AbsoluteContentSize.Y)
        end
    end)
end

local function setStatus(text)
    if chatUi and chatUi.status then chatUi.status.Text = text end
end

local cancelRequested = false
local activeJobId = nil

local function setBuildButton(label)
    if chatUi and chatUi.send then chatUi.send.Text = label end
end

-- One way out of a build, so no exit can leave the button stuck on "Cancel".
local function finishBuild()
    busy = false
    cancelRequested = false
    activeJobId = nil
    setBuildButton("Build")
end

-- ============ Live sync: dashboard scripts -> this place ============
-- Every synced instance is tagged with the project it came from, so a new
-- version can replace exactly what BloxForge put there and NOTHING the user
-- made themselves. Applying is always a two-step press with the list shown.

local SYNC_ATTR = "BloxForgeProject"

local function syncTargetFor(wherePath)
    if wherePath == "StarterPlayerScripts" then
        local starterPlayer = game:GetService("StarterPlayer")
        return starterPlayer:FindFirstChildOfClass("StarterPlayerScripts")
    end
    if wherePath == "ReplicatedStorage" then return game:GetService("ReplicatedStorage") end
    return game:GetService("ServerScriptService")
end

local function applySyncBundle(bundle)
    local applied = 0
    -- Out with the whole previous version first: half-old, half-new scripts
    -- would talk to events the other half no longer creates.
    for _, service in ipairs({ game:GetService("ServerScriptService"), game:GetService("ReplicatedStorage"), syncTargetFor("StarterPlayerScripts") }) do
        if service then
            for _, child in ipairs(service:GetChildren()) do
                if child:GetAttribute(SYNC_ATTR) == bundle.projectId then child:Destroy() end
            end
        end
    end
    for _, entry in ipairs(bundle.scripts) do
        local target = syncTargetFor(entry.where_path)
        if target then
            local inst = Instance.new(entry.class_name)
            inst.Name = entry.name
            inst.Source = entry.source
            inst:SetAttribute(SYNC_ATTR, bundle.projectId)
            inst.Parent = target
            applied = applied + 1
        end
    end
    ChangeHistoryService:SetWaypoint("BloxForge synced " .. bundle.title)
    return applied
end

local pendingSync = nil

local function runSync()
    if busy then return end
    if pendingSync then
        -- Second press: the user saw the list and confirmed.
        local bundle = pendingSync
        pendingSync = nil
        local ok, applied = pcall(applySyncBundle, bundle)
        if not ok then
            addChatLine("error", "Sync failed: " .. tostring(applied))
            return
        end
        addChatLine("boris", "Synced \"" .. bundle.title .. "\" v" .. bundle.version .. " — " .. applied .. " scripts are in the place. Press Play to try it.")
        task.spawn(function()
            httpJson("POST", "/api/boris/synced", { token = licenseToken, projectId = bundle.projectId, version = bundle.version })
        end)
        return
    end
    setStatus("Checking the dashboard...")
    task.spawn(function()
        local bundle = httpJson("GET", "/api/boris/sync?token=" .. licenseToken)
        setStatus("Ready.")
        if not bundle or not bundle.ok then
            addChatLine("error", (bundle and bundle.error) or "Could not reach the dashboard.")
            return
        end
        local byWhere = {}
        for _, entry in ipairs(bundle.scripts) do
            byWhere[entry.where_path] = (byWhere[entry.where_path] or 0) + 1
        end
        local parts = {}
        for where, count in pairs(byWhere) do table.insert(parts, count .. " in " .. where) end
        addChatLine("boris", "Ready to sync \"" .. bundle.title .. "\" v" .. bundle.version .. ": " .. #bundle.scripts .. " scripts (" .. table.concat(parts, ", ") .. "). It replaces what BloxForge synced before, never your own work. Press Sync again to apply.")
        pendingSync = bundle
        task.delay(60, function() if pendingSync == bundle then pendingSync = nil end end)
    end)
end

local function runBuild(promptText)
    if busy then return end
    busy = true
    addChatLine("you", promptText)
    setStatus("Checking the price...")

    cancelRequested = false
    activeJobId = nil
    setBuildButton("Cancel")

    -- Every exit from a build must reach finishBuild(). Without this wrapper a
    -- single unexpected error killed the coroutine silently: the panel sat on
    -- "Cancel" forever, the status never changed, and reopening Studio was the
    -- only way out. An error is now reported and the button always comes back.
    task.spawn(function()
        local ok, err = pcall(function()
        -- The price is arithmetic on the server, so asking costs nothing and the
        -- player always knows what a build will take before it takes it.
        -- The project goes to the quote as well: without it the server cannot
        -- tell an edit from a new game and prices the dearer path.
        local project = loadProject()
        local quote = httpJson("POST", "/api/boris/estimate",
            { prompt = promptText, token = licenseToken, project = project })
        -- A refused quote used to fall straight through to the build: the server
        -- said "that is not a request", the panel said nothing, and the build
        -- ran anyway. Whatever the quote refuses, the build never starts.
        if quote and quote.ok == false then
            addChatLine("error", tostring(quote.error or "That request cannot be priced."))
            setStatus("Ready.")
            finishBuild()
            return
        end
        if quote and quote.ok then
            addChatLine("boris", "This build costs " .. tostring(quote.credits) .. " credits (" .. tostring(quote.tier) ..
                "). You have " .. tostring(quote.balance) .. ".")
            if quote.balance ~= nil and quote.credits ~= nil and quote.balance < quote.credits then
                addChatLine("error", "Not enough credits for this one.")
                setStatus("Ready.")
                finishBuild()
                return
            end
        end
        setStatus("Boris is planning your game...")

        -- Sent with every request: given it, the server EDITS this place instead
        -- of inventing a new game, so the map survives "make it winter".
        local started, startErr = httpJson("POST", "/api/boris/generate",
            { prompt = promptText, token = licenseToken, project = project })
        if not started or not started.ok then
            local reason = (started and started.error) or startErr or "could not reach the server"
            addChatLine("error", "Could not start the build: " .. reason)
            setStatus("Ready.")
            finishBuild()
            return
        end
        if started.credits ~= nil then
            addChatLine("boris", "Charged " .. tostring(started.price) .. " credits, " .. tostring(started.credits) .. " left.")
        end

        activeJobId = started.jobId
        local waited = 0
        while waited < JOB_MAX_WAIT do
            task.wait(JOB_POLL_INTERVAL)
            waited = waited + JOB_POLL_INTERVAL
            if cancelRequested then
                httpJson("POST", "/api/boris/cancel", { jobId = started.jobId, token = licenseToken })
                addChatLine("boris", "Cancelled. Your credits were refunded.")
                setStatus("Ready.")
                finishBuild()
                return
            end
            local job = httpJson("GET", "/api/boris/job?id=" .. started.jobId .. "&token=" .. licenseToken)
            if job and job.status == "done" then
                local data = job.result or {}
                local commands = data.commands or {}
                if data.title then addChatLine("boris", "Building: " .. tostring(data.title)) end
                if data.edited then
                    addChatLine("boris", "Editing what is here — the map stays, anything you placed by hand inside BorisBuild does not.")
                end
                setStatus("Building " .. #commands .. " pieces...")
                local failures = 0
                local measurements = {}
                for i, command in ipairs(commands) do
                    if cancelRequested then
                        setStatus("Stopping...")
                        -- Half a scene matches neither the old plan nor the new
                        -- one, so the memory is dropped: the next request starts
                        -- clean instead of "editing" a game that is not there.
                        clearProject()
                        addChatLine("boris", "Stopped at " .. i .. " of " .. #commands .. " pieces. Build again or undo to clear it.")
                        setStatus("Ready.")
                        finishBuild()
                        return
                    end
                    local execOk, result = pcall(executeCommand, command)
                    if not execOk or (type(result) == "string" and result:match("^%w+ error")) then
                        failures = failures + 1
                    elseif command.action == "insertAsset" and type(result) == "string" then
                        local decodeOk, info = pcall(function() return HttpService:JSONDecode(result) end)
                        if decodeOk and info and info.inserted and info.x then
                            table.insert(measurements, {
                                assetId = info.inserted, x = info.x, y = info.y, z = info.z,
                                childPath = info.childPath
                            })
                        end
                    end
                    -- Every fifth piece, not every twenty-fifth: a single model
                    -- insert can take seconds, and with the old spacing the panel
                    -- looked frozen and a press of Cancel looked ignored.
                    if i % 5 == 0 then
                        setStatus(cancelRequested and "Stopping..." or ("Building... " .. i .. "/" .. #commands))
                        task.wait()
                    end
                end
                if #measurements > 0 then
                    task.spawn(function()
                        httpJson("POST", "/api/boris/measurements", { token = licenseToken, measurements = measurements })
                    end)
                end
                -- Saved only once the pieces are really in the place, so a build
                -- that died halfway cannot be "edited" from a plan nobody built.
                if data.project then saveProject(data.project) end
                addChatLine("boris", failures == 0
                    and ("Done — " .. #commands .. " pieces placed. Press Play to try it.")
                    or ("Done — " .. #commands .. " pieces placed, " .. failures .. " could not be built."))
                setStatus("Ready.")
                finishBuild()
                return
            elseif job and job.status == "error" then
                addChatLine("error", "Build failed: " .. tostring(job.error or "unknown error"))
                setStatus("Ready.")
                finishBuild()
                return
            elseif job and job.status == "missing" then
                addChatLine("error", "The build was lost on the server. Please try again.")
                setStatus("Ready.")
                finishBuild()
                return
            end
            setStatus("Boris is building... (" .. waited .. "s)")
        end
        addChatLine("error", "Timed out waiting for the build.")
        setStatus("Ready.")
        finishBuild()
        end)
        if not ok then
            addChatLine("error", "Something went wrong in the plugin: " .. tostring(err))
            setStatus("Ready.")
            finishBuild()
        end
    end)
end

local buildChatScreen, buildLoginScreen

buildLoginScreen = function(parentFrame, message)
    parentFrame:ClearAllChildren()
    chatUi = nil
    makeLabel(parentFrame, "Sign in to BloxForge AI", UDim2.new(1, -24, 0, 22), GREEN, UDim2.new(0, 12, 0, 14)).Font = Enum.Font.GothamBold
    local email = makeInput(parentFrame, "Email", UDim2.new(1, -24, 0, 30), UDim2.new(0, 12, 0, 48))
    local password = makeInput(parentFrame, "Password", UDim2.new(1, -24, 0, 30), UDim2.new(0, 12, 0, 86), true)
    local note = makeLabel(parentFrame, message or "Use the same account as bloxforge.ai", UDim2.new(1, -24, 0, 34), MUTED, UDim2.new(0, 12, 0, 158))
    local signIn = makeButton(parentFrame, "Sign in", UDim2.new(1, -24, 0, 32), UDim2.new(0, 12, 0, 124))

    local function attemptSignIn()
        if signIn.Text ~= "Sign in" then return end
        signIn.Text = "Signing in..."
        note.Text = ""
        task.spawn(function()
            local result, err = httpJson("POST", "/api/boris/login", { email = email.Text, password = password.Text })
            signIn.Text = "Sign in"
            if not result or not result.ok then
                note.TextColor3 = Color3.fromRGB(240, 120, 120)
                note.Text = (result and result.error) or err or "Could not reach the server"
                return
            end
            licenseToken = result.token
            plugin:SetSetting("borisToken", licenseToken)
            buildChatScreen(parentFrame)
            if not result.is_paid then
                addChatLine("error", "Signed in, but this account is not activated for generating yet — builds will be refused.")
            end
        end)
    end

    signIn.MouseButton1Click:Connect(attemptSignIn)
    password.FocusLost:Connect(function(enter) if enter then attemptSignIn() end end)
end

buildChatScreen = function(parentFrame)
    parentFrame:ClearAllChildren()

    local header = makeLabel(parentFrame, "BloxForge AI", UDim2.new(1, -80, 0, 20), GREEN, UDim2.new(0, 12, 0, 10))
    header.Font = Enum.Font.GothamBold

    -- Without this the panel never says WHOSE account it is spending credits
    -- from, which is exactly what you cannot check by looking at it.
    local account = makeLabel(parentFrame, "Signing in...", UDim2.new(1, -24, 0, 14), MUTED, UDim2.new(0, 12, 0, 30))
    account.TextSize = 11
    task.spawn(function()
        local me = httpJson("GET", "/api/boris/me?token=" .. licenseToken)
        if not (me and me.ok) then
            account.Text = "Signed in, but the server did not answer"
            return
        end
        local line = me.email .. "  ·  " .. me.credits .. " credits"
        if not me.is_paid then line = line .. "  ·  not activated" end
        if me.pluginVersion and me.pluginVersion > PLUGIN_VERSION then
            line = line .. "  ·  plugin v" .. me.pluginVersion .. " is out, download it again"
            account.TextColor3 = BLUE
        end
        account.Text = line
    end)
    local signOut = Instance.new("TextButton")
    signOut.Size = UDim2.new(0, 62, 0, 20)
    signOut.Position = UDim2.new(1, -74, 0, 10)
    signOut.BackgroundTransparency = 1
    signOut.Font = Enum.Font.Gotham
    signOut.TextSize = 12
    signOut.TextColor3 = MUTED
    signOut.Text = "Sign out"
    signOut.Parent = parentFrame

    local scroll = Instance.new("ScrollingFrame")
    scroll.Size = UDim2.new(1, -24, 1, -130)
    scroll.Position = UDim2.new(0, 12, 0, 52)
    scroll.BackgroundColor3 = PANEL
    scroll.BorderSizePixel = 0
    scroll.ScrollBarThickness = 4
    scroll.CanvasSize = UDim2.new(0, 0, 0, 0)
    scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y
    scroll.Parent = parentFrame
    makeCorner(scroll)

    local log = Instance.new("Frame")
    log.BackgroundTransparency = 1
    log.Size = UDim2.new(1, -12, 0, 0)
    log.Position = UDim2.new(0, 6, 0, 6)
    log.AutomaticSize = Enum.AutomaticSize.Y
    log.Parent = scroll
    local layout = Instance.new("UIListLayout")
    layout.SortOrder = Enum.SortOrder.LayoutOrder
    layout.Padding = UDim.new(0, 8)
    layout.Parent = log

    local status = makeLabel(parentFrame, "Ready.", UDim2.new(1, -24, 0, 16), MUTED, UDim2.new(0, 12, 1, -74))
    status.TextSize = 12

    local input = makeInput(parentFrame, "Describe the game you want...", UDim2.new(1, -152, 0, 32), UDim2.new(0, 12, 1, -52))
    local send = makeButton(parentFrame, "Build", UDim2.new(0, 68, 0, 32), UDim2.new(1, -140, 1, -52))
    local sync = makeButton(parentFrame, "Sync", UDim2.new(0, 52, 0, 32), UDim2.new(1, -64, 1, -52))
    sync.MouseButton1Click:Connect(runSync)

    chatUi = { log = log, scroll = scroll, status = status, layout = layout, send = send }

    local function submit()
        if busy then
            cancelRequested = true
            setStatus("Cancelling...")
            return
        end
        local text = input.Text
        if #text < 3 then return end
        input.Text = ""
        runBuild(text)
    end
    send.MouseButton1Click:Connect(submit)
    input.FocusLost:Connect(function(enter) if enter then submit() end end)
    signOut.MouseButton1Click:Connect(function()
        licenseToken = nil
        plugin:SetSetting("borisToken", nil)
        buildLoginScreen(parentFrame, "Signed out.")
    end)

    addChatLine("boris", "Hi! Tell me what game to build — try \"a go kart race track in a canyon\" or \"an obby with lava jumps\".")
end

local function ensureWidget()
    if widget then return widget end
    local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 320, 480, 280, 360)
    widget = plugin:CreateDockWidgetPluginGui("BorisAIChat", info)
    widget.Title = "BloxForge AI"
    widget.Name = "BorisAIChat"

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

    if licenseToken then buildChatScreen(root) else buildLoginScreen(root) end
    return widget
end

local function startPolling()
    active = true
    print("[BloxForge AI] Connected. Polling " .. BRIDGE_URL)
    pollConnection = task.spawn(function()
        while active do
            local ok, response = pcall(function()
                return HttpService:GetAsync(BRIDGE_URL .. "/poll")
            end)
            if ok and response then
                local parseOk, data = pcall(function()
                    return HttpService:JSONDecode(response)
                end)
                if parseOk and data and data.command then
                    local execOk, result = pcall(executeCommand, data.command)
                    local msg = execOk and result or ("error: " .. tostring(result))
                    print("[BloxForge AI] " .. msg)
                    pcall(function()
                        HttpService:PostAsync(
                            BRIDGE_URL .. "/result",
                            HttpService:JSONEncode({ result = msg }),
                            Enum.HttpContentType.ApplicationJson
                        )
                    end)
                end
            end
            task.wait(POLL_INTERVAL)
        end
    end)
end

toggleButton.ClickableWhenViewportHidden = true
toggleButton.Click:Connect(function()
    local w = ensureWidget()
    w.Enabled = not w.Enabled
    toggleButton:SetActive(w.Enabled)
end)

startPolling()
print("[BloxForge AI] Plugin loaded. Click the BloxForge AI button to open the chat.")
