local _, ns = ...

-- Blizzard exposes movie playback by numeric ID but no catalogue API. Every
-- entry here is curated client data; unknown movies seen during play are kept
-- separately in SavedVariables so they can still appear in All Movies.
ns.EXPANSIONS = {
    { key = "classic", name = "Classic" },
    { key = "burning_crusade", name = "The Burning Crusade" },
    { key = "wrath", name = "Wrath of the Lich King" },
    { key = "cataclysm", name = "Cataclysm" },
    { key = "mists", name = "Mists of Pandaria" },
    { key = "warlords", name = "Warlords of Draenor" },
    { key = "legion", name = "Legion" },
    { key = "bfa", name = "Battle for Azeroth" },
    { key = "shadowlands", name = "Shadowlands" },
    { key = "dragonflight", name = "Dragonflight" },
    { key = "war_within", name = "The War Within" },
    { key = "midnight", name = "Midnight" },
}

ns.CATALOG = {
    { id = 2, expansion = "classic", title = "Seasons of War", context = "World of Warcraft cinematic trailer" },
    { id = 27, expansion = "burning_crusade", title = "The Burning Crusade", context = "Expansion cinematic trailer" },
    { id = 18, expansion = "wrath", title = "Wrath of the Lich King", context = "Expansion cinematic trailer" },
    { id = 14, expansion = "wrath", title = "The Wrathgate", context = "Dragonblight" },
    { id = 16, expansion = "wrath", title = "Fall of the Lich King", context = "Icecrown Citadel" },
    { id = 23, expansion = "cataclysm", title = "Cataclysm", context = "Expansion cinematic trailer" },
    { id = 21, expansion = "cataclysm", title = "Worgen Introduction", context = "Gilneas" },
    { id = 22, expansion = "cataclysm", title = "Goblin Introduction", context = "Kezan" },
    { id = 73, expansion = "cataclysm", title = "Dragon Soul: Act I", context = "Dragon Soul" },
    { id = 74, expansion = "cataclysm", title = "Dragon Soul: Act II", context = "Dragon Soul" },
    { id = 75, expansion = "cataclysm", title = "Dragon Soul: Act III", context = "Dragon Soul" },
    { id = 76, expansion = "cataclysm", title = "Dragon Soul: Finale", context = "Dragon Soul" },
    { id = 115, expansion = "mists", title = "Mists of Pandaria", context = "Expansion cinematic trailer" },
    { id = 116, expansion = "mists", title = "A New Fate", context = "The Jade Forest" },
    { id = 117, expansion = "mists", title = "Risking It All", context = "The Jade Forest" },
    { id = 118, expansion = "mists", title = "Battle of Serpent's Heart", context = "The Jade Forest" },
    { id = 119, expansion = "mists", title = "Alliance Introduction", context = "Pandaria campaign" },
    { id = 120, expansion = "mists", title = "Horde Introduction", context = "Pandaria campaign" },
    { id = 121, expansion = "mists", title = "Hellscream's Fist Crashes", context = "Honeydew Village, The Jade Forest" },
    { id = 127, expansion = "mists", title = "Arrival of the Alliance Fleet", context = "Pandaria campaign" },
    { id = 128, expansion = "mists", title = "Arrival of the Horde Fleet", context = "Pandaria campaign" },
    { id = 151, expansion = "mists", title = "Horde Finale", context = "Siege of Orgrimmar" },
    { id = 152, expansion = "mists", title = "Alliance Finale", context = "Siege of Orgrimmar" },
    { id = 195, expansion = "warlords", title = "Warlords of Draenor", context = "Expansion cinematic intro" },
    { id = 185, expansion = "warlords", title = "Dark Portal Introduction", context = "Tanaan Jungle" },
    { id = 187, expansion = "warlords", title = "Dark Portal Finale", context = "Tanaan Jungle" },
    { id = 167, expansion = "warlords", title = "Shadowmoon Valley Finale", context = "Shadowmoon Valley" },
    { id = 168, expansion = "warlords", title = "Frostfire Ridge Finale", context = "Frostfire Ridge" },
    { id = 188, expansion = "warlords", title = "Talador Finale", context = "Talador" },
    { id = 177, expansion = "warlords", title = "Nagrand Finale", context = "Nagrand" },
    { id = 189, expansion = "warlords", title = "Horde Garrison: Outpost to Level 1", context = "Garrison construction" },
    { id = 190, expansion = "warlords", title = "Horde Garrison: Level 1 to 2", context = "Garrison construction" },
    { id = 191, expansion = "warlords", title = "Horde Garrison: Level 2 to 3", context = "Garrison construction" },
    { id = 192, expansion = "warlords", title = "Alliance Garrison: Outpost to Level 1", context = "Garrison construction" },
    { id = 193, expansion = "warlords", title = "Alliance Garrison: Level 1 to 2", context = "Garrison construction" },
    { id = 194, expansion = "warlords", title = "Alliance Garrison: Level 2 to 3", context = "Garrison construction" },
    { id = 270, expansion = "warlords", title = "The Legendary Quest Continues", context = "Legendary ring campaign" },
    { id = 292, expansion = "warlords", title = "Horde Shipyard Construction", context = "Garrison shipyard" },
    { id = 293, expansion = "warlords", title = "Alliance Shipyard Construction", context = "Garrison shipyard" },
    { id = 294, expansion = "warlords", title = "Gul'dan's Plan", context = "Tanaan campaign" },
    { id = 295, expansion = "warlords", title = "Victory on Draenor", context = "Tanaan campaign" },
    { id = 470, expansion = "legion", title = "Legion", context = "Expansion cinematic trailer" },
    { id = 469, expansion = "legion", title = "Demon Hunter: Sacrifice", context = "Demon hunter introduction" },
    { id = 471, expansion = "legion", title = "Demon Hunter: Return", context = "Demon hunter introduction" },
    { id = 472, expansion = "legion", title = "Val'sharah Epilogue", context = "Ysera's death and cleansing" },
    { id = 473, expansion = "legion", title = "Val'sharah Introduction", context = "Xavius corrupts Ysera" },
    { id = 474, expansion = "legion", title = "Fate of the Horde", context = "Broken Shore" },
    { id = 475, expansion = "legion", title = "Stormheim Epilogue", context = "Stormheim" },
    { id = 476, expansion = "legion", title = "Harbingers: Gul'dan", context = "Harbingers" },
    { id = 477, expansion = "legion", title = "Harbingers: Khadgar", context = "Harbingers" },
    { id = 478, expansion = "legion", title = "Harbingers: Illidan", context = "Harbingers" },
    { id = 483, expansion = "legion", title = "Stormheim: Alliance", context = "Greymane's Gambit" },
    { id = 484, expansion = "legion", title = "Stormheim: Horde", context = "Greymane's Gambit" },
    { id = 485, expansion = "legion", title = "Suramar Introduction", context = "Thalyssra recounts Suramar's fall" },
    { id = 486, expansion = "legion", title = "The Broken Shore Introduction", context = "Assault on the Tomb of Sargeras" },
    { id = 487, expansion = "legion", title = "The Broken Shore: Alliance", context = "Varian Wrynn's last stand" },
    { id = 488, expansion = "legion", title = "The Broken Shore: Horde", context = "Vol'jin orders the Horde retreat" },
    { id = 489, expansion = "legion", title = "Khadgar and Light's Heart", context = "A Falling Star" },
    { id = 490, expansion = "legion", title = "Illidan's Demise", context = "Light's Heart quest line" },
    { id = 549, expansion = "legion", title = "The Nighthold Finale", context = "The Nighthold" },
    { id = 635, expansion = "legion", title = "The Tomb of Sargeras Trailer", context = "Patch 7.2" },
    { id = 636, expansion = "legion", title = "A Found Memento: Raising a King", context = "The King's Path" },
    { id = 637, expansion = "legion", title = "Opening the Tomb", context = "Broken Shore" },
    { id = 656, expansion = "legion", title = "Tomb of Sargeras Finale", context = "Tomb of Sargeras" },
    { id = 677, expansion = "legion", title = "The Battle for Argus Begins", context = "Argus campaign" },
    { id = 679, expansion = "legion", title = "Rejection of the Gift", context = "Argus campaign" },
    { id = 681, expansion = "legion", title = "Alleria's Transformation", context = "Seat of the Triumvirate" },
    { id = 686, expansion = "legion", title = "Argus the Unmaker Introduction", context = "Antorus, the Burning Throne" },
    { id = 688, expansion = "legion", title = "Antorus Finale", context = "Antorus, the Burning Throne" },
    { id = 716, expansion = "legion", title = "Alliance Epilogue", context = "Legion epilogue" },
    { id = 717, expansion = "legion", title = "Horde Epilogue", context = "Legion epilogue" },
    { id = 852, expansion = "bfa", title = "Battle for Azeroth", context = "Expansion cinematic trailer" },
    { id = 853, expansion = "bfa", title = "Warbringers: Sylvanas", context = "Warbringers" },
    { id = 854, expansion = "bfa", title = "Lordaeron Throne Room: Alliance", context = "Battle for Lordaeron" },
    { id = 855, expansion = "bfa", title = "Lordaeron Throne Room: Horde", context = "Battle for Lordaeron" },
    { id = 856, expansion = "bfa", title = "Turn the Tide", context = "Battle for Lordaeron" },
    { id = 857, expansion = "bfa", title = "Arrival in Zandalar: Horde", context = "Zandalar campaign" },
    { id = 858, expansion = "bfa", title = "Arrival in Zandalar: Alliance", context = "Zandalar campaign" },
    { id = 859, expansion = "bfa", title = "Arrival in Kul Tiras", context = "Kul Tiras campaign" },
    { id = 860, expansion = "bfa", title = "Jaina's Nightmare", context = "Kul Tiras campaign" },
    { id = 861, expansion = "bfa", title = "Warbringers: Jaina", context = "Warbringers" },
    { id = 862, expansion = "bfa", title = "Rastakhan's Deal with Bwonsamdi", context = "Zandalar campaign" },
    { id = 863, expansion = "bfa", title = "The Threat Within: Zandalar", context = "Zandalar campaign" },
    { id = 864, expansion = "bfa", title = "The Return of Hope: Kul Tiras", context = "Kul Tiras campaign" },
    { id = 865, expansion = "bfa", title = "Realm of Torment", context = "Kul Tiras campaign" },
    { id = 871, expansion = "bfa", title = "Warbringers: Sylvanas — Short Version", context = "Warbringers" },
    { id = 874, expansion = "bfa", title = "Terror of Darkshore", context = "Darkshore warfront" },
    { id = 884, expansion = "bfa", title = "Warbringers: Azshara", context = "Warbringers" },
    { id = 875, expansion = "bfa", title = "Death of Rastakhan", context = "Battle of Dazar'alor" },
    { id = 876, expansion = "bfa", title = "Battle of Dazar'alor Introduction", context = "Battle of Dazar'alor" },
    { id = 879, expansion = "bfa", title = "An Unexpected Reunion", context = "War Campaign" },
    { id = 883, expansion = "bfa", title = "Rise of Azshara Now Live: Alliance", context = "Rise of Azshara" },
    { id = 885, expansion = "bfa", title = "The Crossroads", context = "Rise of Azshara" },
    { id = 886, expansion = "bfa", title = "The Eternal Palace Finale", context = "The Eternal Palace" },
    { id = 894, expansion = "bfa", title = "Rise of Azshara Now Live: Horde", context = "Rise of Azshara" },
    { id = 903, expansion = "bfa", title = "Negotiations", context = "War Campaign" },
    { id = 904, expansion = "bfa", title = "Reckoning", context = "War Campaign" },
    { id = 926, expansion = "bfa", title = "Ny'alotha Finale", context = "Ny'alotha, the Waking City" },
    { id = 927, expansion = "bfa", title = "Ny'alotha: Wrathion's Scene", context = "Ny'alotha, the Waking City" },
    { id = 928, expansion = "bfa", title = "Visions of N'Zoth Introduction", context = "Patch 8.3" },
    { id = 936, expansion = "shadowlands", title = "Shadowlands", context = "Expansion cinematic trailer" },
    { id = 895, expansion = "shadowlands", title = "Exile's Reach: Alliance Introduction", context = "New player experience" },
    { id = 931, expansion = "shadowlands", title = "Exile's Reach: Horde Introduction", context = "New player experience" },
    { id = 932, expansion = "shadowlands", title = "Afterlives: Bastion", context = "Afterlives" },
    { id = 933, expansion = "shadowlands", title = "Afterlives: Revendreth", context = "Afterlives" },
    { id = 934, expansion = "shadowlands", title = "Afterlives: Maldraxxus", context = "Afterlives" },
    { id = 935, expansion = "shadowlands", title = "Afterlives: Ardenweald", context = "Afterlives" },
    { id = 937, expansion = "shadowlands", title = "Dark Abduction", context = "Shadowlands campaign" },
    { id = 941, expansion = "shadowlands", title = "Ysera Reborn", context = "Ardenweald" },
    { id = 942, expansion = "shadowlands", title = "For Teldrassil", context = "Shadowlands campaign" },
    { id = 943, expansion = "shadowlands", title = "Beyond the Veil", context = "Shadowlands campaign" },
    { id = 944, expansion = "shadowlands", title = "Remember This Lesson", context = "Revendreth" },
    { id = 945, expansion = "shadowlands", title = "Breaking the Arbiter", context = "Oribos" },
    { id = 946, expansion = "shadowlands", title = "Enter the Maw", context = "The Maw" },
    { id = 947, expansion = "shadowlands", title = "No More Lies", context = "Shadowlands campaign" },
    { id = 948, expansion = "shadowlands", title = "Sylvanas' Choice", context = "Shadowlands campaign" },
    { id = 949, expansion = "shadowlands", title = "Kingsmourne", context = "Chains of Domination" },
    { id = 950, expansion = "shadowlands", title = "Sanctum Finale", context = "Sanctum of Domination" },
    { id = 951, expansion = "shadowlands", title = "Battle for Ardenweald Finale", context = "Chains of Domination" },
    { id = 953, expansion = "shadowlands", title = "By Our Hand", context = "Chains of Domination" },
    { id = 954, expansion = "shadowlands", title = "Pelagos' Ascension", context = "Zereth Mortis" },
    { id = 955, expansion = "shadowlands", title = "Shattered Legacies", context = "Zereth Mortis" },
    { id = 956, expansion = "shadowlands", title = "Anduin Finale", context = "Sepulcher of the First Ones" },
    { id = 957, expansion = "shadowlands", title = "The Jailer Introduction", context = "Sepulcher of the First Ones" },
    { id = 958, expansion = "shadowlands", title = "The Jailer's Fall", context = "Sepulcher of the First Ones" },
    { id = 960, expansion = "dragonflight", title = "Dragonflight", context = "Expansion cinematic trailer" },
    { id = 961, expansion = "dragonflight", title = "Raszageth's Escape", context = "Vault of the Incarnates" },
    { id = 962, expansion = "dragonflight", title = "A Tempest Unleashed", context = "Dragonflight campaign" },
    { id = 963, expansion = "dragonflight", title = "A Seed of Hope", context = "Dragonflight campaign" },
    { id = 964, expansion = "dragonflight", title = "Raszageth's Demise", context = "Vault of the Incarnates" },
    { id = 965, expansion = "dragonflight", title = "The Storm-Eater's Fury", context = "Raszageth confronts Alexstrasza" },
    { id = 966, expansion = "dragonflight", title = "Legacies: Chapter One", context = "Dragonflight Legacies" },
    { id = 967, expansion = "dragonflight", title = "Legacies: Chapter Two", context = "Dragonflight Legacies" },
    { id = 968, expansion = "dragonflight", title = "Legacies: Chapter Three", context = "Dragonflight Legacies" },
    { id = 969, expansion = "dragonflight", title = "Dracthyr Dream Introduction", context = "Dracthyr starting experience" },
    { id = 973, expansion = "dragonflight", title = "Take to the Skies", context = "Dragon Isles" },
    { id = 974, expansion = "dragonflight", title = "Secrets of the Reach", context = "Forbidden Reach" },
    { id = 979, expansion = "dragonflight", title = "Opening the Way", context = "Dragon Isles" },
    { id = 980, expansion = "dragonflight", title = "A Symbol of Hope", context = "Embers of Neltharion" },
    { id = 981, expansion = "dragonflight", title = "The Burning of Loamm", context = "Zaralek Cavern" },
    { id = 991, expansion = "dragonflight", title = "Dawn of the Infinite: Interlude", context = "Dawn of the Infinite" },
    { id = 992, expansion = "dragonflight", title = "Dawn of the Infinite: Finale", context = "Dawn of the Infinite" },
    { id = 993, expansion = "dragonflight", title = "A Matter of Time", context = "The Bronze Enclave" },
    { id = 1001, expansion = "dragonflight", title = "Fury Incarnate", context = "Dragonflight campaign" },
    { id = 1002, expansion = "dragonflight", title = "Crown of Flame", context = "Amirdrassil" },
    { id = 1003, expansion = "dragonflight", title = "Blessing of Amirdrassil", context = "Amirdrassil" },
    { id = 1009, expansion = "dragonflight", title = "Iridikron and Vyranoth Parlay", context = "Dragonflight epilogue" },
    { id = 1010, expansion = "war_within", title = "Dalaran Teleports to Khaz Algar", context = "Visions of Azeroth" },
    { id = 1012, expansion = "war_within", title = "The Destruction of Dalaran", context = "The War Within opening campaign" },
    { id = 1013, expansion = "war_within", title = "Magni's Sacrifice", context = "Visions of Azeroth" },
    { id = 1014, expansion = "war_within", title = "The War Within", context = "Expansion cinematic trailer" },
    { id = 1019, expansion = "war_within", title = "Alleria Confronts Xal'atath", context = "Khaz Algar campaign" },
    { id = 1020, expansion = "war_within", title = "Kingdoms Reborn", context = "Earthen unification finale" },
    { id = 1021, expansion = "war_within", title = "Threads of Destiny", context = "Making of a Monster" },
    { id = 1023, expansion = "war_within", title = "Shadows Beneath", context = "Khaz Algar campaign" },
    { id = 1024, expansion = "war_within", title = "The Story So Far", context = "Campaign recap" },
    { id = 1028, expansion = "war_within", title = "The Boardroom", context = "Undermine(d) opening" },
    { id = 1029, expansion = "war_within", title = "A New Tomorrow", context = "Undermine epilogue" },
    { id = 1030, expansion = "war_within", title = "Orweyna's Worldsoul Vision", context = "Khaz Algar campaign" },
    { id = 1033, expansion = "war_within", title = "The Summoning of Dimensius", context = "Ghosts of K'aresh introduction" },
    { id = 1034, expansion = "war_within", title = "Manaforge Omega Finale", context = "Manaforge Omega" },
    { id = 1040, expansion = "war_within", title = "Voidstorm Unleashed", context = "The War Within campaign" },
    { id = 1045, expansion = "war_within", title = "Xal'atath Confronts Gallywix", context = "Undermine(d) epilogue" },
    { id = 1052, expansion = "war_within", title = "The War Within Recap", context = "Patch 11.2.7" },
    { id = 1053, expansion = "war_within", title = "The War Within: Alternate Cut", context = "Expansion cinematic alternate edit" },
    { id = 1047, expansion = "midnight", title = "Turalyon Strikes Down Arator", context = "Midnight campaign" },
    { id = 1048, expansion = "midnight", title = "Sunwell Introduction", context = "Midnight campaign" },
    { id = 1049, expansion = "midnight", title = "The Fall", context = "Midnight campaign" },
    { id = 1050, expansion = "midnight", title = "March on Quel'Danas Finale", context = "Isle of Quel'Danas campaign" },
    { id = 1051, expansion = "midnight", title = "Midnight", context = "Expansion cinematic" },
    { id = 1059, expansion = "bfa", title = "Safe Haven", context = "Saurfang seeks Thrall in Nagrand" },
    { id = 1061, expansion = "midnight", title = "Xal'atath's Supremacy", context = "Midnight campaign" },
    { id = 1062, expansion = "midnight", title = "Xal'atath and Arator", context = "Midnight campaign" },
    { id = 1063, expansion = "midnight", title = "The Legacy of the Amani Intro", context = "History Lesson" },
    { id = 1064, expansion = "midnight", title = "The Might of Ula'tek", context = "Curse of Ula'tek" },
    { id = 1065, expansion = "midnight", title = "The Defeat of Ula'tek", context = "The Venomous Abyss raid finale" },
    { id = 1066, expansion = "midnight", title = "Midnight: The Story So Far", context = "Pre-expansion story recap" },
}

local expansionNames, expansionOrder, catalogByID = {}, {}, {}
for index, expansion in ipairs(ns.EXPANSIONS) do
    expansionNames[expansion.key] = expansion.name
    expansionOrder[expansion.key] = index
end
for _, movie in ipairs(ns.CATALOG) do
    catalogByID[movie.id] = movie
end

local KIND_LABELS = {
    archive = "Archive",
    construction = "Construction",
    raid = "Raid",
    recap = "Recap",
    short = "Animated short",
    story = "Story",
    trailer = "Trailer",
    variant = "Alternate edit",
}

local function BuildDescription(movie)
    local override = ns.MOVIE_DESCRIPTION_OVERRIDES and ns.MOVIE_DESCRIPTION_OVERRIDES[movie.id]
    if type(override) == "string" and override ~= "" then return override end

    local context = movie.context
    if movie.kind == "trailer" then
        if string.find(string.lower(context), "expansion cinematic", 1, true) then
            return "The cinematic that introduced " .. (expansionNames[movie.expansion] or movie.title) .. "."
        end
        return "A cinematic trailer for " .. context .. "."
    elseif movie.kind == "raid" then
        return "A pre-rendered raid chapter connected to " .. context .. "."
    elseif movie.kind == "short" then
        return "An animated cinematic chapter from " .. context .. "."
    elseif movie.kind == "recap" then
        return "A concise cinematic recap connected to " .. context .. "."
    elseif movie.kind == "variant" then
        return "An alternate cinematic edit connected to " .. context .. "."
    elseif movie.kind == "construction" then
        return "A brief construction cinematic from " .. context .. "."
    elseif movie.kind == "archive" then
        return "A pre-rendered client movie preserved in the cinematic archive."
    end
    return "A pre-rendered story chapter connected to " .. context .. "."
end

-- Enrich the compact base catalogue from the separately maintained chronology.
-- This keeps titles and playback IDs readable while still making every record
-- self-contained for UI consumers.
for expansionKey, groups in pairs(ns.PATCH_GROUPS or {}) do
    for patchOrder, group in ipairs(groups) do
        for storyOrder, tuple in ipairs(group.movies or {}) do
            local movie = catalogByID[tuple[1]]
            if movie then
                movie.patch = group.key
                movie.patchLabel = group.name
                movie.patchOrder = patchOrder
                movie.storyOrder = storyOrder
                movie.kind = tuple[2] or "story"
                movie.kindLabel = KIND_LABELS[movie.kind] or movie.kind
                movie.sourcePatch = ns.MOVIE_SOURCE_PATCHES and ns.MOVIE_SOURCE_PATCHES[movie.id] or nil
                movie.description = BuildDescription(movie)
            end
        end
    end
end

function ns:GetExpansionName(key)
    if key == "all" then return "All Movies" end
    if key == "favorites" then return "Favorites" end
    if key == "discovered" then return "Discovered" end
    return expansionNames[key] or key
end

function ns:IsExpansionKey(key)
    return key == "all" or key == "favorites" or expansionNames[key] ~= nil
end

function ns:GetExpansionChoices()
    local result = { { key = "all", name = "All Movies" } }
    for _, expansion in ipairs(self.EXPANSIONS) do result[#result + 1] = expansion end
    result[#result + 1] = { key = "favorites", name = "Favorites" }
    return result
end

function ns:IsMovieFavorite(movieID)
    return self:IsSafeNumber(movieID)
        and self.db
        and type(self.db.favorites) == "table"
        and self.db.favorites[movieID] == true
end

function ns:SetMovieFavorite(movieID, favorite)
    if not self.db or type(self.db.favorites) ~= "table"
        or not self:IsSafeNumber(movieID) or movieID <= 0 or movieID ~= math.floor(movieID) then
        return false
    end
    if favorite then self.db.favorites[movieID] = true else self.db.favorites[movieID] = nil end
    if self.UI and self.UI.Refresh then self.UI:Refresh() end
    return favorite == true
end

function ns:ToggleMovieFavorite(movieID)
    return self:SetMovieFavorite(movieID, not self:IsMovieFavorite(movieID))
end

function ns:GetPatchName(expansionKey, patchKey)
    if patchKey == "all" or patchKey == nil then return "All patches" end
    for _, group in ipairs((self.PATCH_GROUPS and self.PATCH_GROUPS[expansionKey]) or {}) do
        if group.key == patchKey then return group.name end
    end
    return patchKey
end

function ns:IsPatchKey(expansionKey, patchKey)
    if patchKey == "all" then return true end
    for _, group in ipairs((self.PATCH_GROUPS and self.PATCH_GROUPS[expansionKey]) or {}) do
        if group.key == patchKey then return true end
    end
    return false
end

function ns:GetPatchChoices(expansionKey)
    local choices = { { key = "all", name = "All patches", count = 0 } }
    local groups = self.PATCH_GROUPS and self.PATCH_GROUPS[expansionKey]
    if type(groups) ~= "table" then return choices end

    for _, group in ipairs(groups) do
        local count = #(group.movies or {})
        choices[1].count = choices[1].count + count
        choices[#choices + 1] = { key = group.key, name = group.name, count = count }
    end
    return choices
end

function ns:GetDiscoveredMovies()
    local result = {}
    local discovered = self.db and self.db.discovered or {}
    for movieID, record in pairs(discovered) do
        if self:IsSafeNumber(movieID) and movieID > 0 and type(record) == "table" then
            local curated = catalogByID[movieID]
            local title = curated and curated.title
                or (self:IsSafeString(record.title) and record.title)
                or ("Movie " .. movieID)
            result[#result + 1] = {
                id = movieID,
                expansion = "discovered",
                title = title,
                context = curated and self:GetExpansionName(curated.expansion) or "Seen by this account",
                patch = curated and curated.patch or "discovered",
                patchLabel = curated and curated.patchLabel or "Discovered",
                patchOrder = curated and curated.patchOrder or 999,
                storyOrder = curated and curated.storyOrder or movieID,
                kind = curated and curated.kind or "archive",
                kindLabel = curated and curated.kindLabel or "Discovered",
                description = curated and curated.description or "A movie this account has encountered in the game client.",
                discovered = true,
                known = curated ~= nil,
            }
        end
    end
    table.sort(result, function(left, right) return left.id < right.id end)
    return result
end

local function Contains(haystack, needle)
    if needle == "" then return true end
    return string.find(string.lower(haystack or ""), needle, 1, true) ~= nil
end

local function Trim(text)
    return string.match(text or "", "^%s*(.-)%s*$") or ""
end

function ns:GetFilteredMovies(expansionKey, patchKey, searchText)
    expansionKey = type(expansionKey) == "string" and expansionKey or "all"
    -- Backward compatibility for the original (expansionKey, searchText) API.
    if searchText == nil then
        searchText = patchKey
        patchKey = "all"
    end
    patchKey = type(patchKey) == "string" and patchKey or "all"
    searchText = self:IsSafeString(searchText) and string.lower(Trim(searchText)) or ""
    local source = {}
    for _, movie in ipairs(self.CATALOG) do source[#source + 1] = movie end
    if expansionKey == "all" or expansionKey == "favorites" then
        for _, movie in ipairs(self:GetDiscoveredMovies()) do
            if not movie.known then source[#source + 1] = movie end
        end
    end

    local result = {}
    for _, movie in ipairs(source) do
        local inExpansion = expansionKey == "all"
            or (expansionKey == "favorites" and self:IsMovieFavorite(movie.id))
            or movie.expansion == expansionKey
        local inPatch = patchKey == "all" or movie.patch == patchKey
        local matches = Contains(movie.title, searchText)
            or Contains(movie.context, searchText)
            or Contains(movie.description, searchText)
            or Contains(movie.patchLabel, searchText)
            or Contains(movie.kindLabel, searchText)
            or Contains(self:GetExpansionName(movie.expansion), searchText)
            or Contains(tostring(movie.id), searchText)
        if inExpansion and inPatch and matches then result[#result + 1] = movie end
    end
    table.sort(result, function(left, right)
        local leftOrder = expansionOrder[left.expansion] or 999
        local rightOrder = expansionOrder[right.expansion] or 999
        if leftOrder ~= rightOrder then return leftOrder < rightOrder end
        local leftPatch, rightPatch = left.patchOrder or 999, right.patchOrder or 999
        if leftPatch ~= rightPatch then return leftPatch < rightPatch end
        local leftStory, rightStory = left.storyOrder or 999, right.storyOrder or 999
        if leftStory ~= rightStory then return leftStory < rightStory end
        if left.title ~= right.title then return left.title < right.title end
        return left.id < right.id
    end)
    return result
end

function ns:ValidateCatalog()
    local knownExpansions, ids = {}, {}
    for _, expansion in ipairs(self.EXPANSIONS) do knownExpansions[expansion.key] = true end
    for index, movie in ipairs(self.CATALOG) do
        if type(movie.id) ~= "number" or movie.id <= 0 or movie.id ~= math.floor(movie.id) then
            return false, "Invalid movie ID at entry " .. index
        end
        if ids[movie.id] then return false, "Duplicate movie ID " .. movie.id end
        if not knownExpansions[movie.expansion] then return false, "Unknown expansion at movie ID " .. movie.id end
        if type(movie.title) ~= "string" or movie.title == "" then return false, "Missing title at movie ID " .. movie.id end
        if type(movie.context) ~= "string" or movie.context == "" then return false, "Missing context at movie ID " .. movie.id end
        if type(movie.patch) ~= "string" or movie.patch == "" then return false, "Missing patch at movie ID " .. movie.id end
        if type(movie.patchLabel) ~= "string" or movie.patchLabel == "" then return false, "Missing patch label at movie ID " .. movie.id end
        if type(movie.patchOrder) ~= "number" or movie.patchOrder <= 0 then return false, "Missing patch order at movie ID " .. movie.id end
        if type(movie.storyOrder) ~= "number" or movie.storyOrder <= 0 then return false, "Missing story order at movie ID " .. movie.id end
        if type(movie.kind) ~= "string" or not KIND_LABELS[movie.kind] then return false, "Invalid kind at movie ID " .. movie.id end
        if type(movie.description) ~= "string" or movie.description == "" then return false, "Missing description at movie ID " .. movie.id end
        ids[movie.id] = true
    end
    return true
end
