--[[
    UI/Browser.lua

    Turns the database into the flat element list the virtualized list draws,
    applies the collection browser's search and filters, and wires selection
    through to the detail panel.

    Layout is computed in two stages on purpose:

      BuildLayout()    sorts families and appearances and builds a cached
                       search document for each appearance. It depends only on
                       the database, locale and map names, so it runs once.
      BuildElements()  walks the cached layout, applies the current filters and
                       reads volatile fields (status, favourite and counters).

    Typing therefore costs one walk of cached strings, not a re-sort or a set
    of map API calls. The virtualized list continues to own only the visible
    row frames regardless of database size.
]]

local _, ns = ...

local Browser = ns.NewModule("Browser")
ns.UI = ns.UI or {}
ns.UI.Browser = Browser

local STATUS_COLORS = {
    COLLECTED = { r = 0.35, g = 0.95, b = 0.35 },
    SEEN      = { r = 0.45, g = 0.70, b = 0.95 },
    MISSING   = { r = 0.85, g = 0.35, b = 0.35 },
    PENDING   = { r = 0.95, g = 0.80, b = 0.30 },
    IGNORED   = { r = 0.50, g = 0.50, b = 0.50 },
}

local CONTROL_HEIGHT = 22
local CONTROL_GAP = 5
local FILTER_AREA_HEIGHT = 105
local Theme = ns.UI.Theme

local function Normalize(value)
    return string.lower(tostring(value or ""))
end

local function AddSearchPart(parts, value)
    if value ~= nil and value ~= "" then
        parts[#parts + 1] = Normalize(value)
    end
end

--=============================================================================
-- Layout and cached search data
--=============================================================================

--- Builds the searchable facts for one appearance. Map names are resolved here
--- rather than on every keystroke.
local function BuildAppearanceEntry(appearanceID, groupName)
    local API = ns.Data.API
    local L = ns.L
    local appearance = API:GetAppearance(appearanceID)
    local expansionEntry
    local parts = {}
    local rare = false
    local elite = false
    local mapIDs = {}

    for _, entry in ipairs(ns.Data.expansionList) do
        if entry.token == appearance.expansion then
            expansionEntry = entry
            break
        end
    end

    AddSearchPart(parts, L[appearance.nameKey])
    AddSearchPart(parts, groupName)
    AddSearchPart(parts, expansionEntry and L[expansionEntry.nameKey])

    for _, tag in ipairs(appearance.tags or {}) do
        AddSearchPart(parts, tag)
    end

    for _, creatureID in ipairs(API:GetCreatureIDsByAppearance(appearanceID)) do
        local creature = API:GetCreature(creatureID)

        if creature then
            AddSearchPart(parts, creature.name)
            rare = rare or creature.rare == true
            elite = elite or creature.elite == true
        end

        for _, location in ipairs(API:GetLocationsByCreature(creatureID)) do
            mapIDs[location.mapID] = true
            local mapInfo = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(location.mapID)
            AddSearchPart(parts, mapInfo and mapInfo.name)
            AddSearchPart(parts, location.subzone)
        end
    end

    return {
        id = appearanceID,
        name = L[appearance.nameKey],
        family = appearance.family,
        expansion = appearance.expansion,
        expansionName = expansionEntry and L[expansionEntry.nameKey] or "",
        searchText = table.concat(parts, "\n"),
        rare = rare,
        elite = elite,
        exotic = API:IsAppearanceExotic(appearanceID),
        mapIDs = mapIDs,
    }
end

--- Sorted families, each with its sorted searchable appearance entries.
-- The registry's index arrays are returned by reference, so they are copied
-- before sorting: sorting them in place would reorder the database itself.
local function BuildLayout()
    local API = ns.Data.API
    local L = ns.L
    local layout = {}

    for token, family in pairs(API:GetAllFamilies()) do
        local indexed = API:GetAppearanceIDsByFamily(token)

        if #indexed > 0 then
            local appearanceIDs = {}
            local name = L[family.nameKey]

            for index = 1, #indexed do
                appearanceIDs[index] = indexed[index]
            end

            table.sort(appearanceIDs, function(a, b)
                return L[API:GetAppearance(a).nameKey] < L[API:GetAppearance(b).nameKey]
            end)

            local appearances = {}
            for _, appearanceID in ipairs(appearanceIDs) do
                appearances[#appearances + 1] = BuildAppearanceEntry(appearanceID, name)
            end

            layout[#layout + 1] = {
                token = token,
                name = name,
                icon = family.icon,
                appearances = appearances,
            }
        end
    end

    table.sort(layout, function(a, b)
        return a.name < b.name
    end)

    -- The journal number is stable across locales: localized alphabetical
    -- order is useful for browsing, but must never renumber a collected pet.
    local orderedIDs = {}
    for _, group in ipairs(layout) do
        for _, entry in ipairs(group.appearances) do
            orderedIDs[#orderedIDs + 1] = entry.id
        end
    end
    table.sort(orderedIDs)

    local catalogNumbers = {}
    for number, appearanceID in ipairs(orderedIDs) do
        catalogNumbers[appearanceID] = number
    end
    for _, group in ipairs(layout) do
        for _, entry in ipairs(group.appearances) do
            entry.catalogNumber = catalogNumbers[entry.id]
        end
    end

    ns.Debug:Log("UI", "browser layout built: %d families", #layout)

    return layout
end

local function MatchesFilters(entry, filters)
    local State = ns.Collection.State

    if filters.query ~= ""
        and not string.find(entry.searchText, filters.query, 1, true) then
        return false
    end

    if filters.family and entry.family ~= filters.family then
        return false
    end

    if filters.expansion and entry.expansion ~= filters.expansion then
        return false
    end

    if filters.mapID and not entry.mapIDs[filters.mapID] then
        return false
    end

    if filters.status == "FAVORITE" then
        if not State:IsFavorite(entry.id) then
            return false
        end
    elseif filters.status and State:GetStatus(entry.id) ~= filters.status then
        return false
    end

    if filters.trait and not entry[Normalize(filters.trait)] then
        return false
    end

    return true
end

local function BuildElements(layout, collapsed, filters)
    local State = ns.Collection.State
    local L = ns.L
    local elements = {}
    local matchCount = 0

    for _, group in ipairs(layout) do
        local matches = {}

        for _, entry in ipairs(group.appearances) do
            if MatchesFilters(entry, filters) then
                matches[#matches + 1] = entry
            end
        end

        if #matches > 0 then
            local isCollapsed = collapsed[group.token] == true
            local header = {
                kind = "header",
                token = group.token,
                name = group.name,
                icon = group.icon,
                collapsible = true,
                collapsed = isCollapsed,
                collected = 0,
                total = #matches,
            }
            elements[#elements + 1] = header

            for _, entry in ipairs(matches) do
                local status = State:GetStatus(entry.id)
                matchCount = matchCount + 1

                if status == State.status.COLLECTED then
                    header.collected = header.collected + 1
                end

                if not isCollapsed then
                    local appearance = ns.Data.API:GetAppearance(entry.id)
                    elements[#elements + 1] = {
                        selectionID = entry.id,
                        name = entry.name,
                        subtitle = entry.expansionName ~= ""
                            and (group.name .. "  -  " .. entry.expansionName)
                            or group.name,
                        icon = group.icon,
                        portraitDisplayID = appearance and appearance.displayIDs
                            and appearance.displayIDs[1] or nil,
                        statusText = L["STATUS_" .. status],
                        statusColor = STATUS_COLORS[status],
                        favorite = State:IsFavorite(entry.id),
                        desaturated = status ~= State.status.COLLECTED,
                        catalogText = L["CATALOG_NUMBER_FORMAT"]:format(
                            entry.catalogNumber),
                    }
                end
            end

            header.counterText = L["PROGRESS_FORMAT"]:format(header.collected, header.total)
        end
    end

    if matchCount == 0 then
        elements[1] = {
            name = L["FILTER_NO_RESULTS"],
            subtitle = L["FILTER_NO_RESULTS_HINT"],
        }
    end

    return elements, matchCount
end

--=============================================================================
-- Controls
--=============================================================================

local function CreateCycleButton(parent, width, choices, onChanged)
    local button = CreateFrame("Button", nil, parent, "BackdropTemplate")
    button:SetSize(width, CONTROL_HEIGHT)
    button:SetNormalFontObject(GameFontNormalSmall)
    button.choiceIndex = 1
    Theme:ApplyButton(button, false)

    function button:SetChoices(newChoices, silent)
        choices = newChoices
        self.choiceIndex = 1
        self:SetText(choices[1].label)
        if not silent then
            onChanged(choices[1].value)
        end
    end

    function button:Reset(silent)
        self.choiceIndex = 1
        self:SetText(choices[1].label)
        if not silent then
            onChanged(choices[1].value)
        end
    end

    button:SetScript("OnClick", function(self)
        self.choiceIndex = self.choiceIndex % #choices + 1
        local choice = choices[self.choiceIndex]
        self:SetText(choice.label)
        onChanged(choice.value)
    end)

    button:SetText(choices[1].label)

    return button
end

--- A modern Retail context-menu selector.  It falls back to the same cycling
--- behaviour as the compact filters if MenuUtil is unavailable on a future
--- client build, so opening the journal can never fail over one widget.
local function CreateMenuButton(parent, width, choices, onChanged)
    local button = CreateFrame("Button", nil, parent, "BackdropTemplate")
    button:SetSize(width, CONTROL_HEIGHT)
    button:SetNormalFontObject(GameFontNormalSmall)
    button.choices = choices
    button.choiceIndex = 1
    button.value = choices[1].value
    Theme:ApplyButton(button, false)

    button.MenuArrow = Theme:CreateMenuArrow(button)

    function button:SetChoices(newChoices, silent)
        self.choices = newChoices
        self.choiceIndex = 1
        self.value = newChoices[1].value
        self:SetText(newChoices[1].label)
        if not silent then
            onChanged(self.value)
        end
    end

    function button:Reset(silent)
        self.choiceIndex = 1
        self.value = self.choices[1].value
        self:SetText(self.choices[1].label)
        if not silent then
            onChanged(self.value)
        end
    end

    button:SetScript("OnClick", function(self)
        if MenuUtil and MenuUtil.CreateContextMenu then
            MenuUtil.CreateContextMenu(self, function(_, rootDescription)
                for _, choice in ipairs(self.choices) do
                    local data = choice
                    rootDescription:CreateRadio(data.label,
                        function(data) return self.value == data.value end,
                        function(data)
                            self.value = data.value
                            self:SetText(data.label)
                            onChanged(data.value)
                            return MenuResponse and MenuResponse.Close
                        end,
                        data)
                end
            end)
            return
        end

        self.choiceIndex = self.choiceIndex % #self.choices + 1
        local choice = self.choices[self.choiceIndex]
        self.value = choice.value
        self:SetText(choice.label)
        onChanged(choice.value)
    end)

    button:SetText(choices[1].label)
    return button
end


local function PrefixChoices(prefix, values)
    local choices = {}

    for _, value in ipairs(values) do
        choices[#choices + 1] = {
            value = value.value,
            label = prefix .. ": " .. value.label,
        }
    end

    return choices
end

local function BuildFilterChoices(layout)
    local L = ns.L
    local families = { { value = nil, label = L["FILTER_ALL"] } }
    local expansions = { { value = nil, label = L["FILTER_ALL"] } }
    local zones = { { value = nil, label = L["FILTER_ALL"] } }
    local presentExpansions = {}
    local presentMaps = {}

    for _, group in ipairs(layout) do
        families[#families + 1] = { value = group.token, label = group.name }
        for _, entry in ipairs(group.appearances) do
            presentExpansions[entry.expansion] = true
            for mapID in pairs(entry.mapIDs) do
                presentMaps[mapID] = true
            end
        end
    end


    local sortedZones = {}
    for mapID in pairs(presentMaps) do
        local mapInfo = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(mapID)
        sortedZones[#sortedZones + 1] = {
            value = mapID,
            label = mapInfo and mapInfo.name or tostring(mapID),
        }
    end
    table.sort(sortedZones, function(a, b) return a.label < b.label end)
    for _, zone in ipairs(sortedZones) do
        zones[#zones + 1] = zone
    end

    for _, entry in ipairs(ns.Data.expansionList) do
        if presentExpansions[entry.token] then
            expansions[#expansions + 1] = {
                value = entry.token,
                label = L[entry.nameKey],
            }
        end
    end

    return {
        status = PrefixChoices(L["FILTER_STATUS"], {
            { value = nil, label = L["FILTER_ALL"] },
            { value = "MISSING", label = L["STATUS_MISSING"] },
            { value = "SEEN", label = L["STATUS_SEEN"] },
            { value = "COLLECTED", label = L["STATUS_COLLECTED"] },
            { value = "PENDING", label = L["STATUS_PENDING"] },
            { value = "IGNORED", label = L["STATUS_IGNORED"] },
            { value = "FAVORITE", label = L["FILTER_FAVORITES"] },
        }),
        family = PrefixChoices(L["FILTER_FAMILY"], families),
        expansion = PrefixChoices(L["FILTER_EXPANSION"], expansions),
        trait = PrefixChoices(L["FILTER_TRAIT"], {
            { value = nil, label = L["FILTER_ALL"] },
            { value = "RARE", label = L["TRAIT_RARE"] },
            { value = "ELITE", label = L["TRAIT_ELITE"] },
            { value = "EXOTIC", label = L["TRAIT_EXOTIC"] },
        }),
        map = PrefixChoices(L["FILTER_ZONE"], zones),
    }
end

local function CreateControls(parent, browser)
    local controls = CreateFrame("Frame", nil, parent)
    controls:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    controls:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0)
    controls:SetHeight(FILTER_AREA_HEIGHT)

    local clear = CreateFrame("Button", nil, controls, "BackdropTemplate")
    clear:SetSize(26, CONTROL_HEIGHT)
    clear:SetNormalFontObject(GameFontNormal)
    clear:SetPoint("TOPRIGHT", controls, "TOPRIGHT", 0, 0)
    clear:SetText("×")
    Theme:ApplyButton(clear, false)

    local search = CreateFrame("EditBox", nil, controls, "InputBoxTemplate")
    search:SetPoint("TOPLEFT", controls, "TOPLEFT", 4, 0)
    search:SetPoint("RIGHT", clear, "LEFT", -CONTROL_GAP, 0)
    search:SetHeight(CONTROL_HEIGHT)
    search:SetAutoFocus(false)
    search:SetTextInsets(7, 7, 0, 0)

    local placeholder = search:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall")
    placeholder:SetPoint("LEFT", search, "LEFT", 8, 0)
    placeholder:SetText(ns.L["FILTER_SEARCH_PLACEHOLDER"])

    search:SetScript("OnTextChanged", function(self)
        placeholder:SetShown(self:GetText() == "")
        browser.filters.query = Normalize(self:GetText())
        browser:Refresh()
    end)
    search:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)
    search:SetScript("OnEnterPressed", function(self)
        self:ClearFocus()
    end)

    local buttonWidth = (parent:GetWidth() - CONTROL_GAP) / 2
    if buttonWidth <= 0 then
        buttonWidth = 157
    end

    local choices = BuildFilterChoices(browser.layout)
    local buttons = {}

    buttons.status = CreateCycleButton(controls, buttonWidth, choices.status, function(value)
        browser.filters.status = value
        browser:Refresh()
    end)
    buttons.family = CreateCycleButton(controls, buttonWidth, choices.family, function(value)
        browser.filters.family = value
        browser:Refresh()
    end)
    buttons.expansion = CreateCycleButton(controls, buttonWidth, choices.expansion, function(value)
        browser.filters.expansion = value
        browser:Refresh()
    end)
    buttons.trait = CreateCycleButton(controls, buttonWidth, choices.trait, function(value)
        browser.filters.trait = value
        browser:Refresh()
    end)
    buttons.map = CreateMenuButton(controls, parent:GetWidth(), choices.map, function(value)
        browser.filters.mapID = value
        browser:Refresh()
    end)

    buttons.status:SetPoint("TOPLEFT", search, "BOTTOMLEFT", -4, -CONTROL_GAP)
    buttons.family:SetPoint("LEFT", buttons.status, "RIGHT", CONTROL_GAP, 0)
    buttons.expansion:SetPoint("TOPLEFT", buttons.status, "BOTTOMLEFT", 0, -CONTROL_GAP)
    buttons.trait:SetPoint("LEFT", buttons.expansion, "RIGHT", CONTROL_GAP, 0)
    buttons.map:SetPoint("TOPLEFT", buttons.expansion, "BOTTOMLEFT", 0, -CONTROL_GAP)

    clear:SetScript("OnClick", function()
        search:SetText("")
        for _, button in pairs(buttons) do
            button:Reset(true)
        end
        browser.filters.status = nil
        browser.filters.family = nil
        browser.filters.expansion = nil
        browser.filters.trait = nil
        browser.filters.mapID = nil
        browser:Refresh()
    end)

    browser.searchBox = search
    browser.filterButtons = buttons

    return controls
end

--=============================================================================
-- Construction
--=============================================================================

--- Builds the browser into a parent frame.
-- @param listParent Frame Owner of the list and its controls.
-- @param detailParent Frame Owner of the detail panel.
-- @return table A browser handle with Refresh().
function Browser:Create(listParent, detailParent)
    local browser = {
        collapsed = {},
        filters = {
            query = "",
            status = nil,
            family = nil,
            expansion = nil,
            trait = nil,
            mapID = nil,
        },
    }

    browser.detail = ns.UI.DetailPanel:Create(detailParent)
    browser.layout = BuildLayout()
    browser.catalogNumbers = {}
    for _, group in ipairs(browser.layout) do
        for _, entry in ipairs(group.appearances) do
            browser.catalogNumbers[entry.id] = entry.catalogNumber
        end
    end

    local controls = CreateControls(listParent, browser)
    local listArea = CreateFrame("Frame", nil, listParent)
    listArea:SetPoint("TOPLEFT", controls, "BOTTOMLEFT", 0, -CONTROL_GAP)
    listArea:SetPoint("BOTTOMRIGHT", listParent, "BOTTOMRIGHT", 0, 0)

    browser.list = ns.UI.PetList:Create(listArea, {
        onSelect = function(appearanceID)
            browser.detail:Show(appearanceID, browser.catalogNumbers[appearanceID])
        end,

        onToggleHeader = function(token)
            browser.collapsed[token] = not browser.collapsed[token] or nil
            browser:Refresh()
        end,
    })

    --- Re-sorts and redraws everything. For a database or locale change.
    function browser:Rebuild()
        self.layout = BuildLayout()
        self.catalogNumbers = {}
        for _, group in ipairs(self.layout) do
            for _, entry in ipairs(group.appearances) do
                self.catalogNumbers[entry.id] = entry.catalogNumber
            end
        end
        local choices = BuildFilterChoices(self.layout)
        self.filterButtons.status:SetChoices(choices.status, true)
        self.filterButtons.family:SetChoices(choices.family, true)
        self.filterButtons.expansion:SetChoices(choices.expansion, true)
        self.filterButtons.trait:SetChoices(choices.trait, true)
        self.filterButtons.map:SetChoices(choices.map, true)
        self.filters.status = nil
        self.filters.family = nil
        self.filters.expansion = nil
        self.filters.trait = nil
        self.filters.mapID = nil
        self:Refresh()
    end

    --- Redraws from the cached layout and current collection state.
    function browser:Refresh()
        local elements = BuildElements(self.layout or {}, self.collapsed, self.filters)
        self.list:SetElements(elements)
        self.detail:Refresh()
    end

    browser:Refresh()

    return browser
end

--=============================================================================
-- Lifecycle
--=============================================================================

function Browser:OnEnable()
    -- The browser frame is built lazily with the window, so these only forward
    -- to it when it exists.
    ns.Events:Subscribe("COLLECTION_CHANGED", self, function()
        local instance = ns.UI.MainWindow:GetBrowser()
        if instance then
            instance:Refresh()
        end
    end)

    ns.Events:Subscribe("DATABASE_RESET", self, function()
        local instance = ns.UI.MainWindow:GetBrowser()
        if instance then
            instance:Rebuild()
        end
    end)
end
