-- Housing Decor Guide - Trainers Module
-- Loaded on demand when user clicks Trainers button
-- New UX: Single panel with collapsible profession sections, only shows known professions

local addonName, addon = ...
local L = HDG_L or {}

-- Module namespace
HDG_Trainers = HDG_Trainers or {}

-- ============================================================================
-- THEME HELPERS (delegating to ThemeEngine canonical helpers)
-- ============================================================================

local HDG = _G.HousingDecorGuide
local function GetScheme() return HDG.Theme.GetCurrentScheme() end
local function RegisterWidget(w, t) HDG.Theme.RegisterWidget(w, t) end
local ApplyFont = function(fs, s, ft) HDG.Theme.ApplyFont(fs, s, ft) end


-- Local references (set during init)
local CONFIG
local mainFrame
local PlayerHasProfession

-- Zone Map IDs for TomTom integration and localization
-- These are uiMapIDs used by C_Map.GetMapInfo()
local ZONE_MAP_IDS = {
    -- Capital Cities (Kalimdor/Eastern Kingdoms)
    ["Stormwind City"] = 84,
    ["Orgrimmar"] = 85,
    ["Ironforge"] = 87,
    ["Thunder Bluff"] = 88,
    ["Darnassus"] = 89,
    ["Undercity"] = 90,
    ["Silvermoon City"] = 110,
    ["Exodar"] = 103,
    ["The Exodar"] = 103,

    -- TBC
    ["Shattrath City"] = 111,
    ["Shattrath"] = 111,
    ["Hellfire Peninsula"] = 100,

    -- WotLK
    ["Dalaran (Northrend)"] = 125,
    ["Dalaran"] = 125,

    -- MoP
    ["Jade Forest"] = 371,
    ["The Jade Forest"] = 371,
    ["Valley of the Four Winds"] = 376,
    ["Kun-Lai Summit"] = 379,

    -- WoD
    ["Stormshield"] = 622,
    ["Warspear"] = 624,
    ["Lunarfall"] = 582,
    ["Frostwall"] = 590,

    -- Legion
    ["Dalaran (Broken Isles)"] = 627,

    -- BfA
    ["Boralus"] = 1161,
    ["Dazar'alor"] = 1165,

    -- Shadowlands
    ["Oribos"] = 1670,

    -- Dragonflight
    ["Valdrakken"] = 2112,

    -- TWW
    ["Dornogal"] = 2339,
}

-- Build reverse lookup: mapID -> localized name (built on first use)
local mapIDToLocalizedName = nil

local function BuildLocalizedZoneLookup()
    if mapIDToLocalizedName then return end
    mapIDToLocalizedName = {}

    for englishName, mapID in pairs(ZONE_MAP_IDS) do
        if not mapIDToLocalizedName[mapID] and C_Map and C_Map.GetMapInfo then
            local mapInfo = C_Map.GetMapInfo(mapID)
            if mapInfo and mapInfo.name then
                mapIDToLocalizedName[mapID] = mapInfo.name
            end
        end
    end
end

-- Reference centralized expansion order for display
local EXPANSION_ORDER = HDG_ExpansionData.EXPANSION_ORDER

-- Track collapsed state per profession
local collapsedProfessions = {}

-- Cached config for the current Refresh cycle (set in Refresh(), used by CreateTrainerEntry)
local _TC = nil

-- Sort comparator for GetCharactersNeedingTraining (hoisted to avoid per-call closure)
local function _SortBySkillAsc(a, b) return a.skill < b.skill end

-- Get characters who need training for a profession/expansion
-- Returns array of character names (without realm) whose skill is below max
local function GetCharactersNeedingTraining(profName, expName)
    local needsTraining = {}

    if not HDG_Settings or not HDG_Settings.characters then
        return needsTraining
    end

    -- Get max skill for this profession/expansion
    local maxSkill = nil
    if HDG_ProfessionThresholds and HDG_ProfessionThresholds[profName] and HDG_ProfessionThresholds[profName][expName] then
        maxSkill = HDG_ProfessionThresholds[profName][expName].max
    end

    if not maxSkill then
        return needsTraining
    end

    -- Check each character
    for charKey, charData in pairs(HDG_Settings.characters) do
        if charData.professions and charData.professions[profName] then
            local profData = charData.professions[profName]
            if profData.skillLevels and profData.skillLevels[expName] then
                local skillData = profData.skillLevels[expName]
                -- skillData can be a number or a table { current = X, max = Y }
                local currentSkill = type(skillData) == "table" and skillData.current or skillData
                if currentSkill and currentSkill > 0 and currentSkill < maxSkill then
                    -- Extract character name without realm
                    local charName = charKey:match("^([^-]+)") or charKey
                    table.insert(needsTraining, {
                        name = charName,
                        skill = currentSkill,
                        max = maxSkill
                    })
                end
            end
        end
    end

    table.sort(needsTraining, _SortBySkillAsc)

    return needsTraining
end

-- Get character with most known decor recipes for a profession
-- Returns { name = "Char-Realm", count = N, total = M } or nil if none found
local function GetTopDecorCrafter(profName)
    if not HDG_Settings or not HDG_Settings.characters then
        return nil
    end

    local topCrafter = nil
    local topCount = 0

    for charKey, charData in pairs(HDG_Settings.characters) do
        if charData.professions and charData.professions[profName] then
            local profData = charData.professions[profName]
            local knownCount = profData.knownCount or 0
            local totalCount = profData.totalCount or 0

            if knownCount > topCount then
                topCount = knownCount
                topCrafter = {
                    name = charKey,
                    count = knownCount,
                    total = totalCount
                }
            end
        end
    end

    return topCrafter
end

-- Cache for localized zone names
local localizedZoneCache = {}

-- Get localized zone name from English zone name using WoW API
local function GetLocalizedZoneName(englishZoneName)
    if not englishZoneName then return nil end

    -- Check cache first
    if localizedZoneCache[englishZoneName] then
        return localizedZoneCache[englishZoneName]
    end

    -- Build the lookup table if needed
    BuildLocalizedZoneLookup()

    -- Look up mapID from our zone table
    local mapID = ZONE_MAP_IDS[englishZoneName]
    if mapID then
        -- First try our pre-built lookup
        if mapIDToLocalizedName and mapIDToLocalizedName[mapID] then
            localizedZoneCache[englishZoneName] = mapIDToLocalizedName[mapID]
            return mapIDToLocalizedName[mapID]
        end

        -- Fallback: try C_Map.GetMapInfo directly
        if C_Map and C_Map.GetMapInfo then
            local mapInfo = C_Map.GetMapInfo(mapID)
            if mapInfo and mapInfo.name then
                localizedZoneCache[englishZoneName] = mapInfo.name
                return mapInfo.name
            end
        end
    end

    -- Fallback to English name
    return englishZoneName
end

-- Module frame reference
local trainersPanel = nil

-- ============================================================================
-- FRAME POOLS (avoid destroying/recreating frames on every Refresh)
-- ============================================================================

-- Pool for profession section headers (collapsible buttons)
local profHeaderPool = {}
local activeProfHeaders = {}

-- Pool for expansion section frames (expansion label bars)
local expSectionPool = {}
local activeExpSections = {}

-- Pool for trainer entry rows
local trainerRowPool = {}
local activeTrainerRows = {}

-- Pool for standalone fontstrings (notes, empty text)
local tFontStringPool = {}
local activeTFontStrings = {}

local function AcquireProfHeader(parent)
    local header = table.remove(profHeaderPool)
    if not header then
        header = CreateFrame("Button", nil, parent, "BackdropTemplate")
        header:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            tile = false,
            insets = {left = 0, right = 0, top = 0, bottom = 0}
        })

        header.collapseIcon = CreateFrame("Button", nil, header)
        header.collapseIcon:SetSize(16, 16)
        header.collapseIcon:SetPoint("LEFT", 6, 0)
        header.collapseIcon:SetNormalAtlas("common-button-collapseExpand-down")
        header.collapseIcon:SetPushedAtlas("common-button-collapseExpand-down-pressed")
        header.collapseIcon:SetHighlightAtlas("common-button-collapseExpand-hover")

        header.profText = header:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        header.profText:SetPoint("LEFT", 24, 0)
        header.profText:SetShadowOffset(0, 0)

        header.crafterText = header:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        header.crafterText:SetPoint("RIGHT", -90, 0)
        header.crafterText:SetShadowOffset(0, 0)

        header.countText = header:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        header.countText:SetPoint("RIGHT", -10, 0)
        header.countText:SetShadowOffset(0, 0)
    end

    header:SetParent(parent)
    header:Show()
    header:ClearAllPoints()
    if header.mapAllBtn then header.mapAllBtn:Hide() end
    if header.clearAllBtn then header.clearAllBtn:Hide() end
    table.insert(activeProfHeaders, header)
    return header
end

local function AcquireExpSection(parent)
    local frame = table.remove(expSectionPool)
    if not frame then
        frame = CreateFrame("Frame", nil, parent, "BackdropTemplate")
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            tile = false,
        })

        frame.accentBar = frame:CreateTexture(nil, "ARTWORK")
        frame.accentBar:SetSize(3, 16)
        frame.accentBar:SetPoint("LEFT", 2, 0)
        frame.accentBar:SetTexture("Interface\\Buttons\\WHITE8X8")

        frame.expLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.expLabel:SetPoint("LEFT", 10, 0)
        frame.expLabel:SetShadowOffset(0, 0)

        frame.trainingText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.trainingText:SetPoint("RIGHT", -8, 0)
        frame.trainingText:SetShadowOffset(0, 0)
    end

    frame:SetParent(parent)
    frame:Show()
    frame:ClearAllPoints()
    -- Reset training text (hidden by default)
    frame.trainingText:SetText("")
    frame.trainingText:Hide()
    table.insert(activeExpSections, frame)
    return frame
end

local function AcquireTrainerRow(parent)
    local row = table.remove(trainerRowPool)
    if not row then
        row = CreateFrame("Frame", nil, parent, "BackdropTemplate")
        row:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            tile = false,
        })
        row:EnableMouse(true)

        -- Faction: Alliance bg + text
        row.factionBg = row:CreateTexture(nil, "BACKGROUND")
        row.factionBg:SetTexture("Interface\\Buttons\\WHITE8X8")

        row.factionText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row.factionText:SetShadowOffset(0, 0)

        -- Faction: Horde bg + text (for "Both" factions)
        row.hordeBg = row:CreateTexture(nil, "BACKGROUND")
        row.hordeBg:SetColorTexture(0.4, 0.1, 0.1, 0.8)
        row.hordeBg:Hide()

        row.hordeText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row.hordeText:SetJustifyH("CENTER")
        row.hordeText:SetShadowOffset(0, 0)
        row.hordeText:Hide()

        -- Trainer name
        row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row.nameText:SetJustifyH("LEFT")
        row.nameText:SetShadowOffset(0, 0)

        -- Zone name
        row.zoneText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row.zoneText:SetJustifyH("LEFT")
        row.zoneText:SetShadowOffset(0, 0)

        -- Coordinates
        row.coordText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row.coordText:SetJustifyH("LEFT")
        row.coordText:SetShadowOffset(0, 0)

    end

    row:SetParent(parent)
    row:Show()
    row:ClearAllPoints()
    -- Reset optional elements
    row.hordeBg:Hide()
    row.hordeText:Hide()
    if row.mapBtn then row.mapBtn:Hide() end
    if row.clearBtn then row.clearBtn:Hide() end
    row:SetScript("OnEnter", nil)
    row:SetScript("OnLeave", nil)
    table.insert(activeTrainerRows, row)
    return row
end

local function AcquireTFontString(parent)
    local fs = table.remove(tFontStringPool)
    if not fs then
        fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        fs:SetShadowOffset(0, 0)
    end
    fs:SetParent(parent)
    fs:Show()
    fs:ClearAllPoints()
    table.insert(activeTFontStrings, fs)
    return fs
end

local function ReleaseAllTrainerPools()
    for _, h in ipairs(activeProfHeaders) do
        h:Hide()
        h:ClearAllPoints()
        h:SetScript("OnClick", nil)
        h:SetScript("OnEnter", nil)
        h:SetScript("OnLeave", nil)
        table.insert(profHeaderPool, h)
    end
    activeProfHeaders = {}

    for _, f in ipairs(activeExpSections) do
        f:Hide()
        f:ClearAllPoints()
        table.insert(expSectionPool, f)
    end
    activeExpSections = {}

    for _, r in ipairs(activeTrainerRows) do
        r:Hide()
        r:ClearAllPoints()
        r:SetScript("OnEnter", nil)
        r:SetScript("OnLeave", nil)
        if r.mapBtn then r.mapBtn:Hide() end
        if r.clearBtn then r.clearBtn:Hide() end
        r.hordeBg:Hide()
        r.hordeText:Hide()
        table.insert(trainerRowPool, r)
    end
    activeTrainerRows = {}

    for _, fs in ipairs(activeTFontStrings) do
        fs:Hide()
        fs:ClearAllPoints()
        table.insert(tFontStringPool, fs)
    end
    activeTFontStrings = {}
end

-- NPC Name Cache (populated at runtime via WoW API)
local NPCNameCache = {}

-- Get NPC name from WoW API with fallback to cached name
-- Uses C_TooltipInfo.GetHyperlink pattern similar to AllTheThings
local function GetNPCName(npcID, cachedName)
    if not npcID then return cachedName or "Unknown" end

    -- Check our cache first
    if NPCNameCache[npcID] then
        return NPCNameCache[npcID]
    end

    -- Try to get name from WoW API
    if C_TooltipInfo and C_TooltipInfo.GetHyperlink then
        local tooltipData = C_TooltipInfo.GetHyperlink(string.format("unit:Creature-0-0-0-0-%d-0000000000", npcID))
        if tooltipData and tooltipData.lines and tooltipData.lines[1] then
            local name = tooltipData.lines[1].leftText
            if name and name ~= "" and not name:find("Retrieving") then
                NPCNameCache[npcID] = name
                return name
            end
        end
    end

    -- Fallback to cached name from database
    return cachedName or "Unknown"
end

-- Helper function to parse coordinates from location string
local function ParseTrainerCoords(location)
    if not location then return nil, nil, nil end
    
    local zoneName, x, y = location:match("^(.-)%s*%[([%d%.]+),%s*([%d%.]+)%]$")
    if zoneName and x and y then
        return zoneName, tonumber(x), tonumber(y)
    end
    
    return location, nil, nil
end

-- Helper function to set waypoint via facade (resolves zone name -> mapID)
local function SetTrainerWaypoint(zoneName, x, y, title)
    if not _G.HousingDecorGuide or not _G.HousingDecorGuide.Waypoints then return false end
    local mapID = ZONE_MAP_IDS[zoneName] or HDG_ExpansionData.GetMapIDForZone(zoneName)
    if not mapID then
        print("|cFFFF6600[HDG]|r " .. string.format(L["TRAINER_UNKNOWN_ZONE"] or "Unknown zone: '%s' - please report this!", tostring(zoneName)))
        return false
    end
    local ok, provider = _G.HousingDecorGuide.Waypoints:Set(mapID, x, y, title)
    _G.HousingDecorGuide.Waypoints:PrintResult(ok, provider, title, nil, mapID)
    return ok
end

-- Column widths for trainer table layout (use CONFIG if available, else defaults)
local function GetTrainersConfig()
    if CONFIG and CONFIG.TRAINERS_TAB then
        return CONFIG.TRAINERS_TAB
    end
    -- Fallback defaults
    return {
        FACTION_COL = 46,
        NAME_COL = 180,
        ZONE_COL = 140,
        COORDS_COL = 70,
        ROW_HEIGHT = 22,
        ENTRY_PADDING = 24,
        FACTION_BOX_WIDTH = 20,
        FACTION_BOX_HEIGHT = 16,
    }
end

-- Create a trainer entry row with columns: Faction | Name | Zone | Coords | TomTom
local function CreateTrainerEntry(parent, width, yOffset, faction, trainerInfo)
    local TC = _TC
    local row = AcquireTrainerRow(parent)
    row:SetSize(width - TC.ENTRY_PADDING, TC.ROW_HEIGHT)
    row:SetPoint("TOPLEFT", TC.ENTRY_PADDING, yOffset)

    -- Alternate row colors for readability
    local rowIndex = math.floor(math.abs(yOffset) / TC.ROW_HEIGHT)
    local rowScheme = GetScheme()
    row._scheme = rowScheme
    if rowIndex % 2 == 0 then
        if rowScheme then
            row:SetBackdropColor(rowScheme.panel.r, rowScheme.panel.g, rowScheme.panel.b, 0.6)
        else
            row:SetBackdropColor(unpack(HDG_UIHelpers.ROW_EVEN))
        end
    else
        if rowScheme then
            row:SetBackdropColor(rowScheme.bg.r, rowScheme.bg.g, rowScheme.bg.b, 0.6)
        else
            row:SetBackdropColor(unpack(HDG_UIHelpers.ROW_ODD))
        end
    end

    local xPos = 4

    -- Column 1: Faction indicator
    row.factionBg:ClearAllPoints()
    row.factionBg:SetPoint("LEFT", xPos, 0)
    ApplyFont(row.factionText, rowScheme, "small")
    row.factionText:ClearAllPoints()
    row.factionText:SetPoint("LEFT", xPos + 2, 0)

    if faction == "Alliance" then
        row.factionBg:SetSize(TC.FACTION_BOX_WIDTH, TC.FACTION_BOX_HEIGHT)
        row.factionBg:SetVertexColor(0.1, 0.2, 0.4, 0.8)
        row.factionText:SetWidth(16)
        row.factionText:SetText("|cFF3366CCA|r")
    elseif faction == "Horde" then
        row.factionBg:SetSize(TC.FACTION_BOX_WIDTH, TC.FACTION_BOX_HEIGHT)
        row.factionBg:SetVertexColor(0.4, 0.1, 0.1, 0.8)
        row.factionText:SetWidth(16)
        row.factionText:SetText("|cFFCC3333H|r")
    else
        -- Both factions: show A and H side by side
        row.factionBg:SetSize(TC.FACTION_BOX_WIDTH, TC.FACTION_BOX_HEIGHT)
        row.factionBg:SetVertexColor(0.1, 0.2, 0.4, 0.8)
        row.factionText:SetWidth(16)
        row.factionText:SetText("|cFF3366CCA|r")

        row.hordeBg:SetSize(TC.FACTION_BOX_WIDTH, TC.FACTION_BOX_HEIGHT)
        row.hordeBg:ClearAllPoints()
        row.hordeBg:SetPoint("LEFT", row.factionBg, "RIGHT", 2, 0)
        row.hordeBg:Show()

        ApplyFont(row.hordeText, rowScheme, "small")
        row.hordeText:ClearAllPoints()
        row.hordeText:SetPoint("CENTER", row.hordeBg, "CENTER", 0, 0)
        row.hordeText:SetWidth(16)
        row.hordeText:SetText("|cFFCC3333H|r")
        row.hordeText:Show()
    end
    xPos = xPos + TC.FACTION_COL

    -- Parse coordinates from location string
    local zoneName, coordX, coordY = ParseTrainerCoords(trainerInfo.location)
    local hasCoords = coordX and coordY
    local localizedZone = GetLocalizedZoneName(zoneName) or zoneName or ""

    -- Column 2: Trainer Name (white, bold)
    local displayName = GetNPCName(trainerInfo.npcID, trainerInfo.name)
    row.nameText:ClearAllPoints()
    row.nameText:SetPoint("LEFT", xPos, 0)
    row.nameText:SetWidth(TC.NAME_COL)
    ApplyFont(row.nameText, rowScheme, "body")
    if trainerInfo.note then
        row.nameText:SetText(HDG.Theme.C_Text() .. displayName .. "|r " .. HDG.Theme.C_Dim() .. "(" .. trainerInfo.note .. ")|r")
    else
        row.nameText:SetText(HDG.Theme.C_Text() .. displayName .. "|r")
    end
    xPos = xPos + TC.NAME_COL

    -- Column 3: Zone Name (gray) - localized
    row.zoneText:ClearAllPoints()
    row.zoneText:SetPoint("LEFT", xPos, 0)
    row.zoneText:SetWidth(TC.ZONE_COL)
    ApplyFont(row.zoneText, rowScheme, "small")
    row.zoneText:SetText(HDG.Theme.C_Dim() .. localizedZone .. "|r")
    xPos = xPos + TC.ZONE_COL

    -- Column 4: Coordinates (cyan if available, dim if not)
    row.coordText:ClearAllPoints()
    row.coordText:SetPoint("LEFT", xPos, 0)
    row.coordText:SetWidth(TC.COORDS_COL)
    ApplyFont(row.coordText, rowScheme, "small")
    if hasCoords then
        row.coordText:SetText(string.format(HDG.Theme.C_Accent() .. "%.1f, %.1f|r", coordX, coordY))
    else
        row.coordText:SetText(HDG.Theme.C_Dim() .. "--|r")
    end
    xPos = xPos + TC.COORDS_COL

    -- Waypoint buttons (right-aligned, HDG.UI:Button styled)
    if hasCoords then
        if not row.clearBtn then
            row.clearBtn = HDG.UI:Button(row, L["BTN_CLEAR"], 22, TC.ROW_HEIGHT, nil)
        end
        if not row.mapBtn then
            row.mapBtn = HDG.UI:Button(row, "Map", 36, TC.ROW_HEIGHT, nil)
        end
        local clearBtn = row.clearBtn
        clearBtn:ClearAllPoints()
        clearBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0)
        clearBtn:SetScript("OnClick", function()
            HDG.Waypoints:Clear()
            print("|cFF88BBDD[HDG]|r " .. (L["TRAINER_WAYPOINT_CLEARED"] or "Waypoint cleared."))
        end)
        clearBtn:Show()
        HDG_UIHelpers.SetupTooltip(clearBtn, "Clear Waypoint", "Remove all HDG waypoints and map pins.", "ANCHOR_BOTTOM")

        local mapBtn = row.mapBtn
        mapBtn:ClearAllPoints()
        mapBtn:SetPoint("RIGHT", clearBtn, "LEFT", -2, 0)
        mapBtn:SetScript("OnClick", function()
            local title = displayName .. " - " .. localizedZone
            SetTrainerWaypoint(zoneName, coordX, coordY, title)
        end)
        mapBtn:Show()
        HDG_UIHelpers.SetupTooltip(mapBtn, "Set Waypoint", string.format("%s (%.1f, %.1f)", localizedZone, coordX, coordY), "ANCHOR_BOTTOM")
    end

    -- Row hover effect
    row:SetScript("OnEnter", function(self)
        local s = self._scheme
        if s then
            self:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, 0.8)
        else
            self:SetBackdropColor(unpack(HDG_UIHelpers.ROW_HOVER))
        end
    end)
    row:SetScript("OnLeave", function(self)
        local idx = math.floor(math.abs(yOffset) / TC.ROW_HEIGHT)
        local s = self._scheme
        if idx % 2 == 0 then
            if s then
                self:SetBackdropColor(s.panel.r, s.panel.g, s.panel.b, 0.6)
            else
                self:SetBackdropColor(unpack(HDG_UIHelpers.ROW_EVEN))
            end
        else
            if s then
                self:SetBackdropColor(s.bg.r, s.bg.g, s.bg.b, 0.6)
            else
                self:SetBackdropColor(unpack(HDG_UIHelpers.ROW_ODD))
            end
        end
    end)

    return yOffset - TC.ENTRY_PADDING
end

-- Create expansion section within a profession
local function CreateExpansionSection(parent, width, yOffset, expName, expData, profName)
    -- Expansion label (indented, with subtle background)
    local expFrame = AcquireExpSection(parent)
    expFrame:SetSize(width - 12, 20)
    expFrame:SetPoint("TOPLEFT", 12, yOffset)
    local expScheme = GetScheme()
    if expScheme then
        expFrame:SetBackdropColor(expScheme.panel.r, expScheme.panel.g, expScheme.panel.b, 0.5)
    else
        expFrame:SetBackdropColor(0.15, 0.15, 0.18, 0.5)
    end

    local expColor = HDG_ExpansionData.GetColor(expName)

    -- Colored left accent bar
    expFrame.accentBar:SetVertexColor(expColor[1], expColor[2], expColor[3], 1)

    ApplyFont(expFrame.expLabel, expScheme, "small")
    expFrame.expLabel:SetText(string.format("|cFF%02x%02x%02x%s|r",
        expColor[1] * 255, expColor[2] * 255, expColor[3] * 255, expName))

    -- Check for characters needing training and show on right side of expansion header
    local charsNeedingTraining = GetCharactersNeedingTraining(profName, expName)
    if #charsNeedingTraining > 0 then
        local names = {}
        for _, charInfo in ipairs(charsNeedingTraining) do
            table.insert(names, string.format(HDG.Theme.C_RecipeKnown() .. "%s|r " .. HDG.Theme.C_Dim() .. "(%d/%d)|r", charInfo.name, charInfo.skill, charInfo.max))
        end
        ApplyFont(expFrame.trainingText, expScheme, "small")
        expFrame.trainingText:SetText(HDG.Theme.C_Dim() .. (L["TRAINER_NEEDS_TRAINING"] or "Needs training:") .. "|r " .. table.concat(names, ", "))
        expFrame.trainingText:Show()
    end

    yOffset = yOffset - 22

    if expData.notes then
        local noteText = AcquireTFontString(parent)
        noteText:SetPoint("TOPLEFT", 28, yOffset)
        ApplyFont(noteText, expScheme, "small")
        noteText:SetText(HDG.Theme.C_Dim() .. expData.notes .. "|r")
        yOffset = yOffset - 20
    else
        -- Render faction entries (supports single {npcID=...} or array {{npcID=...}, ...})
        for _, factionKey in ipairs({"Alliance", "Horde", "Both"}) do
            local factionData = expData[factionKey]
            if factionData then
                if factionData.npcID then
                    yOffset = CreateTrainerEntry(parent, width, yOffset, factionKey, factionData)
                else
                    for _, entry in ipairs(factionData) do
                        yOffset = CreateTrainerEntry(parent, width, yOffset, factionKey, entry)
                    end
                end
            end
        end
    end

    return yOffset - 2
end

-- Collect all waypoint-able trainer locations for a profession
local function CollectProfWaypoints(trainerData)
    local waypoints = {}
    if not trainerData then return waypoints end
    for _, expData in pairs(trainerData) do
        for _, fKey in ipairs({"Alliance", "Horde", "Both"}) do
            local fd = expData[fKey]
            if fd then
                local entries = fd.npcID and {fd} or fd
                for _, entry in ipairs(entries) do
                    local zone, x, y = ParseTrainerCoords(entry.location)
                    if x and y then
                        local mapID = ZONE_MAP_IDS[zone] or HDG_ExpansionData.GetMapIDForZone(zone)
                        if mapID then
                            local name = GetNPCName(entry.npcID, entry.name) or "Trainer"
                            local localZone = GetLocalizedZoneName(zone) or zone or ""
                            waypoints[#waypoints + 1] = { mapID, x, y, name .. " - " .. localZone }
                        end
                    end
                end
            end
        end
    end
    return waypoints
end

-- Create collapsible profession section
-- isKnownByPlayer: true if the current character has this profession
local function CreateProfessionSection(parent, width, yOffset, profName, trainerData, isKnownByPlayer)
    local isCollapsed = collapsedProfessions[profName]
    local localizedProfName = HDG_ExpansionData.GetLocalizedProfessionName(profName)

    -- Profession header (clickable) - pooled
    local header = AcquireProfHeader(parent)
    header:SetSize(width, 28)
    header:SetPoint("TOPLEFT", 0, yOffset)

    -- Different background color for known vs not-known professions
    local headerScheme = GetScheme()
    header._scheme = headerScheme
    header._isKnownByPlayer = isKnownByPlayer
    if isKnownByPlayer then
        if headerScheme then
            header:SetBackdropColor(headerScheme.button_normal.r, headerScheme.button_normal.g, headerScheme.button_normal.b, 0.95)
        else
            header:SetBackdropColor(0.18, 0.18, 0.25, 0.95)
        end
    else
        if headerScheme then
            header:SetBackdropColor(headerScheme.bg.r, headerScheme.bg.g, headerScheme.bg.b, 0.95)
        else
            header:SetBackdropColor(0.12, 0.12, 0.15, 0.95)
        end
    end
    if headerScheme then
        header:SetBackdropBorderColor(headerScheme.border.r, headerScheme.border.g, headerScheme.border.b, 0.7)
        RegisterWidget(header, "Button")
    else
        header:SetBackdropBorderColor(unpack(HDG_UIHelpers.BORDER_COLOR_DIM))
    end

    -- Collapse indicator (atlas arrow)
    if isCollapsed then
        header.collapseIcon:SetNormalAtlas("common-button-collapseExpand-up")
        header.collapseIcon:SetPushedAtlas("common-button-collapseExpand-up-pressed")
    else
        header.collapseIcon:SetNormalAtlas("common-button-collapseExpand-down")
        header.collapseIcon:SetPushedAtlas("common-button-collapseExpand-down-pressed")
    end

    -- Profession name - gold if player has it, gray if not
    ApplyFont(header.profText, headerScheme, "body")
    if isKnownByPlayer then
        header.profText:SetText(HDG.Theme.C_RecipeKnown() .. localizedProfName .. "|r")
    else
        header.profText:SetText(HDG.Theme.C_RecipeUnknown() .. localizedProfName .. "|r")
    end

    -- Top decor crafter for this profession
    local topCrafter = GetTopDecorCrafter(profName)
    ApplyFont(header.crafterText, headerScheme, "small")
    if topCrafter and topCrafter.count > 0 then
        header.crafterText:SetText(HDG.Theme.C_Accent() .. topCrafter.name .. "|r")
    else
        header.crafterText:SetText("")
    end

    -- Trainer count hint
    ApplyFont(header.countText, headerScheme, "small")
    local trainerCount = 0
    if trainerData then
        for _, expData in pairs(trainerData) do
            for _, fKey in ipairs({"Alliance", "Horde", "Both"}) do
                local fd = expData[fKey]
                if fd then
                    trainerCount = trainerCount + (fd.npcID and 1 or #fd)
                end
            end
        end
    end
    header.countText:SetText(HDG.Theme.C_Dim() .. trainerCount .. (L["TRAINER_TRAINERS_SUFFIX"] or " trainers") .. "|r")

    -- Map All / Clear All buttons (left side, next to profession name)
    local profWaypoints = CollectProfWaypoints(trainerData)
    if #profWaypoints > 0 then
        if not header.mapAllBtn then
            header.mapAllBtn = HDG.UI:Button(header, "Map All", 52, 20, nil)
        end
        if not header.clearAllBtn then
            header.clearAllBtn = HDG.UI:Button(header, L["BTN_CLEAR"], 22, 20, nil)
        end

        local mapAllBtn = header.mapAllBtn
        mapAllBtn:ClearAllPoints()
        mapAllBtn:SetPoint("LEFT", header.profText, "RIGHT", 8, 0)
        mapAllBtn:SetScript("OnClick", function()
            local count, provider = HDG.Waypoints:SetMultiple(profWaypoints)
            HDG.Waypoints:PrintResult(count > 0, provider, nil, count, profWaypoints[1] and profWaypoints[1][1])
        end)
        mapAllBtn:Show()
        HDG_UIHelpers.SetupTooltip(mapAllBtn, "Map All Trainers",
            string.format("Set waypoints for all %d %s trainers", #profWaypoints, localizedProfName), "ANCHOR_BOTTOM")

        local clearAllBtn = header.clearAllBtn
        clearAllBtn:ClearAllPoints()
        clearAllBtn:SetPoint("LEFT", mapAllBtn, "RIGHT", 2, 0)
        clearAllBtn:SetScript("OnClick", function()
            HDG.Waypoints:Clear()
            print("|cFF88BBDD[HDG]|r " .. (L["TRAINER_ALL_CLEARED"] or "All waypoints and map pins cleared."))
        end)
        clearAllBtn:Show()
        HDG_UIHelpers.SetupTooltip(clearAllBtn, "Clear All Waypoints",
            "Remove all HDG waypoints and map pins.", "ANCHOR_BOTTOM")
    end

    -- Click to toggle
    local function toggleCollapse()
        collapsedProfessions[profName] = not collapsedProfessions[profName]
        HDG_Trainers:Refresh()
    end
    header:SetScript("OnClick", toggleCollapse)
    header.collapseIcon:SetScript("OnClick", toggleCollapse)

    header:SetScript("OnEnter", function(self)
        local s = self._scheme
        if s then
            self:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, 0.95)
        else
            self:SetBackdropColor(0.25, 0.25, 0.35, 0.95)
        end
    end)

    header:SetScript("OnLeave", function(self)
        local s = self._scheme
        if self._isKnownByPlayer then
            if s then
                self:SetBackdropColor(s.button_normal.r, s.button_normal.g, s.button_normal.b, 0.95)
            else
                self:SetBackdropColor(0.18, 0.18, 0.25, 0.95)
            end
        else
            if s then
                self:SetBackdropColor(s.bg.r, s.bg.g, s.bg.b, 0.95)
            else
                self:SetBackdropColor(0.12, 0.12, 0.15, 0.95)
            end
        end
    end)

    yOffset = yOffset - 30

    -- If not collapsed, show expansions
    if not isCollapsed and trainerData then
        for _, expName in ipairs(EXPANSION_ORDER) do
            local expData = trainerData[expName]
            if expData then
                yOffset = CreateExpansionSection(parent, width, yOffset, expName, expData, profName)
            end
        end
        yOffset = yOffset - 6  -- Extra gap after profession
    end

    return yOffset
end

-- Get all professions, with player's known professions first, then others
-- Returns: { professions = {}, knownSet = {} }
local function GetAllProfessionsSorted()
    local allProfs = {
        "Alchemy", "Blacksmithing", "Enchanting", "Engineering",
        "Inscription", "Jewelcrafting", "Leatherworking", "Tailoring", "Cooking"
    }

    local known = {}
    local notKnown = {}
    local knownSet = {}

    for _, profName in ipairs(allProfs) do
        if PlayerHasProfession and PlayerHasProfession(profName) then
            knownSet[profName] = true
            -- Add Cooking at the end of known list
            if profName ~= "Cooking" then
                table.insert(known, profName)
            end
        else
            -- Add Cooking at the end of not-known list
            if profName ~= "Cooking" then
                table.insert(notKnown, profName)
            end
        end
    end

    -- Add Cooking last in its respective group
    if knownSet["Cooking"] then
        table.insert(known, "Cooking")
    else
        table.insert(notKnown, "Cooking")
    end

    -- Combine: known first, then not known
    local result = {}
    for _, prof in ipairs(known) do
        table.insert(result, prof)
    end
    for _, prof in ipairs(notKnown) do
        table.insert(result, prof)
    end

    return { professions = result, knownSet = knownSet }
end

-- Initialize the module (called from main addon)
function HDG_Trainers:Init(configRef, mainFrameRef, playerHasProfFunc)
    CONFIG = configRef
    mainFrame = mainFrameRef
    PlayerHasProfession = playerHasProfFunc

    -- All professions default to collapsed
    local allProfs = {
        "Alchemy", "Blacksmithing", "Enchanting", "Engineering",
        "Inscription", "Jewelcrafting", "Leatherworking", "Tailoring", "Cooking"
    }

    -- Check if we have saved state with the new "allCollapsedDefault" marker
    -- If not, reset to all collapsed (migration from old format)
    if HDG_Settings and HDG_Settings.trainersCollapsed and HDG_Settings.trainersCollapsed._allCollapsedDefault then
        -- Load saved state
        for profName, isCollapsed in pairs(HDG_Settings.trainersCollapsed) do
            if profName ~= "_allCollapsedDefault" then
                collapsedProfessions[profName] = isCollapsed
            end
        end
    else
        -- First time or migration: all collapsed
        for _, profName in ipairs(allProfs) do
            collapsedProfessions[profName] = true
        end
        -- Mark that we've set the default
        if HDG_Settings then
            HDG_Settings.trainersCollapsed = { _allCollapsedDefault = true }
            for _, profName in ipairs(allProfs) do
                HDG_Settings.trainersCollapsed[profName] = true
            end
        end
    end
end

-- Create the trainers panel (called when tab is first clicked)
function HDG_Trainers:CreatePanel()
    if trainersPanel then return trainersPanel end

    -- Use factory for base panel structure (no header - we have title + subtitle)
    local layout = HDG.UI:TabPanel(mainFrame, {
        hasHeaderBar = false,
        hasScroll = true,
    })

    trainersPanel = layout.frame
    trainersPanel:Hide()
    trainersPanel.scrollFrame = layout.scrollFrame
    trainersPanel.content = layout.scrollChild

    -- Title
    local scheme = GetScheme()
    local title = trainersPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOP", 0, -8)
    ApplyFont(title, scheme, "header")
    title:SetText(L["TRAINER_PANEL_TITLE"] or "Profession Trainers")
    RegisterWidget(title, "SectionHeader")

    -- Subtitle
    local subtitle = trainersPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    subtitle:SetPoint("TOP", title, "BOTTOM", 0, -2)
    ApplyFont(subtitle, scheme, "small")
    subtitle:SetText(HDG.Theme.C_Dim() .. (L["TRAINER_PANEL_SUBTITLE"] or "Click profession to expand/collapse") .. "|r")

    -- Reposition scroll frame for title + subtitle space
    layout.scrollFrame:ClearAllPoints()
    layout.scrollFrame:SetPoint("TOPLEFT", 8, -36)
    layout.scrollFrame:SetPoint("BOTTOMRIGHT", -28, 8)

    mainFrame.trainersPanel = trainersPanel
    return trainersPanel
end

-- Refresh the trainers display
function HDG_Trainers:Refresh()
    if not trainersPanel then
        self:CreatePanel()
    end

    local content = trainersPanel.content
    if not content then return end

    _TC = GetTrainersConfig()

    -- Release all pooled frames (no destroy/reparent)
    ReleaseAllTrainerPools()

    -- Get trainer data
    local allTrainerData = HDG_TrainersDB
    if not allTrainerData then
        local emptyScheme = GetScheme()
        local emptyText = AcquireTFontString(content)
        emptyText:SetPoint("CENTER", 0, 0)
        ApplyFont(emptyText, emptyScheme, "body")
        emptyText:SetText(HDG.Theme.C_Dim() .. (L["TRAINER_DATA_NOT_LOADED"] or "Trainer data not loaded") .. "|r")
        content:SetHeight(100)
        return
    end

    -- Get all professions sorted (player's known first, then others)
    local profData = GetAllProfessionsSorted()
    local allProfs = profData.professions
    local knownSet = profData.knownSet

    local contentWidth = trainersPanel.scrollFrame:GetWidth() - 4
    local yOffset = 0

    -- Create section for each profession (all professions, known ones first)
    for _, profName in ipairs(allProfs) do
        local trainerData = allTrainerData[profName]
        if trainerData then
            yOffset = CreateProfessionSection(content, contentWidth, yOffset, profName, trainerData, knownSet[profName])
        end
    end

    -- Update content height
    content:SetHeight(math.abs(yOffset) + 10)

    -- Save collapsed state
    if HDG_Settings then
        HDG_Settings.trainersCollapsed = collapsedProfessions
    end
end

-- Show the panel
function HDG_Trainers:Show()
    if not trainersPanel then
        self:CreatePanel()
    end
    self:Refresh()
    trainersPanel:Show()
end

-- Hide the panel
function HDG_Trainers:Hide()
    if trainersPanel then
        trainersPanel:Hide()
    end
end

-- Check if panel is shown
function HDG_Trainers:IsShown()
    return trainersPanel and trainersPanel:IsShown()
end
