-- ============================================================
-- Asylum Compagnon — GearCompassV2.lua
-- Domaines structurés du contrat GearCompass schema_version 2.
-- Aucune recommandation, aucun tooltip localisé, aucune valeur devinée.
-- ============================================================

local _, NS = ...

local V2 = {}
NS.GearCompassV2 = V2

local JSON = NS.GearCompassJSON
local Manifest = NS.GearCompassManifest
local NULL = JSON.NULL
local VOIDFORGE_UNLOCK_QUEST_ID = 94675
local NILHAMMER_UNLOCK_QUEST_ID = 95271

local REGION_NAMES = { [1] = "US", [2] = "KR", [3] = "EU", [4] = "TW", [5] = "CN" }
local ITEM_CLASS_WEAPON = Enum and Enum.ItemClass and Enum.ItemClass.Weapon or 2
local INVENTORY_TYPE_TRINKET = Enum and Enum.InventoryType
    and Enum.InventoryType.IndexTrinketType or 12
local INVENTORY_TYPE_TWO_HAND = Enum and Enum.InventoryType
    and Enum.InventoryType.Index2HweaponType or 17
local TWO_HANDED_WEAPON_SUBCLASSES = {
    [1] = true,  -- hache 2M
    [2] = true,  -- arc
    [3] = true,  -- arme à feu
    [5] = true,  -- masse 2M
    [6] = true,  -- arme d'hast
    [8] = true,  -- épée 2M
    [10] = true, -- bâton
    [18] = true, -- arbalète
    [20] = true, -- canne à pêche
}
local KNOWN_WEAPON_SUBCLASSES = {
    [0] = true, [1] = true, [2] = true, [3] = true, [4] = true,
    [5] = true, [6] = true, [7] = true, [8] = true, [9] = true,
    [10] = true, [11] = true, [12] = true, [13] = true, [14] = true,
    [15] = true, [16] = true, [17] = true, [18] = true, [19] = true,
    [20] = true,
}

local function IsSecret(value)
    if type(issecretvalue) ~= "function" then return false end
    local ok, secret = pcall(issecretvalue, value)
    return ok and secret and true or false
end

local function Plain(value, expectedType)
    if IsSecret(value) or value == nil then return nil end
    if expectedType and type(value) ~= expectedType then return nil end
    return value
end

local function OrNull(value)
    if IsSecret(value) or value == nil then return NULL end
    return value
end

local function IsNull(value)
    return JSON.IsNull(value)
end

local function SafeCall(fn, ...)
    if type(fn) ~= "function" then return false end
    local ok, a, b, c, d, e, f = pcall(fn, ...)
    if not ok then return false end
    return true, a, b, c, d, e, f
end

local function Api(api, key, fallback)
    if type(api) == "table" and api[key] ~= nil then return api[key] end
    return fallback
end

local function Field(info, key, expectedType)
    if type(info) ~= "table" or IsSecret(info) then return nil end
    local ok, value = pcall(function() return info[key] end)
    if not ok then return nil end
    return Plain(value, expectedType)
end

local function ResourceError(key, code, idField, id)
    local result = {
        domain = "resources",
        key = key,
        code = code,
    }
    if idField then result[idField] = OrNull(id) end
    return result
end

function V2.ResolveRegion(regionID)
    regionID = Plain(regionID, "number")
    if not regionID or regionID ~= math.floor(regionID) or not REGION_NAMES[regionID] then
        return nil, nil
    end
    return regionID, REGION_NAMES[regionID]
end

local function ReadRegion(api)
    local regionID
    local unitGUID = Api(api, "unitGUID", UnitGUID)
    local getGameAccount = Api(api, "getGameAccountInfoByGUID",
        C_BattleNet and C_BattleNet.GetGameAccountInfoByGUID)
    local okGUID, guid = SafeCall(unitGUID, "player")
    guid = okGUID and Plain(guid, "string") or nil
    if guid and type(getGameAccount) == "function" then
        local okInfo, info = SafeCall(getGameAccount, guid)
        if okInfo then regionID = Field(info, "regionID", "number") end
    end
    if not regionID then
        local getCurrentRegion = Api(api, "getCurrentRegion", GetCurrentRegion)
        local ok, fallbackID = SafeCall(getCurrentRegion)
        if ok then regionID = Plain(fallbackID, "number") end
    end
    return V2.ResolveRegion(regionID)
end

function V2.BuildClient(api)
    local version, build, interface
    local getBuildInfo = Api(api, "getBuildInfo", GetBuildInfo)
    local ok, a, b, _, d = SafeCall(getBuildInfo)
    if ok then
        version = Plain(a, "string")
        build = Plain(b, "string")
        interface = Plain(d, "number")
    end
    local getLocale = Api(api, "getLocale", GetLocale)
    local localeOK, locale = SafeCall(getLocale)
    local regionID, region = ReadRegion(api)
    local getServerTime = Api(api, "getServerTime", GetServerTime or time)
    local timeOK, generatedAt = SafeCall(getServerTime)
    return {
        wow_version = OrNull(version),
        build = OrNull(build),
        interface = OrNull(interface),
        locale = OrNull(localeOK and Plain(locale, "string") or nil),
        region_id = OrNull(regionID),
        region = OrNull(region),
        generated_at = OrNull(timeOK and Plain(generatedAt, "number") or nil),
    }
end

function V2.ResolveLootSpecialization(lootSpecID, activeSpecID, apiAvailable)
    if apiAvailable == false then return nil, nil end
    lootSpecID = Plain(lootSpecID, "number")
    activeSpecID = Plain(activeSpecID, "number")
    if not lootSpecID or lootSpecID < 0 or lootSpecID ~= math.floor(lootSpecID) then
        return nil, nil
    end
    if lootSpecID > 0 then return lootSpecID, false end
    if activeSpecID and activeSpecID > 0 and activeSpecID == math.floor(activeSpecID) then
        return activeSpecID, true
    end
    return nil, true
end

function V2.ReadLootSpecialization(activeSpecID, api)
    local fn = Api(api, "getLootSpecialization", GetLootSpecialization)
    if type(fn) ~= "function" then return NULL, NULL end
    local ok, lootSpecID = SafeCall(fn)
    local id, usesActive = V2.ResolveLootSpecialization(lootSpecID, activeSpecID, ok)
    return OrNull(id), OrNull(usesActive)
end

function V2.ReadCurrency(definition, api)
    definition = type(definition) == "table" and definition or {}
    local currencyID = Plain(definition.currency_id, "number")
    local getCurrencyInfo = Api(api, "getCurrencyInfo",
        C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo)
    local ok, info = SafeCall(getCurrencyInfo, currencyID)
    if not ok or type(info) ~= "table" or IsSecret(info) then
        return {
            key = OrNull(definition.key), currency_id = OrNull(currencyID),
            quantity = NULL, max_quantity = NULL, earned_this_week = NULL,
            weekly_max = NULL, total_earned = NULL, discovered = NULL,
            is_account_transferable = NULL,
            source = "C_CurrencyInfo.GetCurrencyInfo", complete = false,
        }, ResourceError(definition.key, "CURRENCY_DATA_UNAVAILABLE", "currency_id", currencyID)
    end
    local quantity = Field(info, "quantity", "number")
    local discovered = Field(info, "discovered", "boolean")
    local result = {
        key = OrNull(definition.key),
        currency_id = OrNull(currencyID),
        quantity = OrNull(quantity),
        max_quantity = OrNull(Field(info, "maxQuantity", "number")),
        earned_this_week = OrNull(Field(info, "quantityEarnedThisWeek", "number")),
        weekly_max = OrNull(Field(info, "maxWeeklyQuantity", "number")),
        total_earned = OrNull(Field(info, "totalEarned", "number")),
        discovered = OrNull(discovered),
        is_account_transferable = OrNull(Field(info, "isAccountTransferable", "boolean")),
        source = "C_CurrencyInfo.GetCurrencyInfo",
        complete = quantity ~= nil and discovered ~= nil,
        _recharging_cycle_duration_ms = OrNull(
            Field(info, "rechargingCycleDurationMS", "number")),
        _recharging_amount_per_cycle = OrNull(
            Field(info, "rechargingAmountPerCycle", "number")),
    }
    if result.complete then return result end
    return result, ResourceError(definition.key, "CURRENCY_FIELDS_INCOMPLETE", "currency_id", currencyID)
end

function V2.ReadItemResource(definition, api)
    definition = type(definition) == "table" and definition or {}
    local itemID = Plain(definition.item_id, "number")
    local getItemCount = Api(api, "getItemCount", C_Item and C_Item.GetItemCount)
    -- includeBank=true, includeUses=false, includeReagentBank=true,
    -- includeAccountBank=true. Le contenu des banques n'est jamais exporté.
    local ok, quantity = SafeCall(getItemCount, itemID, true, false, true, true)
    quantity = ok and Plain(quantity, "number") or nil
    local result = {
        key = OrNull(definition.key), item_id = OrNull(itemID),
        quantity = OrNull(quantity), includes_bank = true,
        includes_reagent_bank = true, includes_account_bank = true,
        complete = quantity ~= nil,
    }
    if result.complete then return result end
    return result, ResourceError(definition.key, "ITEM_COUNT_UNAVAILABLE", "item_id", itemID)
end

local function PublicCurrency(currency)
    return {
        key = currency.key, currency_id = currency.currency_id,
        quantity = currency.quantity, max_quantity = currency.max_quantity,
        earned_this_week = currency.earned_this_week,
        weekly_max = currency.weekly_max, total_earned = currency.total_earned,
        discovered = currency.discovered,
        is_account_transferable = currency.is_account_transferable,
        source = currency.source, complete = currency.complete,
    }
end

function V2.ReadAccountQuestUnlock(key, questID, api)
    local getServerTime = Api(api, "getServerTime", GetServerTime or time)
    local timeOK, checkedAt = SafeCall(getServerTime)
    checkedAt = timeOK and Plain(checkedAt, "number") or nil
    local meta = {
        method = "C_QuestLog.IsQuestFlaggedCompletedOnAccount",
        quest_id = questID,
        checked = false,
        complete = false,
        checked_at = OrNull(checkedAt and math.floor(checkedAt) or nil),
    }
    local fn = Api(api, "isQuestCompletedOnAccount",
        C_QuestLog and C_QuestLog.IsQuestFlaggedCompletedOnAccount)
    if type(fn) ~= "function" then
        return NULL, meta,
            ResourceError(key, "ACCOUNT_QUEST_API_UNAVAILABLE", "quest_id", questID)
    end
    local ok, value = SafeCall(fn, questID)
    if ok then
        value = Plain(value, "boolean")
    else
        value = nil
    end
    if value == nil then
        return NULL, meta,
            ResourceError(key, "ACCOUNT_QUEST_QUERY_FAILED", "quest_id", questID)
    end
    meta.checked = true
    meta.complete = true
    return value, meta, nil
end

function V2.BuildResources(api)
    local season = Manifest and Manifest.GetActive and Manifest.GetActive() or nil
    local currencies, items, errors = JSON.Array({}), JSON.Array({}), JSON.Array({})
    local currencyByKey, itemByKey = {}, {}
    local complete = type(season) == "table"
    if not season then
        errors[#errors + 1] = ResourceError("season_manifest", "SEASON_MANIFEST_UNAVAILABLE")
    end

    for _, definition in ipairs(season and season.currencies or {}) do
        local internal, err = V2.ReadCurrency(definition, api)
        currencyByKey[definition.key] = internal
        currencies[#currencies + 1] = PublicCurrency(internal)
        if not internal.complete then complete = false end
        if err then errors[#errors + 1] = err end
    end
    for _, definition in ipairs(season and season.items or {}) do
        local resource, err = V2.ReadItemResource(definition, api)
        itemByKey[definition.key] = resource
        items[#items + 1] = resource
        if not resource.complete then complete = false end
        if err then errors[#errors + 1] = err end
    end

    local catalystCurrency = currencyByKey.dawnlight_manaflux
    local catalyst = {
        currency_id = catalystCurrency and catalystCurrency.currency_id or NULL,
        quantity = catalystCurrency and catalystCurrency.quantity or NULL,
        max_quantity = catalystCurrency and catalystCurrency.max_quantity or NULL,
        recharging_cycle_duration_ms = catalystCurrency
            and catalystCurrency._recharging_cycle_duration_ms or NULL,
        recharging_amount_per_cycle = catalystCurrency
            and catalystCurrency._recharging_amount_per_cycle or NULL,
        discovered = catalystCurrency and catalystCurrency.discovered or NULL,
        complete = catalystCurrency and catalystCurrency.complete == true or false,
    }
    if not catalyst.complete then complete = false end

    local nebulous = currencyByKey.nebulous_voidcore
    local shards = itemByKey.ascendant_voidshard
    local cores = itemByKey.ascendant_voidcore
    local unlocked, unlockedMeta, unlockedError = V2.ReadAccountQuestUnlock(
        "voidforge_unlocked", VOIDFORGE_UNLOCK_QUEST_ID, api)
    local nilhammerUnlocked, nilhammerMeta, nilhammerError = V2.ReadAccountQuestUnlock(
        "nilhammer_unlocked", NILHAMMER_UNLOCK_QUEST_ID, api)
    if unlockedError then errors[#errors + 1] = unlockedError end
    if nilhammerError then errors[#errors + 1] = nilhammerError end

    local voidforge = {
        unlocked = unlocked,
        unlocked_meta = unlockedMeta,
        nilhammer_unlocked = nilhammerUnlocked,
        nilhammer_unlocked_meta = nilhammerMeta,
        -- Alias additif conservé pour les imports 0.47.0 déjà en circulation.
        nilhammer_meta = nilhammerMeta,
        nebulous_voidcores = {
            quantity = nebulous and nebulous.quantity or NULL,
            complete = nebulous and nebulous.complete == true or false,
        },
        ascendant_voidshards = {
            quantity = shards and shards.quantity or NULL,
            complete = shards and shards.complete == true or false,
        },
        ascendant_voidcores = {
            quantity = cores and cores.quantity or NULL,
            complete = cores and cores.complete == true or false,
        },
        complete = unlockedMeta.complete == true and nilhammerMeta.complete == true
            and nebulous and nebulous.complete == true
            and shards and shards.complete == true
            and cores and cores.complete == true or false,
    }
    if not voidforge.complete then complete = false end

    return {
        complete = complete,
        season_manifest = Manifest and Manifest.ACTIVE_SEASON or NULL,
        currencies = currencies,
        items = items,
        catalyst = catalyst,
        voidforge = voidforge,
        errors = errors,
    }
end

function V2.BuildItemCatalyst(location, expectedItemID, api)
    local result = {
        checked = false,
        conversion_available = NULL,
        conversion_output_item_id = NULL,
        conversion_output_item_name = NULL,
        conversion_output_item_link = NULL,
        conversion_output_icon_file_id = NULL,
        conversion_output_set_id = NULL,
        conversion_output_is_tier = NULL,
        resolution_method = NULL,
        resolution_complete = false,
        checked_at = NULL,
        complete = false,
        reason = "conversion_check_unavailable",
    }
    if location == nil then return result end
    local getItemID = Api(api, "getItemID", C_Item and C_Item.GetItemID)
    local isConvertible = Api(api, "isItemConvertible",
        C_Item and C_Item.IsItemConvertibleAndValidForPlayer)
    local getOutputIcon = Api(api, "getConversionOutputIcon",
        C_Item and C_Item.GetItemConversionOutputIcon)
    if type(getItemID) ~= "function" or type(isConvertible) ~= "function" then
        return result
    end
    local idOK, currentItemID = SafeCall(getItemID, location)
    currentItemID = idOK and Plain(currentItemID, "number") or nil
    if not currentItemID or currentItemID ~= Plain(expectedItemID, "number") then
        result.reason = "item_location_changed"
        return result
    end
    local convertibleOK, convertible = SafeCall(isConvertible, location)
    if convertibleOK then
        convertible = Plain(convertible, "boolean")
    else
        convertible = nil
    end
    if convertible == nil then
        result.reason = "conversion_eligibility_unavailable"
        return result
    end
    local getServerTime = Api(api, "getServerTime", GetServerTime or time)
    local timeOK, checkedAt = SafeCall(getServerTime)
    checkedAt = timeOK and Plain(checkedAt, "number") or nil
    result.checked = true
    result.conversion_available = convertible
    result.checked_at = OrNull(checkedAt and math.floor(checkedAt) or nil)
    if not result.conversion_available then
        -- L'absence de conversion est une reponse complete : aucun objet de
        -- sortie ne doit exister dans ce cas.
        result.complete = true
        result.reason = NULL
        result.resolution_method = "C_Item.IsItemConvertibleAndValidForPlayer"
        result.resolution_complete = true
        return result
    end

    if type(getOutputIcon) ~= "function" then
        result.reason = "conversion_output_icon_api_unavailable"
        return result
    end
    local iconOK, icon = SafeCall(getOutputIcon, location)
    icon = iconOK and not IsSecret(icon) and Plain(icon, "number") or nil
    if not icon or icon <= 0 then
        result.reason = "conversion_output_icon_unavailable"
        return result
    end

    -- Retail fournit l'éligibilité et l'icône, pas l'identité structurée de la
    -- sortie. L'icône n'est jamais convertie en itemID, setID ou état tier.
    result.conversion_output_icon_file_id = icon
    result.resolution_method = "C_Item.GetItemConversionOutputIcon"
    result.resolution_complete = true
    result.complete = true
    result.reason = NULL
    return result
end

function V2.IsTwoHanded(itemClassID, itemSubclassID, inventoryTypeID)
    itemClassID = Plain(itemClassID, "number")
    itemSubclassID = Plain(itemSubclassID, "number")
    inventoryTypeID = Plain(inventoryTypeID, "number")
    if itemClassID ~= ITEM_CLASS_WEAPON then return nil end
    if inventoryTypeID == INVENTORY_TYPE_TWO_HAND then return true end
    if itemSubclassID and TWO_HANDED_WEAPON_SUBCLASSES[itemSubclassID] then return true end
    if itemSubclassID and KNOWN_WEAPON_SUBCLASSES[itemSubclassID] then return false end
    return nil
end

function V2.BuildCrafting(itemInfo, expectedItemID, craftedQuality, originalCraft, api)
    local creationContext
    local getCreationContext = Api(api, "getItemCreationContext",
        C_Item and C_Item.GetItemCreationContext)
    local ok, returnedItemID, context = SafeCall(getCreationContext, itemInfo)
    returnedItemID = ok and Plain(returnedItemID, "number") or nil
    if ok and (not expectedItemID or returnedItemID == expectedItemID) then
        creationContext = Plain(context, "string")
        if creationContext == "" then creationContext = nil end
    end

    craftedQuality = Plain(craftedQuality, "number")
    local recipeID = type(originalCraft) == "table"
        and Plain(originalCraft.recipe_id, "number") or nil
    local isCrafted
    if craftedQuality ~= nil or (recipeID and recipeID > 0) then
        isCrafted = true
    elseif creationContext then
        local normalized = creationContext:lower():gsub("[^%a]", "")
        isCrafted = normalized == "tradeskill"
    end
    local complete = isCrafted ~= nil and creationContext ~= nil
        and (isCrafted == false or craftedQuality ~= nil)
    return {
        is_crafted = OrNull(isCrafted),
        quality = OrNull(craftedQuality),
        creation_context = OrNull(creationContext),
        complete = complete,
    }
end

function V2.BuildItemVoidforge(item)
    item = type(item) == "table" and item or {}
    local classID = Plain(item.item_class_id, "number")
    local inventoryTypeID = Plain(item.inventory_type_id, "number")
        or Plain(item.inventory_type, "number")
    local equipLocation = Plain(item.equip_location, "string")
    local relevantLocation = equipLocation == "INVTYPE_TRINKET"
        or equipLocation == "INVTYPE_WEAPON"
        or equipLocation == "INVTYPE_2HWEAPON"
        or equipLocation == "INVTYPE_WEAPONMAINHAND"
        or equipLocation == "INVTYPE_WEAPONOFFHAND"
        or equipLocation == "INVTYPE_SHIELD"
        or equipLocation == "INVTYPE_HOLDABLE"
        or equipLocation == "INVTYPE_RANGED"
        or equipLocation == "INVTYPE_RANGEDRIGHT"
    if classID ~= ITEM_CLASS_WEAPON and inventoryTypeID ~= INVENTORY_TYPE_TRINKET
        and not relevantLocation then
        return nil
    end
    -- Aucun signal structuré 12.0.7 ne certifie l'éligibilité ou l'application
    -- d'un Ascendant Voidcore sur une instance arbitraire.
    return {
        checked = false,
        eligible_for_ascendant_upgrade = NULL,
        already_ascendant_upgraded = NULL,
        complete = false,
    }
end

local function RedundancySlots(api)
    local explicit = type(api) == "table" and api.redundancySlots or nil
    local source = explicit or (Enum and Enum.ItemRedundancySlot)
    local slots, seen = {}, {}
    if type(source) == "table" then
        for key, value in pairs(source) do
            local slot = type(value) == "number" and value or key
            slot = Plain(slot, "number")
            if slot and slot == math.floor(slot) and not seen[slot] then
                seen[slot] = true
                slots[#slots + 1] = slot
            end
        end
    end
    table.sort(slots)
    return slots
end

function V2.BuildUpgradeState(api)
    local slots, errors = JSON.Array({}), JSON.Array({})
    local getHighWatermark = Api(api, "getHighWatermarkForSlot",
        C_ItemUpgrade and C_ItemUpgrade.GetHighWatermarkForSlot)
    local slotIDs = RedundancySlots(api)
    local complete = type(getHighWatermark) == "function" and #slotIDs > 0
    if #slotIDs == 0 then
        errors[#errors + 1] = {
            domain = "upgrade_state", key = "slots",
            code = "ITEM_REDUNDANCY_SLOTS_UNAVAILABLE",
        }
    end
    for _, slotID in ipairs(slotIDs) do
        local ok, character, account = SafeCall(getHighWatermark, slotID)
        character = ok and Plain(character, "number") or nil
        account = ok and Plain(account, "number") or nil
        local slotComplete = character ~= nil and account ~= nil
        slots[#slots + 1] = {
            slot_id = slotID,
            character_high_watermark = OrNull(character),
            account_high_watermark = OrNull(account),
            complete = slotComplete,
        }
        if not slotComplete then
            complete = false
            errors[#errors + 1] = {
                domain = "upgrade_state", key = "slot", slot_id = slotID,
                code = "HIGH_WATERMARK_UNAVAILABLE",
            }
        end
    end
    return { complete = complete, slots = slots, errors = errors }
end

local function PublicCalendarTime(value)
    if type(value) ~= "table" or IsSecret(value) then return NULL, false end
    local result = {
        year = OrNull(Field(value, "year", "number")),
        month = OrNull(Field(value, "month", "number")),
        month_day = OrNull(Field(value, "monthDay", "number")),
        weekday = OrNull(Field(value, "weekday", "number")),
        hour = OrNull(Field(value, "hour", "number")),
        minute = OrNull(Field(value, "minute", "number")),
    }
    local complete = true
    for _, key in ipairs({ "year", "month", "month_day", "weekday", "hour", "minute" }) do
        if IsNull(result[key]) then complete = false end
    end
    result.complete = complete
    return result, complete
end

function V2.BuildMythicPlusRuns(api)
    local output, errors = JSON.Array({}), JSON.Array({})
    local requestMapInfo = Api(api, "requestMythicPlusMapInfo",
        C_MythicPlus and C_MythicPlus.RequestMapInfo)
    if type(requestMapInfo) == "function" then SafeCall(requestMapInfo) end

    local getRunHistory = Api(api, "getMythicPlusRunHistory",
        C_MythicPlus and C_MythicPlus.GetRunHistory)
    if type(getRunHistory) ~= "function" then
        errors[#errors + 1] = {
            domain = "weekly_rewards", key = "mythic_plus_runs",
            code = "MYTHIC_PLUS_RUN_HISTORY_API_UNAVAILABLE",
        }
        return output, {
            complete = false,
            source = "C_MythicPlus.GetRunHistory",
            scope = "current_week_completed",
            include_previous_weeks = false,
            include_incomplete_runs = false,
            current_season_only = true,
            returned_count = NULL,
            count = 0,
        }, errors
    end

    -- Signature Retail 12.0.7 : includePreviousWeeks=false,
    -- includeIncompleteRuns=false, currentSeasonOnly=true.
    local ok, history = SafeCall(getRunHistory, false, false, true)
    if not ok or type(history) ~= "table" or IsSecret(history) then
        errors[#errors + 1] = {
            domain = "weekly_rewards", key = "mythic_plus_runs",
            code = "MYTHIC_PLUS_RUN_HISTORY_UNAVAILABLE",
        }
        return output, {
            complete = false,
            source = "C_MythicPlus.GetRunHistory",
            scope = "current_week_completed",
            include_previous_weeks = false,
            include_incomplete_runs = false,
            current_season_only = true,
            returned_count = NULL,
            count = 0,
        }, errors
    end

    local complete = true
    local getMapUIInfo = Api(api, "getChallengeMapUIInfo",
        C_ChallengeMode and C_ChallengeMode.GetMapUIInfo)
    for sourceIndex, run in ipairs(history) do
        if type(run) ~= "table" or IsSecret(run) then
            complete = false
            errors[#errors + 1] = {
                domain = "weekly_rewards", key = "mythic_plus_run",
                run_index = sourceIndex,
                code = "MYTHIC_PLUS_RUN_UNAVAILABLE",
            }
        else
            local thisWeek = Field(run, "thisWeek", "boolean")
            local completed = Field(run, "completed", "boolean")
            if thisWeek == nil or completed == nil then
                complete = false
                errors[#errors + 1] = {
                    domain = "weekly_rewards", key = "mythic_plus_run",
                    run_index = sourceIndex,
                    code = "MYTHIC_PLUS_RUN_SCOPE_UNAVAILABLE",
                }
            elseif thisWeek and completed then
                local challengeMapID = Field(run, "mapChallengeModeID", "number")
                local level = Field(run, "level", "number")
                local coreComplete = challengeMapID ~= nil and level ~= nil
                if not coreComplete then
                    complete = false
                    errors[#errors + 1] = {
                        domain = "weekly_rewards", key = "mythic_plus_run",
                        run_index = sourceIndex,
                        code = "MYTHIC_PLUS_RUN_FIELDS_INCOMPLETE",
                    }
                end

                local mapName, uiMapID
                if challengeMapID and type(getMapUIInfo) == "function" then
                    local mapOK, name, _, _, _, _, rawUIMapID = SafeCall(
                        getMapUIInfo, challengeMapID)
                    if mapOK then
                        mapName = Plain(name, "string")
                        uiMapID = Plain(rawUIMapID, "number")
                    end
                end
                local completionDate = Field(run, "completionDate", "table")
                local publicDate, dateComplete = PublicCalendarTime(completionDate)
                output[#output + 1] = {
                    map_challenge_mode_id = OrNull(challengeMapID),
                    map_name = OrNull(mapName),
                    ui_map_id = OrNull(uiMapID),
                    level = OrNull(level),
                    this_week = true,
                    completed = true,
                    run_score = OrNull(Field(run, "runScore", "number")),
                    duration_seconds = OrNull(Field(run, "durationSec", "number")),
                    completion_date = publicDate,
                    completion_date_complete = dateComplete,
                    season_id = OrNull(Field(run, "season", "number")),
                    complete = coreComplete,
                }
            end
        end
    end

    -- Ordre déterministe et directement exploitable pour les rangs 1/4/8.
    -- Aucun run n'est dédupliqué : deux clés identiques restent deux faits.
    table.sort(output, function(left, right)
        local ll = Plain(left.level, "number") or -math.huge
        local rl = Plain(right.level, "number") or -math.huge
        if ll ~= rl then return ll > rl end
        local lm = Plain(left.map_challenge_mode_id, "number") or math.huge
        local rm = Plain(right.map_challenge_mode_id, "number") or math.huge
        return lm < rm
    end)
    return output, {
        complete = complete,
        source = "C_MythicPlus.GetRunHistory",
        scope = "current_week_completed",
        include_previous_weeks = false,
        include_incomplete_runs = false,
        current_season_only = true,
        returned_count = #history,
        count = #output,
    }, errors
end

function V2.BuildWeeklyRewards(api)
    local output, errors = JSON.Array({}), JSON.Array({})
    local getActivities = Api(api, "getWeeklyActivities",
        C_WeeklyRewards and C_WeeklyRewards.GetActivities)
    local ok, activities = SafeCall(getActivities)
    local activitiesComplete = ok and type(activities) == "table"
        and not IsSecret(activities)
    if not activitiesComplete then
        errors[#errors + 1] = {
            domain = "weekly_rewards", key = "activities",
            code = "WEEKLY_ACTIVITIES_UNAVAILABLE",
        }
        activities = {}
    end
    for sourceIndex, activity in ipairs(activities) do
        if type(activity) == "table" and not IsSecret(activity) then
            local activityType = Field(activity, "type", "number")
            local activityIndex = Field(activity, "index", "number")
            local threshold = Field(activity, "threshold", "number")
            local progress = Field(activity, "progress", "number")
            local unlocked
            if threshold ~= nil and progress ~= nil then
                unlocked = progress >= threshold and 1 or 0
            end
            if activityType == nil or activityIndex == nil
                or threshold == nil or progress == nil then
                activitiesComplete = false
                errors[#errors + 1] = {
                    domain = "weekly_rewards", key = "activity",
                    activity_index = sourceIndex,
                    code = "WEEKLY_ACTIVITY_FIELDS_INCOMPLETE",
                }
            end
            output[#output + 1] = {
                type = OrNull(activityType),
                index = OrNull(activityIndex),
                threshold = OrNull(threshold),
                progress = OrNull(progress),
                tier = OrNull(Field(activity, "activityTierID", "number")),
                level = OrNull(Field(activity, "level", "number")),
                rewards_unlocked = OrNull(unlocked),
            }
        else
            activitiesComplete = false
            errors[#errors + 1] = {
                domain = "weekly_rewards", key = "activity",
                activity_index = sourceIndex,
                code = "WEEKLY_ACTIVITY_UNAVAILABLE",
            }
        end
    end
    table.sort(output, function(left, right)
        local lt = Plain(left.type, "number") or math.huge
        local rt = Plain(right.type, "number") or math.huge
        if lt ~= rt then return lt < rt end
        return (Plain(left.index, "number") or math.huge)
            < (Plain(right.index, "number") or math.huge)
    end)

    local mythicRuns, mythicMeta, mythicErrors = V2.BuildMythicPlusRuns(api)
    for _, err in ipairs(mythicErrors) do errors[#errors + 1] = err end
    return {
        complete = activitiesComplete and mythicMeta.complete == true,
        activities = output,
        mythic_plus_runs = mythicRuns,
        mythic_plus_runs_meta = mythicMeta,
        errors = errors,
    }
end

function V2.NormalizeGearErrors(errors)
    local output = JSON.Array({})
    for _, source in ipairs(type(errors) == "table" and errors or {}) do
        if type(source) == "table" then
            local copy = {}
            for key, value in pairs(source) do copy[key] = value end
            copy.domain = Plain(copy.domain, "string") or "gear"
            output[#output + 1] = copy
        end
    end
    return output
end

function V2.BuildCollectionStatus(gearComplete, characterStatsComplete,
        resources, upgradeState, weeklyRewards)
    return {
        gear = { complete = gearComplete == true },
        character_stats = { complete = characterStatsComplete == true },
        resources = { complete = type(resources) == "table" and resources.complete == true },
        upgrade_state = { complete = type(upgradeState) == "table"
            and upgradeState.complete == true },
        weekly_rewards = { complete = type(weeklyRewards) == "table"
            and weeklyRewards.complete == true },
    }
end

function V2.BuildDomains(api)
    return {
        client = V2.BuildClient(api),
        resources = V2.BuildResources(api),
        upgrade_state = V2.BuildUpgradeState(api),
        weekly_rewards = V2.BuildWeeklyRewards(api),
    }
end

-- Tests purs sans dépendre de l'état courant du personnage.
function V2.SelfTest()
    local euID, eu = V2.ResolveRegion(3)
    local unknownID, unknown = V2.ResolveRegion(99)
    local explicitLoot, explicitUses = V2.ResolveLootSpecialization(72, 71, true)
    local activeLoot, activeUses = V2.ResolveLootSpecialization(0, 104, true)

    local known = V2.ReadCurrency({ key = "known", currency_id = 1 }, {
        getCurrencyInfo = function()
            return { quantity = 12, maxQuantity = 90, quantityEarnedThisWeek = 4,
                maxWeeklyQuantity = 8, totalEarned = 30, discovered = true,
                isAccountTransferable = false }
        end,
    })
    local undiscovered = V2.ReadCurrency({ key = "hidden", currency_id = 2 }, {
        getCurrencyInfo = function() return { quantity = 0, discovered = false } end,
    })
    local unavailable = V2.ReadCurrency({ key = "missing", currency_id = 3 }, {
        getCurrencyInfo = function() return nil end,
    })

    local countArgs
    local counted = V2.ReadItemResource({ key = "item", item_id = 4 }, {
        getItemCount = function(...)
            countArgs = { ... }
            return 7
        end,
    })

    local loc = {}
    local catalystAvailable = V2.BuildItemCatalyst(loc, 50, {
        getItemID = function() return 50 end,
        isItemConvertible = function() return true end,
        getConversionOutputIcon = function() return 134400 end,
        getServerTime = function() return 1784134655 end,
    })
    local catalystTemporarilyUnavailable = V2.BuildItemCatalyst(loc, 50, {
        getItemID = function() return 50 end,
        isItemConvertible = function() return true end,
        getConversionOutputIcon = function() return nil end,
    })
    local catalystAbsent = V2.BuildItemCatalyst(loc, 50, {
        getItemID = function() return 50 end,
        isItemConvertible = function() return false end,
        getConversionOutputIcon = function() return nil end,
    })
    local catalystImpossible = V2.BuildItemCatalyst(loc, 50, {
        getItemID = function() return 51 end,
        isItemConvertible = function() return true end,
        getConversionOutputIcon = function() return 134400 end,
    })
    local weaponVoidforge = V2.BuildItemVoidforge({
        item_class_id = ITEM_CLASS_WEAPON, equip_location = "INVTYPE_WEAPON",
    })
    local offhandVoidforge = V2.BuildItemVoidforge({
        item_class_id = 4, equip_location = "INVTYPE_HOLDABLE",
    })
    local unrelatedVoidforge = V2.BuildItemVoidforge({
        item_class_id = 4, equip_location = "INVTYPE_HEAD",
    })
    -- Emplacements catalysables Retail : tete, epaules, dos, torse, poignets,
    -- mains, taille, jambes et pieds. Chaque emplacement est couvert une fois
    -- equipe et une fois en sac ; l'eligibilite reelle reste toujours decidee
    -- par C_Item.IsItemConvertibleAndValidForPlayer, jamais par cette liste.
    local catalystSlotCases = {}
    local catalystSlots = { 1, 3, 15, 5, 9, 10, 6, 7, 8 }
    for _, sourceType in ipairs({ "equipped", "bag" }) do
        for _, slot in ipairs(catalystSlots) do
            local sourceID = 50000 + slot
            local candidate = V2.BuildItemCatalyst(
                { source_type = sourceType, slot = slot }, sourceID, {
                    getItemID = function() return sourceID end,
                    isItemConvertible = function() return true end,
                    getConversionOutputIcon = function() return 7000000 + slot end,
                })
            catalystSlotCases[#catalystSlotCases + 1] = {
                source_type = sourceType, slot = slot, result = candidate,
            }
        end
    end
    local catalystSlotCasesOK = #catalystSlotCases == 18
    for _, case in ipairs(catalystSlotCases) do
        local candidate = case.result
        catalystSlotCasesOK = catalystSlotCasesOK
            and candidate.checked == true
            and candidate.conversion_available == true
            and candidate.complete == true
            and candidate.resolution_complete == true
            and candidate.resolution_method == "C_Item.GetItemConversionOutputIcon"
            and IsNull(candidate.conversion_output_item_id)
    end

    local upgrades = V2.BuildUpgradeState({
        redundancySlots = { Head = 1, Trinket = 12 },
        getHighWatermarkForSlot = function(slot) return 275 + slot, 288 + slot end,
    })
    local oneHanded = V2.IsTwoHanded(ITEM_CLASS_WEAPON, 0, 13)
    local twoHanded = V2.IsTwoHanded(ITEM_CLASS_WEAPON, 8, 17)
    local crafted = V2.BuildCrafting("item:1", 1, 5,
        { recipe_id = 123 }, {
            getItemCreationContext = function() return 1, "TradeSkill" end,
        })

    local resourceAPI = {
        getCurrencyInfo = function(id)
            return { quantity = id == 3418 and 3 or 0, maxQuantity = 100,
                discovered = true, isAccountTransferable = false }
        end,
        getItemCount = function(id)
            if id == 268650 then return 2 end
            if id == 268552 then return 1 end
            return 0
        end,
        isQuestCompletedOnAccount = function(id)
            return id == VOIDFORGE_UNLOCK_QUEST_ID
                or id == NILHAMMER_UNLOCK_QUEST_ID
        end,
        getServerTime = function() return 1784134655 end,
    }
    local resourcesPresent = V2.BuildResources(resourceAPI)
    local resourcesLocked = V2.BuildResources({
        getCurrencyInfo = resourceAPI.getCurrencyInfo,
        getItemCount = resourceAPI.getItemCount,
        isQuestCompletedOnAccount = function() return false end,
        getServerTime = function() return 1784134655 end,
    })
    local resourcesAbsent = V2.BuildResources({
        getCurrencyInfo = function() return nil end,
        getItemCount = function() return nil end,
        isQuestCompletedOnAccount = false,
    })
    local questTrue, questTrueMeta = V2.ReadAccountQuestUnlock(
        "voidforge_unlocked", VOIDFORGE_UNLOCK_QUEST_ID, {
            isQuestCompletedOnAccount = function() return true end,
        })
    local questFalse, questFalseMeta = V2.ReadAccountQuestUnlock(
        "voidforge_unlocked", VOIDFORGE_UNLOCK_QUEST_ID, {
            isQuestCompletedOnAccount = function() return false end,
        })
    local questMissing, questMissingMeta, questMissingError = V2.ReadAccountQuestUnlock(
        "voidforge_unlocked", VOIDFORGE_UNLOCK_QUEST_ID, {
            isQuestCompletedOnAccount = false,
        })
    local questFailed, questFailedMeta, questFailedError = V2.ReadAccountQuestUnlock(
        "voidforge_unlocked", VOIDFORGE_UNLOCK_QUEST_ID, {
            isQuestCompletedOnAccount = function() error("unavailable") end,
        })
    local collection = V2.BuildCollectionStatus(true, true, resourcesAbsent,
        upgrades, { complete = true })
    local runHistoryArgs, mapInfoRequests
    local weekly = V2.BuildWeeklyRewards({
        getWeeklyActivities = function()
            return {
                { type = 3, index = 2, threshold = 4, progress = 3,
                    activityTierID = 10, level = 1 },
                { type = 1, index = 1, threshold = 1, progress = 2,
                    activityTierID = 20, level = 8 },
            }
        end,
        requestMythicPlusMapInfo = function()
            mapInfoRequests = (mapInfoRequests or 0) + 1
        end,
        getMythicPlusRunHistory = function(...)
            runHistoryArgs = { ... }
            return {
                { mapChallengeModeID = 200, level = 10, thisWeek = true,
                    completed = true, runScore = 120.5, durationSec = 1800,
                    completionDate = { year = 2026, month = 7, monthDay = 15,
                        weekday = 4, hour = 20, minute = 10 }, season = 1 },
                -- Deux runs identiques sont deux faits distincts et ne doivent
                -- jamais être dédupliqués.
                { mapChallengeModeID = 200, level = 10, thisWeek = true,
                    completed = true, runScore = 119.5, durationSec = 1810,
                    completionDate = { year = 2026, month = 7, monthDay = 15,
                        weekday = 4, hour = 21, minute = 10 }, season = 1 },
                { mapChallengeModeID = 201, level = 12, thisWeek = true,
                    completed = true, runScore = 140, durationSec = 1700,
                    completionDate = { year = 2026, month = 7, monthDay = 16,
                        weekday = 5, hour = 18, minute = 30 }, season = 1 },
                { mapChallengeModeID = 202, level = 8, thisWeek = true,
                    completed = true, runScore = 100, durationSec = 1900,
                    completionDate = { year = 2026, month = 7, monthDay = 14,
                        weekday = 3, hour = 19, minute = 45 }, season = 1 },
                { mapChallengeModeID = 999, level = 20, thisWeek = false,
                    completed = true },
                { mapChallengeModeID = 998, level = 20, thisWeek = true,
                    completed = false },
            }
        end,
        getChallengeMapUIInfo = function(challengeMapID)
            return "Map " .. challengeMapID, challengeMapID, 1800,
                nil, nil, 1000 + challengeMapID
        end,
    })
    local weeklyUnavailable = V2.BuildWeeklyRewards({
        getWeeklyActivities = function() return nil end,
        getMythicPlusRunHistory = function() return nil end,
    })
    local weeklyEmpty = V2.BuildWeeklyRewards({
        getWeeklyActivities = function() return {} end,
        getMythicPlusRunHistory = function() return {} end,
    })
    local weeklyUnknownScope = V2.BuildWeeklyRewards({
        getWeeklyActivities = function() return {} end,
        getMythicPlusRunHistory = function()
            return { { mapChallengeModeID = 200, level = 10, completed = true } }
        end,
    })

    local encoded = JSON.Encode({ schema_version = 2, export_type = "gearcompass" })
    local lastBrace = type(encoded) == "string" and encoded:match(".*()}") or nil
    local exactJSON = type(encoded) == "string" and not encoded:find("%z")
        and encoded:sub(-1) == "}" and lastBrace == #encoded

    local ok = euID == 3 and eu == "EU" and unknownID == nil and unknown == nil
        and explicitLoot == 72 and explicitUses == false
        and activeLoot == 104 and activeUses == true
        and known.complete == true and known.quantity == 12
        and undiscovered.complete == true and undiscovered.quantity == 0
        and undiscovered.discovered == false
        and unavailable.complete == false and IsNull(unavailable.quantity)
        and counted.complete == true and counted.quantity == 7
        and countArgs and countArgs[2] == true and countArgs[3] == false
        and countArgs[4] == true and countArgs[5] == true
        and catalystAvailable.checked == true
        and catalystAvailable.conversion_available == true
        and catalystAvailable.complete == true
        and IsNull(catalystAvailable.reason)
        and IsNull(catalystAvailable.conversion_output_item_id)
        and catalystAvailable.resolution_method == "C_Item.GetItemConversionOutputIcon"
        and catalystAvailable.resolution_complete == true
        and catalystAvailable.checked_at == 1784134655
        and catalystTemporarilyUnavailable.checked == true
        and catalystTemporarilyUnavailable.conversion_available == true
        and catalystTemporarilyUnavailable.complete == false
        and catalystTemporarilyUnavailable.resolution_complete == false
        and catalystTemporarilyUnavailable.reason
            == "conversion_output_icon_unavailable"
        and catalystAbsent.checked == true
        and catalystAbsent.conversion_available == false
        and catalystAbsent.complete == true and IsNull(catalystAbsent.reason)
        and catalystImpossible.checked == false
        and catalystImpossible.reason == "item_location_changed"
        and weaponVoidforge and weaponVoidforge.complete == false
        and weaponVoidforge.checked == false
        and IsNull(weaponVoidforge.eligible_for_ascendant_upgrade)
        and offhandVoidforge and offhandVoidforge.complete == false
        and unrelatedVoidforge == nil
        and catalystSlotCasesOK
        and upgrades.complete == true and #upgrades.slots == 2
        and upgrades.slots[1].character_high_watermark == 276
        and upgrades.slots[1].account_high_watermark == 289
        and oneHanded == false and twoHanded == true
        and crafted.is_crafted == true and crafted.quality == 5
        and resourcesPresent.voidforge.nebulous_voidcores.quantity == 3
        and resourcesPresent.voidforge.ascendant_voidshards.quantity == 2
        and resourcesPresent.voidforge.ascendant_voidcores.quantity == 1
        and resourcesPresent.voidforge.unlocked == true
        and resourcesPresent.voidforge.unlocked_meta.quest_id == 94675
        and resourcesPresent.voidforge.unlocked_meta.method
            == "C_QuestLog.IsQuestFlaggedCompletedOnAccount"
        and resourcesPresent.voidforge.unlocked_meta.checked == true
        and resourcesPresent.voidforge.unlocked_meta.checked_at == 1784134655
        and resourcesPresent.voidforge.unlocked_meta.complete == true
        and resourcesPresent.voidforge.nilhammer_unlocked == true
        and resourcesPresent.voidforge.nilhammer_unlocked_meta.quest_id == 95271
        and resourcesPresent.voidforge.nilhammer_unlocked_meta.checked == true
        and resourcesPresent.voidforge.complete == true
        and resourcesLocked.voidforge.unlocked == false
        and resourcesLocked.voidforge.unlocked_meta.checked == true
        and resourcesLocked.voidforge.unlocked_meta.complete == true
        and resourcesLocked.voidforge.nilhammer_unlocked == false
        and questTrue == true and questTrueMeta.complete == true
        and questTrueMeta.checked == true
        and questFalse == false and questFalseMeta.complete == true
        and questFalseMeta.checked == true
        and IsNull(questMissing) and questMissingMeta.complete == false
        and questMissingMeta.checked == false
        and questMissingError.code == "ACCOUNT_QUEST_API_UNAVAILABLE"
        and IsNull(questFailed) and questFailedMeta.complete == false
        and questFailedError.code == "ACCOUNT_QUEST_QUERY_FAILED"
        and resourcesAbsent.complete == false
        and IsNull(resourcesAbsent.voidforge.unlocked)
        and IsNull(resourcesAbsent.voidforge.nilhammer_unlocked)
        and IsNull(resourcesAbsent.voidforge.nebulous_voidcores.quantity)
        and IsNull(resourcesAbsent.voidforge.ascendant_voidshards.quantity)
        and IsNull(resourcesAbsent.voidforge.ascendant_voidcores.quantity)
        and collection.gear.complete == true
        and collection.resources.complete == false
        and weekly.complete == true and #weekly.activities == 2
        and weekly.activities[1].type == 1
        and weekly.activities[1].rewards_unlocked == 1
        and weekly.activities[2].rewards_unlocked == 0
        and #weekly.mythic_plus_runs == 4
        and weekly.mythic_plus_runs[1].level == 12
        and weekly.mythic_plus_runs[2].level == 10
        and weekly.mythic_plus_runs[3].level == 10
        and weekly.mythic_plus_runs[4].level == 8
        and weekly.mythic_plus_runs[1].map_challenge_mode_id == 201
        and weekly.mythic_plus_runs[1].ui_map_id == 1201
        and weekly.mythic_plus_runs[1].completion_date.month_day == 16
        and weekly.mythic_plus_runs[1].completion_date_complete == true
        and weekly.mythic_plus_runs_meta.complete == true
        and weekly.mythic_plus_runs_meta.returned_count == 6
        and weekly.mythic_plus_runs_meta.count == 4
        and runHistoryArgs and runHistoryArgs[1] == false
        and runHistoryArgs[2] == false and runHistoryArgs[3] == true
        and mapInfoRequests == 1
        and weeklyUnavailable.complete == false
        and weeklyUnavailable.mythic_plus_runs_meta.complete == false
        and #weeklyUnavailable.mythic_plus_runs == 0
        and weeklyEmpty.complete == true
        and weeklyEmpty.mythic_plus_runs_meta.complete == true
        and weeklyEmpty.mythic_plus_runs_meta.count == 0
        and weeklyUnknownScope.complete == false
        and weeklyUnknownScope.mythic_plus_runs_meta.complete == false
        and #weeklyUnknownScope.mythic_plus_runs == 0
        and exactJSON

    return not not ok, {
        region_eu = eu, region_unknown = OrNull(unknown),
        explicit_loot_spec = explicitLoot, active_loot_spec = activeLoot,
        known_currency = known, undiscovered_currency = undiscovered,
        unavailable_currency = unavailable, item_count = counted,
        catalyst_available = catalystAvailable, catalyst_absent = catalystAbsent,
        catalyst_temporarily_unavailable = catalystTemporarilyUnavailable,
        catalyst_impossible = catalystImpossible,
        weapon_voidforge = weaponVoidforge, offhand_voidforge = offhandVoidforge,
        catalyst_slot_cases = catalystSlotCases, upgrades = upgrades,
        one_handed = oneHanded, two_handed = twoHanded, crafted = crafted,
        resources_present = resourcesPresent, resources_absent = resourcesAbsent,
        resources_locked = resourcesLocked,
        quest_true = questTrue, quest_false = questFalse,
        quest_missing = questMissing, quest_missing_error = questMissingError,
        quest_failed = questFailed, quest_failed_error = questFailedError,
        collection_status = collection, weekly_rewards = weekly,
        weekly_rewards_unavailable = weeklyUnavailable, exact_json = exactJSON,
        weekly_rewards_empty = weeklyEmpty,
        weekly_rewards_unknown_scope = weeklyUnknownScope,
    }
end
