Jump to content

Module:AchievementSystem: Difference between revisions

// via Wikitext Extension for VSCode
// via Wikitext Extension for VSCode
 
(61 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:AchievementSystem
--[[
-- Simplified achievement system that loads data from MediaWiki:AchievementData.json,
* Name: AchievementSystem
-- retrieves achievement information for pages, and renders achievement displays
* Author: Mark W. Datysgeld
-- for templates with simplified error handling and display.
* Description: Comprehensive achievement system that manages user badges and titles throughout ICANNWiki, loading data from MediaWiki JSON files and providing rendering functions for Person templates
--
* Notes: Loads from MediaWiki:AchievementData.json (user assignments) and MediaWiki:AchievementList.json (type definitions). CSS styling defined in Templates.css using achievement-{id} format. Includes caching and fallback mechanisms for robust JSON handling
-- STYLING NOTE: All achievement styling is defined in CSS/Templates.css, not in the JSON.
]]
-- This module only assigns CSS classes based on achievement IDs in the format:
 
-- .person-template .template-title.achievement-{id}::after {}
---@class UserAchievement
--
---@field type string
-- The module does not use any styling information from the JSON data structure.
---@field date? string


local Achievements = {}
local Achievements = {}


-- Debug configuration - set to true to enable console logging
--------------------------------------------------------------------------------
local DEBUG_MODE = true
-- JSON Handling
local function debugLog(message)
--------------------------------------------------------------------------------
     if not DEBUG_MODE then return end
-- Helper function to ensure we get an array
local function ensureArray(value)
    if type(value) ~= "table" then
        return {}
    end
   
    -- Check if it's an array-like table
    local isArray = true
    local count = 0
    for _ in pairs(value) do
        count = count + 1
    end
   
    -- If it has no numeric indices or is empty, return empty array
     if count == 0 then
        return {}
    end
   
    -- If it's a single string, wrap it in an array
    if count == 1 and type(value[1]) == "string" then
        return {value[1]}
    end
      
      
     -- Log to JavaScript console with structured data
     -- If it has a single non-array value, try to convert it to an array
     pcall(function()
     if count == 1 and next(value) and type(next(value)) ~= "number" then
         mw.logObject({
         local k, v = next(value)
            system = "achievement_simple",
        if type(v) == "string" then
             message = message,
             return {v}
            timestamp = os.date('%H:%M:%S')
         end
         })
     end
     end)
      
      
     -- Backup log to MediaWiki
     -- Return the original table if it seems to be an array
     mw.log("ACHIEVEMENT-DEBUG: " .. message)
     return value
end
end


-- Try to load JSON module with error handling
-- Use MediaWiki's built-in JSON functions directly
local json
local function jsonDecode(jsonString)
local jsonLoaded = pcall(function()
    if not jsonString then return nil end
    json = require('Module:JSON')
   
end)
    if mw.text and mw.text.jsonDecode then
 
        local success, result = pcall(function()
-- If JSON module failed to load, create a minimal fallback
            -- Use WITHOUT PRESERVE_KEYS flag to ensure proper array handling
if not jsonLoaded or not json then
            return mw.text.jsonDecode(jsonString)
     json = { decode = function() return nil end }
        end)
     debugLog('WARNING: Module:JSON not available, achievement features will be limited')
       
        if success and result then
            return result
        end
     end
      
    return nil
end
end


-- Create a fallback htmlEncode if not available
-- Simple HTML encode fallback
local htmlEncode = function(str)
local function htmlEncode(str)
     if mw.text and mw.text.htmlEncode then
     if mw.text and mw.text.htmlEncode then
         return mw.text.htmlEncode(str or '')
         return mw.text.htmlEncode(str or '')
     else
     else
         return (str or ''):gsub('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;'):gsub('"', '&quot;')
         return (str or '')
            :gsub('&', '&amp;')
            :gsub('<', '&lt;')
            :gsub('>', '&gt;')
            :gsub('"', '&quot;')
     end
     end
end
end


-- Constants
--------------------------------------------------------------------------------
-- Configuration, Default Data, and Cache
--------------------------------------------------------------------------------
local ACHIEVEMENT_DATA_PAGE = 'MediaWiki:AchievementData.json'
local ACHIEVEMENT_DATA_PAGE = 'MediaWiki:AchievementData.json'
 
local ACHIEVEMENT_LIST_PAGE = 'MediaWiki:AchievementList.json'
-- Test/debug configuration
local TEST_CONFIG = {
    enabled = true,                  -- Master toggle for test mode
    test_page_id = "18451",          -- Page ID used for testing
    test_user = "direct-test-user",  -- Test username
    debug_messages = true,          -- Show debug messages
    force_achievements = true,      -- Force achievements when JSON fails
   
    -- Type mapping for backward compatibility
    type_mapping = {
        ["jedi"] = "ach1",
        ["champion"] = "ach2",
        ["sponsor"] = "ach3",
        -- Include the new achievement types directly
        ["ach1"] = "ach1",
        ["ach2"] = "ach2",
        ["ach3"] = "ach3",
        ["title-test"] = "title-test"
    },
   
    -- Default test achievements
    test_achievements = {
        "title-test",
        "ach1",
        "ach2",
        "ach3"
    }
}
 
-- Helper to check if a page ID is the test page
local function isTestPage(pageId)
    return TEST_CONFIG.enabled and tostring(pageId) == TEST_CONFIG.test_page_id
end
 
-- Cache for achievement data (within request)
local dataCache = nil
local dataCache = nil
local typesCache = nil


-- Default data structure to use if loading fails
local DEFAULT_DATA = {
local DEFAULT_DATA = {  
     schema_version = 2,
     schema_version = 1,
     last_updated = os.date('!%Y-%m-%dT%H:%M:%SZ'),
     last_updated = os.date('!%Y-%m-%dT%H:%M:%SZ'),
     achievement_types = {},  
     achievement_types = {},
     user_achievements = {},
     user_achievements = {},
    cache_control = { version = 0 }
}
}


--[[
--------------------------------------------------------------------------------
Loads achievement data from MediaWiki:AchievementData.json with caching
-- Load achievement types from the JSON page
@return table The achievement data structure or default empty structure on failure
-- @param frame - The Scribunto frame object for preprocessing
]]
-- @return Array of achievement type definitions
function Achievements.loadData()
--------------------------------------------------------------------------------
     -- Direct console log for better visibility
function Achievements.loadTypes(frame)
    mw.log("JSON-DEBUG: Starting to load achievement data")
     -- Use the request-level cache if we already loaded data once
   
     if typesCache then
    -- Check if we can use the request-level cache
         return typesCache
     if dataCache then
        mw.log("JSON-DEBUG: Using request-level cached data")
         return dataCache
     end
     end
   
 
    -- Try to load data with error handling
     local success, types = pcall(function()
     local success, data = pcall(function()
         -- Get the JSON content using frame:preprocess if available
         -- First try to load from parser cache
         local jsonText
         local loadDataSuccess, cachedData = pcall(function()
        if frame and type(frame) == "table" and frame.preprocess then
            return mw.loadData('Module:AchievementSystem')
            -- Make sure frame is valid and has preprocess method
         end)
            local preprocessSuccess, preprocessResult = pcall(function()
                return frame:preprocess('{{MediaWiki:AchievementList.json}}')
            end)
           
            if preprocessSuccess and preprocessResult then
                jsonText = preprocessResult
            end
         end
          
          
         if loadDataSuccess and cachedData then
         -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading
             mw.log("JSON-DEBUG: Using mw.loadData cached data")
        if not jsonText then
             return cachedData
            -- Try using mw.loadJsonData first (preferred method)
            if mw.loadJsonData then
                local loadJsonSuccess, jsonData = pcall(function()
                    return mw.loadJsonData(ACHIEVEMENT_LIST_PAGE)
                end)
               
                if loadJsonSuccess and jsonData and type(jsonData) == 'table' and jsonData.achievement_types then
                    return jsonData.achievement_types
                end
             end
           
            -- Direct content loading approach as fallback
            local pageTitle = mw.title.new(ACHIEVEMENT_LIST_PAGE)
            if pageTitle and pageTitle.exists then
                -- Get raw content from the wiki page
                local contentSuccess, content = pcall(function()
                    return pageTitle:getContent()
                end)
               
                if contentSuccess and content and content ~= "" then
                    -- Remove any BOM or leading whitespace that might cause issues
                    content = content:gsub("^%s+", "")
                    if content:byte(1) == 239 and content:byte(2) == 187 and content:byte(3) == 191 then
                        content = content:sub(4)
                    end
                   
                    jsonText = content
                   
                    -- Try different JSON decode approaches
                    if jsonText and mw.text and mw.text.jsonDecode then
                        -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
                        local jsonDecodeSuccess, jsonData = pcall(function()
                            return mw.text.jsonDecode(jsonText)
                        end)
                       
                        if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                            return jsonData.achievement_types
                        end
                       
                        -- If that failed, try with JSON_TRY_FIXING flag
                        jsonDecodeSuccess, jsonData = pcall(function()
                            return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
                        end)
                       
                        if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                            return jsonData.achievement_types
                        end
                    end
                end
            end
           
            -- If we couldn't load from AchievementList.json, fall back to AchievementData.json
            local data = Achievements.loadData(frame)
             if data and data.achievement_types then
                return data.achievement_types
            end
         else
         else
             mw.log("JSON-DEBUG: mw.loadData failed or returned empty, using direct page load")
             -- We have jsonText from frame:preprocess, try to decode it
            if jsonText and mw.text and mw.text.jsonDecode then
                -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
                local jsonDecodeSuccess, jsonData = pcall(function()
                    return mw.text.jsonDecode(jsonText)
                end)
               
                if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                    return jsonData.achievement_types
                end
               
                -- If that failed, try with JSON_TRY_FIXING flag
                jsonDecodeSuccess, jsonData = pcall(function()
                    return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
                end)
               
                if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                    return jsonData.achievement_types
                end
            end
           
            -- If we couldn't decode the JSON, fall back to AchievementData.json
            local data = Achievements.loadData(frame)
            if data and data.achievement_types then
                return data.achievement_types
            end
         end
         end
          
          
         -- Fall back to direct page load
         -- As an absolute last resort, return an empty array
         local content = nil
         return {}
        local pageSuccess, page = pcall(function() return mw.title.new(ACHIEVEMENT_DATA_PAGE) end)
    end)
       
 
        if not pageSuccess or not page then
    if not success or not types then
            mw.log("JSON-DEBUG: Failed to create title object for " .. ACHIEVEMENT_DATA_PAGE)
        -- If there was an error, fall back to AchievementData.json
             return DEFAULT_DATA
        local data = Achievements.loadData(frame)
        if data and data.achievement_types then
            typesCache = data.achievement_types
             return typesCache
         end
         end
          
         types = {}
        if not page.exists then
    end
            mw.log("JSON-DEBUG: Page " .. ACHIEVEMENT_DATA_PAGE .. " does not exist")
 
             return DEFAULT_DATA
    typesCache = types
    return types
end
 
--------------------------------------------------------------------------------
-- Load achievement data from the JSON page
-- @param frame - The Scribunto frame object for preprocessing
-- @return Table containing the full achievement data
--------------------------------------------------------------------------------
function Achievements.loadData(frame)
    -- Use the request-level cache if we already loaded data once
    if dataCache then
        return dataCache
    end
 
    local success, data = pcall(function()
        -- Get the JSON content using frame:preprocess if available
        local jsonText
        if frame and type(frame) == "table" and frame.preprocess then
            -- Make sure frame is valid and has preprocess method
            local preprocessSuccess, preprocessResult = pcall(function()
                return frame:preprocess('{{MediaWiki:AchievementData.json}}')
            end)
              
            if preprocessSuccess and preprocessResult then
                jsonText = preprocessResult
            end
         end
         end
          
          
         -- Page exists, try to get content
         -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading
         content = page:getContent()
         if not jsonText then
       
            -- Try using mw.loadJsonData first (preferred method)
        if not content or content == '' then
            if mw.loadJsonData then
             mw.log("JSON-DEBUG: Page content is empty")
                local loadJsonSuccess, jsonData = pcall(function()
             return DEFAULT_DATA
                    return mw.loadJsonData(ACHIEVEMENT_DATA_PAGE)
                end)
               
                if loadJsonSuccess and jsonData and type(jsonData) == 'table' then
                    return jsonData
                end
            end
           
            -- Direct content loading approach as fallback
             local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
            if not pageTitle or not pageTitle.exists then
                return DEFAULT_DATA
            end
           
            -- Get raw content from the wiki page
            local contentSuccess, content = pcall(function()
                return pageTitle:getContent()
            end)
           
            if contentSuccess and content and content ~= "" then
                -- Remove any BOM or leading whitespace that might cause issues
                content = content:gsub("^%s+", "")
                if content:byte(1) == 239 and content:byte(2) == 187 and content:byte(3) == 191 then
                    content = content:sub(4)
                end
               
                jsonText = content
             else
                return DEFAULT_DATA
            end
         end
         end
          
          
         -- Log content statistics for debugging
         -- Try different JSON decode approaches
         mw.log("JSON-DEBUG: Raw JSON content length: " .. #content)
         if jsonText and mw.text and mw.text.jsonDecode then
        mw.log("JSON-DEBUG: First 100 chars: " .. content:sub(1, 100))
            -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
        mw.log("JSON-DEBUG: Contains '18451': " .. (content:find('"18451"') and "true" or "false"))
            local jsonDecodeSuccess, jsonData = pcall(function()
       
                return mw.text.jsonDecode(jsonText)
        -- Parse JSON with detailed error handling
            end)
        local parseSuccess, parsedData = pcall(function()  
           
            return json.decode(content)  
            if jsonDecodeSuccess and jsonData then
        end)
                return jsonData
       
             end
        if not parseSuccess or not parsedData then
           
            mw.log("JSON-DEBUG: JSON parse failed: " .. tostring(parsedData or "unknown error"))
            -- If that failed, try with JSON_TRY_FIXING flag
             return DEFAULT_DATA
            jsonDecodeSuccess, jsonData = pcall(function()
        end
                return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
       
             end)
        -- Check structure exists
        mw.log("JSON-DEBUG: Parse successful, checking data structure")
       
        -- Verify key structures exist
        mw.log("JSON-DEBUG: Has achievement_types: " .. tostring(parsedData.achievement_types ~= nil))
        mw.log("JSON-DEBUG: Has user_achievements: " .. tostring(parsedData.user_achievements ~= nil))
       
        -- Verify our test page
        if parsedData.user_achievements then
             mw.log("JSON-DEBUG: Has data for 18451: " .. tostring(parsedData.user_achievements["18451"] ~= nil))
              
              
            -- If we have 18451 data, log how many achievements
             if jsonDecodeSuccess and jsonData then
             if parsedData.user_achievements["18451"] then
                 return jsonData
                 mw.log("JSON-DEBUG: Number of achievements for 18451: " .. #parsedData.user_achievements["18451"])
                for i, achievement in ipairs(parsedData.user_achievements["18451"]) do
                    mw.log("JSON-DEBUG: Achievement " .. i .. " is type: " .. tostring(achievement.type))
                end
             end
             end
         end
         end
       
         -- As an absolute last resort, use local default data
         -- Log successful load
         return DEFAULT_DATA
        mw.log("JSON-DEBUG: Successfully loaded achievement data")
       
         return parsedData
     end)
     end)
   
 
    -- Handle errors
     if not success or not data then
     if not success or not data then
        mw.log("JSON-DEBUG: Critical error in load process: " .. tostring(data or 'unknown error'))
         data = DEFAULT_DATA
         data = DEFAULT_DATA
     end
     end
   
 
    -- Update request cache so we don't need to reload within this page render
     dataCache = data
     dataCache = data
   
     return data
     return data
end
end


--[[
--------------------------------------------------------------------------------
Checks if a user has any achievements
-- Get user achievements
-- @param pageId - The page ID to get achievements for
-- @return Array of achievement objects for the specified page
--------------------------------------------------------------------------------
local userAchievementsCache = {}


@param pageId string|number The page ID to check
---@return UserAchievement[]
@return boolean True if the user has any achievements, false otherwise
function Achievements.getUserAchievements(pageId)
]]
     if not pageId or pageId == '' then
function Achievements.hasAchievements(pageId)
        return {}
     if not pageId or pageId == '' then return false end
    end
      
      
    -- Check cache first
    local cacheKey = tostring(pageId)
    if userAchievementsCache[cacheKey] then
        return userAchievementsCache[cacheKey]
    end
     local data = Achievements.loadData()
     local data = Achievements.loadData()
     if not data or not data.user_achievements then return false end
     if not data or not data.user_achievements then
   
        return {}
     -- Convert to string for consistent lookup
    end
     local key = tostring(pageId)
 
     local key = cacheKey
     local userEntry = data.user_achievements[key]
      
      
     -- Check for direct match
     -- If found with string key, return achievements
     if data.user_achievements[key] and #data.user_achievements[key] > 0 then
     if userEntry and userEntry.achievements then
         return true
        local achievements = ensureArray(userEntry.achievements)
        userAchievementsCache[cacheKey] = achievements
         return achievements
     end
     end
      
      
     -- Check for achievements under n-prefixed key (backward compatibility)
     -- Try numeric key as fallback
     if key:match("^%d+$") and data.user_achievements["n" .. key] and #data.user_achievements["n" .. key] > 0 then
    local numKey = tonumber(key)
         return true
     if numKey then
        userEntry = data.user_achievements[numKey]
        if userEntry and userEntry.achievements then
            local achievements = ensureArray(userEntry.achievements)
            userAchievementsCache[cacheKey] = achievements
            return achievements
         end
     end
     end
      
      
     -- Special case for test page - force true for testing
     -- Cache empty result to avoid repeated lookups
    if isTestPage(pageId) then
    userAchievementsCache[cacheKey] = {}
        debugLog("Special case: Forcing true for test page " .. key)
    return {}
         return true
end
 
--------------------------------------------------------------------------------
-- Check if a page/user has any achievements
-- @param pageId - The page ID to check
-- @return Boolean indicating if the page has any achievements
--------------------------------------------------------------------------------
function Achievements.hasAchievements(pageId)
    if not pageId or pageId == '' then
         return false
     end
     end
      
 
     return false
     local userAchievements = Achievements.getUserAchievements(pageId)
     return #userAchievements > 0
end
end


--[[
--------------------------------------------------------------------------------
Gets the actual name of an achievement for display purposes
-- Get all badge-type achievements for a user
-- @param pageId - The page ID to check
-- @param frame - The Scribunto frame object for preprocessing
-- @return Array of badge achievement objects
--------------------------------------------------------------------------------
function Achievements.getBadgeAchievements(pageId, frame)
    if not pageId or pageId == '' then
        return {}
    end


@param achievementType string The achievement type ID
    local userAchievements = Achievements.getUserAchievements(pageId)
@return string The display name for the achievement or a default value
     if #userAchievements == 0 then
]]
         return {}
function Achievements.getAchievementName(achievementType)
     if not achievementType or achievementType == '' then  
        debugLog("Empty achievement type provided to getAchievementName")
         return 'Unknown'
     end
     end
    local types = Achievements.loadTypes(frame)
      
      
     local data = Achievements.loadData()
    -- Build a lookup table for achievement types for efficient access
    if not data or not data.achievement_types then
     local typeDefinitions = {}
        debugLog("No achievement data available for name lookup")
    for _, typeData in ipairs(types) do
         return achievementType -- Fall back to the ID as a last resort
        if typeData.id and typeData.type then
            typeDefinitions[typeData.id] = typeData
         end
     end
     end
      
      
     -- Loop through achievement types to find a match
    local badgeAchievements = {}
     for _, typeData in ipairs(data.achievement_types) do
     -- Filter user achievements to only include badge types
         if typeData.id == achievementType then
     for _, achievementTbl in ipairs(userAchievements) do
            debugLog("Found name for achievement type " .. achievementType .. ": " .. typeData.name)
        local achType = achievementTbl['type']
             return typeData.name
         if achType and typeDefinitions[achType] and typeDefinitions[achType]['type'] == "badge" then
                local newAchievement = {
                    type = achType,
                    date = achievementTbl['date'] or '',
                    name = typeDefinitions[achType].name or achType,
                    category = typeDefinitions[achType].category
                }
             table.insert(badgeAchievements, newAchievement)
         end
         end
     end
     end
   
 
    -- If we get here, we couldn't find the achievement type
     return badgeAchievements
    debugLog("No name found for achievement type: " .. achievementType)
     return achievementType -- Fall back to the ID as a last resort
end
end


--[[
--------------------------------------------------------------------------------
Gets the CSS class for the highest achievement to be applied to the template title
-- Get a user-friendly name for a given achievement type
-- @param achievementType - The achievement type ID
-- @param frame - The Scribunto frame object for preprocessing
-- @return String containing the user-friendly name
--------------------------------------------------------------------------------
function Achievements.getAchievementName(achievementType, frame)
    if not achievementType or achievementType == '' then
        return 'Unknown'
    end


@param pageId string|number The page ID to check
    local types = Achievements.loadTypes(frame)
@return string The CSS class name or empty string if no achievement
]]
function Achievements.getTitleClass(pageId)
    if not pageId or pageId == '' then
        debugLog("Empty page ID provided to getTitleClass")
        return ''
    end
      
      
     local data = Achievements.loadData()
     -- Try to match achievement ID
    if not data or not data.user_achievements then
    for _, typeData in ipairs(types) do
        debugLog("No achievement data available")
        if typeData.id == achievementType then
         return ''
            if typeData.name and typeData.name ~= "" then
                return typeData.name
            else
                return achievementType
            end
         end
     end
     end
      
 
    -- Convert to string for consistent lookup
     return achievementType
    local key = tostring(pageId)
end
    debugLog("Looking up achievements for ID: " .. key)
 
   
--------------------------------------------------------------------------------
    -- Try with direct key first
-- Find the top-tier Title achievement for the user (lowest tier number)
    local userAchievements = data.user_achievements[key] or {}
-- Return the CSS class and the readable achievement name
   
-- @param pageId - The page ID to get the title achievement for
    -- Try with n-prefix if not found (for backward compatibility)
-- @param frame - The Scribunto frame object for preprocessing
     if #userAchievements == 0 and key:match("^%d+$") then
-- @return CSS class, display name
         local nKey = "n" .. key
--------------------------------------------------------------------------------
        debugLog("Trying alternative key: " .. nKey)
function Achievements.getTitleClass(pageId, frame)
        userAchievements = data.user_achievements[nKey] or {}
     if not pageId or pageId == '' then
         return '', ''
     end
     end
   
 
     -- Special case for test page - always return title-test achievement class
     local userAchievements = Achievements.getUserAchievements(pageId)
    if isTestPage(pageId) then
        mw.log("TITLE-DEBUG: Returning title-test achievement class for test page")
        return "achievement-title-test"
    end
   
     if #userAchievements == 0 then
     if #userAchievements == 0 then
        debugLog("No achievements found")
         return '', ''
         return ''
     end
     end
   
 
     -- Find the highest tier (lowest number) achievement
     local types = Achievements.loadTypes(frame)
    local highestTier = 999
     local highestAchievement = nil
     local highestAchievement = nil
    local highestTier = 999
 
   
     for _, achievement in ipairs(userAchievements) do
     for _, achievement in ipairs(userAchievements) do
         local achievementType = achievement.type
         local achType = achievement["type"]
        debugLog("Found achievement type: " .. achievementType)
          
          
         for _, typeData in ipairs(data.achievement_types or {}) do
         for _, typeData in ipairs(types) do
             if typeData.id == achievementType and (typeData.tier or 999) < highestTier then
             if typeData.id == achType then
                highestAchievement = typeData
                local tier = typeData.tier or 999
                highestTier = typeData.tier or 999
                if tier < highestTier then
                 debugLog("New highest tier achievement: " .. typeData.id .. " (tier " .. (typeData.tier or "unknown") .. ")")
                    highestTier = tier
                    highestAchievement = typeData
                 end
             end
             end
         end
         end
     end
     end
   
 
     if not highestAchievement or not highestAchievement.id then
     if not highestAchievement or not highestAchievement.id then
        debugLog("No valid achievement type found")
         return '', ''
         return ''
     end
     end
    local cssClass = "achievement-" .. highestAchievement.id
    local displayName = highestAchievement.name or highestAchievement.id or "Award"
      
      
    local className = 'achievement-' .. highestAchievement.id
     return cssClass, displayName
    debugLog("Using achievement class: " .. className)
     return className
end
end


--[[
--------------------------------------------------------------------------------
Achievement box renderer - now shows real achievement names
-- Renders a box with the top-tier achievement for the user
-- @param pageId - The page ID to render the achievement box for
-- @param frame - The Scribunto frame object for preprocessing
-- @return HTML string containing the achievement box
--------------------------------------------------------------------------------
function Achievements.renderAchievementBox(pageId, frame)
    if not pageId or pageId == '' then
        return ''
    end


@param pageId string|number The page ID to render achievements for
    local userAchievements = Achievements.getUserAchievements(pageId)
@return string HTML for the achievement box or empty string if no achievements
     if #userAchievements == 0 then
]]
         return ''
function Achievements.renderAchievementBox(pageId)
    -- For test page, return a proper test achievement with name
     if isTestPage(pageId) then
        debugLog("Creating test achievement for test page")
        -- Look up the name for title-test achievement
        local achievementName = Achievements.getAchievementName("title-test")
         return '<div class="achievement-box-simple" data-achievement-type="title-test">' ..
              htmlEncode(achievementName) .. '</div>'
     end
     end
      
      
    -- Get achievements for other pages (if any)
     local types = Achievements.loadTypes(frame)
     local data = Achievements.loadData()
    if not data or not data.user_achievements then return '' end
      
      
     -- Convert to string for consistent lookup
     -- Build a lookup table for achievement type definitions
     local key = tostring(pageId)
     local typeDefinitions = {}
     local userAchievements = {}
    for _, typeData in ipairs(types) do
        if typeData.id and typeData.name then
            typeDefinitions[typeData.id] = {
                name = typeData.name,
                tier = typeData.tier or 999
            }
        end
    end
 
    -- Look for the highest-tier Title achievement (lowest tier number)
     local highestTier = 999
    local topAchType = nil
 
    for _, achievement in ipairs(userAchievements) do
        local achType = achievement.type
        if typeDefinitions[achType] and typeDefinitions[achType].tier < highestTier then
            highestTier = typeDefinitions[achType].tier
            topAchType = achType
        end
    end
 
    -- If we found an achievement, render it
    if topAchType and typeDefinitions[topAchType] then
        local achName = typeDefinitions[topAchType].name or topAchType
       
        return string.format(
            '<div class="achievement-box-simple" data-achievement-type="%s">%s</div>',
            topAchType,
            htmlEncode(achName)
        )
    end
 
    return ''
end
 
--------------------------------------------------------------------------------
-- Get page name for a given page ID
-- @param pageId - The page ID to get the name for
-- @return String containing the page name
--------------------------------------------------------------------------------
function Achievements.getPageName(pageId)
    if not pageId or pageId == '' then
        return ''
    end
      
      
     -- Try with direct key first
     local data = Achievements.loadData()
    if data.user_achievements[key] and #data.user_achievements[key] > 0 then
     if not data or not data.user_achievements then
        userAchievements = data.user_achievements[key]
     -- Try with n-prefix if not found (for backward compatibility)
    elseif key:match("^%d+$") and data.user_achievements["n" .. key] and #data.user_achievements["n" .. key] > 0 then
        userAchievements = data.user_achievements["n" .. key]
    else
        -- No achievements found
         return ''
         return ''
     end
     end
      
      
     -- Find highest tier achievement (same logic as getTitleClass)
     local key = tostring(pageId)
     local highestAchievement = nil
     local userEntry = data.user_achievements[key]
    local highestTier = 999
      
      
     for _, achievement in ipairs(userAchievements) do
     -- Check if entry exists with string key
        local achievementType = achievement.type
    if userEntry and userEntry.page_name then
       
        return userEntry.page_name
        for _, typeData in ipairs(data.achievement_types or {}) do
            if typeData.id == achievementType and (typeData.tier or 999) < highestTier then
                highestAchievement = typeData
                highestTier = typeData.tier or 999
            end
        end
     end
     end
      
      
     -- If we found a highest achievement, display it with its proper name
     -- Try numeric key as fallback
     if highestAchievement then
    local numKey = tonumber(key)
         return '<div class="achievement-box-simple" data-achievement-type="' ..  
     if numKey then
              highestAchievement.id .. '">' .. htmlEncode(highestAchievement.name) .. '</div>'
         userEntry = data.user_achievements[numKey]
        if userEntry and userEntry.page_name then
            return userEntry.page_name
        end
     end
     end
      
      
    -- Otherwise return empty string
     return ''
     return ''
end
end


--[[
--------------------------------------------------------------------------------
Tracks a page that displays achievements for cache purging
-- Retrieve a specific achievement if present, by type
@param pageId number|string The page ID to track
-- @param pageId - The page ID to get the achievement for
@param pageName string The page name (for reference)
-- @param achievementType - The achievement type ID to look for
@return boolean Always returns true (for future expansion)
-- @return Achievement object or nil if not found
]]
--------------------------------------------------------------------------------
function Achievements.trackPage(pageId, pageName)
function Achievements.getSpecificAchievement(pageId, achievementType)
     -- This function is designed to be safe by default
    if not pageId or not achievementType or pageId == '' then
     return true
        return nil
    end
 
    local userAchievements = Achievements.getUserAchievements(pageId)
   
     -- Direct lookup for the requested achievement type
    for _, achievementTbl in ipairs(userAchievements) do
        if achievementTbl["type"] == achievementType then
local def = Achievements.getAchievementDefinition(achievementType)
            return {
                type    = achievementTbl.type,
                date    = achievementTbl.date or '',
                name    = def and def.name or achievementType,
                category = def and def.category
            }
        end
    end
 
     return nil
end
end


--[[
--------------------------------------------------------------------------------
Retrieves a specific achievement type for a user
-- Get achievement definition directly from JSON data
@param pageId string|number The page ID to check
-- @param achievementType - The achievement type ID to get the definition for
@param achievementType string The specific achievement type to look for
-- @param frame - The Scribunto frame object for preprocessing
@return table|nil The achievement data if found, nil otherwise
-- @return Achievement type definition or nil if not found
]]
--------------------------------------------------------------------------------
function Achievements.getSpecificAchievement(pageId, achievementType)
function Achievements.getAchievementDefinition(achievementType, frame)
     -- Log detailed info about what we're checking
     if not achievementType or achievementType == '' then
     debugLog("ACHIEVEMENT-DEBUG: Looking for '" .. achievementType .. "' achievement for ID: " .. tostring(pageId))
        return nil
    end
   
     local types = Achievements.loadTypes(frame)
      
      
     if not pageId or pageId == '' or not achievementType then  
     -- Direct lookup in achievement_types array
        debugLog("ACHIEVEMENT-DEBUG: Invalid inputs, pageId or achievementType missing")
    for _, typeData in ipairs(types) do
         return nil
        if typeData.id == achievementType then
            return typeData
         end
     end
     end
      
      
     local data = Achievements.loadData()
     return nil
     if not data or not data.user_achievements then
end
         debugLog("ACHIEVEMENT-DEBUG: No achievement data available")
 
--------------------------------------------------------------------------------
-- Find and return title achievement for the user if one exists
-- This specifically looks for achievements with type="title"
-- Return the CSS class, readable achievement name, and achievement ID (or empty strings if none found)
-- @param pageId - The page ID to get the title achievement for
-- @param frame - The Scribunto frame object for preprocessing
-- @return achievementId, displayName, achievementId
--------------------------------------------------------------------------------
function Achievements.getTitleAchievement(pageId, frame)
     if not pageId or pageId == '' then
         return nil
    end
 
    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
         return nil
         return nil
     end
     end
    local types = Achievements.loadTypes(frame)
      
      
     -- Log what data is available at each step
     -- Build a table of achievement definitions for quick lookup
     local key = tostring(pageId)
     local typeDefinitions = {}
     debugLog("ACHIEVEMENT-DEBUG: Checking direct key: " .. key)
     for _, typeData in ipairs(types) do
   
        typeDefinitions[typeData.id] = typeData
    -- First check in direct key
    if data.user_achievements[key] then
        debugLog("ACHIEVEMENT-DEBUG: Found data for key: " .. key .. " with " .. #data.user_achievements[key] .. " achievements")
        for i, achievement in ipairs(data.user_achievements[key]) do
            debugLog("ACHIEVEMENT-DEBUG: Key " .. key .. " achievement " .. i .. " is type: " .. tostring(achievement.type))
            if achievement.type == achievementType then
                debugLog("ACHIEVEMENT-DEBUG: MATCH FOUND in key: " .. key)
                return achievement
            end
        end
    else
        debugLog("ACHIEVEMENT-DEBUG: No data found for key: " .. key)
     end
     end
    -- Find title achievements only
    local highestTier = 999
    local titleAchievement = nil
      
      
     -- Then check n-prefixed key
     for _, achievement in ipairs(userAchievements) do
    if key:match("^%d+$") then
         local achType = achievement["type"]
         local nKey = "n" .. key
         if achType then
        debugLog("ACHIEVEMENT-DEBUG: Checking n-prefixed key: " .. nKey)
             local typeData = typeDefinitions[achType]
       
             if typeData and typeData["type"] == "title" then
         if data.user_achievements[nKey] then
                local tier = typeData.tier or 999
             debugLog("ACHIEVEMENT-DEBUG: Found data for key: " .. nKey .. " with " .. #data.user_achievements[nKey] .. " achievements")
                 if tier < highestTier then
             for i, achievement in ipairs(data.user_achievements[nKey]) do
                     highestTier = tier
                debugLog("ACHIEVEMENT-DEBUG: Key " .. nKey .. " achievement " .. i .. " is type: " .. tostring(achievement.type))
                     titleAchievement = typeData
                 if achievement.type == achievementType then
                     debugLog("ACHIEVEMENT-DEBUG: MATCH FOUND in key: " .. nKey)
                     return achievement
                 end
                 end
             end
             end
        else
            debugLog("ACHIEVEMENT-DEBUG: No data found for key: " .. nKey)
         end
         end
     end
     end
    return titleAchievement
end
-- Renders a title block with achievement integration
function Achievements.renderTitleBlockWithAchievement(args, titleClass, titleText, achievementClass, achievementId, achievementName)
    titleClass = titleClass or "template-title"
      
      
     -- Special test case for test page - always force achievements when enabled
     -- Only add achievement attributes if they exist
     if isTestPage(pageId) and TEST_CONFIG.force_achievements then
     if achievementClass and achievementClass ~= "" and achievementId and achievementId ~= "" then
         -- Add more direct console logging to ensure visibility
        return string.format(
         mw.log("ACHIEVEMENT-CONSOLE: Testing for achievement type: " .. achievementType)
            '|-\n! colspan="2" class="%s %s" data-achievement-id="%s" data-achievement-name="%s" | %s',
            titleClass, achievementClass, achievementId, achievementName, titleText
        )
    else
         -- Clean row with no achievement data
         return string.format('|-\n! colspan="2" class="%s" | %s', titleClass, titleText)
    end
end
 
--------------------------------------------------------------------------------
-- Generate wikitext category links for a given list of achievements
-- @param achievements - An array of user achievement objects
-- @param frame - The Scribunto frame object
-- @return A string of wikitext category links, e.g., "[[Category:Cat1]][[Category:Cat2]]"
--------------------------------------------------------------------------------
function Achievements.getCategoryLinks(achievements, frame)
    if not achievements or #achievements == 0 then
        return ""
    end
 
    local types = Achievements.loadTypes(frame)
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        typeDefinitions[typeData.id] = typeData
    end
 
    local categoryLinks = {}
    local foundCategories = {} -- To prevent duplicate categories
 
    for _, ach in ipairs(achievements) do
        local achType = ach['type']
        local definition = typeDefinitions[achType]
          
          
         -- Get mapped type using the central type mapping
         if definition and definition.category and definition.category ~= "" and not foundCategories[definition.category] then
        local mappedType = TEST_CONFIG.type_mapping[achievementType] or achievementType
             table.insert(categoryLinks, "[[Category:" .. definition.category .. "]]")
        mw.log("ACHIEVEMENT-CONSOLE: Mapped type: " .. mappedType)
             foundCategories[definition.category] = true
       
        -- Always try to get from JSON first, then fallback to force-injection
        local testPageId = TEST_CONFIG.test_page_id
        local data = Achievements.loadData()
        if data and data.user_achievements and data.user_achievements[testPageId] then
             -- Search for this achievement type in the JSON
            for _, achievement in ipairs(data.user_achievements[testPageId]) do
                if achievement.type == mappedType then
                    mw.log("ACHIEVEMENT-CONSOLE: Found achievement " .. mappedType .. " in JSON data")
                    return achievement
                end
             end
         end
         end
       
        -- If not found in JSON, force-inject
        mw.log("ACHIEVEMENT-CONSOLE: Forcing " .. mappedType .. " achievement (not found in JSON)")
        return {
            type = mappedType,
            granted_date = os.date('!%Y-%m-%dT%H:%M:%SZ'),
            source = "test",
            forced = true
        }
     end
     end
   
 
     debugLog("ACHIEVEMENT-DEBUG: No match found for achievement type: " .. achievementType)
     return table.concat(categoryLinks)
    return nil
end
end


-- Return the module
return Achievements
return Achievements

Latest revision as of 02:56, 25 August 2025

Documentation for this module may be created at Module:AchievementSystem/doc

--[[
* Name: AchievementSystem
* Author: Mark W. Datysgeld
* Description: Comprehensive achievement system that manages user badges and titles throughout ICANNWiki, loading data from MediaWiki JSON files and providing rendering functions for Person templates
* Notes: Loads from MediaWiki:AchievementData.json (user assignments) and MediaWiki:AchievementList.json (type definitions). CSS styling defined in Templates.css using achievement-{id} format. Includes caching and fallback mechanisms for robust JSON handling
]]

---@class UserAchievement
---@field type string
---@field date? string

local Achievements = {}

--------------------------------------------------------------------------------
-- JSON Handling
--------------------------------------------------------------------------------
-- Helper function to ensure we get an array
local function ensureArray(value)
    if type(value) ~= "table" then
        return {}
    end
    
    -- Check if it's an array-like table
    local isArray = true
    local count = 0
    for _ in pairs(value) do
        count = count + 1
    end
    
    -- If it has no numeric indices or is empty, return empty array
    if count == 0 then
        return {}
    end
    
    -- If it's a single string, wrap it in an array
    if count == 1 and type(value[1]) == "string" then
        return {value[1]}
    end
    
    -- If it has a single non-array value, try to convert it to an array
    if count == 1 and next(value) and type(next(value)) ~= "number" then
        local k, v = next(value)
        if type(v) == "string" then
            return {v}
        end
    end
    
    -- Return the original table if it seems to be an array
    return value
end

-- Use MediaWiki's built-in JSON functions directly
local function jsonDecode(jsonString)
    if not jsonString then return nil end
    
    if mw.text and mw.text.jsonDecode then
        local success, result = pcall(function()
            -- Use WITHOUT PRESERVE_KEYS flag to ensure proper array handling
            return mw.text.jsonDecode(jsonString)
        end)
        
        if success and result then
            return result
        end
    end
    
    return nil
end

-- Simple HTML encode fallback
local function htmlEncode(str)
    if mw.text and mw.text.htmlEncode then
        return mw.text.htmlEncode(str or '')
    else
        return (str or '')
            :gsub('&', '&amp;')
            :gsub('<', '&lt;')
            :gsub('>', '&gt;')
            :gsub('"', '&quot;')
    end
end

--------------------------------------------------------------------------------
-- Configuration, Default Data, and Cache
--------------------------------------------------------------------------------
local ACHIEVEMENT_DATA_PAGE = 'MediaWiki:AchievementData.json'
local ACHIEVEMENT_LIST_PAGE = 'MediaWiki:AchievementList.json'
local dataCache = nil
local typesCache = nil

local DEFAULT_DATA = {
    schema_version = 2,
    last_updated = os.date('!%Y-%m-%dT%H:%M:%SZ'),
    achievement_types = {},
    user_achievements = {},
}

--------------------------------------------------------------------------------
-- Load achievement types from the JSON page
-- @param frame - The Scribunto frame object for preprocessing
-- @return Array of achievement type definitions
--------------------------------------------------------------------------------
function Achievements.loadTypes(frame)
    -- Use the request-level cache if we already loaded data once
    if typesCache then
        return typesCache
    end

    local success, types = pcall(function()
        -- Get the JSON content using frame:preprocess if available
        local jsonText
        if frame and type(frame) == "table" and frame.preprocess then
            -- Make sure frame is valid and has preprocess method
            local preprocessSuccess, preprocessResult = pcall(function()
                return frame:preprocess('{{MediaWiki:AchievementList.json}}')
            end)
            
            if preprocessSuccess and preprocessResult then
                jsonText = preprocessResult
            end
        end
        
        -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading
        if not jsonText then
            -- Try using mw.loadJsonData first (preferred method)
            if mw.loadJsonData then
                local loadJsonSuccess, jsonData = pcall(function()
                    return mw.loadJsonData(ACHIEVEMENT_LIST_PAGE)
                end)
                
                if loadJsonSuccess and jsonData and type(jsonData) == 'table' and jsonData.achievement_types then
                    return jsonData.achievement_types
                end
            end
            
            -- Direct content loading approach as fallback
            local pageTitle = mw.title.new(ACHIEVEMENT_LIST_PAGE)
            if pageTitle and pageTitle.exists then
                -- Get raw content from the wiki page
                local contentSuccess, content = pcall(function()
                    return pageTitle:getContent()
                end)
                
                if contentSuccess and content and content ~= "" then
                    -- Remove any BOM or leading whitespace that might cause issues
                    content = content:gsub("^%s+", "")
                    if content:byte(1) == 239 and content:byte(2) == 187 and content:byte(3) == 191 then
                        content = content:sub(4)
                    end
                    
                    jsonText = content
                    
                    -- Try different JSON decode approaches
                    if jsonText and mw.text and mw.text.jsonDecode then
                        -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
                        local jsonDecodeSuccess, jsonData = pcall(function()
                            return mw.text.jsonDecode(jsonText)
                        end)
                        
                        if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                            return jsonData.achievement_types
                        end
                        
                        -- If that failed, try with JSON_TRY_FIXING flag
                        jsonDecodeSuccess, jsonData = pcall(function()
                            return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
                        end)
                        
                        if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                            return jsonData.achievement_types
                        end
                    end
                end
            end
            
            -- If we couldn't load from AchievementList.json, fall back to AchievementData.json
            local data = Achievements.loadData(frame)
            if data and data.achievement_types then
                return data.achievement_types
            end
        else
            -- We have jsonText from frame:preprocess, try to decode it
            if jsonText and mw.text and mw.text.jsonDecode then
                -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
                local jsonDecodeSuccess, jsonData = pcall(function()
                    return mw.text.jsonDecode(jsonText)
                end)
                
                if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                    return jsonData.achievement_types
                end
                
                -- If that failed, try with JSON_TRY_FIXING flag
                jsonDecodeSuccess, jsonData = pcall(function()
                    return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
                end)
                
                if jsonDecodeSuccess and jsonData and jsonData.achievement_types then
                    return jsonData.achievement_types
                end
            end
            
            -- If we couldn't decode the JSON, fall back to AchievementData.json
            local data = Achievements.loadData(frame)
            if data and data.achievement_types then
                return data.achievement_types
            end
        end
        
        -- As an absolute last resort, return an empty array
        return {}
    end)

    if not success or not types then
        -- If there was an error, fall back to AchievementData.json
        local data = Achievements.loadData(frame)
        if data and data.achievement_types then
            typesCache = data.achievement_types
            return typesCache
        end
        types = {}
    end

    typesCache = types
    return types
end

--------------------------------------------------------------------------------
-- Load achievement data from the JSON page
-- @param frame - The Scribunto frame object for preprocessing
-- @return Table containing the full achievement data
--------------------------------------------------------------------------------
function Achievements.loadData(frame)
    -- Use the request-level cache if we already loaded data once
    if dataCache then
        return dataCache
    end

    local success, data = pcall(function()
        -- Get the JSON content using frame:preprocess if available
        local jsonText
        if frame and type(frame) == "table" and frame.preprocess then
            -- Make sure frame is valid and has preprocess method
            local preprocessSuccess, preprocessResult = pcall(function()
                return frame:preprocess('{{MediaWiki:AchievementData.json}}')
            end)
            
            if preprocessSuccess and preprocessResult then
                jsonText = preprocessResult
            end
        end
        
        -- If we couldn't get JSON from frame:preprocess, fall back to direct content loading
        if not jsonText then
            -- Try using mw.loadJsonData first (preferred method)
            if mw.loadJsonData then
                local loadJsonSuccess, jsonData = pcall(function()
                    return mw.loadJsonData(ACHIEVEMENT_DATA_PAGE)
                end)
                
                if loadJsonSuccess and jsonData and type(jsonData) == 'table' then
                    return jsonData
                end
            end
            
            -- Direct content loading approach as fallback
            local pageTitle = mw.title.new(ACHIEVEMENT_DATA_PAGE)
            if not pageTitle or not pageTitle.exists then
                return DEFAULT_DATA
            end
            
            -- Get raw content from the wiki page
            local contentSuccess, content = pcall(function()
                return pageTitle:getContent()
            end)
            
            if contentSuccess and content and content ~= "" then
                -- Remove any BOM or leading whitespace that might cause issues
                content = content:gsub("^%s+", "")
                if content:byte(1) == 239 and content:byte(2) == 187 and content:byte(3) == 191 then
                    content = content:sub(4)
                end
                
                jsonText = content
            else
                return DEFAULT_DATA
            end
        end
        
        -- Try different JSON decode approaches
        if jsonText and mw.text and mw.text.jsonDecode then
            -- First try WITHOUT PRESERVE_KEYS flag (standard approach)
            local jsonDecodeSuccess, jsonData = pcall(function()
                return mw.text.jsonDecode(jsonText)
            end)
            
            if jsonDecodeSuccess and jsonData then
                return jsonData
            end
            
            -- If that failed, try with JSON_TRY_FIXING flag
            jsonDecodeSuccess, jsonData = pcall(function()
                return mw.text.jsonDecode(jsonText, mw.text.JSON_TRY_FIXING)
            end)
            
            if jsonDecodeSuccess and jsonData then
                return jsonData
            end
        end
        -- As an absolute last resort, use local default data
        return DEFAULT_DATA
    end)

    if not success or not data then
        data = DEFAULT_DATA
    end

    dataCache = data
    return data
end

--------------------------------------------------------------------------------
-- Get user achievements
-- @param pageId - The page ID to get achievements for
-- @return Array of achievement objects for the specified page
--------------------------------------------------------------------------------
local userAchievementsCache = {}

---@return UserAchievement[]
function Achievements.getUserAchievements(pageId)
    if not pageId or pageId == '' then
        return {}
    end
    
    -- Check cache first
    local cacheKey = tostring(pageId)
    if userAchievementsCache[cacheKey] then
        return userAchievementsCache[cacheKey]
    end

    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        return {}
    end

    local key = cacheKey
    local userEntry = data.user_achievements[key]
    
    -- If found with string key, return achievements
    if userEntry and userEntry.achievements then
        local achievements = ensureArray(userEntry.achievements)
        userAchievementsCache[cacheKey] = achievements
        return achievements
    end
    
    -- Try numeric key as fallback
    local numKey = tonumber(key)
    if numKey then
        userEntry = data.user_achievements[numKey]
        if userEntry and userEntry.achievements then
            local achievements = ensureArray(userEntry.achievements)
            userAchievementsCache[cacheKey] = achievements
            return achievements
        end
    end
    
    -- Cache empty result to avoid repeated lookups
    userAchievementsCache[cacheKey] = {}
    return {}
end

--------------------------------------------------------------------------------
-- Check if a page/user has any achievements
-- @param pageId - The page ID to check
-- @return Boolean indicating if the page has any achievements
--------------------------------------------------------------------------------
function Achievements.hasAchievements(pageId)
    if not pageId or pageId == '' then
        return false
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    return #userAchievements > 0
end

--------------------------------------------------------------------------------
-- Get all badge-type achievements for a user
-- @param pageId - The page ID to check
-- @param frame - The Scribunto frame object for preprocessing
-- @return Array of badge achievement objects
--------------------------------------------------------------------------------
function Achievements.getBadgeAchievements(pageId, frame)
    if not pageId or pageId == '' then
        return {}
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return {}
    end

    local types = Achievements.loadTypes(frame)
    
    -- Build a lookup table for achievement types for efficient access
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        if typeData.id and typeData.type then
            typeDefinitions[typeData.id] = typeData
        end
    end
    
    local badgeAchievements = {}
    -- Filter user achievements to only include badge types
    for _, achievementTbl in ipairs(userAchievements) do
        local achType = achievementTbl['type']
        if achType and typeDefinitions[achType] and typeDefinitions[achType]['type'] == "badge" then
                local newAchievement = {
                    type = achType,
                    date = achievementTbl['date'] or '',
                    name = typeDefinitions[achType].name or achType,
                    category = typeDefinitions[achType].category
                }
            table.insert(badgeAchievements, newAchievement)
        end
    end

    return badgeAchievements
end

--------------------------------------------------------------------------------
-- Get a user-friendly name for a given achievement type
-- @param achievementType - The achievement type ID
-- @param frame - The Scribunto frame object for preprocessing
-- @return String containing the user-friendly name
--------------------------------------------------------------------------------
function Achievements.getAchievementName(achievementType, frame)
    if not achievementType or achievementType == '' then
        return 'Unknown'
    end

    local types = Achievements.loadTypes(frame)
    
    -- Try to match achievement ID
    for _, typeData in ipairs(types) do
        if typeData.id == achievementType then
            if typeData.name and typeData.name ~= "" then
                return typeData.name
            else
                return achievementType
            end
        end
    end

    return achievementType
end

--------------------------------------------------------------------------------
-- Find the top-tier Title achievement for the user (lowest tier number)
-- Return the CSS class and the readable achievement name
-- @param pageId - The page ID to get the title achievement for
-- @param frame - The Scribunto frame object for preprocessing
-- @return CSS class, display name
--------------------------------------------------------------------------------
function Achievements.getTitleClass(pageId, frame)
    if not pageId or pageId == '' then
        return '', ''
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return '', ''
    end

    local types = Achievements.loadTypes(frame)
    local highestTier = 999
    local highestAchievement = nil

    for _, achievement in ipairs(userAchievements) do
        local achType = achievement["type"]
        
        for _, typeData in ipairs(types) do
            if typeData.id == achType then
                local tier = typeData.tier or 999
                if tier < highestTier then
                    highestTier = tier
                    highestAchievement = typeData
                end
            end
        end
    end

    if not highestAchievement or not highestAchievement.id then
        return '', ''
    end

    local cssClass = "achievement-" .. highestAchievement.id
    local displayName = highestAchievement.name or highestAchievement.id or "Award"
    
    return cssClass, displayName
end

--------------------------------------------------------------------------------
-- Renders a box with the top-tier achievement for the user
-- @param pageId - The page ID to render the achievement box for
-- @param frame - The Scribunto frame object for preprocessing
-- @return HTML string containing the achievement box
--------------------------------------------------------------------------------
function Achievements.renderAchievementBox(pageId, frame)
    if not pageId or pageId == '' then
        return ''
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return ''
    end
    
    local types = Achievements.loadTypes(frame)
    
    -- Build a lookup table for achievement type definitions
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        if typeData.id and typeData.name then
            typeDefinitions[typeData.id] = {
                name = typeData.name,
                tier = typeData.tier or 999
            }
        end
    end

    -- Look for the highest-tier Title achievement (lowest tier number)
    local highestTier = 999
    local topAchType = nil

    for _, achievement in ipairs(userAchievements) do
        local achType = achievement.type
        if typeDefinitions[achType] and typeDefinitions[achType].tier < highestTier then
            highestTier = typeDefinitions[achType].tier
            topAchType = achType
        end
    end

    -- If we found an achievement, render it
    if topAchType and typeDefinitions[topAchType] then
        local achName = typeDefinitions[topAchType].name or topAchType
        
        return string.format(
            '<div class="achievement-box-simple" data-achievement-type="%s">%s</div>',
            topAchType,
            htmlEncode(achName)
        )
    end

    return ''
end

--------------------------------------------------------------------------------
-- Get page name for a given page ID
-- @param pageId - The page ID to get the name for
-- @return String containing the page name
--------------------------------------------------------------------------------
function Achievements.getPageName(pageId)
    if not pageId or pageId == '' then
        return ''
    end
    
    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        return ''
    end
    
    local key = tostring(pageId)
    local userEntry = data.user_achievements[key]
    
    -- Check if entry exists with string key
    if userEntry and userEntry.page_name then
        return userEntry.page_name
    end
    
    -- Try numeric key as fallback
    local numKey = tonumber(key)
    if numKey then
        userEntry = data.user_achievements[numKey]
        if userEntry and userEntry.page_name then
            return userEntry.page_name
        end
    end
    
    return ''
end

--------------------------------------------------------------------------------
-- Retrieve a specific achievement if present, by type
-- @param pageId - The page ID to get the achievement for
-- @param achievementType - The achievement type ID to look for
-- @return Achievement object or nil if not found
--------------------------------------------------------------------------------
function Achievements.getSpecificAchievement(pageId, achievementType)
    if not pageId or not achievementType or pageId == '' then
        return nil
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    
    -- Direct lookup for the requested achievement type
    for _, achievementTbl in ipairs(userAchievements) do
        if achievementTbl["type"] == achievementType then
local def = Achievements.getAchievementDefinition(achievementType)
            return {
                type     = achievementTbl.type,
                date     = achievementTbl.date or '',
                name     = def and def.name or achievementType,
                category = def and def.category
            }
        end
    end

    return nil
end

--------------------------------------------------------------------------------
-- Get achievement definition directly from JSON data
-- @param achievementType - The achievement type ID to get the definition for
-- @param frame - The Scribunto frame object for preprocessing
-- @return Achievement type definition or nil if not found
--------------------------------------------------------------------------------
function Achievements.getAchievementDefinition(achievementType, frame)
    if not achievementType or achievementType == '' then
        return nil
    end
    
    local types = Achievements.loadTypes(frame)
    
    -- Direct lookup in achievement_types array
    for _, typeData in ipairs(types) do
        if typeData.id == achievementType then
            return typeData
        end
    end
    
    return nil
end

--------------------------------------------------------------------------------
-- Find and return title achievement for the user if one exists
-- This specifically looks for achievements with type="title"
-- Return the CSS class, readable achievement name, and achievement ID (or empty strings if none found)
-- @param pageId - The page ID to get the title achievement for
-- @param frame - The Scribunto frame object for preprocessing
-- @return achievementId, displayName, achievementId
--------------------------------------------------------------------------------
function Achievements.getTitleAchievement(pageId, frame)
    if not pageId or pageId == '' then
        return nil
    end

    local userAchievements = Achievements.getUserAchievements(pageId)
    if #userAchievements == 0 then
        return nil
    end

    local types = Achievements.loadTypes(frame)
    
    -- Build a table of achievement definitions for quick lookup
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        typeDefinitions[typeData.id] = typeData
    end

    -- Find title achievements only
    local highestTier = 999
    local titleAchievement = nil
    
    for _, achievement in ipairs(userAchievements) do
        local achType = achievement["type"]
        if achType then
            local typeData = typeDefinitions[achType]
            if typeData and typeData["type"] == "title" then
                local tier = typeData.tier or 999
                if tier < highestTier then
                    highestTier = tier
                    titleAchievement = typeData
                end
            end
        end
    end

    return titleAchievement
end

-- Renders a title block with achievement integration
function Achievements.renderTitleBlockWithAchievement(args, titleClass, titleText, achievementClass, achievementId, achievementName)
    titleClass = titleClass or "template-title"
    
    -- Only add achievement attributes if they exist
    if achievementClass and achievementClass ~= "" and achievementId and achievementId ~= "" then
        return string.format(
            '|-\n! colspan="2" class="%s %s" data-achievement-id="%s" data-achievement-name="%s" | %s',
            titleClass, achievementClass, achievementId, achievementName, titleText
        )
    else
        -- Clean row with no achievement data
        return string.format('|-\n! colspan="2" class="%s" | %s', titleClass, titleText)
    end
end

--------------------------------------------------------------------------------
-- Generate wikitext category links for a given list of achievements
-- @param achievements - An array of user achievement objects
-- @param frame - The Scribunto frame object
-- @return A string of wikitext category links, e.g., "[[Category:Cat1]][[Category:Cat2]]"
--------------------------------------------------------------------------------
function Achievements.getCategoryLinks(achievements, frame)
    if not achievements or #achievements == 0 then
        return ""
    end

    local types = Achievements.loadTypes(frame)
    local typeDefinitions = {}
    for _, typeData in ipairs(types) do
        typeDefinitions[typeData.id] = typeData
    end

    local categoryLinks = {}
    local foundCategories = {} -- To prevent duplicate categories

    for _, ach in ipairs(achievements) do
        local achType = ach['type']
        local definition = typeDefinitions[achType]
        
        if definition and definition.category and definition.category ~= "" and not foundCategories[definition.category] then
            table.insert(categoryLinks, "[[Category:" .. definition.category .. "]]")
            foundCategories[definition.category] = true
        end
    end

    return table.concat(categoryLinks)
end

return Achievements