-------------------------------------------------------
-- Moonlight_SkillSound
-- 스킬 사용 시 지정된 사운드를 재생하는 애드온
-------------------------------------------------------

local ADDON_NAME = "Moonlight_SkillSound"

-------------------------------------------------------
-- 버전 관리
-- MOONLIGHT_SKILLSOUND_PRESET_VERSION : DefaultPreset.lua 참고
-- DB_MIGRATE_VERSION : DB 경로 오류 교정이 필요할 때 올림
-------------------------------------------------------

-------------------------------------------------------
-- 직업 정의
-------------------------------------------------------
local CLASS_LIST = {
    { id = 1,  name = "전사",        nameEN = "WARRIOR",      color = "C79C6E" },
    { id = 2,  name = "성기사",      nameEN = "PALADIN",      color = "F58CBA" },
    { id = 3,  name = "사냥꾼",      nameEN = "HUNTER",       color = "ABD473" },
    { id = 4,  name = "도적",        nameEN = "ROGUE",        color = "FFF569" },
    { id = 5,  name = "사제",        nameEN = "PRIEST",       color = "FFFFFF" },
    { id = 6,  name = "죽음의 기사", nameEN = "DEATHKNIGHT",  color = "C41F3B" },
    { id = 7,  name = "주술사",      nameEN = "SHAMAN",       color = "0070DE" },
    { id = 8,  name = "마법사",      nameEN = "MAGE",         color = "69CCF0" },
    { id = 9,  name = "흑마법사",    nameEN = "WARLOCK",      color = "9482C9" },
    { id = 10, name = "수도사",      nameEN = "MONK",         color = "00FF96" },
    { id = 11, name = "드루이드",    nameEN = "DRUID",        color = "FF7D0A" },
    { id = 12, name = "악마사냥꾼",  nameEN = "DEMONHUNTER",  color = "A330C9" },
    { id = 13, name = "기원사",      nameEN = "EVOKER",       color = "33937F" },
    { id = 14, name = "장신구 및 아이템", nameEN = "TRINKET",      color = "E8D000" },
    { id = 15, name = "테스트 중",   nameEN = "ITEM",         color = "A0E0FF" },
    { id = 16, name = "☠ 사망",      nameEN = "DEATH_EVENT",  color = "FF6969" },
}

-- ★ 직업 역방향 맵: classFile(영문) → CLASS_LIST 항목
--   RebuildRuntimeMap에서 매번 순회하지 않도록 미리 구성
local CLASS_BY_ENGLISH = {}
for _, c in ipairs(CLASS_LIST) do
    CLASS_BY_ENGLISH[c.nameEN] = c
end

local CHANNELS = { "Master", "SFX", "Music", "Ambience", "Dialog" }

-- ★ soundChannel 정규화: 대소문자 혼용 방지 ("master"→"Master" 등)
local CHANNEL_NORMALIZE = {}
for _, ch in ipairs(CHANNELS) do
    CHANNEL_NORMALIZE[ch:lower()] = ch
end
local function NormalizeChannel(ch)
    return CHANNEL_NORMALIZE[(ch or ""):lower()] or "Master"
end

-- ★ 경로 정규화
-- 1) \\ 두 개 → \ 한 개로 교정 (SavedVariables 이스케이프 문제 방지)
-- 2) / → \ 로 교정 (WoW 사운드 경로는 백슬래시만 허용)
-- DB에 경로를 쓰거나 읽는 모든 곳에서 이 함수를 통과시킵니다.
local function NormalizePath(path)
    if type(path) ~= "string" or path == "" then return path or "" end
    -- \\ 두 개 → \ 한 개로 교정
    path = path:gsub("\\\\", "\\")
    -- / → \ 로 교정
    path = path:gsub("/", "\\")
    return path
end

-------------------------------------------------------
-- LibSharedMedia-3.0 참조
-- ★ 재생 직전에 LSM에서 실시간으로 경로를 resolve합니다.
--   PLAYER_LOGIN 시점에 캐시해두면 SharedMedia 등록 순서에 따라
--   경로가 nil로 굳어버리는 문제를 방지합니다.
-------------------------------------------------------
local LSM = nil  -- PLAYER_LOGIN 이후 LibStub이 준비된 뒤 할당

local function ResolveSoundPath(path)
    if type(path) ~= "string" or path == "" then return nil end
    -- LSM에 등록된 이름(사람이 읽는 레이블)으로 먼저 조회
    -- ★ LSM:Fetch는 키를 모를 때 기본값(엉뚱한 값)을 반환할 수 있으므로
    --   반환값이 실제 문자열 경로인지 반드시 확인해야 함.
    if LSM then
        local resolved = LSM:Fetch("sound", path)
        if type(resolved) == "string" and resolved ~= "" then return resolved end
    end
    -- LSM에 없으면 파일 경로 그대로 사용 (직접 경로 입력 방식 호환)
    return path
end

-------------------------------------------------------
-- DB
-------------------------------------------------------
MoonlightSkillSoundDB = MoonlightSkillSoundDB or {}

-- 경로 오류 안내 메시지 (ADDON_LOADED 시점엔 채팅창/스킬명 사용 불가 → PLAYER_LOGIN 때 출력)
local pendingWarnings = {}

-------------------------------------------------------
-- DB 경로 마이그레이션
-- 잘못 배포된 경로(\ 가 이스케이프 처리되어 구분자 자체가 사라진 경우)를
-- DefaultPreset의 올바른 경로로 덮어씁니다.
-- 판별 기준: soundPath/imagePath 에 / 또는 \ 가 전혀 없으면 깨진 경로로 판단.
-- _migrateVersion으로 1회만 실행됩니다.
-------------------------------------------------------
-- DefaultPreset에서 특정 spellID의 원본 필드 값을 가져오는 헬퍼
local function MigratePathSeparators()
    -- 매 로그인마다 DB 전체를 검사해 잘못된 경로를 교정
    -- 스킬 수가 많지 않으므로 성능 문제 없음
    local count = 0
    for classID, spells in pairs(MoonlightSkillSoundDB) do
        if type(classID) == "number" and type(spells) == "table" then
            local defaults = MoonlightSkillSoundDefaults and MoonlightSkillSoundDefaults[classID]
            for spellID, entry in pairs(spells) do
                if type(entry) == "table" then
                    -- imagePath는 조용히 교정
                    if type(entry.imagePath) == "string" and entry.imagePath ~= "" then
                        entry.imagePath = NormalizePath(entry.imagePath)
                    end

                    if type(entry.soundPath) == "string" and entry.soundPath ~= "" then
                        local original = entry.soundPath
                        local src = defaults and defaults[spellID]
                        local correctPath = src and src.soundPath and NormalizePath(src.soundPath)

                        -- 문법 오류 판별: 구분자 없음 / 이중 백슬래시 / 슬래시 사용
                        local kind
                        if not original:find("[/\\]") then
                            kind = "missing_separator"
                        elseif original:find("\\\\") then
                            kind = "double_slash"
                        elseif original:find("/") then
                            kind = "forward_slash"
                        end

                        if kind then
                            -- 문법 오류 O → 교정 시도
                            if kind == "missing_separator" then
                                -- 구분자 자체가 없으면 NormalizePath로는 복원 불가
                                -- DefaultPreset이 있으면 복원, 없으면 수동 안내
                                if correctPath and correctPath ~= "" then
                                    entry.soundPath = correctPath
                                    count = count + 1
                                    table.insert(pendingWarnings, {
                                        spellID = spellID,
                                        path    = original,
                                        fixed   = correctPath,
                                        kind    = "missing_separator_fixed",
                                    })
                                else
                                    table.insert(pendingWarnings, {
                                        spellID = spellID,
                                        path    = original,
                                        kind    = "missing_separator",
                                    })
                                end
                            else
                                -- 이중 백슬래시 또는 슬래시 → NormalizePath로 교정 가능
                                local fixed = NormalizePath(original)
                                entry.soundPath = fixed
                                count = count + 1
                                table.insert(pendingWarnings, {
                                    spellID = spellID,
                                    path    = original,
                                    fixed   = fixed,
                                    kind    = kind .. "_fixed",
                                })
                            end
                        end
                        -- 문법 오류 없는 정상 경로: DefaultPreset과 달라도 건드리지 않음
                    end
                end
            end
        end
    end

    if count > 0 then
        print("|cffffcc00[Moonlight 스킬사운드]|r 경로 오류 " .. count .. "개를 자동 교정했습니다.")
    end
end

-- alias 마이그레이션: DefaultPreset에 alias가 있으면 기존 DB에도 반영
-- 유저가 직접 수정한 alias는 덮어쓰지 않음 (_aliasVersion으로 1회 실행)
local function MigrateAlias()
    if MoonlightSkillSoundDB._aliasMigrated then return end
    if not MoonlightSkillSoundDefaults then return end
    for classID, spells in pairs(MoonlightSkillSoundDefaults) do
        for spellID, data in pairs(spells) do
            if data.alias then
                local entry = MoonlightSkillSoundDB[classID] and MoonlightSkillSoundDB[classID][spellID]
                if entry and (not entry.alias or entry.alias == "") then
                    entry.alias = data.alias
                end
            end
        end
    end
    MoonlightSkillSoundDB._aliasMigrated = true
end
local function ApplyMissingDefaults(overwrite)
    if not MoonlightSkillSoundDefaults then return end
    for classID, spells in pairs(MoonlightSkillSoundDefaults) do
        if not MoonlightSkillSoundDB[classID] then
            MoonlightSkillSoundDB[classID] = {}
        end
        for spellID, data in pairs(spells) do
            -- ★ forceOverwrite = true 가 달린 스킬은 overwrite 플래그와 무관하게
            --   항상 덮어씁니다. 음원 경로 교정, 잘못 배포된 스킬 수정 등에 사용.
            --   배포 후 다음 버전에서는 플래그를 제거해 유저 설정을 다시 보존하세요.
            local shouldWrite = overwrite
                             or not MoonlightSkillSoundDB[classID][spellID]
                             or data.forceOverwrite == true
            if shouldWrite then
                MoonlightSkillSoundDB[classID][spellID] = {
                    soundPath      = NormalizePath(data.soundPath),
                    soundFileDataID = data.soundFileDataID or nil,  -- 와우 내장 음원 FileDataID
                    soundChannel   = NormalizeChannel(data.soundChannel),
                    enabled        = data.enabled ~= false,
                    triggerType    = data.triggerType or "instant",
                    alias          = data.alias or nil,
                    -- 이미지 필드
                    imagePath        = NormalizePath(data.imagePath or ""),
                    imageEnabled     = data.imageEnabled     == true,
                    imageDuration    = data.imageDuration    or 3.0,
                    imageScale       = data.imageScale       or 1.0,
                    imagePosX        = data.imagePosX        or 0,
                    imagePosY        = data.imagePosY        or 0,
                    imagePosAnchor   = data.imagePosAnchor   or nil,
                    -- 페이드 효과
                    imageFadeEnabled = data.imageFadeEnabled == true,
                    imageFadeIn      = data.imageFadeIn      or 0.3,
                    imageFadeOut     = data.imageFadeOut     or 0.4,

                    -- 초시계 필드
                    timerEnabled   = data.timerEnabled   == true,
                    timerDuration  = data.timerDuration  or 0,
                    timerSpellName = data.timerSpellName or "",
                    timerCustomName = data.timerCustomName or nil,  -- ★ 사용자 지정 표시 이름
                    timerFontSize  = data.timerFontSize  or 22,
                    timerColorR    = data.timerColorR    or 1,
                    timerColorG    = data.timerColorG    or 1,
                    timerColorB    = data.timerColorB    or 1,
                    timerBgEnabled = data.timerBgEnabled == true,
                    timerBgAlpha   = data.timerBgAlpha   or 0.5,
                    timerPosX      = data.timerPosX      or 0,
                    timerPosY      = data.timerPosY      or 0,
                    timerPosAnchor = data.timerPosAnchor or nil,
                }
            end
        end
    end
end

local function InitDB()
    for _, c in ipairs(CLASS_LIST) do
        if not MoonlightSkillSoundDB[c.id] then
            MoonlightSkillSoundDB[c.id] = {}
        end
    end

    -- ★ 경로 마이그레이션: 단일 \ → \\ 교정 (기존 유저 DB 1회 수정)
    --   반드시 ApplyMissingDefaults 호출 전에 실행해야 합니다.
    MigratePathSeparators()
    MigrateAlias()

    local isFirstInstall = (MoonlightSkillSoundDB._version == nil)
    local PRESET_VERSION = MOONLIGHT_SKILLSOUND_PRESET_VERSION or 1

    if isFirstInstall then
        ApplyMissingDefaults(true)
        MoonlightSkillSoundDB._version = PRESET_VERSION
    elseif (MoonlightSkillSoundDB._version or 0) < PRESET_VERSION then
        ApplyMissingDefaults(false)
        MoonlightSkillSoundDB._version = PRESET_VERSION
    end

    -- 전역 초시계 앵커 기본값
    if not MoonlightSkillSoundDB._timerAnchor then
        MoonlightSkillSoundDB._timerAnchor = {
            posX    = 0,
            posY    = 150,
            anchor  = nil,   -- nil = CENTER 기준, "BOTTOMLEFT" = 좌하단 기준
            gap     = 36,    -- 초시계 간격(px)
            growDir = "down", -- "down" = 아래로 쌓임, "up" = 위로 쌓임
        }
    end
    if MoonlightSkillSoundDB._timerAnchor.gap     == nil then MoonlightSkillSoundDB._timerAnchor.gap     = 36     end
    if MoonlightSkillSoundDB._timerAnchor.growDir == nil then MoonlightSkillSoundDB._timerAnchor.growDir = "down" end

    -- 사망 이벤트 카테고리 (classID=16) 초기화
    if not MoonlightSkillSoundDB[16] then
        MoonlightSkillSoundDB[16] = {}
    end

    -- ★ 기존 _deathSound 구조를 classID=16 구조로 마이그레이션
    --   _deathSound가 있고 아직 마이그레이션이 안 된 경우 한 번만 실행
    if MoonlightSkillSoundDB._deathSound and not MoonlightSkillSoundDB._deathMigrated then
        local old = MoonlightSkillSoundDB._deathSound
        if not MoonlightSkillSoundDB[16]["__death__"] then
            MoonlightSkillSoundDB[16]["__death__"] = {
                soundPath    = old.soundPath  or "",
                soundChannel = NormalizeChannel(old.channel),
                enabled      = old.enabled    == true,
                triggerType  = "instant",
                imagePath = "", imageEnabled = false, imageDuration = 3.0,
                imageScale = 1.0, imagePosX = 0, imagePosY = 0, imagePosAnchor = nil,
                imageFadeEnabled = false, imageFadeIn = 0.3, imageFadeOut = 0.4,
                timerEnabled = false, timerDuration = 0, timerSpellName = "사망",
                timerFontSize = 22, timerColorR = 1, timerColorG = 0.2, timerColorB = 0.2,
                timerBgEnabled = false, timerBgAlpha = 0.5,
            }
        end
        MoonlightSkillSoundDB._deathMigrated = true
    end

    -- ★ 처음 설치이거나 classID=16 항목이 없으면 프리셋에서 채우기
    local preset     = MoonlightSkillSoundDeathDefault
    local presetVer  = MOONLIGHT_DEATH_PRESET_VERSION or 1
    local appliedVer = MoonlightSkillSoundDB._deathSoundVersion or 0

    if not MoonlightSkillSoundDB[16]["__death__"] or appliedVer < presetVer then
        local ds = MoonlightSkillSoundDB[16]["__death__"] or {}
        local defaultPath = preset and NormalizePath(preset.soundPath) or ""
        local oldDefault  = ds._lastPresetPath or ""

        if not ds.soundPath or ds.soundPath == "" or ds.soundPath == oldDefault then
            ds.soundPath = defaultPath
        end
        ds.soundChannel  = preset and NormalizeChannel(preset.channel) or "Master"
        ds.enabled       = preset and (preset.enabled == true) or false
        ds.triggerType   = "instant"

        -- 이미지 경로: 비어 있거나 이전 프리셋 경로 그대로인 경우만 덮어씁니다
        local presetImgPath = preset and NormalizePath(preset.imagePath or "") or ""
        local oldImgDefault = ds._lastPresetImagePath or ""
        if presetImgPath ~= "" and (not ds.imagePath or ds.imagePath == "" or ds.imagePath == oldImgDefault) then
            ds.imagePath = presetImgPath
        end
        if ds.imagePath == nil then ds.imagePath = "" end

        -- imageEnabled: 버전 업 시 프리셋 값으로 무조건 덮어씁니다
        --              (preset에 필드가 없으면 false)
        ds.imageEnabled = (preset and preset.imageEnabled == true)

        -- 나머지 이미지/타이머 필드는 기존 값이 있으면 유지
        if ds.imageDuration   == nil then ds.imageDuration   = preset and preset.imageDuration   or 3.0 end
        if ds.imageScale      == nil then ds.imageScale      = preset and preset.imageScale      or 1.0 end
        if ds.imagePosX       == nil then ds.imagePosX       = 0 end
        if ds.imagePosY       == nil then ds.imagePosY       = 0 end
        if ds.imageFadeEnabled== nil then ds.imageFadeEnabled= preset and preset.imageFadeEnabled == true or false end
        if ds.imageFadeIn     == nil then ds.imageFadeIn     = preset and preset.imageFadeIn     or 0.3 end
        if ds.imageFadeOut    == nil then ds.imageFadeOut    = preset and preset.imageFadeOut    or 0.4 end
        if ds.timerEnabled    == nil then ds.timerEnabled    = false end
        if ds.timerDuration   == nil then ds.timerDuration   = 0 end
        if ds.timerSpellName  == nil then ds.timerSpellName  = "사망" end
        if ds.timerFontSize   == nil then ds.timerFontSize   = 22 end
        if ds.timerColorR     == nil then ds.timerColorR     = 1 end
        if ds.timerColorG     == nil then ds.timerColorG     = 0.2 end
        if ds.timerColorB     == nil then ds.timerColorB     = 0.2 end
        if ds.timerBgEnabled  == nil then ds.timerBgEnabled  = false end
        if ds.timerBgAlpha    == nil then ds.timerBgAlpha    = 0.5 end

        ds._lastPresetPath      = defaultPath
        ds._lastPresetImagePath = presetImgPath
        MoonlightSkillSoundDB[16]["__death__"] = ds
        MoonlightSkillSoundDB._deathSoundVersion = presetVer
    end
end

-------------------------------------------------------
-- 런타임 맵 (triggerType별 분리)
-------------------------------------------------------
local instantMap = {}
local castMap    = {}
local channelMap = {}

-- ────────────────────────────────────────────────────────────
--  아이템 맵: itemID → { path, channel }
--  UseAction hook에서 조회. classID=15(아이템) DB 항목을 기반으로 빌드.
--  장신구(classID=14)와 달리 스킬 이벤트가 아닌 액션 사용으로 감지.
-- ────────────────────────────────────────────────────────────
local itemMap = {}

local function RebuildItemMap()
    wipe(itemMap)
    local tbl = MoonlightSkillSoundDB[15]
    if not tbl then return end
    for itemID, data in pairs(tbl) do
        if data.enabled and data.soundPath and data.soundPath ~= "" then
            local key = tonumber(itemID) or itemID
            local entry = {
                path    = data.soundPath,
                channel = NormalizeChannel(data.soundChannel),
            }
            -- ItemID 기준으로 등록
            itemMap[key] = entry
            -- ★ GetActionInfo가 SpellID를 반환하는 경우도 커버:
            --   GetItemSpell(itemID) → spellName, spellID
            --   생명석처럼 아이템 사용 시 내부적으로 스펠이 실행되는 경우
            --   actionType="spell"로 넘어올 수 있으므로 SpellID로도 매핑.
            if GetItemSpell then
                local _, spellID = GetItemSpell(key)
                if spellID then
                    itemMap[tonumber(spellID)] = entry
                end
            end
        end
    end
end

-- ────────────────────────────────────────────────────────────
--  이미지 맵 (RebuildRuntimeMap보다 먼저 정의해야 호출 가능)
--
--  ★ 스킬별 독립 프레임 방식
--    단일 imgFrame을 공유하면 A 스킬 표시 중 B 스킬이 발동할 때
--    scale·위치·텍스처가 덮어씌워지는 간섭이 발생함.
--    각 스킬에 전용 프레임을 할당해 완전히 독립적으로 동작하게 함.
--
--  프레임 풀(imgFramePool): WoW는 프레임을 GC로 해제할 수 없으므로
--    RebuildImageMap 호출 시 기존 프레임을 재사용(숨김→풀 반환)함.
-- ────────────────────────────────────────────────────────────
local imageMap    = {}
local imgMoveMode = false

-- 프레임 풀: 사용하지 않는 프레임을 보관했다가 재사용
local imgFramePool = {}

-- 미리보기 전용 단일 프레임 (UI 편집 시에만 사용, 실제 재생과 분리)
local imgPreviewFrame = CreateFrame("Frame", "MoonlightSkillImagePreviewFrame", UIParent)
imgPreviewFrame:SetSize(256, 256)
imgPreviewFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
imgPreviewFrame:Hide()
local imgPreviewTexture = imgPreviewFrame:CreateTexture(nil, "OVERLAY")
imgPreviewTexture:SetAllPoints(imgPreviewFrame)
imgPreviewTexture:SetAlpha(0)

-- 풀에서 프레임을 꺼내거나 새로 생성
local function GetPooledImgFrame()
    for i, f in ipairs(imgFramePool) do
        if not f:IsShown() then
            table.remove(imgFramePool, i)
            return f
        end
    end
    -- 풀에 여유 프레임 없음 → 새로 생성
    local f = CreateFrame("Frame", nil, UIParent)
    f:SetSize(256, 256)
    f:Hide()
    local tex = f:CreateTexture(nil, "OVERLAY")
    tex:SetAllPoints(f)
    tex:SetAlpha(0)
    f._texture = tex
    return f
end

-- 프레임을 풀로 반환 (숨김 처리 포함)
local function ReturnImgFrameToPool(f)
    if not f then return end
    -- 진행 중인 타이머/틱커 정리
    if f._hideTimer  then f._hideTimer:Cancel();  f._hideTimer  = nil end
    if f._fadeTicker then f._fadeTicker:Cancel(); f._fadeTicker = nil end
    f:Hide()
    if f._texture then f._texture:SetAlpha(0) end
    table.insert(imgFramePool, f)
end

local function ImgFrameFadeOut(f, fadeTime)
    if f._fadeTicker then f._fadeTicker:Cancel() end
    local tex   = f._texture
    local start = GetTime()
    local FADE  = math.max(0.05, fadeTime or 0.4)
    f._fadeTicker = C_Timer.NewTicker(0.05, function(t)
        local prog = math.min((GetTime() - start) / FADE, 1)
        tex:SetAlpha(1 - prog)
        if prog >= 1 then
            t:Cancel()
            f._fadeTicker = nil
            ReturnImgFrameToPool(f)
        end
    end)
end

local function ShowSkillImage(spellID)
    if imgMoveMode then return end
    local entry = imageMap[tonumber(spellID)] or imageMap[spellID]
    if not entry then return end

    -- 이미 이 스킬의 프레임이 표시 중이면 초기화 후 재사용
    local f = entry.frame
    if f then
        ReturnImgFrameToPool(f)
    end

    -- 풀에서 프레임 확보
    f = GetPooledImgFrame()
    entry.frame = f  -- 이 스킬의 현재 활성 프레임으로 등록

    local tex   = f._texture
    local destX = entry.posX or 0
    local destY = entry.posY or 0

    f:SetSize(entry.width or 256, entry.height or 256)
    f:SetScale(entry.scale or 1.0)
    tex:SetTexture(entry.path)

    f:ClearAllPoints()
    if entry.posAnchor == "BOTTOMLEFT" then
        f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", destX, destY)
    else
        f:SetPoint("CENTER", UIParent, "CENTER", destX, destY)
    end
    f:Show()

    -- ── 페이드인 ─────────────────────────────────────────────
    if entry.fadeEnabled then
        local fadeIn   = math.max(0.05, entry.fadeIn or 0.3)
        local startFade = GetTime()
        tex:SetAlpha(0)
        f._fadeTicker = C_Timer.NewTicker(0.05, function(t)
            local prog = math.min((GetTime() - startFade) / fadeIn, 1)
            tex:SetAlpha(prog)
            if prog >= 1 then
                tex:SetAlpha(1)
                t:Cancel()
                f._fadeTicker = nil
            end
        end)
    else
        tex:SetAlpha(1)
    end

    -- ── 표시 시간 후 페이드아웃 또는 즉시 숨김 ───────────────
    local capturedF = f  -- 클로저용 캡처 (entry.frame이 교체될 수 있으므로)
    f._hideTimer = C_Timer.NewTimer(entry.duration, function()
        capturedF._hideTimer = nil
        if not imgMoveMode then
            if entry.fadeEnabled then
                ImgFrameFadeOut(capturedF, entry.fadeOut)
            else
                ReturnImgFrameToPool(capturedF)
            end
        end
    end)
end

local function RebuildImageMap()
    -- 기존 활성 프레임을 모두 풀로 반환
    for _, entry in pairs(imageMap) do
        if entry.frame then
            ReturnImgFrameToPool(entry.frame)
            entry.frame = nil
        end
    end
    wipe(imageMap)

    local _, classFile = UnitClass("player")
    if not classFile then return end
    local cls = CLASS_BY_ENGLISH[classFile]
    if not cls then return end

    local function loadImg(tbl)
        if not tbl then return end
        for spellID, data in pairs(tbl) do
            if data.imageEnabled and data.imagePath and data.imagePath ~= "" then
                imageMap[tonumber(spellID) or spellID] = {
                    frame        = nil,   -- 재생 시 풀에서 할당
                    path         = data.imagePath,
                    duration     = data.imageDuration or 3.0,
                    scale        = data.imageScale    or 1.0,
                    width        = 256,
                    height       = 256,
                    posX         = data.imagePosX     or 0,
                    posY         = data.imagePosY     or 0,
                    posAnchor    = data.imagePosAnchor,
                    fadeEnabled  = data.imageFadeEnabled == true,
                    fadeIn       = data.imageFadeIn    or 0.3,
                    fadeOut      = data.imageFadeOut   or 0.4,
                }
            end
        end
    end
    loadImg(MoonlightSkillSoundDB[cls.id])
    loadImg(MoonlightSkillSoundDB[14])
    loadImg(MoonlightSkillSoundDB[16])  -- 사망 이벤트: 모든 직업 공통
end

-- ────────────────────────────────────────────────────────────
--  초시계 런타임 (앵커 기준 스택 방식)
--  모든 초시계는 전역 앵커(MoonlightSkillSoundDB._timerAnchor) 기준으로
--  위에서부터 gap 간격으로 쌓임.
-- ────────────────────────────────────────────────────────────
local timerMap      = {}
local timerMoveMode = false   -- 앵커 이동 모드

-- 활성 타이머: spellID → { frame, ticker }
local activeTimers  = {}

-- 프레임 풀 (재사용)
local timerFramePool = {}

local function GetTimerAnchor()
    if MoonlightSkillSoundDB and MoonlightSkillSoundDB._timerAnchor then
        return MoonlightSkillSoundDB._timerAnchor
    end
    return { posX=0, posY=150, anchor=nil, gap=36 }
end

local function GetPooledTimerFrame()
    for _, f in ipairs(timerFramePool) do
        if not f:IsShown() then return f end
    end
    local f = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    f:SetSize(200, 36)
    f:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        tile = false, edgeSize = 1,
    })
    f:SetBackdropColor(0,0,0,0)
    f:SetBackdropBorderColor(0,0,0,0)
    f:Hide()
    f:SetAlpha(0)
    local t = f:CreateFontString(nil, "OVERLAY")
    t:SetFont(GameFontNormal:GetFont(), 22, "OUTLINE")
    t:SetPoint("CENTER", f, "CENTER", 0, 0)
    t:SetText("")
    t:SetTextColor(1,1,1)
    f.timerText = t
    table.insert(timerFramePool, f)
    return f
end

-- 모든 활성 초시계의 위치를 앵커 기준으로 재배치
local function RelayoutTimers()
    local anc     = GetTimerAnchor()
    local gap     = anc.gap     or 36
    local growDir = anc.growDir or "down"
    local dir     = (growDir == "up") and 1 or -1   -- 위 = +Y, 아래 = -Y

    -- spellID 정렬로 순서 일관성 유지
    local order = {}
    for sid in pairs(activeTimers) do table.insert(order, sid) end
    table.sort(order)

    for i, sid in ipairs(order) do
        local info = activeTimers[sid]
        if info and info.frame and info.frame:IsShown() then
            local f       = info.frame
            local offsetY = dir * (i - 1) * gap

            f:ClearAllPoints()
            if anc.anchor == "BOTTOMLEFT" then
                f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", anc.posX, anc.posY + offsetY)
            else
                f:SetPoint("CENTER", UIParent, "CENTER", anc.posX, anc.posY + offsetY)
            end

            -- 첫 번째(앵커 기준) 스킬: 텍스트에 ★ 마커 추가
            info.isAnchor = (i == 1)
        end
    end
end

local function StopTimerForSpell(spellID)
    local active = activeTimers[spellID]
    if not active then return end
    if active.ticker then active.ticker:Cancel() end
    active.frame:Hide()
    active.frame:SetAlpha(0)
    activeTimers[spellID] = nil
    RelayoutTimers()
end

local function ShowSkillTimer(spellID)
    if timerMoveMode then return end
    local entry = timerMap[tonumber(spellID)] or timerMap[spellID]
    if not entry or entry.duration <= 0 then return end

    StopTimerForSpell(spellID)

    local f = GetPooledTimerFrame()
    local t = f.timerText

    local font = GameFontNormal:GetFont()
    t:SetFont(font, entry.fontSz or 22, "OUTLINE")
    t:SetTextColor(entry.r or 1, entry.g or 1, entry.b or 1)

    if entry.bgEnabled then
        f:SetBackdropColor(0,0,0, entry.bgAlpha or 0.5)
        f:SetBackdropBorderColor(0.3,0.3,0.3,0.8)
    else
        f:SetBackdropColor(0,0,0,0)
        f:SetBackdropBorderColor(0,0,0,0)
    end

    local startTime = GetTime()
    local dur       = entry.duration
    -- ★ timerCustomName/alias/timerSpellName이 없으면(nil) 발동 시점에 실시간 조회
    --   classID를 함께 전달해 장신구(14) 등도 올바른 API로 조회
    local label = entry.spellName
                  or (GetEntryInfo_Safe and GetEntryInfo_Safe(entry.rawSpellID, entry.classID))
                  or (GetSpellInfo_Safe  and GetSpellInfo_Safe(entry.rawSpellID))
                  or ("Spell " .. tostring(entry.rawSpellID or "?"))
    local lastCeil  = -1  -- 마지막으로 표시한 초 값 (변경 시에만 SetText·SetSize)

    local function updateText()
        local remaining = math.max(0, dur - (GetTime() - startTime))
        local ceilVal   = math.ceil(remaining)

        -- 표시 값이 바뀌었을 때만 SetText·SetSize 호출 (매 틱 불필요한 렌더링 방지)
        if ceilVal ~= lastCeil then
            lastCeil = ceilVal
            local prefix = ""
            if activeTimers[spellID] and activeTimers[spellID].isAnchor then
                prefix = "★ "
            end
            t:SetText(string.format("%s%s - %d초", prefix, label, ceilVal))
            local tw = t:GetStringWidth() + 20
            local th = t:GetStringHeight() + 14
            f:SetSize(math.max(tw, 80), math.max(th, 30))
        end

        if remaining <= 0 then
            StopTimerForSpell(spellID)
        end
    end

    updateText()
    f:SetAlpha(1)
    f:Show()
    local ticker = C_Timer.NewTicker(0.5, function() updateText() end)
    activeTimers[spellID] = { frame = f, ticker = ticker }
    RelayoutTimers()
end
local function RebuildTimerMap()
    wipe(timerMap)
    local _, classFile = UnitClass("player")
    if not classFile then return end
    local cls = CLASS_BY_ENGLISH[classFile]
    if not cls then return end
    local function loadTimer(tbl, srcClassID)
        if not tbl then return end
        for spellID, data in pairs(tbl) do
            if data.timerEnabled and (data.timerDuration or 0) > 0 then
                -- ★ 우선순위: timerCustomName(사용자 지정) > alias(메모) > timerSpellName(자동저장) > nil(발동 시 실시간 조회)
                local customName = (data.timerCustomName and data.timerCustomName ~= "") and data.timerCustomName or nil
                local aliasName  = (data.alias and data.alias ~= "") and data.alias or nil
                local fixedName  = customName
                                   or aliasName
                                   or ((data.timerSpellName and data.timerSpellName ~= ""
                                        and not data.timerSpellName:find("^Spell ")
                                        and not data.timerSpellName:find("^Entry ")) and data.timerSpellName or nil)
                timerMap[tonumber(spellID) or spellID] = {
                    duration   = data.timerDuration or 0,
                    spellName  = fixedName,   -- nil이면 ShowSkillTimer에서 실시간 조회
                    rawSpellID = tonumber(spellID) or spellID,
                    classID    = srcClassID,  -- ★ GetEntryInfo_Safe에 classID 전달용
                    fontSz     = data.timerFontSize  or 22,
                    r          = data.timerColorR    or 1,
                    g          = data.timerColorG    or 1,
                    b          = data.timerColorB    or 1,
                    bgEnabled  = data.timerBgEnabled == true,
                    bgAlpha    = data.timerBgAlpha   or 0.5,
                    -- 위치는 전역 앵커에서 RelayoutTimers()가 결정
                }
            end
        end
    end
    loadTimer(MoonlightSkillSoundDB[cls.id], cls.id)
    loadTimer(MoonlightSkillSoundDB[14],     14)
    loadTimer(MoonlightSkillSoundDB[16],     16)  -- 사망 이벤트: 모든 직업 공통
end

local function RebuildRuntimeMap()
    wipe(instantMap)
    wipe(castMap)
    wipe(channelMap)

    local _, classFile = UnitClass("player")
    if not classFile then return end

    -- ★ 역방향 맵으로 O(1) 직업 조회
    local cls = CLASS_BY_ENGLISH[classFile]
    if not cls then return end

    local function loadEntries(tbl)
        if not tbl then return end
        for spellID, data in pairs(tbl) do
            -- ★ soundFileDataID(내장 음원) 또는 soundPath(파일 경로) 중 하나라도 있으면 등록
            local hasFID  = data.soundFileDataID and data.soundFileDataID ~= 0
            local hasPath = data.soundPath and data.soundPath ~= ""
            if data.enabled and (hasFID or hasPath) then
                -- ★ SavedVariables에서 불러오면 숫자 키도 문자열로 바뀜.
                --   이벤트로 넘어오는 spellID는 항상 숫자이므로 여기서 숫자로 통일.
                local key = tonumber(spellID) or spellID
                local entry = {
                    path        = data.soundPath,
                    fileDataID  = hasFID and tonumber(data.soundFileDataID) or nil,
                    channel     = NormalizeChannel(data.soundChannel),
                }
                local tt = data.triggerType or "instant"
                if tt == "cast" then
                    castMap[key] = entry
                elseif tt == "channel" then
                    channelMap[key] = entry
                else
                    instantMap[key] = entry
                end
            end
        end
    end

    loadEntries(MoonlightSkillSoundDB[cls.id])
    loadEntries(MoonlightSkillSoundDB[14])  -- 장신구: 모든 직업 공통
    RebuildItemMap()
    RebuildImageMap()
    RebuildTimerMap()

    -- ★ timerSpellName이 비어있는 DB 항목을 보완: PLAYER_LOGIN 이후 GetSpellInfo가
    --   준비된 시점에 RebuildRuntimeMap이 호출되므로, 여기서 스킬명을 채워두면
    --   최초 발동 시 "Spell XXXXX" 폴백이 표시되는 문제를 방지합니다.
    local function fillMissingSpellNames(tbl, cID)
        if not tbl then return end
        for spellID, data in pairs(tbl) do
            if data.timerEnabled and (data.timerDuration or 0) > 0
            and (not data.timerSpellName or data.timerSpellName == ""
                 or data.timerSpellName:find("^Spell ")
                 or data.timerSpellName:find("^Entry ")) then
                local name
                if cID == 15 then
                    name = GetItemInfo and (GetItemInfo(tonumber(spellID) or spellID))
                else
                    if C_Spell and C_Spell.GetSpellInfo then
                        local info = C_Spell.GetSpellInfo(tonumber(spellID) or spellID)
                        name = info and info.name
                    end
                    if not name and GetSpellInfo then
                        name = GetSpellInfo(tonumber(spellID) or spellID)
                    end
                    if not name and C_Spell and C_Spell.GetSpellName then
                        name = C_Spell.GetSpellName(tonumber(spellID) or spellID)
                    end
                end
                if name and name ~= "" then
                    data.timerSpellName = name
                    -- timerMap도 즉시 갱신
                    local key = tonumber(spellID) or spellID
                    if timerMap[key] and not timerMap[key].spellName then
                        timerMap[key].spellName = name
                    end
                end
            end
        end
    end
    fillMissingSpellNames(MoonlightSkillSoundDB[cls.id], cls.id)
    fillMissingSpellNames(MoonlightSkillSoundDB[14], 14)
    fillMissingSpellNames(MoonlightSkillSoundDB[16], 16)
end

-- 중복 재생 방지 테이블 (spellID_triggerType → 마지막 재생 시각)
local recentPlayed = {}

-- 전체 재생률 제한: 0.5초 슬라이딩 윈도우 내 최대 10회
-- AOE·도트 스킬이 동시에 이벤트를 여러 번 발생시킬 때 버스트 차단
-- (6→10으로 완화: 연속 시전 직업에서 의도치 않게 차단되던 문제 개선)
local recentPlayLog   = {}
local PLAY_RATE_LIMIT  = 10
local PLAY_RATE_WINDOW = 0.5

-- recentPlayed 자동 청소: 장기 전투 시 테이블이 무한 누적되는 것을 방지
-- 2초 이상 된 항목은 어차피 중복 판정에 쓰이지 않으므로 제거
local function StartRecentPlayedCleanup()
    C_Timer.NewTicker(60, function()
        local now = GetTime()
        for k, t in pairs(recentPlayed) do
            if (now - t) > 2.0 then
                recentPlayed[k] = nil
            end
        end
        -- 재생 로그도 오래된 것 청소
        local cutoff = now - PLAY_RATE_WINDOW * 2
        local j = 0
        for i = 1, #recentPlayLog do
            if recentPlayLog[i] >= cutoff then
                j = j + 1
                recentPlayLog[j] = recentPlayLog[i]
            end
        end
        for i = j + 1, #recentPlayLog do recentPlayLog[i] = nil end
    end)
end

local function PlaySkillSound(spellID, triggerType)
    local map
    if triggerType == "cast" then
        map = castMap
    elseif triggerType == "channel" then
        map = channelMap
    else
        map = instantMap
    end
    local s = map[spellID]
    if not s then return end

    -- 중복 재생 방지: 동일 spellID + triggerType 조합으로 0.2초 이내 재생 차단
    local key = spellID .. "_" .. triggerType
    local now = GetTime()
    if recentPlayed[key] and (now - recentPlayed[key]) < 0.2 then return end
    recentPlayed[key] = now

    -- 전체 재생률 제한: 0.5초 슬라이딩 윈도우 내 PLAY_RATE_LIMIT 회 초과 시 차단
    -- 오래된 항목을 앞에서부터 제거 (in-place)
    local cutoff = now - PLAY_RATE_WINDOW
    local head = 1
    while head <= #recentPlayLog and recentPlayLog[head] < cutoff do
        head = head + 1
    end
    if head > 1 then
        local j = 0
        for i = head, #recentPlayLog do
            j = j + 1
            recentPlayLog[j] = recentPlayLog[i]
        end
        for i = j + 1, #recentPlayLog do recentPlayLog[i] = nil end
    end
    if #recentPlayLog >= PLAY_RATE_LIMIT then return end
    recentPlayLog[#recentPlayLog + 1] = now

    -- ★ WoW는 PlaySoundFile 동시 스트림 수에 내부 한도가 있습니다.
    --   보스전처럼 BGM·환경음·스킬음이 동시에 쏟아질 때 한도에 걸리면
    --   엔진이 새 스트림 요청을 조용히 무시하고 핸들로 nil을 반환합니다.
    --
    --   대응 전략:
    --   1) soundFileDataID가 있으면 내장 음원(FileDataID)으로 우선 재생
    --   2) 없으면 파일 경로로 재생 (기존 방식)
    --   3) 핸들이 nil(= 슬롯 부족)이면 Dialog 채널로 즉시 재시도
    --   4) Dialog도 nil이면 0.05초 후 Dialog로 한 번 더 재시도
    local handle
    if s.fileDataID then
        -- ★ 내장 음원 FileDataID 재생 (PlaySoundFile은 숫자도 허용)
        handle = PlaySoundFile(s.fileDataID, s.channel)
        if not handle then
            handle = PlaySoundFile(s.fileDataID, "Dialog")
            if not handle then
                C_Timer.After(0.05, function()
                    PlaySoundFile(s.fileDataID, "Dialog")
                end)
            end
        end
    else
        -- ★ SharedMedia 이름 또는 파일 경로를 재생 직전에 실시간 resolve
        --   PLAYER_LOGIN 때 캐시한 경로 대신 여기서 LSM을 참조하므로
        --   SharedMedia 등록 순서 문제로 경로가 nil이 되는 현상을 방지합니다.
        local resolvedPath = ResolveSoundPath(s.path)
        if not resolvedPath then return end

        handle = PlaySoundFile(resolvedPath, s.channel)
        if not handle then
            -- 2차 시도: Dialog 채널 (슬롯이 게임 SFX와 분리됨)
            handle = PlaySoundFile(resolvedPath, "Dialog")
            if not handle then
                -- 3차 시도: 0.05초 딜레이 후 Dialog로 재시도
                C_Timer.After(0.05, function()
                    PlaySoundFile(resolvedPath, "Dialog")
                end)
            end
        end
    end
end

-------------------------------------------------------
-- 스킬 정보 조회
-------------------------------------------------------
local function GetSpellInfo_Safe(spellID)
    if C_Spell and C_Spell.GetSpellInfo then
        local info = C_Spell.GetSpellInfo(spellID)
        if info and info.name then return info.name, info.iconID end
    end
    if GetSpellInfo then
        local name, _, icon = GetSpellInfo(spellID)
        if name then return name, icon end
    end
    -- ★ 추가 폴백: C_Spell.GetSpellName (TWW 이후 일부 스펠은 이것만 동작)
    if C_Spell and C_Spell.GetSpellName then
        local name = C_Spell.GetSpellName(spellID)
        if name and name ~= "" then return name, nil end
    end
    return nil, nil
end

-- ★ classID=15(아이템)일 때는 GetItemInfo로 이름/아이콘을 가져옵니다.
--   나머지 classID는 기존처럼 GetSpellInfo_Safe를 씁니다.
local function GetEntryInfo_Safe(entryID, classID)
    if classID == 15 then
        local name, _, _, _, _, _, _, _, _, icon = GetItemInfo(entryID)
        return name, icon
    end
    return GetSpellInfo_Safe(entryID)
end

local function HexColor(hex)
    return tonumber(hex:sub(1,2),16)/255,
           tonumber(hex:sub(3,4),16)/255,
           tonumber(hex:sub(5,6),16)/255
end

-------------------------------------------------------
-- Material Design 3 색상 팔레트
-- Surface 계열: 어두운 배경 레이어
-- Primary: 주요 강조색 (보라빛 파랑)
-- On-Surface: 텍스트/아이콘 색상 계층
-------------------------------------------------------
local MD3 = {
    -- Surface 레이어 (어두운 순서)
    surface          = { 0.071, 0.071, 0.094, 1.00 },  -- #121218 메인 배경
    surfaceContainer = { 0.114, 0.114, 0.149, 1.00 },  -- #1D1D26 카드 배경
    surfaceHigh      = { 0.149, 0.149, 0.196, 1.00 },  -- #262632 호버/선택 배경
    surfaceVariant   = { 0.220, 0.216, 0.275, 1.00 },  -- #383746 테두리/구분선 배경

    -- Primary (강조색: 보라빛 인디고)
    primary          = { 0.490, 0.459, 0.898, 1.00 },  -- #7D75E5
    primaryDim       = { 0.318, 0.294, 0.612, 1.00 },  -- #514B9C 탭 선택 배경
    onPrimary        = { 1.000, 1.000, 1.000, 1.00 },  -- #FFFFFF primary 위 텍스트

    -- Secondary (서브 강조: 골드)
    secondary        = { 1.000, 0.820, 0.200, 1.00 },  -- #FFD133 기존 Moonlight 골드 유지
    onSecondary      = { 0.200, 0.160, 0.000, 1.00 },

    -- On-Surface 텍스트 계층
    onSurface        = { 0.898, 0.882, 0.961, 1.00 },  -- #E5E1F5 기본 텍스트
    onSurfaceMid     = { 0.620, 0.608, 0.710, 1.00 },  -- #9E9BB5 보조 텍스트
    onSurfaceDim     = { 0.420, 0.408, 0.510, 1.00 },  -- #6B6882 비활성 텍스트

    -- 상태색
    success          = { 0.302, 0.882, 0.502, 1.00 },  -- #4DE180
    warning          = { 1.000, 0.647, 0.157, 1.00 },  -- #FFA528
    error            = { 1.000, 0.412, 0.412, 1.00 },  -- #FF6969

    -- 테두리
    outline          = { 0.290, 0.282, 0.376, 0.90 },  -- #4A4860
    outlineVariant   = { 0.200, 0.196, 0.259, 0.80 },  -- #333242
}

-- 팔레트에서 r,g,b,a를 꺼내는 편의 함수
local function MD3C(key) return MD3[key][1], MD3[key][2], MD3[key][3], MD3[key][4] end

-------------------------------------------------------
-- UI 레이아웃 상수 (BuildTab 함수들과 공유)
-------------------------------------------------------
local COL_TOP = -46
local COL_H   = 560
local X1, COL1_W = 16,  148
local X2, COL2_W = 172, 192
local X3, COL3_W = 372, 472
local TAB_W = COL3_W / 3
local TAB_H = 24
local TAB_Y = -50
local PL = 8  -- 탭 내부 패딩
local PW = COL3_W  -- 탭 패널 너비

-------------------------------------------------------
-- UI 상태 변수
-------------------------------------------------------
local mainFrame         = nil
local selectedClassID   = nil
local selectedSpellID   = nil
local selectedChannel   = "Master"
local selectedTriggerType = "instant"
local classRows         = {}
local skillRows         = {}
local skillScrollContent = nil
local _col2Header       = nil  -- 컬럼2 헤더 FontString (BuildUI에서 할당, RefreshSkillColumn에서 갱신)
local _addEdit          = nil  -- 스킬 추가 입력창 (classID=16일 때 숨김)
local _addBtn           = nil  -- 스킬 추가 버튼 (classID=16일 때 숨김)

local detailSpellIcon, detailSpellName
local detailAliasBtn  -- 메모 추가/수정 버튼
local detailPathEdit
-- ★ 내장 음원 FileDataID 관련 UI 변수
local detailFIDEdit         -- FileDataID 입력란
local detailFIDSection      -- FID 섹션 컨테이너 프레임 (show/hide 묶음)
local detailPathSection     -- 파일 경로 섹션 컨테이너 프레임 (show/hide 묶음)
local detailSourceRadioPath -- 라디오: 파일 경로 선택
local detailSourceRadioFID  -- 라디오: 내장 음원 ID 선택
local detailSoundSourceMode = "path"  -- "path" | "fid"
local detailSMPickerBtn, detailSMLabel, detailSMHint
local smPickerPanel = nil
local detailChannelDropdown
local detailTriggerDropdown
local detailEnabledCheck
local detailSaveBtn, detailTestBtn, detailDeleteBtn
local detailStatusFS

-- 이미지 플래시 UI 변수
local detailImageEnabledCheck
local detailImagePathEdit
local detailImageDurationMinus, detailImageDurationPlus, detailImageDurationDisplay
local detailImageScaleDisplay
local detailImageMoveBtn
-- 페이드 효과 UI
local detailImageFadeCheck
local detailImageFadeInDisplay, detailImageFadeOutDisplay

-- imgMoveMode는 상단 이미지 블록에서 이미 선언됨

-- 초시계 UI 변수
local detailTimerEnabledCheck
local detailTimerDurationEdit
local detailTimerCustomNameEdit   -- ★ 초시계 표시 이름 직접 입력
local detailTimerFontSizeDisplay
local detailTimerColorR, detailTimerColorG, detailTimerColorB  -- 현재 선택색 (숫자)
local detailTimerColorBtn
local detailTimerBgCheck
local detailTimerBgAlphaDisplay
local detailTimerMoveBtn

-------------------------------------------------------
-- 헬퍼
-------------------------------------------------------
local function SetStatus(text, r, g, b)
    if detailStatusFS then
        detailStatusFS:SetText(text or "")
        detailStatusFS:SetTextColor(r or 1, g or 0.82, b or 0)
    end
end

local function SetChannel(ch)
    selectedChannel = NormalizeChannel(ch) -- ★ 항상 정규화
    if detailChannelDropdown then
        UIDropDownMenu_SetText(detailChannelDropdown, selectedChannel)
    end
end

local TRIGGER_LABELS = {
    instant = "즉시 시전 (Instant)",
    cast    = "캐스팅 (Cast)",
    channel = "채널링 (Channel)",
}
local function SetTriggerType(tt)
    selectedTriggerType = tt or "instant"
    if detailTriggerDropdown then
        UIDropDownMenu_SetText(detailTriggerDropdown, TRIGGER_LABELS[selectedTriggerType] or selectedTriggerType)
    end
end

-------------------------------------------------------
-- ★ 프리셋 초기화 공통 함수 (resetClassBtn, resetAllBtn 중복 제거)
-------------------------------------------------------
local function ResetClassToPreset(classID)
    if not classID then return false end
    local source = MoonlightSkillSoundDefaults and MoonlightSkillSoundDefaults[classID]
    if not source then return false end
    MoonlightSkillSoundDB[classID] = {}
    for spellID, data in pairs(source) do
        MoonlightSkillSoundDB[classID][spellID] = {
            soundPath      = data.soundPath,
            soundFileDataID = data.soundFileDataID or nil,
            soundChannel   = NormalizeChannel(data.soundChannel),
            enabled        = (data.enabled ~= false),
            triggerType    = data.triggerType or "instant",
            -- 이미지 필드
            imagePath        = data.imagePath        or "",
            imageEnabled     = data.imageEnabled     == true,
            imageDuration    = data.imageDuration    or 3.0,
            imageScale       = data.imageScale       or 1.0,
            imagePosX        = data.imagePosX        or 0,
            imagePosY        = data.imagePosY        or 0,
            imagePosAnchor   = data.imagePosAnchor   or nil,
            imageFadeEnabled = data.imageFadeEnabled == true,
            imageFadeIn      = data.imageFadeIn      or 0.3,
            imageFadeOut     = data.imageFadeOut     or 0.4,

            -- 초시계 필드
            timerEnabled   = data.timerEnabled   == true,
            timerDuration  = data.timerDuration  or 0,
            timerSpellName = data.timerSpellName or "",
            timerCustomName = data.timerCustomName or nil,  -- ★ 사용자 지정 표시 이름
            timerFontSize  = data.timerFontSize  or 22,
            timerColorR    = data.timerColorR    or 1,
            timerColorG    = data.timerColorG    or 1,
            timerColorB    = data.timerColorB    or 1,
            timerBgEnabled = data.timerBgEnabled == true,
            timerBgAlpha   = data.timerBgAlpha   or 0.5,
            timerPosX      = data.timerPosX      or 0,
            timerPosY      = data.timerPosY      or 0,
            timerPosAnchor = data.timerPosAnchor or nil,
        }
    end
    return true
end

-- 현재 선택된 스킬 하나만 프리셋으로 초기화
local function ResetSpellToPreset(classID, spellID)
    if not classID or not spellID then return false end
    local source = MoonlightSkillSoundDefaults
        and MoonlightSkillSoundDefaults[classID]
        and MoonlightSkillSoundDefaults[classID][spellID]
    if not source then return false end
    if not MoonlightSkillSoundDB[classID] then MoonlightSkillSoundDB[classID] = {} end
    MoonlightSkillSoundDB[classID][spellID] = {
        soundPath      = source.soundPath,
        soundFileDataID = source.soundFileDataID or nil,
        soundChannel   = NormalizeChannel(source.soundChannel),
        enabled        = (source.enabled ~= false),
        triggerType    = source.triggerType or "instant",
        imagePath        = source.imagePath        or "",
        imageEnabled     = source.imageEnabled     == true,
        imageDuration    = source.imageDuration    or 3.0,
        imageScale       = source.imageScale       or 1.0,
        imagePosX        = source.imagePosX        or 0,
        imagePosY        = source.imagePosY        or 0,
        imagePosAnchor   = source.imagePosAnchor   or nil,
        imageFadeEnabled = source.imageFadeEnabled == true,
        imageFadeIn      = source.imageFadeIn      or 0.3,
        imageFadeOut     = source.imageFadeOut     or 0.4,

        timerEnabled   = source.timerEnabled   == true,
        timerDuration  = source.timerDuration  or 0,
        timerSpellName = source.timerSpellName or "",
        timerCustomName = nil,  -- ★ 프리셋 초기화 시 커스텀 이름은 지움
        timerFontSize  = source.timerFontSize  or 22,
        timerColorR    = source.timerColorR    or 1,
        timerColorG    = source.timerColorG    or 1,
        timerColorB    = source.timerColorB    or 1,
        timerBgEnabled = source.timerBgEnabled == true,
        timerBgAlpha   = source.timerBgAlpha   or 0.5,
        timerPosX      = source.timerPosX      or 0,
        timerPosY      = source.timerPosY      or 0,
        timerPosAnchor = source.timerPosAnchor or nil,
    }
    return true
end

-------------------------------------------------------
-- 스킬 컬럼 갱신
-------------------------------------------------------
local function RefreshSkillColumn(classID)
    for _, row in ipairs(skillRows) do row:Hide() end
    -- 컬럼2 헤더: 아이템 카테고리 선택 시 "아이템", 나머지는 "스 킬"
    if _col2Header then
        if classID == 15 then
            _col2Header:SetText("|cffA0E0FF아이템|r")
        elseif classID == 16 then
            _col2Header:SetText("|cffFF6969☠ 사망|r")
        else
            _col2Header:SetText("|cff9E9BB5스  킬|r")
        end
    end
    if not classID or not skillScrollContent then return end

    -- classID=16(사망): 추가 입력창 숨기고 고정 항목만 표시
    if _addEdit then _addEdit:SetShown(classID ~= 16) end
    if _addBtn  then _addBtn:SetShown(classID ~= 16)  end

    local db  = MoonlightSkillSoundDB[classID] or {}
    local ids = {}
    for sid in pairs(db) do table.insert(ids, sid) end

    -- ★ 정렬 전 이름 미리 캐싱: 정렬 중 API 중복 호출 방지
    local nameCache = {}
    for _, sid in ipairs(ids) do
        if sid == "__death__" then
            nameCache[sid] = "☠ 캐릭터 사망"
        else
            nameCache[sid] = GetEntryInfo_Safe(sid, classID) or ("Entry " .. sid)
        end
    end
    table.sort(ids, function(a, b) return nameCache[a] < nameCache[b] end)

    for i, spellID in ipairs(ids) do
        local row = skillRows[i]
        if not row then
            row = CreateFrame("Button", nil, skillScrollContent)
            row:SetSize(185, 30)

            row.bg = row:CreateTexture(nil, "BACKGROUND")
            row.bg:SetAllPoints()
            row.bg:SetColorTexture(0,0,0,0)

            row.selBg = row:CreateTexture(nil, "ARTWORK")
            row.selBg:SetAllPoints()
            -- MD3: 선택 행 = primaryDim
            row.selBg:SetColorTexture(MD3C("primaryDim"))
            row.selBg:Hide()

            row.icon = row:CreateTexture(nil, "ARTWORK")
            row.icon:SetSize(20, 20)
            row.icon:SetPoint("LEFT", 6, 0)
            row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

            row.lbl = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            row.lbl:SetPoint("LEFT", row.icon, "RIGHT", 5, 0)
            row.lbl:SetWidth(128)
            row.lbl:SetJustifyH("LEFT")

            row.dot = row:CreateTexture(nil, "OVERLAY")
            row.dot:SetSize(7, 7)
            row.dot:SetPoint("RIGHT", -5, 0)

            row:SetScript("OnClick", function(self)
                selectedSpellID = self.spellID
                for _, r in ipairs(skillRows) do
                    if r:IsShown() then
                        if r.spellID == self.spellID then r.selBg:Show() else r.selBg:Hide() end
                    end
                end
                LoadDetailPanel(self.classID, self.spellID)
            end)
            row:SetScript("OnEnter", function(self)
                if not self.spellID or self.spellID == "__death__" then return end
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                if self.classID == 15 then
                    GameTooltip:SetItemByID(self.spellID)
                else
                    GameTooltip:SetSpellByID(self.spellID)
                end
                GameTooltip:Show()
            end)
            row:SetScript("OnLeave", function()
                GameTooltip:Hide()
            end)
            skillRows[i] = row
        end

        row:ClearAllPoints()
        if i == 1 then
            row:SetPoint("TOPLEFT", skillScrollContent, "TOPLEFT", 0, 0)
        else
            row:SetPoint("TOPLEFT", skillRows[i-1], "BOTTOMLEFT", 0, -2)
        end

        -- ★ 캐시된 이름 재사용, alias 있으면 함께 표시
        local name = nameCache[spellID]
        local _, icon
        if spellID == "__death__" then
            icon = "Interface\\Icons\\ability_creature_cursed_02"
        else
            _, icon = GetEntryInfo_Safe(spellID, classID)
        end
        row.icon:SetTexture(icon or 134400)
        local entry = db[spellID]
        local alias = entry and entry.alias
        local labelText = alias and (name .. " - " .. alias) or name
        -- __death__는 ID 숫자 표시 생략
        if spellID == "__death__" then
            row.lbl:SetText(labelText)
        else
            row.lbl:SetText(labelText .. " |cff888888("..spellID..")|r")
        end
        row.spellID = spellID
        row.classID = classID
        local hasSound  = (entry and entry.soundPath and entry.soundPath ~= "")
                       or (entry and entry.soundFileDataID and entry.soundFileDataID ~= 0)
        local isEnabled = entry and entry.enabled ~= false
        if hasSound and isEnabled then
            row.dot:SetColorTexture(0.2, 1, 0.2, 1); row.dot:Show()
        elseif hasSound then
            row.dot:SetColorTexture(1, 0.6, 0, 1); row.dot:Show()
        else
            row.dot:Hide()
        end

        if selectedSpellID == spellID and selectedClassID == classID then
            row.selBg:Show()
        else
            row.selBg:Hide()
        end
        row:Show()
    end

    skillScrollContent:SetHeight(math.max(1, #ids * 32))
end

-------------------------------------------------------
-- Detail 패널 채우기
-------------------------------------------------------
function LoadDetailPanel(classID, spellID)
    selectedClassID = classID
    selectedSpellID = spellID

    if not spellID then
        if detailSpellIcon    then detailSpellIcon:SetTexture(134400) end
        if detailSpellName    then detailSpellName:SetText("스킬을 선택하세요") end
        if detailAliasBtn     then detailAliasBtn:Hide() end
        if detailPathEdit     then detailPathEdit:SetText("") end
        if detailEnabledCheck then detailEnabledCheck:SetChecked(false) end
        SetChannel("Master")
        SetTriggerType("instant")
        if detailSaveBtn   then detailSaveBtn:Disable()   end
        if detailTestBtn   then detailTestBtn:Disable()   end -- ★ 미선택 시 비활성화
        if detailDeleteBtn then detailDeleteBtn:Disable() end
        SetStatus("")
        return
    end

    local name, icon = GetEntryInfo_Safe(spellID, classID)
    if detailSpellIcon then detailSpellIcon:SetTexture(icon or 134400) end

    -- classID=16(사망): 고정 이름과 아이콘 표시
    if classID == 16 then
        if detailSpellIcon then detailSpellIcon:SetTexture("Interface\\Icons\\ability_creature_cursed_02") end
        name = "☠ 캐릭터 사망"
    end

    local entry = MoonlightSkillSoundDB[classID] and MoonlightSkillSoundDB[classID][spellID]

    -- 스킬명/아이템명 표시: alias가 있으면 "이름 - alias" 형태로
    local displayName = name or (classID == 15 and ("Item "..spellID) or ("Spell "..spellID))
    if entry and entry.alias and entry.alias ~= "" then
        displayName = displayName .. " - " .. entry.alias
    end
    if detailSpellName then detailSpellName:SetText(displayName) end

    -- 메모 버튼 텍스트 갱신
    if detailAliasBtn then
        if entry and entry.alias and entry.alias ~= "" then
            detailAliasBtn:SetText("[메모 수정]")
        else
            detailAliasBtn:SetText("[메모 추가]")
        end
        detailAliasBtn:Show()
    end
    if entry then
        -- ★ 소스 모드 결정: soundFileDataID가 있으면 FID 모드, 없으면 경로 모드
        local hasFID = entry.soundFileDataID and entry.soundFileDataID ~= 0
        detailSoundSourceMode = hasFID and "fid" or "path"
        -- 라디오 버튼 시각 갱신
        if detailSourceRadioPath then
            detailSourceRadioPath:SetChecked(detailSoundSourceMode == "path")
        end
        if detailSourceRadioFID then
            detailSourceRadioFID:SetChecked(detailSoundSourceMode == "fid")
        end
        -- 섹션 표시/숨김
        if detailPathSection then detailPathSection:SetShown(detailSoundSourceMode == "path") end
        if detailFIDSection  then detailFIDSection:SetShown(detailSoundSourceMode == "fid")  end
        -- 입력란 값 복원
        if detailPathEdit     then detailPathEdit:SetText(entry.soundPath or "") end
        if detailFIDEdit then
            local fidVal = entry.soundFileDataID
            detailFIDEdit:SetText(fidVal and fidVal ~= 0 and tostring(fidVal) or "")
        end
        if detailEnabledCheck then detailEnabledCheck:SetChecked(entry.enabled ~= false) end
        SetChannel(entry.soundChannel or "Master")
        SetTriggerType(entry.triggerType or "instant")
        -- 이미지 필드 복원
        if detailImageEnabledCheck then detailImageEnabledCheck:SetChecked(entry.imageEnabled == true) end
        if detailImagePathEdit     then detailImagePathEdit:SetText(entry.imagePath or "") end
        if detailImageDurationDisplay then
            detailImageDurationDisplay:SetText(string.format("%.1f", entry.imageDuration or 3.0))
        end
        if detailImageScaleDisplay then
            detailImageScaleDisplay:SetText(string.format("%.2f", entry.imageScale or 1.0))
        end
        -- 페이드 효과
        if detailImageFadeCheck      then detailImageFadeCheck:SetChecked(entry.imageFadeEnabled == true) end
        if detailImageFadeInDisplay  then detailImageFadeInDisplay:SetText(string.format("%.2f", entry.imageFadeIn  or 0.3)) end
        if detailImageFadeOutDisplay then detailImageFadeOutDisplay:SetText(string.format("%.2f", entry.imageFadeOut or 0.4)) end
        -- 줌인 효과

        -- 초시계 필드 복원
        if detailTimerEnabledCheck   then detailTimerEnabledCheck:SetChecked(entry.timerEnabled == true) end
        if detailTimerDurationEdit   then detailTimerDurationEdit:SetText(tostring(entry.timerDuration or 0)) end
        if detailTimerCustomNameEdit then detailTimerCustomNameEdit:SetText(entry.timerCustomName or "") end
        if detailTimerFontSizeDisplay then detailTimerFontSizeDisplay:SetText(tostring(entry.timerFontSize or 22)) end
        detailTimerColorR = entry.timerColorR or 1
        detailTimerColorG = entry.timerColorG or 1
        detailTimerColorB = entry.timerColorB or 1
        if detailTimerBgCheck        then detailTimerBgCheck:SetChecked(entry.timerBgEnabled == true) end
        if detailTimerBgAlphaDisplay then detailTimerBgAlphaDisplay:SetText(string.format("%.1f", entry.timerBgAlpha or 0.5)) end
    else
        if detailPathEdit     then detailPathEdit:SetText("") end
        if detailFIDEdit      then detailFIDEdit:SetText("") end
        -- 소스 모드 기본값: 경로
        detailSoundSourceMode = "path"
        if detailSourceRadioPath then detailSourceRadioPath:SetChecked(true)  end
        if detailSourceRadioFID  then detailSourceRadioFID:SetChecked(false)  end
        if detailPathSection then detailPathSection:SetShown(true)  end
        if detailFIDSection  then detailFIDSection:SetShown(false)  end
        if detailEnabledCheck then detailEnabledCheck:SetChecked(true) end
        SetChannel("Master")
        SetTriggerType("instant")
        if detailImageEnabledCheck    then detailImageEnabledCheck:SetChecked(false) end
        if detailImagePathEdit        then detailImagePathEdit:SetText("") end
        if detailImageDurationDisplay then detailImageDurationDisplay:SetText("3.0") end
        if detailImageScaleDisplay    then detailImageScaleDisplay:SetText("1.00") end
        if detailImageFadeCheck       then detailImageFadeCheck:SetChecked(false) end
        if detailImageFadeInDisplay   then detailImageFadeInDisplay:SetText("0.30") end
        if detailImageFadeOutDisplay  then detailImageFadeOutDisplay:SetText("0.40") end

        if detailTimerEnabledCheck    then detailTimerEnabledCheck:SetChecked(false) end
        if detailTimerDurationEdit    then detailTimerDurationEdit:SetText("0") end
        if detailTimerCustomNameEdit  then detailTimerCustomNameEdit:SetText("") end
        if detailTimerFontSizeDisplay then detailTimerFontSizeDisplay:SetText("22") end
        detailTimerColorR = 1; detailTimerColorG = 1; detailTimerColorB = 1
        if detailTimerBgCheck         then detailTimerBgCheck:SetChecked(false) end
        if detailTimerBgAlphaDisplay  then detailTimerBgAlphaDisplay:SetText("0.5") end
    end

    if detailSaveBtn   then detailSaveBtn:Enable()   end
    if detailTestBtn   then detailTestBtn:Enable()   end -- ★ 선택 시 활성화
    -- classID=16(사망)은 항목 삭제 불가
    if detailDeleteBtn then
        if classID == 16 then detailDeleteBtn:Disable()
        else detailDeleteBtn:Enable() end
    end
    -- classID=16(사망)은 triggerType이 의미없으므로 드롭다운 숨김
    if detailTriggerDropdown then
        detailTriggerDropdown:SetShown(classID ~= 16)
    end
    if detailSMPickerBtn then detailSMPickerBtn:SetText("선택...") end
    -- smClearBtn: 경로가 있을 때만 활성화
    if smClearBtn then
        local path = (entry and entry.soundPath) or ""
        if path ~= "" then smClearBtn:Enable() else smClearBtn:Disable() end
    end
    SetStatus("")
end

-------------------------------------------------------
-- 직업 행 클릭
-------------------------------------------------------
local function OnClassRowClick(self)
    selectedClassID = self.classID
    selectedSpellID = nil
    for _, r in ipairs(classRows) do
        if r.classID == self.classID then
            r.bg:SetColorTexture(MD3C("primaryDim"))
        else
            r.bg:SetColorTexture(0,0,0,0)
        end
    end
    RefreshSkillColumn(self.classID)
    -- classID=16(사망): 자동으로 __death__ 항목 선택
    if self.classID == 16 then
        selectedSpellID = "__death__"
        LoadDetailPanel(16, "__death__")
        -- 스킬 행 선택 표시 갱신
        for _, r in ipairs(skillRows) do
            if r:IsShown() then
                if r.spellID == "__death__" then r.selBg:Show() else r.selBg:Hide() end
            end
        end
    else
        LoadDetailPanel(self.classID, nil)
    end
end

-------------------------------------------------------
-- UI 공통 헬퍼 (모듈 레벨 — upvalue 절약)
-------------------------------------------------------
local function UI_MakeBox(parent, px, py, pw, ph)
    local f = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    f:SetPoint("TOPLEFT", parent, "TOPLEFT", px, py)
    f:SetSize(pw, ph)
    f:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        tile = false, edgeSize = 1,
    })
    -- MD3: Surface Container 배경 + outline 테두리
    f:SetBackdropColor(MD3C("surfaceContainer"))
    f:SetBackdropBorderColor(MD3C("outlineVariant"))
    return f
end

local function UI_MakeDivider(parent, yOff)
    local d = parent:CreateTexture(nil, "ARTWORK")
    -- MD3: outlineVariant 색상으로 더 섬세한 구분선
    d:SetColorTexture(MD3C("outlineVariant"))
    d:SetPoint("TOPLEFT",  parent, "TOPLEFT",  4, yOff)
    d:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -4, yOff)
    d:SetHeight(1)
    return d
end

local function UI_MakeDropdown(parent, x, y, w, h, items, getVal, setVal, displayFunc)
    local btn = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    btn:SetSize(w, h)
    btn:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
    local bTxt = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    bTxt:SetPoint("LEFT",  btn, "LEFT",  8, 0)
    bTxt:SetPoint("RIGHT", btn, "RIGHT", -18, 0)
    bTxt:SetJustifyH("LEFT")
    bTxt:SetText(displayFunc and displayFunc(getVal()) or getVal())
    btn.label = bTxt
    local arrow = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    arrow:SetPoint("RIGHT", btn, "RIGHT", -6, 0)
    arrow:SetText("v")
    local popup = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    popup:SetSize(w, #items * h)
    popup:SetFrameStrata("TOOLTIP"); popup:SetFrameLevel(200)
    popup:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        tile = false, edgeSize = 1,
    })
    popup:SetBackdropColor(MD3C("surfaceContainer"))
    popup:SetBackdropBorderColor(MD3C("outline"))
    popup:Hide()
    local itemBtns = {}
    for i, item in ipairs(items) do
        local ib = CreateFrame("Button", nil, popup)
        ib:SetSize(w, h)
        ib:SetPoint("TOPLEFT", popup, "TOPLEFT", 0, -(i-1)*h)
        local ibBg = ib:CreateTexture(nil, "BACKGROUND")
        ibBg:SetAllPoints(); ibBg:SetColorTexture(1,1,1,0)
        ib.bg = ibBg
        local ibTxt = ib:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        ibTxt:SetPoint("LEFT", ib, "LEFT", 8, 0); ibTxt:SetJustifyH("LEFT")
        ibTxt:SetText(item.label or item.value)
        ib:SetScript("OnEnter", function() ibBg:SetColorTexture(MD3C("surfaceHigh")) end)
        ib:SetScript("OnLeave", function()
            if getVal() == item.value then ibBg:SetColorTexture(MD3C("primaryDim"))
            else ibBg:SetColorTexture(0,0,0,0) end
        end)
        ib:SetScript("OnClick", function()
            setVal(item.value)
            bTxt:SetText(displayFunc and displayFunc(item.value) or item.value)
            for j = 1, #items do
                if getVal() == items[j].value then itemBtns[j].bg:SetColorTexture(MD3C("primaryDim"))
                else itemBtns[j].bg:SetColorTexture(0,0,0,0) end
            end
            popup:Hide()
        end)
        itemBtns[i] = ib
    end
    btn:SetScript("OnClick", function()
        if popup:IsShown() then popup:Hide()
        else
            for i = 1, #items do
                if getVal() == items[i].value then itemBtns[i].bg:SetColorTexture(MD3C("primaryDim"))
                else itemBtns[i].bg:SetColorTexture(0,0,0,0) end
            end
            popup:ClearAllPoints()
            popup:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2)
            popup:Show()
        end
    end)
    btn.Refresh = function()
        bTxt:SetText(displayFunc and displayFunc(getVal()) or getVal())
    end
    return btn
end

-- SaveAllFields / AutoSave (모듈 레벨 — BuildTab들이 참조 가능)
local AutoSave  -- forward declare
local function SaveAllFields(silent)
    if not selectedClassID or not selectedSpellID then return end
    local db = MoonlightSkillSoundDB[selectedClassID]
    if not db[selectedSpellID] then db[selectedSpellID] = {} end
    local e = db[selectedSpellID]
    if detailPathEdit     then e.soundPath    = NormalizePath(detailPathEdit:GetText() or "") end
    -- ★ 소스 모드에 따라 soundFileDataID / soundPath 상호배타 저장
    if detailSoundSourceMode == "fid" then
        local fidVal = detailFIDEdit and tonumber(detailFIDEdit:GetText())
        e.soundFileDataID = fidVal and fidVal > 0 and fidVal or nil
        e.soundPath = ""  -- FID 모드에서는 경로 비움
    else
        e.soundFileDataID = nil  -- 경로 모드에서는 FID 비움
    end
    e.soundChannel = selectedChannel
    if detailEnabledCheck then e.enabled      = detailEnabledCheck:GetChecked() end
    e.triggerType  = selectedTriggerType
    if detailImageEnabledCheck  then e.imageEnabled  = detailImageEnabledCheck:GetChecked() end
    if detailImagePathEdit      then e.imagePath     = NormalizePath(detailImagePathEdit:GetText() or "") end
    if detailImageDurationDisplay then e.imageDuration = tonumber(detailImageDurationDisplay:GetText()) or 3.0 end
    if detailImageScaleDisplay  then
        local sc = tonumber(detailImageScaleDisplay:GetText()) or 1.0
        e.imageScale = sc; imgPreviewFrame:SetScale(sc)
    end
    -- 페이드 효과
    if detailImageFadeCheck      then e.imageFadeEnabled = detailImageFadeCheck:GetChecked() end
    if detailImageFadeInDisplay  then e.imageFadeIn      = tonumber(detailImageFadeInDisplay:GetText())  or 0.3 end
    if detailImageFadeOutDisplay then e.imageFadeOut     = tonumber(detailImageFadeOutDisplay:GetText()) or 0.4 end

    -- 이미지 위치 (OnDragStop에서 직접 저장되지만 SaveAllFields에서도 보존)
    -- DB에 이미 있는 값을 그대로 유지 (OnDragStop이 먼저 썼을 경우 덮어쓰지 않음)
    if e.imagePosX == nil then e.imagePosX = 0 end
    if e.imagePosY == nil then e.imagePosY = 0 end
    if detailTimerEnabledCheck    then e.timerEnabled   = detailTimerEnabledCheck:GetChecked() end
    if detailTimerDurationEdit    then e.timerDuration  = tonumber(detailTimerDurationEdit:GetText()) or 0 end
    local spellName = (selectedClassID == 16) and "사망"
                      or GetEntryInfo_Safe(selectedSpellID, selectedClassID)
                      or (e.alias and e.alias ~= "" and e.alias)  -- ★ API 실패 시 alias 폴백
                      or nil  -- nil로 두어 발동 시점에 재시도
    e.timerSpellName = spellName or ""
    -- ★ 사용자가 직접 입력한 초시계 표시 이름 저장 (비어 있으면 nil → 자동 이름 사용)
    if detailTimerCustomNameEdit then
        local customName = detailTimerCustomNameEdit:GetText()
        e.timerCustomName = (customName and customName ~= "") and customName or nil
    end
    if detailTimerFontSizeDisplay then e.timerFontSize  = tonumber(detailTimerFontSizeDisplay:GetText()) or 22 end
    e.timerColorR = detailTimerColorR or 1
    e.timerColorG = detailTimerColorG or 1
    e.timerColorB = detailTimerColorB or 1
    if detailTimerBgCheck        then e.timerBgEnabled = detailTimerBgCheck:GetChecked() end
    if detailTimerBgAlphaDisplay then e.timerBgAlpha   = tonumber(detailTimerBgAlphaDisplay:GetText()) or 0.5 end
    RebuildRuntimeMap()
    RefreshSkillColumn(selectedClassID)
    if not silent then SetStatus("저장되었습니다!", 0.3,1,0.3) end
end
AutoSave = function() SaveAllFields(true) end


-- ════════════════════════════════════════════════
local function BuildTab1(tabPanels)
local P  = tabPanels[1]

-- ────────────────────────────────────────────────
-- ★ 소스 선택 라디오 버튼 (경로 / 내장 음원 ID)
-- ────────────────────────────────────────────────
local sourceLbl = P:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
sourceLbl:SetPoint("TOPLEFT", P, "TOPLEFT", PL, -6)
sourceLbl:SetText("|cffaaaaaa사운드 소스|r")

-- 라디오: 파일 경로
detailSourceRadioPath = CreateFrame("CheckButton", "MSSSourceRadioPath", P, "UIRadioButtonTemplate")
detailSourceRadioPath:SetSize(20, 20)
detailSourceRadioPath:SetPoint("TOPLEFT", P, "TOPLEFT", PL, -20)
detailSourceRadioPath:SetChecked(true)
local radioPathLbl = P:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
radioPathLbl:SetPoint("LEFT", detailSourceRadioPath, "RIGHT", 2, 0)
radioPathLbl:SetText("파일 경로")

-- 라디오: 내장 음원 ID
detailSourceRadioFID = CreateFrame("CheckButton", "MSSSourceRadioFID", P, "UIRadioButtonTemplate")
detailSourceRadioFID:SetSize(20, 20)
detailSourceRadioFID:SetPoint("LEFT", radioPathLbl, "RIGHT", 16, 0)
detailSourceRadioFID:SetChecked(false)
local radioFIDLbl = P:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
radioFIDLbl:SetPoint("LEFT", detailSourceRadioFID, "RIGHT", 2, 0)
radioFIDLbl:SetTextColor(0.4, 0.9, 0.6)
radioFIDLbl:SetText("내장 음원 ID")

-- 라디오 전환 헬퍼
local function SetSoundSourceMode(mode)
    detailSoundSourceMode = mode
    detailSourceRadioPath:SetChecked(mode == "path")
    detailSourceRadioFID:SetChecked(mode == "fid")
    if detailPathSection then detailPathSection:SetShown(mode == "path") end
    if detailFIDSection  then detailFIDSection:SetShown(mode == "fid")  end
end

detailSourceRadioPath:SetScript("OnClick", function() SetSoundSourceMode("path"); AutoSave() end)
detailSourceRadioFID:SetScript("OnClick",  function() SetSoundSourceMode("fid");  AutoSave() end)

-- ────────────────────────────────────────────────
-- 경로 섹션 컨테이너 (기존 UI를 감싸는 투명 프레임)
-- ────────────────────────────────────────────────
detailPathSection = CreateFrame("Frame", nil, P)
detailPathSection:SetPoint("TOPLEFT",  P, "TOPLEFT",  0, -44)
detailPathSection:SetPoint("TOPRIGHT", P, "TOPRIGHT", 0, -44)
detailPathSection:SetHeight(98)
detailPathSection:Show()

local pathLbl = detailPathSection:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
pathLbl:SetPoint("TOPLEFT", detailPathSection, "TOPLEFT", PL, -2)
pathLbl:SetText("|cffaaaaaa사운드 파일 경로 (직접 입력)|r")

detailPathEdit = CreateFrame("EditBox", nil, detailPathSection, "InputBoxTemplate")
detailPathEdit:SetSize(PW - PL*2, 20)
detailPathEdit:SetPoint("TOPLEFT", detailPathSection, "TOPLEFT", PL, -18)
detailPathEdit:SetAutoFocus(false); detailPathEdit:SetMaxLetters(512)

detailSMLabel = detailPathSection:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
detailSMLabel:SetPoint("TOPLEFT", detailPathSection, "TOPLEFT", PL, -46)
detailSMLabel:SetTextColor(0.4,0.8,1)
detailSMLabel:SetText("또는 SharedMedia에서 선택:")

detailSMHint = detailPathSection:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
detailSMHint:SetPoint("TOPLEFT", detailPathSection, "TOPLEFT", PL, -46)
detailSMHint:SetTextColor(0.4,0.4,0.4)
detailSMHint:SetText("SharedMedia가 설치되어 있지 않습니다.")
detailSMHint:Hide()

detailSMPickerBtn = CreateFrame("Button", nil, detailPathSection, "UIPanelButtonTemplate")
detailSMPickerBtn:SetSize(math.floor((PW - PL*2) * 0.70), 22)
detailSMPickerBtn:SetPoint("TOPLEFT", detailPathSection, "TOPLEFT", PL, -60)
detailSMPickerBtn:SetText("선택…")

-- SharedMedia 선택 취소 버튼
local smClearBtn = CreateFrame("Button", nil, detailPathSection, "UIPanelButtonTemplate")
smClearBtn:SetSize(math.floor((PW - PL*2) * 0.30) - 4, 22)
smClearBtn:SetPoint("LEFT", detailSMPickerBtn, "RIGHT", 4, 0)
smClearBtn:SetText("선택 취소")
smClearBtn:GetFontString():SetTextColor(1, 0.4, 0.4)
smClearBtn:Disable()
smClearBtn:SetScript("OnClick", function()
    -- DefaultPreset에 등록된 기본 경로로 복구, 없으면 빈 값
    local defaultPath = ""
    if selectedClassID and selectedSpellID and MoonlightSkillSoundDefaults then
        local cls = MoonlightSkillSoundDefaults[selectedClassID]
        local spell = cls and cls[selectedSpellID]
        if spell and spell.soundPath then
            defaultPath = spell.soundPath
        end
    end
    detailPathEdit:SetText(defaultPath)
    detailSMPickerBtn:SetText("선택…")
    smClearBtn:Disable()
    AutoSave()
end)

-- ────────────────────────────────────────────────
-- ★ FileDataID 섹션 컨테이너
-- ────────────────────────────────────────────────
detailFIDSection = CreateFrame("Frame", nil, P)
detailFIDSection:SetPoint("TOPLEFT",  P, "TOPLEFT",  0, -44)
detailFIDSection:SetPoint("TOPRIGHT", P, "TOPRIGHT", 0, -44)
detailFIDSection:SetHeight(98)
detailFIDSection:Hide()  -- 기본 숨김

local fidLbl = detailFIDSection:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
fidLbl:SetPoint("TOPLEFT", detailFIDSection, "TOPLEFT", PL, -2)
fidLbl:SetTextColor(0.4, 0.9, 0.6)
fidLbl:SetText("|cff66E699내장 음원 FileDataID (숫자 입력)|r")

detailFIDEdit = CreateFrame("EditBox", nil, detailFIDSection, "InputBoxTemplate")
detailFIDEdit:SetSize(PW - PL*2 - 80, 20)
detailFIDEdit:SetPoint("TOPLEFT", detailFIDSection, "TOPLEFT", PL, -18)
detailFIDEdit:SetAutoFocus(false); detailFIDEdit:SetMaxLetters(12)
detailFIDEdit:SetNumeric(false)  -- 숫자만 허용하지만 붙여넣기 편의상 false
detailFIDEdit:SetScript("OnEnterPressed", function(self)
    self:ClearFocus(); AutoSave()
end)
detailFIDEdit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
detailFIDEdit:SetScript("OnEditFocusLost", function()
    -- 포커스를 잃을 때(클릭 이동, 탭 등) 완성된 숫자로 저장
    AutoSave()
end)

-- ▶ FID 즉시 테스트 버튼
local fidTestBtn = CreateFrame("Button", nil, detailFIDSection, "UIPanelButtonTemplate")
fidTestBtn:SetSize(74, 22)
fidTestBtn:SetPoint("LEFT", detailFIDEdit, "RIGHT", 4, 0)
fidTestBtn:SetText("▶ 미리듣기")
fidTestBtn:SetScript("OnClick", function()
    local fid = tonumber(detailFIDEdit:GetText())
    if fid and fid > 0 then
        PlaySoundFile(fid, NormalizeChannel(selectedChannel))
    else
        SetStatus("올바른 FileDataID를 입력하세요.", 1, 0.4, 0.4)
    end
end)

-- 힌트 텍스트
local fidHint = detailFIDSection:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
fidHint:SetPoint("TOPLEFT", detailFIDSection, "TOPLEFT", PL, -44)
fidHint:SetTextColor(0.45, 0.45, 0.45)
fidHint:SetText("예) 569593   •   wago.tools/sounds 에서 검색")

local fidHint2 = detailFIDSection:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
fidHint2:SetPoint("TOPLEFT", detailFIDSection, "TOPLEFT", PL, -58)
fidHint2:SetTextColor(0.45, 0.45, 0.45)
fidHint2:SetText("클라이언트에 없는 ID는 재생되지 않을 수 있습니다.")

-- ────────────────────────────────────────────────
-- SharedMedia 팝업 (경로 섹션 전용 — 기존 로직 유지)
-- ────────────────────────────────────────────────
local function BuildSMPanel()
    if smPickerPanel then return end
    smPickerPanel = CreateFrame("Frame", "MSSPickerPanel", UIParent, "BackdropTemplate")
    smPickerPanel:SetSize(300, 360)
    smPickerPanel:SetFrameStrata("TOOLTIP"); smPickerPanel:SetFrameLevel(100)
    smPickerPanel:SetBackdrop({
        bgFile   = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        tile = false, edgeSize = 1,
    })
    smPickerPanel:SetBackdropBorderColor(0.4,0.8,1,0.9)
    smPickerPanel:Hide()
    table.insert(UISpecialFrames, "MSSPickerPanel")

    local searchBox = CreateFrame("EditBox", nil, smPickerPanel, "InputBoxTemplate")
    searchBox:SetSize(260, 22)
    searchBox:SetPoint("TOPLEFT", smPickerPanel, "TOPLEFT", 10, -10)
    searchBox:SetAutoFocus(false); searchBox:SetMaxLetters(64)

    local searchHint = smPickerPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    searchHint:SetPoint("LEFT", searchBox, "LEFT", 4, 0)
    searchHint:SetTextColor(0.5,0.5,0.5); searchHint:SetText("검색…")
    searchBox:SetScript("OnTextChanged", function(self)
        searchHint:SetShown(self:GetText() == "")
        smPickerPanel.filterText = self:GetText():lower()
        smPickerPanel.Populate()
    end)

    local scroll = CreateFrame("ScrollFrame", nil, smPickerPanel, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT",     smPickerPanel, "TOPLEFT",      4, -38)
    scroll:SetPoint("BOTTOMRIGHT", smPickerPanel, "BOTTOMRIGHT", -24,  4)

    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(272, 1); scroll:SetScrollChild(content)

    smPickerPanel:EnableMouseWheel(true)
    smPickerPanel:SetScript("OnMouseWheel", function(_, delta)
        local cur = scroll:GetVerticalScroll()
        local maxScroll = math.max(0, content:GetHeight() - scroll:GetHeight())
        scroll:SetVerticalScroll(math.max(0, math.min(maxScroll, cur - delta*20)))
    end)

    local smRows = {}
    local ROW_H  = 22

    smPickerPanel.Populate = function()
        local LSM    = LibStub and LibStub("LibSharedMedia-3.0", true)
        local sounds = LSM and LSM:List("sound") or {}
        local filter = smPickerPanel.filterText or ""
        local filtered = {}
        for _, name in ipairs(sounds) do
            if filter == "" or name:lower():find(filter, 1, true) then
                filtered[#filtered+1] = name
            end
        end
        for _, r in ipairs(smRows) do r:Hide() end
        for i, name in ipairs(filtered) do
            local row = smRows[i]
            if not row then
                row = CreateFrame("Button", nil, content)
                row:SetSize(272, ROW_H)
                row.bg = row:CreateTexture(nil, "BACKGROUND")
                row.bg:SetAllPoints(); row.bg:SetColorTexture(1,1,1,0.04)
                row.lbl = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                row.lbl:SetPoint("LEFT", 6, 0); row.lbl:SetWidth(220); row.lbl:SetJustifyH("LEFT")
                row.playBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
                row.playBtn:SetSize(36, 18); row.playBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0)
                row.playBtn:SetText("▶")
                row:SetScript("OnEnter", function(s) s.bg:SetColorTexture(1,0.82,0,0.12) end)
                row:SetScript("OnLeave", function(s) s.bg:SetColorTexture(1,1,1,0.04) end)
                smRows[i] = row
            end
            local capName = name
            row.lbl:SetText(capName)
            row:SetScript("OnClick", function()
                local LSM2 = LibStub and LibStub("LibSharedMedia-3.0", true)
                local path = LSM2 and LSM2:Fetch("sound", capName)
                if path then
                    detailPathEdit:SetText(path)
                    detailSMPickerBtn:SetText(capName)
                    smClearBtn:Enable()
                end
                smPickerPanel:Hide()
            end)
            row.playBtn:SetScript("OnClick", function()
                local LSM2 = LibStub and LibStub("LibSharedMedia-3.0", true)
                local path = LSM2 and LSM2:Fetch("sound", capName)
                if path then PlaySoundFile(path, "Master") end
            end)
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -(i-1)*ROW_H)
            row:Show()
        end
        content:SetHeight(math.max(1, #filtered*ROW_H))
        scroll:SetVerticalScroll(0)
    end
    smPickerPanel.searchBox = searchBox
end

detailSMPickerBtn:SetScript("OnClick", function(self)
    BuildSMPanel()
    if smPickerPanel:IsShown() then smPickerPanel:Hide()
    else
        smPickerPanel.filterText = ""
        smPickerPanel.searchBox:SetText("")
        smPickerPanel.Populate()
        smPickerPanel:ClearAllPoints()
        smPickerPanel:SetPoint("BOTTOMLEFT", self, "TOPLEFT", 0, 4)
        smPickerPanel:Show()
    end
end)

local function UpdateSMVisibility()
    local LSM = LibStub and LibStub("LibSharedMedia-3.0", true)
    if LSM then detailSMLabel:Show(); detailSMPickerBtn:Show(); detailSMHint:Hide()
    else detailSMLabel:Hide(); detailSMPickerBtn:Hide(); detailSMHint:Show() end
end
UpdateSMVisibility()

UI_MakeDivider(P, -148)

-- 채널 / 트리거 (2열 커스텀 드롭다운)
local HW = math.floor((PW - PL*2 - 8) / 2)

local chanLbl = P:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
chanLbl:SetPoint("TOPLEFT", P, "TOPLEFT", PL, -112)
chanLbl:SetText("|cffaaaaaa채널|r")

local trigLbl = P:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
trigLbl:SetPoint("TOPLEFT", P, "TOPLEFT", PL+HW+8, -112)
trigLbl:SetText("|cffaaaaaa시전 타입|r")

local chanItems = {}
for _, ch in ipairs(CHANNELS) do chanItems[#chanItems+1] = { label=ch, value=ch } end

local trigItems = {
    { label="즉시 (Instant)", value="instant" },
    { label="캐스팅 (Cast)",  value="cast"    },
    { label="채널링 (Chan.)", value="channel" },
}
local function GetTrigLabel(v)
    for _, t in ipairs(trigItems) do if t.value == v then return t.label end end
    return v
end

detailChannelDropdown = UI_MakeDropdown(P, PL, -126, HW, 22,
    chanItems,
    function() return selectedChannel end,
    function(v) SetChannel(v) end)

detailTriggerDropdown = UI_MakeDropdown(P, PL+HW+8, -126, HW, 22,
    trigItems,
    function() return selectedTriggerType end,
    function(v) SetTriggerType(v) end,
    GetTrigLabel)

-- SetChannel / SetTriggerType 오버라이드 (커스텀 드롭다운 갱신)
SetChannel = function(ch)
    selectedChannel = NormalizeChannel(ch)
    if detailChannelDropdown and detailChannelDropdown.Refresh then
        detailChannelDropdown.Refresh()
    end
end
SetTriggerType = function(tt)
    selectedTriggerType = tt or "instant"
    if detailTriggerDropdown and detailTriggerDropdown.Refresh then
        detailTriggerDropdown.Refresh()
    end
end

UI_MakeDivider(P, -156)

-- 활성화 체크
detailEnabledCheck = CreateFrame("CheckButton", "MSSEnabledCheck", P, "UICheckButtonTemplate")
detailEnabledCheck:SetSize(22, 22)
detailEnabledCheck:SetPoint("TOPLEFT", P, "TOPLEFT", PL-2, -166)
detailEnabledCheck:SetChecked(true)

local enableLbl = P:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
enableLbl:SetPoint("LEFT", detailEnabledCheck, "RIGHT", 2, 0)
enableLbl:SetText("이 스킬 사운드 활성화")

UI_MakeDivider(P, -196)

-- 저장 / 테스트 / 삭제
local BW = math.floor((PW - PL*2 - 8) / 3)

detailSaveBtn = CreateFrame("Button", nil, P, "UIPanelButtonTemplate")
detailSaveBtn:SetSize(BW, 26)
detailSaveBtn:SetPoint("TOPLEFT", P, "TOPLEFT", PL, -204)
detailSaveBtn:SetText("저장")
detailSaveBtn:GetFontString():SetTextColor(0.3, 1, 0.3)
detailSaveBtn:Disable()

detailTestBtn = CreateFrame("Button", nil, P, "UIPanelButtonTemplate")
detailTestBtn:SetSize(BW, 26)
detailTestBtn:SetPoint("LEFT", detailSaveBtn, "RIGHT", 4, 0)
detailTestBtn:SetText("▶ 테스트"); detailTestBtn:Disable()

detailDeleteBtn = CreateFrame("Button", nil, P, "UIPanelButtonTemplate")
detailDeleteBtn:SetSize(BW, 26)
detailDeleteBtn:SetPoint("LEFT", detailTestBtn, "RIGHT", 4, 0)
detailDeleteBtn:SetText("삭제"); detailDeleteBtn:Disable()

-- (detailStatusFS는 BuildUI의 box3에 배치됨)

-- ════════════════════════════════════════════════
--  탭2: 이미지 플래시
-- ════════════════════════════════════════════════
end

-- ════════════════════════════════════════════════
local function BuildTab2(tabPanels)
local P2 = tabPanels[2]

detailImageEnabledCheck = CreateFrame("CheckButton", nil, P2, "UICheckButtonTemplate")
detailImageEnabledCheck:SetSize(22, 22)
detailImageEnabledCheck:SetPoint("TOPLEFT", P2, "TOPLEFT", PL-2, -6)
detailImageEnabledCheck:SetChecked(false)
local imgEnableLbl = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
imgEnableLbl:SetPoint("LEFT", detailImageEnabledCheck, "RIGHT", 2, 0)
imgEnableLbl:SetText("이미지 표시 활성화")

UI_MakeDivider(P2, -36)

local imgPathLbl = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
imgPathLbl:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -44)
imgPathLbl:SetText("|cffaaaaaa이미지 경로  (.tga / .png)|r")

detailImagePathEdit = CreateFrame("EditBox", nil, P2, "InputBoxTemplate")
detailImagePathEdit:SetSize(PW - PL*2, 20)
detailImagePathEdit:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -60)
detailImagePathEdit:SetAutoFocus(false); detailImagePathEdit:SetMaxLetters(512)

UI_MakeDivider(P2, -90)

local imgHW = math.floor((PW - PL*2 - 8) / 2)

local imgDurLbl = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
imgDurLbl:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -98)
imgDurLbl:SetText("|cffaaaaaa표시 시간 (초)|r")

local imgScaleLbl = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
imgScaleLbl:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+imgHW+8, -98)
imgScaleLbl:SetText("|cffaaaaaa배율 (직접 입력 가능)|r")

detailImageDurationMinus = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
detailImageDurationMinus:SetSize(26, 22)
detailImageDurationMinus:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -118)
detailImageDurationMinus:SetText("-")
detailImageDurationMinus:SetScript("OnClick", function()
    local cur = tonumber(detailImageDurationDisplay:GetText()) or 3.0
    detailImageDurationDisplay:SetText(string.format("%.1f", math.max(0.5, cur-0.5)))
end)

detailImageDurationDisplay = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
detailImageDurationDisplay:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+28, -118)
detailImageDurationDisplay:SetWidth(imgHW - 56)
detailImageDurationDisplay:SetJustifyH("CENTER")
detailImageDurationDisplay:SetText("3.0")

detailImageDurationPlus = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
detailImageDurationPlus:SetSize(26, 22)
detailImageDurationPlus:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+imgHW-26, -118)
detailImageDurationPlus:SetText("+")
detailImageDurationPlus:SetScript("OnClick", function()
    local cur = tonumber(detailImageDurationDisplay:GetText()) or 3.0
    detailImageDurationDisplay:SetText(string.format("%.1f", math.min(30.0, cur+0.5)))
end)

local imgScaleMinus = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
imgScaleMinus:SetSize(26, 22)
imgScaleMinus:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+imgHW+8, -118)
imgScaleMinus:SetText("-")
imgScaleMinus:SetScript("OnClick", function()
    local v = math.max(0.1, math.floor(((tonumber(detailImageScaleDisplay:GetText()) or 1.0)-0.1)*100+0.5)/100)
    detailImageScaleDisplay:SetText(string.format("%.2f", v)); imgPreviewFrame:SetScale(v)
end)

detailImageScaleDisplay = CreateFrame("EditBox", nil, P2, "InputBoxTemplate")
detailImageScaleDisplay:SetSize(imgHW-56, 22)
detailImageScaleDisplay:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+imgHW+8+28, -118)
detailImageScaleDisplay:SetAutoFocus(false); detailImageScaleDisplay:SetMaxLetters(6)
detailImageScaleDisplay:SetText("1.00"); detailImageScaleDisplay:SetJustifyH("CENTER")
detailImageScaleDisplay:SetScript("OnEnterPressed", function(self)
    local v = tonumber(self:GetText())
    if v then v=math.max(0.1,math.min(5.0,v)); self:SetText(string.format("%.2f",v)); imgPreviewFrame:SetScale(v)
    else self:SetText("1.00"); imgPreviewFrame:SetScale(1.0) end
    self:ClearFocus()
end)
detailImageScaleDisplay:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

local imgScalePlus = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
imgScalePlus:SetSize(26, 22)
imgScalePlus:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+imgHW+8+imgHW-26, -118)
imgScalePlus:SetText("+")
imgScalePlus:SetScript("OnClick", function()
    local v = math.min(5.0, math.floor(((tonumber(detailImageScaleDisplay:GetText()) or 1.0)+0.1)*100+0.5)/100)
    detailImageScaleDisplay:SetText(string.format("%.2f", v)); imgPreviewFrame:SetScale(v)
end)

UI_MakeDivider(P2, -194)

-- 미리보기 + 위치이동 통합 버튼 (3단계)
-- 이미지 미리보기 + 위치이동 통합 버튼 (2단계)
-- 1클릭: 이미지를 화면에 표시하고 드래그로 위치 조정 가능
-- 2클릭: 위치 저장 후 이미지 숨김, 기본 상태로 복귀
local imgPreviewing = false

detailImageMoveBtn = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
detailImageMoveBtn:SetSize(PW - PL*2, 26)
detailImageMoveBtn:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -160)
detailImageMoveBtn:SetText("▶  이미지 미리보기 및 위치이동")
detailImageMoveBtn:GetFontString():SetTextColor(0.3,1,0.7)

local function ImgBtn_SetNormal()
    imgPreviewing = false
    imgMoveMode   = false
    detailImageMoveBtn:SetText("▶  이미지 미리보기 및 위치이동")
    detailImageMoveBtn:GetFontString():SetTextColor(0.3,1,0.7)
end

detailImageMoveBtn:SetScript("OnClick", function()
    if not imgPreviewing then
        -- 미리보기 시작 + 즉시 드래그 가능
        local path = detailImagePathEdit:GetText() or ""
        if path == "" then SetStatus("이미지 경로를 먼저 입력하세요.", 1,0.4,0.4); return end
        imgPreviewTexture:SetTexture(path)
        local sc = tonumber(detailImageScaleDisplay:GetText()) or 1.0
        imgPreviewFrame:SetSize(256,256); imgPreviewFrame:SetScale(sc)
        imgPreviewTexture:SetAlpha(1)

        -- 미리보기 시작 시 DB에 저장된 위치로 프레임을 먼저 배치
        -- 이 설정 없이 Show()하면 프레임이 이전 위치(또는 CENTER)에서 나타나고
        -- OnDragStop에서 ClearAllPoints 후 SetPoint 할 때 위치가 튀게 됨
        imgPreviewFrame:ClearAllPoints()
        if selectedClassID and selectedSpellID then
            local db = MoonlightSkillSoundDB[selectedClassID]
            local entry = db and db[selectedSpellID]
            if entry and entry.imagePosAnchor == "BOTTOMLEFT" then
                imgPreviewFrame:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT",
                    entry.imagePosX or 0, entry.imagePosY or 0)
            else
                imgPreviewFrame:SetPoint("CENTER", UIParent, "CENTER",
                    entry and entry.imagePosX or 0, entry and entry.imagePosY or 0)
            end
        else
            imgPreviewFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
        end

        imgPreviewFrame:Show()
        imgMoveMode = true
        imgPreviewFrame:SetMovable(true); imgPreviewFrame:EnableMouse(true)
        imgPreviewFrame:RegisterForDrag("LeftButton")
        imgPreviewFrame:SetScript("OnDragStart", imgPreviewFrame.StartMoving)
        imgPreviewFrame:SetScript("OnDragStop", function(self)
            self:StopMovingOrSizing()
            -- WoW에서 GetLeft()/GetBottom()은 UIParent 좌표계 기준값을 반환하므로
            -- 별도 scale 보정 없이 그대로 SetPoint offset으로 사용하면 됨
            local x = self:GetLeft()
            local y = self:GetBottom()
            self:ClearAllPoints()
            self:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", x, y)
            if selectedClassID and selectedSpellID then
                local db = MoonlightSkillSoundDB[selectedClassID]
                if db and db[selectedSpellID] then
                    db[selectedSpellID].imagePosX      = x
                    db[selectedSpellID].imagePosY      = y
                    db[selectedSpellID].imagePosAnchor = "BOTTOMLEFT"
                end
            end
            RebuildRuntimeMap()
        end)
        imgPreviewing = true
        detailImageMoveBtn:SetText("|cffff9999■  드래그로 위치 조정 중… (클릭해서 완료)|r")
        detailImageMoveBtn:GetFontString():SetTextColor(1,1,1)
    else
        -- 완료: 위치 저장 후 이미지 숨김
        imgMoveMode = false
        imgPreviewFrame:SetMovable(false); imgPreviewFrame:EnableMouse(false)
        imgPreviewFrame:SetScript("OnDragStart", nil); imgPreviewFrame:SetScript("OnDragStop", nil)
        imgPreviewFrame:Hide(); imgPreviewTexture:SetAlpha(0)
        RebuildRuntimeMap()
        ImgBtn_SetNormal()
        SetStatus("위치가 저장되었습니다. 저장 버튼을 눌러 적용하세요.", 0.3,1,0.7)
    end
end)

UI_MakeDivider(P2, -200)

-- ── 페이드 효과 ──────────────────────────────────────────────
local imgFadeLbl = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
imgFadeLbl:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -208)
imgFadeLbl:SetText("|cffaaaaaa페이드 효과|r")

detailImageFadeCheck = CreateFrame("CheckButton", nil, P2, "UICheckButtonTemplate")
detailImageFadeCheck:SetSize(22, 22)
detailImageFadeCheck:SetPoint("TOPLEFT", P2, "TOPLEFT", PL-2, -222)
detailImageFadeCheck:SetChecked(false)
local imgFadeEnLbl = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
imgFadeEnLbl:SetPoint("LEFT", detailImageFadeCheck, "RIGHT", 2, 0)
imgFadeEnLbl:SetText("페이드 인 / 아웃 활성화")

-- 페이드인 시간
local fadeSW = math.floor((PW - PL*2 - 8) / 2)
local imgFadeInLbl = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
imgFadeInLbl:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -252)
imgFadeInLbl:SetText("|cffaaaaaa페이드 인 (초)|r")

local imgFadeOutLbl = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
imgFadeOutLbl:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+fadeSW+8, -252)
imgFadeOutLbl:SetText("|cffaaaaaa페이드 아웃 (초)|r")

local imgFadeInMinus = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
imgFadeInMinus:SetSize(26, 22)
imgFadeInMinus:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -268)
imgFadeInMinus:SetText("-")
imgFadeInMinus:SetScript("OnClick", function()
    if not detailImageFadeInDisplay then return end
    local v = math.max(0.05, math.floor(((tonumber(detailImageFadeInDisplay:GetText()) or 0.3) - 0.05)*100+0.5)/100)
    detailImageFadeInDisplay:SetText(string.format("%.2f", v))
end)

detailImageFadeInDisplay = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
detailImageFadeInDisplay:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+28, -268)
detailImageFadeInDisplay:SetWidth(fadeSW - 56)
detailImageFadeInDisplay:SetJustifyH("CENTER")
detailImageFadeInDisplay:SetText("0.30")

local imgFadeInPlus = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
imgFadeInPlus:SetSize(26, 22)
imgFadeInPlus:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+fadeSW-26, -268)
imgFadeInPlus:SetText("+")
imgFadeInPlus:SetScript("OnClick", function()
    if not detailImageFadeInDisplay then return end
    local v = math.min(5.0, math.floor(((tonumber(detailImageFadeInDisplay:GetText()) or 0.3) + 0.05)*100+0.5)/100)
    detailImageFadeInDisplay:SetText(string.format("%.2f", v))
end)

local imgFadeOutMinus = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
imgFadeOutMinus:SetSize(26, 22)
imgFadeOutMinus:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+fadeSW+8, -268)
imgFadeOutMinus:SetText("-")
imgFadeOutMinus:SetScript("OnClick", function()
    if not detailImageFadeOutDisplay then return end
    local v = math.max(0.05, math.floor(((tonumber(detailImageFadeOutDisplay:GetText()) or 0.4) - 0.05)*100+0.5)/100)
    detailImageFadeOutDisplay:SetText(string.format("%.2f", v))
end)

detailImageFadeOutDisplay = P2:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
detailImageFadeOutDisplay:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+fadeSW+8+28, -268)
detailImageFadeOutDisplay:SetWidth(fadeSW - 56)
detailImageFadeOutDisplay:SetJustifyH("CENTER")
detailImageFadeOutDisplay:SetText("0.40")

local imgFadeOutPlus = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
imgFadeOutPlus:SetSize(26, 22)
imgFadeOutPlus:SetPoint("TOPLEFT", P2, "TOPLEFT", PL+fadeSW+8+fadeSW-26, -268)
imgFadeOutPlus:SetText("+")
imgFadeOutPlus:SetScript("OnClick", function()
    if not detailImageFadeOutDisplay then return end
    local v = math.min(5.0, math.floor(((tonumber(detailImageFadeOutDisplay:GetText()) or 0.4) + 0.05)*100+0.5)/100)
    detailImageFadeOutDisplay:SetText(string.format("%.2f", v))
end)

UI_MakeDivider(P2, -300)

local imgResetBtn = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
imgResetBtn:SetSize(PW - PL*2, 22)
imgResetBtn:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -382)
imgResetBtn:SetText("위치·배율 초기화")
imgResetBtn:SetScript("OnClick", function()
    StaticPopupDialogs["MSS_IMG_RESET_CONFIRM"] = {
        text = "이미지 위치와 배율을 기본값으로 초기화합니다.\n계속하시겠습니까?",
        button1 = "초기화",
        button2 = "취소",
        OnAccept = function()
            if selectedClassID and selectedSpellID then
                local db = MoonlightSkillSoundDB[selectedClassID]
                if db and db[selectedSpellID] then
                    db[selectedSpellID].imagePosX      = 0
                    db[selectedSpellID].imagePosY      = 0
                    db[selectedSpellID].imagePosAnchor = nil
                end
            end
            imgPreviewFrame:SetScale(1.0)
            if detailImageScaleDisplay then detailImageScaleDisplay:SetText("1.00") end
            imgPreviewFrame:ClearAllPoints()
            imgPreviewFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
            SetStatus("초기화되었습니다.", 0.3,1,0.3)
        end,
        timeout      = 0,
        whileDead    = true,
        hideOnEscape = true,
    }
    StaticPopup_Show("MSS_IMG_RESET_CONFIRM")
end)

-- 저장 버튼 (이미지 탭)
local imgSaveBtn = CreateFrame("Button", nil, P2, "UIPanelButtonTemplate")
imgSaveBtn:SetSize(PW - PL*2, 26)
imgSaveBtn:SetPoint("TOPLEFT", P2, "TOPLEFT", PL, -414)
imgSaveBtn:SetText("저장")
imgSaveBtn:GetFontString():SetTextColor(0.3, 1, 0.3)
imgSaveBtn:SetScript("OnClick", function() SaveAllFields() end)

-- ════════════════════════════════════════════════
--  탭2: 이미지 플래시
-- ════════════════════════════════════════════════
end

-- ════════════════════════════════════════════════
local function BuildTab3(tabPanels)
local P3 = tabPanels[3]

-- 활성화
detailTimerEnabledCheck = CreateFrame("CheckButton", nil, P3, "UICheckButtonTemplate")
detailTimerEnabledCheck:SetSize(22, 22)
detailTimerEnabledCheck:SetPoint("TOPLEFT", P3, "TOPLEFT", PL-2, -6)
detailTimerEnabledCheck:SetChecked(false)
local timerEnableLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
timerEnableLbl:SetPoint("LEFT", detailTimerEnabledCheck, "RIGHT", 2, 0)
timerEnableLbl:SetText("초시계 활성화")

local function MakeDividerP3(yOff)
    local d = P3:CreateTexture(nil, "ARTWORK")
    d:SetColorTexture(0.3,0.3,0.3,0.7)
    d:SetPoint("TOPLEFT",  P3, "TOPLEFT",  0, yOff)
    d:SetPoint("TOPRIGHT", P3, "TOPRIGHT", 0, yOff)
    d:SetHeight(1)
    return d
end

MakeDividerP3(-36)

-- 지속 시간 입력
local timerDurLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
timerDurLbl:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -44)
timerDurLbl:SetText("|cffaaaaaa스킬 지속 시간 (초) — 직접 입력 후 엔터|r")

detailTimerDurationEdit = CreateFrame("EditBox", nil, P3, "InputBoxTemplate")
detailTimerDurationEdit:SetSize(PW - PL*2, 20)
detailTimerDurationEdit:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -60)
detailTimerDurationEdit:SetAutoFocus(false)
detailTimerDurationEdit:SetNumeric(true)
detailTimerDurationEdit:SetMaxLetters(4)
detailTimerDurationEdit:SetText("0")

-- ★ 초시계 표시 이름 직접 입력
local timerCustomNameLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
timerCustomNameLbl:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -88)
timerCustomNameLbl:SetText("|cffaaaaaa초시계 표시 이름 (비워두면 스킬 원래 이름 사용)|r")

detailTimerCustomNameEdit = CreateFrame("EditBox", nil, P3, "InputBoxTemplate")
detailTimerCustomNameEdit:SetSize(PW - PL*2, 20)
detailTimerCustomNameEdit:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -104)
detailTimerCustomNameEdit:SetAutoFocus(false)
detailTimerCustomNameEdit:SetMaxLetters(40)
detailTimerCustomNameEdit:SetText("")
detailTimerCustomNameEdit:SetScript("OnEnterPressed", function(self) self:ClearFocus(); AutoSave() end)
detailTimerCustomNameEdit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
detailTimerCustomNameEdit:SetScript("OnEditFocusLost", function() AutoSave() end)

MakeDividerP3(-132)

-- 글씨 크기 + 색상 (한 줄에)
local timerFSLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
timerFSLbl:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -140)
timerFSLbl:SetText("|cffaaaaaa글씨 크기|r")

local timerColorLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
timerColorLbl:SetPoint("TOPLEFT", P3, "TOPLEFT", PL + 120, -140)
timerColorLbl:SetText("|cffaaaaaa글씨 색상|r")

local timerFSMinus = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
timerFSMinus:SetSize(26, 22)
timerFSMinus:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -156)
timerFSMinus:SetText("-")

detailTimerFontSizeDisplay = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
detailTimerFontSizeDisplay:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+28, -156)
detailTimerFontSizeDisplay:SetWidth(40)
detailTimerFontSizeDisplay:SetJustifyH("CENTER")
detailTimerFontSizeDisplay:SetText("22")

local timerFSPlus = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
timerFSPlus:SetSize(26, 22)
timerFSPlus:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+72, -156)
timerFSPlus:SetText("+")

timerFSMinus:SetScript("OnClick", function()
    local v = math.max(8, (tonumber(detailTimerFontSizeDisplay:GetText()) or 22) - 1)
    detailTimerFontSizeDisplay:SetText(tostring(v))
    AutoSave()
end)
timerFSPlus:SetScript("OnClick", function()
    local v = math.min(60, (tonumber(detailTimerFontSizeDisplay:GetText()) or 22) + 1)
    detailTimerFontSizeDisplay:SetText(tostring(v))
    AutoSave()
end)

-- 색상 스워치 + 버튼 (글씨 크기 옆)
local colorSwatch = P3:CreateTexture(nil, "OVERLAY")
colorSwatch:SetSize(18, 18)
colorSwatch:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+120, -156)
colorSwatch:SetColorTexture(1,1,1,1)

detailTimerColorBtn = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
detailTimerColorBtn:SetSize(PW - PL*2 - 140, 22)
detailTimerColorBtn:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+144, -156)
detailTimerColorBtn:SetText("색상 선택")

local function RefreshColorSwatch()
    colorSwatch:SetColorTexture(detailTimerColorR or 1, detailTimerColorG or 1, detailTimerColorB or 1, 1)
end
RefreshColorSwatch()

-- 색상 버튼 스크립트는 아래 AutoSave 블록에서 설정됨

MakeDividerP3(-188)

-- 배경 표시 + 불투명도 (한 줄에)
detailTimerBgCheck = CreateFrame("CheckButton", nil, P3, "UICheckButtonTemplate")
detailTimerBgCheck:SetSize(22, 22)
detailTimerBgCheck:SetPoint("TOPLEFT", P3, "TOPLEFT", PL-2, -194)
detailTimerBgCheck:SetChecked(false)
local timerBgLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
timerBgLbl:SetPoint("LEFT", detailTimerBgCheck, "RIGHT", 2, 0)
timerBgLbl:SetText("배경 표시")

local timerBgAlphaLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
timerBgAlphaLbl:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+120, -198)
timerBgAlphaLbl:SetText("|cffaaaaaa불투명도|r")

local timerBgAlphaMinus = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
timerBgAlphaMinus:SetSize(26, 22)
timerBgAlphaMinus:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+120, -214)
timerBgAlphaMinus:SetText("-")

detailTimerBgAlphaDisplay = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
detailTimerBgAlphaDisplay:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+148, -214)
detailTimerBgAlphaDisplay:SetWidth(44)
detailTimerBgAlphaDisplay:SetJustifyH("CENTER")
detailTimerBgAlphaDisplay:SetText("0.5")

local timerBgAlphaPlus = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
timerBgAlphaPlus:SetSize(26, 22)
timerBgAlphaPlus:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+196, -214)
timerBgAlphaPlus:SetText("+")

timerBgAlphaMinus:SetScript("OnClick", function()
    local v = math.max(0.0, math.floor(((tonumber(detailTimerBgAlphaDisplay:GetText()) or 0.5) - 0.1)*10+0.5)/10)
    detailTimerBgAlphaDisplay:SetText(string.format("%.1f", v))
    AutoSave()
end)
timerBgAlphaPlus:SetScript("OnClick", function()
    local v = math.min(1.0, math.floor(((tonumber(detailTimerBgAlphaDisplay:GetText()) or 0.5) + 0.1)*10+0.5)/10)
    detailTimerBgAlphaDisplay:SetText(string.format("%.1f", v))
    AutoSave()
end)

MakeDividerP3(-246)

-- ─ 앵커(기준점) 설명 ─────────────────────────────────────
local anchorDescLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
anchorDescLbl:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -254)
anchorDescLbl:SetWidth(PW - PL*2)
anchorDescLbl:SetText(
    "|cffaaaaaa[기준점]|r  여러 초시계가 동시에 활성화되면, " ..
    "가장 첫 번째(★) 초시계 위치가 기준점입니다. " ..
    "나머지는 지정 방향으로 차례로 나타납니다."
)

MakeDividerP3(-300)

-- ─ 간격 + 방향 (같은 줄) ────────────────────────────────
local gapLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
gapLbl:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -308)
gapLbl:SetText("|cffaaaaaa간격 (px)|r")

local dirLbl = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
dirLbl:SetPoint("TOPLEFT", P3, "TOPLEFT", PL + 130, -308)
dirLbl:SetText("|cffaaaaaa쌓이는 방향|r")

-- 간격 스테퍼
local gapMinus = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
gapMinus:SetSize(26, 22)
gapMinus:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -324)
gapMinus:SetText("-")

local gapDisplay = P3:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
gapDisplay:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+28, -324)
gapDisplay:SetWidth(44)
gapDisplay:SetJustifyH("CENTER")
gapDisplay:SetText("36")

local gapPlus = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
gapPlus:SetSize(26, 22)
gapPlus:SetPoint("TOPLEFT", P3, "TOPLEFT", PL+76, -324)
gapPlus:SetText("+")

local function GetGapDisplay() return tonumber(gapDisplay:GetText()) or 36 end
local function SaveAnchorField(key, val)
    if not MoonlightSkillSoundDB._timerAnchor then
        MoonlightSkillSoundDB._timerAnchor = { posX=0, posY=150, anchor=nil, gap=36, growDir="down" }
    end
    MoonlightSkillSoundDB._timerAnchor[key] = val
end

gapMinus:SetScript("OnClick", function()
    local v = math.max(20, GetGapDisplay() - 2)
    gapDisplay:SetText(tostring(v)); SaveAnchorField("gap", v)
end)
gapPlus:SetScript("OnClick", function()
    local v = math.min(120, GetGapDisplay() + 2)
    gapDisplay:SetText(tostring(v)); SaveAnchorField("gap", v)
end)

-- 방향 토글 버튼 (↓ 아래로 / ↑ 위로)
local dirBtn = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
dirBtn:SetSize(PW - PL*2 - 130, 22)
dirBtn:SetPoint("TOPLEFT", P3, "TOPLEFT", PL + 130, -324)

local function RefreshDirBtn()
    local anc = GetTimerAnchor()
    if (anc.growDir or "down") == "down" then
        dirBtn:SetText("↓  아래로")
    else
        dirBtn:SetText("↑  위로")
    end
end
RefreshDirBtn()

dirBtn:SetScript("OnClick", function()
    local anc = GetTimerAnchor()
    local newDir = ((anc.growDir or "down") == "down") and "up" or "down"
    SaveAnchorField("growDir", newDir)
    RefreshDirBtn()
    -- 설명 문구도 갱신
    anchorDescLbl:SetText(
        "|cffaaaaaa[기준점]|r  여러 초시계가 동시에 활성화되면, " ..
        "가장 첫 번째(★) 초시계 위치가 기준점입니다. " ..
        "나머지는 지정 방향으로 차례로 나타납니다."
    )
    RelayoutTimers()
end)

MakeDividerP3(-356)

-- ─ 기준점 미리보기 버튼 (등록된 모든 스킬 동시 표시) ────
local timerPreviewing  = false
local previewFrames    = {}   -- 미리보기용 프레임 목록

detailTimerMoveBtn = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
detailTimerMoveBtn:SetSize(PW - PL*2, 26)
detailTimerMoveBtn:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -366)
detailTimerMoveBtn:SetText("▶  기준점 미리보기 및 위치이동")
detailTimerMoveBtn:GetFontString():SetTextColor(0.3,1,0.7)

local function ClearPreviewFrames()
    for _, pf in ipairs(previewFrames) do
        if pf._previewTicker then pf._previewTicker:Cancel(); pf._previewTicker = nil end
        pf:SetMovable(false); pf:EnableMouse(false)
        pf:SetScript("OnDragStart", nil); pf:SetScript("OnDragStop", nil)
        pf:Hide(); pf:SetAlpha(0)
    end
    previewFrames = {}
end

local function TimerBtn_SetNormal()
    timerPreviewing = false
    timerMoveMode   = false
    detailTimerMoveBtn:SetText("▶  기준점 미리보기 및 위치이동")
    detailTimerMoveBtn:GetFontString():SetTextColor(0.3,1,0.7)
end

detailTimerMoveBtn:SetScript("OnClick", function()
    if not timerPreviewing then
        -- 등록된 스킬 목록 수집 (timerMap 기준 + 현재 편집 중인 스킬 포함)
        local anc     = GetTimerAnchor()
        local gap     = anc.gap     or 36
        local growDir = anc.growDir or "down"
        local dir     = (growDir == "up") and 1 or -1

        -- 미리보기에 표시할 항목 목록 구성
        -- timerMap에 있는 스킬들 + 현재 편집 중인 스킬(미저장이어도 포함)
        local previewList = {}  -- { label, dur, fsz, r,g,b, bgOn, bgA }

        -- 현재 편집 스킬 정보
        -- ★ 커스텀 이름이 입력돼 있으면 미리보기에도 반영
        local curCustomName = detailTimerCustomNameEdit and detailTimerCustomNameEdit:GetText()
        local curSpellName = (curCustomName and curCustomName ~= "" and curCustomName)
                             or (selectedSpellID and GetSpellInfo_Safe(selectedSpellID))
                             or "현재 스킬"
        local curDur  = tonumber(detailTimerDurationEdit and detailTimerDurationEdit:GetText()) or 0
        local curFsz  = tonumber(detailTimerFontSizeDisplay and detailTimerFontSizeDisplay:GetText()) or 22
        local curR, curG, curB = detailTimerColorR or 1, detailTimerColorG or 1, detailTimerColorB or 1
        local curBgOn = detailTimerBgCheck and detailTimerBgCheck:GetChecked()
        local curBgA  = tonumber(detailTimerBgAlphaDisplay and detailTimerBgAlphaDisplay:GetText()) or 0.5
        if curDur <= 0 then curDur = 10 end

        -- timerMap 스킬들 (정렬)
        local mapIDs = {}
        for sid in pairs(timerMap) do table.insert(mapIDs, sid) end
        table.sort(mapIDs)

        local addedCur = false
        for _, sid in ipairs(mapIDs) do
            local e = timerMap[sid]
            local isCur = (selectedSpellID and tonumber(selectedSpellID) == tonumber(sid))
            if isCur then
                -- 현재 편집 중인 스킬 → 편집 값 우선
                table.insert(previewList, { label=curSpellName, dur=curDur, fsz=curFsz,
                    r=curR, g=curG, b=curB, bgOn=curBgOn, bgA=curBgA })
                addedCur = true
            else
                table.insert(previewList, { label=e.spellName, dur=e.duration, fsz=e.fontSz,
                    r=e.r, g=e.g, b=e.b, bgOn=e.bgEnabled, bgA=e.bgAlpha })
            end
        end
        -- 현재 스킬이 timerMap에 없으면 첫 번째로 추가
        if not addedCur and selectedSpellID then
            table.insert(previewList, 1, { label=curSpellName, dur=curDur, fsz=curFsz,
                r=curR, g=curG, b=curB, bgOn=curBgOn, bgA=curBgA })
        end
        -- 최소 1개 보장
        if #previewList == 0 then
            table.insert(previewList, { label="스킬명", dur=10, fsz=22, r=1,g=1,b=1, bgOn=false, bgA=0.5 })
        end

        timerMoveMode   = true
        timerPreviewing = true
        local font = GameFontNormal:GetFont()

        for i, item in ipairs(previewList) do
            local pf = GetPooledTimerFrame()
            local pt = pf.timerText
            local offsetY = dir * (i - 1) * gap

            pf:ClearAllPoints()
            if anc.anchor == "BOTTOMLEFT" then
                pf:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", anc.posX, anc.posY + offsetY)
            else
                pf:SetPoint("CENTER", UIParent, "CENTER", anc.posX, anc.posY + offsetY)
            end

            pt:SetFont(font, item.fsz, "OUTLINE")
            pt:SetTextColor(item.r, item.g, item.b)

            if item.bgOn then
                pf:SetBackdropColor(0,0,0, item.bgA)
                pf:SetBackdropBorderColor(0.3,0.3,0.3,0.8)
            else
                pf:SetBackdropColor(0,0,0,0)
                pf:SetBackdropBorderColor(0,0,0,0)
            end

            -- 첫 번째 스킬에 ★(앵커 기준) 표시
            local prefix = (i == 1) and "★ " or "  "
            local startTime = GetTime()
            local dur = item.dur
            local label = item.label

            local function updatePreview()
                local rem = math.max(0, dur - (GetTime() - startTime))
                pt:SetText(string.format("%s%s - %d초", prefix, label, math.ceil(rem)))
                local tw = pt:GetStringWidth() + 20
                local th = pt:GetStringHeight() + 14
                pf:SetSize(math.max(tw,80), math.max(th,30))
            end

            updatePreview()
            pf:SetAlpha(1); pf:Show()

            local ticker = C_Timer.NewTicker(0.5, function()
                if not timerPreviewing then return end
                updatePreview()
            end)
            pf._previewTicker = ticker

            -- 첫 번째 프레임만 드래그 가능 (앵커 이동)
            if i == 1 then
                pf:SetMovable(true); pf:EnableMouse(true)
                pf:RegisterForDrag("LeftButton")
                pf:SetScript("OnDragStart", pf.StartMoving)
                pf:SetScript("OnDragStop", function(self)
                    self:StopMovingOrSizing()
                    if not MoonlightSkillSoundDB._timerAnchor then
                        MoonlightSkillSoundDB._timerAnchor = { posX=0, posY=150, anchor=nil, gap=36, growDir="down" }
                    end
                    MoonlightSkillSoundDB._timerAnchor.posX   = self:GetLeft()
                    MoonlightSkillSoundDB._timerAnchor.posY   = self:GetBottom()
                    MoonlightSkillSoundDB._timerAnchor.anchor = "BOTTOMLEFT"
                    -- 나머지 미리보기 프레임도 같이 재배치
                    local newAnc = GetTimerAnchor()
                    local newGap = newAnc.gap or 36
                    local newDir = ((newAnc.growDir or "down") == "up") and 1 or -1
                    for j, opf in ipairs(previewFrames) do
                        local offsetJ = newDir * (j - 1) * newGap
                        opf:ClearAllPoints()
                        opf:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT",
                            newAnc.posX, newAnc.posY + offsetJ)
                    end
                    RebuildTimerMap()
                end)
            end

            table.insert(previewFrames, pf)
        end

        detailTimerMoveBtn:SetText("|cffff9999■  ★ 드래그로 기준점 이동 중… (클릭해서 완료)|r")
        detailTimerMoveBtn:GetFontString():SetTextColor(1,1,1)
    else
        ClearPreviewFrames()
        TimerBtn_SetNormal()
        SetStatus("기준점 위치가 저장되었습니다.", 0.3,1,0.7)
    end
end)

-- DB값 초기 반영 (ADDON_LOADED 이후)
C_Timer.After(0, function()
    local a = MoonlightSkillSoundDB and MoonlightSkillSoundDB._timerAnchor
    if a then
        gapDisplay:SetText(tostring(a.gap or 36))
        RefreshDirBtn()
    end
end)

-- 저장 버튼 (초시계 탭)
local timerSaveBtn = CreateFrame("Button", nil, P3, "UIPanelButtonTemplate")
timerSaveBtn:SetSize(PW - PL*2, 26)
timerSaveBtn:SetPoint("TOPLEFT", P3, "TOPLEFT", PL, -404)
timerSaveBtn:SetText("저장")
timerSaveBtn:GetFontString():SetTextColor(0.3, 1, 0.3)
timerSaveBtn:SetScript("OnClick", function() SaveAllFields() end)

-- 탭 초기화
end

-- ════════════════════════════════════════════════
local function BuildFinalize()
-- SaveAllFields / AutoSave는 모듈 레벨에서 정의됨

-- 초시계 탭 컨트롤에 AutoSave 연결
detailTimerEnabledCheck:SetScript("OnClick",   AutoSave)
detailTimerBgCheck:SetScript("OnClick",        AutoSave)
detailTimerColorBtn:SetScript("OnClick", function()  -- 색상 버튼은 피커 열기 + 피커 닫힐 때 저장
    local r, g, b = detailTimerColorR or 1, detailTimerColorG or 1, detailTimerColorB or 1
    if ColorPickerFrame.SetupColorPickerAndShow then
        ColorPickerFrame:SetupColorPickerAndShow({
            r=r, g=g, b=b, hasOpacity=false,
            swatchFunc = function()
                detailTimerColorR, detailTimerColorG, detailTimerColorB = ColorPickerFrame:GetColorRGB()
                RefreshColorSwatch(); AutoSave()
            end,
            cancelFunc = function(prev)
                detailTimerColorR, detailTimerColorG, detailTimerColorB = prev.r, prev.g, prev.b
                RefreshColorSwatch()
            end,
            previousValues = { r=r, g=g, b=b },
        })
    else
        ColorPickerFrame:Hide()
        ColorPickerFrame.func = function()
            detailTimerColorR, detailTimerColorG, detailTimerColorB = ColorPickerFrame:GetColorRGB()
            RefreshColorSwatch(); AutoSave()
        end
        ColorPickerFrame.cancelFunc = function()
            detailTimerColorR, detailTimerColorG, detailTimerColorB = r, g, b
            RefreshColorSwatch()
        end
        ColorPickerFrame:SetColorRGB(r, g, b)
        ColorPickerFrame:Show()
    end
end)

-- 이미지 탭 체크박스에도 AutoSave 연결
if detailImageEnabledCheck then
    detailImageEnabledCheck:SetScript("OnClick", AutoSave)
end
if detailImageFadeCheck then
    detailImageFadeCheck:SetScript("OnClick", AutoSave)
end


-- 지속 시간 EditBox: 엔터 시 포커스 해제 + 자동 저장
detailTimerDurationEdit:SetScript("OnEnterPressed", function(self)
    self:ClearFocus()
    AutoSave()
end)
detailTimerDurationEdit:SetScript("OnEscapePressed", function(self)
    self:ClearFocus()
end)

-------------------------------------------------------
-- 버튼 스크립트
-------------------------------------------------------
detailSaveBtn:SetScript("OnClick", function()
    SaveAllFields(false)
end)

detailTestBtn:SetScript("OnClick", function()
    if detailSoundSourceMode == "fid" then
        -- ★ 내장 음원 FID 테스트
        local fid = detailFIDEdit and tonumber(detailFIDEdit:GetText())
        if not fid or fid <= 0 then
            SetStatus("올바른 FileDataID를 입력하세요.", 1, 0.3, 0.3)
            return
        end
        PlaySoundFile(fid, selectedChannel)
        SetStatus("재생 중: FileDataID " .. fid, 0.4, 1, 0.4)
    else
        -- 파일 경로 테스트 (기존)
        local path = detailPathEdit and (detailPathEdit:GetText() or "")
        if path == "" then SetStatus("재생할 사운드 경로를 입력하세요.", 1, 0.3, 0.3); return end
        PlaySoundFile(path, selectedChannel)
        SetStatus("재생 중: " .. path, 0.4, 1, 0.4)
    end
end)

detailDeleteBtn:SetScript("OnClick", function()
    if not selectedClassID or not selectedSpellID then return end
    MoonlightSkillSoundDB[selectedClassID][selectedSpellID] = nil
    selectedSpellID = nil
    RebuildRuntimeMap(); RefreshSkillColumn(selectedClassID)
    LoadDetailPanel(selectedClassID, nil)
    SetStatus("삭제되었습니다.", 1,0.7,0.3)
end)

-------------------------------------------------------
-- 하단: 초기화 드롭다운 (버튼 1개로 통합)
-------------------------------------------------------
local bottomDiv = mainFrame:CreateTexture(nil, "ARTWORK")
bottomDiv:SetColorTexture(0.35,0.35,0.35,0.5)
bottomDiv:SetPoint("BOTTOMLEFT",  mainFrame, "BOTTOMLEFT",  16, 50)
bottomDiv:SetPoint("BOTTOMRIGHT", mainFrame, "BOTTOMRIGHT", -16, 50)
bottomDiv:SetHeight(1)

local resetActions = {
    { label="현재 스킬 프리셋으로 초기화", func=function()
        if not selectedClassID then SetStatus("먼저 직업을 선택하세요.",1,0.3,0.3); return end
        if not selectedSpellID then SetStatus("먼저 스킬을 선택하세요.",1,0.3,0.3); return end
        if not ResetSpellToPreset(selectedClassID, selectedSpellID) then
            SetStatus("해당 스킬의 프리셋 데이터가 없습니다.",1,0.3,0.3); return end
        RebuildRuntimeMap(); RefreshSkillColumn(selectedClassID)
        LoadDetailPanel(selectedClassID, selectedSpellID)
        SetStatus("현재 스킬을 프리셋으로 초기화했습니다.",0.3,1,0.3)
    end },
    { label="현재 직업 프리셋으로 초기화", func=function()
        if not selectedClassID then SetStatus("먼저 직업을 선택하세요.",1,0.3,0.3); return end
        if not ResetClassToPreset(selectedClassID) then
            SetStatus("해당 직업의 프리셋 데이터가 없습니다.",1,0.3,0.3); return end
        RebuildRuntimeMap(); RefreshSkillColumn(selectedClassID)
        LoadDetailPanel(selectedClassID, nil)
        SetStatus("현재 직업 프리셋으로 초기화되었습니다.",0.3,1,0.3)
    end },
    { label="|cffff8888모든 직업 프리셋으로 초기화|r", func=function()
        if not MoonlightSkillSoundDefaults then
            SetStatus("프리셋 데이터가 없습니다.",1,0.3,0.3); return end
        for classID in pairs(MoonlightSkillSoundDefaults) do ResetClassToPreset(classID) end
        RebuildRuntimeMap(); RefreshSkillColumn(selectedClassID)
        LoadDetailPanel(selectedClassID, nil)
        SetStatus("모든 직업이 프리셋으로 초기화되었습니다.",0.3,1,0.3)
    end },
}

local resetPopup = CreateFrame("Frame", nil, mainFrame, "BackdropTemplate")
resetPopup:SetSize(240, #resetActions * 26)
resetPopup:SetFrameStrata("TOOLTIP"); resetPopup:SetFrameLevel(120)
resetPopup:SetBackdrop({
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Buttons\\WHITE8x8",
    tile = false, edgeSize = 1,
})
resetPopup:SetBackdropColor(MD3C("surfaceContainer"))
resetPopup:SetBackdropBorderColor(MD3C("outline"))
resetPopup:Hide()

for i, act in ipairs(resetActions) do
    local rb = CreateFrame("Button", nil, resetPopup)
    rb:SetSize(240, 26)
    rb:SetPoint("TOPLEFT", resetPopup, "TOPLEFT", 0, -(i-1)*26)
    local rbBg = rb:CreateTexture(nil, "BACKGROUND")
    rbBg:SetAllPoints(); rbBg:SetColorTexture(1,1,1,0)
    local rbLbl = rb:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    rbLbl:SetPoint("LEFT", rb, "LEFT", 8, 0); rbLbl:SetText(act.label)
    rb:SetScript("OnEnter", function() rbBg:SetColorTexture(MD3C("surfaceHigh")) end)
    rb:SetScript("OnLeave", function() rbBg:SetColorTexture(0,0,0,0) end)
    local fn = act.func
    rb:SetScript("OnClick", function() resetPopup:Hide(); fn() end)
end

-- chPopup: 채널 일괄 변경 팝업 (아래에서 생성, resetMenuBtn OnClick에서 참조)
local chPopup

local resetMenuBtn = CreateFrame("Button", nil, mainFrame, "UIPanelButtonTemplate")
resetMenuBtn:SetSize(200, 26)
resetMenuBtn:SetPoint("BOTTOMLEFT", mainFrame, "BOTTOMLEFT", 16, 16)
resetMenuBtn:SetText("초기화  ▾")
resetMenuBtn:SetScript("OnClick", function(self)
    if chPopup then chPopup:Hide() end  -- 채널 팝업이 열려 있으면 닫기
    if resetPopup:IsShown() then resetPopup:Hide()
    else
        resetPopup:ClearAllPoints()
        resetPopup:SetPoint("BOTTOMLEFT", self, "TOPLEFT", 0, 2)
        resetPopup:Show()
    end
end)

-------------------------------------------------------
-- 하단: 채널 일괄 변경 버튼
-------------------------------------------------------
-- 모든 직업·장신구의 스킬 사운드 채널을 한 번에 바꿉니다.
-- 적용 범위: MoonlightSkillSoundDB 내 soundPath가 있는 모든 항목
local function ApplyChannelToAll(newChannel)
    local ch = NormalizeChannel(newChannel)
    local count = 0
    for classID, spells in pairs(MoonlightSkillSoundDB) do
        if type(classID) == "number" and type(spells) == "table" then
            for spellID, entry in pairs(spells) do
                if type(entry) == "table" and entry.soundPath and entry.soundPath ~= "" then
                    entry.soundChannel = ch
                    count = count + 1
                end
            end
        end
    end
    RebuildRuntimeMap()
    -- 현재 열려 있는 스킬 디테일 패널의 채널 드롭다운도 즉시 갱신
    if selectedSpellID then
        SetChannel(ch)
    end
    SetStatus(count .. "개 스킬의 채널을 " .. ch .. " 로 변경했습니다.", 0.3, 1, 0.7)
end

local chPopupItems = {
    { label = "Master",   ch = "Master"   },
    { label = "SFX",      ch = "SFX"      },
    { label = "Music",    ch = "Music"    },
    { label = "Ambience", ch = "Ambience" },
    { label = "Dialog",   ch = "Dialog"   },
}

chPopup = CreateFrame("Frame", nil, mainFrame, "BackdropTemplate")
chPopup:SetSize(160, #chPopupItems * 26)
chPopup:SetFrameStrata("TOOLTIP"); chPopup:SetFrameLevel(120)
chPopup:SetBackdrop({
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Buttons\\WHITE8x8",
    tile = false, edgeSize = 1,
})
chPopup:SetBackdropColor(MD3C("surfaceContainer"))
chPopup:SetBackdropBorderColor(MD3C("outline"))
chPopup:Hide()

for i, item in ipairs(chPopupItems) do
    local cb = CreateFrame("Button", nil, chPopup)
    cb:SetSize(160, 26)
    cb:SetPoint("TOPLEFT", chPopup, "TOPLEFT", 0, -(i-1)*26)
    local cbBg = cb:CreateTexture(nil, "BACKGROUND")
    cbBg:SetAllPoints(); cbBg:SetColorTexture(1,1,1,0)
    local cbLbl = cb:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    cbLbl:SetPoint("LEFT", cb, "LEFT", 8, 0); cbLbl:SetText(item.label)
    cb:SetScript("OnEnter", function() cbBg:SetColorTexture(MD3C("surfaceHigh")) end)
    cb:SetScript("OnLeave", function() cbBg:SetColorTexture(0,0,0,0) end)
    local ch = item.ch
    cb:SetScript("OnClick", function()
        chPopup:Hide()
        ApplyChannelToAll(ch)
    end)
end

local chMenuBtn = CreateFrame("Button", nil, mainFrame, "UIPanelButtonTemplate")
chMenuBtn:SetSize(180, 26)
chMenuBtn:SetPoint("BOTTOMLEFT", mainFrame, "BOTTOMLEFT", 224, 16)
chMenuBtn:SetText("채널 일괄 변경  ▾")
chMenuBtn:SetScript("OnClick", function(self)
    resetPopup:Hide()  -- 초기화 팝업이 열려 있으면 닫기
    if chPopup:IsShown() then chPopup:Hide()
    else
        chPopup:ClearAllPoints()
        chPopup:SetPoint("BOTTOMLEFT", self, "TOPLEFT", 0, 2)
        chPopup:Show()
    end
end)

local verFS = mainFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
verFS:SetPoint("BOTTOMRIGHT", mainFrame, "BOTTOMRIGHT", -16, 20)
verFS:SetTextColor(0.4,0.4,0.4); verFS:SetText("/mss")
end

-------------------------------------------------------
-- 경로 오류 알림 창
-------------------------------------------------------
local function ShowPathWarningFrame(warnings)
    local FRAME_W = 520
    local FRAME_H = 400

    local f = CreateFrame("Frame", "MoonlightPathWarningFrame", UIParent, "BackdropTemplate")
    f:SetSize(FRAME_W, FRAME_H)
    f:SetPoint("CENTER")
    f:SetFrameStrata("DIALOG")
    f:SetFrameLevel(30)
    f:SetMovable(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetClampedToScreen(true)
    f:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        tile = false, edgeSize = 1,
    })
    f:SetBackdropColor(MD3C("surface"))
    f:SetBackdropBorderColor(MD3C("outline"))
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)
    table.insert(UISpecialFrames, "MoonlightPathWarningFrame")

    -- 제목
    local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", f, "TOPLEFT", 20, -16)
    title:SetText("|cff7D75E5Moonlight|r |cffE5E1F5스킬사운드|r |cff9E9BB5- 경로 오류 알림|r")

    -- 닫기 버튼
    local closeBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    closeBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -6, -6)
    closeBtn:SetScript("OnClick", function() f:Hide() end)

    -- 구분선
    local topDiv = f:CreateTexture(nil, "ARTWORK")
    topDiv:SetColorTexture(MD3C("outlineVariant"))
    topDiv:SetPoint("TOPLEFT",  f, "TOPLEFT",  16, -40)
    topDiv:SetPoint("TOPRIGHT", f, "TOPRIGHT", -16, -40)
    topDiv:SetHeight(1)

    -- 확인 버튼
    local okBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    okBtn:SetSize(120, 26)
    okBtn:SetPoint("BOTTOM", f, "BOTTOM", 0, 16)
    okBtn:SetText("확인")
    okBtn:SetScript("OnClick", function() f:Hide() end)

    -- 하단 구분선
    local botDiv = f:CreateTexture(nil, "ARTWORK")
    botDiv:SetColorTexture(MD3C("outlineVariant"))
    botDiv:SetPoint("BOTTOMLEFT",  f, "BOTTOMLEFT",  16, 50)
    botDiv:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -16, 50)
    botDiv:SetHeight(1)

    -- 스크롤 프레임
    local scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT",     f, "TOPLEFT",     16, -48)
    scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -30, 58)

    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(FRAME_W - 50, 1)
    scroll:SetScrollChild(content)

    -- 마우스 휠 스크롤
    f:EnableMouseWheel(true)
    f:SetScript("OnMouseWheel", function(_, delta)
        local cur = scroll:GetVerticalScroll()
        local maxScroll = math.max(0, content:GetHeight() - scroll:GetHeight())
        scroll:SetVerticalScroll(math.max(0, math.min(maxScroll, cur - delta * 30)))
    end)

    -- 내용 생성
    local LINE_H  = 20  -- 기본 줄 높이
    local INDENT_W = 14 -- 들여쓰기 너비
    local yOffset = 0
    local contentW = FRAME_W - 50

    local function AddText(text, font, r, g, b, indent)
        local fs = content:CreateFontString(nil, "OVERLAY", font or "GameFontHighlightSmall")
        local x = indent or 0
        fs:SetPoint("TOPLEFT", content, "TOPLEFT", x, yOffset)
        fs:SetWidth(contentW - x - 10)
        fs:SetJustifyH("LEFT")
        fs:SetText(text)
        if r then fs:SetTextColor(r, g, b) end
        -- 글자 수 기준으로 줄 수 추정 (한 줄 약 55자 기준)
        local lineCount = math.max(1, math.ceil(#text / 55))
        yOffset = yOffset - LINE_H * lineCount - 2
    end

    local function AddGap(h)
        yOffset = yOffset - (h or 6)
    end

    local function AddDivider()
        local d = content:CreateTexture(nil, "ARTWORK")
        d:SetColorTexture(MD3C("outlineVariant"))
        d:SetPoint("TOPLEFT",  content, "TOPLEFT",  0, yOffset - 4)
        d:SetPoint("TOPRIGHT", content, "TOPRIGHT", -10, yOffset - 4)
        d:SetHeight(1)
        yOffset = yOffset - 14
    end

    -- 자동 교정 항목
    local fixed = {}
    local manual = {}
    for _, w in ipairs(warnings) do
        if w.kind == "missing_separator_fixed" or w.kind == "double_slash_fixed" or w.kind == "path_mismatch_fixed" then
            table.insert(fixed, w)
        else
            table.insert(manual, w)
        end
    end

    if #fixed > 0 then
        AddText("✅  자동 교정 완료  (" .. #fixed .. "건)", "GameFontNormal", MD3.success[1], MD3.success[2], MD3.success[3])
        AddGap(4)
        for _, w in ipairs(fixed) do
            local spellName = GetSpellInfo_Safe(w.spellID) or ("Spell "..tostring(w.spellID))
            AddText("· " .. spellName, "GameFontHighlight", MD3.secondary[1], MD3.secondary[2], MD3.secondary[3])
            AddText("  이전: " .. w.path, nil, MD3.onSurfaceDim[1], MD3.onSurfaceDim[2], MD3.onSurfaceDim[3], INDENT_W)
            AddText("  교정: " .. w.fixed, nil, MD3.success[1], MD3.success[2], MD3.success[3], INDENT_W)
            AddGap(6)
        end
    end

    if #fixed > 0 and #manual > 0 then
        AddDivider()
    end

    if #manual > 0 then
        AddText("⚠  수동 수정 필요  (" .. #manual .. "건)", "GameFontNormal", MD3.warning[1], MD3.warning[2], MD3.warning[3])
        AddGap(4)
        for _, w in ipairs(manual) do
            local spellName = GetSpellInfo_Safe(w.spellID) or ("Spell "..tostring(w.spellID))
            AddText("· " .. spellName, "GameFontHighlight", MD3.secondary[1], MD3.secondary[2], MD3.secondary[3])
            AddText("  현재: " .. w.path, nil, MD3.onSurfaceDim[1], MD3.onSurfaceDim[2], MD3.onSurfaceDim[3], INDENT_W)
            AddText("  해결: 폴더 사이에 \\ 를 넣어주세요.", nil, MD3.warning[1], MD3.warning[2], MD3.warning[3], INDENT_W)
            AddText("  예시: Interface\\AddOns\\Moonlight_SkillSound\\sounds\\파일명.ogg", nil, MD3.onSurfaceMid[1], MD3.onSurfaceMid[2], MD3.onSurfaceMid[3], INDENT_W)
            AddText("  /mss 를 열어 경로를 직접 수정할 수 있습니다.", nil, MD3.primary[1], MD3.primary[2], MD3.primary[3], INDENT_W)
            AddGap(6)
        end
    end

    content:SetHeight(math.abs(yOffset) + 20)
    f:Show()
end

-------------------------------------------------------
-- UI 빌드
-------------------------------------------------------
local function BuildUI()

    -- ── 메인 프레임 (컴팩트) ──────────────────────────
    mainFrame = CreateFrame("Frame", "MoonlightSkillSoundFrame", UIParent, "BackdropTemplate")
    mainFrame:SetSize(860, 660)
    mainFrame:SetPoint("CENTER")
    mainFrame:SetFrameStrata("DIALOG")
    mainFrame:SetFrameLevel(20)
    mainFrame:SetMovable(true)
    mainFrame:EnableMouse(true)
    mainFrame:RegisterForDrag("LeftButton")
    mainFrame:SetClampedToScreen(true)
    mainFrame:Hide()
    mainFrame:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        tile = false, edgeSize = 1,
    })
    mainFrame:SetBackdropColor(MD3C("surface"))
    mainFrame:SetBackdropBorderColor(MD3C("outline"))
    mainFrame:SetScript("OnDragStart", mainFrame.StartMoving)
    mainFrame:SetScript("OnDragStop",  mainFrame.StopMovingOrSizing)
    table.insert(UISpecialFrames, "MoonlightSkillSoundFrame")

    -- MD3: 상단 헤더 배경 바 (Surface Container 레이어)
    local headerBg = mainFrame:CreateTexture(nil, "BACKGROUND")
    headerBg:SetColorTexture(MD3C("surfaceContainer"))
    headerBg:SetPoint("TOPLEFT",  mainFrame, "TOPLEFT",  1, -1)
    headerBg:SetPoint("TOPRIGHT", mainFrame, "TOPRIGHT", -1, -1)
    headerBg:SetHeight(42)

    -- MD3: Primary 컬러 상단 액센트 라인
    local accentLine = mainFrame:CreateTexture(nil, "ARTWORK")
    accentLine:SetColorTexture(MD3C("primary"))
    accentLine:SetPoint("TOPLEFT",  mainFrame, "TOPLEFT",  1, -1)
    accentLine:SetPoint("TOPRIGHT", mainFrame, "TOPRIGHT", -1, -1)
    accentLine:SetHeight(2)

    local titleFS = mainFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    titleFS:SetPoint("TOPLEFT", 20, -13)
    -- MD3: Title에 Primary 포인트 컬러 적용
    titleFS:SetText("|cff7D75E5Moonlight|r |cffE5E1F5SkillSound|r")

    local subFS = mainFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    subFS:SetPoint("LEFT", titleFS, "RIGHT", 10, -1)
    subFS:SetTextColor(MD3C("onSurfaceDim"))
    subFS:SetText("/mss")

    local closeBtn = CreateFrame("Button", nil, mainFrame, "UIPanelCloseButton")
    closeBtn:SetPoint("TOPRIGHT", -6, -6)

    -- MD3: 헤더 하단 구분선 (outline 색상)
    local topDiv = mainFrame:CreateTexture(nil, "ARTWORK")
    topDiv:SetColorTexture(MD3C("outline"))
    topDiv:SetPoint("TOPLEFT",  mainFrame, "TOPLEFT",  0, -42)
    topDiv:SetPoint("TOPRIGHT", mainFrame, "TOPRIGHT",  0, -42)
    topDiv:SetHeight(1)

    -- 헬퍼 함수: UI_MakeBox / UI_MakeDivider / UI_MakeDropdown (모듈 레벨)
    local function MakeBox(px, py, pw, ph)  return UI_MakeBox(mainFrame, px, py, pw, ph) end
    local function MakeDivider(p, y)        return UI_MakeDivider(p, y) end
    local function MakeDropdown(...)        return UI_MakeDropdown(...) end

    -- ════════════════════════════════════════════════
    --  컬럼 1: 직업
    -- ════════════════════════════════════════════════
    local box1 = MakeBox(X1, COL_TOP, COL1_W, COL_H)

    local t1 = box1:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    t1:SetPoint("TOPLEFT", box1, "TOPLEFT", 8, -7)
    -- MD3: 컬럼 헤더는 onSurfaceMid (보조 텍스트 계층)
    t1:SetText("|cff9E9BB5직  업|r")
    MakeDivider(box1, -20)

    for i, cls in ipairs(CLASS_LIST) do
        local row = CreateFrame("Button", nil, box1)
        row:SetSize(COL1_W - 4, 24)
        if i == 1 then row:SetPoint("TOPLEFT", box1, "TOPLEFT", 2, -24)
        else row:SetPoint("TOPLEFT", classRows[i-1], "BOTTOMLEFT", 0, -1) end

        row.bg = row:CreateTexture(nil, "BACKGROUND")
        row.bg:SetAllPoints(); row.bg:SetColorTexture(0,0,0,0)

        local bar = row:CreateTexture(nil, "ARTWORK")
        bar:SetSize(3, 16); bar:SetPoint("LEFT", row, "LEFT", 4, 0)
        local cr, cg, cb = HexColor(cls.color)
        bar:SetColorTexture(cr, cg, cb, 1)

        local lbl = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        lbl:SetPoint("LEFT", bar, "RIGHT", 6, 0)
        lbl:SetText(cls.name); lbl:SetTextColor(MD3C("onSurface"))
        row.lbl = lbl
        row.classColor = { cr, cg, cb }

        row.classID = cls.id
        row:SetScript("OnClick", OnClassRowClick)
        row:SetScript("OnEnter", function(s)
            s.bg:SetColorTexture(MD3C("surfaceHigh"))
        end)
        row:SetScript("OnLeave", function(s)
            if selectedClassID == s.classID then
                -- MD3: 선택된 행은 primaryDim 배경 + 직업 컬러 텍스트
                s.bg:SetColorTexture(MD3C("primaryDim"))
            else
                s.bg:SetColorTexture(0,0,0,0)
            end
        end)
        classRows[i] = row
    end

    -- ════════════════════════════════════════════════
    --  컬럼 2: 스킬
    -- ════════════════════════════════════════════════
    local box2 = MakeBox(X2, COL_TOP, COL2_W, COL_H)

    local t2 = box2:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    t2:SetPoint("TOPLEFT", box2, "TOPLEFT", 8, -7)
    t2:SetText("|cff9E9BB5스  킬|r")
    _col2Header = t2  -- 모듈 레벨 변수에 할당 → RefreshSkillColumn에서 갱신 가능
    MakeDivider(box2, -20)

    local addEdit = CreateFrame("EditBox", nil, box2, "InputBoxTemplate")
    addEdit:SetSize(COL2_W - 62, 20)
    addEdit:SetPoint("TOPLEFT", box2, "TOPLEFT", 6, -30)
    addEdit:SetAutoFocus(false); addEdit:SetNumeric(true)
    _addEdit = addEdit

    local addHint = box2:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    addHint:SetPoint("LEFT", addEdit, "LEFT", 4, 0)
    addHint:SetTextColor(0.70, 0.70, 0.70); addHint:SetText("Spell ID 입력…")
    addEdit:SetScript("OnTextChanged", function(self)
        -- classID=15(아이템)이면 힌트 텍스트를 Item ID로 변경
        addHint:SetText(selectedClassID == 15 and "Item ID 입력…" or "Spell ID 입력…")
        addHint:SetShown(self:GetText() == "")
    end)
    addHint:SetShown(true)

    local addBtn = CreateFrame("Button", nil, box2, "UIPanelButtonTemplate")
    addBtn:SetSize(46, 20); addBtn:SetPoint("LEFT", addEdit, "RIGHT", 4, 0)
    addBtn:SetText("추가")
    _addBtn = addBtn

    addBtn:SetScript("OnClick", function()
        local entryID = tonumber(addEdit:GetText())
        if not entryID or entryID <= 0 then
            SetStatus("올바른 " .. (selectedClassID == 15 and "Item" or "Spell") .. " ID를 입력하세요.", 1,0.3,0.3); return
        end
        if not selectedClassID then SetStatus("먼저 직업을 선택하세요.", 1,0.3,0.3); return end

        -- classID=15는 아이템, 나머지는 스킬로 검증
        local nm = GetEntryInfo_Safe(entryID, selectedClassID)
        if not nm then
            SetStatus("존재하지 않는 " .. (selectedClassID == 15 and "Item" or "Spell") .. " ID입니다.", 1,0.3,0.3); return
        end
        local db = MoonlightSkillSoundDB[selectedClassID]
        if db[entryID] then SetStatus("이미 추가된 항목입니다.", 1,0.7,0.3)
        else
            db[entryID] = { soundPath="", soundChannel="Master", enabled=true, triggerType="instant",
                            imagePath="", imageEnabled=false, imageDuration=3.0,
                            imageScale=1.0, imagePosX=0, imagePosY=0, imagePosAnchor=nil }
            SetStatus((selectedClassID == 15 and "아이템" or "스킬") .. "이 추가되었습니다.", 0.3,1,0.3)
        end
        addEdit:SetText(""); addEdit:SetFocus()
        selectedSpellID = entryID
        RefreshSkillColumn(selectedClassID)
        LoadDetailPanel(selectedClassID, entryID)
    end)
    addEdit:SetScript("OnEnterPressed", function() addBtn:Click() end)

    local skillScroll = CreateFrame("ScrollFrame", nil, box2, "UIPanelScrollFrameTemplate")
    skillScroll:SetPoint("TOPLEFT",     box2, "TOPLEFT",     4, -56)
    skillScroll:SetPoint("BOTTOMRIGHT", box2, "BOTTOMRIGHT", -22, 4)

    skillScrollContent = CreateFrame("Frame", nil, skillScroll)
    skillScrollContent:SetSize(COL2_W - 28, 1)
    skillScroll:SetScrollChild(skillScrollContent)

    -- ════════════════════════════════════════════════
    --  컬럼 3: 사운드 설정 (탭 포함)
    -- ════════════════════════════════════════════════
    local box3 = MakeBox(X3, COL_TOP, COL3_W, COL_H)

    detailSpellIcon = box3:CreateTexture(nil, "ARTWORK")
    detailSpellIcon:SetSize(32, 32); detailSpellIcon:SetPoint("TOPLEFT", box3, "TOPLEFT", 8, -8)
    detailSpellIcon:SetTexture(134400); detailSpellIcon:SetTexCoord(0.08,0.92,0.08,0.92)

    -- 아이콘 위 투명 버튼: 마우스 이벤트를 받아 툴팁 표시
    local iconOverlay = CreateFrame("Button", nil, box3)
    iconOverlay:SetSize(32, 32)
    iconOverlay:SetPoint("TOPLEFT", box3, "TOPLEFT", 8, -8)
    iconOverlay:SetScript("OnEnter", function(self)
        if not selectedSpellID or selectedSpellID == "__death__" then return end
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        if selectedClassID == 15 then
            GameTooltip:SetItemByID(selectedSpellID)
        else
            GameTooltip:SetSpellByID(selectedSpellID)
        end
        GameTooltip:Show()
    end)
    iconOverlay:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    detailSpellName = box3:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    detailSpellName:SetPoint("LEFT", detailSpellIcon, "RIGHT", 6, 0)
    detailSpellName:SetPoint("RIGHT", box3, "RIGHT", -100, 0)
    detailSpellName:SetJustifyH("LEFT")
    detailSpellName:SetText("|cff6B6882스킬을 선택하세요|r")

    -- 메모 추가/수정 버튼 (스킬 아이콘/이름과 같은 줄, 우측 정렬)
    detailAliasBtn = CreateFrame("Button", nil, box3, "UIPanelButtonTemplate")
    detailAliasBtn:SetSize(88, 20)
    detailAliasBtn:SetPoint("TOPRIGHT", box3, "TOPRIGHT", -6, -8)
    detailAliasBtn:SetText("[메모 추가]")
    detailAliasBtn:GetFontString():SetTextColor(0.4, 0.8, 1)
    detailAliasBtn:GetFontString():SetFont(GameFontNormal:GetFont(), 9, "OUTLINE")
    detailAliasBtn:Hide()
    detailAliasBtn:SetScript("OnClick", function()
        if not selectedClassID or not selectedSpellID then return end
        local entry = MoonlightSkillSoundDB[selectedClassID] and MoonlightSkillSoundDB[selectedClassID][selectedSpellID]
        if not entry then return end

        local currentAlias = entry.alias or ""
        StaticPopupDialogs["MSS_ALIAS_INPUT"] = {
            text         = "메모를 입력하세요.\n(비워두면 메모가 삭제됩니다)",
            button1      = "확인",
            button2      = "취소",
            hasEditBox   = true,
            editBoxWidth = 220,
            maxLetters   = 50,
            OnShow = function(self)
                self.EditBox:SetText(currentAlias)
                self.EditBox:SetFocus()
                self.EditBox:HighlightText()
            end,
            OnAccept = function(self)
                local input = self.EditBox:GetText() or ""
                entry.alias = (input ~= "") and input or nil
                RebuildRuntimeMap()
                LoadDetailPanel(selectedClassID, selectedSpellID)
                RefreshSkillColumn(selectedClassID)
                SetStatus("메모가 저장되었습니다.", 0.3, 1, 0.3)
            end,
            EditBoxOnEnterPressed = function(self)
                local parent = self:GetParent()
                local input = parent.EditBox:GetText() or ""
                entry.alias = (input ~= "") and input or nil
                RebuildRuntimeMap()
                LoadDetailPanel(selectedClassID, selectedSpellID)
                RefreshSkillColumn(selectedClassID)
                SetStatus("메모가 저장되었습니다.", 0.3, 1, 0.3)
                parent:Hide()
            end,
            timeout      = 0,
            whileDead    = true,
            hideOnEscape = true,
        }
        StaticPopup_Show("MSS_ALIAS_INPUT")
    end)

    MakeDivider(box3, -46)

    -- 저장 상태 메시지 (모든 탭에서 보이도록 box3 하단에 배치)
    detailStatusFS = box3:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    detailStatusFS:SetPoint("BOTTOMLEFT", box3, "BOTTOMLEFT", PL, 8)
    detailStatusFS:SetWidth(PW - PL*2)
    detailStatusFS:SetJustifyH("LEFT")
    detailStatusFS:SetText("")

    -- 탭 바
    local tabPanels = {}
    local tabBtns   = {}

    local function ShowDetailTab(idx)
        for i, tb in ipairs(tabBtns) do
            if i == idx then
                -- MD3: 선택 탭 = primaryDim 배경 + primary 텍스트
                tb:SetBackdropColor(MD3C("primaryDim"))
                tb:SetBackdropBorderColor(MD3C("primary"))
                tb.lbl:SetTextColor(MD3C("primary"))
            else
                -- MD3: 비선택 탭 = surfaceContainer + outlineVariant
                tb:SetBackdropColor(MD3C("surfaceContainer"))
                tb:SetBackdropBorderColor(MD3C("outlineVariant"))
                tb.lbl:SetTextColor(MD3C("onSurfaceMid"))
            end
        end
        for i, p in ipairs(tabPanels) do
            if i == idx then p:Show() else p:Hide() end
        end
    end

    local tabLabels = { "사운드", "이미지", "초시계" }
    for i, lbl in ipairs(tabLabels) do
        local tb = CreateFrame("Button", nil, box3, "BackdropTemplate")
        tb:SetSize(TAB_W, TAB_H)
        tb:SetPoint("TOPLEFT", box3, "TOPLEFT", (i-1)*TAB_W, TAB_Y)
        tb:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            tile = false, edgeSize = 1,
        })
        tb:SetBackdropColor(MD3C("surfaceContainer"))
        tb:SetBackdropBorderColor(MD3C("outlineVariant"))
        -- BackdropTemplate 버튼은 GetFontString()이 없으므로 직접 저장
        local tbLbl = tb:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        tbLbl:SetPoint("CENTER", tb, "CENTER", 0, 0)
        tbLbl:SetText(lbl); tbLbl:SetTextColor(MD3C("onSurfaceMid"))
        tb.lbl = tbLbl
        local idx = i
        tb:SetScript("OnClick", function() ShowDetailTab(idx) end)
        tabBtns[i] = tb

        local panel = CreateFrame("Frame", nil, box3)
        panel:SetPoint("TOPLEFT",     box3, "TOPLEFT",     0, TAB_Y - TAB_H)
        panel:SetPoint("BOTTOMRIGHT", box3, "BOTTOMRIGHT", 0, 0)
        panel:Hide()
        tabPanels[i] = panel
    end

    -- ════════════════════════════════════════════════
    --  탭1: 사운드
    -- ════════════════════════════════════════════════
    -- 탭별 빌드 (함수 분리로 upvalue 제한 회피)
    BuildTab1(tabPanels)
    BuildTab2(tabPanels)
    BuildTab3(tabPanels)
    BuildFinalize()
end


-------------------------------------------------------
-- 창 열기 / 닫기
-------------------------------------------------------
local function OpenUI()
    if not mainFrame then BuildUI() end

    if not selectedClassID then
        local _, classFile = UnitClass("player")
        for i, c in ipairs(CLASS_LIST) do
            if c.nameEN == classFile then
                selectedClassID = c.id
                if classRows[i] then OnClassRowClick(classRows[i]) end
                break
            end
        end
    end
    mainFrame:Show()
end

local function ToggleUI()
    if not mainFrame then BuildUI() end
    if mainFrame:IsShown() then mainFrame:Hide() else OpenUI() end
end

-------------------------------------------------------
-- 게임 내 설정 패널 등록
-------------------------------------------------------
local function RegisterSettingsPanel()
    local panel = CreateFrame("Frame", "MoonlightSkillSoundSettingsPanel")
    panel.name  = "!Moonlight_스킬사운드"

    local title = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", 16, -16)
    title:SetText("|cffffcc00Moonlight|r 스킬사운드")

    local desc = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
    desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -10)
    desc:SetText("스킬 사용 시 지정된 사운드 파일을 재생합니다.")
    desc:SetTextColor(0.8, 0.8, 0.8)

    local openBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    openBtn:SetSize(220, 28)
    openBtn:SetPoint("TOPLEFT", desc, "BOTTOMLEFT", 0, -20)
    openBtn:SetText("스킬사운드 설정 창 열기")
    openBtn:SetScript("OnClick", function()
        if SettingsPanel and SettingsPanel:IsShown() then
            HideUIPanel(SettingsPanel)
        elseif InterfaceOptionsFrame and InterfaceOptionsFrame:IsShown() then
            HideUIPanel(InterfaceOptionsFrame)
        end
        OpenUI()
    end)

    local slashLbl = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    slashLbl:SetPoint("TOPLEFT", openBtn, "BOTTOMLEFT", 0, -10)
    slashLbl:SetText("슬래시 명령어:  /mss")
    slashLbl:SetTextColor(0.6, 0.6, 0.6)

    if Settings and Settings.RegisterCanvasLayoutCategory then
        local cat = Settings.RegisterCanvasLayoutCategory(panel, "!Moonlight SkillSound")
        Settings.RegisterAddOnCategory(cat)
    elseif InterfaceOptions_AddCategory then
        InterfaceOptions_AddCategory(panel)
    end
end

-------------------------------------------------------
-- 프리셋 내보내기 (배포자 전용)
-- /mss export moonlight
-------------------------------------------------------

-- 숫자를 소수점 없이 깔끔하게 직렬화
local function SerializeVal(v)
    if type(v) == "string"  then return string.format("%q", v):gsub("\\\n", "\\n") end
    if type(v) == "number"  then
        -- 정수면 정수 표기, 소수면 소수 표기
        if v == math.floor(v) then return tostring(math.floor(v))
        else return tostring(v) end
    end
    if type(v) == "boolean" then return tostring(v) end
    return "nil"
end

-- CLASS_LIST 역방향: id → name (ExportPreset 전용)
local function GetClassName(id)
    for _, c in ipairs(CLASS_LIST) do
        if c.id == id then return c.name .. " (" .. c.nameEN .. ")" end
    end
    return "classID=" .. tostring(id)
end

-- 스킬 이름 조회 (내보낼 때 주석으로 달아줌)
local function GetSpellNameSafe(spellID)
    if type(spellID) ~= "number" then return nil end
    if C_Spell and C_Spell.GetSpellInfo then
        local info = C_Spell.GetSpellInfo(spellID)
        if info and info.name then return info.name end
    end
    if GetSpellInfo then
        local name = GetSpellInfo(spellID)
        if name then return name end
    end
    return nil
end

-- 기본값과 같으면 생략해서 출력을 깔끔하게 만드는 헬퍼
-- (soundPath / soundFileDataID / soundChannel / enabled / triggerType 은 항상 출력)
local FIELD_DEFAULTS = {
    imagePath        = "",
    imageEnabled     = false,
    imageDuration    = 3.0,
    imageScale       = 1.0,
    imagePosX        = 0,
    imagePosY        = 0,
    imagePosAnchor   = nil,
    imageFadeEnabled = false,
    imageFadeIn      = 0.3,
    imageFadeOut     = 0.4,
    timerEnabled     = false,
    timerDuration    = 0,
    timerSpellName   = "",
    timerCustomName  = nil,
    timerFontSize    = 22,
    timerColorR      = 1,
    timerColorG      = 1,
    timerColorB      = 1,
    timerBgEnabled   = false,
    timerBgAlpha     = 0.5,
    timerPosX        = 0,
    timerPosY        = 0,
    timerPosAnchor   = nil,
    alias            = nil,
}
-- 항상 출력할 필드 (기본값과 같아도 생략 안 함)
local FIELD_ALWAYS = {
    soundPath = true, soundFileDataID = true,
    soundChannel = true, enabled = true, triggerType = true,
}
-- 출력 순서 정의
local FIELD_ORDER = {
    "soundPath", "soundFileDataID",
    "soundChannel", "enabled", "triggerType",
    "alias",
    "imagePath", "imageEnabled", "imageDuration", "imageScale",
    "imagePosX", "imagePosY", "imagePosAnchor",
    "imageFadeEnabled", "imageFadeIn", "imageFadeOut",
    "timerEnabled", "timerDuration", "timerSpellName", "timerCustomName",
    "timerFontSize", "timerColorR", "timerColorG", "timerColorB",
    "timerBgEnabled", "timerBgAlpha",
    "timerPosX", "timerPosY", "timerPosAnchor",
}

local function ExportPreset()
    if not MoonlightSkillSoundDB then
        print("|cffffcc00[MSS Export]|r DB가 초기화되지 않았습니다.")
        return
    end

    -- 버전 계산: 현재 버전 +1 권고
    local curVer = MOONLIGHT_SKILLSOUND_PRESET_VERSION or 1
    local newVer = curVer + 1

    -- ── 출력 버퍼 ──────────────────────────────────────
    local lines = {}
    local function W(s) lines[#lines + 1] = s end

    W("-------------------------------------------------------")
    W("-- DefaultPreset.lua  [내보내기: " .. date("%Y-%m-%d %H:%M") .. "]")
    W("-- 아래 버전을 기존 파일의 버전보다 높게 설정하세요.")
    W("-------------------------------------------------------")
    W("")
    W("MOONLIGHT_SKILLSOUND_PRESET_VERSION = " .. newVer
      .. "   -- (내보내기 전 버전: " .. curVer .. ")")
    W("")
    W("MoonlightSkillSoundDefaults = {")
    W("")

    -- classID 순서대로 출력
    local classIDs = {}
    for classID in pairs(MoonlightSkillSoundDB) do
        if type(classID) == "number" then
            classIDs[#classIDs + 1] = classID
        end
    end
    table.sort(classIDs)

    for _, classID in ipairs(classIDs) do
        local spells = MoonlightSkillSoundDB[classID]
        if type(spells) == "table" then

            -- 이 직업에서 실제로 출력할 항목이 있는지 먼저 확인
            local spellIDs = {}
            for spellID, entry in pairs(spells) do
                if type(entry) == "table"
                   and type(spellID) ~= "string"  -- __death__ 같은 문자열 키 제외
                   and ((entry.soundPath and entry.soundPath ~= "")
                        or (entry.soundFileDataID and entry.soundFileDataID ~= 0)) then
                    spellIDs[#spellIDs + 1] = spellID
                end
            end

            if #spellIDs > 0 then
                table.sort(spellIDs)

                W("    -- ── " .. GetClassName(classID) .. " ──")
                W("    [" .. classID .. "] = {")

                for _, spellID in ipairs(spellIDs) do
                    local entry = spells[spellID]
                    local spellName = GetSpellNameSafe(spellID)
                    if spellName then
                        W("        -- " .. spellName)
                    end
                    W("        [" .. spellID .. "] = {")

                    for _, field in ipairs(FIELD_ORDER) do
                        local val = entry[field]
                        local def = FIELD_DEFAULTS[field]
                        local always = FIELD_ALWAYS[field]
                        local shouldWrite = true

                        -- soundFileDataID: nil/0이면 생략
                        if field == "soundFileDataID" and (val == nil or val == 0) then
                            shouldWrite = false
                        elseif not always then
                            -- 기본값과 같거나 nil이면 생략
                            if val == nil or val == def then
                                shouldWrite = false
                            elseif type(val) == "string" and val == "" then
                                shouldWrite = false
                            end
                        end

                        if shouldWrite then
                            W("            " .. field .. " = " .. SerializeVal(val) .. ",")
                        end
                    end

                    W("        },")
                end

                W("    },")
                W("")
            end
        end
    end

    W("}")
    W("")

    -- ── 팝업 텍스트박스로 출력 ─────────────────────────
    local fullText = table.concat(lines, "\n")

    -- 팝업 프레임 (이미 있으면 재사용)
    if not _G["MSS_ExportFrame"] then
        local f = CreateFrame("Frame", "MSS_ExportFrame", UIParent, "BasicFrameTemplateWithInset")
        f:SetSize(640, 480)
        f:SetPoint("CENTER")
        f:SetMovable(true); f:EnableMouse(true)
        f:RegisterForDrag("LeftButton")
        f:SetScript("OnDragStart", f.StartMoving)
        f:SetScript("OnDragStop",  f.StopMovingOrSizing)
        f:SetFrameStrata("DIALOG")

        local title = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        title:SetPoint("TOP", f, "TOP", 0, -8)
        title:SetText("|cffffcc00[MSS]|r DefaultPreset 내보내기")

        local hint = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        hint:SetPoint("TOPLEFT", f, "TOPLEFT", 14, -32)
        hint:SetTextColor(0.6, 0.9, 0.6)
        hint:SetText("Ctrl+A → Ctrl+C 로 전체 복사 후 DefaultPreset.lua에 붙여넣으세요.")

        local scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
        scroll:SetPoint("TOPLEFT",  f, "TOPLEFT",   14, -52)
        scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -30, 10)

        local edit = CreateFrame("EditBox", nil, scroll)
        edit:SetMultiLine(true)
        edit:SetAutoFocus(false)
        edit:SetFontObject(GameFontHighlightSmall)
        edit:SetWidth(scroll:GetWidth() or 580)
        edit:SetScript("OnEscapePressed", function() f:Hide() end)
        scroll:SetScrollChild(edit)

        f.editBox = edit
    end

    local ef = _G["MSS_ExportFrame"]
    ef.editBox:SetText(fullText)
    ef.editBox:HighlightText()
    ef:Show()

    print("|cffffcc00[MSS Export]|r 내보내기 완료. "
          .. "팝업창에서 Ctrl+A → Ctrl+C 로 복사하세요.")
    print("|cffffcc00[MSS Export]|r 권장 버전: MOONLIGHT_SKILLSOUND_PRESET_VERSION = "
          .. newVer)
end

-------------------------------------------------------
-- 슬래시 명령어
-------------------------------------------------------
SLASH_MSS1 = "/mss"
SLASH_MSS2 = "/ㅡㄴㄴ"
SlashCmdList["MSS"] = function(msg)
    local arg = msg and msg:match("^%s*(.-)%s*$") or ""
    if arg == "" then
        ToggleUI()
    elseif arg:lower() == "export moonlight" then
        -- 배포자 전용: 현재 DB를 DefaultPreset.lua 형식으로 내보내기
        ExportPreset()
    else
        print("|cffffcc00[MSS]|r 알 수 없는 명령어: " .. arg)
        print("|cffffcc00[MSS]|r 사용법: /mss")
    end
end
SlashCmdList["ㅡㄴㄴ"] = function() ToggleUI() end
-------------------------------------------------------
-- 이벤트
-------------------------------------------------------
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED",     "player")
eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_START",         "player")
eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_START", "player")
eventFrame:RegisterEvent("PLAYER_DEAD")

eventFrame:SetScript("OnEvent", function(self, event, ...)
    if event == "ADDON_LOADED" then
        local name = ...
        if name == ADDON_NAME then
            InitDB()
            RegisterSettingsPanel()
        end

    elseif event == "PLAYER_LOGIN" then
        -- ★ LibStub / LSM은 PLAYER_LOGIN 시점에 확실히 로드되어 있음
        --   (ADDON_LOADED보다 늦게 참조해야 모든 애드온의 SharedMedia 등록이 끝남)
        if LibStub then
            LSM = LibStub("LibSharedMedia-3.0", true)
        end

        -- ★ 이후에도 다른 애드온이 SharedMedia에 사운드를 추가 등록할 경우
        --   ResolveSoundPath가 실시간 조회하므로 별도 콜백 없이도 자동 반영됩니다.
        --   단, RebuildRuntimeMap을 다시 돌려 entry.path 자체가 갱신되어야 하는
        --   경우(경로명이 아닌 레이블로 저장된 항목)를 위해 콜백도 등록합니다.
        if LSM then
            LSM.RegisterCallback({}, "LibSharedMedia_Registered", function(_, mediatype)
                if mediatype == "sound" then
                    RebuildRuntimeMap()
                end
            end)
        end

        RebuildRuntimeMap()
        StartRecentPlayedCleanup()

        -- ★ UseAction hook: 액션바에서 아이템을 사용할 때 itemMap 조회 후 사운드 재생
        --   hooksecurefunc는 보안 함수에 읽기 전용 hook을 걸므로 타조(taint) 없이 안전.
        --   actionID → GetActionInfo() → "item", itemID 로 아이템 여부 판별.
        hooksecurefunc("UseAction", function(actionID, _, onSelf)
            local actionType, id = GetActionInfo(actionID)
            -- ★ 아이템은 "item"으로, 일부 아이템은 내부 스펠로 래핑돼 "spell"로 넘어옴.
            --   두 경우 모두 itemMap에서 조회 (RebuildItemMap에서 SpellID로도 등록해둠).
            if actionType ~= "item" and actionType ~= "spell" then return end
            local lookupID = tonumber(id)
            if not lookupID then return end
            local s = itemMap[lookupID]
            if not s then return end

            -- ★ 쿨다운 체크: 아이템이 쿨다운 중이면 실제 사용이 안 되므로 재생 차단
            --   GetActionCooldown → start, duration, enable
            --   duration > 0 이면 쿨다운 진행 중
            local start, duration, enable = GetActionCooldown(actionID)
            if duration and duration > 1.5 then return end  -- 1.5초 이상 쿨다운이면 차단 (GCD 제외)

            -- 중복 방지: 0.2초 이내 동일 아이템 재사용 차단
            local key = "item_" .. lookupID
            local now = GetTime()
            if recentPlayed[key] and (now - recentPlayed[key]) < 0.2 then return end
            recentPlayed[key] = now

            local resolvedPath = ResolveSoundPath(s.path)
            if not resolvedPath then return end

            local handle = PlaySoundFile(resolvedPath, s.channel)
            if not handle then
                handle = PlaySoundFile(resolvedPath, "Dialog")
                if not handle then
                    C_Timer.After(0.05, function()
                        PlaySoundFile(resolvedPath, "Dialog")
                    end)
                end
            end
        end)

        -- 경로 오류 알림 창 표시 (ADDON_LOADED 때 예약된 항목)
        -- C_Timer.After로 딜레이를 줘서 UI가 완전히 준비된 후 창을 띄움
        if #pendingWarnings > 0 then
            local captured = pendingWarnings
            pendingWarnings = {}
            print("|cffffcc00[Moonlight 스킬사운드]|r 디버그: pendingWarnings 항목 수 = " .. #captured)
            for i, w in ipairs(captured) do
                print("  [" .. i .. "] kind=" .. tostring(w.kind) .. " spellID=" .. tostring(w.spellID))
            end
            C_Timer.After(2, function()
                ShowPathWarningFrame(captured)
            end)
        end

    elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" and spellID then
            spellID = tonumber(spellID) or spellID
            if not castMap[spellID] and not channelMap[spellID] then
                PlaySkillSound(spellID, "instant")
            end
            ShowSkillImage(spellID)
            ShowSkillTimer(spellID)
        end

    elseif event == "UNIT_SPELLCAST_START" then
        local unit, _, spellID = ...
        if unit == "player" and spellID then
            spellID = tonumber(spellID) or spellID
            -- channelMap에 있는 스킬은 UNIT_SPELLCAST_CHANNEL_START에서 처리하므로 제외
            if not instantMap[spellID] and not channelMap[spellID] then
                PlaySkillSound(spellID, "cast")
            end
            ShowSkillImage(spellID)
            ShowSkillTimer(spellID)
        end

    elseif event == "UNIT_SPELLCAST_CHANNEL_START" then
        local unit, _, spellID = ...
        if unit == "player" and spellID then
            spellID = tonumber(spellID) or spellID
            PlaySkillSound(spellID, "channel")
            ShowSkillImage(spellID)
            ShowSkillTimer(spellID)
        end

    elseif event == "PLAYER_DEAD" then
        local ds = MoonlightSkillSoundDB and MoonlightSkillSoundDB[16] and MoonlightSkillSoundDB[16]["__death__"]
        if not ds or not ds.enabled then return end

        -- 중복 재생 방지: 2초 이내 재발동 차단
        local now = GetTime()
        if recentPlayed["__player_dead__"] and (now - recentPlayed["__player_dead__"]) < 2.0 then return end
        recentPlayed["__player_dead__"] = now

        -- 사운드 재생
        if ds.soundPath and ds.soundPath ~= "" then
            local resolvedPath = ResolveSoundPath(ds.soundPath)
            if resolvedPath then
                local ch = NormalizeChannel(ds.soundChannel)
                local handle = PlaySoundFile(resolvedPath, ch)
                if not handle then
                    handle = PlaySoundFile(resolvedPath, "Dialog")
                    if not handle then
                        C_Timer.After(0.05, function() PlaySoundFile(resolvedPath, "Dialog") end)
                    end
                end
            end
        end

        -- 이미지 플래시
        ShowSkillImage("__death__")

        -- 초시계
        ShowSkillTimer("__death__")
    end
end)
