local _, ns = ...
local GameTooltip = _G.GameTooltip

local UI = {}
ns.UI = UI

local CARD_COLUMNS, CARD_HEIGHT, CARD_GAP_X, CARD_GAP_Y = 3, 168, 10, 10
local ROW_STEP, POOL_ROWS = CARD_HEIGHT + CARD_GAP_Y, 5
local WINDOW_WIDTH, WINDOW_HEIGHT = 1060, 700
local ACTIVE_LAYOUT

local SHORT_NAMES = {
    all = "All memories", classic = "Classic", burning_crusade = "Burning Crusade",
    wrath = "Wrath", cataclysm = "Cataclysm", mists = "Pandaria", warlords = "Draenor",
    legion = "Legion", bfa = "Battle for Azeroth", shadowlands = "Shadowlands",
    dragonflight = "Dragonflight", war_within = "The War Within", midnight = "Midnight",
    favorites = "Favorites",
}

local function SafeNumber(value, fallback)
    if issecretvalue and issecretvalue(value) then return fallback or 0 end
    return type(value) == "number" and value or (fallback or 0)
end

local function GetPatchTabLabel(choice)
    local name = choice.name or choice.label or choice.key or ""
    if choice.key == "all" then return "All patches" end
    if choice.key == "prelaunch" or name:lower():find("pre%-launch", 1) then return "Pre-launch" end
    return name:match("(%d+%.%d+%.?%d*)%s*$") or name
end

local function IsBlizzardStyle()
    return true
end

local function ConfigureLayout()
    ACTIVE_LAYOUT = IsBlizzardStyle() and ns.Skin.GetLayoutMetrics and ns.Skin:GetLayoutMetrics() or nil
    if ACTIVE_LAYOUT then
        WINDOW_WIDTH, WINDOW_HEIGHT = ACTIVE_LAYOUT.windowWidth, ACTIVE_LAYOUT.windowHeight
        CARD_COLUMNS = ACTIVE_LAYOUT.cardColumns
        CARD_HEIGHT = ACTIVE_LAYOUT.cardHeight
        CARD_GAP_X, CARD_GAP_Y = ACTIVE_LAYOUT.cardGapX, ACTIVE_LAYOUT.cardGapY
    else
        WINDOW_WIDTH, WINDOW_HEIGHT = 1060, 700
        CARD_COLUMNS, CARD_HEIGHT, CARD_GAP_X, CARD_GAP_Y = 3, 168, 10, 10
    end
    ROW_STEP = CARD_HEIGHT + CARD_GAP_Y
end

local function FitToScreen(frame)
    local width, height = UIParent:GetWidth(), UIParent:GetHeight()
    if issecretvalue and (issecretvalue(width) or issecretvalue(height)) then return end
    if type(width) ~= "number" or type(height) ~= "number" then return end
    frame:SetScale(math.max(0.50, math.min(1, (width - 36) / WINDOW_WIDTH, (height - 36) / WINDOW_HEIGHT)))
end

local function RegisterEscapeFrame(name)
    if not UISpecialFrames then return end
    for _, registered in ipairs(UISpecialFrames) do if registered == name then return end end
    UISpecialFrames[#UISpecialFrames + 1] = name
end

local function MakeDraggable(region, frame)
    if not region or not frame then return end
    region:EnableMouse(true)
    region:RegisterForDrag("LeftButton")
    region:SetScript("OnDragStart", function() frame:StartMoving() end)
    region:SetScript("OnDragStop", function() frame:StopMovingOrSizing() end)
end

local function CreateButton(parent, text, width, onClick, primary)
    local button = CreateFrame("Button", nil, parent, "BackdropTemplate")
    button:SetSize(width or 92, 26)
    button:SetText(text or "")
    button:SetNormalFontObject("GameFontNormalSmall")
    button:SetHighlightFontObject("GameFontNormalSmall")
    button:SetScript("OnClick", onClick)
    ns.Skin:Button(button, primary)
    local label = button:GetFontString()
    if label then
        label:ClearAllPoints(); label:SetPoint("LEFT", 6, 0); label:SetPoint("RIGHT", -6, 0)
        label:SetJustifyH("CENTER"); label:SetWordWrap(false)
        if label.SetMaxLines then label:SetMaxLines(1) end
    end
    return button
end

local function CreateSlimScroll(parent)
    local scroll = CreateFrame("ScrollFrame", nil, parent)
    scroll:EnableMouseWheel(true)
    if scroll.SetClipsChildren then scroll:SetClipsChildren(true) end
    local content = CreateFrame("Frame", nil, scroll)
    content:SetPoint("TOPLEFT")
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)

    local slider = CreateFrame("Slider", nil, parent)
    slider:SetOrientation("VERTICAL")
    slider:SetWidth(10)
    slider:SetMinMaxValues(0, 0)
    slider:SetValueStep(1)
    slider:SetObeyStepOnDrag(false)
    local track = slider:CreateTexture(nil, "BACKGROUND")
    track:SetPoint("TOP", 0, -2); track:SetPoint("BOTTOM", 0, 2); track:SetWidth(2)
    local thumb = slider:CreateTexture(nil, "ARTWORK")
    thumb:SetSize(8, 36)
    slider:SetThumbTexture(thumb)
    ns.Skin:ScrollBar(slider, track, thumb)

    local updating
    local function NotifyScroll()
        if scroll.onScroll then scroll.onScroll(scroll:GetVerticalScroll()) end
    end
    slider:SetScript("OnValueChanged", function(_, value)
        if updating then return end
        scroll:SetVerticalScroll(value)
        NotifyScroll()
    end)
    local function UpdateRange()
        local range = SafeNumber(scroll:GetVerticalScrollRange(), 0)
        local current = math.max(0, math.min(range, SafeNumber(scroll:GetVerticalScroll(), 0)))
        updating = true
        slider:SetMinMaxValues(0, range)
        scroll:SetVerticalScroll(current)
        slider:SetValue(current)
        updating = nil
        slider:SetShown(range > 0)
        NotifyScroll()
    end
    scroll:SetScript("OnMouseWheel", function(_, delta)
        local range = SafeNumber(scroll:GetVerticalScrollRange(), 0)
        slider:SetValue(math.max(0, math.min(range, slider:GetValue() - delta * ROW_STEP)))
    end)
    scroll:SetScript("OnScrollRangeChanged", UpdateRange)
    scroll.UpdateRange, scroll.content, scroll.slider = UpdateRange, content, slider
    return scroll
end

local function ShowMovieTooltip(card)
    local movie = card.movie
    if not movie then return end
    GameTooltip:SetOwner(card, "ANCHOR_RIGHT")
    GameTooltip:SetText(movie.title or ("Movie " .. movie.id), 0.96, 0.82, 0.46)
    local expansion = movie.expansion == "discovered" and "Discovered" or ns:GetExpansionName(movie.expansion)
    GameTooltip:AddLine(expansion .. "  •  " .. (movie.patchLabel or movie.patch or "Uncatalogued"), 0.82, 0.66, 0.40)
    if movie.description and movie.description ~= "" then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(movie.description, 0.95, 0.90, 0.78, true)
    end
    if movie.context and movie.context ~= "" then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(movie.context, 0.74, 0.67, 0.54, true)
    end
    if movie.id then
        GameTooltip:AddLine("Movie ID: " .. tostring(movie.id), 0.58, 0.53, 0.45)
    end
    GameTooltip:AddLine("Click to replay", 0.52, 0.75, 0.68)
    GameTooltip:Show()
end

local function ShowFavoriteTooltip(button)
    local movie = button:GetParent().movie
    if not movie then return end
    GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
    if ns:IsMovieFavorite(movie.id) then
        GameTooltip:SetText("Remove from Favorites", 0.96, 0.82, 0.46)
    else
        GameTooltip:SetText("Add to Favorites", 0.96, 0.82, 0.46)
    end
    GameTooltip:AddLine(movie.title, 0.82, 0.72, 0.56)
    GameTooltip:Show()
end

local function CreateMovieCard(parent, seed)
    local blizzard = IsBlizzardStyle()
    local card = CreateFrame("Button", nil, parent, "BackdropTemplate")
    card:SetHeight(CARD_HEIGHT)
    card:RegisterForClicks("LeftButtonUp")
    if ns.Skin.PaperCard then ns.Skin:PaperCard(card, seed) else ns.Skin:Panel(card, "panel") end

    local imageFrame = CreateFrame("Frame", nil, card, "BackdropTemplate")
    local imageInset = blizzard and 6 or 7
    imageFrame:SetPoint("TOPLEFT", imageInset, -imageInset)
    imageFrame:SetPoint("TOPRIGHT", -imageInset, -imageInset)
    imageFrame:SetHeight(blizzard and 117 or 118)
    if imageFrame.SetClipsChildren then imageFrame:SetClipsChildren(true) end
    if ns.Skin.ThumbnailFrame then ns.Skin:ThumbnailFrame(imageFrame) else ns.Skin:Panel(imageFrame, "inset") end
    card.imageFrame = imageFrame
    card.image = imageFrame:CreateTexture(nil, "ARTWORK")
    card.image:SetAllPoints(); card.image:SetTexCoord(0.04, 0.96, 0.02, 0.98)
    card.fallbackIcon = imageFrame:CreateTexture(nil, "OVERLAY")
    card.fallbackIcon:SetTexture("Interface\\Icons\\INV_Misc_Book_17")
    card.fallbackIcon:SetSize(42, 42); card.fallbackIcon:SetPoint("CENTER", 0, 3)
    card.fallbackIcon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
    card.fallbackIcon:SetVertexColor(0.24, 0.18, 0.10, 0.58); card.fallbackIcon:Hide()
    card.title = card:CreateFontString(nil, "OVERLAY", blizzard and "SystemFont_Large" or "GameFontNormalSmall")
    card.title:SetPoint("TOPLEFT", imageFrame, "BOTTOMLEFT", 3, -3); card.title:SetPoint("RIGHT", -43, 0)
    card.title:SetHeight(blizzard and 32 or 24); card.title:SetJustifyH("LEFT"); card.title:SetJustifyV("TOP"); card.title:SetWordWrap(true)
    if card.title.SetMaxLines then card.title:SetMaxLines(2) end
    ns.Skin:Text(card.title)
    if blizzard and ns.Skin.SetFontRole then ns.Skin:SetFontRole(card.title, "title") end
    card.meta = card:CreateFontString(nil, "OVERLAY", blizzard and "SystemFont_Med1" or "GameFontHighlightSmall")
    card.meta:SetPoint("TOPLEFT", card.title, "BOTTOMLEFT", 0, -1); card.meta:SetPoint("RIGHT", -43, 0)
    card.meta:SetHeight(13); card.meta:SetJustifyH("LEFT"); card.meta:SetWordWrap(false)
    ns.Skin:Text(card.meta, blizzard and "text" or "muted")
    if blizzard and ns.Skin.SetFontRole then ns.Skin:SetFontRole(card.meta, "meta") end
    card.favoriteButton = CreateFrame("Button", nil, card)
    card.favoriteButton:SetSize(blizzard and 24 or 32, blizzard and 24 or 32); card.favoriteButton:SetPoint("BOTTOMRIGHT", blizzard and -11 or -4, blizzard and 8 or 2)
    card.favoriteButton:SetFrameLevel(card:GetFrameLevel() + 5)
    card.favoriteButton:SetScript("OnClick", function(button)
        local movie = button:GetParent().movie
        if movie then ns:ToggleMovieFavorite(movie.id) end
    end)
    card.favoriteButton:SetScript("OnEnter", ShowFavoriteTooltip)
    card.favoriteButton:SetScript("OnLeave", function() GameTooltip:Hide() end)
    if ns.Skin.FavoriteBookmark then
        card.favoriteBookmark = ns.Skin:FavoriteBookmark(card, imageFrame)
    end
    card:SetScript("OnClick", function(self) if self.movie then ns:PlayMovie(self.movie) end end)
    card:SetScript("OnEnter", function(self)
        if ns.Skin.SetCardHovered then ns.Skin:SetCardHovered(self, true) end
        ShowMovieTooltip(self)
    end)
    card:SetScript("OnLeave", function(self)
        if ns.Skin.SetCardHovered then ns.Skin:SetCardHovered(self, false) end
        GameTooltip:Hide()
    end)
    card:Hide()
    return card
end

function UI:RefreshCardFavorite(card, movie)
    local favorite = ns:IsMovieFavorite(movie.id)
    if card.favoriteBookmark then card.favoriteBookmark:SetShown(favorite) end
    if ns.Skin.FavoriteButton then
        ns.Skin:FavoriteButton(card.favoriteButton, favorite)
    end
end

function UI:ApplyCardThumbnail(card, movie)
    local thumbnail = ns:GetThumbnail(movie.id)
    local hasMovieThumbnail = thumbnail ~= nil
    if not thumbnail and ns.Skin.GetFallbackThumbnail then thumbnail = ns.Skin:GetFallbackThumbnail(movie.expansion, movie.id) end
    card.image:SetTexture(thumbnail or "Interface\\Icons\\INV_Misc_Film_01")
    if hasMovieThumbnail then
        if IsBlizzardStyle() then
            -- Curated stills are 2:1; crop equal slivers from their sides to
            -- fill the 16:9 stock-style viewport without stretching them.
            card.image:SetTexCoord(0.05556, 0.94444, 0, 1)
        else
            card.image:SetTexCoord(0.04, 0.96, 0.02, 0.98)
        end
    else
        card.image:SetTexCoord(0.04, 0.96, 0.26, 0.74)
    end
    card.fallbackIcon:SetShown(not hasMovieThumbnail)
end

function UI:SetStatus(message, kind)
    if not self.status or not ns:IsSafeString(message) then return end
    self.status:SetText(message)
    ns.Skin:SetTextRole(self.status, kind == "danger" and "danger" or (kind == "warning" and "warning" or "muted"))
end

function UI:SelectExpansion(key)
    if not ns:IsExpansionKey(key) then key = "all" end
    ns.db.selectedExpansion, self.selectedExpansion = key, key
    self.selectedPatch = (ns.db.selectedPatchByExpansion and ns.db.selectedPatchByExpansion[key]) or "all"
    if self.listScroll then self.listScroll.slider:SetValue(0) end
    self:Refresh()
end

function UI:SelectPatch(key)
    self.selectedPatch = type(key) == "string" and key or "all"
    if ns.db.selectedPatchByExpansion then ns.db.selectedPatchByExpansion[self.selectedExpansion] = self.selectedPatch end
    if self.listScroll then self.listScroll.slider:SetValue(0) end
    self:Refresh()
end

function UI:RefreshExpansionRail()
    for key, button in pairs(self.expansionButtons) do
        local selected = key == self.selectedExpansion
        ns.Skin:SetButtonSelected(button, selected)
        if ns.Skin.Bookmark then ns.Skin:Bookmark(button, button.bookmarkVariant, selected, key) end
        if IsBlizzardStyle() then
            button:SetEnabled(not selected)
            local baseLevel = self.frame and self.frame:GetFrameLevel() or 0
            button:SetFrameLevel(baseLevel + (selected and 4 or 3))
            local y = selected and -3 or 2
            local label = button:GetFontString()
            label:ClearAllPoints(); label:SetPoint("LEFT", button, "LEFT", 12, y); label:SetPoint("RIGHT", button, "RIGHT", -37, y)
            button.count:ClearAllPoints(); button.count:SetPoint("RIGHT", button, "RIGHT", -10, y)
        end
        local count = #ns:GetFilteredMovies(key, "all", "")
        button:SetText(SHORT_NAMES[key] or button.expansionName)
        if button.count then
            button.count:SetText(count)
            if IsBlizzardStyle() then ns.Skin:SetTextRole(button.count, selected and "light" or "accent") end
        end
    end
end

function UI:RefreshPatchTabs()
    local choices = ns.GetPatchChoices and ns:GetPatchChoices(self.selectedExpansion) or {{ key = "all", name = "All chapters" }}
    local found
    for _, choice in ipairs(choices) do if choice.key == self.selectedPatch then found = true break end end
    if not found then self.selectedPatch = "all" end
    for _, button in ipairs(self.patchButtons) do button:Hide() end
    for _, rule in ipairs(self.patchRules or {}) do rule:Hide() end
    local blizzard = IsBlizzardStyle()
    local function RemovePatchArea()
        self.selectedPatch = "all"
        if ns.db.selectedPatchByExpansion then ns.db.selectedPatchByExpansion[self.selectedExpansion] = "all" end
        if self.listScroll then
            self.listScroll:ClearAllPoints()
            if blizzard then
                self.listScroll:SetPoint("TOPLEFT", self.journal, "TOPLEFT", ACTIVE_LAYOUT.contentLeft, -118)
                self.listScroll:SetPoint("BOTTOMRIGHT", self.journal, "BOTTOMRIGHT", -ACTIVE_LAYOUT.contentRight, ACTIVE_LAYOUT.contentBottom)
            else
                self.listScroll:SetPoint("TOPLEFT", self.journal, "TOPLEFT", 25, -72)
                self.listScroll:SetPoint("BOTTOMRIGHT", self.journal, "BOTTOMRIGHT", -38, 44)
            end
        end
    end
    if self.selectedExpansion == "favorites" or self.selectedExpansion == "all" then
        RemovePatchArea()
        return
    end
    local realChoices = {}
    for _, choice in ipairs(choices) do
        if choice.key ~= "all" then realChoices[#realChoices + 1] = choice end
    end
    if #realChoices <= 1 then
        choices = realChoices
        if #choices == 0 then
            RemovePatchArea()
            return
        end
        if self.selectedPatch == "all" then
            self.selectedPatch = choices[1].key
            if ns.db.selectedPatchByExpansion then
                ns.db.selectedPatchByExpansion[self.selectedExpansion] = self.selectedPatch
            end
        end
    end
    local availableWidth = blizzard and 714 or 706
    local startX = blizzard and 58 or 25
    local startY = blizzard and 118 or 72
    local tabHeight = blizzard and 32 or 24
    local rowStep = blizzard and 31 or 27
    local gap = blizzard and 8 or 5
    local cursorX, row = 0, 0
    local placements = {}
    for index, choice in ipairs(choices) do
        local button = self.patchButtons[index]
        if not button then
            button = CreateButton(self.journal, "", 96, function(selfButton)
                if selfButton.patchKey then UI:SelectPatch(selfButton.patchKey) end
            end)
            button:SetHeight(tabHeight)
            self.patchButtons[index] = button
        end
        button:SetHeight(tabHeight)
        button.patchKey, button.patchName = choice.key, choice.name or choice.label or choice.key
        button.patchLabel = GetPatchTabLabel(choice)
        if not button._cutscenePatchTooltip then
            button:SetScript("OnEnter", function(active)
                if not active.patchName then return end
                GameTooltip:SetOwner(active, "ANCHOR_TOP")
                GameTooltip:SetText(active.patchName)
                GameTooltip:Show()
            end)
            button:SetScript("OnLeave", function() GameTooltip:Hide() end)
            button._cutscenePatchTooltip = true
        end
        -- Measure in an unconstrained region first; GetStringWidth can report
        -- the clipped region width when a FontString already has both anchors.
        button:SetWidth(availableWidth)
        button:SetText(button.patchLabel)
        local label = button:GetFontString()
        local measured = label and label.GetUnboundedStringWidth and label:GetUnboundedStringWidth()
            or (label and label:GetStringWidth())
        local labelWidth = SafeNumber(measured, 64)
        local buttonWidth
        if blizzard then
            local compactVersion = button.patchLabel ~= button.patchName
            local minimumWidth = compactVersion and 58 or 78
            local horizontalPadding = compactVersion and 28 or 18
            buttonWidth = math.max(minimumWidth, math.min(220, math.ceil(labelWidth + horizontalPadding)))
            if cursorX > 0 and cursorX + buttonWidth > availableWidth then cursorX, row = 0, row + 1 end
        else
            buttonWidth = math.max(78, math.min(availableWidth, math.ceil(labelWidth + 22)))
            if cursorX > 0 and cursorX + buttonWidth > availableWidth then cursorX, row = 0, row + 1 end
        end
        button:SetWidth(buttonWidth)
        placements[#placements + 1] = { button = button, x = cursorX, row = row }
        cursorX = cursorX + buttonWidth + gap
        local selected = choice.key == self.selectedPatch
        if blizzard and ns.Skin.Bookmark then
            ns.Skin:Bookmark(button, nil, selected, "patch:" .. tostring(choice.key))
            button:SetEnabled(not selected)
        else
            ns.Skin:SetButtonSelected(button, selected)
        end
        button:Show()
    end
    -- Read patch tabs in their natural order from the upper-left, wrapping
    -- overflow onto a left-aligned row beneath. Lower rows sit on a higher
    -- frame level so their touching borders remain clean.
    for _, placement in ipairs(placements) do
        local visualRow = placement.row
        local button = placement.button
        button:ClearAllPoints()
        button:SetPoint("TOPLEFT", self.journal, "TOPLEFT", startX + placement.x, -startY - visualRow * rowStep)
        if blizzard then
            button:SetFrameLevel(self.journal:GetFrameLevel() + 10 + visualRow)
        end
    end
    if blizzard then
        self.patchRules = self.patchRules or {}
        for rowIndex = 0, row do
            local rule = self.patchRules[rowIndex + 1]
            if not rule then
                rule = self.journal:CreateTexture(nil, "ARTWORK", nil, -1)
                rule:SetHeight(2)
                if ns.Skin.InkRule then ns.Skin:InkRule(rule) end
                self.patchRules[rowIndex + 1] = rule
            end
            rule:ClearAllPoints()
            rule:SetPoint("TOPLEFT", self.journal, "TOPLEFT", startX, -startY - rowIndex * rowStep - tabHeight + 1)
            rule:SetPoint("TOPRIGHT", self.journal, "TOPRIGHT", -ACTIVE_LAYOUT.contentRight, -startY - rowIndex * rowStep - tabHeight + 1)
            rule:Show()
        end
    end
    if self.listScroll then
        self.listScroll:ClearAllPoints()
        if blizzard then
            self.listScroll:SetPoint("TOPLEFT", self.journal, "TOPLEFT", ACTIVE_LAYOUT.contentLeft, -160 - row * rowStep)
            self.listScroll:SetPoint("BOTTOMRIGHT", self.journal, "BOTTOMRIGHT", -ACTIVE_LAYOUT.contentRight, ACTIVE_LAYOUT.contentBottom)
        else
            self.listScroll:SetPoint("TOPLEFT", self.journal, "TOPLEFT", 25, -104 - row * rowStep)
            self.listScroll:SetPoint("BOTTOMRIGHT", self.journal, "BOTTOMRIGHT", -38, 44)
        end
    end
end

function UI:UpdateVisibleCards()
    if not self.listScroll or not self.movies then return end
    local width = math.max(420, SafeNumber(self.listScroll:GetWidth(), 750))
    local cardWidth = ACTIVE_LAYOUT and ACTIVE_LAYOUT.cardWidth
        or math.floor((width - CARD_GAP_X * (CARD_COLUMNS - 1) - 2) / CARD_COLUMNS)
    local startIndex = math.max(0, math.floor(SafeNumber(self.listScroll:GetVerticalScroll(), 0) / ROW_STEP)) * CARD_COLUMNS + 1
    for slot, card in ipairs(self.cards) do
        local movieIndex, movie = startIndex + slot - 1, self.movies[startIndex + slot - 1]
        if movie then
            local row, column = math.floor((movieIndex - 1) / CARD_COLUMNS), (movieIndex - 1) % CARD_COLUMNS
            card:ClearAllPoints(); card:SetPoint("TOPLEFT", self.listScroll.content, "TOPLEFT", column * (cardWidth + CARD_GAP_X), -row * ROW_STEP)
            card:SetWidth(cardWidth); card.movie = movie
            if IsBlizzardStyle() then card.title:SetHeight(32) end
            card.title:SetText(movie.title)
            if IsBlizzardStyle() and card.title.GetStringHeight then
                local renderedTitleHeight = math.ceil(SafeNumber(card.title:GetStringHeight(), 16))
                card.title:SetHeight(math.max(16, math.min(32, renderedTitleHeight)))
            end
            card.meta:SetText(movie.patchLabel or movie.patch or (movie.discovered and "Found in play") or "Archive")
            self:ApplyCardThumbnail(card, movie)
            self:RefreshCardFavorite(card, movie)
            card:Show()
        else
            card.movie = nil
            card:Hide()
        end
    end
end

function UI:RefreshCards(movies)
    self.movies = movies
    self.listScroll.content:SetHeight(math.max(1, math.ceil(#movies / CARD_COLUMNS) * ROW_STEP - CARD_GAP_Y))
    self.listScroll.content:SetWidth(math.max(1, SafeNumber(self.listScroll:GetWidth(), 750)))
    self.listScroll:UpdateRange()
    self:UpdateVisibleCards()
end

function UI:Refresh()
    if not self.frame or not ns.db then return end
    self.selectedExpansion = self.selectedExpansion or ns.db.selectedExpansion or "all"
    self.selectedPatch = self.selectedPatch or (ns.db.selectedPatchByExpansion and ns.db.selectedPatchByExpansion[self.selectedExpansion]) or "all"
    local searchText = self.search and self.search:GetText() or ""
    if issecretvalue and issecretvalue(searchText) then searchText = "" end
    self:RefreshPatchTabs()
    local movies = ns:GetFilteredMovies(self.selectedExpansion, self.selectedPatch, searchText)
    self.sectionTitle:SetText(ns:GetExpansionName(self.selectedExpansion))
    self.resultCount:SetFormattedText("%d %s", #movies, #movies == 1 and "memory" or "memories")
    self.search.placeholder:SetShown(searchText == "")
    self:RefreshExpansionRail(); self:RefreshCards(movies); self.empty:SetShown(#movies == 0)
end

function UI:HideForPlayback()
    if not self.frame then return end
    self.hidingForPlayback = true
    self.frame:Hide()
    self.hidingForPlayback = nil
end
function UI:Hide() if self.frame then self.frame:Hide() end end
function UI:Show()
    if not self.frame then ns:InitializeUI() end
    if not self.frame then return end
    self.frame:Show(); self.frame:Raise(); self:Refresh()
end
function UI:Toggle()
    if not self.frame then ns:InitializeUI() end
    if not self.frame then return end
    if self.frame:IsShown() then self:Hide() else self:Show() end
end

function ns:InitializeUI()
    if UI.frame then return end
    local validCatalog, reason = self:ValidateCatalog()
    if not validCatalog then print("|cffff5555Memories of Azeroth:|r " .. reason); return end
    ConfigureLayout()
    local blizzard = IsBlizzardStyle()
    local frame = CreateFrame("Frame", "MemoriesOfAzerothFrame", UIParent, "BackdropTemplate")
    frame:SetSize(WINDOW_WIDTH, WINDOW_HEIGHT); frame:SetPoint("CENTER"); frame:SetFrameStrata("FULLSCREEN_DIALOG")
    frame:SetFrameLevel(500); frame:SetToplevel(true); frame:SetClampedToScreen(true); frame:SetMovable(true); frame:EnableMouse(true)
    frame:SetScript("OnShow", function(shownFrame) FitToScreen(shownFrame); shownFrame:Raise(); UI:Refresh() end)
    frame:RegisterEvent("DISPLAY_SIZE_CHANGED"); frame:RegisterEvent("UI_SCALE_CHANGED")
    frame:SetScript("OnEvent", function(changedFrame) if changedFrame:IsShown() then FitToScreen(changedFrame) end end)
    frame:SetScript("OnHide", function()
        GameTooltip:Hide()
        -- A preload retry intentionally hides the library immediately before
        -- StartMovie. Do not let that hide cancel its own ticker; manual closes
        -- and unrelated hides must still release pending movie data.
        if not UI.hidingForPlayback then ns:CancelPendingMovie() end
    end)
    frame:Hide()
    if not blizzard then ns.Skin:Shell(frame, "transparent") end
    UI.frame = frame

    local pageX = blizzard and ACTIVE_LAYOUT.pageX or 216
    if blizzard then
        local bookShell = CreateFrame("Frame", nil, frame, "PortraitFrameTemplate")
        bookShell:SetPoint("TOPLEFT", pageX, 0)
        bookShell:SetSize(ACTIVE_LAYOUT.shellWidth or ACTIVE_LAYOUT.pageWidth, WINDOW_HEIGHT)
        bookShell:SetFrameLevel(frame:GetFrameLevel() + 1)
        if bookShell.PortraitContainer then
            bookShell.PortraitContainer:Show()
            -- PortraitFrameTemplate normally keeps its medallion above the
            -- NineSlice. Our explicitly raised shell border needs the same
            -- ordering restored so its top edge cannot cut through the icon.
            bookShell.PortraitContainer:SetFrameLevel(frame:GetFrameLevel() + 21)
            local portrait = bookShell.PortraitContainer.portrait
                or bookShell.PortraitContainer.Portrait
                or bookShell.portrait
                or bookShell.Portrait
            if portrait then
                portrait:SetTexture("Interface\\Icons\\INV_Misc_Book_17")
                portrait:SetTexCoord(0.07, 0.93, 0.07, 0.93)
            end
        end
        if bookShell.TitleContainer then bookShell.TitleContainer:Hide() end
        if bookShell.CloseButton then bookShell.CloseButton:Hide() end
        ns.Skin:Shell(bookShell)
        if bookShell._cutsceneBlizzardShellBorder then
            bookShell._cutsceneBlizzardShellBorder:SetFrameLevel(frame:GetFrameLevel() + 15)
        end
        MakeDraggable(bookShell, frame)
        UI.bookShell = bookShell
    end
    local header = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    if blizzard then
        -- The one-page facade begins directly beneath the outer metal title
        -- bar. The stock two-page attic is intentionally omitted.
        header:SetPoint("TOPLEFT", pageX, -ACTIVE_LAYOUT.chromeTop)
        header:SetSize(ACTIVE_LAYOUT.pageWidth, 1)
        header:Hide()
    else
        header:SetPoint("TOPLEFT", pageX, -8); header:SetPoint("TOPRIGHT", -18, -8); header:SetHeight(52)
    end
    if not blizzard then
        if ns.Skin.HeaderStrip then ns.Skin:HeaderStrip(header) elseif ns.Skin.JournalPage then ns.Skin:JournalPage(header) else ns.Skin:Panel(header, "panel") end
    end
    MakeDraggable(header, frame)
    local controlParent = blizzard and frame or header
    local close = CreateButton(controlParent, "", 24, function() frame:Hide() end)
    close:SetSize(24, 24)
    if blizzard then
        close:SetPoint("TOPRIGHT", UI.bookShell, "TOPRIGHT", -4, -2)
        close:SetFrameLevel(frame:GetFrameLevel() + 20)
    else
        close:SetPoint("RIGHT", -26, 0)
    end
    ns.Skin:CloseButton(close, true)

    local settingsButton = CreateFrame("Button", nil, controlParent)
    settingsButton:SetSize(20, 20)
    settingsButton:SetPoint("RIGHT", close, "LEFT", -4, 0)
    settingsButton:SetFrameLevel(frame:GetFrameLevel() + 20)
    local settingsIcon = settingsButton:CreateTexture(nil, "ARTWORK")
    settingsIcon:SetAtlas("questlog-icon-setting")
    settingsIcon:SetAllPoints()
    local settingsHighlight = settingsButton:CreateTexture(nil, "HIGHLIGHT")
    settingsHighlight:SetAtlas("questlog-icon-setting")
    settingsHighlight:SetAllPoints(); settingsHighlight:SetBlendMode("ADD"); settingsHighlight:SetAlpha(0.45)
    settingsButton:SetScript("OnClick", function() if ns.OpenSettings then ns:OpenSettings() end end)
    settingsButton:SetScript("OnEnter", function(button)
        GameTooltip:SetOwner(button, "ANCHOR_BOTTOMRIGHT")
        GameTooltip:SetText("Memories of Azeroth settings")
        GameTooltip:Show()
    end)
    settingsButton:SetScript("OnLeave", function() GameTooltip:Hide() end)

    if blizzard then
        local titleLayer = CreateFrame("Frame", nil, frame)
        titleLayer:SetPoint("TOPLEFT", UI.bookShell, "TOPLEFT")
        titleLayer:SetPoint("TOPRIGHT", UI.bookShell, "TOPRIGHT")
        titleLayer:SetHeight(ACTIVE_LAYOUT.chromeTop)
        titleLayer:SetFrameLevel(frame:GetFrameLevel() + 20)
        UI.windowTitle = titleLayer:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        UI.windowTitle:SetPoint("TOP", titleLayer, "TOP", 0, -7)
        UI.windowTitle:SetText("Memories of Azeroth")
        UI.titleLayer = titleLayer
    end

    local journal = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    if blizzard then journal:SetFrameLevel(frame:GetFrameLevel() + 5) end
    if blizzard then
        journal:SetPoint("TOPLEFT", pageX, -ACTIVE_LAYOUT.pageTop)
        journal:SetSize(ACTIVE_LAYOUT.pageWidth, ACTIVE_LAYOUT.pageHeight)
    else
        journal:SetPoint("TOPLEFT", pageX, -68); journal:SetPoint("BOTTOMRIGHT", -18, 38)
    end
    if ns.Skin.JournalPage then ns.Skin:JournalPage(journal) else ns.Skin:Panel(journal, "inset") end
    MakeDraggable(journal, frame)
    UI.journal = journal
    UI.expansionButtons = {}
    local railParent = frame
    if blizzard then
        local railClip = CreateFrame("Frame", nil, frame)
        railClip:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, -ACTIVE_LAYOUT.chromeTop)
        railClip:SetPoint("BOTTOMRIGHT", frame, "BOTTOMLEFT", pageX + 12, 0)
        railClip:SetFrameLevel(frame:GetFrameLevel() + 2)
        if railClip.SetClipsChildren then railClip:SetClipsChildren(true) end
        UI.railClip = railClip
        railParent = railClip
    end
    for index, expansion in ipairs(ns:GetExpansionChoices()) do
        local button = CreateButton(railParent, SHORT_NAMES[expansion.key] or expansion.name, blizzard and ACTIVE_LAYOUT.railWidth or 208, function(selfButton)
            UI:SelectExpansion(selfButton.expansionKey)
        end)
        if blizzard then
            button:SetHeight(ACTIVE_LAYOUT.railTabHeight)
            button:SetPoint("TOPLEFT", ACTIVE_LAYOUT.railX, -(ACTIVE_LAYOUT.railTop - ACTIVE_LAYOUT.chromeTop) - (index - 1) * (ACTIVE_LAYOUT.railTabHeight + ACTIVE_LAYOUT.railTabGap))
        else
            button:SetHeight(34); button:SetPoint("TOPLEFT", 20, -80 - (index - 1) * 36)
        end
        button:GetFontString():SetJustifyH("LEFT"); button:GetFontString():SetWordWrap(false)
        button:GetFontString():ClearAllPoints(); button:GetFontString():SetPoint("LEFT", blizzard and 12 or 14, 0); button:GetFontString():SetPoint("RIGHT", blizzard and -37 or -43, 0)
        if button:GetFontString().SetMaxLines then button:GetFontString():SetMaxLines(1) end
        button.count = button:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        button.count:SetPoint("RIGHT", blizzard and -10 or -20, 0); button.count:SetWidth(26); button.count:SetJustifyH("RIGHT")
        ns.Skin:Text(button.count, "muted")
        button.expansionKey, button.expansionName, button.bookmarkVariant = expansion.key, expansion.name, ((index - 1) % 4) + 1
        if ns.Skin.Bookmark then ns.Skin:Bookmark(button, button.bookmarkVariant, false, expansion.key) else ns.Skin:AddButtonMark(button) end
        if not blizzard and ns.Skin.HasExpansionEar and ns.Skin:HasExpansionEar(expansion.key) then
            button:GetFontString():Hide()
        end
        UI.expansionButtons[expansion.key] = button
    end

    UI.sectionTitle = journal:CreateFontString(nil, "OVERLAY", blizzard and "SystemFont_Huge2" or "GameFontNormalLarge")
    UI.sectionTitle:SetPoint("TOPLEFT", blizzard and ACTIVE_LAYOUT.contentLeft or 25, blizzard and -39 or -18); UI.sectionTitle:SetJustifyH("LEFT"); ns.Skin:Text(UI.sectionTitle)
    if blizzard and ns.Skin.PageHeading then ns.Skin:PageHeading(journal, UI.sectionTitle) end
    UI.resultCount = journal:CreateFontString(nil, "OVERLAY", blizzard and "SystemFont_Med1" or "GameFontHighlightSmall")
    UI.resultCount:SetPoint("TOPLEFT", UI.sectionTitle, "BOTTOMLEFT", 0, blizzard and -1 or -3); ns.Skin:Text(UI.resultCount, blizzard and "text" or "muted")
    if blizzard and ns.Skin.SetFontRole then ns.Skin:SetFontRole(UI.resultCount, "meta") end
    local search = CreateFrame("EditBox", nil, journal, "BackdropTemplate")
    search:SetSize(blizzard and ACTIVE_LAYOUT.searchWidth or 244, blizzard and ACTIVE_LAYOUT.searchHeight or 27)
    search:SetPoint("TOPRIGHT", blizzard and -ACTIVE_LAYOUT.contentRight or -27, blizzard and -37 or -22); search:SetAutoFocus(false); search:SetMaxLetters(80)
    search:SetFontObject("GameFontHighlightSmall"); search:SetTextInsets(26, 6, 0, 0); ns.Skin:EditBox(search); UI.search = search
    local searchIcon = search:CreateTexture(nil, "ARTWORK")
    searchIcon:SetAtlas("common-search-magnifyingglass"); searchIcon:SetSize(14, 14); searchIcon:SetPoint("LEFT", 7, 0)
    search.placeholder = search:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    search.placeholder:SetPoint("LEFT", 26, 0); search.placeholder:SetText("Search…")
    if not blizzard then ns.Skin:Text(search.placeholder, "muted") end
    search:SetScript("OnTextChanged", function() UI:Refresh() end)
    search:SetScript("OnEscapePressed", function(box)
        local text = box:GetText(); if issecretvalue and issecretvalue(text) then return end
        if text ~= "" then box:SetText("") end; box:ClearFocus()
    end)
    MakeDraggable(search, frame)
    local divider = journal:CreateTexture(nil, "ARTWORK")
    if blizzard then
        -- PageHeading owns Blizzard's native spellbook-divider artwork.
        divider:Hide()
    else
        divider:SetPoint("TOPLEFT", 24, -61); divider:SetPoint("TOPRIGHT", -24, -61); divider:SetHeight(1)
        if ns.Skin.InkRule then ns.Skin:InkRule(divider) else ns.Skin:Accent(divider) end
    end
    UI.patchButtons = {}
    UI.patchRules = {}

    local listScroll = CreateSlimScroll(journal)
    MakeDraggable(listScroll, frame)
    if blizzard then
        listScroll:SetPoint("TOPLEFT", ACTIVE_LAYOUT.contentLeft, -160)
        listScroll:SetPoint("BOTTOMRIGHT", -ACTIVE_LAYOUT.contentRight, ACTIVE_LAYOUT.contentBottom)
    else
        listScroll:SetPoint("TOPLEFT", 25, -104); listScroll:SetPoint("BOTTOMRIGHT", -38, 44)
    end
    listScroll.slider:SetPoint("TOPLEFT", listScroll, "TOPRIGHT", 8, 0); listScroll.slider:SetPoint("BOTTOMLEFT", listScroll, "BOTTOMRIGHT", 8, 0)
    listScroll.onScroll = function() UI:UpdateVisibleCards() end
    listScroll:SetScript("OnSizeChanged", function()
        if C_Timer then C_Timer.After(0, function() UI:RefreshCards(UI.movies or {}) end) end
    end)
    UI.listScroll, UI.cards = listScroll, {}
    for index = 1, POOL_ROWS * CARD_COLUMNS do UI.cards[index] = CreateMovieCard(listScroll.content, index) end
    UI.empty = journal:CreateFontString(nil, "OVERLAY", blizzard and "SystemFont_Med1" or "GameFontHighlight")
    UI.empty:SetPoint("CENTER", listScroll, "CENTER"); UI.empty:SetText("No memories match this page."); ns.Skin:Text(UI.empty, "muted")
    UI.status = journal:CreateFontString(nil, "OVERLAY", blizzard and "SystemFont_Med1" or "GameFontHighlightSmall")
    UI.status:SetPoint("BOTTOMLEFT", blizzard and ACTIVE_LAYOUT.contentLeft or 28, blizzard and 27 or 18); UI.status:SetPoint("RIGHT", blizzard and -ACTIVE_LAYOUT.contentRight or -28, 0)
    UI.status:SetJustifyH("LEFT")
    ns.Skin:Text(UI.status, "muted")
    if blizzard and ns.Skin.SetFontRole then
        ns.Skin:SetFontRole(UI.empty, "meta")
        ns.Skin:SetFontRole(UI.status, "meta")
    end
    UI:SetStatus("", "muted")
    UI.selectedExpansion = ns.db.selectedExpansion or "all"
    UI.selectedPatch = ns.db.selectedPatchByExpansion[UI.selectedExpansion] or "all"
    RegisterEscapeFrame("MemoriesOfAzerothFrame")
    UI:Refresh()
end
