--[[
    UI/DetailPanel.lua

    The right hand pane: everything known about the selected appearance.

    Reads from ns.Data.API and ns.Collection.State and owns neither. The only
    state it holds is which appearance is currently shown.

    Zone names are not stored in our database: they are resolved here through
    C_Map.GetMapInfo(mapID).name, which the client returns already localized.

    Coordinates are stored canonically in 0..1 and converted to percentages at
    this boundary only -- this file is the "where a human reads them" edge
    described in docs/database-schema.md.

    The model view is not here yet; it arrives in phase 0.5, once a Blizzard
    display id can actually be verified. Until then the panel says so rather
    than showing an empty model frame.
]]

local _, ns = ...

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

local BUTTON_WIDTH, BUTTON_HEIGHT = 178, 28
local MODEL_HEIGHT = 300
local HUNT_PANEL_HEIGHT = 96
local HEADER_HEIGHT = 38
local FAMILY_ICON_SIZE = 20
local INFO_BOTTOM_GAP = 38
local Theme = ns.UI.Theme
local activePanel

--=============================================================================
-- Text building
--=============================================================================

--- Specialization and expansion. The family name itself is not repeated here:
--- it has its own place in the header, next to its icon.
local function DescribeFamily(appearance)
    local L = ns.L
    local API = ns.Data.API
    local family = API:GetFamilyOfAppearance(appearance.id)
    local parts = {}

    if family and family.specialization then
        parts[#parts + 1] = L["SPEC_" .. family.specialization]
    end

    if appearance.expansion then
        parts[#parts + 1] = L["EXPANSION_" .. appearance.expansion]
    end

    return table.concat(parts, "  |cff808080-|r  ")
end

local function DescribeTraits(appearance)
    local L = ns.L
    local API = ns.Data.API
    local traits = {}

    if API:IsAppearanceExotic(appearance.id) then
        traits[#traits + 1] = L["TRAIT_EXOTIC"]
    end

    -- Rarity and level come from the creatures, since an appearance itself has
    -- neither. If every source agrees they are stated once.
    local anyRare, anyElite = false, false
    local levelMin, levelMax

    for _, creatureID in ipairs(API:GetCreatureIDsByAppearance(appearance.id)) do
        local creature = API:GetCreature(creatureID)
        if creature then
            anyRare = anyRare or creature.rare == true
            anyElite = anyElite or creature.elite == true

            if creature.levelMin then
                levelMin = math.min(levelMin or creature.levelMin, creature.levelMin)
            end
            if creature.levelMax then
                levelMax = math.max(levelMax or creature.levelMax, creature.levelMax)
            end
        end
    end

    if anyRare then
        traits[#traits + 1] = L["TRAIT_RARE"]
    end
    if anyElite then
        traits[#traits + 1] = L["TRAIT_ELITE"]
    end
    if levelMin and levelMax then
        traits[#traits + 1] = L["TRAIT_LEVEL"]:format(levelMin, levelMax)
    end

    return table.concat(traits, "  |cff808080-|r  ")
end

local function DescribeRequirements(appearance)
    local L = ns.L
    local API = ns.Data.API
    local lines, seen = {}, {}

    for _, creatureID in ipairs(API:GetCreatureIDsByAppearance(appearance.id)) do
        for _, requirementID in ipairs(API:GetEffectiveRequirements(creatureID)) do
            if not seen[requirementID] then
                seen[requirementID] = true

                local requirement = API:GetRequirement(requirementID)
                if requirement and requirement.descriptionKey then
                    lines[#lines + 1] = L[requirement.descriptionKey]
                end
            end
        end
    end

    return table.concat(lines, "\n")
end

local function DescribeLocation(location)
    local L = ns.L
    local mapInfo = C_Map.GetMapInfo(location.mapID)
    local zone = mapInfo and mapInfo.name or L["DETAIL_ZONE_UNKNOWN"]

    if location.x and location.y then
        local navigation = ns.Navigation and ns.Navigation.NativeWaypoint
        local active = navigation and navigation:IsTarget(
            location.mapID, location.x, location.y)
        local label = ("%s  %s"):format(zone,
            L["DETAIL_COORDS"]:format(location.x * 100, location.y * 100))
        if active then
            label = label .. "  |cff45b7ad" .. L["DETAIL_POINT_ACTIVE"] .. "|r"
        end

        return ("|Hshinywaypoint:%d:%.5f:%.5f|h|cffffd100%s|r|h"):format(
            location.mapID, location.x, location.y, label)
    end

    return ("%s  |cff808080%s|r"):format(zone, L["DETAIL_NO_COORDS"])
end

local function DescribeSources(appearance)
    local L = ns.L
    local API = ns.Data.API
    local lines = {}

    for _, creatureID in ipairs(API:GetCreatureIDsByAppearance(appearance.id)) do
        local creature = API:GetCreature(creatureID)
        local locations = API:GetLocationsByCreature(creatureID)

        -- `creature.name` is English reference data and must not be displayed.
        -- Until creature names can be resolved from the client, the source is
        -- identified by where it is found.
        if #locations == 0 then
            lines[#lines + 1] = L["DETAIL_LOCATION_UNKNOWN"]
        else
            for _, location in ipairs(locations) do
                lines[#lines + 1] = DescribeLocation(location)
            end
        end

        if creature and creature.source == ns.Data.Source.DATA_DEV_SAMPLE then
            lines[#lines + 1] = "|cffff8800" .. L["DETAIL_DEV_SAMPLE"] .. "|r"
        end
    end

    return table.concat(lines, "\n")
end

local function GetHuntSummary(appearance)
    local API = ns.Data.API
    local exactCount, maps, mapNames = 0, {}, {}

    for _, creatureID in ipairs(API:GetCreatureIDsByAppearance(appearance.id)) do
        for _, location in ipairs(API:GetLocationsByCreature(creatureID)) do
            if location.x and location.y then
                exactCount = exactCount + 1
                if not maps[location.mapID] then
                    maps[location.mapID] = true
                    local info = C_Map.GetMapInfo(location.mapID)
                    mapNames[#mapNames + 1] = info and info.name
                        or ns.L["DETAIL_ZONE_UNKNOWN"]
                end
            end
        end
    end

    if exactCount == 0 then
        return 0, ns.L["HUNT_NO_ROUTE"]
    end

    local place = #mapNames == 1 and mapNames[1]
        or ns.L["HUNT_MAPS_FORMAT"]:format(#mapNames)
    return exactCount, ns.L["HUNT_READY_FORMAT"]:format(place, exactCount)
end

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

local function CreateLabel(parent, fontObject, anchorTo, spacing)
    local label = parent:CreateFontString(nil, "ARTWORK", fontObject)
    label:SetJustifyH("LEFT")
    label:SetSpacing(2)

    if anchorTo then
        label:SetPoint("TOPLEFT", anchorTo, "BOTTOMLEFT", 0, -spacing)
    else
        label:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    end
    label:SetPoint("RIGHT", parent, "RIGHT", 0, 0)

    return label
end

--- Builds the panel.
-- @param parent Frame
-- @return table A panel handle with Show(appearanceID) and Refresh().
function DetailPanel:Create(parent)
    local panel = { appearanceID = nil }
    activePanel = panel

    -- A frame with no anchor points has zero size, and every label anchored to
    -- it would be laid out into nothing.
    local frame = CreateFrame("Frame", nil, parent)
    frame:SetAllPoints(parent)
    panel.frame = frame

    -- Header: the pet's name on the left, its family with its own icon on the
    -- right, the way the game's own collection panels lay it out.
    local header = CreateFrame("Frame", nil, frame)
    header:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
    header:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)
    header:SetHeight(HEADER_HEIGHT)
    frame.Header = header

    local headerRule = Theme:CreateRule(header, Theme.color.goldSoft)
    headerRule:SetHeight(1)
    headerRule:SetPoint("BOTTOMLEFT", header, "BOTTOMLEFT", 0, 0)
    headerRule:SetPoint("BOTTOMRIGHT", header, "BOTTOMRIGHT", 0, 0)

    frame.CatalogNumber = header:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall")
    frame.CatalogNumber:SetPoint("TOPLEFT", header, "TOPLEFT", 1, -1)
    frame.CatalogNumber:SetTextColor(Theme.color.muted[1], Theme.color.muted[2],
        Theme.color.muted[3], Theme.color.muted[4])

    frame.FamilyName = header:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
    frame.FamilyName:SetPoint("RIGHT", header, "RIGHT", 0, 0)
    frame.FamilyName:SetJustifyH("RIGHT")

    frame.FamilyIcon = header:CreateTexture(nil, "ARTWORK")
    frame.FamilyIcon:SetSize(FAMILY_ICON_SIZE, FAMILY_ICON_SIZE)
    frame.FamilyIcon:SetPoint("RIGHT", frame.FamilyName, "LEFT", -6, 0)
    frame.FamilyIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    frame.Name = header:CreateFontString(nil, "ARTWORK", "GameFontNormalHuge")
    frame.Name:SetPoint("BOTTOMLEFT", header, "BOTTOMLEFT", 0, 7)
    frame.Name:SetPoint("RIGHT", frame.FamilyIcon, "LEFT", -10, 0)
    frame.Name:SetJustifyH("LEFT")
    frame.Name:SetWordWrap(false)

    -- The model sits under the header and the text flows underneath it, so a
    -- wide creature is not squeezed into a column.
    local modelContainer = Theme:CreatePanel(frame, Theme.color.panelSoft, Theme.color.goldSoft)
    modelContainer:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -4)
    modelContainer:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, -4)
    modelContainer:SetHeight(MODEL_HEIGHT)
    panel.model = ns.UI.ModelViewer:Create(modelContainer)

    -- The signature interaction: a hunter's field instrument rather than a
    -- generic action card. Coordinates are an optional inspection detail; the
    -- primary action starts the complete autonomous patrol.
    local hunt = CreateFrame("Frame", nil, frame)
    hunt:SetPoint("TOPLEFT", modelContainer, "BOTTOMLEFT", 0, -7)
    hunt:SetPoint("TOPRIGHT", modelContainer, "BOTTOMRIGHT", 0, -7)
    hunt:SetHeight(HUNT_PANEL_HEIGHT)
    hunt:SetFrameLevel(frame:GetFrameLevel() + 3)
    Theme:ApplyTrackerArtwork(hunt, 106, 58)
    frame.Hunt = hunt

    frame.HuntTitle = hunt:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    frame.HuntTitle:SetPoint("TOPLEFT", hunt, "TOPLEFT", 108, -19)
    frame.HuntTitle:SetText(ns.L["HUNT_PANEL_TITLE"])
    frame.HuntTitle:SetTextColor(Theme.color.tracker[1],
        Theme.color.tracker[2], Theme.color.tracker[3])

    frame.HuntSummary = hunt:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    frame.HuntSummary:SetPoint("TOPLEFT", frame.HuntTitle, "BOTTOMLEFT", 0, -5)
    frame.HuntSummary:SetPoint("RIGHT", hunt, "RIGHT", -248, 0)
    frame.HuntSummary:SetJustifyH("LEFT")
    frame.HuntSummary:SetWordWrap(false)
    frame.HuntSummary:SetTextColor(0.88, 0.82, 0.68)

    frame.HuntProgressGlow = hunt:CreateTexture(nil, "ARTWORK")
    frame.HuntProgressGlow:SetTexture("Interface\\Buttons\\WHITE8X8")
    frame.HuntProgressGlow:SetBlendMode("ADD")
    frame.HuntProgressGlow:SetColorTexture(0.20, 0.95, 0.86, 0.22)
    frame.HuntProgressGlow:SetSize(15, 15)
    frame.HuntProgressGlow:SetRotation(math.pi / 4)
    frame.HuntProgressGlow:Hide()

    frame.HuntProgress = hunt:CreateTexture(nil, "OVERLAY")
    frame.HuntProgress:SetTexture("Interface\\Buttons\\WHITE8X8")
    frame.HuntProgress:SetColorTexture(0.38, 0.93, 0.84, 1)
    frame.HuntProgress:SetSize(6, 6)
    frame.HuntProgress:SetRotation(math.pi / 4)
    frame.HuntProgress:SetPoint("CENTER", frame.HuntProgressGlow, "CENTER")
    frame.HuntProgress:Hide()

    frame.CoordinatesButton = CreateFrame("Button", nil, hunt)
    frame.CoordinatesButton:SetSize(154, 18)
    frame.CoordinatesButton:SetPoint("BOTTOMLEFT", hunt, "BOTTOMLEFT", 108, 14)
    -- SetNormalFontObject expects the Font object on current Retail. Passing
    -- its global name as text aborts lazy construction of the whole journal,
    -- which made every minimap click create another model viewer and fail.
    frame.CoordinatesButton:SetNormalFontObject(GameFontNormalSmall)
    -- Give the button its initial label before asking for its FontString.
    -- Retail may not create that region for an empty label, leaving
    -- GetFontString() nil and aborting the journal build on the next call.
    frame.CoordinatesButton:SetText(ns.L["BUTTON_SHOW_COORDS"])
    local coordinatesFont = frame.CoordinatesButton:GetFontString()
    if coordinatesFont then
        coordinatesFont:SetTextColor(0.50, 0.78, 0.72)
    end
    frame.CoordinatesButton:SetScript("OnClick", function()
        panel.showCoordinates = not panel.showCoordinates
        panel:Refresh()
    end)

    frame.HuntButton = CreateFrame("Button", nil, hunt, "BackdropTemplate")
    frame.HuntButton:SetSize(176, 34)
    frame.HuntButton:SetPoint("RIGHT", hunt, "RIGHT", -54, 1)
    frame.HuntButton:SetNormalFontObject(GameFontNormal)
    frame.HuntButton:SetScript("OnClick", function()
        local appearance = panel.appearanceID
            and ns.Data.API:GetAppearance(panel.appearanceID)
        if appearance then
            ns.Navigation.NativeWaypoint:StartAppearance(
                appearance.id, ns.L[appearance.nameKey])
            panel:Refresh()
        end
    end)

    frame.HuntButtonRune = frame.HuntButton:CreateTexture(nil, "ARTWORK")
    frame.HuntButtonRune:SetTexture("Interface\\Buttons\\WHITE8X8")
    frame.HuntButtonRune:SetSize(7, 7)
    frame.HuntButtonRune:SetRotation(math.pi / 4)
    frame.HuntButtonRune:SetPoint("LEFT", frame.HuntButton, "LEFT", 14, 0)

    -- Long requirements and creatures with many spawn points used to continue
    -- past the bottom edge.  The information owns a clipped scroll region now;
    -- action buttons remain fixed and can never be displaced by its contents.
    local infoScroll = CreateFrame("ScrollFrame", nil, frame)
    infoScroll:SetPoint("TOPLEFT", hunt, "BOTTOMLEFT", 0, -8)
    infoScroll:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -4, INFO_BOTTOM_GAP)
    infoScroll:EnableMouseWheel(true)
    frame.InfoScroll = infoScroll

    local info = CreateFrame("Frame", nil, infoScroll)
    info:SetHeight(1)
    infoScroll:SetScrollChild(info)
    frame.Info = info

    info:EnableMouse(true)
    info:SetHyperlinksEnabled(true)
    info:SetScript("OnHyperlinkEnter", function(self, link)
        if string.find(link or "", "^shinywaypoint:") then
            GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
            GameTooltip:SetText(ns.L["DETAIL_WAYPOINT_TOOLTIP"])
            GameTooltip:AddLine(ns.L["DETAIL_WAYPOINT_TOOLTIP_HINT"],
                0.75, 0.75, 0.75, true)
            GameTooltip:Show()
        end
    end)
    info:SetScript("OnHyperlinkLeave", function()
        GameTooltip:Hide()
    end)
    info:SetScript("OnHyperlinkClick", function(_, link)
        local mapID, x, y = string.match(link or "",
            "^shinywaypoint:(%d+):([%d%.]+):([%d%.]+)$")
        local appearance = panel.appearanceID
            and ns.Data.API:GetAppearance(panel.appearanceID)
        if mapID and x and y and appearance then
            local mapInfo = C_Map.GetMapInfo(tonumber(mapID))
            ns.Navigation.NativeWaypoint:Select(
                tonumber(mapID), tonumber(x), tonumber(y), ns.L[appearance.nameKey],
                mapInfo and mapInfo.name, appearance.id)
            panel:Refresh()
        end
    end)

    infoScroll:SetScript("OnSizeChanged", function(_, width)
        info:SetWidth(math.max(width - 4, 1))
    end)
    local function OnInfoMouseWheel(_, delta)
        local target = infoScroll:GetVerticalScroll() - delta * 34
        infoScroll:SetVerticalScroll(math.max(0, math.min(target,
            infoScroll:GetVerticalScrollRange())))
    end
    infoScroll:SetScript("OnMouseWheel", OnInfoMouseWheel)
    info:EnableMouseWheel(true)
    info:SetScript("OnMouseWheel", OnInfoMouseWheel)

    local scrollHint = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    scrollHint:SetPoint("BOTTOMRIGHT", infoScroll, "BOTTOMRIGHT", -8, 3)
    scrollHint:SetText(ns.L["DETAIL_SCROLL_HINT"])
    scrollHint:Hide()
    frame.ScrollHint = scrollHint

    frame.Family = CreateLabel(info, "GameFontHighlight", nil, 0)
    frame.Traits = CreateLabel(info, "GameFontHighlightSmall", frame.Family, 4)
    frame.Status = CreateLabel(info, "GameFontNormal", frame.Traits, 12)
    frame.Ownership = CreateLabel(info, "GameFontHighlightSmall", frame.Status, 4)
    frame.Requirements = CreateLabel(info, "GameFontHighlightSmall", frame.Ownership, 10)
    frame.SourcesHeader = CreateLabel(info, "GameFontNormalSmall", frame.Requirements, 14)
    frame.Sources = CreateLabel(info, "GameFontHighlightSmall", frame.SourcesHeader, 4)

    local function UpdateInfoExtent()
        local contentHeight = frame.Family:GetStringHeight()
            + frame.Traits:GetStringHeight()
            + frame.Status:GetStringHeight()
            + frame.Ownership:GetStringHeight()
            + frame.Requirements:GetStringHeight()
            + frame.SourcesHeader:GetStringHeight()
            + frame.Sources:GetStringHeight()
            + 56
        info:SetHeight(math.max(infoScroll:GetHeight(), contentHeight))
        scrollHint:SetShown(contentHeight > infoScroll:GetHeight() + 1)
    end

    frame.FavoriteButton = CreateFrame("Button", nil, frame, "BackdropTemplate")
    frame.FavoriteButton:SetSize(BUTTON_WIDTH, BUTTON_HEIGHT)
    frame.FavoriteButton:SetNormalFontObject(GameFontNormal)
    frame.FavoriteButton:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)
    frame.FavoriteButton:SetScript("OnClick", function()
        if panel.appearanceID then
            ns.Collection.State:ToggleFavorite(panel.appearanceID)
        end
    end)

    frame.CollectedButton = CreateFrame("Button", nil, frame, "BackdropTemplate")
    frame.CollectedButton:SetSize(BUTTON_WIDTH, BUTTON_HEIGHT)
    frame.CollectedButton:SetNormalFontObject(GameFontNormal)
    frame.CollectedButton:SetPoint("LEFT", frame.FavoriteButton, "RIGHT", 8, 0)
    frame.CollectedButton:SetScript("OnClick", function()
        local id = panel.appearanceID
        if id then
            ns.Collection.State:SetCollected(id, not ns.Collection.State:IsCollected(id))
        end
    end)

    --- Shows one appearance, or the empty state when given nil.
    function panel:Show(appearanceID, catalogNumber)
        if self.appearanceID ~= appearanceID then
            self.showCoordinates = false
        end
        self.appearanceID = appearanceID
        self.catalogNumber = catalogNumber
        self:Refresh()
    end

    --- Redraws from current data and collection state.
    function panel:Refresh()
        local L = ns.L
        local State = ns.Collection.State
        local appearance = self.appearanceID and ns.Data.API:GetAppearance(self.appearanceID)

        if not appearance then
            infoScroll:SetVerticalScroll(0)
            frame.Name:SetText(L["DETAIL_NO_SELECTION"])
            frame.CatalogNumber:SetText("")
            for _, key in ipairs({ "Family", "Traits", "Status", "Ownership",
                                   "Requirements", "SourcesHeader", "Sources",
                                   "FamilyName" }) do
                frame[key]:SetText("")
            end
            frame.FamilyIcon:Hide()
            frame.Hunt:Hide()
            self.model:SetDisplayID(nil)
            frame.FavoriteButton:Hide()
            frame.CollectedButton:Hide()
            UpdateInfoExtent()
            return
        end

        local collected = State:IsCollected(appearance.id)
        local favorite = State:IsFavorite(appearance.id)
        local exactCount, huntSummary = GetHuntSummary(appearance)
        local patrolState = ns.Navigation.NativeWaypoint:GetPatrolState(
            appearance.id)

        frame.Hunt:Show()
        if patrolState then
            frame.HuntSummary:SetText(ns.L["HUNT_ACTIVE_FORMAT"]:format(
                patrolState.index, patrolState.total))
            frame.HuntButton:SetText(ns.L["BUTTON_HUNT_STOP"])
            Theme:ApplyHuntButton(frame.HuntButton, true)
            frame.HuntButtonRune:SetColorTexture(1.00, 0.42, 0.22, 1)

            local fraction = patrolState.total > 1
                and (patrolState.index - 1) / (patrolState.total - 1) or 0
            local railX = Theme:GetTrackerRailStop(
                frame.Hunt:GetWidth(), 106, 58, fraction)
            frame.HuntProgressGlow:ClearAllPoints()
            frame.HuntProgressGlow:SetPoint("CENTER", frame.Hunt, "BOTTOMLEFT",
                railX, 18)
            frame.HuntProgressGlow:Show()
            frame.HuntProgress:Show()
        else
            frame.HuntSummary:SetText(huntSummary)
            frame.HuntButton:SetText(ns.L["BUTTON_HUNT_START"])
            Theme:ApplyHuntButton(frame.HuntButton, false)
            frame.HuntButtonRune:SetColorTexture(0.38, 0.93, 0.84, 1)
            frame.HuntProgressGlow:Hide()
            frame.HuntProgress:Hide()
        end

        frame.HuntButton:SetEnabled(exactCount > 0)
        frame.HuntButton:SetAlpha(exactCount > 0 and 1 or 0.45)
        frame.CoordinatesButton:SetShown(exactCount > 0)
        frame.CoordinatesButton:SetText(self.showCoordinates
            and ns.L["BUTTON_HIDE_COORDS"] or ns.L["BUTTON_SHOW_COORDS"])

        frame.Name:SetText(L[appearance.nameKey])
        frame.CatalogNumber:SetText(self.catalogNumber
            and L["CATALOG_NUMBER_FORMAT"]:format(self.catalogNumber) or "")

        -- Favourites are tinted rather than badged, matching the list, because
        -- no favourite icon atlas has been verified on this client.
        if favorite then
            frame.Name:SetTextColor(1.00, 0.82, 0.10)
        else
            frame.Name:SetTextColor(1.00, 1.00, 1.00)
        end

        local family = ns.Data.API:GetFamilyOfAppearance(appearance.id)
        frame.FamilyName:SetText(family and L[family.nameKey] or "")

        if family and family.icon then
            frame.FamilyIcon:SetTexture(family.icon)
            frame.FamilyIcon:Show()
        else
            frame.FamilyIcon:Hide()
        end

        frame.Family:SetText(DescribeFamily(appearance))
        frame.Traits:SetText(DescribeTraits(appearance))

        if collected then
            frame.Status:SetText("|cff40ff40" .. L["STATUS_COLLECTED"] .. "|r")
        else
            frame.Status:SetText("|cffff6060" .. L["STATUS_MISSING"] .. "|r")
        end

        -- Ownership is only ever stated positively. "Not in your stable" would
        -- be a claim we cannot support: the stable may simply not have been
        -- read on this character yet.
        if State:IsOwned(appearance.id) then
            frame.Ownership:SetText("|cff40ff40" .. L["DETAIL_IN_STABLE"] .. "|r")
        elseif State:IsSeen(appearance.id) then
            frame.Ownership:SetText("|cff70b3f0" .. L["DETAIL_SEEN_IN_WORLD"] .. "|r")
        else
            frame.Ownership:SetText("")
        end

        frame.Requirements:SetText(DescribeRequirements(appearance))
        if self.showCoordinates then
            frame.SourcesHeader:SetText(("%s  |cff8f857d%s|r"):format(
                L["DETAIL_SOURCES"], L["DETAIL_COORDINATE_HINT"]))
            frame.Sources:SetText(DescribeSources(appearance))
        else
            frame.SourcesHeader:SetText("")
            frame.Sources:SetText("")
        end
        infoScroll:SetVerticalScroll(0)
        UpdateInfoExtent()

        -- The first display id is the canonical look. Appearances with several
        -- are for the variant picker in a later phase.
        self.model:SetDisplayID(appearance.displayIDs and appearance.displayIDs[1] or nil)
        self.model:SetDiscovered(collected)

        frame.FavoriteButton:SetText(favorite and L["BUTTON_FAVORITE_REMOVE"]
                                              or L["BUTTON_FAVORITE_ADD"])
        frame.CollectedButton:SetText(collected and L["BUTTON_MARK_MISSING"]
                                                 or L["BUTTON_MARK_COLLECTED"])
        Theme:ApplyButton(frame.FavoriteButton, favorite)
        Theme:ApplyButton(frame.CollectedButton, collected)
        frame.FavoriteButton:Show()
        frame.CollectedButton:Show()
    end

    panel:Show(nil)
    ns.Debug:Log("UI", "detail panel created")

    return panel
end

function DetailPanel:OnEnable()
    ns.Events:Subscribe("NAVIGATION_CHANGED", self, function()
        if activePanel then
            activePanel:Refresh()
        end
    end)
end
