local _, Addon = ...

local DungeonEnemies = {}
Addon.DungeonEnemies = DungeonEnemies

local rawEnemyCache = {}
local enemyCache = {}
local transformCache = {}

local function ResolveDungeon(mapID)
    local data = Addon.DungeonEnemyData
    local dungeonIndex = data and data.maps and data.maps[mapID]
    local dungeon = dungeonIndex and data.dungeons and data.dungeons[dungeonIndex]
    if dungeon then
        return dungeonIndex, dungeon
    end
end

local function IsDungeonInstance()
    local inInstance, instanceType = IsInInstance()
    return inInstance and (instanceType == "party" or instanceType == "scenario")
end

local function IsMarkerIncluded(mask, markerIndex)
    if not mask then
        return true
    end

    local nibbleIndex = math.floor((markerIndex - 1) / 4) + 1
    local nibble = tonumber(string.sub(mask, nibbleIndex, nibbleIndex), 16)
    if not nibble then
        return false
    end

    local bitValue = 2 ^ ((markerIndex - 1) % 4)
    return math.floor(nibble / bitValue) % 2 == 1
end

local function BuildRawEnemies(dungeonIndex, dungeon, width, height)
    if rawEnemyCache[dungeonIndex] then
        return rawEnemyCache[dungeonIndex]
    end

    local markers = {}
    local enemyIndices = Addon.DungeonEnemyData.enemyIndices
        and Addon.DungeonEnemyData.enemyIndices[dungeonIndex]
    local dungeonCloneIndices = Addon.DungeonEnemyData.cloneIndices
        and Addon.DungeonEnemyData.cloneIndices[dungeonIndex]
    for enemyPosition, enemy in ipairs(dungeon.enemies or {}) do
        local enemyIndex = enemyIndices and enemyIndices[enemyPosition] or enemyPosition
        local cloneIndices = dungeonCloneIndices and dungeonCloneIndices[enemyPosition]
        for clonePosition, clone in ipairs(enemy.clones or {}) do
            local cloneIndex = cloneIndices and cloneIndices[clonePosition] or clonePosition
            local sourceX = clone.x
            local sourceY = clone.y
            if type(sourceX) == "number" and type(sourceY) == "number" then
                sourceX = sourceX / width
                sourceY = -sourceY / height
                if Addon:IsValidMapCoordinate(sourceX, sourceY) then
                    markers[#markers + 1] = {
                        sourceX = sourceX,
                        sourceY = sourceY,
                        name = enemy.name,
                        npcID = enemy.npcID,
                        displayID = enemy.displayID,
                        forces = enemy.count,
                        level = enemy.level,
                        creatureType = enemy.creatureType,
                        dungeonIndex = dungeonIndex,
                        enemyIndex = enemyIndex,
                        cloneIndex = cloneIndex,
                        scale = (enemy.scale or 1) * (clone.scale or 1),
                        interruptible = enemy.interruptible,
                        patrol = clone.patrol,
                    }
                end
            end
        end
    end
    rawEnemyCache[dungeonIndex] = markers
    return markers
end

local function SolveLinear3(matrix, vector)
    local rows = {
        { matrix[1][1], matrix[1][2], matrix[1][3], vector[1] },
        { matrix[2][1], matrix[2][2], matrix[2][3], vector[2] },
        { matrix[3][1], matrix[3][2], matrix[3][3], vector[3] },
    }

    for column = 1, 3 do
        local pivot = column
        for row = column + 1, 3 do
            if math.abs(rows[row][column]) > math.abs(rows[pivot][column]) then
                pivot = row
            end
        end
        if math.abs(rows[pivot][column]) < 0.0000001 then
            return nil
        end
        rows[column], rows[pivot] = rows[pivot], rows[column]

        local divisor = rows[column][column]
        for index = column, 4 do
            rows[column][index] = rows[column][index] / divisor
        end
        for row = 1, 3 do
            if row ~= column then
                local factor = rows[row][column]
                for index = column, 4 do
                    rows[row][index] = rows[row][index] - (factor * rows[column][index])
                end
            end
        end
    end
    return rows[1][4], rows[2][4], rows[3][4]
end

local function ApplyTransform(transform, x, y)
    return (transform.x1 * x) + (transform.x2 * y) + transform.x3,
        (transform.y1 * x) + (transform.y2 * y) + transform.y3
end

local function FitAxisTransform(points)
    if #points < 2 then
        return nil
    end

    local sourceMeanX, sourceMeanY = 0, 0
    local targetMeanX, targetMeanY = 0, 0
    for _, point in ipairs(points) do
        sourceMeanX = sourceMeanX + point.sourceX
        sourceMeanY = sourceMeanY + point.sourceY
        targetMeanX = targetMeanX + point.targetX
        targetMeanY = targetMeanY + point.targetY
    end
    sourceMeanX = sourceMeanX / #points
    sourceMeanY = sourceMeanY / #points
    targetMeanX = targetMeanX / #points
    targetMeanY = targetMeanY / #points

    local numeratorX, denominatorX = 0, 0
    local numeratorY, denominatorY = 0, 0
    for _, point in ipairs(points) do
        local sourceDeltaX = point.sourceX - sourceMeanX
        local sourceDeltaY = point.sourceY - sourceMeanY
        numeratorX = numeratorX + (sourceDeltaX * (point.targetX - targetMeanX))
        denominatorX = denominatorX + (sourceDeltaX * sourceDeltaX)
        numeratorY = numeratorY + (sourceDeltaY * (point.targetY - targetMeanY))
        denominatorY = denominatorY + (sourceDeltaY * sourceDeltaY)
    end
    if denominatorX < 0.0000001 or denominatorY < 0.0000001 then
        return nil
    end

    local scaleX = numeratorX / denominatorX
    local scaleY = numeratorY / denominatorY
    if scaleX < 0.2 or scaleX > 5 or scaleY < 0.2 or scaleY > 5 then
        return nil
    end
    local transform = {
        x1 = scaleX,
        x2 = 0,
        x3 = targetMeanX - (scaleX * sourceMeanX),
        y1 = 0,
        y2 = scaleY,
        y3 = targetMeanY - (scaleY * sourceMeanY),
    }

    local maxError = 0
    for _, point in ipairs(points) do
        local x, y = ApplyTransform(transform, point.sourceX, point.sourceY)
        local deltaX = x - point.targetX
        local deltaY = y - point.targetY
        maxError = math.max(maxError, math.sqrt((deltaX * deltaX) + (deltaY * deltaY)))
    end
    if maxError > 0.10 then
        return nil
    end
    return transform
end

local function FitAffineTransform(points)
    if #points < 3 then
        return nil
    end

    local sumXX, sumXY, sumX = 0, 0, 0
    local sumYY, sumY = 0, 0
    local targetX = { 0, 0, 0 }
    local targetY = { 0, 0, 0 }
    for _, point in ipairs(points) do
        sumXX = sumXX + (point.sourceX * point.sourceX)
        sumXY = sumXY + (point.sourceX * point.sourceY)
        sumX = sumX + point.sourceX
        sumYY = sumYY + (point.sourceY * point.sourceY)
        sumY = sumY + point.sourceY
        targetX[1] = targetX[1] + (point.sourceX * point.targetX)
        targetX[2] = targetX[2] + (point.sourceY * point.targetX)
        targetX[3] = targetX[3] + point.targetX
        targetY[1] = targetY[1] + (point.sourceX * point.targetY)
        targetY[2] = targetY[2] + (point.sourceY * point.targetY)
        targetY[3] = targetY[3] + point.targetY
    end

    local matrix = {
        { sumXX, sumXY, sumX },
        { sumXY, sumYY, sumY },
        { sumX, sumY, #points },
    }
    local x1, x2, x3 = SolveLinear3(matrix, targetX)
    local y1, y2, y3 = SolveLinear3(matrix, targetY)
    if not x1 or not y1 then
        return nil
    end

    local transform = { x1 = x1, x2 = x2, x3 = x3, y1 = y1, y2 = y2, y3 = y3 }
    for _, coefficient in pairs(transform) do
        if type(coefficient) ~= "number" or math.abs(coefficient) > 10 then
            return nil
        end
    end

    local maxError = 0
    for _, point in ipairs(points) do
        local x, y = ApplyTransform(transform, point.sourceX, point.sourceY)
        local deltaX = x - point.targetX
        local deltaY = y - point.targetY
        maxError = math.max(maxError, math.sqrt((deltaX * deltaX) + (deltaY * deltaY)))
    end
    if maxError > 0.08 then
        return nil
    end
    return transform
end

local function FitSimilarityTransform(points)
    if #points < 2 then
        return nil
    end

    local sourceMeanX, sourceMeanY = 0, 0
    local targetMeanX, targetMeanY = 0, 0
    for _, point in ipairs(points) do
        sourceMeanX = sourceMeanX + point.sourceX
        sourceMeanY = sourceMeanY + point.sourceY
        targetMeanX = targetMeanX + point.targetX
        targetMeanY = targetMeanY + point.targetY
    end
    sourceMeanX = sourceMeanX / #points
    sourceMeanY = sourceMeanY / #points
    targetMeanX = targetMeanX / #points
    targetMeanY = targetMeanY / #points

    local numeratorA, numeratorB, denominator = 0, 0, 0
    for _, point in ipairs(points) do
        local sourceX = point.sourceX - sourceMeanX
        local sourceY = point.sourceY - sourceMeanY
        local targetX = point.targetX - targetMeanX
        local targetY = point.targetY - targetMeanY
        numeratorA = numeratorA + (sourceX * targetX) + (sourceY * targetY)
        numeratorB = numeratorB + (sourceX * targetY) - (sourceY * targetX)
        denominator = denominator + (sourceX * sourceX) + (sourceY * sourceY)
    end
    if denominator < 0.0000001 then
        return nil
    end

    local a = numeratorA / denominator
    local b = numeratorB / denominator
    local scale = math.sqrt((a * a) + (b * b))
    if scale < 0.2 or scale > 5 then
        return nil
    end

    local transform = {
        x1 = a,
        x2 = -b,
        x3 = targetMeanX - (a * sourceMeanX) + (b * sourceMeanY),
        y1 = b,
        y2 = a,
        y3 = targetMeanY - (b * sourceMeanX) - (a * sourceMeanY),
    }
    local maxError = 0
    for _, point in ipairs(points) do
        local x, y = ApplyTransform(transform, point.sourceX, point.sourceY)
        local deltaX = x - point.targetX
        local deltaY = y - point.targetY
        maxError = math.max(maxError, math.sqrt((deltaX * deltaX) + (deltaY * deltaY)))
    end
    if maxError > 0.10 then
        return nil
    end
    return transform
end

local function GetCalibrationPoints(mapID, dungeon, width, height)
    if not C_EncounterJournal.GetEncountersOnMap or type(EJ_GetCreatureInfo) ~= "function" then
        return nil
    end

    local byNpcID = {}
    local byDisplayID = {}
    for _, boss in ipairs(dungeon.bosses or {}) do
        byNpcID[boss.npcID] = boss
        if boss.displayID then
            byDisplayID[boss.displayID] = boss
        end
    end
    if not next(byNpcID) then
        return nil
    end

    local ok, encounters = pcall(C_EncounterJournal.GetEncountersOnMap, mapID)
    if not ok or type(encounters) ~= "table" then
        return nil
    end

    local points = {}
    local officialPoints = {}
    local usedBosses = {}
    for _, encounterInfo in ipairs(encounters) do
        local targetX = encounterInfo.mapX
        local targetY = encounterInfo.mapY
        local encounterID = encounterInfo.encounterID
        if encounterID and Addon:IsValidMapCoordinate(targetX, targetY) then
            officialPoints[#officialPoints + 1] = { targetX = targetX, targetY = targetY }
            local matchedBoss
            for creatureIndex = 1, 12 do
                local creatureOK, creatureID, creatureName, creatureDescription, displayID = pcall(
                    EJ_GetCreatureInfo,
                    creatureIndex,
                    encounterID
                )
                if not creatureOK or not creatureID then
                    break
                end
                local boss = byNpcID[tonumber(creatureID)] or byDisplayID[tonumber(displayID)]
                if boss and not usedBosses[boss] then
                    matchedBoss = boss
                    break
                end
            end
            if matchedBoss then
                usedBosses[matchedBoss] = true
                points[#points + 1] = {
                    sourceX = matchedBoss.x / width,
                    sourceY = -matchedBoss.y / height,
                    targetX = targetX,
                    targetY = targetY,
                }
            end
        end
    end

    return points, officialPoints
end

local function BuildOrderPoints(officialPoints, dungeon, width, height)
    if #officialPoints < 2 or type(dungeon.calibrationOrder) ~= "table" then
        return nil
    end

    local points = {}
    local count = math.min(#officialPoints, #dungeon.calibrationOrder)
    for index = 1, count do
        local boss = dungeon.calibrationOrder[index]
        points[index] = {
            sourceX = boss.x / width,
            sourceY = -boss.y / height,
            targetX = officialPoints[index].targetX,
            targetY = officialPoints[index].targetY,
        }
    end
    return points
end

local function FitBestTransform(points, source)
    if not points or #points < 2 then
        return nil
    end

    local transform = FitAxisTransform(points)
    if transform then
        return transform, source .. "-axis"
    end
    if #points >= 3 then
        transform = FitAffineTransform(points)
        if transform then
            return transform, source .. "-affine"
        end
    end
    transform = FitSimilarityTransform(points)
    if transform then
        return transform, source .. "-similarity"
    end
    return nil
end

local function GetTransform(mapID, dungeon, width, height)
    if transformCache[mapID] then
        return transformCache[mapID]
    end

    local identityPoints, officialPoints = GetCalibrationPoints(mapID, dungeon, width, height)
    local transform, method = FitBestTransform(identityPoints, "identity")
    local points = identityPoints
    if not transform then
        points = BuildOrderPoints(officialPoints or {}, dungeon, width, height)
        transform, method = FitBestTransform(points, "order")
    end
    if Addon.db then
        Addon.db.enemyCalibrationDiagnostics = Addon.db.enemyCalibrationDiagnostics or {}
        Addon.db.enemyCalibrationDiagnostics[mapID] = {
            points = points and #points or 0,
            method = method or "unavailable",
        }
    end
    if transform then
        transformCache[mapID] = transform
    end
    return transform
end

function DungeonEnemies:IsAvailable()
    local data = Addon.DungeonEnemyData
    return type(data) == "table"
        and type(data.maps) == "table"
        and type(data.dungeons) == "table"
        and type(Addon.DungeonMapTransforms) == "table"
end

function DungeonEnemies:GetEnemies(mapID)
    if not self:IsAvailable() then
        return nil, "missing"
    end
    if not IsDungeonInstance() then
        return nil, "outside"
    end
    if mapID and enemyCache[mapID] then
        return enemyCache[mapID]
    end

    local data = Addon.DungeonEnemyData
    local mapDefinition = Addon.DungeonMapTransforms[mapID]
    if type(mapDefinition) ~= "table" then
        return nil, "unsupported"
    end
    local dungeonIndex, dungeon = ResolveDungeon(mapID)
    if not dungeon or dungeonIndex ~= mapDefinition.dungeonIndex then
        return nil, "unsupported"
    end

    local markers = {}
    local usedMarkers = {}
    local rawEnemies = BuildRawEnemies(dungeonIndex, dungeon, data.width, data.height)
    for _, layer in ipairs(mapDefinition.layers or {}) do
        local transform = layer.transform
        if type(transform) == "table" then
            for markerIndex, enemy in ipairs(rawEnemies) do
                if not usedMarkers[markerIndex] and IsMarkerIncluded(layer.include, markerIndex) then
                    local mapX, mapY = ApplyTransform(transform, enemy.sourceX, enemy.sourceY)
                    if Addon:IsValidMapCoordinate(mapX, mapY) then
                        local marker = {}
                        for key, value in pairs(enemy) do
                            if key ~= "sourceX" and key ~= "sourceY" then
                                marker[key] = value
                            end
                        end
                        marker.mapX = mapX
                        marker.mapY = mapY
                        markers[#markers + 1] = marker
                        usedMarkers[markerIndex] = true
                    end
                end
            end
        end
    end
    table.sort(markers, function(left, right)
        if left.mapY == right.mapY then
            return left.mapX < right.mapX
        end
        return left.mapY < right.mapY
    end)

    if mapID then
        enemyCache[mapID] = markers
    end
    if Addon.db then
        Addon.db.enemyCalibrationDiagnostics = Addon.db.enemyCalibrationDiagnostics or {}
        Addon.db.enemyCalibrationDiagnostics[mapID] = {
            method = "static-art",
            points = #(mapDefinition.layers or {}),
            markers = #markers,
        }
    end
    return markers
end

DungeonEnemies.FitAffineTransform = FitAffineTransform
DungeonEnemies.FitAxisTransform = FitAxisTransform
DungeonEnemies.FitSimilarityTransform = FitSimilarityTransform
DungeonEnemies.ApplyTransform = ApplyTransform
