-- Pathfinder Card Game Tabletop Simulator Deck Importer v1.8.2
--
-- Attach this script to a scripting object.
-- Paste a clean PFTCG2 export string into the on-object text field, then press
-- "Build PFTCG Deck". The input field is cleared immediately.
--
-- Source bag GUID: 0e8c9d
-- Finished deck position: {-148.04, 1.94, 49.76}
-- Finished deck rotation: {0.00, 180.00, 0.00}
--
-- This version does NOT call group() at the end. Instead, every requested
-- card is merged immediately into one persistent output Card/Deck by using
-- putObject(), which returns the newly formed/updated deck reference.

PFTCG_BAG_GUID = "0e8c9d"
PFTCG_DECK_POSITION = {-148.04, 1.94, 49.76}
PFTCG_DECK_ROTATION = {0.00, 180.00, 0.00}

local deckStringInput = ""
local missingCards = {}
local requestedName = "PFTCG Deck"
local requestedPlayerName = "PFTCG Player"
local requestingPlayer = "White"
local buildInProgress = false
local createdCardCount = 0

-- The one output object. It begins as a Card and becomes a Deck when the
-- second card is merged. putObject() returns the replacement reference.
local outputObject = nil
local outputGuid = nil
local buildMarker = nil

local DECK_INPUT_INDEX = 0
local MERGE_DELAY_FRAMES = 2
local SOURCE_SPAWN_DELAY_FRAMES = 3
local CARD_HOLD_HEIGHT = 2.5
local COPY_SPACING = 0.10

local categoryOrder = {"A", "C", "N", "E", "S"}
local categoryLabels = {
    A = "Actions",
    C = "Creatures",
    N = "Creatures (NPC)",
    E = "Equipment",
    S = "Spells"
}

function isObjectAlive(object)
    if object == nil then return false end

    local destroyedOk, destroyed = pcall(function()
        return object.isDestroyed()
    end)
    if destroyedOk and destroyed then return false end

    local guidOk, guid = pcall(function()
        return object.getGUID()
    end)
    return guidOk and guid ~= nil and guid ~= ""
end

function safeDestroyObject(object)
    if not isObjectAlive(object) then return end
    pcall(function() destroyObject(object) end)
end

function getObjectRemainder(object)
    if object == nil then return nil end
    local ok, remainder = pcall(function() return object.remainder end)
    if ok and remainder ~= nil then return remainder end
    return nil
end

function sameTransformPosition(yOffset)
    return {
        PFTCG_DECK_POSITION[1],
        PFTCG_DECK_POSITION[2] + (yOffset or 0),
        PFTCG_DECK_POSITION[3]
    }
end

function setExactTransform(object, locked)
    if not isObjectAlive(object) then return false end

    pcall(function() object.setRotation(PFTCG_DECK_ROTATION) end)
    pcall(function() object.setPosition(PFTCG_DECK_POSITION) end)
    pcall(function() object.setVelocity({0, 0, 0}) end)
    pcall(function() object.setAngularVelocity({0, 0, 0}) end)
    if locked ~= nil then
        pcall(function() object.setLock(locked) end)
    end
    return true
end

function bbcodeDeckName()
    return "Card Deck\n"
        .. "[-][000000][b]Deck Name:[/b][-] " .. tostring(requestedName or "PFTCG Deck") .. "\n"
        .. "[-][000000][b]Game Mode:[/b][-] Normal\n"
        .. "[-][000000][b]Version:[/b][-]  [-][228B22]Epsilon[-]\n"
        .. "[-][000000][b]Player:[/b][-] [-][E60600]" .. tostring(requestedPlayerName or "PFTCG Player") .. "[-]"
end

function stampOutput(object, finalStamp)
    if not isObjectAlive(object) then return end

    -- The website string stays clean. BBCode is applied only to the finished
    -- Tabletop Simulator object's name by this importer.
    pcall(function() object.setName(bbcodeDeckName()) end)

    if finalStamp then
        pcall(function()
            object.setDescription(
                "Built from a PFTCG2 website export. Source cards were copied " ..
                "from bag GUID " .. PFTCG_BAG_GUID .. "."
            )
        end)
    else
        pcall(function()
            object.setDescription(
                tostring(buildMarker or "PFTCG_BUILD") .. "\n" ..
                "PFTCG deck build in progress."
            )
        end)
    end
end

function recoverOutputObject()
    if isObjectAlive(outputObject) then return outputObject end

    if outputGuid ~= nil and outputGuid ~= "" then
        local byGuid = getObjectFromGUID(outputGuid)
        if isObjectAlive(byGuid) then
            outputObject = byGuid
            return byGuid
        end
    end

    -- Fallback recovery uses the unique build marker, not the bag GUID.
    -- This is only needed if Tabletop Simulator replaces a Card/Deck object.
    if buildMarker ~= nil then
        for _, object in ipairs(getAllObjects()) do
            if isObjectAlive(object) then
                local tag = tostring(object.tag or "")
                if tag == "Card" or tag == "Deck" then
                    local ok, description = pcall(function()
                        return tostring(object.getDescription() or "")
                    end)
                    if ok and string.find(description, buildMarker, 1, true) then
                        outputObject = object
                        local guidOk, guid = pcall(function() return object.getGUID() end)
                        if guidOk then outputGuid = guid end
                        return object
                    end
                end
            end
        end
    end

    return nil
end

function rememberOutput(object)
    if not isObjectAlive(object) then return false end

    outputObject = object
    local ok, guid = pcall(function() return object.getGUID() end)
    if ok and guid and guid ~= "" then outputGuid = guid end

    stampOutput(object, false)
    setExactTransform(object, true)
    return true
end

function onLoad()
    self.clearInputs()
    self.clearButtons()

    self.createInput({
        input_function = "updateDeckStringInput",
        function_owner = self,
        label = "Paste PFTCG2 deck string here",
        position = {0, 0.35, -0.55},
        rotation = {0, 0, 0},
        width = 5200,
        height = 520,
        font_size = 180,
        color = {0.96, 0.93, 0.84},
        font_color = {0.10, 0.07, 0.04},
        alignment = 2,
        validation = 1,
        tab = 1,
        value = "",
        tooltip = "Paste the complete PFTCG2 website export string here."
    })

    self.createButton({
        click_function = "buildPFTCGDeck",
        function_owner = self,
        label = "Build PFTCG Deck",
        position = {0, 0.35, 0.35},
        rotation = {0, 0, 0},
        width = 2500,
        height = 560,
        font_size = 250,
        color = {0.18, 0.12, 0.07},
        font_color = {1, 0.82, 0.42},
        tooltip = "Build the deck from the PFTCG2 string in the text field."
    })
end

function updateDeckStringInput(obj, playerColor, inputValue, selected)
    deckStringInput = tostring(inputValue or "")
end

function buildPFTCGDeck(obj, playerColor, altClick)
    requestingPlayer = playerColor or "White"

    if buildInProgress then
        broadcastToColor(
            "A PFTCG deck is already being built.",
            requestingPlayer,
            {1, 0.65, 0.25}
        )
        return
    end

    local submittedString = tostring(deckStringInput or "")
    deckStringInput = ""
    self.editInput({index = DECK_INPUT_INDEX, value = ""})
    Wait.frames(function()
        deckStringInput = ""
        self.editInput({index = DECK_INPUT_INDEX, value = ""})
    end, 1)

    missingCards = {}
    createdCardCount = 0
    outputObject = nil
    outputGuid = nil
    buildMarker = "[PFTCG_BUILD:" .. self.getGUID() .. ":" .. tostring(Time.time) .. "]"

    local parsed, err = parsePFTCGString(submittedString)
    if not parsed then
        broadcastToColor(
            err or "Invalid PFTCG deck string.",
            requestingPlayer,
            {1, 0.35, 0.25}
        )
        return
    end

    requestedName = parsed.name
    requestedPlayerName = parsed.player

    -- This is the only bag lookup. The bag is identified directly by GUID.
    local epsilonBag = getObjectFromGUID(PFTCG_BAG_GUID)
    if not epsilonBag then
        broadcastToColor(
            "Could not find the Epsilon bag with GUID " .. PFTCG_BAG_GUID .. ".",
            requestingPlayer,
            {1, 0.35, 0.25}
        )
        return
    end

    if epsilonBag.tag ~= "Bag" and epsilonBag.tag ~= "Infinite" then
        broadcastToColor(
            "Object " .. PFTCG_BAG_GUID .. " exists, but it is not a bag.",
            requestingPlayer,
            {1, 0.35, 0.25}
        )
        return
    end

    buildInProgress = true
    broadcastToColor(
        "Building '" .. requestedName .. "' from bag " .. PFTCG_BAG_GUID .. "…",
        requestingPlayer,
        {1, 0.82, 0.35}
    )
    processCategory(epsilonBag, parsed.groups, 1)
end

function parsePFTCGString(value)
    local text = tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")
    local encodedPlayer, encodedName, payload = text:match("^PFTCG2|([^|]*)|([^|]*)|(.*)$")
    if encodedName == nil then
        -- Legacy support for the previous PFCG2 website prefix.
        encodedPlayer, encodedName, payload = text:match("^PFCG2|([^|]*)|([^|]*)|(.*)$")
    end
    if encodedName == nil then
        -- Older PFCG1 strings did not carry a player.
        encodedName, payload = text:match("^PFCG1|([^|]*)|(.*)$")
        encodedPlayer = "PFTCG%20Player"
    end
    if encodedName == nil then
        return nil, "Paste a valid PFTCG2 deck string into the text field."
    end

    local result = {
        player = percentDecode(encodedPlayer or ""),
        name = percentDecode(encodedName or ""),
        groups = {A = {}, C = {}, N = {}, E = {}, S = {}}
    }
    if result.player == "" then result.player = "PFTCG Player" end
    if result.name == "" then result.name = "PFTCG Deck" end

    for entry in string.gmatch(payload or "", "[^,]+") do
        -- Lua patterns do not support the regex '?' quantifier, so parse the
        -- alternative-art and standard forms explicitly.
        local cardId, qtyText = entry:match("^([ACENS]%d+)%(Alt%)=([0-9]+)$")
        local isAlt = cardId ~= nil
        if not cardId then cardId, qtyText = entry:match("^([ACENS]%d+)=([0-9]+)$") end
        local qty = tonumber(qtyText)
        if cardId and qty and qty > 0 then
            local prefix = cardId:sub(1, 1)
            table.insert(result.groups[prefix], {
                id = cardId,
                alt = isAlt,
                displayId = cardId .. (isAlt and "(Alt)" or ""),
                qty = math.min(99, math.floor(qty))
            })
        end
    end

    local total = 0
    for _, prefix in ipairs(categoryOrder) do
        for _, item in ipairs(result.groups[prefix]) do
            total = total + item.qty
        end
    end

    if total == 0 then
        return nil, "The PFTCG deck string contains no supported cards."
    end

    return result
end

function percentDecode(value)
    return (tostring(value or ""):gsub("%%(%x%x)", function(hex)
        return string.char(tonumber(hex, 16))
    end))
end

function processCategory(epsilonBag, groups, index)
    if index > #categoryOrder then
        finaliseDeck()
        return
    end

    local prefix = categoryOrder[index]
    local requests = groups[prefix] or {}
    if #requests == 0 then
        processCategory(epsilonBag, groups, index + 1)
        return
    end

    local deckEntry = findCategoryDeckEntry(epsilonBag, prefix)
    if not deckEntry then
        for _, request in ipairs(requests) do
            table.insert(missingCards, request.displayId or request.id)
        end
        processCategory(epsilonBag, groups, index + 1)
        return
    end

    local bagPosition = epsilonBag.getPosition()
    local stagePosition = {
        bagPosition.x + 5,
        bagPosition.y + 3,
        bagPosition.z + index * 1.5
    }

    epsilonBag.takeObject({
        guid = deckEntry.guid,
        position = stagePosition,
        smooth = false,
        callback_function = function(sourceDeck)
            if not isObjectAlive(sourceDeck) then
                for _, request in ipairs(requests) do
                    table.insert(missingCards, request.displayId or request.id)
                end
                processCategory(epsilonBag, groups, index + 1)
                return
            end

            local workingDeck = sourceDeck.clone({
                position = {stagePosition[1] + 2, stagePosition[2], stagePosition[3]},
                snap_to_grid = false
            })

            epsilonBag.putObject(sourceDeck)

            Wait.frames(function()
                if not isObjectAlive(workingDeck) then
                    for _, request in ipairs(requests) do
                        table.insert(missingCards, request.displayId or request.id)
                    end
                    processCategory(epsilonBag, groups, index + 1)
                    return
                end

                extractRequests(workingDeck, requests, 1, function(remainingContainer)
                    safeDestroyObject(remainingContainer)
                    if remainingContainer ~= workingDeck then
                        safeDestroyObject(workingDeck)
                    end
                    processCategory(epsilonBag, groups, index + 1)
                end)
            end, SOURCE_SPAWN_DELAY_FRAMES)
        end
    })
end

function findCategoryDeckEntry(epsilonBag, prefix)
    for _, entry in ipairs(epsilonBag.getObjects()) do
        local nickname = string.lower(tostring(entry.nickname or entry.name or ""))

        if prefix == "N" and string.find(nickname, "creatures (npc)", 1, true) then
            return entry
        end
        if prefix == "C" and string.find(nickname, "creatures", 1, true)
            and not string.find(nickname, "(npc)", 1, true) then
            return entry
        end
        if prefix == "A" and string.find(nickname, "actions", 1, true) then
            return entry
        end
        if prefix == "E" and string.find(nickname, "equipment", 1, true) then
            return entry
        end
        if prefix == "S" and string.find(nickname, "spells", 1, true) then
            return entry
        end
    end

    return nil
end

function extractRequests(container, requests, index, onComplete)
    if index > #requests then
        onComplete(container)
        return
    end

    if not isObjectAlive(container) then
        for i = index, #requests do
            table.insert(missingCards, requests[i].id)
        end
        onComplete(nil)
        return
    end

    local request = requests[index]
    local cardEntry = findCardEntry(container, request.id, request.alt)

    if not cardEntry then
        table.insert(missingCards, request.displayId or request.id)
        extractRequests(container, requests, index + 1, onComplete)
        return
    end

    -- When the source deck becomes one remaining Card, use it directly.
    if cardEntry.directObject then
        local card = cardEntry.directObject
        prepareCardBatch(card, request.qty, function(batch)
            mergeCardBatch(batch, 1, function(successCount)
                if successCount == 0 then
                    table.insert(missingCards, request.displayId or request.id)
                end
                Wait.frames(function()
                    extractRequests(nil, requests, index + 1, onComplete)
                end, 1)
            end)
        end)
        return
    end

    local nextContainer = container
    local takeOk = pcall(function()
        container.takeObject({
            guid = cardEntry.guid,
            position = sameTransformPosition(CARD_HOLD_HEIGHT),
            rotation = PFTCG_DECK_ROTATION,
            smooth = false,
            callback_function = function(card)
                prepareCardBatch(card, request.qty, function(batch)
                    mergeCardBatch(batch, 1, function(successCount)
                        if successCount == 0 then
                            table.insert(missingCards, request.displayId or request.id)
                        end
                        Wait.frames(function()
                            extractRequests(nextContainer, requests, index + 1, onComplete)
                        end, 1)
                    end)
                end)
            end
        })
    end)

    if not takeOk then
        table.insert(missingCards, request.displayId or request.id)
        extractRequests(container, requests, index + 1, onComplete)
        return
    end

    -- A Deck is destroyed when its second-last card is removed. Carry the
    -- spawned final Card forward as the next source container.
    local remainder = getObjectRemainder(container)
    if remainder ~= nil then nextContainer = remainder end
end

function findCardEntry(container, cardId, wantsAlt)
    if not isObjectAlive(container) then return nil end

    local marker = "#" .. cardId
    local tagOk, objectTag = pcall(function() return container.tag end)

    if tagOk and objectTag == "Card" then
        local textOk, text = pcall(function()
            return tostring(container.getName() or "")
                .. " " .. tostring(container.getDescription() or "")
        end)

        local lowerText = string.lower(text or "")
        local isAlt = string.find(lowerText, "(alt)", 1, true) ~= nil or string.find(lowerText, "alternative art", 1, true) ~= nil
        if textOk and string.find(text, marker, 1, true) and (not wantsAlt or isAlt) then
            return {
                guid = container.getGUID(),
                directObject = container
            }
        end
        return nil
    end

    local objectsOk, objects = pcall(function() return container.getObjects() end)
    if not objectsOk or objects == nil then return nil end

    for _, entry in ipairs(objects) do
        local text = tostring(entry.nickname or entry.name or "")
            .. " " .. tostring(entry.description or "")

        local lowerText = string.lower(text)
        local isAlt = string.find(lowerText, "(alt)", 1, true) ~= nil or string.find(lowerText, "alternative art", 1, true) ~= nil
        if string.find(text, marker, 1, true) and (not wantsAlt or isAlt) then return entry end
    end

    return nil
end

-- Create the requested copies while the original card reference is still
-- valid. All copies are locked on the same X/Z line, so they cannot merge
-- prematurely before the script deliberately calls putObject().
function prepareCardBatch(card, quantity, onReady)
    if not isObjectAlive(card) then
        onReady({})
        return
    end

    local batch = {card}
    pcall(function() card.setLock(true) end)
    pcall(function() card.setRotation(PFTCG_DECK_ROTATION) end)
    pcall(function() card.setPosition(sameTransformPosition(CARD_HOLD_HEIGHT)) end)

    for copyIndex = 2, quantity do
        local copyPosition = sameTransformPosition(
            CARD_HOLD_HEIGHT + ((copyIndex - 1) * COPY_SPACING)
        )

        local ok, copy = pcall(function()
            return card.clone({
                position = copyPosition,
                snap_to_grid = false
            })
        end)

        if ok and isObjectAlive(copy) then
            pcall(function() copy.setLock(true) end)
            pcall(function() copy.setRotation(PFTCG_DECK_ROTATION) end)
            pcall(function() copy.setPosition(copyPosition) end)
            table.insert(batch, copy)
        end
    end

    Wait.frames(function() onReady(batch) end, 1)
end

function mergeCardBatch(batch, index, onComplete, successCount)
    successCount = successCount or 0

    if index > #batch then
        onComplete(successCount)
        return
    end

    local card = batch[index]
    mergeOneCard(card, function(success)
        if success then successCount = successCount + 1 end
        Wait.frames(function()
            mergeCardBatch(batch, index + 1, onComplete, successCount)
        end, MERGE_DELAY_FRAMES)
    end)
end

function mergeOneCard(card, onComplete)
    if not isObjectAlive(card) then
        onComplete(false)
        return
    end

    pcall(function() card.setRotation(PFTCG_DECK_ROTATION) end)
    pcall(function() card.setLock(false) end)

    local collector = recoverOutputObject()

    -- First card becomes the collector.
    if collector == nil then
        if rememberOutput(card) then
            createdCardCount = createdCardCount + 1
            Wait.frames(function() onComplete(true) end, MERGE_DELAY_FRAMES)
        else
            onComplete(false)
        end
        return
    end

    -- Hold the incoming card directly above the exact final X/Z point.
    pcall(function() card.setPosition(sameTransformPosition(1.25)) end)
    pcall(function() collector.setLock(false) end)

    -- putObject() returns the container/deck formed or updated by the merge.
    -- This returned reference replaces any Card/Deck reference destroyed by TTS.
    local ok, mergedObject = pcall(function()
        return collector.putObject(card)
    end)

    if not ok or not isObjectAlive(mergedObject) then
        -- A one-frame retry handles objects that have just finished spawning.
        Wait.frames(function()
            local retryCollector = recoverOutputObject()
            if not isObjectAlive(retryCollector) or not isObjectAlive(card) then
                onComplete(false)
                return
            end

            pcall(function() retryCollector.setLock(false) end)
            pcall(function() card.setLock(false) end)

            local retryOk, retryMerged = pcall(function()
                return retryCollector.putObject(card)
            end)

            if retryOk and rememberOutput(retryMerged) then
                createdCardCount = createdCardCount + 1
                onComplete(true)
            else
                onComplete(false)
            end
        end, 1)
        return
    end

    if rememberOutput(mergedObject) then
        createdCardCount = createdCardCount + 1
        Wait.frames(function() onComplete(true) end, MERGE_DELAY_FRAMES)
    else
        onComplete(false)
    end
end

function finaliseDeck()
    local finalObject = recoverOutputObject()

    if createdCardCount == 0 then
        buildInProgress = false
        broadcastToColor(
            "No requested card IDs were found in the source decks inside bag " ..
            PFTCG_BAG_GUID .. ".",
            requestingPlayer,
            {1, 0.35, 0.25}
        )
        return
    end

    if not isObjectAlive(finalObject) then
        buildInProgress = false
        broadcastToColor(
            "The cards were copied, but the completed output deck could not be reacquired.",
            requestingPlayer,
            {1, 0.35, 0.25}
        )
        return
    end

    stampOutput(finalObject, true)
    setExactTransform(finalObject, true)

    -- Keep it locked while the last merge settles, then apply the exact
    -- transform one final time and unlock it for normal play.
    Wait.frames(function()
        local settledObject = recoverOutputObject()
        if isObjectAlive(settledObject) then
            stampOutput(settledObject, true)
            setExactTransform(settledObject, false)
        end

        buildInProgress = false

        local message = "Built '" .. requestedName .. "' with " ..
            tostring(createdCardCount) .. " cards."
        if #missingCards > 0 then
            message = message .. " Missing IDs: " .. table.concat(missingCards, ", ")
            broadcastToColor(message, requestingPlayer, {1, 0.65, 0.25})
        else
            broadcastToColor(message, requestingPlayer, {0.35, 1, 0.45})
        end
    end, 5)
end