--## Author: Christian ## Version: 0.7.0 ## SavedVariables: WeeklyAltTrackerDB
local WAT = {}

-- Lokalisierung fuer WeeklyAltTracker.
--
-- Zwei vollstaendige Roh-Woerterbuecher: deDE und enUS. enUS ist der Fallback
-- fuer jede andere, fehlende, fehlerhafte oder unlesbare Clientsprache. Die
-- Werte enthalten bewusst keine Farbcodes und kein dekoratives Unicode - Farbe
-- und Layout bleiben Sache der UI, damit ein Uebersetzungswert nie Markup
-- beschaedigen kann. Platzhalter sind ausschliesslich nicht-positional; die
-- Reihenfolge der Argumente ist damit Teil des Vertrags und wird vom
-- Runtime-Test als Multiset gegen das jeweils andere Woerterbuch geprueft.
--
-- Namen aus der WoW-API (Klasse, Dungeon, Gegenstand, Beruf, Erfolg) stehen
-- hier absichtlich NICHT. Die werden zur Laufzeit clientlokalisiert bezogen.

local WATL = {}
WAT.WATL = WATL

local enUS = {
    -- Panels
    ADDON_NAME = "WeeklyAltTracker",
    PANEL_OVERVIEW = "Overview",
    PANEL_OVERVIEW_SHORT = "OVERVIEW",
    PANEL_OVERVIEW_DESC = "Characters, item level, Twilight Crests, Great Vault and M+10 at a glance.",
    PANEL_MIDNIGHT = "Midnight Week",
    PANEL_MIDNIGHT_SHORT = "MIDNIGHT WEEK",
    PANEL_MIDNIGHT_DESC = "Weekly quest, hunts and ritual sites for every recorded character.",
    PANEL_PROFESSIONS = "Professions",
    PANEL_PROFESSIONS_SHORT = "PROFESSIONS",
    PANEL_PROFESSIONS_DESC = "Midnight skill, free knowledge points, bag knowledge, weeklies and treatises.",
    PANEL_SOURCES = "Crest Sources",
    PANEL_SOURCES_SHORT = "CREST SOURCES",
    PANEL_SOURCES_DESC = "Weekly, seasonal, repeatable and indirect sources without raid.",
    PANEL_KEYSTONES = "Keystones",
    PANEL_KEYSTONES_SHORT = "KEYSTONES",
    PANEL_KEYSTONES_DESC = "Currently owned Mythic+ keystone as an offline snapshot per character.",
    PANEL_STATISTICS = "Statistics",
    PANEL_STATISTICS_SHORT = "STATISTICS",
    PANEL_STATISTICS_DESC = "Lifetime achievement statistics per character plus the account total.",
    PANEL_SETTINGS = "Settings",
    PANEL_SETTINGS_SHORT = "SETTINGS",
    PANEL_SETTINGS_DESC = "Window, scale and minimap button. Every option lives here.",

    -- Spaltenkoepfe
    COL_CHARACTER = "CHARACTER",
    COL_LEVEL = "LVL",
    COL_ITEM_LEVEL = "ILVL",
    COL_GILDED = "GILDED\nSTASH",
    COL_CRESTS = "TWILIGHT CRESTS\nC / H / M",
    COL_WORLD_VAULT = "DELVES VAULT",
    COL_MYTHIC_VAULT = "M+ VAULT",
    COL_MYTHIC10 = "M+10\n272 ILVL",
    COL_UPDATED = "LAST UPDATE",
    COL_WEEKLY_QUEST = "MIDNIGHT WEEKLY",
    COL_PREY = "HUNT\nNORMAL / HARD / NIGHTMARE",
    COL_RITUAL = "RITUAL SITES",
    COL_DATA_AGE = "DATA AGE",
    COL_PROFESSION1 = "PROFESSION 1",
    COL_PROFESSION2 = "PROFESSION 2",
    COL_SKILL = "SKILL",
    COL_KNOWLEDGE = "FREE / BAGS",
    COL_WEEK = "WEEK",
    COL_TREATISE = "TREATISE",
    COL_GILDED_WEEKLY = "GILDED STASH\nWEEKLY",
    COL_HEROIC_SHOWDOWN = "HEROIC\nSHOWDOWNS",
    COL_CRACKED = "CRACKED\nKEYSTONE",
    COL_NULLAEUS = "NULLAEUS T11\nSEASON BONUS",
    COL_RITUAL_FARM = "RITUAL T6\nREPEATABLE",
    COL_MYTHIC_FARM = "M+\nREPEATABLE",
    COL_EXCHANGE = "HERO to MYTH\nEXCHANGEABLE",
    COL_DUNDUN = "DUNDUN",
    COL_DUNGEON = "DUNGEON",
    COL_KEYSTONE_LEVEL = "LEVEL",

    -- Statistiken: kurzer Spaltenkopf und ausgeschriebener Name. Der Name ist
    -- nur der Ersatz - bevorzugt zeigt der Tooltip den clientlokalisierten
    -- Namen aus GetAchievementInfo.
    STAT_COL_DELVES = "DELVES",
    STAT_COL_DELVES_MIDNIGHT = "DELVES\nMIDNIGHT",
    STAT_COL_DEATHS = "DEATHS",
    STAT_COL_DEATHS_DUNGEON = "DEATHS\nDUNGEON",
    STAT_COL_DEATHS_RAID = "DEATHS\nRAID",
    STAT_COL_DEATHS_FALLING = "DEATHS\nFALLING",
    STAT_COL_QUESTS = "QUESTS",
    STAT_COL_QUESTS_DAILY = "QUESTS\nDAILY",
    STAT_COL_QUESTS_ABANDONED = "QUESTS\nABANDONED",
    STAT_COL_HEALTHSTONES = "HEALTH-\nSTONES",
    STAT_COL_DUNGEONS = "DUNGEONS\nENTERED",
    STAT_COL_DUNGEONS_MIDNIGHT = "DUNGEONS\nMIDNIGHT",
    STAT_COL_PLAYTIME = "PLAYTIME",
    STAT_NAME_DELVES = "Delves completed",
    STAT_NAME_DELVES_MIDNIGHT = "Midnight delves completed",
    STAT_NAME_DEATHS = "Total deaths",
    STAT_NAME_DEATHS_DUNGEON = "Deaths in dungeons",
    STAT_NAME_DEATHS_RAID = "Deaths in raids",
    STAT_NAME_DEATHS_FALLING = "Deaths from falling",
    STAT_NAME_QUESTS = "Quests completed",
    STAT_NAME_QUESTS_DAILY = "Daily quests completed",
    STAT_NAME_QUESTS_ABANDONED = "Quests abandoned",
    STAT_NAME_HEALTHSTONES = "Healthstones used",
    STAT_NAME_DUNGEONS = "5-player dungeons entered",
    STAT_NAME_DUNGEONS_MIDNIGHT = "Midnight dungeons (final boss kills)",
    STAT_NAME_PLAYTIME = "Total playtime",
    -- Blizzard counts this statistic on entering a dungeon, not on finishing
    -- it. The tooltip has to say so; a short column head cannot.
    STAT_TIP_DUNGEONS = "Counts 5-player dungeons entered, not completed.",
    STAT_TIP_DUNGEONS_MIDNIGHT = "Sum of the final boss kills in the eight Midnight dungeons across Normal, Heroic and Mythic. If a single part is unreadable, the whole sum stays unknown.",
    STAT_TIP_PLAYTIME = "Total playtime of this character as reported by the client.",
    DURATION_UNIT_DAYS = "d",
    DURATION_UNIT_HOURS = "h",
    DURATION_UNIT_MINUTES = "m",
    -- Kurzformen fuer die Kompaktdarstellung grosser Zellenwerte. Sie stehen
    -- NUR in der Tabellenzelle; der Tooltip nennt immer den vollen Wert.
    NUMBER_UNIT_THOUSAND = "K",
    NUMBER_UNIT_MILLION = "M",
    NUMBER_UNIT_BILLION = "B",
    NUMBER_UNIT_TRILLION = "T",
    STAT_SCOPE_TOTAL = "TOTAL",
    STAT_GROUP_CONTENT = "CONTENT",
    STAT_GROUP_SURVIVAL = "SURVIVAL",
    STAT_GROUP_QUESTS = "QUESTS",
    STAT_ACCOUNT_TOTAL = "ALL CHARACTERS",
    STAT_ACCOUNT_TOOLTIP = "Account total",
    STAT_ACCOUNT_HINT = "Sum of all known character values. Characters without a recorded value are not counted.",
    STAT_RECORDED = "Recorded",
    STAT_NOT_RECORDED = "not recorded yet",
    STAT_OFFLINE_HINT = "Lifetime values. They are updated the next time this character logs in.",

    -- Status
    STATUS_DONE = "done",
    STATUS_READY_TO_TURN_IN = "Ready to turn in",
    STATUS_TURNED_IN = "Turned in",
    STATUS_OPEN = "open",
    STATUS_UNKNOWN = "unknown",
    STATUS_STALE_WEEK = "old week",
    STATUS_ACTIVE = "active",
    STATUS_NOT_ACTIVE = "not active",
    STATUS_NOT_TRACKED = "not tracked",
    STATUS_YES = "Yes",
    STATUS_OPEN_CAPITAL = "Open",
    STATUS_LOCKED = "locked",
    STATUS_UNLOCKED = "unlocked",
    STATUS_VARIANT_UNKNOWN = "variant unknown",
    STATUS_CURRENT = "current",

    -- Zeit
    TIME_JUST_NOW = "just now",
    TIME_MINUTES = "%d min",
    TIME_HOURS = "%d h",
    DATE_FORMAT_SHORT = "%m/%d %H:%M",

    -- Wappen
    CREST_CHAMPION = "Champion",
    CREST_HERO = "Hero",
    CREST_MYTH = "Myth",
    CREST_GENERIC = "Twilight Crest",
    CREST_TOOLTIP_LABEL = "%s Twilight Crest",
    CREST_WEEK_SUFFIX = " / week %d/%d",

    -- Jagd
    HUNT_SHORT_NORMAL = "N",
    HUNT_SHORT_HARD = "H",
    HUNT_SHORT_NIGHTMARE = "NM",
    HUNT_NORMAL = "Hunt - Normal",
    HUNT_HARD = "Hunt - Hard",
    HUNT_NIGHTMARE = "Hunt - Nightmare",

    -- Midnight-Meta-Weekly. Eigene beschreibende Kurzlabels, keine
    -- Questnamen aus dem Client.
    META_QUEST_93766 = "World Quests",
    META_QUEST_93767 = "Arcantina",
    META_QUEST_93769 = "Housing",
    META_QUEST_93889 = "Saltheril's Soiree",
    META_QUEST_93890 = "Abundance",
    META_QUEST_93891 = "Legends of the Haranir",
    META_QUEST_93892 = "Stormarium Assault",
    META_QUEST_93909 = "Delves",
    META_QUEST_93910 = "Hunts",
    META_QUEST_93911 = "Dungeons",
    META_QUEST_93912 = "Raid Weekly",
    META_QUEST_93913 = "World Boss",
    META_QUEST_94457 = "Battlegrounds",
    META_QUEST_95842 = "Void Assaults",
    META_QUEST_95843 = "Ritual Sites",

    -- Schatzkammer
    VAULT_NO_DATA = "No Great Vault data recorded yet.",
    VAULT_SLOT_LINE = "Slot %d: %s/%s / %s %s / %s / %s %s",
    VAULT_LEVEL_LABEL_WORLD = "Tier",
    VAULT_LEVEL_LABEL_MYTHIC = "+",
    REWARD_ITEM_LEVEL = "Item Level",
    REWARD_ITEM_LEVEL_UP_TO = "up to Item Level",
    REWARD_LEVEL_GENERIC = "Reward Level",

    -- Uebersichts-Tooltip
    TOOLTIP_CLASS = "Class",
    TOOLTIP_EQUIPPED_ILVL = "Equipped Item Level",
    TOOLTIP_WEEK_STATE = "Week status",
    TOOLTIP_WEEK_STALE = "old week - log in this character",
    TOOLTIP_WORLD_VAULT = "Delves / World Vault",
    TOOLTIP_MYTHIC_VAULT = "M+ Vault",
    TOOLTIP_MYTHIC10 = "M+10 or higher",
    MYTHIC10_YES = "Yes - 272 reward level reached",
    MYTHIC10_NO = "Open - no +10 or higher completed yet",
    GILDED_STASH = "Gilded Stash",
    GILDED_NOT_SEEN = "not yet recorded in a delve",

    -- Midnight-Tooltip
    TOOLTIP_MIDNIGHT_WEEKLY = "Midnight Weekly Quest",
    RITUAL_SITES = "Ritual Sites",
    RITUAL_DONE = "100% / done",

    -- Berufe
    PROF_HEADER = "Profession %d: %s",
    PROF_MIDNIGHT_SKILL = "Midnight Skill",
    PROF_FREE_KNOWLEDGE = "Free Knowledge Points",
    PROF_BAG_KNOWLEDGE = "Knowledge Points in Bags",
    PROF_BAG_FROM_ITEMS = "%d from %d items",
    PROF_BAG_COUNT_UNKNOWN = "%d / count unknown",
    PROF_KNOWLEDGE_DETAIL = "  %s x%d = %d knowledge",
    PROF_WEEKLY_QUEST = "Profession Weekly",
    PROF_TREATISE = "Thalassian Treatise",
    PROF_PROGRESS_RECORDED = "Progress recorded",
    ITEM_FALLBACK = "Item %d",
    ITEM_UNKNOWN = "Unknown item",

    -- Wappenquellen
    SRC_GILDED_WEEKLY = "Gilded Stash - weekly",
    SRC_GILDED_VALUE = "%d/%d / %d Myth per stash",
    SRC_SHOWDOWN = "Heroic Showdowns - weekly",
    SRC_SHOWDOWN_VALUE = "%d/%d / %d Myth per slot",
    SRC_SHOWDOWN_MAIN = "Main slot",
    SRC_SHOWDOWN_FOLLOWUP = "Follow-up slot",
    SRC_SHOWDOWN_SLOT_DONE = "done / %s",
    SRC_SHOWDOWN_SLOT_ACTIVE = "active / %s",
    SRC_SHOWDOWN_QUEST_FALLBACK = "Quest %d",
    SRC_CRACKED = "Cracked Keystone - once",
    SRC_CRACKED_DONE = "done / %d Myth + %d Hero",
    SRC_NULLAEUS = "Nullaeus T11 - once",
    SRC_NULLAEUS_DONE = "done / %d Myth",
    SRC_NULLAEUS_OPEN = "open / %d Myth",
    SRC_RITUAL_T6 = "Ritual Sites T6 - repeatable",
    SRC_RITUAL_T6_VALUE = "%d Myth per completion",
    SRC_MYTHIC = "Mythic+ - repeatable",
    SRC_MYTHIC_OBSERVED = "+%d observed / Myth from +9",
    SRC_MYTHIC_GENERIC = "Myth crests from +9",
    SRC_EXCHANGE = "Hero to Myth - indirect",
    SRC_EXCHANGE_LOCKED = "locked / achievement '%s' missing",
    SRC_EXCHANGE_LOCKED_GENERIC = "locked / achievement missing",
    SRC_EXCHANGE_POTENTIAL = "%d Myth exchangeable from current Hero stock",
    SRC_EXCHANGE_UNLOCKED_UNKNOWN = "unlocked / stock unknown",
    SRC_FOOTNOTE = "Repeatable sources are not a retroactive weekly counter. Only safely observable data is shown.",
    -- Dundun-Splitter: ein Offline-Ressourcen-Snapshot, kein Wochenwert.
    DUNDUN_NAME_FALLBACK = "Shard of Dundun",
    DUNDUN_SCOPE = "Scope",
    DUNDUN_SCOPE_ACCOUNT = "account-wide",
    DUNDUN_SCOPE_CHARACTER = "character-specific",
    DUNDUN_OFFLINE_NOTE = "Offline resource snapshot - not a completed weekly source.",
    EASTER_EGG_DUNDUN = "Panra holds the line, Cataline keeps him in the Light - Dundun doesn't stand a chance.",
    CELL_RITUAL_FARM = "%d M per T6",
    CELL_MYTHIC_FARMABLE = "+%d / farmable",
    CELL_MYTHIC_MIN = "+%d / from +9",
    CELL_MYTHIC_FROM9 = "from +9",
    CELL_EXCHANGE_POTENTIAL = "%d M exchangeable",
    CELL_SEASONAL_DONE = "done / +%d M",
    CELL_SEASONAL_ACTIVE = "active / +%d M",
    CELL_SEASONAL_OPEN = "open / +%d M",

    -- Schluesselsteine
    KEY_KEYSTONE = "Keystone",
    KEY_NONE = "no keystone",
    KEY_DUNGEON = "Dungeon",
    KEY_DUNGEON_ID = "Dungeon ID %d",
    KEY_MAP_ID = "Challenge Map ID",
    KEY_LEVEL = "Level",
    KEY_RECORDED = "Recorded",

    -- Fensterrahmen
    CHROME_EYEBROW = "ACCOUNT-WIDE WEEKLY PROGRESS",
    CHROME_SIDEBAR_HEADING = "SECTIONS",
    CHROME_SIDEBAR_HINT = "/wat  /  movable window",
    CHROME_REFRESH = "REFRESH",
    CHROME_TOOLBAR = "Character comparison / hover a row for details",
    CHROME_TOOLBAR_COUNT = "%d CHARACTERS  /  hover a row for details",
    CHROME_TOOLBAR_SETTINGS = "Settings apply immediately and are stored account-wide",
    CHROME_LEGEND = "Green: done  /  Yellow: in progress  /  Red: open  /  Grey: unknown or old week",
    TOOLTIP_OFFLINE_HINT = "Offline data updates the next time this character logs in.",
    TOOLTIP_DRAG_REORDER = "Drag to reorder characters",
    MINIMAP_LEFTCLICK = "Left click: open or close",
    MINIMAP_DRAG = "Drag: change position",

    -- Einstellungsseite
    SETTINGS_HEADING_WINDOW = "Window",
    SETTINGS_HEADING_MINIMAP = "Minimap button",
    SETTINGS_HEADING_SCALE = "Window scale",
    SETTINGS_REFRESH = "Refresh now",
    SETTINGS_RESET_POSITION = "Reset position",
    SETTINGS_WINDOW_DESC = "Reads the character you are logged in on again and moves the window back to the centre of the screen.",
    SETTINGS_MINIMAP_SHOW = "Visible",
    SETTINGS_MINIMAP_HIDE = "Hidden",
    SETTINGS_MINIMAP_DESC = "The addon window always stays reachable through /wat, even without the minimap button.",
    SETTINGS_SCALE_PERCENT = "%d%%",
    SETTINGS_SCALE_DESC = "Applies immediately and is stored account-wide for every character.",

    -- Chat und Slash-Befehle. Die Tokens selbst bleiben unveraendert.
    -- Ohne Pipes: WoW liest |h und |r im Chat als Hyperlink- bzw.
    -- Farbcode-Escape und zerlegt die Zeile sonst sichtbar.
    SLASH_HELP = "All options live in the Settings area of /wat.",
    SLASH_DEBUG = "Char=%s | Stash=%s/%s | Crests C/H/M=%s/%s/%s | Keystone=%s | Week ends=%s",

    CHARACTER_UNKNOWN = "Unknown",
}

local zhCN = {
    -- Panels
    ADDON_NAME = "小号统计",
    PANEL_OVERVIEW = "总览",
    PANEL_OVERVIEW_DESC = "角色, 装等, 暮光试炼, 低保 和 M+10 一览",
    PANEL_MIDNIGHT = "周常",
    PANEL_MIDNIGHT_DESC = "周常任务, 每个角色的狩猎和仪式场地",
    PANEL_PROFESSIONS = "专业",
    PANEL_PROFESSIONS_DESC = "专业技能、可用知识点、背包知识、周常和论文。",
    PANEL_SOURCES = "纹章",
    PANEL_SOURCES_DESC = "不含团本的周常、赛季、可重复和各种来源。",
    PANEL_KEYSTONES = "钥石",
    PANEL_KEYSTONES_DESC = "离线记录每个角色当前持有的 Mythic+ 钥石。",
    PANEL_STATISTICS = "统计",
    PANEL_STATISTICS_DESC = "查看各角色终身成就统计以及账号总计。",
    PANEL_SETTINGS = "设置",
    PANEL_SETTINGS_DESC = "窗口、缩放选项都在这里。",

    -- Spaltenkoepfe
    COL_CHARACTER = CHARACTER,
    COL_LEVEL = "等级",
    COL_ITEM_LEVEL = "装等",
    COL_GILDED = "鎏金\n藏匿物",
    COL_CRESTS = "暮光试炼\nC / H / M",
    COL_WORLD_VAULT = "宝库\n(世界)",
    COL_MYTHIC_VAULT = "宝库\n(地下城)",
    COL_MYTHIC10 = "M+10\n272装等",
    COL_UPDATED = "上次刷新",
    COL_WEEKLY_QUEST = "至暗之夜周常",
    COL_PREY = "狩猎\n普通 / 困难 / 梦魇",
    COL_RITUAL = "仪式场地",
    COL_DATA_AGE = "数据时间",
    COL_PROFESSION1 = "专业 1",
    COL_PROFESSION2 = "专业 2",
    COL_SKILL = "技能",
    COL_KNOWLEDGE = "可用 / 背包",
    COL_WEEK = "周常",
    COL_TREATISE = "论文",
    COL_GILDED_WEEKLY = "鎏金储藏\n每周",
    COL_HEROIC_SHOWDOWN = "HEROISCHE\nSHOWDOWNS",
    COL_CRACKED = "破裂\n钥石",
    COL_NULLAEUS = "NULLAEUS T11\n赛季奖励",
    COL_RITUAL_FARM = "仪式 T6\n可重复",
    COL_MYTHIC_FARM = "M+\n可重复",
    COL_EXCHANGE = "英雄换神话\n可兑换",
    COL_DUNDUN = "顿顿碎片",
    COL_DUNGEON = "地下城",
    COL_KEYSTONE_LEVEL = "层数",

    -- Statistiken: kurzer Spaltenkopf und ausgeschriebener Name. Der Name ist
    -- nur der Ersatz - bevorzugt zeigt der Tooltip den clientlokalisierten
    -- Namen aus GetAchievementInfo.
    STAT_COL_DELVES = "地下堡",
    STAT_COL_DELVES_MIDNIGHT = "地下堡\n至暗之夜",
    STAT_COL_DEATHS = "死亡",
    STAT_COL_DEATHS_DUNGEON = "死亡\n地下城",
    STAT_COL_DEATHS_RAID = "死亡\n团本",
    STAT_COL_DEATHS_FALLING = "死亡\n坠落",
    STAT_COL_QUESTS = "任务",
    STAT_COL_QUESTS_DAILY = "任务\n日常",
    STAT_COL_QUESTS_ABANDONED = "任务\n放弃",
    STAT_COL_HEALTHSTONES = "治疗\n石",
    STAT_COL_DUNGEONS = "地下城\n进入",
    STAT_COL_DUNGEONS_MIDNIGHT = "地下城\n至暗之夜",
    STAT_COL_PLAYTIME = "游戏时间",
    STAT_NAME_DELVES = "已完成地下堡",
    STAT_NAME_DELVES_MIDNIGHT = "已完成至暗之夜地下堡",
    STAT_NAME_DEATHS = "总死亡次数",
    STAT_NAME_DEATHS_DUNGEON = "地下城死亡次数",
    STAT_NAME_DEATHS_RAID = "团本死亡次数",
    STAT_NAME_DEATHS_FALLING = "坠落死亡次数",
    STAT_NAME_QUESTS = "已完成任务数",
    STAT_NAME_QUESTS_DAILY = "已完成日常任务数",
    STAT_NAME_QUESTS_ABANDONED = "已放弃任务数",
    STAT_NAME_HEALTHSTONES = "已使用治疗石",
    STAT_NAME_DUNGEONS = "已进入 5 人地下城",
    STAT_NAME_DUNGEONS_MIDNIGHT = "至暗之夜地下城（尾王击杀）",
    STAT_NAME_PLAYTIME = "总游戏时间",
    -- Blizzard counts this statistic on entering a dungeon, not on finishing
    -- it. The tooltip has to say so; a short column head cannot.
    STAT_TIP_DUNGEONS = "该统计记录的是进入过的 5 人地下城，而不是完成的地下城。",
    STAT_TIP_DUNGEONS_MIDNIGHT = "统计八个至暗之夜地下城在普通、英雄和史诗难度下的尾王击杀总和。只要其中任意一项无法读取，总和就保持未知。",
    STAT_TIP_PLAYTIME = "这是客户端报告的该角色总游戏时间。",
    DURATION_UNIT_DAYS = "天",
    DURATION_UNIT_HOURS = "小时",
    DURATION_UNIT_MINUTES = "分钟",
    -- Kurzformen fuer die Kompaktdarstellung grosser Zellenwerte. Sie stehen
    -- NUR in der Tabellenzelle; der Tooltip nennt immer den vollen Wert.
    NUMBER_UNIT_THOUSAND = "K",
    NUMBER_UNIT_MILLION = "M",
    NUMBER_UNIT_BILLION = "B",
    NUMBER_UNIT_TRILLION = "T",
    STAT_SCOPE_TOTAL = "总计",
    STAT_GROUP_CONTENT = "内容",
    STAT_GROUP_SURVIVAL = "生存",
    STAT_GROUP_QUESTS = "任务",
    STAT_ACCOUNT_TOTAL = "全部角色",
    STAT_ACCOUNT_TOOLTIP = "账号总计",
    STAT_ACCOUNT_HINT = "这是所有已知角色数值的总和。没有记录到数值的角色不会被计入。",
    STAT_RECORDED = "记录时间",
    STAT_NOT_RECORDED = "尚未记录",
    STAT_OFFLINE_HINT = "这些是终身统计数值，会在该角色下次登录时更新。",

    -- Status
    STATUS_DONE = "完成",
    STATUS_READY_TO_TURN_IN = "可交付",
    STATUS_TURNED_IN = "已交付",
    STATUS_OPEN = "未完成",
    STATUS_UNKNOWN = "未知",
    STATUS_STALE_WEEK = "上周数据",
    STATUS_ACTIVE = "进行中",
    STATUS_NOT_ACTIVE = "未激活",
    STATUS_NOT_TRACKED = "未追踪",
    STATUS_YES = "是",
    STATUS_OPEN_CAPITAL = "未完成",
    STATUS_LOCKED = "未解锁",
    STATUS_UNLOCKED = "已解锁",
    STATUS_VARIANT_UNKNOWN = "类型未知",
    STATUS_CURRENT = "当前",

    -- Zeit
    TIME_JUST_NOW = "刚刚",
    TIME_MINUTES = "%d 分钟",
    TIME_HOURS = "%d 小时",
    DATE_FORMAT_SHORT = "%m/%d %H:%M",

    -- Wappen
    CREST_CHAMPION = "勇士",
    CREST_HERO = "英雄",
    CREST_MYTH = "神话",
    CREST_GENERIC = "暮光纹章",
    CREST_TOOLTIP_LABEL = "%s 暮光纹章",
    CREST_WEEK_SUFFIX = " / 本周 %d/%d",

    -- Jagd
    HUNT_SHORT_NORMAL = "普",
    HUNT_SHORT_HARD = "难",
    HUNT_SHORT_NIGHTMARE = "梦",
    HUNT_NORMAL = "狩猎 - 普通",
    HUNT_HARD = "狩猎 - 困难",
    HUNT_NIGHTMARE = "狩猎 - 梦魇",

    -- Midnight-Meta-Weekly
    META_QUEST_93766 = "世界任务",
    META_QUEST_93767 = "奥术之庭",
    META_QUEST_93769 = "住房",
    META_QUEST_93889 = "萨瑟里尔晚会",
    META_QUEST_93890 = "丰饶",
    META_QUEST_93891 = "哈拉尼尔传说",
    META_QUEST_93892 = "风暴圣堂突袭",
    META_QUEST_93909 = "地下堡",
    META_QUEST_93910 = "狩猎",
    META_QUEST_93911 = "地下城",
    META_QUEST_93912 = "团本周常",
    META_QUEST_93913 = "世界首领",
    META_QUEST_94457 = "战场",
    META_QUEST_95842 = "虚空突袭",
    META_QUEST_95843 = "仪式场地",

    -- Schatzkammer
    VAULT_NO_DATA = "尚未记录宝库数据。",
    VAULT_SLOT_LINE = "槽位 %d: %s/%s / %s %s / %s / %s %s",
    VAULT_LEVEL_LABEL_WORLD = "等级",
    VAULT_LEVEL_LABEL_MYTHIC = "+",
    REWARD_ITEM_LEVEL = "物品等级",
    REWARD_ITEM_LEVEL_UP_TO = "最高物品等级",
    REWARD_LEVEL_GENERIC = "奖励等级",

    -- Uebersichts-Tooltip
    TOOLTIP_CLASS = "职业",
    TOOLTIP_EQUIPPED_ILVL = "当前装备装等",
    TOOLTIP_WEEK_STATE = "本周状态",
    TOOLTIP_WEEK_STALE = "旧周数据 - 请登录该角色更新",
    TOOLTIP_WORLD_VAULT = "地下堡 / 世界宝库",
    TOOLTIP_MYTHIC_VAULT = "M+ 宝库",
    TOOLTIP_MYTHIC10 = "M+10 或更高",
    MYTHIC10_YES = "是 - 已达到 272 奖励等级",
    MYTHIC10_NO = "未完成 - 还没有完成 10 层或更高",
    GILDED_STASH = "鎏金储藏",
    GILDED_NOT_SEEN = "尚未在地下堡中记录到",

    -- Midnight-Tooltip
    TOOLTIP_MIDNIGHT_WEEKLY = "至暗之夜周常任务",
    RITUAL_SITES = "仪式场地",
    RITUAL_DONE = "100% / 完成",

    -- Berufe
    PROF_HEADER = "专业 %d：%s",
    PROF_MIDNIGHT_SKILL = "至暗之夜技能",
    PROF_FREE_KNOWLEDGE = "可用知识点",
    PROF_BAG_KNOWLEDGE = "背包中的知识点",
    PROF_BAG_FROM_ITEMS = "%d 点，来自 %d 个物品",
    PROF_BAG_COUNT_UNKNOWN = "%d / 数量未知",
    PROF_KNOWLEDGE_DETAIL = "  %s x%d = %d 知识点",
    PROF_WEEKLY_QUEST = "专业周常",
    PROF_TREATISE = "萨拉希语论文",
    PROF_PROGRESS_RECORDED = "已记录进度",
    ITEM_FALLBACK = "物品 %d",
    ITEM_UNKNOWN = "未知物品",

    -- Wappenquellen
    SRC_GILDED_WEEKLY = "鎏金储藏 - 每周",
    SRC_GILDED_VALUE = "%d/%d / 每个储藏 %d 神话",
    SRC_SHOWDOWN = "Heroische Showdowns - wöchentlich",
    SRC_SHOWDOWN_VALUE = "%d/%d / %d Mythische je Slot",
    SRC_SHOWDOWN_MAIN = "Hauptslot",
    SRC_SHOWDOWN_FOLLOWUP = "Folgeslot",
    SRC_SHOWDOWN_SLOT_DONE = "fertig / %s",
    SRC_SHOWDOWN_SLOT_ACTIVE = "aktiv / %s",
    SRC_SHOWDOWN_QUEST_FALLBACK = "Quest %d",
    SRC_CRACKED = "破裂钥石 - 一次性",
    SRC_CRACKED_DONE = "完成 / %d 神话 + %d 英雄",
    SRC_NULLAEUS = "努雷乌斯 T11 - 一次性",
    SRC_NULLAEUS_DONE = "完成 / %d 神话",
    SRC_NULLAEUS_OPEN = "未完成 / %d 神话",
    SRC_RITUAL_T6 = "仪式场地 T6 - 可重复",
    SRC_RITUAL_T6_VALUE = "每次完成 %d 神话",
    SRC_MYTHIC = "大秘境+ - 可重复",
    SRC_MYTHIC_OBSERVED = "已观测到 +%d / 神话来源于 +9",
    SRC_MYTHIC_GENERIC = "+9 掉落神话纹章",
    SRC_EXCHANGE = "英雄换神话 - 间接来源",
    SRC_EXCHANGE_LOCKED = "未解锁 / 缺少成就“%s”",
    SRC_EXCHANGE_LOCKED_GENERIC = "未解锁 / 缺少成就",
    SRC_EXCHANGE_POTENTIAL = "当前英雄库存可兑换 %d 神话",
    SRC_EXCHANGE_UNLOCKED_UNKNOWN = "已解锁 / 库存未知",
    SRC_FOOTNOTE = "可重复来源不是追溯性的每周计数器，仅显示可以安全观测的数据。",
    -- Dundun-Splitter: ein Offline-Ressourcen-Snapshot, kein Wochenwert.
    DUNDUN_NAME_FALLBACK = "顿顿碎片",
    DUNDUN_SCOPE = "范围",
    DUNDUN_SCOPE_ACCOUNT = "账号共享",
    DUNDUN_SCOPE_CHARACTER = "角色独立",
    DUNDUN_OFFLINE_NOTE = "这是离线资源快照，不是已完成的每周来源。",
    EASTER_EGG_DUNDUN = "潘拉顶在前线，凯特琳以圣光护持——顿顿毫无胜算。",
    CELL_RITUAL_FARM = "每次 T6 得 %d 神话",
    CELL_MYTHIC_FARMABLE = "+%d / 可刷",
    CELL_MYTHIC_MIN = "+%d / 需从 +9 起",
    CELL_MYTHIC_FROM9 = "从 +9 开始",
    CELL_EXCHANGE_POTENTIAL = "可兑换 %d 神话",
    CELL_SEASONAL_DONE = "完成 / +%d 神话",
    CELL_SEASONAL_ACTIVE = "进行中 / +%d 神话",
    CELL_SEASONAL_OPEN = "未完成 / +%d 神话",

    -- Schluesselsteine
    KEY_KEYSTONE = "钥石",
    KEY_NONE = "没有钥石",
    KEY_DUNGEON = "地下城",
    KEY_DUNGEON_ID = "地下城 ID %d",
    KEY_MAP_ID = "挑战地图 ID",
    KEY_LEVEL = "层数",
    KEY_RECORDED = "记录时间",

    -- Fensterrahmen
    CHROME_EYEBROW = "账号共享每周进度",
    CHROME_SIDEBAR_HEADING = "分区",
    CHROME_SIDEBAR_HINT = "/wat  /  可移动窗口",
    CHROME_REFRESH = "刷新",
    CHROME_TOOLBAR = "角色对比 / 鼠标悬停行可查看详情",
    CHROME_TOOLBAR_COUNT = "%d 个角色  /  鼠标悬停行可查看详情",
    CHROME_TOOLBAR_SETTINGS = "设置会立即生效，并按账号保存",
    CHROME_LEGEND = "绿色：完成  /  黄色：进行中  /  红色：未完成  /  灰色：未知或旧周数据",
    TOOLTIP_OFFLINE_HINT = "离线数据会在该角色下次登录时更新。",
    TOOLTIP_DRAG_REORDER = "拖动以调整角色顺序",
    MINIMAP_LEFTCLICK = "左键：打开或关闭",
    MINIMAP_DRAG = "拖动：调整位置",

    -- Einstellungsseite
    SETTINGS_HEADING_WINDOW = "窗口",
    SETTINGS_HEADING_MINIMAP = "小地图按钮",
    SETTINGS_HEADING_SCALE = "窗口缩放",
    SETTINGS_REFRESH = "立即刷新",
    SETTINGS_RESET_POSITION = "重置位置",
    SETTINGS_WINDOW_DESC = "重新读取当前登录角色的数据，并把窗口移回屏幕中央。",
    SETTINGS_MINIMAP_SHOW = "显示",
    SETTINGS_MINIMAP_HIDE = "隐藏",
    SETTINGS_MINIMAP_DESC = "即使隐藏小地图按钮，也始终可以通过 /wat 打开插件窗口。",
    SETTINGS_SCALE_PERCENT = "%d%%",
    SETTINGS_SCALE_DESC = "设置会立即生效，并按账号保存在所有角色之间共享。",

    -- Chat und Slash-Befehle. Die Tokens selbst bleiben unveraendert.
    -- Ohne Pipes: WoW liest |h und |r im Chat als Hyperlink- bzw.
    -- Farbcode-Escape und zerlegt die Zeile sonst sichtbar.
    SLASH_HELP = "所有选项都在 /wat 的设置页面中。",
    SLASH_DEBUG = "角色=%s | 储藏=%s/%s | 纹章 C/H/M=%s/%s/%s | 钥石=%s | 周结束=%s",

    CHARACTER_UNKNOWN = UNKNOWN,
}

-- Test-API: die Roh-Woerterbuecher selbst. Bewusst keine Setter oder sonstige
-- veraenderbare oeffentliche API - der Runtime-Test greift direkt auf die
-- Tabellen zu, die Produktion liest sie nur.
WATL.dictionaries = { enUS = enUS, zhCN = zhCN }

-- Jede nicht aufgefuehrte Clientsprache landet auf enUS.
local SUPPORTED = {
    zhCN = "zhCN",
    enUS = "enUS",
    zhTW = "zhCN",
}

-- GetLocale kann fehlen, werfen, einen Secret Value oder etwas liefern, das
-- kein String ist. Jeder dieser Faelle ergibt enUS, nie einen Fehler.
local function ReadClientLocale()
    if type(GetLocale) ~= "function" then return nil end
    local ok, value = pcall(GetLocale)
    if not ok then return nil end
    if issecretvalue and issecretvalue(value) then return nil end
    if type(value) ~= "string" or value == "" then return nil end
    return value
end

local clientLocale = ReadClientLocale()
WATL.clientLocale = clientLocale
WATL.locale = (clientLocale and SUPPORTED[clientLocale]) or "enUS"

local function Lookup(key)
    if issecretvalue and issecretvalue(key) then return nil end
    if type(key) ~= "string" then return nil end
    local dictionaries = WATL.dictionaries
    if type(dictionaries) ~= "table" then return nil end
    local active = dictionaries[WATL.locale]
    local value = type(active) == "table" and active[key] or nil
    if type(value) ~= "string" then
        local fallback = dictionaries.enUS
        value = type(fallback) == "table" and fallback[key] or nil
    end
    if type(value) ~= "string" then return nil end
    return value
end

-- Unbekannte Schluessel bleiben sichtbar, brechen aber nichts ab. Ein
-- fehlgeschlagenes string.format liefert den Rohwert statt eines Fehlers.
local function L(key, ...)
    local value = Lookup(key)
    if value == nil then
        if not (issecretvalue and issecretvalue(key)) and type(key) == "string" then
            return "[" .. key .. "]"
        end
        return "[?]"
    end
    if select("#", ...) == 0 then return value end
    local ok, formatted = pcall(string.format, value, ...)
    if ok and not (issecretvalue and issecretvalue(formatted)) and type(formatted) == "string" then
        return formatted
    end
    return value
end

WAT.L = L
WATL.Get = L

_G.WeeklyAltTracker = WAT
WAT.name = L("ADDON_NAME")
WAT.version = "0.7.0"
WAT.events = CreateFrame("Frame")

local function Print(message)
    DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99WeeklyAltTracker:|r " .. tostring(message))
end
WAT.Print = Print

local function SafeNumber(value, fallback)
    if issecretvalue and issecretvalue(value) then return fallback end
    if type(value) ~= "number" then return fallback end
    return value
end
WAT.SafeNumber = SafeNumber

local function SafeString(value, fallback)
    if issecretvalue and issecretvalue(value) then return fallback end
    if type(value) ~= "string" then return fallback end
    return value
end
WAT.SafeString = SafeString

local function SafeTable(value)
    if issecretvalue and issecretvalue(value) then return nil end
    return type(value) == "table" and value or nil
end

local function SafeBoolean(value)
    if issecretvalue and issecretvalue(value) then return nil end
    return type(value) == "boolean" and value or nil
end

-- SavedVariables koennen aus einer aelteren Preview, einer manuellen Aenderung
-- oder einem frueheren Secret-Value-Fehler stammen. Der innere Snapshot wird
-- deshalb genauso defensiv normalisiert wie der Charakter selbst. Eine nicht
-- sicher lesbare Menge entwertet den gesamten Snapshot; optionale Felder werden
-- einzeln verworfen, ohne die bekannte Menge zu verlieren.
local function NormalizeDundunSnapshot(resources)
    local snapshot = SafeTable(resources.dundun)
    local quantity = snapshot and SafeNumber(snapshot.quantity)
    if quantity == nil then
        resources.dundun = nil
        return
    end

    local expectedID = WAT.Data and SafeNumber(WAT.Data.DUNDUN_CURRENCY_ID)
    local currencyID = SafeNumber(snapshot.currencyID)
    if expectedID ~= nil and currencyID ~= expectedID then currencyID = nil end
    local maximum = SafeNumber(snapshot.maxQuantity)
    if maximum ~= nil and maximum <= 0 then maximum = nil end
    local weeklyMaximum = SafeNumber(snapshot.maxWeeklyQuantity)
    if weeklyMaximum ~= nil and weeklyMaximum <= 0 then weeklyMaximum = nil end

    resources.dundun = {
        currencyID = currencyID,
        quantity = quantity,
        maxQuantity = maximum,
        quantityEarnedThisWeek = SafeNumber(snapshot.quantityEarnedThisWeek),
        maxWeeklyQuantity = weeklyMaximum,
        isAccountWide = SafeBoolean(snapshot.isAccountWide),
        isAccountTransferable = SafeBoolean(snapshot.isAccountTransferable),
        weekEnd = SafeNumber(snapshot.weekEnd),
        updated = SafeNumber(snapshot.updated),
    }
end

-- Ein brauchbarer Eintrag fuer settings.characterOrder: ein nicht-leerer,
-- sicherer String. Alles andere (Secret Value, Zahl, Tabelle, leerer String)
-- ist Muell und wird beim Normalisieren stillschweigend verworfen.
local function IsValidOrderKey(value)
    local safe = SafeString(value)
    return safe ~= nil and safe ~= ""
end

local VALID_POINTS = {
    TOPLEFT = true, TOP = true, TOPRIGHT = true,
    LEFT = true, CENTER = true, RIGHT = true,
    BOTTOMLEFT = true, BOTTOM = true, BOTTOMRIGHT = true,
}

local function NormalizeCharacter(record, oldKey)
    if issecretvalue and issecretvalue(record) then return nil end
    if type(record) ~= "table" then return nil end
    record.weekly = SafeTable(record.weekly) or {}
    record.season = SafeTable(record.season) or {}
    record.professions = SafeTable(record.professions) or {}
    -- Additiv: eine 0.2.6-Datenbank kennt noch keine Statistiken. Der Container
    -- wird nur ergaenzt, das Schema bleibt deshalb bei Version 2.
    record.statistics = SafeTable(record.statistics) or {}
    -- Additiv: Offline-Ressourcen-Snapshots (z.B. Dundun-Splitter) sind kein
    -- Wochenwert und liegen deshalb neben weekly. Ein Secret- oder Fremdtyp-
    -- Container wird verworfen, nie mit erfundenem Inhalt gefuellt.
    record.resources = SafeTable(record.resources) or {}
    NormalizeDundunSnapshot(record.resources)
    record.guid = SafeString(record.guid)
    -- Kein Ersatztext: ein unlesbarer Name bleibt nil und wird erst zur
    -- Renderzeit lokalisiert. In die SavedVariables gehoert kein Locale-Text.
    record.name = SafeString(record.name)
    record.realm = SafeString(record.realm)
    record.className = SafeString(record.className)
    record.classFile = SafeString(record.classFile)
    -- Additiv wie className/classFile: eine 0.4.x-Datenbank kennt noch keine
    -- Rasse. raceFile/raceID sind sprach- und expansion-stabil; raceName ist
    -- nur der optionale, clientlokalisierte Anzeigename.
    record.raceName = SafeString(record.raceName)
    record.raceFile = SafeString(record.raceFile)
    record.raceID = SafeNumber(record.raceID)
    record.faction = SafeString(record.faction)
    record.level = SafeNumber(record.level)
    record.itemLevel = SafeNumber(record.itemLevel)
    record.lastSeen = SafeNumber(record.lastSeen)
    record.weekEnd = SafeNumber(record.weekEnd)
    record.weekUnknown = SafeBoolean(record.weekUnknown)
    local key = record.guid or SafeString(oldKey)
    if not key or key == "" then return nil end
    record.key = key
    return record, key
end

function WAT:InitializeDatabase()
    if (issecretvalue and issecretvalue(WeeklyAltTrackerDB)) or type(WeeklyAltTrackerDB) ~= "table" then WeeklyAltTrackerDB = {} end
    local db = WeeklyAltTrackerDB
    if (issecretvalue and issecretvalue(db.characters)) or type(db.characters) ~= "table" then
        db.characters = {}
    end
    local migratedCharacters = {}
    for oldKey, raw in pairs(db.characters) do
        local character, key = NormalizeCharacter(raw, oldKey)
        if character then
            local existing = migratedCharacters[key]
            local existingSeen = existing and SafeNumber(existing.lastSeen, 0) or -1
            local candidateSeen = SafeNumber(character.lastSeen, 0)
            if not existing or candidateSeen >= existingSeen then migratedCharacters[key] = character end
        end
    end
    db.characters = migratedCharacters

    if (issecretvalue and issecretvalue(db.settings)) or type(db.settings) ~= "table" then
        db.settings = {}
    end
    local settings = db.settings
    settings.scale = SafeNumber(settings.scale, 1)
    if settings.scale < 0.7 or settings.scale > 1.5 then settings.scale = 1 end
    local position = SafeTable(settings.point) or {}
    local point = SafeString(position.point, "CENTER")
    local relativePoint = SafeString(position.relativePoint, "CENTER")
    settings.point = {
        point = VALID_POINTS[point] and point or "CENTER",
        relativePoint = VALID_POINTS[relativePoint] and relativePoint or "CENTER",
        x = SafeNumber(position.x, 0),
        y = SafeNumber(position.y, 0),
    }
    settings.minimapAngle = SafeNumber(settings.minimapAngle, 225) % 360
    -- Ein echter Boolean mit Vorgabe false. Ein kaputter oder unlesbarer Wert
    -- darf nicht zu nil kollabieren, sonst waere "sichtbar" nicht mehr von
    -- "nie eingestellt" zu unterscheiden.
    --[[local minimapHidden = settings.minimapHidden
    if (issecretvalue and issecretvalue(minimapHidden)) or type(minimapHidden) ~= "boolean" then
        minimapHidden = false
    end
    settings.minimapHidden = minimapHidden]]
    local activeTab = SafeString(settings.activeTab)
    settings.activeTab = (activeTab == "overview" or activeTab == "midnight"
        or activeTab == "professions" or activeTab == "sources" or activeTab == "keystones"
        or activeTab == "statistics" or activeTab == "settings")
        and activeTab or "overview"
    -- Additiv: eine Datenbank vor dieser Version kennt noch keine gespeicherte
    -- Charakterreihenfolge. Das Schema bleibt deshalb bei Version 2; der
    -- eigentliche Inhalt wird gleich unten durch NormalizeCharacterOrder
    -- gegen db.characters gefiltert und ergaenzt.
    settings.characterOrder = SafeTable(settings.characterOrder) or {}
    db.version = 2
    self.db = db
    self:NormalizeCharacterOrder()
end

-- Liefert die normalisierte, persistierte Charakterreihenfolge und schreibt
-- sie gleich zurueck nach db.settings.characterOrder. Sie ist die EINE Quelle
-- der Wahrheit fuer die Sortierung aller fuenf Tabellenseiten und der
-- Statistik-Charakterreiter:
--   1. bekannte, eindeutige Schluessel bleiben in der gespeicherten Reihenfolge
--   2. verwaiste, doppelte, nicht-String- oder Secret-Eintraege verschwinden
--   3. Charaktere, die noch in keiner gespeicherten Reihenfolge stehen, werden
--      deterministisch alphabetisch (Name+Realm, kleingeschrieben) angehaengt
-- Ein neuer Charakter erscheint damit vorhersagbar am Ende, statt die
-- gespeicherte Reihenfolge durcheinanderzuwuerfeln.
function WAT:NormalizeCharacterOrder()
    local db = self.db
    if type(db) ~= "table" then return {} end
    if (issecretvalue and issecretvalue(db.characters)) or type(db.characters) ~= "table" then
        db.characters = {}
    end
    local characters = db.characters
    if (issecretvalue and issecretvalue(db.settings)) or type(db.settings) ~= "table" then
        db.settings = {}
    end
    local settings = db.settings
    local saved = SafeTable(settings.characterOrder) or {}

    local order, seen = {}, {}
    for _, key in ipairs(saved) do
        if IsValidOrderKey(key) and type(characters[key]) == "table" and not seen[key] then
            order[#order + 1] = key
            seen[key] = true
        end
    end

    local missing = {}
    for key, character in pairs(characters) do
        if IsValidOrderKey(key) and not seen[key] and type(character) == "table" then
            missing[#missing + 1] = key
        end
    end
    table.sort(missing, function(a, b)
        local characterA, characterB = characters[a], characters[b]
        local nameA = string.lower(SafeString(characterA.name, "") .. SafeString(characterA.realm, ""))
        local nameB = string.lower(SafeString(characterB.name, "") .. SafeString(characterB.realm, ""))
        if nameA == nameB then return a < b end
        return nameA < nameB
    end)
    for _, key in ipairs(missing) do
        order[#order + 1] = key
        seen[key] = true
    end

    settings.characterOrder = order
    return order
end

-- Verschiebt einen Charakter tatsaechlich an die Zielposition, statt ihn nur
-- mit dem Ziel zu tauschen, und persistiert sofort. Ein Selbst-Drop oder ein
-- Schluessel, der nicht in der normalisierten Reihenfolge steht (unbekannt,
-- veraltet, GESAMT-Bereichsschluessel der Statistikseite, ...), aendert
-- nichts und liefert false - so kann ein ungueltiger Drop die Datenbank nie
-- beschaedigen.
function WAT:MoveCharacterOrder(sourceKey, targetKey)
    if not IsValidOrderKey(sourceKey) or not IsValidOrderKey(targetKey) then return false end
    if sourceKey == targetKey then return false end
    local order = self:NormalizeCharacterOrder()
    local sourceIndex, targetIndex
    for index, key in ipairs(order) do
        if key == sourceKey then sourceIndex = index end
        if key == targetKey then targetIndex = index end
    end
    if not sourceIndex or not targetIndex then return false end
    table.remove(order, sourceIndex)
    -- Ziel ist die urspruengliche Zielposition. Beim Verschieben nach oben wird
    -- davor eingefuegt; beim Verschieben nach unten ist das Ziel durch remove
    -- bereits um einen Platz nach vorn gerutscht, sodass derselbe numerische
    -- targetIndex die Quelle dahinter einfuegt. So ist auch der letzte Platz
    -- per Drag-and-drop erreichbar.
    table.insert(order, targetIndex, sourceKey)
    self.db.settings.characterOrder = order
    return true
end

-- Sprachneutraler Baustein fuer den DB-Schluessel, wenn weder GUID noch Name
-- lesbar sind. Bewusst NICHT lokalisiert: der Schluessel muss ueber alle
-- Clientsprachen hinweg derselbe bleiben, sonst entstuenden Doppeleintraege.
local UNKNOWN_KEY_PART = "Unknown"

function WAT:GetCurrentCharacterKey()
    local guid = SafeString(UnitGUID("player"))
    if guid and guid ~= "" then return guid end
    local name, realm = UnitFullName("player")
    name = SafeString(name) or SafeString(UnitName("player")) or UNKNOWN_KEY_PART
    realm = SafeString(realm) or SafeString(GetRealmName()) or UNKNOWN_KEY_PART
    return name .. "-" .. realm
end

function WAT:GetSecondsUntilReset()
    if not C_DateAndTime or not C_DateAndTime.GetSecondsUntilWeeklyReset then return nil end
    local ok, seconds = pcall(C_DateAndTime.GetSecondsUntilWeeklyReset)
    seconds = ok and SafeNumber(seconds) or nil
    if not seconds or seconds <= 0 then return nil end
    return seconds
end

function WAT:GetCurrentWeekEnd()
    local seconds = self:GetSecondsUntilReset()
    if not seconds then return nil end
    return time() + seconds
end

function WAT:IsStale(character)
    if character and character.weekUnknown then return true end
    local weekEnd = SafeNumber(character and character.weekEnd)
    return weekEnd ~= nil and time() >= weekEnd
end

function WAT:PrepareCurrentCharacter()
    local key = self:GetCurrentCharacterKey()
    local character = self.db.characters[key]
    if type(character) ~= "table" then
        character = { weekly = {} }
        self.db.characters[key] = character
    end
    if type(character.weekly) ~= "table" then character.weekly = {} end
    -- Offline-Ressourcen-Snapshots ueberleben den Wochenreset unten wie
    -- season/professions/statistics - der Container muss dafuer nur existieren.
    if type(character.resources) ~= "table" then character.resources = {} end

    local now = time()
    local currentWeekEnd = self:GetCurrentWeekEnd()
    local oldWeekEnd = SafeNumber(character.weekEnd)
    if oldWeekEnd and now >= oldWeekEnd then
        character.weekly = {}
        character.weekEnd = nil
        character.weekUnknown = true
    end
    if currentWeekEnd then
        local weekStart = currentWeekEnd - (7 * 24 * 60 * 60)
        local snapshotUpdated = SafeNumber(character.weekly.updated)
        if not character.weekEnd and next(character.weekly) ~= nil
                and (not snapshotUpdated or snapshotUpdated < weekStart) then
            character.weekly = {}
        end
        character.weekEnd = currentWeekEnd
        character.weekUnknown = nil
    elseif not character.weekEnd then
        character.weekUnknown = true
    end

    local name, realm = UnitFullName("player")
    character.key = key
    character.guid = SafeString(UnitGUID("player"), character.guid)
    character.name = SafeString(name) or SafeString(UnitName("player")) or character.name
    character.realm = SafeString(realm) or SafeString(GetRealmName()) or character.realm
    local className, classFile = UnitClass("player")
    character.className = SafeString(className, character.className)
    character.classFile = SafeString(classFile, character.classFile)
    -- raceFile/raceID sind sprach- und expansion-stabil; raceName ist nur der
    -- clientlokalisierte Anzeigename. Ein Secret- oder Fremdtyp-Wert laesst den
    -- zuletzt sicheren Stand stehen, genau wie className/classFile oben.
    local raceName, raceFile, raceID = UnitRace("player")
    character.raceName = SafeString(raceName, character.raceName)
    character.raceFile = SafeString(raceFile, character.raceFile)
    character.raceID = SafeNumber(raceID, character.raceID)
    character.faction = SafeString(UnitFactionGroup("player"), character.faction)
    local level = SafeNumber(UnitLevel("player"))
    if level ~= nil then character.level = level end
    local _, equipped = GetAverageItemLevel()
    equipped = SafeNumber(equipped)
    if equipped ~= nil then character.itemLevel = equipped end
    character.lastSeen = time()
    self.currentKey = key
    return character
end

function WAT:Refresh(reason)
    if not self.db then return end
    local character = self:PrepareCurrentCharacter()
    if self.ScanCharacter then self:ScanCharacter(character, reason) end
    -- Die Spielzeit kommt nicht aus einem Scan, sondern asynchron als Event.
    -- RequestTimePlayed entscheidet selbst, ob der Grund erlaubt und die
    -- Drosselung abgelaufen ist; hier wird nur der volle Weg angeboten.
    if self.RequestTimePlayed then self:RequestTimePlayed(reason) end
    character.lastSeen = time()
    if self.RefreshUI then self:RefreshUI() end
end

function WAT:RefreshKeystone(reason)
    if not self.db then return end
    local character = self:PrepareCurrentCharacter()
    if self.ScanKeystone then self:ScanKeystone(character, true) end
    character.lastSeen = time()
    if self.RefreshUI then self:RefreshUI() end
end

function WAT:RefreshStatistics(reason)
    if not self.db then return end
    local character = self:PrepareCurrentCharacter()
    if self.ScanStatistics then self:ScanStatistics(character, reason) end
    character.lastSeen = time()
    if self.RefreshUI then self:RefreshUI() end
end

function WAT:SaveFramePosition()
    if not self.frame or not self.db then return end
    local point, _, relativePoint, x, y = self.frame:GetPoint(1)
    self.db.settings.point = {
        point = point or "CENTER",
        relativePoint = relativePoint or "CENTER",
        x = SafeNumber(x, 0), y = SafeNumber(y, 0),
    }
end

function WAT:ResetPosition()
    if not self.frame then return end
    self.frame:ClearAllPoints()
    self.frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    self:SaveFramePosition()
end

local function HandleSlash(message)
    local command = string.match(message or "", "^%s*(%S*)")
    command = string.lower(command or "")
    if command == "" then
        if WAT.ToggleUI then WAT:ToggleUI() end
    elseif command == "debug" then
        local character = WAT.db.characters[WAT.currentKey]
        local weekly = character and character.weekly or {}
        local stash = weekly.gilded or {}
        local crests = weekly.crests or {}
        local champion = type(crests.champion) == "table" and crests.champion.quantity or nil
        local hero = type(crests.hero) == "table" and crests.hero.quantity or nil
        local myth = type(crests.myth) == "table" and crests.myth.quantity or nil
        local keystone = type(weekly.keystone) == "table" and weekly.keystone or {}
        local keystoneText = WAT.L("STATUS_UNKNOWN")
        if keystone.hasKey == false then
            keystoneText = WAT.L("KEY_NONE")
        elseif keystone.hasKey == true and type(keystone.level) == "number" then
            keystoneText = (type(keystone.dungeonName) == "string" and keystone.dungeonName
                or WAT.L("KEY_DUNGEON")) .. " +" .. keystone.level
        end
        Print(WAT.L("SLASH_DEBUG",
            character and character.name or "?", tostring(stash.current), tostring(stash.maximum),
            tostring(champion), tostring(hero), tostring(myth), keystoneText,
            character and date(WAT.L("DATE_FORMAT_SHORT"), character.weekEnd or 0) or "?"))
    else
        -- Ab 0.3.0 gibt es keine oeffentlichen Unterbefehle mehr. Statt eine
        -- Befehlsliste zu drucken, fuehrt jedes Argument dorthin, wo die
        -- Optionen jetzt liegen. Die Guards sind noetig, weil Core.lua vor
        -- UI.lua geladen wird und ein Aufruf theoretisch davor liegen kann.
        if WAT.ShowUI then WAT:ShowUI() end
        if WAT.SetActiveTab and WAT.panels and WAT.panels.settings then
            WAT:SetActiveTab("settings")
        end
        Print(WAT.L("SLASH_HELP"))
    end
end

SLASH_WEEKLYALTTRACKER1 = "/wat"
SLASH_WEEKLYALTTRACKER2 = "/weeklyalt"
SlashCmdList.WEEKLYALTTRACKER = HandleSlash

local function RegisterEventSafely(event)
    local ok = pcall(WAT.events.RegisterEvent, WAT.events, event)
    return ok
end

WAT.events:SetScript("OnEvent", function(_, event, ...)
    if event == "ADDON_LOADED" then
        local loaded = ...
        if loaded ~= "OrzUI" then return end
        WAT:InitializeDatabase()
        WAT:CreateUI()
        RegisterEventSafely("PLAYER_LOGIN")
        RegisterEventSafely("PLAYER_ENTERING_WORLD")
        RegisterEventSafely("PLAYER_LEVEL_UP")
        RegisterEventSafely("PLAYER_EQUIPMENT_CHANGED")
        RegisterEventSafely("CURRENCY_DISPLAY_UPDATE")
        RegisterEventSafely("UPDATE_UI_WIDGET")
        RegisterEventSafely("ZONE_CHANGED_NEW_AREA")
        RegisterEventSafely("WEEKLY_REWARDS_UPDATE")
        RegisterEventSafely("WEEKLY_REWARDS_ITEM_CHANGED")
        RegisterEventSafely("CHALLENGE_MODE_COMPLETED")
        RegisterEventSafely("CHALLENGE_MODE_MAPS_UPDATE")
        RegisterEventSafely("BAG_UPDATE_DELAYED")
        RegisterEventSafely("MYTHIC_PLUS_CURRENT_AFFIX_UPDATE")
        RegisterEventSafely("QUEST_LOG_UPDATE")
        RegisterEventSafely("QUEST_TURNED_IN")
        RegisterEventSafely("SKILL_LINES_CHANGED")
        RegisterEventSafely("SKILL_LINE_SPECS_RANKS_CHANGED")
        RegisterEventSafely("TRAIT_CONFIG_UPDATED")
        RegisterEventSafely("ACHIEVEMENT_EARNED")
        -- Statistiken. RECEIVED_ACHIEVEMENT_LIST feuert, sobald der Client die
        -- Erfolgsdaten nachgeladen hat - vorher liefert GetStatistic nur "--".
        -- CRITERIA_UPDATE waere der naheliegende, aber falsche Kandidat: es
        -- feuert im Kampf im Sekundentakt und wuerde den Scan sinnlos treiben.
        RegisterEventSafely("RECEIVED_ACHIEVEMENT_LIST")
        RegisterEventSafely("PLAYER_DEAD")
        -- Antwort auf RequestTimePlayed(). Die Spielzeit ist ueber keine
        -- synchrone API lesbar; ohne dieses Event bliebe sie fuer immer
        -- unbekannt.
        RegisterEventSafely("TIME_PLAYED_MSG")
    elseif event == "PLAYER_LOGIN" then
        WAT:Refresh(event)
        C_Timer.After(2, function() WAT:Refresh("delayed-login") end)
    elseif event == "PLAYER_DEAD" then
        -- Die Statistikaufrufe genuegen. Vault, Taschen, Widgets und Berufe
        -- muessen im Todesmoment nicht komplett neu gescannt werden. Die
        -- Spielzeit wird hier bewusst NICHT angefordert: sie aendert sich im
        -- Todesmoment nicht sprunghaft, und Blizzards Antwort waere eine
        -- sichtbare Chatzeile bei jedem Tod.
        WAT:RefreshStatistics(event)
    elseif event == "TIME_PLAYED_MSG" then
        -- Nur die Gesamtzeit wird gespeichert; die Levelzeit des zweiten
        -- Rueckgabewerts wird bewusst verworfen. RecordTimePlayed prueft den
        -- Wert selbst und laesst einen bekannten Vorwert stehen, wenn er
        -- unbrauchbar ist. Kein Vollscan: das Event traegt seine Daten mit.
        --
        -- Die Chatunterdrueckung wird ZUERST aufgehoben, vor jedem Guard: ein
        -- fehlender Wert oder eine fehlende Datenbank darf den Standardchat
        -- nicht dauerhaft von diesem Ereignis abschneiden.
        if WAT.RestoreTimePlayedChat then WAT:RestoreTimePlayedChat(WAT.timePlayedToken) end
        if not WAT.db or not WAT.RecordTimePlayed then return end
        local totalSeconds = ...
        local character = WAT:PrepareCurrentCharacter()
        WAT:RecordTimePlayed(character, totalSeconds)
        if WAT.RefreshUI then WAT:RefreshUI() end
    elseif event == "BAG_UPDATE_DELAYED" then
        WAT:Refresh(event)
    elseif event == "MYTHIC_PLUS_CURRENT_AFFIX_UPDATE" then
        WAT:RefreshKeystone(event)
    elseif event == "UPDATE_UI_WIDGET" then
        local widgetInfo = ...
        if (issecretvalue and issecretvalue(widgetInfo)) or type(widgetInfo) ~= "table" then return end
        local widgetID = SafeNumber(widgetInfo.widgetID)
        if widgetID and WAT.IsGildedWidgetID and WAT:IsGildedWidgetID(widgetID) then
            WAT:Refresh(event)
        end
    else
        WAT:Refresh(event)
        if event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then
            C_Timer.After(2, function() WAT:Refresh("delayed-zone") end)
        end
    end
end)

WAT.events:RegisterEvent("ADDON_LOADED")



local Data = {}
WAT.Data = Data

-- Currency-IDs der Midnight-Dämmerwappen. Namen kommen zur Laufzeit lokalisiert
-- aus C_CurrencyInfo; labelKey verweist auf das eigene Kurzlabel in
-- WATL.lua. Der Kurzbuchstabe ist in beiden Sprachen identisch und
-- deshalb sprachneutral hier gespeichert.
Data.CRESTS = {
    champion = { currencyID = 3343, short = "C", labelKey = "CREST_CHAMPION" },
    hero = { currencyID = 3345, short = "H", labelKey = "CREST_HERO" },
    myth = { currencyID = 3347, short = "M", labelKey = "CREST_MYTH" },
}

-- Currency-ID des Dundun-Splitters. Kein Wochenwert: der Bestand wird
-- ausschließlich als Offline-Ressourcen-Snapshot je Charakter gehalten
-- (character.resources.dundun), nicht in character.weekly.
Data.DUNDUN_CURRENCY_ID = 3376

-- Midnight-Meta-Weekly. Der Pool enthält auch die Raid-Variante, damit das Addon
-- eine vom Spieler gewählte Weekly erkennen kann. Es gibt dennoch keinerlei
-- Raid-Vault- oder Raid-Fortschritts-Tracker.
Data.META_QUESTS = {
    93766, 93767, 93769, 93889, 93890,
    93891, 93892, 93909, 93910, 93911,
    93912, 93913, 94457, 95842, 95843,
}
-- Das Label einer Meta-Weekly ist kein gespeicherter Text, sondern entsteht
-- zur Renderzeit aus der questID. Gespeichert wird ausschliesslich die ID,
-- damit in den SavedVariables kein eigener Locale-Text landet und ein
-- Sprachwechsel den Altbestand sofort korrekt anzeigt.
function Data.MetaQuestLabelKey(questID)
    if type(questID) ~= "number" then return nil end
    return "META_QUEST_" .. questID
end

Data.PREY_GOAL = 4
Data.PREY_NORMAL = {
    91095, 91096, 91097, 91098, 91099, 91100, 91101, 91102, 91103, 91104,
    91105, 91106, 91107, 91108, 91109, 91110, 91111, 91112, 91113, 91114,
    91115, 91116, 91117, 91118, 91119, 91120, 91121, 91122, 91123, 91124,
}
Data.PREY_HARD = {
    91210, 91212, 91214, 91216, 91218, 91220, 91222, 91224, 91226, 91228,
    91230, 91232, 91234, 91236, 91238, 91240, 91242, 91243, 91244, 91245,
    91246, 91247, 91248, 91249, 91250, 91251, 91252, 91253, 91254, 91255,
}
Data.PREY_NIGHTMARE = {
    91211, 91213, 91215, 91217, 91219, 91221, 91223, 91225, 91227, 91229,
    91231, 91233, 91235, 91237, 91239, 91241, 91256, 91257, 91258, 91259,
    91260, 91261, 91262, 91263, 91264, 91265, 91266, 91267, 91268, 91269,
}

Data.RITUAL_QUEST_ID = 95843

-- Heroische Showdowns (Val/Naigtal): zwei alternative Hauptslot-Quests und
-- vier alternative Folgeslot-Quests (zwei je Hauptslot-Variante). Pro Woche
-- ist je Slot genau eine Variante abschliessbar. Jeder abgeschlossene Slot
-- gibt 5 Mythische Dämmerwappen über die jeweilige Riftstalker's Overflowing
-- Cache/Favor/Prize - macht maximal 10 pro Woche. Für PTR 12.1 bleibt der
-- Scan defensiv über die Quest-Completion-API: eine verschobene oder unbekannte
-- Variante liefert unbekannt, nie 0.
Data.HEROIC_SHOWDOWN_MAIN_QUESTS = {
    96714, 96718,
}
Data.HEROIC_SHOWDOWN_FOLLOWUP_QUESTS = {
    97081, 97087, 97083, 97086,
}
Data.HEROIC_SHOWDOWN_MYTH_PER_SLOT = 5
Data.HEROIC_SHOWDOWN_MAX_MYTH = 10

-- Weitere bestätigte Quellen für Mythische Dämmerwappen (Raid ausgeschlossen).
Data.CRACKED_KEYSTONE_QUEST_ID = 92600
Data.NULLAEUS_T11_ACHIEVEMENT_ID = 61798
Data.HERO_TO_MYTH_ACHIEVEMENT_ID = 42769
Data.GILDED_MYTH_PER_STASH = 5
Data.RITUAL_T6_MYTH_PER_RUN = 5
Data.CRACKED_KEYSTONE_MYTH_REWARD = 20
Data.CRACKED_KEYSTONE_HERO_REWARD = 20
Data.NULLAEUS_T11_MYTH_REWARD = 30
Data.HERO_TO_MYTH_HERO_COST = 30
Data.HERO_TO_MYTH_MYTH_REWARD = 10

-- Charakterbezogene, additive WoW-Erfolgsstatistiken. Gelesen werden sie
-- ausschließlich über GetStatistic(id) für den gerade eingeloggten Charakter.
--
-- Die IDs sind einzeln gegen Wowhead (Retail 12.0.7) und den Aufruf von
-- GetStatistic(id) in Blizzards AchievementUI verifiziert. Es wird bewusst
-- KEINE weitere ID geraten: eine falsche Statistik-ID liefert nicht etwa
-- nichts, sondern still den Wert einer fremden Statistik.
--
-- key ist sprachneutral und stabil; er landet nie in den SavedVariables und
-- ist nur der interne Bezeichner. labelKey ist der kurze Spaltenkopf, nameKey
-- der ausgeschriebene Name für den Tooltip. Beide werden erst zur Renderzeit
-- über WATL.lua aufgelöst. Bevorzugt zeigt der Tooltip ohnehin den
-- clientlokalisierten Namen aus GetAchievementInfo; nameKey ist der Ersatz,
-- wenn der nicht sicher lesbar ist.
Data.STATISTICS = {
    { key = "delvesTotal", statisticID = 40734, labelKey = "STAT_COL_DELVES", nameKey = "STAT_NAME_DELVES" },
    { key = "delvesMidnight", statisticID = 61790, labelKey = "STAT_COL_DELVES_MIDNIGHT", nameKey = "STAT_NAME_DELVES_MIDNIGHT" },
    { key = "deathsTotal", statisticID = 60, labelKey = "STAT_COL_DEATHS", nameKey = "STAT_NAME_DEATHS" },
    { key = "deathsDungeon", statisticID = 14787, labelKey = "STAT_COL_DEATHS_DUNGEON", nameKey = "STAT_NAME_DEATHS_DUNGEON" },
    { key = "deathsRaid", statisticID = 14784, labelKey = "STAT_COL_DEATHS_RAID", nameKey = "STAT_NAME_DEATHS_RAID" },
    { key = "deathsFalling", statisticID = 114, labelKey = "STAT_COL_DEATHS_FALLING", nameKey = "STAT_NAME_DEATHS_FALLING" },
    { key = "questsCompleted", statisticID = 98, labelKey = "STAT_COL_QUESTS", nameKey = "STAT_NAME_QUESTS" },
    { key = "questsDaily", statisticID = 97, labelKey = "STAT_COL_QUESTS_DAILY", nameKey = "STAT_NAME_QUESTS_DAILY" },
    { key = "questsAbandoned", statisticID = 94, labelKey = "STAT_COL_QUESTS_ABANDONED", nameKey = "STAT_NAME_QUESTS_ABANDONED" },
    -- 0.4.0. Bewusst ANGEHAENGT: die Reihenfolge der urspruenglichen neun
    -- bleibt unveraendert, damit ein 0.3.1-Snapshot unveraendert weiterlebt.
    { key = "healthstones", statisticID = 812, labelKey = "STAT_COL_HEALTHSTONES", nameKey = "STAT_NAME_HEALTHSTONES" },
    -- 932 zaehlt BETRETENE 5-Spieler-Dungeons, nicht abgeschlossene. Label und
    -- Tooltip muessen das sagen; alles andere waere eine stille Falschaussage.
    { key = "dungeonsEntered", statisticID = 932, labelKey = "STAT_COL_DUNGEONS", nameKey = "STAT_NAME_DUNGEONS", tooltipKey = "STAT_TIP_DUNGEONS" },
}

-- Die 24 Endboss-Statistiken der acht Midnight-Dungeons ueber Normal,
-- Heroisch und Mythisch (Mythisch schliesst dort mit ein, wo Blizzard
-- Mythisch+ mitzaehlt). Blizzard fuehrt KEINE einzelne Statistik
-- "Midnight-Dungeons"; der angezeigte Wert entsteht deshalb erst als Summe.
--
-- Die IDs stammen aus der Achievement-DB2 von Retail 12.0.7. Es wird bewusst
-- keine ID geraten oder ergaenzt: eine falsche ID liefert nicht nichts,
-- sondern still den Wert einer fremden Statistik.
Data.MIDNIGHT_DUNGEON_STATISTICS = {
    41293, 41294, 41295,
    61215, 61216, 61217,
    61273, 61274, 61275,
    61511, 61512, 61513,
    61650, 61651, 61652,
    61653, 61654, 61655,
    61656, 61657, 61658,
    61659, 61660, 61661,
}

-- Sprachneutrale Speicherschluessel der abgeleiteten Werte. Sie stehen als
-- Strings neben den numerischen Statistik-IDs im selben Container und sind
-- damit ueber alle Clientsprachen hinweg identisch.
Data.MIDNIGHT_DUNGEONS_KEY = "midnightDungeons"
Data.PLAYTIME_KEY = "playtimeTotal"

-- Werte, die NICHT aus einem einzelnen GetStatistic-Aufruf stammen. Sie tragen
-- deshalb keine statisticID, und GetAchievementInfo kann fuer sie auch keinen
-- clientlokalisierten Namen liefern: Name und Tooltip kommen zwingend aus den
-- eigenen Woerterbuechern.
Data.DERIVED_STATISTICS = {
    {
        key = "midnightDungeons",
        storageKey = Data.MIDNIGHT_DUNGEONS_KEY,
        kind = "composite",
        labelKey = "STAT_COL_DUNGEONS_MIDNIGHT",
        nameKey = "STAT_NAME_DUNGEONS_MIDNIGHT",
        tooltipKey = "STAT_TIP_DUNGEONS_MIDNIGHT",
    },
    {
        key = "playtimeTotal",
        storageKey = Data.PLAYTIME_KEY,
        kind = "duration",
        labelKey = "STAT_COL_PLAYTIME",
        nameKey = "STAT_NAME_PLAYTIME",
        tooltipKey = "STAT_TIP_PLAYTIME",
    },
}

-- Schlüssel ist die lokalisierungsunabhängige Basis-Skill-Line-ID aus
-- GetProfessionInfo(...), Rückgabewert 7.
Data.PROFESSION_WEEKLIES = {
    [171] = { 93690 },
    [164] = { 93691 },
    [202] = { 93692 },
    [773] = { 93693 },
    [755] = { 93694 },
    [165] = { 93695 },
    [197] = { 93696 },
    [333] = { 93697, 93698, 93699 },
    [182] = { 93700, 93701, 93702, 93703, 93704 },
    [186] = { 93705, 93706, 93707, 93708, 93709 },
    [393] = { 93710, 93711, 93712, 93713, 93714 },
}
Data.PROFESSION_TREATISES = {
    [171] = { 95127 },
    [164] = { 95128 },
    [202] = { 95138 },
    [773] = { 95131 },
    [755] = { 95133 },
    [165] = { 95134 },
    [197] = { 95137 },
    [333] = { 95129 },
    [182] = { 95130 },
    [186] = { 95135 },
    [393] = { 95136 },
}

-- Erweiterungsspezifische Skill-Line-IDs für Midnight. Die Basis-ID identifiziert
-- den Beruf unabhängig von Sprache und Erweiterung; C_ProfSpecs benötigt dagegen
-- die jeweilige Midnight-Skill-Line.
Data.MIDNIGHT_PROFESSION_SKILL_LINES = {
    [171] = 2906, -- Alchemie
    [164] = 2907, -- Schmiedekunst
    [333] = 2909, -- Verzauberkunst
    [202] = 2910, -- Ingenieurskunst
    [182] = 2912, -- Kräuterkunde
    [773] = 2913, -- Inschriftenkunde
    [755] = 2914, -- Juwelierskunst
    [165] = 2915, -- Lederverarbeitung
    [186] = 2916, -- Bergbau
    [393] = 2917, -- Kürschnerei
    [197] = 2918, -- Schneiderei
}

-- Midnight-Wissensgegenstände: 169 faktische Item-IDs, das sind 169 der 170
-- IDs aus BetterBags_AllCraftingKnowledge 1.0.14 (RaithZ, Data/Midnight.lua).
-- Bewusst ausgelassen ist 255157 ("Abyss Angler's Fish Log"), ein
-- Angel-Wissensgegenstand: Angeln wird hier nicht als Hauptberuf getrackt.
-- Eine unabhängige vollständige Erhebung wird nicht behauptet. Kein fremder
-- Quelltext, Text oder Asset ist übernommen; Struktur und Codeausdruck unten
-- sind eigenständig.
-- Details und Lizenzstatus siehe THIRD_PARTY_NOTICES.md.
-- Gespeichert werden ausschließlich Item-ID, Basisberuf und Wissenswert.
Data.MIDNIGHT_KNOWLEDGE_ITEMS = {}
local function AddKnowledgeItems(professionID, points, itemIDs)
    for _, itemID in ipairs(itemIDs) do
        Data.MIDNIGHT_KNOWLEDGE_ITEMS[itemID] = {
            professionID = professionID,
            points = points,
        }
    end
end

-- Thalassische Traktate (+1).
AddKnowledgeItems(171, 1, { 245755 })
AddKnowledgeItems(164, 1, { 245763 })
AddKnowledgeItems(333, 1, { 245759 })
AddKnowledgeItems(202, 1, { 245809 })
AddKnowledgeItems(182, 1, { 245761 })
AddKnowledgeItems(773, 1, { 245757 })
AddKnowledgeItems(755, 1, { 245760 })
AddKnowledgeItems(165, 1, { 245758 })
AddKnowledgeItems(186, 1, { 245762 })
AddKnowledgeItems(393, 1, { 245828 })
AddKnowledgeItems(197, 1, { 245756 })

-- Einmalige Händlerbücher (+10).
AddKnowledgeItems(171, 10, { 262645 })
AddKnowledgeItems(164, 10, { 262644 })
AddKnowledgeItems(333, 10, { 250445, 257600 })
AddKnowledgeItems(202, 10, { 262646 })
AddKnowledgeItems(182, 10, { 258410, 250443 })
AddKnowledgeItems(773, 10, { 258411 })
AddKnowledgeItems(755, 10, { 257599 })
AddKnowledgeItems(165, 10, { 250922 })
AddKnowledgeItems(186, 10, { 250444, 250924 })
AddKnowledgeItems(393, 10, { 250360, 250923 })
AddKnowledgeItems(197, 10, { 257601 })

-- Einmalige offene Berufsschätze (+3).
AddKnowledgeItems(171, 3, { 238532, 238533, 238534, 238535, 238536, 238537, 238538, 238539 })
AddKnowledgeItems(164, 3, { 238540, 238541, 238542, 238543, 238544, 238545, 238546, 238547 })
AddKnowledgeItems(333, 3, { 238548, 238549, 238550, 238551, 238552, 238553, 238554, 238555 })
AddKnowledgeItems(202, 3, { 238556, 238557, 238558, 238559, 238560, 238561, 238562, 238563 })
AddKnowledgeItems(182, 3, { 238468, 238469, 238470, 238471, 238472, 238473, 238474, 238475 })
AddKnowledgeItems(773, 3, { 238572, 238573, 238574, 238575, 238576, 238577, 238578, 238579 })
AddKnowledgeItems(755, 3, { 238580, 238581, 238582, 238583, 238584, 238585, 238586, 238587 })
AddKnowledgeItems(165, 3, { 238588, 238589, 238590, 238591, 238592, 238593, 238594, 238595 })
AddKnowledgeItems(186, 3, { 238596, 238597, 238598, 238599, 238600, 238601, 238602, 238603 })
AddKnowledgeItems(393, 3, { 238628, 238629, 238630, 238631, 238632, 238633, 238634, 238635 })
AddKnowledgeItems(197, 3, { 238612, 238613, 238614, 238615, 238616, 238617, 238618, 238619 })

-- Wöchentliche Questbelohnungen.
AddKnowledgeItems(171, 1, { 263454 })
AddKnowledgeItems(164, 2, { 263455 })
AddKnowledgeItems(333, 3, { 263464 })
AddKnowledgeItems(202, 1, { 263456 })
AddKnowledgeItems(182, 3, { 263462 })
AddKnowledgeItems(773, 4, { 263457 })
AddKnowledgeItems(755, 3, { 263458 })
AddKnowledgeItems(165, 2, { 263459 })
AddKnowledgeItems(186, 3, { 263463 })
AddKnowledgeItems(393, 3, { 263461 })
AddKnowledgeItems(197, 2, { 263460 })

-- Sammelberuf-Fundstücke und Catch-up-Gegenstände.
AddKnowledgeItems(182, 1, { 238465, 238467 })
AddKnowledgeItems(182, 4, { 238466 })
AddKnowledgeItems(186, 1, { 237496, 237507 })
AddKnowledgeItems(186, 3, { 237506 })
AddKnowledgeItems(393, 1, { 238625, 238627 })
AddKnowledgeItems(393, 3, { 238626 })
AddKnowledgeItems(171, 1, { 259188, 259189 })
AddKnowledgeItems(164, 2, { 259190, 259191 })
AddKnowledgeItems(333, 2, { 259192, 259193 })
AddKnowledgeItems(202, 1, { 259194, 259195 })
AddKnowledgeItems(773, 2, { 259196, 259197 })
AddKnowledgeItems(755, 2, { 259198, 259199 })
AddKnowledgeItems(165, 2, { 259200, 259201 })
AddKnowledgeItems(197, 2, { 259202, 259203 })

-- Handwerksauftrag-Belohnungen.
AddKnowledgeItems(171, 2, { 246321 }); AddKnowledgeItems(171, 1, { 246320 })
AddKnowledgeItems(164, 2, { 246323 }); AddKnowledgeItems(164, 1, { 246322 })
AddKnowledgeItems(333, 2, { 246325 }); AddKnowledgeItems(333, 1, { 246324 })
AddKnowledgeItems(202, 2, { 246327 }); AddKnowledgeItems(202, 1, { 246326 })
AddKnowledgeItems(773, 2, { 246329 }); AddKnowledgeItems(773, 1, { 246328 })
AddKnowledgeItems(755, 2, { 246331 }); AddKnowledgeItems(755, 1, { 246330 })
AddKnowledgeItems(165, 2, { 246333 }); AddKnowledgeItems(165, 1, { 246332 })
AddKnowledgeItems(197, 2, { 246335 }); AddKnowledgeItems(197, 1, { 246334 })

-- Verzauberkunst: Entzauberungs- und Kombinationsgegenstände.
AddKnowledgeItems(333, 1, { 267653, 267654 })
AddKnowledgeItems(333, 4, { 267655 })



local GILDED_SPELL_ID = 1216211
local GILDED_WIDGET_IDS = {
    7591, 6659, 6718, 6719, 6720, 6721, 6722, 6723, 6724,
    6725, 6726, 6727, 6728, 6729, 6794, 7193,
}
local GILDED_WIDGET_SET = {}
for _, widgetID in ipairs(GILDED_WIDGET_IDS) do GILDED_WIDGET_SET[widgetID] = true end

local function IsSafeValue(value)
    return not (issecretvalue and issecretvalue(value))
end

local function CopyNumber(value)
    if not IsSafeValue(value) or type(value) ~= "number" then return nil end
    return value
end

local function CopyString(value)
    if not IsSafeValue(value) or type(value) ~= "string" then return nil end
    return value
end

local function CopyBoolean(value)
    if not IsSafeValue(value) or type(value) ~= "boolean" then return nil end
    return value
end

-- Zählt Mehrfachrückgaben explizit. Ein nil zwischen zwei Werten macht # unzuverlässig
-- und würde spätere Rückgaben abschneiden.
local function PackResults(...)
    return select("#", ...), { ... }
end

local function SlotThreshold(slot)
    if not IsSafeValue(slot) or type(slot) ~= "table" then return math.huge end
    return CopyNumber(slot.threshold) or math.huge
end

function WAT:IsGildedWidgetID(widgetID)
    if not IsSafeValue(widgetID) then return false end
    return type(widgetID) == "number" and GILDED_WIDGET_SET[widgetID] == true
end

local function ReadGildedStash()
    local manager = C_UIWidgetManager
    local getter = manager and manager.GetSpellDisplayVisualizationInfo
    if not getter then return nil end

    for _, widgetID in ipairs(GILDED_WIDGET_IDS) do
        local ok, info = pcall(getter, widgetID)
        if ok and IsSafeValue(info) and type(info) == "table"
                and IsSafeValue(info.spellInfo) and type(info.spellInfo) == "table" then
            local spellID = CopyNumber(info.spellInfo.spellID)
            local tooltip = CopyString(info.spellInfo.tooltip)
            if spellID == GILDED_SPELL_ID and tooltip then
                local parsed, current, maximum = pcall(string.match, tooltip, "(%d+)%s*/%s*(%d+)")
                current = parsed and tonumber(current) or nil
                maximum = parsed and tonumber(maximum) or nil
                if current and maximum and maximum > 0 then return current, maximum, widgetID end
            end
        end
    end
    return nil
end

local function ReadCrest(currencyID)
    local getter = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo
    if not getter then return nil end
    local ok, info = pcall(getter, currencyID)
    if not ok or not IsSafeValue(info) or type(info) ~= "table" then return nil end
    local quantity = CopyNumber(info.quantity)
    if quantity == nil then return nil end
    return {
        currencyID = currencyID,
        name = CopyString(info.name),
        quantity = quantity,
        earnedThisWeek = CopyNumber(info.quantityEarnedThisWeek),
        weeklyMaximum = CopyNumber(info.maxWeeklyQuantity),
        totalMaximum = CopyNumber(info.maxQuantity),
        updated = time(),
    }
end

local function ReadCrests(previous, legacyMyth)
    local definitions = WAT.Data and WAT.Data.CRESTS
    if type(definitions) ~= "table" then return nil end
    local result = {}
    for key, definition in pairs(definitions) do
        local currencyID = type(definition) == "table" and CopyNumber(definition.currencyID) or nil
        local snapshot = currencyID and ReadCrest(currencyID) or nil
        if snapshot then
            result[key] = snapshot
        elseif type(previous) == "table" and type(previous[key]) == "table" then
            result[key] = previous[key]
        elseif key == "myth" and type(legacyMyth) == "table" then
            result[key] = legacyMyth
        end
    end
    if next(result) == nil then return nil end
    result.updated = time()
    return result
end

-- Dundun-Splitter (Currency 3376): ein Offline-Ressourcen-Snapshot, kein
-- Wochenwert. Anders als ReadCrest darf ein API-Ausfall NICHT einfach nil
-- liefern - das wuerde ScanCharacter dazu bringen, gar nichts zu schreiben,
-- was fuer einen bereits vorhandenen Snapshot richtig waere, einen frischen
-- Charakter aber niemals veraendert (kein Unterschied noetig). Stattdessen
-- gibt ReadDundun bei jedem Fehlerfall explizit den Vorwert zurueck: so bleibt
-- ein vorhandener sicherer Snapshot unveraendert erhalten, ohne dass der
-- Aufrufer selbst zwischen "nichts Neues" und "loeschen" unterscheiden muss.
-- Ein sicher gelesenes Maximum <= 0 entfernt einen alten Deckel bewusst. Ein
-- fehlendes/geschuetztes optionales Feld behaelt dagegen den sicheren Vorwert.
-- Wochenfelder duerfen nur innerhalb desselben weekEnd nachgetragen werden.
local function ReadDundun(previous, weekEnd)
    local getter = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo
    local id = WAT.Data and CopyNumber(WAT.Data.DUNDUN_CURRENCY_ID)
    if not getter or not id then return previous end
    local ok, info = pcall(getter, id)
    if not ok or not IsSafeValue(info) or type(info) ~= "table" then return previous end
    local quantity = CopyNumber(info.quantity)
    if quantity == nil then return previous end

    local old = IsSafeValue(previous) and type(previous) == "table" and previous or {}
    local currentWeekEnd = CopyNumber(weekEnd)
    local sameWeek = currentWeekEnd ~= nil and CopyNumber(old.weekEnd) == currentWeekEnd

    local function MergeNumber(raw, oldValue, clearNonPositive)
        local value = CopyNumber(raw)
        if value ~= nil then
            if clearNonPositive and value <= 0 then return nil end
            return value
        end
        return CopyNumber(oldValue)
    end

    local function MergeBoolean(raw, oldValue)
        local value = CopyBoolean(raw)
        if value ~= nil then return value end
        return CopyBoolean(oldValue)
    end

    return {
        currencyID = id,
        quantity = quantity,
        maxQuantity = MergeNumber(info.maxQuantity, old.maxQuantity, true),
        quantityEarnedThisWeek = MergeNumber(info.quantityEarnedThisWeek,
            sameWeek and old.quantityEarnedThisWeek or nil, false),
        maxWeeklyQuantity = MergeNumber(info.maxWeeklyQuantity,
            sameWeek and old.maxWeeklyQuantity or nil, true),
        isAccountWide = MergeBoolean(info.isAccountWide, old.isAccountWide),
        isAccountTransferable = MergeBoolean(info.isAccountTransferable, old.isAccountTransferable),
        weekEnd = currentWeekEnd,
        updated = time(),
    }
end

local function ReadOwnedKeystone(previous, allowClear)
    local mythicPlus = C_MythicPlus
    local getMapID = mythicPlus and mythicPlus.GetOwnedKeystoneChallengeMapID
    local getLevel = mythicPlus and mythicPlus.GetOwnedKeystoneLevel
    if not getMapID or not getLevel then return nil end

    local okMap, rawMapID = pcall(getMapID)
    local okLevel, rawLevel = pcall(getLevel)
    if not okMap or not okLevel or not IsSafeValue(rawMapID) or not IsSafeValue(rawLevel) then return nil end
    if rawMapID ~= nil and type(rawMapID) ~= "number" then return nil end
    if rawLevel ~= nil and type(rawLevel) ~= "number" then return nil end

    local mapID = CopyNumber(rawMapID)
    local level = CopyNumber(rawLevel)
    local mapMissing = mapID == nil or mapID <= 0
    local levelMissing = level == nil or level <= 0
    if mapMissing and levelMissing then
        if not allowClear then return nil end
        return { hasKey = false, updated = time() }
    end
    if mapMissing or levelMissing then return nil end

    local dungeonName
    local getMapInfo = C_ChallengeMode and C_ChallengeMode.GetMapUIInfo
    if getMapInfo then
        local okName, name = pcall(getMapInfo, mapID)
        if okName then dungeonName = CopyString(name) end
    end
    if not dungeonName and type(previous) == "table" and previous.mapID == mapID then
        dungeonName = CopyString(previous.dungeonName)
    end

    return {
        hasKey = true,
        mapID = mapID,
        dungeonName = dungeonName,
        level = level,
        updated = time(),
    }
end

function WAT:ScanKeystone(character, allowClear)
    if type(character) ~= "table" then return end
    character.weekly = type(character.weekly) == "table" and character.weekly or {}
    local weekly = character.weekly
    local fresh = ReadOwnedKeystone(weekly.keystone, allowClear)
    if fresh then weekly.keystone = fresh end
end

local function RewardItemLevelFromActual(activity)
    if not IsSafeValue(activity) or type(activity) ~= "table" then return nil end
    local rewards = activity.rewards
    if not IsSafeValue(rewards) or type(rewards) ~= "table" then return nil end
    local getLink = C_WeeklyRewards and C_WeeklyRewards.GetItemHyperlink
    local getItemLevel = C_Item and C_Item.GetDetailedItemLevelInfo
    if not getItemLevel and GetDetailedItemLevelInfo then getItemLevel = GetDetailedItemLevelInfo end
    if not getLink or not getItemLevel then return nil end
    local best
    for _, reward in ipairs(rewards) do
        if IsSafeValue(reward) and type(reward) == "table" then
            local itemDBID = CopyNumber(reward.itemDBID)
            local okLink, hyperlink
            if itemDBID then
                okLink, hyperlink = pcall(getLink, itemDBID)
            end
            hyperlink = okLink and CopyString(hyperlink) or nil
            local okLevel, itemLevel
            if hyperlink then
                okLevel, itemLevel = pcall(getItemLevel, hyperlink)
            end
            itemLevel = okLevel and CopyNumber(itemLevel) or nil
            if itemLevel and (not best or itemLevel > best) then best = itemLevel end
        end
    end
    return best
end

local function RewardItemLevelFromExample(activityID)
    if not C_WeeklyRewards or not C_WeeklyRewards.GetExampleRewardItemHyperlinks then return nil end
    if type(activityID) ~= "number" then return nil end
    local getItemLevel = C_Item and C_Item.GetDetailedItemLevelInfo
    if not getItemLevel and GetDetailedItemLevelInfo then getItemLevel = GetDetailedItemLevelInfo end
    if not getItemLevel then return nil end

    local count, result = PackResults(pcall(C_WeeklyRewards.GetExampleRewardItemHyperlinks, activityID))
    if not result[1] then return nil end
    local best
    for index = 2, count do
        local link = CopyString(result[index])
        if link and link ~= "" then
            local ok, itemLevel = pcall(getItemLevel, link)
            itemLevel = ok and CopyNumber(itemLevel) or nil
            if itemLevel and (not best or itemLevel > best) then best = itemLevel end
        end
    end
    return best
end

local function ReadVault(activityType)
    if type(activityType) ~= "number" or not C_WeeklyRewards or not C_WeeklyRewards.GetActivities then return nil end
    local ok, activities = pcall(C_WeeklyRewards.GetActivities, activityType)
    if not ok or not IsSafeValue(activities) or type(activities) ~= "table" then return nil end

    local result = { activityType = activityType, slots = {}, updated = time() }
    for _, activity in ipairs(activities) do
        if IsSafeValue(activity) and type(activity) == "table" then
            local threshold = CopyNumber(activity.threshold)
            if threshold and threshold > 0 then
                local progress = CopyNumber(activity.progress)
                local index = CopyNumber(activity.index)
                local activityID = CopyNumber(activity.id)
                local unlocked = progress ~= nil and progress >= threshold
                local rewardItemLevel
                local rewardIsPreview
                if unlocked then
                    rewardItemLevel = RewardItemLevelFromActual(activity)
                end
                if rewardItemLevel == nil then
                    rewardItemLevel = RewardItemLevelFromExample(activityID)
                    if rewardItemLevel ~= nil then rewardIsPreview = true end
                else
                    rewardIsPreview = false
                end
                result.slots[#result.slots + 1] = {
                    id = activityID,
                    index = index,
                    threshold = threshold,
                    progress = progress,
                    level = CopyNumber(activity.level),
                    rewardItemLevel = rewardItemLevel,
                    rewardIsPreview = rewardIsPreview,
                }
            end
        end
    end
    table.sort(result.slots, function(a, b) return SlotThreshold(a) < SlotThreshold(b) end)
    return result
end

local function FindPreviousSlot(previous, slot)
    if type(previous) ~= "table" or type(previous.slots) ~= "table" then return nil end
    for _, old in ipairs(previous.slots) do
        if type(old) == "table" then
            if slot.id and old.id == slot.id then return old end
            if slot.index and old.index == slot.index and slot.threshold == old.threshold then return old end
        end
    end
    return nil
end

local function MergeVault(previous, fresh)
    if type(fresh) ~= "table" or type(fresh.slots) ~= "table" then return previous end
    for _, slot in ipairs(fresh.slots) do
        local old = FindPreviousSlot(previous, slot)
        if old then
            if slot.progress == nil then slot.progress = old.progress end
            if slot.level == nil then slot.level = old.level end
            if slot.rewardItemLevel == nil
                    or (old.rewardIsPreview == false and slot.rewardIsPreview ~= false) then
                slot.rewardItemLevel = old.rewardItemLevel
                slot.rewardIsPreview = old.rewardIsPreview
            end
        end
    end
    if type(previous) == "table" and type(previous.slots) == "table" then
        for _, old in ipairs(previous.slots) do
            if type(old) == "table" and not FindPreviousSlot(fresh, old) then
                fresh.slots[#fresh.slots + 1] = old
            end
        end
        table.sort(fresh.slots, function(a, b) return SlotThreshold(a) < SlotThreshold(b) end)
    end
    return fresh
end

function WAT:ScanCharacter(character, reason)
    character.weekly = character.weekly or {}
    local weekly = character.weekly
    -- Offline-Ressourcen-Snapshot: kein Wochenwert, deshalb Geschwister von
    -- weekly statt darunter, und niemals vom Wochenreset geleert.
    character.resources = type(character.resources) == "table" and character.resources or {}
    local resources = character.resources
    local dundun = ReadDundun(resources.dundun, character.weekEnd)
    if dundun then resources.dundun = dundun end

    local current, maximum, widgetID = ReadGildedStash()
    if current and maximum then
        weekly.gilded = {
            current = current,
            maximum = maximum,
            widgetID = widgetID,
            updated = time(),
        }
    end

    local crests = ReadCrests(weekly.crests, weekly.mythCrests)
    if crests then
        weekly.crests = crests
        if type(crests.myth) == "table" then weekly.mythCrests = crests.myth end
    end

    self:ScanKeystone(character, reason ~= "PLAYER_LOGIN" and reason ~= "PLAYER_ENTERING_WORLD")

    local types = Enum and Enum.WeeklyRewardChestThresholdType
    if types then
        local world = ReadVault(CopyNumber(types.World))
        if world and #world.slots > 0 then weekly.worldVault = MergeVault(weekly.worldVault, world) end
        local mythic = ReadVault(CopyNumber(types.Activities))
        if mythic and #mythic.slots > 0 then weekly.mythicPlusVault = MergeVault(weekly.mythicPlusVault, mythic) end
    end

    if self.ScanActivities then self:ScanActivities(character, reason) end
    weekly.lastScanReason = reason
    weekly.updated = time()
end

function WAT:GetVaultSummary(vault)
    if type(vault) ~= "table" or type(vault.slots) ~= "table" or #vault.slots == 0 then return "-" end
    -- Sobald ein vorhandener Slot nicht sicher auswertbar ist, bleibt die Summary konservativ
    -- unbekannt. Sonst würde ein teilweise lesbarer Vault fälschlich als fertig erscheinen.
    local unlocked, known = 0, 0
    for _, slot in ipairs(vault.slots) do
        if type(slot) ~= "table" or type(slot.progress) ~= "number"
                or type(slot.threshold) ~= "number" then
            return "-"
        end
        known = known + 1
        if slot.progress >= slot.threshold then unlocked = unlocked + 1 end
    end
    if known == 0 then return "-" end
    return string.format("%d/%d", unlocked, known)
end

function WAT:GetMythicPlusLevelStatus(vault, targetLevel)
    targetLevel = CopyNumber(targetLevel)
    if not targetLevel or targetLevel <= 0
            or not IsSafeValue(vault) or type(vault) ~= "table"
            or not IsSafeValue(vault.slots) or type(vault.slots) ~= "table" then
        return nil
    end

    local hasSlot = false
    local allSlotsKnown = true
    for _, slot in ipairs(vault.slots) do
        hasSlot = true
        if not IsSafeValue(slot) or type(slot) ~= "table" then
            allSlotsKnown = false
        else
            local progress = CopyNumber(slot.progress)
            local threshold = CopyNumber(slot.threshold)
            if progress == nil or not threshold or threshold <= 0 then
                allSlotsKnown = false
            elseif progress >= threshold then
                local level = CopyNumber(slot.level)
                if level and level >= targetLevel then return true end
                if level == nil then allSlotsKnown = false end
            end
        end
    end
    if hasSlot and allSlotsKnown then return false end
    return nil
end

function WAT:GetVaultTooltip(vault, levelLabel)
    if type(vault) ~= "table" or type(vault.slots) ~= "table" or #vault.slots == 0 then
        return WAT.L("VAULT_NO_DATA")
    end
    local lines = {}
    for _, slot in ipairs(vault.slots) do
        if type(slot) == "table" then
            local index = #lines + 1
            local progress = type(slot.progress) == "number" and tostring(slot.progress) or "-"
            local threshold = type(slot.threshold) == "number" and tostring(slot.threshold) or "-"
            local level = type(slot.level) == "number" and tostring(slot.level) or "-"
            local done = type(slot.progress) == "number" and type(slot.threshold) == "number"
                and slot.progress >= slot.threshold
            local state = type(slot.progress) ~= "number" and WAT.L("STATUS_UNKNOWN")
                or (done and WAT.L("STATUS_UNLOCKED") or WAT.L("STATUS_OPEN"))
            local itemLevel = type(slot.rewardItemLevel) == "number" and tostring(slot.rewardItemLevel) or "-"
            local rewardLabel = slot.rewardIsPreview == true and WAT.L("REWARD_ITEM_LEVEL_UP_TO")
                or (slot.rewardIsPreview == false and WAT.L("REWARD_ITEM_LEVEL")
                    or WAT.L("REWARD_LEVEL_GENERIC"))
            lines[#lines + 1] = WAT.L("VAULT_SLOT_LINE",
                index, progress, threshold, levelLabel, level, state, rewardLabel, itemLevel)
        end
    end
    if #lines == 0 then return WAT.L("VAULT_NO_DATA") end
    return table.concat(lines, "\n")
end


local Data = WAT.Data or {}

local function IsSafe(value)
    return not (issecretvalue and issecretvalue(value))
end

local function SafeNumber(value)
    if not IsSafe(value) or type(value) ~= "number" then return nil end
    return value
end

local function SafeString(value)
    if not IsSafe(value) or type(value) ~= "string" then return nil end
    return value
end

local function SafeBoolean(value)
    if not IsSafe(value) or type(value) ~= "boolean" then return nil end
    return value
end

local function QuestCompleted(questID)
    local getter = C_QuestLog and C_QuestLog.IsQuestFlaggedCompleted
    if not getter then return nil end
    local ok, value = pcall(getter, questID)
    if not ok then return nil end
    return SafeBoolean(value)
end

local function QuestOnLog(questID)
    if C_QuestLog and C_QuestLog.IsOnQuest then
        local ok, value = pcall(C_QuestLog.IsOnQuest, questID)
        -- Ein sicheres false muss erhalten bleiben; nur Secret/Fehler bleibt nil.
        if ok then
            local safe = SafeBoolean(value)
            if safe ~= nil then return safe end
        end
    end
    if C_QuestLog and C_QuestLog.GetLogIndexForQuestID then
        local ok, index = pcall(C_QuestLog.GetLogIndexForQuestID, questID)
        index = ok and SafeNumber(index) or nil
        if index ~= nil then return index > 0 end
    end
    return nil
end

local function ReadQuestProgress(questID)
    local current, required, finished
    local getter = C_QuestLog and C_QuestLog.GetQuestObjectives
    if getter then
        local ok, objectives = pcall(getter, questID)
        if ok and IsSafe(objectives) and type(objectives) == "table" then
            for _, objective in ipairs(objectives) do
                if IsSafe(objective) and type(objective) == "table" then
                    local objectiveCurrent = SafeNumber(objective.numFulfilled)
                    local objectiveRequired = SafeNumber(objective.numRequired)
                    if objectiveCurrent ~= nil and objectiveRequired and objectiveRequired > 0 then
                        current = objectiveCurrent
                        required = objectiveRequired
                        finished = SafeBoolean(objective.finished)
                        break
                    end
                end
            end
        end
    end

    local percent
    if current == nil and C_QuestLog and C_QuestLog.GetQuestProgressBarPercent then
        local ok, value = pcall(C_QuestLog.GetQuestProgressBarPercent, questID)
        value = ok and SafeNumber(value) or nil
        if value ~= nil then
            percent = math.max(0, math.min(100, value))
            current = percent
            required = 100
        end
    end
    return current, required, finished, percent
end

local function CandidateIsBetter(candidate, best)
    if not best then return true end
    if best.completed and not candidate.completed then return false end
    if candidate.completed and not best.completed then return true end
    local candidateRatio = 0
    local bestRatio = 0
    if candidate.current and candidate.required and candidate.required > 0 then
        candidateRatio = candidate.current / candidate.required
    end
    if best.current and best.required and best.required > 0 then
        bestRatio = best.current / best.required
    end
    return candidateRatio > bestRatio
end

-- Gemeinsame Poolauswahl fuer Midnight-Weekly und Berufs-Wochenquests: beide
-- sind Listen alternativer questIDs, von denen hoechstens eine gleichzeitig
-- im Log steht. readyToTurnIn (im Log erfuellt, aber noch nicht abgegeben)
-- und turnedIn (IsQuestFlaggedCompleted) sind getrennte Zustaende - ein
-- abgegebener Quest darf nie gleichzeitig als abgabebereit gelten, selbst
-- wenn sein Zielobjekt zufaellig weiterhin "finished" meldet. Bleibt ein
-- Zustand unlesbar, bleibt das jeweilige Feld nil statt eines erfundenen
-- false (Invariante 1).
local function ReadPoolQuestState(pool, requireAllReadable)
    if type(pool) ~= "table" or #pool == 0 then return nil end
    local best
    local sawUnknown = false
    local completedWithoutLogID

    for _, questID in ipairs(pool) do
        local onLog = QuestOnLog(questID)
        local turnedIn = QuestCompleted(questID)
        if onLog == nil then sawUnknown = true end
        if turnedIn == nil then sawUnknown = true end
        if turnedIn == true and onLog ~= true then completedWithoutLogID = questID end
        if onLog == true then
            local current, required, objectiveFinished, percent = ReadQuestProgress(questID)
            local readyToTurnIn
            if turnedIn == true then
                readyToTurnIn = false
            elseif turnedIn == false then
                if objectiveFinished == true then
                    readyToTurnIn = true
                elseif objectiveFinished == false then
                    readyToTurnIn = false
                end
            end
            local done
            if turnedIn == true or objectiveFinished == true then
                done = true
            elseif turnedIn == false and objectiveFinished == false then
                done = false
            end
            -- Bewusst ohne Label: der Snapshot speichert nur die questID,
            -- die UI lokalisiert daraus zur Renderzeit.
            local candidate = {
                questID = questID,
                current = current,
                required = required,
                percent = percent,
                completed = done,
                readyToTurnIn = readyToTurnIn,
                turnedIn = turnedIn,
                active = true,
                variantKnown = true,
                updated = time(),
            }
            if CandidateIsBetter(candidate, best) then best = candidate end
        end
    end

    -- Fuer belohnungsrelevante Variantenpools gilt der Scan atomar: sobald
    -- auch nur eine Alternative nicht sicher lesbar ist, darf eine andere
    -- lesbare Variante keinen sicheren Same-Week-Vorwert herabstufen.
    if requireAllReadable and sawUnknown then return nil end
    -- In den strikten Wappenpools ist "abgegeben" der staerkste sichere
    -- Zustand: eine andere, noch aktive Alternative darf den bereits
    -- verdienten Slot nicht wieder auf offen zurueckstufen.
    if completedWithoutLogID and (requireAllReadable or not best) then
        return {
            questID = completedWithoutLogID,
            completed = true,
            turnedIn = true,
            readyToTurnIn = false,
            active = false,
            variantKnown = false,
            updated = time(),
        }
    end
    if best then return best end
    if sawUnknown then return nil end
    return {
        completed = false,
        turnedIn = false,
        readyToTurnIn = false,
        active = false,
        variantKnown = false,
        updated = time(),
    }
end

function WAT:ScanMidnightWeekly()
    if type(Data.META_QUESTS) ~= "table" then return nil end
    return ReadPoolQuestState(Data.META_QUESTS)
end

local function CountCompletedPool(pool)
    if type(pool) ~= "table" then return nil end
    local count = 0
    local matched = {}
    for _, questID in ipairs(pool) do
        local complete = QuestCompleted(questID)
        if complete == nil then return nil end
        if complete then
            count = count + 1
            matched[#matched + 1] = questID
        end
    end
    local goal = SafeNumber(Data.PREY_GOAL)
    if not goal then return nil end
    if count > goal then count = goal end
    return {
        current = count,
        maximum = goal,
        completed = count >= goal,
        completedQuestIDs = matched,
        updated = time(),
    }
end

function WAT:ScanPrey()
    local normal = CountCompletedPool(Data.PREY_NORMAL)
    local hard = CountCompletedPool(Data.PREY_HARD)
    local nightmare = CountCompletedPool(Data.PREY_NIGHTMARE)
    if not normal or not hard or not nightmare then return nil end
    return {
        normal = normal,
        hard = hard,
        nightmare = nightmare,
        updated = time(),
    }
end

function WAT:ScanRitualSites()
    local questID = SafeNumber(Data.RITUAL_QUEST_ID)
    if not questID then return nil end
    local completed = QuestCompleted(questID)
    if completed == true then
        return { questID = questID, active = false, completed = true, percent = 100, updated = time() }
    end

    local onLog = QuestOnLog(questID)
    if onLog == false and completed == false then
        return { questID = questID, active = false, completed = false, updated = time() }
    end
    if onLog ~= true then return nil end

    local _, _, objectiveFinished, percent = ReadQuestProgress(questID)
    if objectiveFinished == true then percent = 100 end
    if percent == nil then return nil end
    return {
        questID = questID,
        active = true,
        completed = percent >= 100,
        percent = percent,
        updated = time(),
    }
end

local function QuestPoolCompleted(pool)
    if type(pool) ~= "table" or #pool == 0 then return nil, nil end
    for _, questID in ipairs(pool) do
        local complete = QuestCompleted(questID)
        if complete == nil then return nil, nil end
        if complete then return true, questID end
    end
    return false, nil
end

local function ReadProfessionIdentity(professionIndex)
    if not GetProfessionInfo or not IsSafe(professionIndex) or professionIndex == nil then return nil end
    local result = { pcall(GetProfessionInfo, professionIndex) }
    if not result[1] then return nil end
    local name = SafeString(result[2])
    local baseSkillLineID = SafeNumber(result[8])
    if not baseSkillLineID then return nil end
    return { name = name, baseSkillLineID = baseSkillLineID }
end

local function ReadWeeklyProfession(identity)
    if type(identity) ~= "table" then return nil end
    local baseSkillLineID = SafeNumber(identity.baseSkillLineID)
    if not baseSkillLineID then return nil end
    local weeklyPool = Data.PROFESSION_WEEKLIES and Data.PROFESSION_WEEKLIES[baseSkillLineID]
    local treatisePool = Data.PROFESSION_TREATISES and Data.PROFESSION_TREATISES[baseSkillLineID]
    local weeklyDone, weeklyQuestID = QuestPoolCompleted(weeklyPool)
    local treatiseDone, treatiseQuestID = QuestPoolCompleted(treatisePool)
    if type(weeklyPool) == "table" and #weeklyPool > 0 and weeklyDone == nil then return nil end
    if type(treatisePool) == "table" and #treatisePool > 0 and treatiseDone == nil then return nil end
    return {
        name = identity.name,
        baseSkillLineID = baseSkillLineID,
        weeklyDone = weeklyDone,
        weeklyQuestID = weeklyQuestID,
        weeklyQuest = ReadPoolQuestState(weeklyPool),
        treatiseDone = treatiseDone,
        treatiseQuestID = treatiseQuestID,
        updated = time(),
    }
end

local function PreviousProfession(previous, baseSkillLineID)
    if not IsSafe(previous) or type(previous) ~= "table" then return nil end
    for index = 1, 2 do
        local entry = previous[index]
        if IsSafe(entry) and type(entry) == "table"
                and SafeNumber(entry.baseSkillLineID) == baseSkillLineID then
            return entry
        end
    end
    return nil
end

local function PreserveMissingProfessions(target, previous)
    if type(target) ~= "table" or not IsSafe(previous) or type(previous) ~= "table" then return end
    for previousIndex = 1, 2 do
        local old = previous[previousIndex]
        local oldID = type(old) == "table" and SafeNumber(old.baseSkillLineID) or nil
        if oldID then
            local found
            for targetIndex = 1, 2 do
                local candidate = target[targetIndex]
                if type(candidate) == "table" and SafeNumber(candidate.baseSkillLineID) == oldID then
                    found = true
                end
            end
            if not found then
                local destination = target[1] == nil and 1 or (target[2] == nil and 2 or nil)
                if destination then target[destination] = old end
            end
        end
    end
end

local function ReadBagKnowledge()
    local getSlots = C_Container and C_Container.GetContainerNumSlots
    local getInfo = C_Container and C_Container.GetContainerItemInfo
    if not getSlots or not getInfo or type(Data.MIDNIGHT_KNOWLEDGE_ITEMS) ~= "table" then return nil end

    local totals = {}
    for bag = 0, 5 do
        local okSlots, slotCount = pcall(getSlots, bag)
        slotCount = okSlots and SafeNumber(slotCount) or nil
        if slotCount == nil or slotCount < 0 then return nil end
        for slot = 1, slotCount do
            local okInfo, info = pcall(getInfo, bag, slot)
            if not okInfo or not IsSafe(info) then return nil end
            if info ~= nil then
                if type(info) ~= "table" then return nil end
                local itemID = SafeNumber(info.itemID)
                local count = SafeNumber(info.stackCount)
                if itemID == nil or count == nil or count < 1 then return nil end
                local definition = Data.MIDNIGHT_KNOWLEDGE_ITEMS[itemID]
                if type(definition) == "table" then
                    local professionID = SafeNumber(definition.professionID)
                    local points = SafeNumber(definition.points)
                    if not professionID or not points or points < 0 then return nil end
                    local total = totals[professionID]
                    if not total then
                        total = { points = 0, items = 0, detailsByID = {} }
                        totals[professionID] = total
                    end
                    total.points = total.points + (points * count)
                    total.items = total.items + count
                    local detail = total.detailsByID[itemID]
                    if detail then
                        detail.count = detail.count + count
                        detail.totalPoints = detail.totalPoints + (points * count)
                    else
                        total.detailsByID[itemID] = {
                            itemID = itemID,
                            count = count,
                            pointsEach = points,
                            totalPoints = points * count,
                        }
                    end
                end
            end
        end
    end

    for _, total in pairs(totals) do
        total.details = {}
        for _, detail in pairs(total.detailsByID) do total.details[#total.details + 1] = detail end
        table.sort(total.details, function(a, b) return a.itemID < b.itemID end)
        total.detailsByID = nil
    end
    return totals
end

local function ReadProfessionProgress(identity, previous, bagKnowledge)
    if type(identity) ~= "table" then return nil end
    local baseSkillLineID = SafeNumber(identity.baseSkillLineID)
    local midnightSkillLineID = type(Data.MIDNIGHT_PROFESSION_SKILL_LINES) == "table"
        and SafeNumber(Data.MIDNIGHT_PROFESSION_SKILL_LINES[baseSkillLineID]) or nil
    if not baseSkillLineID or not midnightSkillLineID then return nil end

    local old = PreviousProfession(previous, baseSkillLineID) or {}
    local progress = {
        name = identity.name,
        baseSkillLineID = baseSkillLineID,
        midnightSkillLineID = midnightSkillLineID,
    }
    local refreshed

    local getProfessionInfo = C_TradeSkillUI and C_TradeSkillUI.GetProfessionInfoBySkillLineID
    if getProfessionInfo then
        local ok, info = pcall(getProfessionInfo, midnightSkillLineID)
        if ok and IsSafe(info) and type(info) == "table" then
            local skillLevel = SafeNumber(info.skillLevel)
            local maxSkillLevel = SafeNumber(info.maxSkillLevel)
            if skillLevel ~= nil and maxSkillLevel and maxSkillLevel > 0 then
                progress.skillLevel = skillLevel
                progress.maxSkillLevel = maxSkillLevel
                refreshed = true
            end
        end
    end
    if progress.skillLevel == nil then progress.skillLevel = SafeNumber(old.skillLevel) end
    if progress.maxSkillLevel == nil then progress.maxSkillLevel = SafeNumber(old.maxSkillLevel) end

    local getCurrencyInfo = C_ProfSpecs and C_ProfSpecs.GetCurrencyInfoForSkillLine
    if getCurrencyInfo then
        local ok, info = pcall(getCurrencyInfo, midnightSkillLineID)
        if ok and IsSafe(info) and type(info) == "table" then
            local value = SafeNumber(info.numAvailable)
            if value ~= nil and value >= 0 then
                progress.unspentKnowledge = value
                refreshed = true
            end
        end
    end
    if progress.unspentKnowledge == nil then
        progress.unspentKnowledge = SafeNumber(old.unspentKnowledge)
    end

    if type(bagKnowledge) == "table" then
        local bag = bagKnowledge[baseSkillLineID]
        progress.bagKnowledgePoints = type(bag) == "table" and SafeNumber(bag.points) or 0
        progress.bagKnowledgeItems = type(bag) == "table" and SafeNumber(bag.items) or 0
        progress.bagKnowledgeDetails = type(bag) == "table" and bag.details or {}
        refreshed = true
    else
        progress.bagKnowledgePoints = SafeNumber(old.bagKnowledgePoints)
        progress.bagKnowledgeItems = SafeNumber(old.bagKnowledgeItems)
        progress.bagKnowledgeDetails = IsSafe(old.bagKnowledgeDetails)
            and type(old.bagKnowledgeDetails) == "table" and old.bagKnowledgeDetails or nil
    end
    progress.updated = refreshed and time() or SafeNumber(old.updated)
    return progress
end

function WAT:ScanProfessions(previousProgress, previousWeekly, allowRemoval)
    if not GetProfessions then return nil, nil end
    local result = { pcall(GetProfessions) }
    if not result[1] then return nil, nil end
    if not IsSafe(result[2]) or not IsSafe(result[3]) then return nil, nil end
    local firstIndex = result[2]
    local secondIndex = result[3]
    local first = firstIndex ~= nil and ReadProfessionIdentity(firstIndex) or nil
    local second = secondIndex ~= nil and ReadProfessionIdentity(secondIndex) or nil
    if firstIndex ~= nil and not first then return nil end
    if secondIndex ~= nil and not second then return nil end

    local bagKnowledge = ReadBagKnowledge()
    local progress = { updated = time() }
    if first then progress[1] = ReadProfessionProgress(first, previousProgress, bagKnowledge) end
    if second then progress[2] = ReadProfessionProgress(second, previousProgress, bagKnowledge) end
    if first and not progress[1] then return nil, nil end
    if second and not progress[2] then return nil, nil end
    if allowRemoval ~= true then PreserveMissingProfessions(progress, previousProgress) end

    local professions = { updated = time() }
    local weeklyFirst = first and ReadWeeklyProfession(first) or nil
    local weeklySecond = second and ReadWeeklyProfession(second) or nil
    if first and not weeklyFirst then professions = nil end
    if second and not weeklySecond then professions = nil end
    if professions then
        if weeklyFirst then professions[1] = weeklyFirst end
        if weeklySecond then professions[2] = weeklySecond end
        if allowRemoval ~= true then PreserveMissingProfessions(professions, previousWeekly) end
    end
    return professions, progress
end

local function AchievementCompleted(achievementID, perCharacter)
    if not GetAchievementInfo or type(achievementID) ~= "number" then return nil end
    local result = { pcall(GetAchievementInfo, achievementID) }
    if not result[1] then return nil end
    local completed = SafeBoolean(result[5])
    local wasEarnedByMe = SafeBoolean(result[14])
    if perCharacter then
        return wasEarnedByMe
    end
    return completed
end

-- ---------------------------------------------------------------------------
-- Erfolgsstatistiken
--
-- GetStatistic liefert keinen Zahlwert, sondern einen bereits formatierten,
-- clientlokalisierten String. Je nach Sprache stehen darin Gruppentrenner:
-- Punkt, Komma, Apostroph oder ein normales, geschuetztes bzw. schmales
-- geschuetztes Leerzeichen. Solange die Statistik nicht geladen ist, kommt
-- "--" zurueck.
--
-- Der Parser ist bewusst fail-closed. Ein dezimal aussehender Wert wie "1,5"
-- ist KEIN gruppierter Tausenderwert, sondern eine Statistik mit
-- Nachkommastelle (etwa eine Durchschnittsquote). Wuerde man Trenner einfach
-- entfernen, ergaebe das still 15 statt eines verworfenen Werts. Deshalb wird
-- die Gruppenstruktur selbst geprueft: die erste Gruppe hat ein bis drei
-- Ziffern, jede weitere exakt drei. Alles andere ist unbekannt und damit nil.
-- ---------------------------------------------------------------------------

local MAX_STATISTIC_RAW_LENGTH = 64
local MAX_STATISTIC_DIGITS = 15
local MAX_STATISTIC_VALUE = 999999999999999

local function StatisticDigits(text)
    -- Jeder Trennerkandidat wird zunaechst auf ein einziges ASCII-Zeichen
    -- normalisiert; die mehrbyteigen Varianten zuerst, damit ihre Bytes nicht
    -- vom Zeichenklassen-Ersatz zerlegt werden.
    local normalized = string.gsub(text, "\226\128\175", ",")
    normalized = string.gsub(normalized, "\194\160", ",")
    normalized = string.gsub(normalized, "[%.'%s]", ",")

    local groups = {}
    for group in string.gmatch(normalized .. ",", "([^,]*),") do
        groups[#groups + 1] = group
    end
    if #groups == 0 then return nil end
    for index, group in ipairs(groups) do
        if group == "" or string.find(group, "%D") then return nil end
        if #groups > 1 then
            if index == 1 then
                if #group > 3 then return nil end
            elseif #group ~= 3 then
                return nil
            end
        end
    end
    local digits = table.concat(groups)
    if #digits > MAX_STATISTIC_DIGITS then return nil end
    return digits
end

local function ParseStatisticValue(raw)
    if not IsSafe(raw) then return nil end
    if type(raw) == "number" then
        -- NaN, Unendlich, negative und gebrochene Werte fallen ueber denselben
        -- Test heraus: x % 1 ist dort nie exakt 0.
        if raw ~= raw or raw < 0 or raw % 1 ~= 0 then return nil end
        if raw > MAX_STATISTIC_VALUE then return nil end
        return raw
    end
    if type(raw) ~= "string" then return nil end
    if raw == "" or #raw > MAX_STATISTIC_RAW_LENGTH then return nil end
    local digits = StatisticDigits(raw)
    if not digits then return nil end
    local value = tonumber(digits)
    if type(value) ~= "number" or value < 0 or value % 1 ~= 0 then return nil end
    return value
end

local function ReadStatistic(statisticID)
    if type(GetStatistic) ~= "function" then return nil end
    local ok, raw = pcall(GetStatistic, statisticID)
    if not ok then return nil end
    return ParseStatisticValue(raw)
end

-- Summe der 24 Midnight-Endbossstatistiken.
--
-- Ganz oder gar nicht: fehlt auch nur ein Bestandteil, ist die Summe unbekannt
-- und wird nicht geschrieben. Eine Teilsumme waere keine Luecke, sondern eine
-- stille Falschaussage - sie saehe aus wie ein echter, nur kleinerer Wert.
-- Deshalb bricht die Schleife beim ersten unlesbaren Wert ab und liefert nil;
-- der Aufrufer laesst dann den bekannten Vorwert stehen.
local function ReadMidnightDungeonTotal()
    local ids = Data.MIDNIGHT_DUNGEON_STATISTICS
    if not IsSafe(ids) or type(ids) ~= "table" then return nil end
    if #ids == 0 then return nil end
    local total = 0
    for _, id in ipairs(ids) do
        local statisticID = SafeNumber(id)
        if not statisticID then return nil end
        local value = ReadStatistic(statisticID)
        if value == nil then return nil end
        total = total + value
    end
    -- Die Summe unterliegt derselben Obergrenze wie ein Einzelwert.
    if total < 0 or total % 1 ~= 0 or total > MAX_STATISTIC_VALUE then return nil end
    return total
end

-- Schreibt einen abgeleiteten Wert unter seinem sprachneutralen Stringschluessel
-- in denselben Container wie die direkten Statistiken.
local function StoreDerivedValue(store, key, value, now)
    if type(key) ~= "string" or key == "" then return false end
    if type(value) ~= "number" then return false end
    local entry = store[key]
    if not IsSafe(entry) or type(entry) ~= "table" then
        entry = {}
        store[key] = entry
    end
    entry.value = value
    entry.updated = now
    return true
end

-- Ersetzt einen unbrauchbaren Statistikcontainer und liefert ihn zurueck.
local function EnsureStatisticStore(character)
    local store = character.statistics
    if not IsSafe(store) or type(store) ~= "table" then
        store = {}
        character.statistics = store
    end
    return store
end

-- Gesamtspielzeit aus TIME_PLAYED_MSG.
--
-- Der Wert kommt nicht aus GetStatistic, sondern asynchron als Event, und wird
-- gegen dieselbe Obergrenze geprueft wie jede Statistik. Gespeichert wird
-- ausschliesslich die Gesamtzeit des Charakters unter einem sprachneutralen
-- Schluessel neben den lebenslangen Statistiken - nie unter weekly, denn die
-- Spielzeit ist kein Wochenwert und darf den Reset ueberleben.
function WAT:RecordTimePlayed(character, totalSeconds)
    if not IsSafe(character) or type(character) ~= "table" then return false end
    if not IsSafe(totalSeconds) or type(totalSeconds) ~= "number" then return false end
    -- NaN, Unendlich, negative und gebrochene Werte fallen ueber denselben
    -- Test heraus: x % 1 ist dort nie exakt 0.
    if totalSeconds ~= totalSeconds or totalSeconds < 0 or totalSeconds % 1 ~= 0 then return false end
    if totalSeconds > MAX_STATISTIC_VALUE then return false end
    local key = type(Data.PLAYTIME_KEY) == "string" and Data.PLAYTIME_KEY or nil
    if not key then return false end
    return StoreDerivedValue(EnsureStatisticStore(character), key, totalSeconds, time())
end

-- Nur diese Wege duerfen die Spielzeit anfordern. Blizzard beantwortet
-- RequestTimePlayed nicht still, sondern laesst den Standardchat eine Zeile
-- drucken - eine Anforderung bei jedem Taschen- oder Waehrungsereignis waere
-- deshalb Chatspam. Der Todespfad ist bewusst nicht dabei: er scannt nur
-- Statistiken.
--
-- "button" und "settings" sind die beiden manuellen Wege: exakt die Gruende,
-- die die sichtbaren Aktualisierungsknoepfe in UI.lua senden (Fusszeile bzw.
-- Einstellungsseite). Wer hier einen Grund eintraegt, den kein Aufrufer
-- sendet, schaltet den Weg nicht frei, sondern legt ihn still.
local TIME_PLAYED_REASONS = {
    ["PLAYER_LOGIN"] = true,
    ["delayed-login"] = true,
    ["PLAYER_ENTERING_WORLD"] = true,
    ["button"] = true,
    ["settings"] = true,
}
local TIME_PLAYED_THROTTLE = 600
local TIME_PLAYED_EVENT = "TIME_PLAYED_MSG"
-- Obergrenze fuer den Chatrahmen-Durchlauf, falls NUM_CHAT_WINDOWS fehlt oder
-- unbrauchbar ist. WoW erlaubt maximal 10 Fenster; 20 ist bewusst grosszuegig
-- und begrenzt trotzdem hart.
local CHAT_WINDOW_FALLBACK = 10
local CHAT_WINDOW_LIMIT = 20
-- Kommt wider Erwarten nie ein TIME_PLAYED_MSG (Ladebildschirm, Fehler im
-- Client), duerfen die Rahmen nicht dauerhaft abgeschaltet bleiben.
local TIME_PLAYED_RESTORE_DELAY = 10

-- Liest ChatFrame<index> aus dem globalen Namensraum. Alles, was kein
-- benutzbarer Rahmen ist, ergibt nil - inklusive Secret-Werten.
local function GetChatWindow(index)
    local ok, frame = pcall(function() return _G["ChatFrame" .. index] end)
    if not ok then return nil end
    if not IsSafe(frame) or type(frame) ~= "table" then return nil end
    return frame
end

-- Hatte dieser Rahmen TIME_PLAYED_MSG bereits registriert? Nur ein sicher
-- gelesenes true zaehlt: bei fehlender Methode, Fehler oder unlesbarer Antwort
-- bleibt der Rahmen unangetastet.
local function ChatWindowWantsTimePlayed(frame)
    if type(frame.IsEventRegistered) ~= "function" then return false end
    local ok, registered = pcall(frame.IsEventRegistered, frame, TIME_PLAYED_EVENT)
    if not ok then return false end
    return SafeBoolean(registered) == true
end

-- Schaltet ausschliesslich die TIME_PLAYED_MSG-Registrierung ab und liefert
-- genau die Rahmen zurueck, bei denen das nachweislich geklappt hat. Ein
-- Rahmen, dessen UnregisterEvent wirft, gilt bewusst als nicht unterdrueckt -
-- sonst bekaeme er beim Wiederherstellen eine Registrierung geschenkt.
local function SuppressTimePlayedChat()
    local suppressed = {}
    local limit = SafeNumber(NUM_CHAT_WINDOWS) or CHAT_WINDOW_FALLBACK
    if limit ~= limit or limit < 1 then return suppressed end
    if limit > CHAT_WINDOW_LIMIT then limit = CHAT_WINDOW_LIMIT end
    for index = 1, limit do
        local frame = GetChatWindow(index)
        if frame and ChatWindowWantsTimePlayed(frame)
                and type(frame.UnregisterEvent) == "function" then
            local ok = pcall(frame.UnregisterEvent, frame, TIME_PLAYED_EVENT)
            if ok then suppressed[#suppressed + 1] = frame end
        end
    end
    return suppressed
end

-- Nimmt die Unterdrueckung zurueck. Das Token ist die Generation der Anfrage,
-- zu der die Merkliste gehoert: ein veralteter Fallback-Rueckruf traegt ein
-- altes Token und tut nichts. Der zweite Aufruf mit demselben Token faellt
-- ebenfalls heraus, weil die Generation vorher geloescht wird.
function WAT:RestoreTimePlayedChat(token)
    local current = SafeNumber(self.timePlayedToken)
    if current == nil or SafeNumber(token) ~= current then return false end
    local frames = self.timePlayedSuppressed
    self.timePlayedSuppressed = nil
    self.timePlayedToken = nil
    if type(frames) ~= "table" then return false end
    for _, frame in ipairs(frames) do
        if IsSafe(frame) and type(frame) == "table"
                and type(frame.RegisterEvent) == "function" then
            pcall(frame.RegisterEvent, frame, TIME_PLAYED_EVENT)
        end
    end
    return true
end

function WAT:RequestTimePlayed(reason)
    if type(reason) ~= "string" or not TIME_PLAYED_REASONS[reason] then return false end
    local now = time()
    local last = SafeNumber(self.lastTimePlayedRequest)
    if last and now - last < TIME_PLAYED_THROTTLE then return false end
    if type(RequestTimePlayed) ~= "function" then return false end

    -- Eine noch offene Unterdrueckung zuerst aufloesen, sonst ginge ihre
    -- Merkliste beim Ueberschreiben verloren und die Rahmen blieben stumm.
    self:RestoreTimePlayedChat(self.timePlayedToken)

    -- Der Zaehler laeuft monoton weiter und wird beim Wiederherstellen NICHT
    -- zurueckgesetzt - sonst truege die naechste Anfrage wieder Token 1 und
    -- ein veralteter Fallback-Rueckruf wuerde ihre Unterdrueckung aufheben.
    local token = (SafeNumber(self.timePlayedRequestCount) or 0) + 1
    self.timePlayedRequestCount = token
    self.timePlayedToken = token
    self.timePlayedSuppressed = SuppressTimePlayedChat()

    -- Ein Fehlschlag der API darf den Refresh nicht abbrechen - und die
    -- Rahmen nicht abgeschaltet zuruecklassen.
    local ok = pcall(RequestTimePlayed)
    if not ok then
        self:RestoreTimePlayedChat(token)
        return false
    end
    self.lastTimePlayedRequest = now

    if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
        pcall(C_Timer.After, TIME_PLAYED_RESTORE_DELAY, function()
            self:RestoreTimePlayedChat(token)
        end)
    end
    return true
end

-- Liest ausschliesslich fuer den gerade eingeloggten Charakter. Offline-
-- Charaktere behalten ihren letzten Snapshot; ein unlesbarer Wert laesst den
-- bekannten Vorwert unangetastet und wird nie zu 0.
function WAT:ScanStatistics(character)
    if not IsSafe(character) or type(character) ~= "table" then return end
    if type(Data.STATISTICS) ~= "table" then return end

    local store = EnsureStatisticStore(character)

    local now = time()
    local scanned
    for _, definition in ipairs(Data.STATISTICS) do
        local statisticID = IsSafe(definition) and type(definition) == "table"
            and SafeNumber(definition.statisticID) or nil
        if statisticID then
            local value = ReadStatistic(statisticID)
            if value ~= nil then
                local entry = store[statisticID]
                if not IsSafe(entry) or type(entry) ~= "table" then
                    entry = {}
                    store[statisticID] = entry
                end
                entry.value = value
                entry.updated = now
                scanned = true
            end
        end
    end

    -- Die Summenstatistik der Midnight-Dungeons. Bleibt sie unbekannt, wird
    -- nichts geschrieben und ein vorhandener Vorwert bleibt unangetastet.
    local midnightTotal = ReadMidnightDungeonTotal()
    if midnightTotal ~= nil then
        local key = type(Data.MIDNIGHT_DUNGEONS_KEY) == "string" and Data.MIDNIGHT_DUNGEONS_KEY or nil
        if key and StoreDerivedValue(store, key, midnightTotal, now) then scanned = true end
    end

    if scanned then store.scanned = now end
end

function WAT:ScanCrestSources(character)
    if type(character) ~= "table" then return end
    character.season = type(character.season) == "table" and character.season or {}
    character.season.crestSources = type(character.season.crestSources) == "table"
        and character.season.crestSources or {}
    local season = character.season.crestSources

    local crackedID = SafeNumber(Data.CRACKED_KEYSTONE_QUEST_ID)
    if crackedID then
        local completed = QuestCompleted(crackedID)
        if completed ~= nil then
            season.crackedKeystone = {
                questID = crackedID,
                completed = completed,
                active = QuestOnLog(crackedID),
                mythReward = SafeNumber(Data.CRACKED_KEYSTONE_MYTH_REWARD),
                heroReward = SafeNumber(Data.CRACKED_KEYSTONE_HERO_REWARD),
                updated = time(),
            }
        end
    end

    local nullaeusID = SafeNumber(Data.NULLAEUS_T11_ACHIEVEMENT_ID)
    if nullaeusID then
        local completed = AchievementCompleted(nullaeusID, true)
        if completed ~= nil then
            season.nullaeusT11 = {
                achievementID = nullaeusID,
                completed = completed,
                mythReward = SafeNumber(Data.NULLAEUS_T11_MYTH_REWARD),
                updated = time(),
            }
        end
    end

    character.weekly = type(character.weekly) == "table" and character.weekly or {}
    local weekly = character.weekly
    weekly.crestSources = type(weekly.crestSources) == "table" and weekly.crestSources or {}
    local sources = weekly.crestSources
    local exchangeID = SafeNumber(Data.HERO_TO_MYTH_ACHIEVEMENT_ID)
    -- Ein sicheres false darf weder zu nil kollabieren noch vom alten Snapshot überschrieben werden.
    local exchangeUnlocked
    if exchangeID then exchangeUnlocked = AchievementCompleted(exchangeID, false) end
    local hero = type(weekly.crests) == "table" and weekly.crests.hero or nil
    local heroQuantity = type(hero) == "table" and SafeNumber(hero.quantity) or nil
    local previousExchange = type(sources.heroToMyth) == "table" and sources.heroToMyth or nil
    if exchangeUnlocked == nil and previousExchange then
        exchangeUnlocked = SafeBoolean(previousExchange.unlocked)
    end
    if heroQuantity == nil and previousExchange then
        heroQuantity = SafeNumber(previousExchange.heroQuantity)
    end
    local heroCost = SafeNumber(Data.HERO_TO_MYTH_HERO_COST)
    local mythReward = SafeNumber(Data.HERO_TO_MYTH_MYTH_REWARD)
    local potential
    if exchangeUnlocked == true and heroQuantity and heroCost and heroCost > 0 and mythReward then
        potential = math.floor(heroQuantity / heroCost) * mythReward
    end
    sources.heroToMyth = {
        achievementID = exchangeID,
        unlocked = exchangeUnlocked,
        heroQuantity = heroQuantity,
        mythPotential = potential,
        updated = time(),
    }

    -- Heroische Showdowns (Val/Naigtal): Hauptslot- und Folgeslot-Pool sind
    -- je eine eigene, atomare ReadPoolQuestState-Auswertung, damit hoechstens
    -- EIN Zustand je Slot entsteht und ein teilweise unlesbarer Pool keinen
    -- sicheren Same-Week-Vorwert herabstuft (Invarianten 1/2).
    local previousShowdown = type(sources.heroicShowdowns) == "table" and sources.heroicShowdowns or nil
    local mainState = ReadPoolQuestState(Data.HEROIC_SHOWDOWN_MAIN_QUESTS, true)
    local followupState = ReadPoolQuestState(Data.HEROIC_SHOWDOWN_FOLLOWUP_QUESTS, true)
    local mainObserved = mainState ~= nil
    local followupObserved = followupState ~= nil

    if mainState == nil and previousShowdown and type(previousShowdown.main) == "table" then
        mainState = previousShowdown.main
    end
    if followupState == nil and previousShowdown and type(previousShowdown.followup) == "table" then
        followupState = previousShowdown.followup
    end

    -- Bewusst kein "and ... or nil": SafeBoolean(false) waere selbst falsy und
    -- das or-Glied wuerde ein sicheres false wieder zu nil kollabieren lassen.
    local mainDone
    if type(mainState) == "table" then mainDone = SafeBoolean(mainState.turnedIn) end
    local followupDone
    if type(followupState) == "table" then followupDone = SafeBoolean(followupState.turnedIn) end

    local mythPerSlot = SafeNumber(Data.HEROIC_SHOWDOWN_MYTH_PER_SLOT)
    local maxMyth = SafeNumber(Data.HEROIC_SHOWDOWN_MAX_MYTH)
    local earned
    if mainDone ~= nil and followupDone ~= nil and mythPerSlot then
        earned = 0
        if mainDone then earned = earned + mythPerSlot end
        if followupDone then earned = earned + mythPerSlot end
        if maxMyth and earned > maxMyth then earned = maxMyth end
    end

    local showdownUpdated
    if mainObserved or followupObserved then
        showdownUpdated = time()
    elseif previousShowdown then
        showdownUpdated = previousShowdown.updated
    end

    sources.heroicShowdowns = {
        main = mainState,
        followup = followupState,
        mainDone = mainDone,
        followupDone = followupDone,
        earned = earned,
        maximum = maxMyth,
        mythPerSlot = mythPerSlot,
        updated = showdownUpdated,
    }

    local highestKeyLevel
    local vault = weekly.mythicPlusVault
    if type(vault) == "table" and type(vault.slots) == "table" then
        for _, slot in ipairs(vault.slots) do
            local level = type(slot) == "table" and SafeNumber(slot.level) or nil
            if level and (not highestKeyLevel or level > highestKeyLevel) then highestKeyLevel = level end
        end
    end
    sources.mythicPlus = {
        minimumEligibleLevel = 9,
        highestObservedLevel = highestKeyLevel,
        repeatable = true,
        updated = time(),
    }
    sources.ritualT6 = {
        mythPerRun = SafeNumber(Data.RITUAL_T6_MYTH_PER_RUN),
        repeatable = true,
        ledgerAvailable = false,
        updated = time(),
    }
end

function WAT:ScanActivities(character, reason)
    if type(character) ~= "table" then return end
    character.weekly = character.weekly or {}
    local weekly = character.weekly

    local midnight = self:ScanMidnightWeekly()
    if midnight then weekly.midnightWeekly = midnight end
    local prey = self:ScanPrey()
    if prey then weekly.prey = prey end
    local ritual = self:ScanRitualSites()
    if ritual then weekly.ritualSites = ritual end
    local allowProfessionRemoval = reason == "SKILL_LINES_CHANGED"
    local professions, professionProgress = self:ScanProfessions(
        character.professions, weekly.professions, allowProfessionRemoval)
    if professions then weekly.professions = professions end
    if professionProgress then character.professions = professionProgress end
    self:ScanCrestSources(character)
    -- Statistiken sind lebenslang und kein Wochenwert: sie liegen bewusst
    -- neben weekly und ueberleben deshalb den Wochenreset.
    self:ScanStatistics(character)
    weekly.activitiesUpdated = time()
end


local FRAME_WIDTH = 1200
local FRAME_HEIGHT = 900
local CONTENT_WIDTH = 1000
local ROW_HEIGHT = 38
local HEADER_HEIGHT = 36
local SIDEBAR_WIDTH = 150
local CONTENT_LEFT = 165
local SCROLLBAR_GUTTER = 18

-- ---------------------------------------------------------------------------
-- Geometrie der Statistikseite
--
-- Die Seite ist kein Tabellenpanel, sondern ein Dashboard: sie zeigt genau
-- EINEN Bereich (die Accountsumme oder einen Charakter) und dafuer alle
-- dreizehn Werte gleichzeitig. Drei Abschnitte uebereinander, darunter eine
-- feste Registerleiste fuer die Bereichswahl.
--
-- Die Zahlen sind gegen die Panelhoehe gerechnet, nicht geschaetzt:
-- Das Panel ist FRAME_HEIGHT - 150 (Kopf) - 48 (Fuss) = 402px hoch.
-- Verbraucht werden 3*96 (Abschnitte) + 2*22 (Abstaende dazwischen)
-- + 22 (Abstand zur Leiste) + 32 (Leiste) = 386px. Die verbleibenden 16px
-- sind Reserve, damit eine groessere Clientschrift nichts abschneidet.
-- ---------------------------------------------------------------------------
local DASHBOARD_SECTION_HEIGHT = 96
local DASHBOARD_BAR_HEIGHT = 32
local DASHBOARD_GAP = 22
local CARD_GAP = 10
-- Ein Abschnitt traegt seinen Titel oben und darunter die Karten.
local CARD_TOP = 22
local CARD_HEIGHT = DASHBOARD_SECTION_HEIGHT - CARD_TOP - 8
-- Registerleiste: GESAMT ist fest angeheftet, die Charakterreiter liegen in
-- einem blaetternden Ausschnitt zwischen den beiden Pfeilen.
local TOTAL_TAB_WIDTH = 96
local TAB_WIDTH = 104
local TAB_GAP = 4
local TAB_HEIGHT = 26
local ARROW_WIDTH = 22

local COLORS = {
    frame = { 0.050, 0.070, 0.090, 0.99 },
    sidebar = { 0.028, 0.039, 0.052, 0.98 },
    title = { 0.050, 0.070, 0.090, 1 },
    surface = { 0.043, 0.058, 0.075, 0.98 },
    alternate = { 0.035, 0.048, 0.063, 0.98 },
    hover = { 0.072, 0.112, 0.120, 1 },
    line = { 1, 1, 1, 0.07 },
    turquoise = { 0.050, 0.820, 0.620, 1 },
    violet = { 0.655, 0.482, 1, 1 },
    green = "|cff64e68a",
    amber = "|cfff2c35b",
    red = "|cfff06f78",
    unknown = "|cff98a3b1",
    stale = "|cff6d7580",
}

-- Panel- und Spaltentexte entstehen beim Laden in der aufgeloesten Sprache.
-- Die Breiten sind sprachunabhaengig und bleiben unveraendert; die englischen
-- Labels sind so gewaehlt, dass sie in dieselben Spalten passen.
local L = WAT.L

local PANELS = {
    overview = {
        label = L("PANEL_OVERVIEW"),
        shortLabel = L("PANEL_OVERVIEW_SHORT"),
        description = L("PANEL_OVERVIEW_DESC"),
        columns = {
            { key = "character", label = L("COL_CHARACTER"), width = 178, left = true },
            { key = "level", label = L("COL_LEVEL"), width = 40 },
            { key = "itemLevel", label = L("COL_ITEM_LEVEL"), width = 56 },
            { key = "gilded", label = L("COL_GILDED"), width = 90 },
            { key = "crests", label = L("COL_CRESTS"), width = 144 },
            { key = "world", label = L("COL_WORLD_VAULT"), width = 106 },
            { key = "mythic", label = L("COL_MYTHIC_VAULT"), width = 96 },
            { key = "mythic10", label = L("COL_MYTHIC10"), width = 70 },
            { key = "updated", label = L("COL_UPDATED"), width = 128 },
        },
    },
    midnight = {
        label = L("PANEL_MIDNIGHT"),
        shortLabel = L("PANEL_MIDNIGHT_SHORT"),
        description = L("PANEL_MIDNIGHT_DESC"),
        columns = {
            { key = "character", label = L("COL_CHARACTER"), width = 184, left = true },
            { key = "weekly", label = L("COL_WEEKLY_QUEST"), width = 250 },
            { key = "prey", label = L("COL_PREY"), width = 245 },
            { key = "ritual", label = L("COL_RITUAL"), width = 145 },
            { key = "updated", label = L("COL_DATA_AGE"), width = 96 },
        },
    },
    professions = {
        label = L("PANEL_PROFESSIONS"),
        shortLabel = L("PANEL_PROFESSIONS_SHORT"),
        description = L("PANEL_PROFESSIONS_DESC"),
        columns = {
            { key = "character", label = L("COL_CHARACTER"), width = 150, left = true },
            { key = "profession1", label = L("COL_PROFESSION1"), width = 130 },
            { key = "skill1", label = L("COL_SKILL"), width = 55 },
            { key = "knowledge1", label = L("COL_KNOWLEDGE"), width = 70 },
            { key = "weekly1", label = L("COL_WEEK"), width = 65 },
            { key = "treatise1", label = L("COL_TREATISE"), width = 65 },
            { key = "profession2", label = L("COL_PROFESSION2"), width = 130 },
            { key = "skill2", label = L("COL_SKILL"), width = 55 },
            { key = "knowledge2", label = L("COL_KNOWLEDGE"), width = 70 },
            { key = "weekly2", label = L("COL_WEEK"), width = 65 },
            { key = "treatise2", label = L("COL_TREATISE"), width = 65 },
        },
    },
    sources = {
        label = L("PANEL_SOURCES"),
        shortLabel = L("PANEL_SOURCES_SHORT"),
        description = L("PANEL_SOURCES_DESC"),
        columns = {
            { key = "character", label = L("COL_CHARACTER"), width = 150, left = true },
            -- Dundun ist ein Ressourcen-Snapshot; die Heroic-Showdown-Spalte
            -- dagegen ein echter Wochenwert. Alle Breiten ergeben zusammen
            -- exakt CONTENT_WIDTH (920px), damit kein Kopf in Nachbarspalten
            -- hineinragt und kein horizontaler Scrollbereich entsteht.
            { key = "dundun", label = L("COL_DUNDUN"), width = 70 },
            { key = "gilded", label = L("COL_GILDED_WEEKLY"), width = 85 },
            { key = "heroicShowdown", label = L("COL_HEROIC_SHOWDOWN"), width = 95 },
            { key = "cracked", label = L("COL_CRACKED"), width = 105 },
            { key = "nullaeus", label = L("COL_NULLAEUS"), width = 100 },
            { key = "ritualFarm", label = L("COL_RITUAL_FARM"), width = 95 },
            { key = "mythicFarm", label = L("COL_MYTHIC_FARM"), width = 80 },
            { key = "exchange", label = L("COL_EXCHANGE"), width = 140 },
        },
    },
    keystones = {
        label = L("PANEL_KEYSTONES"),
        shortLabel = L("PANEL_KEYSTONES_SHORT"),
        description = L("PANEL_KEYSTONES_DESC"),
        columns = {
            { key = "character", label = L("COL_CHARACTER"), width = 250, left = true },
            { key = "dungeon", label = L("COL_DUNGEON"), width = 430, left = true },
            { key = "keystoneLevel", label = L("COL_KEYSTONE_LEVEL"), width = 120 },
            { key = "updated", label = L("COL_DATA_AGE"), width = 120 },
        },
    },
    -- Dashboard statt Vergleichstabelle: die Seite fuehrt bewusst KEINE
    -- Spalten. Ihr Aufbau steht in STATISTIC_GROUPS.
    statistics = {
        label = L("PANEL_STATISTICS"),
        shortLabel = L("PANEL_STATISTICS_SHORT"),
        description = L("PANEL_STATISTICS_DESC"),
    },
    -- Formularseite ohne Spalten und ohne Charakterzeilen.
    settings = {
        label = L("PANEL_SETTINGS"),
        shortLabel = L("PANEL_SETTINGS_SHORT"),
        description = L("PANEL_SETTINGS_DESC"),
    },
}

-- Feste Stufen statt eines Schiebereglers: der Wertebereich bleibt damit exakt
-- der, den Core.lua beim Laden akzeptiert, und jeder Schritt ist reproduzierbar
-- statt von einer Pixelposition abhaengig.
local SCALE_PRESETS = { 0.70, 0.85, 1.00, 1.15, 1.30, 1.50 }

-- ---------------------------------------------------------------------------
-- Aufbau der Statistikseite
--
-- Dreizehn Werte nebeneinander waeren rund 1200px breit und in 920px nicht
-- lesbar unterzubringen. Statt Spalten zu quetschen zeigt die Seite deshalb
-- immer nur EINEN Bereich und dafuer alle dreizehn Werte gleichzeitig - in
-- drei thematischen Abschnitten mit je gleich breiten Kennzahlkarten.
--
-- Die Zuordnung ist inhaltlich, nicht bloss aufgefuellt. Einen Wert in einen
-- fremden Abschnitt zu schieben ist eine inhaltliche Aenderung und soll den
-- Test brechen. Die Schluessel sind identisch mit Data.STATISTICS[i].key bzw.
-- Data.DERIVED_STATISTICS[i].key; es gibt darueber keine zweite Wahrheit.
-- ---------------------------------------------------------------------------
local STATISTIC_GROUPS = {
    {
        key = "content",
        titleKey = "STAT_GROUP_CONTENT",
        keys = { "delvesTotal", "delvesMidnight", "dungeonsEntered",
                 "midnightDungeons", "playtimeTotal" },
    },
    {
        key = "survival",
        titleKey = "STAT_GROUP_SURVIVAL",
        keys = { "deathsTotal", "deathsDungeon", "deathsRaid",
                 "deathsFalling", "healthstones" },
    },
    {
        key = "quests",
        titleKey = "STAT_GROUP_QUESTS",
        keys = { "questsCompleted", "questsDaily", "questsAbandoned" },
    },
}

-- Der Bereichsschluessel der Accountsumme. Er kann mit keinem Charakter-
-- schluessel kollidieren: eine GUID ist nie leer und beginnt nie mit "*".
local TOTAL_SCOPE = "*total*"

-- ---------------------------------------------------------------------------
-- Spaltenlayout
--
-- Jede Tabellenseite ist einbaendig: eine einzige Reihe von Spalten in einer
-- 38px-Zeile. Header und Datenzeile durchlaufen DIESELBE Funktion, damit es
-- keine zweite Rechnung gibt, die auseinanderlaufen koennte; die gelieferte
-- Kante ist die tatsaechliche rechte Kante.
--
-- Das frueher hier stehende Mehrband-Layout ist mit der Statistiktabelle
-- entfallen. Es war ihr einziger Nutzer; generischer Code ohne Nutzer ist
-- toter Code und wird nicht auf Vorrat gehalten.
-- ---------------------------------------------------------------------------

-- Ruft place(column, left) je Spalte auf und liefert die rechte Kante zurueck.
local function LayoutColumns(columns, place)
    local edge = 4
    for _, column in ipairs(columns) do
        place(column, edge)
        edge = edge + column.width
    end
    return edge
end

local function SetBackdrop(frame, background, border)
    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
    })
    frame:SetBackdropColor(background[1], background[2], background[3], background[4])
    frame:SetBackdropBorderColor(border[1], border[2], border[3], border[4])
end

local function FormatAge(timestamp)
    if type(timestamp) ~= "number" or timestamp <= 0 then return "-" end
    local age = math.max(0, time() - timestamp)
    if age < 60 then return L("TIME_JUST_NOW") end
    if age < 3600 then return L("TIME_MINUTES", math.floor(age / 60)) end
    if age < 86400 then return L("TIME_HOURS", math.floor(age / 3600)) end
    return date(L("DATE_FORMAT_SHORT"), timestamp)
end

local function ClassColoredName(character, stale)
    local unknown = L("CHARACTER_UNKNOWN")
    local name = (character.name or unknown) .. "-" .. (character.realm or unknown)
    if stale then return COLORS.stale .. name .. "|r" end
    local color = character.classFile and RAID_CLASS_COLORS[character.classFile]
    if color then
        return string.format("|cff%02x%02x%02x%s|r",
            math.floor(color.r * 255), math.floor(color.g * 255), math.floor(color.b * 255), name)
    end
    return name
end

local function StatusFraction(current, maximum, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    if type(current) ~= "number" or type(maximum) ~= "number" then
        return COLORS.unknown .. "-|r"
    end
    if current >= maximum then return string.format("%s%d/%d|r", COLORS.green, current, maximum) end
    if current > 0 then return string.format("%s%d/%d|r", COLORS.amber, current, maximum) end
    return string.format("%s%d/%d|r", COLORS.red, current, maximum)
end

local function BooleanStatus(value, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    if value == true then return COLORS.green .. L("STATUS_DONE") .. "|r" end
    if value == false then return COLORS.red .. L("STATUS_OPEN") .. "|r" end
    return COLORS.unknown .. "-|r"
end

local function VaultText(vault, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    local summary = WAT:GetVaultSummary(vault)
    if summary == "-" then return COLORS.unknown .. "-|r" end
    local unlocked, total = string.match(summary, "(%d+)/(%d+)")
    local unlockedNumber = tonumber(unlocked)
    if unlocked and unlocked == total then return COLORS.green .. summary .. "|r" end
    if unlockedNumber and unlockedNumber > 0 then return COLORS.amber .. summary .. "|r" end
    return COLORS.red .. summary .. "|r"
end

local function MythicPlusTenText(vault, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    local status = WAT:GetMythicPlusLevelStatus(vault, 10)
    if status == true then return COLORS.green .. L("STATUS_YES") .. "|r" end
    if status == false then return COLORS.red .. L("STATUS_OPEN_CAPITAL") .. "|r" end
    return COLORS.unknown .. "-|r"
end

-- Reihenfolge und Farbe der Wappenspalte. Der Kurzbuchstabe kommt primär aus
-- Data.CRESTS[key].short; die Buchstaben hier sind nur die Reserve, falls die
-- Datentabelle fehlt oder unbrauchbar ist (keine zweite Wahrheit im Normalfall).
local CREST_ORDER = { "champion", "hero", "myth" }
local CREST_DISPLAY = {
    champion = { short = "C", color = "|cff79bdf2" },
    hero = { short = "H", color = "|cffb28cff" },
    myth = { short = "M", color = "|cffe0b6ff" },
}

-- Wappensymbol des laufenden Clients. Die iconFileID wird ausschliesslich zur
-- Laufzeit referenziert und nie gespeichert. Nur eine sichere, positive
-- Ganzzahl ergibt Markup; jeder andere Fall liefert "" und damit den
-- Buchstaben-Fallback. Die Ganzzahlprüfung ist nötig, weil %d einen Bruchwert
-- je nach Lua-Variante still abschneiden oder mit einem Fehler abbrechen würde;
-- inf und nan fallen über denselben Test heraus (x % 1 ist dort nie 0).
-- Auch der Feldzugriff info.iconFileID liegt im pcall: eine Metatable auf der
-- Rückgabe kann beim Lesen selbst einen Fehler werfen.
local function CrestIcon(currencyID)
    if type(currencyID) ~= "number" then return "" end
    local getter = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo
    if not getter then return "" end
    local ok, icon = pcall(function()
        local info = getter(currencyID)
        if (issecretvalue and issecretvalue(info)) or type(info) ~= "table" then return nil end
        return info.iconFileID
    end)
    if not ok then return "" end
    if (issecretvalue and issecretvalue(icon)) or type(icon) ~= "number" then return "" end
    if icon <= 0 or icon % 1 ~= 0 then return "" end
    return string.format("|T%d:12:12:0:0|t ", icon)
end

local function CrestText(weekly, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    local crests = type(weekly.crests) == "table" and weekly.crests or {}
    local definitions = WAT.Data and WAT.Data.CRESTS or {}
    local function Quantity(key)
        local entry = crests[key]
        if type(entry) == "table" and type(entry.quantity) == "number" then return tostring(entry.quantity) end
        if key == "myth" and type(weekly.mythCrests) == "table"
                and type(weekly.mythCrests.quantity) == "number" then
            return tostring(weekly.mythCrests.quantity)
        end
        return "-"
    end
    local parts = {}
    for _, key in ipairs(CREST_ORDER) do
        local display = CREST_DISPLAY[key]
        local definition = definitions[key] or {}
        local icon = CrestIcon(definition.currencyID)
        local short = definition.short
        if type(short) ~= "string" or short == "" then short = display.short end
        local prefix = icon ~= "" and icon or (short .. " ")
        parts[#parts + 1] = display.color .. prefix .. Quantity(key) .. "|r"
    end
    return table.concat(parts, "  ")
end

-- Das Label der Midnight-Weekly entsteht zur Renderzeit aus der questID. Ein
-- in einer alten Version gespeichertes Label ist nur noch letzter Fallback,
-- damit 0.2.5-Daten lesbar bleiben; die questID gewinnt immer.
local function MidnightWeeklyLabel(snapshot)
    local labelKey = WAT.Data and WAT.Data.MetaQuestLabelKey
        and WAT.Data.MetaQuestLabelKey(snapshot.questID) or nil
    if labelKey then
        local dictionaries = WAT.WATL and WAT.WATL.dictionaries
        local english = type(dictionaries) == "table" and dictionaries.enUS or nil
        if type(english) == "table" and english[labelKey] ~= nil then return L(labelKey) end
    end
    return type(snapshot.label) == "string" and snapshot.label or nil
end

local function MidnightWeeklyText(snapshot, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    if type(snapshot) ~= "table" then return COLORS.unknown .. "-|r" end
    local label = MidnightWeeklyLabel(snapshot)
    if snapshot.turnedIn == true then
        local variant = snapshot.variantKnown and label or L("STATUS_VARIANT_UNKNOWN")
        return COLORS.green .. L("STATUS_TURNED_IN") .. "|r  |cff8f9aa9" .. (variant or "") .. "|r"
    end
    if snapshot.readyToTurnIn == true then
        local variant = snapshot.variantKnown and label or L("STATUS_VARIANT_UNKNOWN")
        return COLORS.amber .. L("STATUS_READY_TO_TURN_IN") .. "|r  |cff8f9aa9" .. (variant or "") .. "|r"
    end
    if snapshot.completed == true then
        local variant = snapshot.variantKnown and label or L("STATUS_VARIANT_UNKNOWN")
        return COLORS.green .. L("STATUS_DONE") .. "|r  |cff8f9aa9" .. (variant or "") .. "|r"
    end
    if snapshot.active == true then
        if type(snapshot.current) == "number" and type(snapshot.required) == "number" then
            return COLORS.amber .. string.format("%s / %s/%s|r",
                label or L("STATUS_ACTIVE"), tostring(snapshot.current), tostring(snapshot.required))
        end
        return COLORS.amber .. (label or L("STATUS_ACTIVE")) .. "|r"
    end
    if snapshot.completed == false then return COLORS.unknown .. L("STATUS_NOT_ACTIVE") .. "|r" end
    return COLORS.unknown .. "-|r"
end

local function PreyText(prey, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    local normalShort = L("HUNT_SHORT_NORMAL")
    local hardShort = L("HUNT_SHORT_HARD")
    local nightmareShort = L("HUNT_SHORT_NIGHTMARE")
    if type(prey) ~= "table" then
        return COLORS.unknown .. normalShort .. " -   " .. hardShort .. " -   "
            .. nightmareShort .. " -|r"
    end
    local function Short(label, entry)
        if type(entry) ~= "table" then return COLORS.unknown .. label .. " -|r" end
        local current, maximum = entry.current, entry.maximum
        if type(current) ~= "number" or type(maximum) ~= "number" then
            return COLORS.unknown .. label .. " -|r"
        end
        local color = current >= maximum and COLORS.green or (current > 0 and COLORS.amber or COLORS.red)
        return color .. label .. " " .. current .. "/" .. maximum .. "|r"
    end
    return Short(normalShort, prey.normal) .. "   " .. Short(hardShort, prey.hard)
        .. "   " .. Short(nightmareShort, prey.nightmare)
end

local function RitualText(ritual, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    if type(ritual) ~= "table" then return COLORS.unknown .. "-|r" end
    if ritual.completed == true then return COLORS.green .. L("RITUAL_DONE") .. "|r" end
    if ritual.active == false then return COLORS.unknown .. L("STATUS_NOT_ACTIVE") .. "|r" end
    if type(ritual.percent) == "number" then
        local color = ritual.percent > 0 and COLORS.amber or COLORS.red
        return color .. math.floor(ritual.percent) .. "%|r"
    end
    return COLORS.unknown .. "-|r"
end

local function SeasonalSourceText(source, reward)
    if type(source) ~= "table" then return COLORS.unknown .. "-|r" end
    if source.completed == true then return COLORS.green .. L("CELL_SEASONAL_DONE", reward) .. "|r" end
    if source.active == true then return COLORS.amber .. L("CELL_SEASONAL_ACTIVE", reward) .. "|r" end
    if source.completed == false then return COLORS.red .. L("CELL_SEASONAL_OPEN", reward) .. "|r" end
    return COLORS.unknown .. "-|r"
end

local function GildedSourceText(weekly, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    local gilded = weekly.gilded
    if type(gilded) ~= "table" or type(gilded.current) ~= "number" or type(gilded.maximum) ~= "number" then
        return COLORS.unknown .. "-|r"
    end
    local perStash = WAT.Data and WAT.Data.GILDED_MYTH_PER_STASH
    if type(perStash) ~= "number" then return StatusFraction(gilded.current, gilded.maximum, false) end
    local color = gilded.current >= gilded.maximum and COLORS.green
        or (gilded.current > 0 and COLORS.amber or COLORS.red)
    return string.format("%s%d/%d / %d/%d M|r", color, gilded.current, gilded.maximum,
        gilded.current * perStash, gilded.maximum * perStash)
end

-- Dundun-Splitter (character.resources.dundun): ein Offline-Ressourcen-Snapshot,
-- kein Wochenwert - deshalb ohne stale-Textersatz wie StatusFraction, nur
-- gedimmt wie die Spalte "Letztes Update". maxQuantity <= 0 wird bereits im
-- Scanner als nil gespeichert und bedeutet hier "kein darstellbares Maximum".
local function DundunCellText(resources, stale)
    local dundun = type(resources) == "table" and resources.dundun or nil
    if type(dundun) ~= "table" or type(dundun.quantity) ~= "number" then
        return COLORS.unknown .. "-|r"
    end
    local color = stale and COLORS.stale or "|cffd8e0e7"
    if type(dundun.maxQuantity) == "number" and dundun.maxQuantity > 0 then
        return string.format("%s%d/%d|r", color, dundun.quantity, dundun.maxQuantity)
    end
    return color .. tostring(dundun.quantity) .. "|r"
end

-- Der volle, clientlokalisierte Name kommt zur Renderzeit aus C_CurrencyInfo,
-- wie schon CrestIcon oben - nie aus dem gespeicherten Snapshot, dessen
-- Sprache aus dem letzten Scan stammen kann. Ohne lesbaren Namen greift der
-- eigene, uebersetzte Ersatztext DUNDUN_NAME_FALLBACK.
local function DundunCurrencyName(currencyID)
    if type(currencyID) ~= "number" then return nil end
    local getter = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo
    if not getter then return nil end
    local ok, info = pcall(getter, currencyID)
    if not ok or (issecretvalue and issecretvalue(info)) or type(info) ~= "table" then return nil end
    local name = info.name
    if (issecretvalue and issecretvalue(name)) or type(name) ~= "string" or name == "" then return nil end
    return name
end

local function AddTooltipLine(label, value)
    GameTooltip:AddDoubleLine(label, value, 0.65, 0.7, 0.78, 0.93, 0.95, 0.97)
end

-- Offline-Ressourcen-Snapshot im Wappenquellen-Tooltip: lokalisierter Name,
-- Wert (mit bekanntem Maximum als Bruch), Reichweite laut API (nie erfunden,
-- wenn isAccountWide unlesbar ist), Erfassungsalter und der ausdrueckliche
-- Hinweis, dass dies kein abgeschlossener Wochenwert ist.
local function ShowDundunTooltip(character)
    local resources = type(character.resources) == "table" and character.resources or {}
    local dundun = type(resources.dundun) == "table" and resources.dundun or nil
    local currencyID = (type(dundun) == "table" and type(dundun.currencyID) == "number" and dundun.currencyID)
        or (WAT.Data and WAT.Data.DUNDUN_CURRENCY_ID)
    local name = DundunCurrencyName(currencyID) or L("DUNDUN_NAME_FALLBACK")
    if type(dundun) ~= "table" or type(dundun.quantity) ~= "number" then
        AddTooltipLine(name, L("STATUS_UNKNOWN"))
        return
    end
    local valueText = (type(dundun.maxQuantity) == "number" and dundun.maxQuantity > 0)
        and string.format("%d/%d", dundun.quantity, dundun.maxQuantity)
        or tostring(dundun.quantity)
    AddTooltipLine(name, valueText)
    local scopeText = L("STATUS_UNKNOWN")
    if dundun.isAccountWide == true then
        scopeText = L("DUNDUN_SCOPE_ACCOUNT")
    elseif dundun.isAccountWide == false then
        scopeText = L("DUNDUN_SCOPE_CHARACTER")
    end
    AddTooltipLine(L("DUNDUN_SCOPE"), scopeText)
    AddTooltipLine(L("KEY_RECORDED"), FormatAge(dundun.updated))
    GameTooltip:AddLine(L("DUNDUN_OFFLINE_NOTE"), 0.56, 0.6, 0.66, true)
end

-- Panra/Cataline: ein rein kosmetisches Easter Egg. Es liest ausschliesslich
-- bereits vorhandene Charakterdaten, schreibt nie in die Datenbank und
-- aendert weder Layout noch Popup/Chat - nur eine einzelne zusaetzliche
-- Tooltipzeile im Wappenquellen-Tooltip, wenn BEIDE Bedingungen zugleich
-- erfuellt sind. Spezialisierung/Rolle werden nirgends gelesen.
local function LowerSafe(value)
    if (issecretvalue and issecretvalue(value)) or type(value) ~= "string" then return nil end
    return string.lower(value)
end

local function HasEasterEggPair()
    local characters = WAT.db and WAT.db.characters
    if type(characters) ~= "table" then return false end
    local hasPanra, hasCataline = false, false
    for _, entry in pairs(characters) do
        if type(entry) == "table" then
            local name = LowerSafe(entry.name)
            local classFile = type(entry.classFile) == "string" and entry.classFile or nil
            if name == "panra" and classFile == "WARRIOR" and LowerSafe(entry.raceFile) == "tauren" then
                hasPanra = true
            elseif name == "cataline" and classFile == "PALADIN" then
                hasCataline = true
            end
        end
    end
    return hasPanra and hasCataline
end

local function EasterEggLine(character)
    local name = LowerSafe(type(character) == "table" and character.name or nil)
    local isPanra = name == "panra" and character.classFile == "WARRIOR"
        and LowerSafe(character.raceFile) == "tauren"
    local isCataline = name == "cataline" and character.classFile == "PALADIN"
    if not isPanra and not isCataline then return nil end
    if not HasEasterEggPair() then return nil end
    return L("EASTER_EGG_DUNDUN")
end

local function CrestTooltip(weekly)
    local crests = type(weekly.crests) == "table" and weekly.crests or {}
    local definitions = WAT.Data and WAT.Data.CRESTS or {}
    for _, key in ipairs({ "champion", "hero", "myth" }) do
        local entry = crests[key]
        if key == "myth" and type(entry) ~= "table" then entry = weekly.mythCrests end
        local definition = definitions[key] or {}
        local label = type(definition.labelKey) == "string"
            and L("CREST_TOOLTIP_LABEL", L(definition.labelKey)) or L("CREST_GENERIC")
        local quantity = type(entry) == "table" and entry.quantity
        local value = type(quantity) == "number" and tostring(quantity) or "-"
        if type(entry) == "table" and type(entry.earnedThisWeek) == "number"
                and type(entry.weeklyMaximum) == "number" and entry.weeklyMaximum > 0 then
            value = value .. L("CREST_WEEK_SUFFIX", entry.earnedThisWeek, entry.weeklyMaximum)
        end
        AddTooltipLine(label, value)
    end
end

local function ShowOverviewTooltip(character, weekly, stale)
    -- character.className kommt clientlokalisiert aus UnitClass und wird
    -- deshalb unveraendert durchgereicht, nicht selbst uebersetzt.
    AddTooltipLine(L("TOOLTIP_CLASS"), character.className or "-")
    local itemLevel = type(character.itemLevel) == "number" and string.format("%.1f", character.itemLevel) or "-"
    AddTooltipLine(L("TOOLTIP_EQUIPPED_ILVL"), itemLevel)
    AddTooltipLine(L("TOOLTIP_WEEK_STATE"),
        stale and L("TOOLTIP_WEEK_STALE") or L("STATUS_CURRENT"))
    GameTooltip:AddLine(" ")
    local gilded = type(weekly.gilded) == "table" and weekly.gilded or {}
    local gildedText = type(gilded.current) == "number" and type(gilded.maximum) == "number"
        and string.format("%d/%d", gilded.current, gilded.maximum) or L("GILDED_NOT_SEEN")
    AddTooltipLine(L("GILDED_STASH"), gildedText)
    CrestTooltip(weekly)
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(L("TOOLTIP_WORLD_VAULT"), COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3])
    for line in string.gmatch(WAT:GetVaultTooltip(weekly.worldVault, L("VAULT_LEVEL_LABEL_WORLD")), "[^\n]+") do
        GameTooltip:AddLine(line, 0.92, 0.95, 0.97, true)
    end
    GameTooltip:AddLine(L("TOOLTIP_MYTHIC_VAULT"), COLORS.violet[1], COLORS.violet[2], COLORS.violet[3])
    for line in string.gmatch(WAT:GetVaultTooltip(weekly.mythicPlusVault, L("VAULT_LEVEL_LABEL_MYTHIC")), "[^\n]+") do
        GameTooltip:AddLine(line, 0.92, 0.95, 0.97, true)
    end
    local mythicPlusTen = WAT:GetMythicPlusLevelStatus(weekly.mythicPlusVault, 10)
    local mythicPlusTenText = mythicPlusTen == true and L("MYTHIC10_YES")
        or (mythicPlusTen == false and L("MYTHIC10_NO") or L("STATUS_UNKNOWN"))
    AddTooltipLine(L("TOOLTIP_MYTHIC10"), mythicPlusTenText)
end

local function ShowMidnightTooltip(weekly)
    local midnight = weekly.midnightWeekly
    local midnightValue = L("STATUS_UNKNOWN")
    if type(midnight) == "table" then
        midnightValue = MidnightWeeklyLabel(midnight)
            or (midnight.completed and L("STATUS_DONE") or L("STATUS_NOT_ACTIVE"))
    end
    AddTooltipLine(L("TOOLTIP_MIDNIGHT_WEEKLY"), midnightValue)
    local prey = type(weekly.prey) == "table" and weekly.prey or {}
    local function HuntValue(entry)
        if type(entry) ~= "table" or type(entry.current) ~= "number" or type(entry.maximum) ~= "number" then return "-" end
        return string.format("%d/%d", entry.current, entry.maximum)
    end
    AddTooltipLine(L("HUNT_NORMAL"), HuntValue(prey.normal))
    AddTooltipLine(L("HUNT_HARD"), HuntValue(prey.hard))
    AddTooltipLine(L("HUNT_NIGHTMARE"), HuntValue(prey.nightmare))
    local ritual = weekly.ritualSites
    local ritualValue = type(ritual) == "table" and type(ritual.percent) == "number"
        and math.floor(ritual.percent) .. "%" or L("STATUS_UNKNOWN")
    AddTooltipLine(L("RITUAL_SITES"), ritualValue)
end

local function FindProfessionProgress(character, weeklyProfession, index)
    local progress = type(character.professions) == "table" and character.professions or {}
    local baseSkillLineID = type(weeklyProfession) == "table" and weeklyProfession.baseSkillLineID or nil
    if type(baseSkillLineID) == "number" then
        for candidateIndex = 1, 2 do
            local candidate = progress[candidateIndex]
            if type(candidate) == "table" and candidate.baseSkillLineID == baseSkillLineID then
                return candidate
            end
        end
    end
    return type(progress[index]) == "table" and progress[index] or nil
end

-- Berufsname zur Renderzeit ueber die baseSkillLineID clientlokalisiert
-- beziehen. Der im Snapshot gespeicherte Name stammt aus der Sprache, in der
-- zuletzt gescannt wurde, und ist deshalb nur der letzte Fallback.
local function ProfessionDisplayName(profession, progress)
    local baseSkillLineID = type(progress) == "table" and progress.baseSkillLineID
        or (type(profession) == "table" and profession.baseSkillLineID) or nil
    local lines = WAT.Data and WAT.Data.MIDNIGHT_PROFESSION_SKILL_LINES
    local midnightSkillLineID = type(lines) == "table" and type(baseSkillLineID) == "number"
        and lines[baseSkillLineID] or nil
    local getter = C_TradeSkillUI and C_TradeSkillUI.GetProfessionInfoBySkillLineID
    if getter and type(midnightSkillLineID) == "number" then
        local ok, info = pcall(getter, midnightSkillLineID)
        if ok and not (issecretvalue and issecretvalue(info)) and type(info) == "table" then
            local name = info.professionName
            if not (issecretvalue and issecretvalue(name)) and type(name) == "string" and name ~= "" then
                return name
            end
        end
    end
    local stored = type(profession) == "table" and profession.name
        or (type(progress) == "table" and progress.name) or nil
    return type(stored) == "string" and stored or nil
end

-- Liest ein Wahrheitsflag, ohne ein sicheres false zu verlieren. Unbekannt bleibt nil.
local function ProfessionFlag(profession, key)
    if type(profession) ~= "table" then return nil end
    local value = profession[key]
    if type(value) ~= "boolean" then return nil end
    return value
end

local function ProfessionSkillText(progress)
    if type(progress) ~= "table" or type(progress.skillLevel) ~= "number"
            or type(progress.maxSkillLevel) ~= "number" then
        return COLORS.unknown .. "-|r"
    end
    local color = progress.skillLevel >= progress.maxSkillLevel and COLORS.green or COLORS.amber
    return string.format("%s%d/%d|r", color, progress.skillLevel, progress.maxSkillLevel)
end

local function ProfessionKnowledgeText(progress)
    if type(progress) ~= "table" then return COLORS.unknown .. "- / -|r" end
    local freeNumber = type(progress.unspentKnowledge) == "number" and progress.unspentKnowledge or nil
    local bagNumber = type(progress.bagKnowledgePoints) == "number" and progress.bagKnowledgePoints or nil
    local free = freeNumber ~= nil and tostring(freeNumber) or "-"
    local bag = bagNumber ~= nil and tostring(bagNumber) or "-"
    local hasKnowledge = (freeNumber ~= nil and freeNumber > 0)
        or (bagNumber ~= nil and bagNumber > 0)
    local color = hasKnowledge and COLORS.amber or "|cffb0bac6"
    return color .. free .. " / " .. bag .. "|r"
end

local function ProfessionWeeklyText(profession, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    local quest = type(profession) == "table" and profession.weeklyQuest or nil
    if type(quest) == "table" then
        if quest.turnedIn == true then
            return COLORS.green .. L("STATUS_TURNED_IN") .. "|r"
        end
        if quest.readyToTurnIn == true then
            return COLORS.amber .. L("STATUS_READY_TO_TURN_IN") .. "|r"
        end
        if quest.active == true and type(quest.current) == "number"
                and type(quest.required) == "number" then
            return COLORS.amber .. string.format("%s/%s", tostring(quest.current),
                tostring(quest.required)) .. "|r"
        end
        if quest.completed == true then
            return COLORS.green .. L("STATUS_DONE") .. "|r"
        end
        if quest.active == true then
            return COLORS.amber .. L("STATUS_ACTIVE") .. "|r"
        end
    end
    return BooleanStatus(ProfessionFlag(profession, "weeklyDone"), false)
end

local function KnowledgeItemName(itemID)
    local getter = C_Item and C_Item.GetItemNameByID
    if getter and type(itemID) == "number" then
        local ok, name = pcall(getter, itemID)
        if ok and not (issecretvalue and issecretvalue(name)) and type(name) == "string" then return name end
    end
    if GetItemInfo and type(itemID) == "number" then
        local ok, name = pcall(GetItemInfo, itemID)
        if ok and not (issecretvalue and issecretvalue(name)) and type(name) == "string" then return name end
    end
    -- Der Gegenstandsname kommt clientlokalisiert aus der API. Nur wenn er
    -- gar nicht lesbar ist, greift der eigene, uebersetzte Ersatztext.
    return type(itemID) == "number" and L("ITEM_FALLBACK", itemID) or L("ITEM_UNKNOWN")
end

local function ShowProfessionTooltip(character, weekly)
    local professions = type(weekly.professions) == "table" and weekly.professions or {}
    for index = 1, 2 do
        local profession = professions[index]
        local progress = FindProfessionProgress(character, profession, index)
        -- Der Berufsname stammt aus GetProfessionInfo und ist damit bereits
        -- clientlokalisiert; nur der Ersatztext ist eigener Text.
        local name = ProfessionDisplayName(profession, progress) or L("STATUS_NOT_TRACKED")
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(L("PROF_HEADER", index, name),
            COLORS.violet[1], COLORS.violet[2], COLORS.violet[3])
        AddTooltipLine(L("PROF_MIDNIGHT_SKILL"), type(progress) == "table"
            and type(progress.skillLevel) == "number" and type(progress.maxSkillLevel) == "number"
            and string.format("%d/%d", progress.skillLevel, progress.maxSkillLevel) or L("STATUS_UNKNOWN"))
        AddTooltipLine(L("PROF_FREE_KNOWLEDGE"), type(progress) == "table"
            and type(progress.unspentKnowledge) == "number" and tostring(progress.unspentKnowledge)
            or L("STATUS_UNKNOWN"))
        local bagText = L("STATUS_UNKNOWN")
        if type(progress) == "table" and type(progress.bagKnowledgePoints) == "number" then
            local itemCount = type(progress.bagKnowledgeItems) == "number" and progress.bagKnowledgeItems or nil
            bagText = itemCount ~= nil
                and L("PROF_BAG_FROM_ITEMS", progress.bagKnowledgePoints, itemCount)
                or L("PROF_BAG_COUNT_UNKNOWN", progress.bagKnowledgePoints)
        end
        AddTooltipLine(L("PROF_BAG_KNOWLEDGE"), bagText)
        if type(progress) == "table" and type(progress.bagKnowledgeDetails) == "table" then
            for _, detail in ipairs(progress.bagKnowledgeDetails) do
                if type(detail) == "table" and type(detail.itemID) == "number"
                        and type(detail.count) == "number" and type(detail.totalPoints) == "number" then
                    GameTooltip:AddLine(L("PROF_KNOWLEDGE_DETAIL",
                        KnowledgeItemName(detail.itemID), detail.count, detail.totalPoints),
                        0.75, 0.8, 0.86, true)
                end
            end
        end
        local function FlagText(key)
            if type(profession) ~= "table" then return L("STATUS_UNKNOWN") end
            if profession[key] == true then return L("STATUS_DONE") end
            if profession[key] == false then return L("STATUS_OPEN") end
            return L("STATUS_UNKNOWN")
        end
        AddTooltipLine(L("PROF_WEEKLY_QUEST"), FlagText("weeklyDone"))
        AddTooltipLine(L("PROF_TREATISE"), FlagText("treatiseDone"))
        AddTooltipLine(L("PROF_PROGRESS_RECORDED"),
            type(progress) == "table" and FormatAge(progress.updated) or "-")
    end
end

-- Der Erfolgsname wird niemals selbst uebersetzt, sondern sicher aus
-- GetAchievementInfo geholt. Ohne lesbaren Namen bleibt der generische Text.
local function AchievementName(achievementID)
    if not GetAchievementInfo or type(achievementID) ~= "number" then return nil end
    local result = { pcall(GetAchievementInfo, achievementID) }
    if not result[1] then return nil end
    local name = result[3]
    if (issecretvalue and issecretvalue(name)) or type(name) ~= "string" or name == "" then return nil end
    return name
end

-- Feste Addon-Konstanten aus Data.lua, nicht gescannte Aktivitaetswerte.
-- Ein fehlender Wert waere ein Paketfehler, kein "unbekannt" des Spielers,
-- deshalb ist hier ein numerischer Ersatzwert zulaessig.
local function Constant(value)
    return WAT.SafeNumber(value, 0)
end

-- Questtitel werden ausschliesslich zur Renderzeit aus dem Client gelesen.
-- Schlaegt die API fehl, liefert einen Secret Value oder keinen Namen, bleibt
-- die sprachneutrale Quest-ID sichtbar; ein englischer Name wird nie geraten.
local function ShowdownQuestName(state)
    local questID = type(state) == "table" and state.questID or nil
    if type(questID) ~= "number" then return nil end
    local getter = C_QuestLog and C_QuestLog.GetTitleForQuestID
    if getter then
        local ok, title = pcall(getter, questID)
        if ok and not (issecretvalue and issecretvalue(title))
                and type(title) == "string" and title ~= "" then
            return title
        end
    end
    return L("SRC_SHOWDOWN_QUEST_FALLBACK", questID)
end

local function ShowdownSlotText(state)
    if type(state) ~= "table" then return L("STATUS_UNKNOWN") end
    local questName = ShowdownQuestName(state)
    if state.turnedIn == true then
        return questName and L("SRC_SHOWDOWN_SLOT_DONE", questName) or L("STATUS_DONE")
    end
    if state.active == true then
        return questName and L("SRC_SHOWDOWN_SLOT_ACTIVE", questName) or L("STATUS_ACTIVE")
    end
    if state.turnedIn == false then return L("STATUS_OPEN") end
    return L("STATUS_UNKNOWN")
end

local function HeroicShowdownText(showdown, stale)
    if stale then return COLORS.stale .. L("STATUS_STALE_WEEK") .. "|r" end
    if type(showdown) ~= "table" or type(showdown.earned) ~= "number"
            or type(showdown.maximum) ~= "number" then
        return COLORS.unknown .. "-|r"
    end
    local color = stale and COLORS.stale or (showdown.earned >= showdown.maximum
        and COLORS.green or (showdown.earned > 0 and COLORS.amber or COLORS.red))
    return color .. string.format("%d/%d", showdown.earned, showdown.maximum) .. "|r"
end

local function ShowSourcesTooltip(character, weekly, stale)
    local data = WAT.Data or {}
    local season = type(character.season) == "table" and character.season or {}
    local seasonal = type(season.crestSources) == "table" and season.crestSources or {}
    local sources = type(weekly.crestSources) == "table" and weekly.crestSources or {}
    if stale then
        AddTooltipLine(L("TOOLTIP_WEEK_STATE"), L("TOOLTIP_WEEK_STALE"))
    end
    local gilded = type(weekly.gilded) == "table" and weekly.gilded or {}
    local gildedValue = type(gilded.current) == "number" and type(gilded.maximum) == "number"
        and L("SRC_GILDED_VALUE", gilded.current, gilded.maximum, Constant(data.GILDED_MYTH_PER_STASH))
        or L("STATUS_UNKNOWN")
    AddTooltipLine(L("SRC_GILDED_WEEKLY"), gildedValue)
    local showdown = sources.heroicShowdowns
    local showdownValue = L("STATUS_UNKNOWN")
    if type(showdown) == "table" and type(showdown.earned) == "number"
            and type(showdown.maximum) == "number" then
        showdownValue = L("SRC_SHOWDOWN_VALUE", showdown.earned, showdown.maximum,
            type(showdown.mythPerSlot) == "number" and showdown.mythPerSlot
                or Constant(data.HEROIC_SHOWDOWN_MYTH_PER_SLOT))
    end
    AddTooltipLine(L("SRC_SHOWDOWN"), showdownValue)
    AddTooltipLine(L("SRC_SHOWDOWN_MAIN"),
        type(showdown) == "table" and ShowdownSlotText(showdown.main) or L("STATUS_UNKNOWN"))
    AddTooltipLine(L("SRC_SHOWDOWN_FOLLOWUP"),
        type(showdown) == "table" and ShowdownSlotText(showdown.followup) or L("STATUS_UNKNOWN"))
    local cracked = seasonal.crackedKeystone
    local crackedText = L("STATUS_UNKNOWN")
    if type(cracked) == "table" then
        if cracked.completed then
            crackedText = L("SRC_CRACKED_DONE", Constant(data.CRACKED_KEYSTONE_MYTH_REWARD),
                Constant(data.CRACKED_KEYSTONE_HERO_REWARD))
        else
            crackedText = cracked.active and L("STATUS_ACTIVE") or L("STATUS_OPEN")
        end
    end
    AddTooltipLine(L("SRC_CRACKED"), crackedText)
    local nullaeus = seasonal.nullaeusT11
    local nullaeusReward = Constant(data.NULLAEUS_T11_MYTH_REWARD)
    AddTooltipLine(L("SRC_NULLAEUS"), type(nullaeus) == "table"
        and (nullaeus.completed and L("SRC_NULLAEUS_DONE", nullaeusReward)
            or L("SRC_NULLAEUS_OPEN", nullaeusReward)) or L("STATUS_UNKNOWN"))
    AddTooltipLine(L("SRC_RITUAL_T6"), L("SRC_RITUAL_T6_VALUE", Constant(data.RITUAL_T6_MYTH_PER_RUN)))
    local mythicPlus = sources.mythicPlus
    local highest = type(mythicPlus) == "table" and mythicPlus.highestObservedLevel or nil
    AddTooltipLine(L("SRC_MYTHIC"), type(highest) == "number"
        and L("SRC_MYTHIC_OBSERVED", highest) or L("SRC_MYTHIC_GENERIC"))
    local exchange = sources.heroToMyth
    local exchangeText = L("STATUS_UNKNOWN")
    if type(exchange) == "table" and exchange.unlocked == false then
        local name = AchievementName(data.HERO_TO_MYTH_ACHIEVEMENT_ID)
        exchangeText = name and L("SRC_EXCHANGE_LOCKED", name) or L("SRC_EXCHANGE_LOCKED_GENERIC")
    elseif type(exchange) == "table" and exchange.unlocked == true then
        exchangeText = type(exchange.mythPotential) == "number"
            and L("SRC_EXCHANGE_POTENTIAL", exchange.mythPotential)
            or L("SRC_EXCHANGE_UNLOCKED_UNKNOWN")
    end
    AddTooltipLine(L("SRC_EXCHANGE"), exchangeText)
    GameTooltip:AddLine(" ")
    ShowDundunTooltip(character)
    local easterEgg = EasterEggLine(character)
    if easterEgg then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(easterEgg, 0.62, 0.82, 0.7, true)
    end
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(L("SRC_FOOTNOTE"), 0.56, 0.6, 0.66, true)
end

-- Dungeonname zur Renderzeit aus C_ChallengeMode.GetMapUIInfo. Bewusst OHNE
-- Rueckgriff auf den gespeicherten Namen: der stammt aus der Sprache des
-- letzten Scans und waere nach einem Sprachwechsel fremdsprachig. Ohne
-- lesbaren Namen zeigt die UI stattdessen die sprachneutrale Dungeon-ID.
local function DungeonDisplayName(keystone)
    local mapID = type(keystone) == "table" and keystone.mapID or nil
    if type(mapID) ~= "number" then return nil end
    local getter = C_ChallengeMode and C_ChallengeMode.GetMapUIInfo
    if getter then
        local ok, name = pcall(getter, mapID)
        if ok and not (issecretvalue and issecretvalue(name))
                and type(name) == "string" and name ~= "" then
            return name
        end
    end
    return L("KEY_DUNGEON_ID", mapID)
end

local function ShowKeystoneTooltip(weekly, stale)
    local keystone = type(weekly.keystone) == "table" and weekly.keystone or nil
    if stale then AddTooltipLine(L("TOOLTIP_WEEK_STATE"), L("TOOLTIP_WEEK_STALE")) end
    if not keystone then
        AddTooltipLine(L("KEY_KEYSTONE"), L("STATUS_NOT_TRACKED"))
        return
    end
    if keystone.hasKey == false then
        AddTooltipLine(L("KEY_KEYSTONE"), L("KEY_NONE"))
        AddTooltipLine(L("KEY_RECORDED"), FormatAge(keystone.updated))
        return
    end
    if keystone.hasKey ~= true then
        AddTooltipLine(L("KEY_KEYSTONE"), L("STATUS_UNKNOWN"))
        return
    end
    AddTooltipLine(L("KEY_DUNGEON"), DungeonDisplayName(keystone) or L("STATUS_UNKNOWN"))
    AddTooltipLine(L("KEY_LEVEL"), type(keystone.level) == "number" and "+" .. keystone.level or "-")
    AddTooltipLine(L("KEY_MAP_ID"), type(keystone.mapID) == "number" and tostring(keystone.mapID) or "-")
    AddTooltipLine(L("KEY_RECORDED"), FormatAge(keystone.updated))
end

-- ---------------------------------------------------------------------------
-- Erfolgsstatistiken
--
-- Lebenslange Werte: sie veralten nicht mit der Woche und werden deshalb nie
-- als "alte Woche" ausgegraut. Unbekannt bleibt ein Strich - ein fehlender
-- Wert darf nie als 0 erscheinen, sonst waere ein nie eingeloggter Charakter
-- von einem Charakter mit echten 0 Toden nicht mehr zu unterscheiden.
-- ---------------------------------------------------------------------------

-- Der Schluessel ist entweder eine numerische Statistik-ID oder der
-- sprachneutrale Stringschluessel eines abgeleiteten Werts. Beide liegen im
-- selben Container.
local function StatisticValue(character, key)
    if type(character) ~= "table" then return nil end
    if type(key) ~= "number" and (type(key) ~= "string" or key == "") then return nil end
    local store = character.statistics
    if type(store) ~= "table" then return nil end
    local entry = store[key]
    if type(entry) ~= "table" or type(entry.value) ~= "number" then return nil end
    return entry.value
end

-- Kompakte, lokalisierte Dauer. Die Einheiten stehen im Woerterbuch, die
-- Zerlegung selbst ist sprachneutral. Eine echte Null bleibt eine Null: sie
-- ist ein gemessener Wert und darf nicht wie "unbekannt" aussehen.
local function FormatDuration(seconds)
    if type(seconds) ~= "number" or seconds < 0 then return nil end
    local days = math.floor(seconds / 86400)
    local hours = math.floor((seconds % 86400) / 3600)
    local minutes = math.floor((seconds % 3600) / 60)
    if days > 0 then
        return days .. L("DURATION_UNIT_DAYS") .. " " .. hours .. L("DURATION_UNIT_HOURS")
    end
    if hours > 0 then
        return hours .. L("DURATION_UNIT_HOURS") .. " " .. minutes .. L("DURATION_UNIT_MINUTES")
    end
    return minutes .. L("DURATION_UNIT_MINUTES")
end

-- Ein abgeleiteter Wert mit kind = "duration" wird als Dauer dargestellt,
-- alles andere als blanke Zahl. Bewusst string.format statt tostring: unter
-- Lua 5.1 kippt tostring grosse Zahlen in die Exponentialschreibweise
-- ("1.2345678901234e+14"), und genau solche Werte kommen hier vor.
local function StatisticDisplayValue(definition, value)
    if type(value) ~= "number" then return nil end
    if type(definition) == "table" and definition.kind == "duration" then
        return FormatDuration(value)
    end
    return string.format("%.0f", value)
end

-- Kompakte Darstellung grosser Zahlen fuer die TABELLENZELLE. Der Parser in
-- Activities.lua akzeptiert 15-stellige Statistikwerte; ausgeschrieben passt
-- so einer in keine Spalte und liefe in den Nachbarn.
--
-- Bewusst ohne Dezimaltrennzeichen: Punkt und Komma haben je nach Clientsprache
-- die umgekehrte Bedeutung, "1.5M" waere in deDE als 15 Millionen lesbar.
-- Abgerundete Ganzzahlen mit lokalisierter Einheit sind in jeder Sprache
-- eindeutig. Die Schwellen sind so gewaehlt, dass die Zelle nie mehr als fuenf
-- Ziffern plus Einheit traegt.
local COMPACT_UNITS = {
    { threshold = 1e14, divisor = 1e12, key = "NUMBER_UNIT_TRILLION" },
    { threshold = 1e11, divisor = 1e9, key = "NUMBER_UNIT_BILLION" },
    { threshold = 1e8, divisor = 1e6, key = "NUMBER_UNIT_MILLION" },
    { threshold = 1e5, divisor = 1e3, key = "NUMBER_UNIT_THOUSAND" },
}

local function CompactNumber(value)
    if type(value) ~= "number" then return nil end
    local sign = value < 0 and "-" or ""
    local magnitude = math.abs(value)
    for _, unit in ipairs(COMPACT_UNITS) do
        if magnitude >= unit.threshold then
            return sign .. string.format("%.0f", math.floor(magnitude / unit.divisor))
                .. L(unit.key)
        end
    end
    -- Unter der ersten Schwelle bleibt der Wert exakt: eine Abkuerzung waere
    -- dort Informationsverlust ohne jeden Platzgewinn.
    return sign .. string.format("%.0f", magnitude)
end

-- Was in der Zelle steht. Eine Dauer wird nie gekuerzt - "1T 1Std" ist bereits
-- kompakt, und eine Tausenderabkuerzung waere dort schlicht falsch.
local function StatisticCellValue(definition, value)
    if type(value) ~= "number" then return nil end
    if type(definition) == "table" and definition.kind == "duration" then
        return FormatDuration(value)
    end
    return CompactNumber(value)
end

local function StatisticCellText(text)
    if type(text) ~= "string" then return COLORS.unknown .. "-|r" end
    return "|cffd8e0e7" .. text .. "|r"
end

-- Summiert ausschliesslich sicher bekannte Charakterwerte. Kennt kein
-- Charakter den Wert, bleibt die Summe unbekannt statt 0 zu behaupten.
local function AccountStatisticTotal(characters, key)
    local total
    if type(characters) ~= "table" then return nil end
    for _, character in ipairs(characters) do
        local value = StatisticValue(character, key)
        if value ~= nil then
            if total == nil then total = 0 end
            total = total + value
        end
    end
    return total
end

-- Der Speicherschluessel eines Werts: die Statistik-ID bei direkten Werten,
-- der sprachneutrale Stringschluessel bei abgeleiteten.
local function StatisticStorageKey(definition)
    if type(definition) ~= "table" then return nil end
    if type(definition.statisticID) == "number" then return definition.statisticID end
    return type(definition.storageKey) == "string" and definition.storageKey or nil
end

-- Direkte und abgeleitete Werte in Anzeigereihenfolge. Menge und Schluessel
-- kommen ausschliesslich aus Data.lua.
local function StatisticDefinitions()
    local definitions = {}
    local data = WAT.Data
    -- Die Quellen werden einzeln angehaengt statt als Literaltabelle gebaut:
    -- fehlt Data.STATISTICS, haette ein Literal ein nil im ersten Slot und
    -- ipairs braeche sofort ab - die abgeleiteten Werte fielen still weg.
    local sources = {}
    if data then
        if type(data.STATISTICS) == "table" then sources[#sources + 1] = data.STATISTICS end
        if type(data.DERIVED_STATISTICS) == "table" then sources[#sources + 1] = data.DERIVED_STATISTICS end
    end
    for _, source in ipairs(sources) do
        for _, definition in ipairs(source) do
            definitions[#definitions + 1] = definition
        end
    end
    return definitions
end

-- Der Statistikname kommt clientlokalisiert aus GetAchievementInfo. Fuer die
-- abgeleiteten Werte gibt es keinen Erfolg und damit keinen Clientnamen: dort
-- ist der eigene uebersetzte Name die einzige Quelle.
local function StatisticDisplayName(definition)
    if type(definition.statisticID) == "number" then
        local name = AchievementName(definition.statisticID)
        if name then return name end
    end
    return type(definition.nameKey) == "string" and L(definition.nameKey) or L("STATUS_UNKNOWN")
end

-- Die Kartenbeschriftung. Die Spaltenkoepfe der alten Tabelle trugen ein
-- bewusstes "\n" ("TODE\nSCHLACHTZUG"); auf einer Karte steht die Beschriftung
-- einzeilig ueber dem Wert, deshalb wird der Umbruch hier zum Leerzeichen.
-- Das haelt Data.lua und die Woerterbuecher unveraendert - eine zweite
-- Beschriftungsquelle waere genau die zweite Wahrheit, die es nicht geben soll.
local function StatisticCardLabel(definition)
    if type(definition) ~= "table" or type(definition.labelKey) ~= "string" then
        return L("STATUS_UNKNOWN")
    end
    return (string.gsub(L(definition.labelKey), "\n", " "))
end

-- Der Tooltip einer Kennzahlkarte. Er traegt das, was die Karte selbst nicht
-- tragen kann: den vollen, NICHT abgekuerzten Wert, den Statistiknamen und die
-- Erklaerung des Werts. Die Kompaktdarstellung gilt ausschliesslich fuer die
-- Karte - sonst waere die Zahl unwiederbringlich verloren.
local function ShowStatisticCardTooltip(card, scope)
    local definition = card.definition
    if type(definition) ~= "table" then return end
    GameTooltip:SetOwner(card.frame, "ANCHOR_RIGHT")
    GameTooltip:ClearLines()
    GameTooltip:AddLine(StatisticDisplayName(definition),
        COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3])

    local key = StatisticStorageKey(definition)
    if scope.isTotal then
        local total = AccountStatisticTotal(scope.characters, key)
        AddTooltipLine(L("STAT_ACCOUNT_TOOLTIP"),
            StatisticDisplayValue(definition, total) or L("STAT_NOT_RECORDED"))
        GameTooltip:AddLine(L("STAT_ACCOUNT_HINT"), 0.56, 0.6, 0.66, true)
    else
        local character = scope.character
        local value = StatisticValue(character, key)
        AddTooltipLine(StatisticCardLabel(definition),
            StatisticDisplayValue(definition, value) or L("STAT_NOT_RECORDED"))
        local store = type(character) == "table" and type(character.statistics) == "table"
            and character.statistics or {}
        AddTooltipLine(L("STAT_RECORDED"), FormatAge(store.scanned))
    end

    -- Erklaerungen, die eine kurze Kartenbeschriftung nicht tragen kann: dass
    -- 932 betretene und keine abgeschlossenen Dungeons zaehlt, und woraus die
    -- Midnight-Summe entsteht.
    if type(definition.tooltipKey) == "string" then
        GameTooltip:AddLine(L(definition.tooltipKey), 0.56, 0.6, 0.66, true)
    end
    GameTooltip:AddLine(" ")
    -- Statistiken sind lebenslang, nicht woechentlich: der Hinweis erklaert
    -- deshalb den Offline-Stand, nicht den Wochenstand.
    GameTooltip:AddLine(L("STAT_OFFLINE_HINT"), 0.56, 0.6, 0.66, true)
    GameTooltip:Show()
end

function WAT:ShowCharacterTooltip(row)
    local character = row.character
    if not character then return end
    local weekly = type(character.weekly) == "table" and character.weekly or {}
    local stale = self:IsStale(character)
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    GameTooltip:ClearLines()
    local unknownName = L("CHARACTER_UNKNOWN")
    GameTooltip:AddLine((character.name or unknownName) .. " - " .. (character.realm or unknownName),
        COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3])
    if row.panelKey == "midnight" then
        ShowMidnightTooltip(weekly)
    elseif row.panelKey == "professions" then
        ShowProfessionTooltip(character, weekly)
    elseif row.panelKey == "sources" then
        ShowSourcesTooltip(character, weekly, stale)
    elseif row.panelKey == "keystones" then
        ShowKeystoneTooltip(weekly, stale)
    else
        ShowOverviewTooltip(character, weekly, stale)
    end
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(L("TOOLTIP_OFFLINE_HINT"), 0.56, 0.6, 0.66, true)
    GameTooltip:AddLine(L("TOOLTIP_DRAG_REORDER"), 0.56, 0.6, 0.66, true)
    GameTooltip:Show()
end

local function CreateNavButton(parent, definition, y)
    local button = CreateFrame("Button", nil, parent, "BackdropTemplate")
    button:SetSize(SIDEBAR_WIDTH, 42)
    button:SetPoint("TOPLEFT", 0, y)
    SetBackdrop(button, { 0, 0, 0, 0 }, { 1, 1, 1, 0 })

    local activeBackground = button:CreateTexture(nil, "BACKGROUND")
    activeBackground:SetAllPoints()
    activeBackground:SetColorTexture(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.10)
    activeBackground:Hide()

    local hover = button:CreateTexture(nil, "BACKGROUND", nil, 1)
    hover:SetAllPoints()
    hover:SetColorTexture(1, 1, 1, 0.045)
    hover:Hide()

    local indicator = button:CreateTexture(nil, "OVERLAY")
    indicator:SetPoint("TOPLEFT")
    indicator:SetPoint("BOTTOMLEFT")
    indicator:SetWidth(3)
    indicator:SetColorTexture(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 1)
    indicator:Hide()

    local marker = button:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    marker:SetPoint("LEFT", 18, 0)
    marker:SetTextColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.75)
    marker:SetText(">")

    local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    text:SetPoint("LEFT", 35, 0)
    text:SetJustifyH("LEFT")
    text:SetText(definition.label)  --definition.shortLabel or 
    text:SetTextColor(1, 1, 1, 0.54)

    button.label = text
    button.marker = marker
    button.indicator = indicator
    button.activeBackground = activeBackground
    button.hover = hover
    button:SetScript("OnEnter", function(self)
        if not self.active then self.hover:Show(); self.label:SetTextColor(1, 1, 1, 0.86) end
    end)
    button:SetScript("OnLeave", function(self)
        self.hover:Hide()
        if not self.active then self.label:SetTextColor(1, 1, 1, 0.54) end
    end)
    return button
end

local function CreatePanel(parent, key, definition)
    local panel = CreateFrame("Frame", nil, parent)
    panel:SetPoint("TOPLEFT", CONTENT_LEFT, -150)
    panel:SetPoint("BOTTOMRIGHT", -20, 48)
    panel.key = key
    -- Eine Seite ohne Spaltendefinition ist kein Fehlerfall, sondern eine
    -- Seite, die ihren Inhalt selbst aufbaut (Formular, Dashboard). Die
    -- generische Tabellenerstellung darf daran nicht scheitern: ohne diesen
    -- Fallback liefe LayoutColumns in ein nil und risse CreateUI mit sich.
    local columns = type(definition.columns) == "table" and definition.columns or {}
    panel.columns = columns

    local headerHeight = HEADER_HEIGHT
    panel.rowHeight = ROW_HEIGHT

    local header = CreateFrame("Frame", nil, panel, "BackdropTemplate")
    header:SetPoint("TOPLEFT")
    header:SetSize(CONTENT_WIDTH, headerHeight)
    SetBackdrop(header, { 0.025, 0.035, 0.047, 0.98 }, COLORS.line)
    local topLine = header:CreateTexture(nil, "OVERLAY")
    topLine:SetPoint("TOPLEFT")
    topLine:SetPoint("TOPRIGHT")
    topLine:SetHeight(1)
    topLine:SetColorTexture(1, 1, 1, 0.08)
    panel.headerCells = {}
    panel.headerLabels = {}
    LayoutColumns(columns, function(column, left)
        -- Derselbe Clipping-Rahmen wie in der Datenzeile: ein Spaltenkopf darf
        -- ebenso wenig in den Nachbarn laufen wie ein Wert.
        local cell = CreateFrame("Frame", nil, header)
        cell:SetPoint("LEFT", left, 0)
        cell:SetSize(column.width - 6, headerHeight - 6)
        cell:SetClipsChildren(true)
        local label = cell:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetAllPoints(cell)
        label:SetJustifyH(column.left and "LEFT" or "CENTER")
        label:SetJustifyV("MIDDLE")
        -- Anders als ein Wert darf ein Kopf zwei Zeilen nutzen: die Labels
        -- tragen dafuer ein bewusstes "\n" ("TODE\nSCHLACHTZUG"). Mehr als
        -- zwei Zeilen passen in die Bandhoehe nicht.
        label:SetMaxLines(2)
        label:SetTextColor(0.67, 0.71, 0.76)
        label:SetText(column.label)
        panel.headerCells[column.key] = cell
        panel.headerLabels[column.key] = label
    end)

    local scroll = CreateFrame("ScrollFrame", nil, panel, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -2)
    scroll:SetPoint("BOTTOMRIGHT", panel, "BOTTOMLEFT", CONTENT_WIDTH, 0)
    local child = CreateFrame("Frame", nil, scroll)
    child:SetSize(CONTENT_WIDTH, 1)
    scroll:SetScrollChild(child)
    panel.child = child
    panel.rows = {}
    return panel
end

-- ---------------------------------------------------------------------------
-- Drag-and-drop-Umsortierung
--
-- OnDragStart faengt nur den Ausgangspunkt: WoW capturet die Maus auf dem
-- Rahmen, der den Zug begonnen hat. Ein OnReceiveDrag auf einem fremden
-- Rahmen feuert dafuer NICHT - das ist ausschliesslich Cursor-Objekten wie
-- Items oder Zaubern vorbehalten, nicht einem per RegisterForDrag bewegten
-- eigenen Rahmen. Das Ziel wird deshalb erst in OnDragStop ermittelt:
-- GetMouseFoci (bzw. der aeltere Einzelname GetMouseFocus als Rueckfall)
-- liefert, was gerade unter dem Cursor liegt, unabhaengig vom Ausgangsrahmen.
--
-- Jede gepoolte Zeile und jeder Charakterreiter traegt dafuer sein eigenes
-- dragCharacterKey. Der GESAMT-Reiter der Statistikseite bekommt bewusst
-- weder Ziehskripte noch ein dragCharacterKey: er ist damit weder Quelle noch
-- Ziel einer Umsortierung. Dieser Block steht bewusst VOR
-- RefreshStatisticsDashboard/CreateRow, die AttachCharacterDragHandlers als
-- lokale Funktion referenzieren - Lua loest ein "local function" nur fuer
-- Code auf, der textuell danach steht.
-- ---------------------------------------------------------------------------

local function FindDragTargetKey()
    local getter = GetMouseFoci or GetMouseFocus
    if type(getter) ~= "function" then return nil end
    local ok, result = pcall(getter)
    if not ok or result == nil then return nil end
    if type(result) == "table" and result.dragCharacterKey == nil and type(result[1]) == "table" then
        -- GetMouseFoci liefert eine Liste, der oberste Treffer zuerst.
        result = result[1]
    end
    if type(result) ~= "table" then return nil end
    local key = result.dragCharacterKey
    return (type(key) == "string" and key ~= "") and key or nil
end

function WAT:BeginCharacterDrag(sourceKey)
    if type(sourceKey) ~= "string" or sourceKey == "" then return end
    self.dragCharacterKey = sourceKey
end

function WAT:EndCharacterDrag()
    local sourceKey = self.dragCharacterKey
    self.dragCharacterKey = nil
    if type(sourceKey) ~= "string" then return end
    local targetKey = FindDragTargetKey()
    if not targetKey or targetKey == sourceKey then return end
    if self:MoveCharacterOrder(sourceKey, targetKey) then
        self:RefreshUI()
    end
end

local function AttachCharacterDragHandlers(frame)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", function(self)
        if self.dragCharacterKey then WAT:BeginCharacterDrag(self.dragCharacterKey) end
    end)
    frame:SetScript("OnDragStop", function() WAT:EndCharacterDrag() end)
end

-- ---------------------------------------------------------------------------
-- Statistikseite: Bereichs-Dashboard mit fester Registerleiste
--
-- Aufbau von oben nach unten: drei Abschnitte mit Kennzahlkarten, darunter die
-- Registerleiste. Links in der Leiste steht fest die Accountsumme, rechts
-- daneben je ein Reiter pro Charakter in einem blaetternden Ausschnitt.
--
-- Die Geometrie ist statisch: es gibt IMMER dreizehn Karten, auch wenn eine
-- Definition zur Ladezeit fehlt. Eine Karte ohne Definition zeigt einen Strich,
-- statt die Seite umzubauen - so bleibt das Bild ueber alle Zustaende stabil.
-- ---------------------------------------------------------------------------

local function StatisticDefinitionsByKey()
    local map = {}
    for _, definition in ipairs(StatisticDefinitions()) do
        if type(definition.key) == "string" then map[definition.key] = definition end
    end
    return map
end

-- Farbe des aktiven Charakterreiters. Sie kommt aus der Klasse des Charakters;
-- ohne lesbare Klassenfarbe bleibt ein neutrales Hell.
local function ScopeTabColor(character)
    local classFile = type(character) == "table" and character.classFile or nil
    local color = classFile and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFile]
    if type(color) == "table" and type(color.r) == "number"
            and type(color.g) == "number" and type(color.b) == "number" then
        return { color.r, color.g, color.b }
    end
    return { 0.85, 0.88, 0.92 }
end

local function StyleScopeTab(tab, active, color)
    tab.active = active
    if active then
        tab:SetBackdropColor(color[1] * 0.20, color[2] * 0.20, color[3] * 0.20, 0.95)
        tab:SetBackdropBorderColor(color[1], color[2], color[3], 0.75)
        tab.label:SetTextColor(color[1], color[2], color[3], 1)
        return
    end
    -- Inaktiv bleibt im neutralen Midnight-Dunkel: weder tuerkis noch
    -- klassenfarbig, damit der aktive Reiter der einzige farbige Punkt ist.
    tab:SetBackdropColor(0.043, 0.058, 0.075, 0.90)
    tab:SetBackdropBorderColor(1, 1, 1, 0.10)
    tab.label:SetTextColor(1, 1, 1, 0.55)
end

local function CreateScopeTab(parent, width)
    local tab = CreateFrame("Button", nil, parent, "BackdropTemplate")
    tab:SetSize(width, TAB_HEIGHT)
    SetBackdrop(tab, { 0.043, 0.058, 0.075, 0.90 }, { 1, 1, 1, 0.10 })
    -- Ein zu langer Charaktername darf nicht in den Nachbarreiter laufen.
    -- SetWordWrap allein reicht dafuer nicht - erst dieser Rahmen schneidet ab.
    tab:SetClipsChildren(true)
    local label = tab:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    label:SetPoint("LEFT", 8, 0)
    label:SetPoint("RIGHT", -8, 0)
    label:SetJustifyH("CENTER")
    label:SetJustifyV("MIDDLE")
    label:SetWordWrap(false)
    label:SetMaxLines(1)
    tab.label = label
    return tab
end

local function CreateStatisticCard(section, width, left)
    local card = { frame = CreateFrame("Frame", nil, section, "BackdropTemplate") }
    card.frame:SetSize(width, CARD_HEIGHT)
    card.frame:SetPoint("TOPLEFT", left, -CARD_TOP)
    SetBackdrop(card.frame, { 0.043, 0.058, 0.075, 0.98 }, { 1, 1, 1, 0.055 })
    card.frame:SetClipsChildren(true)
    card.frame:EnableMouse(true)

    -- Knappe Beschriftung oben, prominenter Wert darunter. Die feste
    -- Zuordnung im selben Rahmen ist die sichtbare Bindung von Label und Wert.
    card.label = card.frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    card.label:SetPoint("TOPLEFT", 10, -9)
    card.label:SetPoint("TOPRIGHT", -10, -9)
    card.label:SetJustifyH("LEFT")
    card.label:SetWordWrap(false)
    card.label:SetMaxLines(1)
    card.label:SetTextColor(1, 1, 1, 0.40)

    card.value = card.frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    card.value:SetPoint("BOTTOMLEFT", 10, 11)
    card.value:SetPoint("BOTTOMRIGHT", -10, 11)
    card.value:SetJustifyH("LEFT")
    card.value:SetWordWrap(false)
    card.value:SetMaxLines(1)
    local valueFont, _, valueFlags = GameFontNormalLarge:GetFont()
    if valueFont then card.value:SetFont(valueFont, 20, valueFlags) end
    return card
end

local function CreateStatisticSection(panel, group, index)
    local section = CreateFrame("Frame", nil, panel)
    section:SetSize(CONTENT_WIDTH, DASHBOARD_SECTION_HEIGHT)
    section:SetPoint("TOPLEFT", 0,
        -((index - 1) * (DASHBOARD_SECTION_HEIGHT + DASHBOARD_GAP)))

    local title = section:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    title:SetPoint("TOPLEFT", 0, 0)
    title:SetTextColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.85)
    title:SetText(L(group.titleKey))

    local entry = { key = group.key, frame = section, title = title, cards = {} }
    -- Gleich breite Karten: eine ungleiche Breite laese sich als Rangfolge,
    -- die es hier nicht gibt. Die letzte Karte endet exakt auf CONTENT_WIDTH.
    local count = #group.keys
    local width = math.floor((CONTENT_WIDTH - (count - 1) * CARD_GAP) / count)
    for cardIndex, statKey in ipairs(group.keys) do
        local card = CreateStatisticCard(section, width, (cardIndex - 1) * (width + CARD_GAP))
        card.statKey = statKey
        entry.cards[cardIndex] = card
    end
    return entry
end

-- Definition und Beschriftung je Karte. Bewusst nicht nur beim Erstellen:
-- Data.lua kann zur Erstellungszeit unvollstaendig sein, und eine Karte ohne
-- Definition soll sich erholen, sobald die Quelle da ist.
local function BindStatisticCards(panel)
    local byKey = StatisticDefinitionsByKey()
    for _, group in ipairs(panel.groups) do
        for _, card in ipairs(group.cards) do
            local definition = byKey[card.statKey]
            card.definition = definition
            card.label:SetText(StatisticCardLabel(definition))
        end
    end
end

local function CreateStatisticsPanel(parent, definition)
    local panel = CreateFrame("Frame", nil, parent)
    panel:SetPoint("TOPLEFT", CONTENT_LEFT, -150)
    panel:SetPoint("BOTTOMRIGHT", -20, 48)
    panel.key = "statistics"
    -- Der Schalter, an dem RefreshUI erkennt, dass hier keine Charakterzeilen
    -- entstehen. rows bleibt leer und existiert nur, damit gemeinsame
    -- Hilfspfade nicht auf ein nil treffen.
    panel.isDashboard = true
    panel.rows = {}
    panel.groups = {}
    panel.cards = {}
    panel.scopeKey = TOTAL_SCOPE
    panel.tabOffset = 0
    panel.scope = {}

    for index, group in ipairs(STATISTIC_GROUPS) do
        local entry = CreateStatisticSection(panel, group, index)
        panel.groups[index] = entry
        for _, card in ipairs(entry.cards) do
            panel.cards[card.statKey] = card
            card.frame:SetScript("OnEnter", function()
                ShowStatisticCardTooltip(card, panel.scope)
            end)
            card.frame:SetScript("OnLeave", function() GameTooltip:Hide() end)
        end
    end
    BindStatisticCards(panel)

    -- -----------------------------------------------------------------------
    -- Registerleiste
    -- -----------------------------------------------------------------------

    local bar = CreateFrame("Frame", nil, panel)
    bar:SetSize(CONTENT_WIDTH, DASHBOARD_BAR_HEIGHT)
    bar:SetPoint("TOPLEFT", 0,
        -(3 * DASHBOARD_SECTION_HEIGHT + 3 * DASHBOARD_GAP))
    panel.tabBar = bar

    local barLine = bar:CreateTexture(nil, "ARTWORK")
    barLine:SetPoint("TOPLEFT", 0, 1)
    barLine:SetPoint("TOPRIGHT", 0, 1)
    barLine:SetHeight(1)
    barLine:SetColorTexture(1, 1, 1, 0.06)

    -- GESAMT haengt in der Leiste selbst, NICHT im blaetternden Ausschnitt.
    -- Nur so kann der wichtigste Bereich beim Blaettern nicht wegwandern.
    local totalTab = CreateScopeTab(bar, TOTAL_TAB_WIDTH)
    totalTab:SetPoint("TOPLEFT", 0, -3)
    totalTab.scopeKey = TOTAL_SCOPE
    totalTab.label:SetText(L("STAT_SCOPE_TOTAL"))
    totalTab:SetScript("OnClick", function() WAT:SetStatisticsScope(TOTAL_SCOPE) end)
    totalTab:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:ClearLines()
        GameTooltip:AddLine(L("STAT_ACCOUNT_TOOLTIP"),
            COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3])
        GameTooltip:AddLine(L("STAT_ACCOUNT_HINT"), 0.56, 0.6, 0.66, true)
        GameTooltip:Show()
    end)
    totalTab:SetScript("OnLeave", function() GameTooltip:Hide() end)
    panel.totalTab = totalTab

    local function CreateArrow(label, direction, left)
        local arrow = CreateFrame("Button", nil, bar, "BackdropTemplate")
        arrow:SetSize(ARROW_WIDTH, TAB_HEIGHT)
        arrow:SetPoint("TOPLEFT", left, -3)
        SetBackdrop(arrow, { 0.043, 0.058, 0.075, 0.90 }, { 1, 1, 1, 0.10 })
        local text = arrow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        text:SetPoint("CENTER")
        text:SetText(label)
        arrow.label = text
        arrow.direction = direction
        arrow:SetScript("OnClick", function(self)
            -- Ein gesperrter Pfeil tut nichts. Ohne diese Sperre liefe der
            -- Versatz ueber den Rand und die Leiste waere zeitweise leer.
            if self.disabled then return end
            WAT:ShiftStatisticsTabs(self.direction)
        end)
        return arrow
    end

    local arrowLeft = TOTAL_TAB_WIDTH + 8
    panel.prevArrow = CreateArrow("<", -1, arrowLeft)
    panel.nextArrow = CreateArrow(">", 1, CONTENT_WIDTH - ARROW_WIDTH)

    -- Der blaetternde Ausschnitt. Er schneidet hart ab, damit ein teilweise
    -- sichtbarer Reiter nicht ueber den Pfeil hinauslaeuft.
    local viewport = CreateFrame("Frame", nil, bar)
    viewport:SetPoint("TOPLEFT", arrowLeft + ARROW_WIDTH + 4, -3)
    viewport:SetSize(CONTENT_WIDTH - (arrowLeft + ARROW_WIDTH + 4) - ARROW_WIDTH - 4,
        TAB_HEIGHT)
    viewport:SetClipsChildren(true)
    panel.tabViewport = viewport
    panel.characterTabs = {}
    -- Wie viele Reiter nebeneinander vollstaendig in den Ausschnitt passen.
    panel.tabsVisible = math.max(1,
        math.floor((viewport:GetWidth() + TAB_GAP) / (TAB_WIDTH + TAB_GAP)))

    panel.definition = definition
    return panel
end

-- Waehlt einen Bereich ueber seinen stabilen Schluessel (GUID bzw. der
-- Datenbankschluessel des Charakters). Der gewaehlte Reiter wird beim naechsten
-- Refresh in den sichtbaren Ausschnitt geholt.
function WAT:SetStatisticsScope(scopeKey)
    local panel = self.panels and self.panels.statistics
    if not panel then return end
    panel.scopeKey = scopeKey
    panel.pendingReveal = scopeKey ~= TOTAL_SCOPE
    self:RefreshUI()
end

function WAT:ShiftStatisticsTabs(direction)
    local panel = self.panels and self.panels.statistics
    if not panel then return end
    -- Seitenweise blaettern: ein Reiter auf einmal waere bei sechzehn
    -- Charakteren eine Klickorgie.
    panel.tabOffset = panel.tabOffset + direction * panel.tabsVisible
    self:RefreshUI()
end

local function SetArrowDisabled(arrow, disabled)
    arrow.disabled = disabled and true or false
    arrow:SetAlpha(disabled and 0.35 or 1)
end

function WAT:RefreshStatisticsDashboard(panel, characters, characterKeys)
    BindStatisticCards(panel)

    -- Die Auswahl haengt am stabilen Schluessel, nicht an einer Position.
    -- Verschwindet der Charakter, faellt die Seite auf GESAMT zurueck, statt
    -- eine leere oder - schlimmer - eine fremde Karte zu zeigen.
    local selectedIndex, selectedCharacter
    if panel.scopeKey ~= TOTAL_SCOPE then
        for index, key in ipairs(characterKeys) do
            if key == panel.scopeKey then
                selectedIndex, selectedCharacter = index, characters[index]
                break
            end
        end
        if not selectedIndex then panel.scopeKey = TOTAL_SCOPE end
    end

    local count = #characters
    local visible = panel.tabsVisible
    local maxOffset = math.max(0, count - visible)

    -- Eine frische Auswahl muss sichtbar werden, auch wenn sie weit hinten liegt.
    if panel.pendingReveal and selectedIndex then
        if selectedIndex <= panel.tabOffset then
            panel.tabOffset = selectedIndex - 1
        elseif selectedIndex > panel.tabOffset + visible then
            panel.tabOffset = selectedIndex - visible
        end
    end
    panel.pendingReveal = nil
    if panel.tabOffset > maxOffset then panel.tabOffset = maxOffset end
    if panel.tabOffset < 0 then panel.tabOffset = 0 end

    local isTotal = panel.scopeKey == TOTAL_SCOPE
    panel.scope.isTotal = isTotal
    panel.scope.character = selectedCharacter
    panel.scope.characters = characters

    -- Karten befuellen. Lebenslange Werte veralten nicht mit der Woche und
    -- werden deshalb nie als "alte Woche" ausgegraut.
    for _, group in ipairs(panel.groups) do
        for _, card in ipairs(group.cards) do
            local text
            if card.definition then
                local key = StatisticStorageKey(card.definition)
                local value
                if isTotal then
                    value = AccountStatisticTotal(characters, key)
                else
                    value = StatisticValue(selectedCharacter, key)
                end
                text = StatisticCellValue(card.definition, value)
            end
            card.value:SetText(StatisticCellText(text))
        end
    end

    StyleScopeTab(panel.totalTab, isTotal, COLORS.turquoise)

    -- Reiter werden gepoolt und wiederverwendet. Nur ein wirklich neuer
    -- Charakter legt einen neuen an; ueberzaehlige werden verborgen und
    -- verlieren ihren Schluessel, damit sie keinen Bereich mehr beanspruchen.
    for index = 1, count do
        local tab = panel.characterTabs[index]
        if not tab then
            tab = CreateScopeTab(panel.tabViewport, TAB_WIDTH)
            tab:SetScript("OnClick", function(self)
                if self.scopeKey then WAT:SetStatisticsScope(self.scopeKey) end
            end)
            tab:SetScript("OnEnter", function(self)
                local character = self.character
                if not character then return end
                local unknown = L("CHARACTER_UNKNOWN")
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:ClearLines()
                -- Der Reiter zeigt einen beschnittenen Namen; die volle
                -- Identitaet steht deshalb hier.
                GameTooltip:AddLine((character.name or unknown) .. " - "
                    .. (character.realm or unknown),
                    COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3])
                AddTooltipLine(L("TOOLTIP_CLASS"), character.className or "-")
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine(L("TOOLTIP_DRAG_REORDER"), 0.56, 0.6, 0.66, true)
                GameTooltip:Show()
            end)
            tab:SetScript("OnLeave", function() GameTooltip:Hide() end)
            AttachCharacterDragHandlers(tab)
            panel.characterTabs[index] = tab
        end
        local character = characters[index]
        tab.scopeKey = characterKeys[index]
        tab.dragCharacterKey = characterKeys[index]
        tab.character = character
        local unknown = L("CHARACTER_UNKNOWN")
        tab.label:SetText((character.name or unknown) .. "-" .. (character.realm or unknown))
        StyleScopeTab(tab, tab.scopeKey == panel.scopeKey, ScopeTabColor(character))

        local slot = index - panel.tabOffset
        if slot >= 1 and slot <= visible then
            tab:ClearAllPoints()
            tab:SetPoint("TOPLEFT", (slot - 1) * (TAB_WIDTH + TAB_GAP), 0)
            tab.dragCharacterKey = characterKeys[index]
            tab:Show()
        else
            tab.dragCharacterKey = nil
            tab:Hide()
        end
    end
    for index = count + 1, #panel.characterTabs do
        local tab = panel.characterTabs[index]
        tab.scopeKey = nil
        tab.dragCharacterKey = nil
        tab.character = nil
        tab:Hide()
    end

    local needsPaging = count > visible
    panel.prevArrow:SetShown(needsPaging)
    panel.nextArrow:SetShown(needsPaging)
    SetArrowDisabled(panel.prevArrow, panel.tabOffset <= 0)
    SetArrowDisabled(panel.nextArrow, panel.tabOffset >= maxOffset)
end

-- ---------------------------------------------------------------------------
-- Einstellungsseite
--
-- Eigene, flache Buttons statt Blizzard-Templates: die uebrige UI verwendet
-- ebenfalls keine, und ein Template braechte fremde Schrift und Metrik in die
-- Seite. Ein Schieberegler ist bewusst nicht dabei - feste Prozentstufen
-- bleiben im von Core.lua akzeptierten Bereich und sind reproduzierbar.
-- ---------------------------------------------------------------------------

local function CreateFormButton(parent, label, width, x, y)
    local button = CreateFrame("Button", nil, parent, "BackdropTemplate")
    button:SetSize(width, 30)
    button:SetPoint("TOPLEFT", x, y)
    SetBackdrop(button, { 0.061, 0.095, 0.120, 0.60 }, { 1, 1, 1, 0.18 })
    local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    text:SetPoint("CENTER")
    text:SetTextColor(1, 1, 1, 0.62)
    text:SetText(label)
    button.label = text
    button:SetScript("OnEnter", function(self)
        if self.active then return end
        self:SetBackdropColor(0.075, 0.113, 0.141, 0.98)
        self:SetBackdropBorderColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.55)
        self.label:SetTextColor(1, 1, 1, 0.92)
    end)
    button:SetScript("OnLeave", function(self)
        if self.active then return end
        self:SetBackdropColor(0.061, 0.095, 0.120, 0.60)
        self:SetBackdropBorderColor(1, 1, 1, 0.18)
        self.label:SetTextColor(1, 1, 1, 0.62)
    end)
    return button
end

-- Hebt genau die Schaltflaeche hervor, die den aktuellen Zustand abbildet.
local function SetFormButtonActive(button, active)
    button.active = active
    if active then
        button:SetBackdropColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.16)
        button:SetBackdropBorderColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.65)
        button.label:SetTextColor(1, 1, 1, 1)
        return
    end
    button:SetBackdropColor(0.061, 0.095, 0.120, 0.60)
    button:SetBackdropBorderColor(1, 1, 1, 0.18)
    button.label:SetTextColor(1, 1, 1, 0.62)
end

function WAT:UpdateSettingsState()
    local controls = self.settingsControls
    if not controls then return end
    local scale = self.SafeNumber(self.db.settings.scale, 1)
    for _, preset in ipairs(controls.scalePresets) do
        SetFormButtonActive(preset, math.abs(preset.scale - scale) < 0.001)
    end
    --local hidden = self.db.settings.minimapHidden == true
    --SetFormButtonActive(controls.minimapShow, not hidden)
    --SetFormButtonActive(controls.minimapHide, hidden)
end

function WAT:SetMinimapHidden(hidden)
    self.db.settings.minimapHidden = hidden and true or false
    if self.minimapButton then
        if hidden then self.minimapButton:Hide() else self.minimapButton:Show() end
    end
    self:UpdateSettingsState()
end

function WAT:SetScalePreset(scale)
    self.db.settings.scale = scale
    if self.frame then self.frame:SetScale(scale) end
    self:UpdateSettingsState()
end

local function CreateSettingsPanel(parent, definition)
    local panel = CreateFrame("Frame", nil, parent)
    panel:SetPoint("TOPLEFT", CONTENT_LEFT, -150)
    panel:SetPoint("BOTTOMRIGHT", -20, 48)
    panel.key = "settings"
    panel.isForm = true
    panel.columns = {}
    panel.rows = {}

    local controls = { scalePresets = {}, labels = {} }

    local function Heading(text, y)
        local heading = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        heading:SetPoint("TOPLEFT", 0, y)
        heading:SetTextColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.9)
        heading:SetText(text)
        controls.labels[#controls.labels + 1] = heading
        return heading
    end

    local function Description(text, y)
        local line = panel:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        line:SetPoint("TOPLEFT", 0, y)
        line:SetWidth(CONTENT_WIDTH - 20)
        line:SetJustifyH("LEFT")
        line:SetTextColor(1, 1, 1, 0.42)
        line:SetText(text)
        controls.labels[#controls.labels + 1] = line
        return line
    end

    local function Button(label, width, x, y)
        local button = CreateFormButton(panel, label, width, x, y)
        controls.labels[#controls.labels + 1] = button.label
        return button
    end

    controls.headingWindow = Heading(L("SETTINGS_HEADING_WINDOW"), 0)
    controls.refresh = Button(L("SETTINGS_REFRESH"), 180, 0, -26)
    controls.refresh:SetScript("OnClick", function() WAT:Refresh("settings") end)
    controls.resetPosition = Button(L("SETTINGS_RESET_POSITION"), 180, 192, -26)
    controls.resetPosition:SetScript("OnClick", function() WAT:ResetPosition() end)
    Description(L("SETTINGS_WINDOW_DESC"), -64)

    --[[controls.headingMinimap = Heading(L("SETTINGS_HEADING_MINIMAP"), -108)
    controls.minimapShow = Button(L("SETTINGS_MINIMAP_SHOW"), 120, 0, -134)
    controls.minimapShow:SetScript("OnClick", function() WAT:SetMinimapHidden(false) end)
    controls.minimapHide = Button(L("SETTINGS_MINIMAP_HIDE"), 120, 132, -134)
    controls.minimapHide:SetScript("OnClick", function() WAT:SetMinimapHidden(true) end)
    Description(L("SETTINGS_MINIMAP_DESC"), -172)]]

    controls.headingScale = Heading(L("SETTINGS_HEADING_SCALE"), -109)
    for index, scale in ipairs(SCALE_PRESETS) do
        -- Lua 5.1: der Wert muss pro Durchlauf gebunden werden, sonst sehen
        -- alle Klickziele denselben letzten Schleifenwert.
        local presetScale = scale
        local percent = math.floor(presetScale * 100 + 0.5)
        local button = Button(L("SETTINGS_SCALE_PERCENT", percent), 84, (index - 1) * 92, -242)
        button.scale = presetScale
        button:SetScript("OnClick", function() WAT:SetScalePreset(presetScale) end)
        controls.scalePresets[index] = button
    end
    Description(L("SETTINGS_SCALE_DESC"), -172)

    WAT.settingsControls = controls
    -- Der Titel steht im Seitenkopf; definition liefert ihn ueber SetActiveTab.
    panel.definition = definition
    return panel
end

local function Atan2(y, x)
    if math.atan2 then return math.atan2(y, x) end
    if x > 0 then return math.atan(y / x) end
    if x < 0 and y >= 0 then return math.atan(y / x) + math.pi end
    if x < 0 and y < 0 then return math.atan(y / x) - math.pi end
    if y > 0 then return math.pi / 2 end
    if y < 0 then return -math.pi / 2 end
    return 0
end

function WAT:UpdateMinimapButtonPosition()
    if not self.minimapButton or not Minimap then return end
    local angle = self.SafeNumber(self.db.settings.minimapAngle, 225) % 360
    local minimapWidth = self.SafeNumber(Minimap:GetWidth(), 140)
    local minimapHeight = self.SafeNumber(Minimap:GetHeight(), 140)
    local buttonWidth = self.SafeNumber(self.minimapButton:GetWidth(), 32)
    local buttonHeight = self.SafeNumber(self.minimapButton:GetHeight(), 32)
    if minimapWidth <= 0 then minimapWidth = 140 end
    if minimapHeight <= 0 then minimapHeight = 140 end
    if buttonWidth <= 0 then buttonWidth = 32 end
    if buttonHeight <= 0 then buttonHeight = 32 end
    local minimapRadius = math.min(minimapWidth, minimapHeight) / 2
    local buttonRadius = math.max(buttonWidth, buttonHeight) / 2
    local radius = minimapRadius + buttonRadius
    local radians = math.rad(angle)
    self.minimapButton:ClearAllPoints()
    self.minimapButton:SetPoint("CENTER", Minimap, "CENTER",
        math.cos(radians) * radius, math.sin(radians) * radius)
end

function WAT:CreateMinimapButton()
    if self.minimapButton or not Minimap then return end
    local button = CreateFrame("Button", "WeeklyAltTrackerMinimapButton", Minimap)
    button:SetSize(32, 32)
    button:SetFrameStrata("MEDIUM")
    button:RegisterForClicks("LeftButtonUp")
    button:RegisterForDrag("LeftButton")

    local icon = button:CreateTexture(nil, "BACKGROUND")
    icon:SetSize(24, 24)
    icon:SetPoint("CENTER")
    icon:SetTexture("Interface\\AddOns\\WeeklyAltTracker\\Media\\WeeklyAltTrackerIcon")
    if icon.SetMask then
        icon:SetMask("Interface\\CharacterFrame\\TempPortraitAlphaMask")
    end
    button.icon = icon

    local border = button:CreateTexture(nil, "OVERLAY")
    border:SetSize(53, 53)
    border:SetPoint("TOPLEFT", 0, 0)
    border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")

    button:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
    button:SetScript("OnClick", function(_, mouseButton)
        if mouseButton == "LeftButton" then WAT:ToggleUI() end
    end)
    button:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:AddLine("WeeklyAltTracker", 0.20, 1, 0.75)
        GameTooltip:AddLine(L("MINIMAP_LEFTCLICK"), 1, 1, 1)
        GameTooltip:AddLine(L("MINIMAP_DRAG"), 0.72, 0.76, 0.82)
        GameTooltip:Show()
    end)
    button:SetScript("OnLeave", function() GameTooltip:Hide() end)
    button:SetScript("OnDragStart", function(self)
        self.dragging = true
        self:SetScript("OnUpdate", function(dragged)
            if not dragged.dragging then return end
            local centerX, centerY = Minimap:GetCenter()
            local cursorX, cursorY = GetCursorPosition()
            local scale = Minimap:GetEffectiveScale()
            if not centerX or not centerY or not cursorX or not cursorY
                    or type(scale) ~= "number" or scale <= 0 then return end
            cursorX, cursorY = cursorX / scale, cursorY / scale
            local angle = math.deg(Atan2(cursorY - centerY, cursorX - centerX)) % 360
            WAT.db.settings.minimapAngle = angle
            WAT:UpdateMinimapButtonPosition()
        end)
    end)
    button:SetScript("OnDragStop", function(self)
        self.dragging = nil
        self:SetScript("OnUpdate", nil)
    end)

    self.minimapButton = button
    self:UpdateMinimapButtonPosition()
    -- Die gespeicherte Sichtbarkeit gilt sofort, nicht erst nach dem ersten
    -- Oeffnen der Einstellungen.
    if self.db.settings.minimapHidden == true then button:Hide() end
end

function WAT:SetActiveTab(key)
    if not self.panels or not self.panels[key] then key = "overview" end
    self.activeTab = key
    self.db.settings.activeTab = key
    local definition = PANELS[key]
    if self.pageTitle then self.pageTitle:SetText(definition.label) end
    if self.pageDescription then self.pageDescription:SetText(definition.description or "") end
    for panelKey, panel in pairs(self.panels) do
        panel:SetShown(panelKey == key)
        local button = self.tabButtons[panelKey]
        if button then
            local active = panelKey == key
            button.active = active
            button.indicator:SetShown(active)
            button.activeBackground:SetShown(active)
            local markerAlpha, labelAlpha = 0.35, 0.54
            if active then markerAlpha, labelAlpha = 1, 1 end
            button.marker:SetAlpha(markerAlpha)
            button.label:SetTextColor(1, 1, 1, labelAlpha)
            if active then button.hover:Hide() end
        end
    end
    self:RefreshUI()
end

-- Registriert einen Rahmennamen genau einmal in UISpecialFrames. Das ist die
-- WoW-Standardsemantik fuer "ESC schliesst dieses Fenster": Blizzards eigener
-- Escape-Handler durchlaeuft diese Liste globaler Frame-Namen und ruft fuer
-- jeden sichtbaren Treffer :Hide() auf. Ohne eigenes OnKeyDown, ohne eigene
-- Tastaturbindung - und deshalb ohne Konflikt mit Slash-Befehl oder
-- Minimap-Symbol, die weiterhin ganz normal ToggleUI/ShowUI/HideUI aufrufen.
local function EnsureUISpecialFrame(name)
    local special = _G.UISpecialFrames
    if type(name) ~= "string" or name == "" or type(special) ~= "table" then return end
    for _, existing in ipairs(special) do
        if existing == name then return end
    end
    table.insert(special, name)
end

function WAT:CreateUI()
    if self.frame then return end
    local frame = CreateFrame("Frame", "WeeklyAltTrackerFrame", UIParent, "BackdropTemplate")
    EnsureUISpecialFrame("WeeklyAltTrackerFrame")
    frame:SetSize(FRAME_WIDTH, FRAME_HEIGHT)
    frame:SetClampedToScreen(true)
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetFrameStrata("DIALOG")
    SetBackdrop(frame, COLORS.frame, { 1, 1, 1, 0.09 })
    frame:SetScript("OnDragStart", function(f) f:StartMoving() end)
    frame:SetScript("OnDragStop", function(f) f:StopMovingOrSizing(); WAT:SaveFramePosition() end)
    frame:SetScale(self.db.settings.scale or 1)

    local pos = self.db.settings.point or {}
    local validPoints = {
        TOP = true, BOTTOM = true, LEFT = true, RIGHT = true, CENTER = true,
        TOPLEFT = true, TOPRIGHT = true, BOTTOMLEFT = true, BOTTOMRIGHT = true,
    }
    local point = validPoints[pos.point] and pos.point or "CENTER"
    local relativePoint = validPoints[pos.relativePoint] and pos.relativePoint or "CENTER"
    local x = WAT.SafeNumber(pos.x, 0)
    local y = WAT.SafeNumber(pos.y, 0)
    frame:SetPoint(point, UIParent, relativePoint, x, y)

    local sidebar = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    sidebar:SetPoint("TOPLEFT", 1, -1)
    sidebar:SetPoint("BOTTOMLEFT", 1, 1)
    sidebar:SetWidth(SIDEBAR_WIDTH)
    SetBackdrop(sidebar, COLORS.sidebar, { 1, 1, 1, 0.04 })
    self.sidebar = sidebar

    local sidebarDivider = sidebar:CreateTexture(nil, "OVERLAY")
    sidebarDivider:SetPoint("TOPRIGHT")
    sidebarDivider:SetPoint("BOTTOMRIGHT")
    sidebarDivider:SetWidth(1)
    sidebarDivider:SetColorTexture(1, 1, 1, 0.07)

    local brandMark = CreateFrame("Frame", nil, sidebar, "BackdropTemplate")
    brandMark:SetSize(31, 31)
    brandMark:SetPoint("TOPLEFT", 12, -12)
    SetBackdrop(brandMark, { COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.16 },
        { COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.55 })
    local brandLetter = brandMark:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    brandLetter:SetPoint("CENTER", 0, 1)
    brandLetter:SetTextColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3])
    brandLetter:SetText("2")

    local brand = sidebar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    brand:SetPoint("TOPLEFT", brandMark, "TOPRIGHT", 10, 0)
    brand:SetTextColor(1, 1, 1, 0.96)
    brand:SetText(L("ADDON_NAME"))
    local brandSub = sidebar:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    brandSub:SetPoint("TOPLEFT", brand, "BOTTOMLEFT", 0, -4)
    brandSub:SetTextColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.9)
    brandSub:SetText(WAT.version)

    local sideHeading = sidebar:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    sideHeading:SetPoint("TOPLEFT", 20, -80)
    sideHeading:SetTextColor(1, 1, 1, 0.34)
    sideHeading:SetText(L("CHROME_SIDEBAR_HEADING"))

    self.tabButtons = {}
    self.panels = {}
    local tabOrder = { "overview", "midnight", "professions", "sources", "keystones",
                       "statistics", "settings" }
    for index, key in ipairs(tabOrder) do
        local targetKey = key
        local definition = PANELS[targetKey]
        local button = CreateNavButton(sidebar, definition, -108 - ((index - 1) * 42))
        button:SetScript("OnClick", function() WAT:SetActiveTab(targetKey) end)
        self.tabButtons[targetKey] = button
        if targetKey == "settings" then
            self.panels[targetKey] = CreateSettingsPanel(frame, definition)
        elseif targetKey == "statistics" then
            self.panels[targetKey] = CreateStatisticsPanel(frame, definition)
        else
            self.panels[targetKey] = CreatePanel(frame, targetKey, definition)
        end
    end

    --[[local sideHint = sidebar:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    sideHint:SetPoint("BOTTOMLEFT", 20, 18)
    sideHint:SetTextColor(1, 1, 1, 0.30)
    sideHint:SetText(L("CHROME_SIDEBAR_HINT"))]]

    local header = CreateFrame("Frame", nil, frame)
    header:SetPoint("TOPLEFT", CONTENT_LEFT, -1)
    header:SetPoint("TOPRIGHT", -1, -1)
    header:SetHeight(140)
    self.header = header

    local headerLine = header:CreateTexture(nil, "OVERLAY")
    headerLine:SetPoint("BOTTOMLEFT", 0, 0)
    headerLine:SetPoint("BOTTOMRIGHT", 0, 0)
    headerLine:SetHeight(1)
    headerLine:SetColorTexture(1, 1, 1, 0.07)

    local eyebrow = header:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    eyebrow:SetPoint("TOPLEFT", 0, -20)
    eyebrow:SetTextColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.9)
    eyebrow:SetText(L("CHROME_EYEBROW"))

    local pageTitle = header:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    pageTitle:SetPoint("TOPLEFT", eyebrow, "BOTTOMLEFT", 0, -8)
    pageTitle:SetTextColor(1, 1, 1, 0.97)
    local pageFont, _, pageFlags = GameFontNormalLarge:GetFont()
    if pageFont then pageTitle:SetFont(pageFont, 24, pageFlags) end
    self.pageTitle = pageTitle

    local pageDescription = header:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    pageDescription:SetPoint("TOPLEFT", pageTitle, "BOTTOMLEFT", 0, -8)
    pageDescription:SetWidth(650)
    pageDescription:SetJustifyH("LEFT")
    pageDescription:SetTextColor(1, 1, 1, 0.50)
    self.pageDescription = pageDescription

    local function StyleHeaderButton(button, label, width)
        button:SetSize(width, 30)
        SetBackdrop(button, { 0.061, 0.095, 0.120, 0.60 }, { 1, 1, 1, 0.18 })
        local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        text:SetPoint("CENTER")
        text:SetTextColor(1, 1, 1, 0.62)
        text:SetText(label)
        button.label = text
        button:SetScript("OnEnter", function(self)
            self:SetBackdropColor(0.075, 0.113, 0.141, 0.98)
            self:SetBackdropBorderColor(COLORS.turquoise[1], COLORS.turquoise[2], COLORS.turquoise[3], 0.55)
            self.label:SetTextColor(1, 1, 1, 0.92)
        end)
        button:SetScript("OnLeave", function(self)
            self:SetBackdropColor(0.061, 0.095, 0.120, 0.60)
            self:SetBackdropBorderColor(1, 1, 1, 0.18)
            self.label:SetTextColor(1, 1, 1, 0.62)
        end)
    end

    local close = CreateFrame("Button", nil, header, "BackdropTemplate")
    StyleHeaderButton(close, "X", 30)
    close:SetPoint("TOPRIGHT", -12, -16)
    close:SetScript("OnClick", function() frame:Hide() end)

    local refresh = CreateFrame("Button", nil, header, "BackdropTemplate")
    StyleHeaderButton(refresh, L("CHROME_REFRESH"), 116)
    refresh:SetPoint("RIGHT", close, "LEFT", -8, 0)
    refresh:SetScript("OnClick", function() WAT:Refresh("button") end)

    local toolbar = header:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    toolbar:SetPoint("BOTTOMLEFT", 0, 13)
    toolbar:SetTextColor(1, 1, 1, 0.38)
    toolbar:SetText(L("CHROME_TOOLBAR"))
    self.toolbar = toolbar

    local footer = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    footer:SetPoint("BOTTOMLEFT", CONTENT_LEFT, 17)
    footer:SetPoint("BOTTOMRIGHT", -(20 + SCROLLBAR_GUTTER), 17)
    footer:SetJustifyH("LEFT")
    footer:SetTextColor(1, 1, 1, 0.38)
    footer:SetText(L("CHROME_LEGEND"))

    self.frame = frame
    --self:CreateMinimapButton()
    local initialTab = self.db.settings.activeTab
    if not self.panels[initialTab] then initialTab = "overview" end
    self:SetActiveTab(initialTab)
    if self.db.settings.seenIntro then frame:Hide() else self.db.settings.seenIntro = true end
end

-- Liefert die Charaktere UND ihre stabilen Datenbankschluessel in derselben
-- Reihenfolge. WAT:NormalizeCharacterOrder() in Core.lua ist die EINE Quelle
-- der Wahrheit fuer diese Reihenfolge - sie treibt alle fuenf Tabellenseiten
-- UND die Statistik-Charakterreiter. Die Statistikseite haengt ihre Auswahl
-- zusaetzlich an den stabilen Schluessel (die GUID), nicht an einer Position:
-- eine Position verschiebt sich, sobald ein Charakter dazukommt oder per
-- Drag-and-drop umsortiert wird.
local function GetCharacters()
    local order = WAT:NormalizeCharacterOrder()
    if type(order) ~= "table" then order = {} end
    local characters, keys = {}, {}
    for _, key in ipairs(order) do
        local character = WAT.db.characters[key]
        if type(character) == "table" then
            characters[#characters + 1] = character
            keys[#keys + 1] = key
        end
    end
    return characters, keys
end

local function CreateRow(panel, index)
    local rowHeight = panel.rowHeight or ROW_HEIGHT
    local row = CreateFrame("Frame", nil, panel.child, "BackdropTemplate")
    row:SetSize(CONTENT_WIDTH, rowHeight - 1)
    SetBackdrop(row, COLORS.surface, { 1, 1, 1, 0.025 })
    row:EnableMouse(true)
    row.values = {}
    row.cells = {}
    row.panelKey = panel.key
    LayoutColumns(panel.columns, function(column, left)
        -- SetWordWrap(false) verhindert nur den Umbruch, nicht das Hinausragen
        -- ueber die Spaltengrenze: ein zu langer Text laeuft weiter in den
        -- Nachbarn. Die harte Grenze zieht erst dieser Rahmen mit
        -- SetClipsChildren - die FontString sitzt darin und wird beschnitten.
        local cell = CreateFrame("Frame", nil, row)
        cell:SetPoint("LEFT", left, 0)
        cell:SetSize(column.width - 6, rowHeight - 2)
        cell:SetClipsChildren(true)
        local value = cell:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        value:SetAllPoints(cell)
        value:SetJustifyH(column.left and "LEFT" or "CENTER")
        value:SetJustifyV("MIDDLE")
        value:SetWordWrap(false)
        -- Ein Datenwert ist immer einzeilig: eine zweite Zeile waere in der
        -- kompakten Zeilenhoehe halb abgeschnitten und damit unlesbar.
        value:SetMaxLines(1)
        row.cells[column.key] = cell
        row.values[column.key] = value
    end)
    row:SetScript("OnEnter", function(r)
        r:SetBackdropColor(COLORS.hover[1], COLORS.hover[2], COLORS.hover[3], COLORS.hover[4])
        WAT:ShowCharacterTooltip(r)
    end)
    row:SetScript("OnLeave", function(r)
        local color = r.rowColor or COLORS.surface
        r:SetBackdropColor(color[1], color[2], color[3], color[4])
        GameTooltip:Hide()
    end)
    AttachCharacterDragHandlers(row)
    panel.rows[index] = row
    return row
end

local function FillOverview(row, character, weekly, stale)
    local gilded = type(weekly.gilded) == "table" and weekly.gilded or {}
    row.values.character:SetText(ClassColoredName(character, stale))
    row.values.level:SetText(type(character.level) == "number" and tostring(character.level) or "-")
    row.values.itemLevel:SetText(type(character.itemLevel) == "number" and string.format("%.1f", character.itemLevel) or "-")
    row.values.gilded:SetText(StatusFraction(gilded.current, gilded.maximum, stale))
    row.values.crests:SetText(CrestText(weekly, stale))
    row.values.world:SetText(VaultText(weekly.worldVault, stale))
    row.values.mythic:SetText(VaultText(weekly.mythicPlusVault, stale))
    row.values.mythic10:SetText(MythicPlusTenText(weekly.mythicPlusVault, stale))
    row.values.updated:SetText((stale and COLORS.stale or "|cffb0bac6") .. FormatAge(character.lastSeen) .. "|r")
end

local function FillMidnight(row, character, weekly, stale)
    row.values.character:SetText(ClassColoredName(character, stale))
    row.values.weekly:SetText(MidnightWeeklyText(weekly.midnightWeekly, stale))
    row.values.prey:SetText(PreyText(weekly.prey, stale))
    row.values.ritual:SetText(RitualText(weekly.ritualSites, stale))
    row.values.updated:SetText((stale and COLORS.stale or "|cffb0bac6") .. FormatAge(weekly.activitiesUpdated) .. "|r")
end

local function FillProfessions(row, character, weekly, stale)
    row.values.character:SetText(ClassColoredName(character, stale))
    local professions = type(weekly.professions) == "table" and weekly.professions or {}
    for index = 1, 2 do
        local profession = professions[index]
        local progress = FindProfessionProgress(character, profession, index)
        local nameKey = "profession" .. index
        local skillKey = "skill" .. index
        local knowledgeKey = "knowledge" .. index
        local weeklyKey = "weekly" .. index
        local treatiseKey = "treatise" .. index
        local name = ProfessionDisplayName(profession, progress)
        row.values[nameKey]:SetText(type(name) == "string" and name
            or COLORS.unknown .. L("STATUS_NOT_TRACKED") .. "|r")
        row.values[skillKey]:SetText(ProfessionSkillText(progress))
        row.values[knowledgeKey]:SetText(ProfessionKnowledgeText(progress))
        row.values[weeklyKey]:SetText(ProfessionWeeklyText(profession, stale))
        row.values[treatiseKey]:SetText(BooleanStatus(ProfessionFlag(profession, "treatiseDone"), stale))
    end
end

local function FillSources(row, character, weekly, stale)
    row.values.character:SetText(ClassColoredName(character, stale))
    row.values.dundun:SetText(DundunCellText(character.resources, stale))
    row.values.gilded:SetText(GildedSourceText(weekly, stale))
    local season = type(character.season) == "table" and character.season or {}
    local seasonal = type(season.crestSources) == "table" and season.crestSources or {}
    row.values.cracked:SetText(SeasonalSourceText(seasonal.crackedKeystone, 20))
    row.values.nullaeus:SetText(SeasonalSourceText(seasonal.nullaeusT11, 30))
    local perRun = Constant(WAT.Data and WAT.Data.RITUAL_T6_MYTH_PER_RUN)
    row.values.ritualFarm:SetText("|cff32e6c4" .. L("CELL_RITUAL_FARM", perRun) .. "|r")
    local sources = type(weekly.crestSources) == "table" and weekly.crestSources or {}
    row.values.heroicShowdown:SetText(HeroicShowdownText(sources.heroicShowdowns, stale))
    local mythicPlus = sources.mythicPlus
    local highest = type(mythicPlus) == "table" and mythicPlus.highestObservedLevel or nil
    if type(highest) == "number" and highest >= 9 then
        row.values.mythicFarm:SetText(COLORS.green .. L("CELL_MYTHIC_FARMABLE", highest) .. "|r")
    elseif type(highest) == "number" then
        row.values.mythicFarm:SetText(COLORS.red .. L("CELL_MYTHIC_MIN", highest) .. "|r")
    else
        row.values.mythicFarm:SetText(COLORS.unknown .. L("CELL_MYTHIC_FROM9") .. "|r")
    end
    local exchange = sources.heroToMyth
    if type(exchange) ~= "table" or exchange.unlocked == nil then
        row.values.exchange:SetText(COLORS.unknown .. "-|r")
    elseif exchange.unlocked == false then
        row.values.exchange:SetText(COLORS.red .. L("STATUS_LOCKED") .. "|r")
    elseif type(exchange.mythPotential) == "number" then
        row.values.exchange:SetText(COLORS.green
            .. L("CELL_EXCHANGE_POTENTIAL", exchange.mythPotential) .. "|r")
    else
        row.values.exchange:SetText(COLORS.amber .. L("STATUS_UNLOCKED") .. "|r")
    end
end

local function FillKeystones(row, character, weekly, stale)
    row.values.character:SetText(ClassColoredName(character, stale))
    local keystone = type(weekly.keystone) == "table" and weekly.keystone or nil
    local valueColor = stale and COLORS.stale or "|cffd8e0e7"
    if not keystone then
        row.values.dungeon:SetText(COLORS.unknown .. L("STATUS_NOT_TRACKED") .. "|r")
        row.values.keystoneLevel:SetText(COLORS.unknown .. "-|r")
        row.values.updated:SetText(COLORS.unknown .. "-|r")
        return
    end
    if keystone.hasKey == false then
        row.values.dungeon:SetText(valueColor .. L("KEY_NONE") .. "|r")
        row.values.keystoneLevel:SetText(COLORS.unknown .. "-|r")
    elseif keystone.hasKey == true then
        local dungeon = DungeonDisplayName(keystone) or L("STATUS_UNKNOWN")
        row.values.dungeon:SetText(valueColor .. dungeon .. "|r")
        local levelColor = stale and COLORS.stale or "|cff0dd19e"
        row.values.keystoneLevel:SetText(type(keystone.level) == "number"
            and levelColor .. "+" .. keystone.level .. "|r" or COLORS.unknown .. "-|r")
    else
        row.values.dungeon:SetText(COLORS.unknown .. L("STATUS_UNKNOWN") .. "|r")
        row.values.keystoneLevel:SetText(COLORS.unknown .. "-|r")
    end
    row.values.updated:SetText((stale and COLORS.stale or "|cffb0bac6")
        .. FormatAge(keystone.updated) .. "|r")
end

local function PlaceRow(panel, index)
    local row = panel.rows[index] or CreateRow(panel, index)
    row:ClearAllPoints()
    row:SetPoint("TOPLEFT", 0, -((index - 1) * (panel.rowHeight or ROW_HEIGHT)))
    return row
end

local function PaintRow(row, color)
    row.rowColor = color
    row:SetBackdropColor(color[1], color[2], color[3], color[4])
end

function WAT:RefreshUI()
    if not self.frame or not self.panels then return end
    local characters, characterKeys = GetCharacters()
    if self.activeTab == "settings" then
        self.toolbar:SetText(L("CHROME_TOOLBAR_SETTINGS"))
    else
        self.toolbar:SetText(L("CHROME_TOOLBAR_COUNT", #characters))
    end
    self:UpdateSettingsState()

    for panelKey, panel in pairs(self.panels) do
        -- Das Einstellungspanel ist ein Formular, die Statistikseite ein
        -- Dashboard. Beide erzeugen bewusst keine Charakterzeilen.
        if panel.isDashboard then
            self:RefreshStatisticsDashboard(panel, characters, characterKeys)
        elseif not panel.isForm then
            for _, row in ipairs(panel.rows) do
                row.character = nil
                row.dragCharacterKey = nil
                row:Hide()
            end
            local index = 0
            for _, character in ipairs(characters) do
                index = index + 1
                local row = PlaceRow(panel, index)
                row.character = character
                row.dragCharacterKey = characterKeys[index]
                PaintRow(row, index % 2 == 0 and COLORS.alternate or COLORS.surface)
                local weekly = type(character.weekly) == "table" and character.weekly or {}
                local stale = self:IsStale(character)
                if panelKey == "midnight" then
                    FillMidnight(row, character, weekly, stale)
                elseif panelKey == "professions" then
                    FillProfessions(row, character, weekly, stale)
                elseif panelKey == "sources" then
                    FillSources(row, character, weekly, stale)
                elseif panelKey == "keystones" then
                    FillKeystones(row, character, weekly, stale)
                else
                    FillOverview(row, character, weekly, stale)
                end
                row:Show()
            end
            panel.child:SetHeight(math.max(1, index * (panel.rowHeight or ROW_HEIGHT)))
        end
    end
end

function WAT:ShowUI()
    if self.frame then self.frame:Show(); self:RefreshUI() end
end
function WAT:HideUI() if self.frame then self.frame:Hide() end end
function WAT:ToggleUI()
    if not self.frame then return end
    if self.frame:IsShown() then self.frame:Hide() else self:ShowUI() end
end
