EbonholdHub.BuildForm = {}

local UI = EbonholdHub.UI
local C  = UI.Colors
local ST = EbonholdHub.SiteTheme
local SW = EbonholdHub.SiteWidgets
local SC = ST and ST.C
local SL = ST and ST.Layout
local SIDEBAR_W   = SL and SL.SIDEBAR_W  or 300
local CLASS_ICON_W = SL and SL.CLASS_ICON or 44
local AD = EbonholdHub.AffixData
local AL = EbonholdHub.AffixLoadout
local AO = EbonholdHub.AffixOwnership
local EL = EbonholdHub.EquipmentLoadout
local IC = EbonholdHub.ItemCatalog
local T  = EbonholdHub.Talents

local COL_GAP = 8
local AFFIX_SLOT_SIZE = 40

local SITE_HEADER_H = 1
local SITE_HEADER_STATS_H = 88
local SITE_HEADER_ROW_STEP = 44
local SITE_HEADER_GAP_SM = 6
local SITE_HEADER_GAP = 8
local SITE_HEADER_BOTTOM_PAD = 8
local SITE_LOCKED_H = 80

local classChangeCallbacks = {}
local metaChangeCallbacks = {}

local function NotifyClassChange()
    for i = 1, #classChangeCallbacks do classChangeCallbacks[i]() end
end

local function NotifyMetaChange()
    for i = 1, #metaChangeCallbacks do metaChangeCallbacks[i]() end
end

function EbonholdHub.BuildForm.OnClassChanged(fn)
    classChangeCallbacks[#classChangeCallbacks + 1] = fn
end

function EbonholdHub.BuildForm.OnMetaChanged(fn)
    metaChangeCallbacks[#metaChangeCallbacks + 1] = fn
end

local CLASS_ORDER = {
    "WARRIOR","PALADIN","HUNTER","ROGUE","PRIEST",
    "DEATHKNIGHT","SHAMAN","MAGE","WARLOCK","DRUID",
}
local CLASS_DISPLAY = {
    WARRIOR     = "Warrior",
    PALADIN     = "Paladin",
    HUNTER      = "Hunter",
    ROGUE       = "Rogue",
    PRIEST      = "Priest",
    DEATHKNIGHT = "Death Knight",
    SHAMAN      = "Shaman",
    MAGE        = "Mage",
    WARLOCK     = "Warlock",
    DRUID       = "Druid",
}
local QUALITY_COLOR = {
    [0]="ffffff",[1]="19ff19",[2]="0066ff",[3]="cc66ff",[4]="ff8000",
}

local viewFrame
local descTabFrame
local sidebarHost = {}
local formBuildHeader
local formLockedCountLabel
local formUpdateDescLayout
local RefreshFormHeader
local RefreshFormLockedCount
local RefreshFormSidebar
local function LockedN()
    return EbonholdHub.Build.GetLockedSlotCount()
end

local function EmptyLockedState()
    return EbonholdHub.Build.EmptyLockedEchoes()
end

local state = {
    mode     = "create",
    id       = nil,
    title    = "",
    class    = nil,
    spec     = 1,
    comments = "",
    locked = EmptyLockedState(),
    settings  = nil,
    talents   = {},
    talentOrder = {},
}
function EbonholdHub.BuildForm.GetEditingClass()
    return state.class
end

function EbonholdHub.BuildForm.GetEditingSpec()
    return state.spec or 1
end
function EbonholdHub.BuildForm.GetEditingSettings()
    if not state.settings then
        state.settings = EbonholdHub.Build.DefaultSettings()
    end
    return state.settings
end

function EbonholdHub.BuildForm.GetEditingLockedEchoes()
    if not state.mode then return nil end
    return state.locked
end

function EbonholdHub.BuildForm.GetEditingTalents()
    state.talents = state.talents or {}
    return state.talents
end

function EbonholdHub.BuildForm.GetEditingTalentOrder()
    state.talentOrder = state.talentOrder or {}
    return state.talentOrder
end

local CLASS_TEXTURE = "Interface\\TargetingFrame\\UI-Classes-Circles"

local classDropdown, specDropdown, metaSpecLabel, metaPanelRef
local slotButtons = {}
local affixDollHolder
local affixSlotButtons = {}
local affixListChild, affixListScroll
local titleBox, commentsBox

-- Global single-install hook: shift-click links go into the comments editbox
-- when it is focused. Guarded so we never install twice.
local _linkHookInstalled = false

local function InstallLinkHook()
    if _linkHookInstalled then return end
    _linkHookInstalled = true
    if not ChatEdit_InsertLink then return end
    hooksecurefunc("ChatEdit_InsertLink", function(link)
        if not link then return end
        local focus = GetCurrentKeyBoardFocus()
        if focus and focus == commentsBox then
            commentsBox:Insert(link)
        end
    end)
end


local function SetClassIcon(tex, classToken)
    local coords = CLASS_ICON_TCOORDS[classToken]
    tex:SetTexture(CLASS_TEXTURE)
    if coords then tex:SetTexCoord(coords[1], coords[2], coords[3], coords[4]) end
end

local function BuildClassOptions()
    local opts = {}
    for _, token in ipairs(CLASS_ORDER) do
        opts[#opts + 1] = { text = CLASS_DISPLAY[token], value = token }
    end
    return opts
end

local function BuildSpecOptions(classToken)
    local opts = {}
    local specs = classToken and EbonholdHub.SpecData and EbonholdHub.SpecData[classToken] or {}
    for i, entry in ipairs(specs) do
        opts[#opts + 1] = { text = entry.name, value = i }
    end
    if #opts == 0 then
        for i = 1, 3 do
            opts[#opts + 1] = { text = "Spec " .. i, value = i }
        end
    end
    return opts
end

local function FindOptionIndex(options, value)
    for i, opt in ipairs(options) do
        if opt.value == value then return i end
    end
    return 1
end

local function RefreshClassSelection()
    if formBuildHeader and formBuildHeader._iconHolder and state.class and SW then
        SW.SetClassIcon(formBuildHeader._iconHolder._classIcon, state.class)
        local _, specIconPath = ST.SpecDisplay(state.class, state.spec)
        if specIconPath and formBuildHeader._iconHolder._specTex then
            formBuildHeader._iconHolder._specTex:SetTexture(specIconPath)
        end
    end
    if classDropdown then
        classDropdown:SetSelectedIndex(FindOptionIndex(BuildClassOptions(), state.class))
    end
end

local function MountSpecDropdown()
    if specDropdown then
        if specDropdown.Release then specDropdown:Release() end
        UI.CloseDropdown(specDropdown)
        specDropdown:Hide()
        specDropdown = nil
    end
    if not metaPanelRef or not metaSpecLabel or not state.class then return end

    local options = BuildSpecOptions(state.class)
    if state.spec > #options then state.spec = 1 end

    local availW = SIDEBAR_W - (SL.PAD + 6 + CLASS_ICON_W + 10) - SL.PAD
    local colW   = math.floor((availW - 6) / 2)
    specDropdown = UI.CreateDropdown(metaPanelRef, colW, options, FindOptionIndex(options, state.spec))
    specDropdown:SetPoint("TOPLEFT", metaSpecLabel, "BOTTOMLEFT", 0, -4)
    specDropdown.OnValueChanged = function(value)
        state.spec = value
        RefreshFormHeader()
        NotifyMetaChange()
    end
end

local function RefreshSpecDropdown()
    MountSpecDropdown()
end

local function BuildClassSpecDropdowns(parent)
    metaPanelRef = parent

    local titleHolder = parent._titleHolder
    -- titleHolder left = (PAD+6) + CLASS_ICON + 10 px from sidebar left
    -- titleHolder right = SIDEBAR_W - PAD
    local availW = SIDEBAR_W - (SL.PAD + 6 + CLASS_ICON_W + 10) - SL.PAD
    local colW   = math.floor((availW - 6) / 2)
    local rightX = colW + 6

    local classLabel = SW.Label(parent, "Class", 10, SC.textMuted, false, "semibold")
    classLabel:SetPoint("TOPLEFT", titleHolder, "BOTTOMLEFT", 0, -8)

    metaSpecLabel = SW.Label(parent, "Spec", 10, SC.textMuted, false, "semibold")
    metaSpecLabel:SetPoint("TOPLEFT", titleHolder, "BOTTOMLEFT", rightX, -8)

    local classOpts = BuildClassOptions()
    if not state.class and classOpts[1] then
        state.class = classOpts[1].value
    end

    classDropdown = UI.CreateDropdown(parent, colW, classOpts, FindOptionIndex(classOpts, state.class))
    classDropdown:SetPoint("TOPLEFT", classLabel, "BOTTOMLEFT", 0, -4)
    classDropdown.OnValueChanged = function(value)
        if state.class == value then return end
        state.class = value
        state.spec = 1
        RefreshClassSelection()
        RefreshFormHeader()
        MountSpecDropdown()
        NotifyClassChange()
        NotifyMetaChange()
    end

    MountSpecDropdown()
end


local function CreateSiteEditBox(parent, height, multi)
    local c = CreateFrame("Frame", nil, parent)
    c:SetHeight(height)
    SW.Fill(c, SC.statCellBg)
    SW.ThinBorder(c, SC.border, 1)

    local box = CreateFrame("EditBox", nil, c)
    box:SetPoint("TOPLEFT",     c, "TOPLEFT",     6,  -4)
    box:SetPoint("BOTTOMRIGHT", c, "BOTTOMRIGHT", -6,  4)
    box:SetAutoFocus(false)
    if multi then
        box:SetMultiLine(true)
        box:SetMaxLetters(0)
        box:EnableMouse(true)
        SW.SetFont(box, 12, "regular")
        box:SetTextColor(unpack(SC.text))
    else
        box:SetMaxLetters(40)
        SW.SetFont(box, 14, "semibold")
        box:SetTextColor(unpack(SC.text))
    end
    box:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    return box, c
end

local function BuildTitleField(parent, iconHolder)
    local holder = CreateFrame("Frame", nil, parent)
    holder:SetPoint("TOPLEFT", iconHolder, "TOPRIGHT", 10, -2)
    holder:SetPoint("RIGHT", parent, "RIGHT", -SL.PAD, 0)
    holder:SetHeight(24)
    parent._titleHolder = holder

    local box, boxHolder = CreateSiteEditBox(holder, 24, false)
    boxHolder:SetAllPoints(holder)
    titleBox = box
    box:SetScript("OnTextChanged", function()
        if formBuildHeader and state.class then
            local classColor = ST.CLASS_COLORS[state.class] or SC.text
            box:SetTextColor(unpack(classColor))
        end
    end)
    return holder
end

local function WireAffixSlotTooltip(btn, slotDef)
    btn:SetScript("OnEnter", function()
        GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
        GameTooltip:ClearLines()
        GameTooltip:AddLine(slotDef.label, 1, 0.82, 0)
        local entry = AL.GetSlot(slotDef.key)
        if entry and entry.name then
            local label = entry.rank and (entry.name .. " " .. AD.RankToRoman(entry.rank)) or entry.name
            GameTooltip:AddLine(label, 0.7, 0.9, 1)
            if AO then AO.AddTooltipLines(entry) end
            GameTooltip:AddLine("Right-click to clear", 0.5, 0.5, 0.5)
        else
            GameTooltip:AddLine("Click to assign an affix", 0.6, 0.6, 0.6)
        end
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
end

local function RefreshAffixSlotButton(btn, slotDef, snapshot)
    local entry = AL.GetSlot(slotDef.key)
    if entry and entry.name then
        btn.icon:SetTexture(AD.GetIcon(entry))
        btn.icon:SetVertexColor(1, 1, 1)
        if entry.rank then
            btn._rankText:SetText(AD.RankToRoman(entry.rank))
            btn._rankText:Show()
        else
            btn._rankText:Hide()
        end
        btn._empty:Hide()
        if AO then AO.ApplySlotBorder(btn, entry, snapshot) end
    else
        btn.icon:SetTexture(AD.GetSlotTexture(slotDef.invSlot))
        btn.icon:SetVertexColor(0.55, 0.55, 0.55)
        btn._rankText:Hide()
        btn._empty:Show()
        if AO then AO.ApplySlotBorder(btn, nil) end
    end
end

local function RefreshFormAffixList(forcedW)
    if not affixListChild then return end
    local snapshot = AO and AO.CollectSnapshot() or nil
    local listW = forcedW or (affixListScroll and affixListScroll:GetWidth())
    if not listW or listW <= 0 then listW = 200 end
    affixListChild:SetWidth(listW)

    if affixListChild._rows then
        for _, row in ipairs(affixListChild._rows) do
            row:Hide()
            row:SetParent(nil)
        end
    end
    affixListChild._rows = {}

    -- gearLoadout больше не нужен в строках (иконки убраны)
    local rowY = 0
    for _, slot in ipairs(AD.SLOTS) do
        local affix = AL.GetSlot(slot.key)
        local affixFilled = affix and affix.name
        local rankRoman = affix and affix.rank
            and (AD.RANK_ROMAN and AD.RANK_ROMAN[affix.rank] or AD.RankToRoman(affix.rank))
        local row = SW.CreateListRow(affixListChild, {
            showIcon       = false,
            slotLabel      = slot.label,
            name           = affixFilled and affix.name or "—",
            rankRoman      = rankRoman,
            height         = 28,
            slotLabelWidth = 60,
            nameWidth      = listW,
        })
        row:SetPoint("TOPLEFT", 0, -rowY)
        row:SetPoint("RIGHT", 0, 0)
        row:SetAlpha(affixFilled and 1 or 0.45)
        row:EnableMouse(true)
        row:SetScript("OnMouseDown", function()
            EbonholdHub.AffixPicker.Show(function(id, name, rank, affixId)
                AL.SetSlot(slot.key, { id = id, name = name, rank = rank, affixId = affixId })
                RefreshAffixSlots()
            end, slot.key, slot.label)
        end)
        row:SetScript("OnMouseUp", function(_, button)
            if button == "RightButton" then
                AL.SetSlot(slot.key, nil)
                RefreshAffixSlots()
            end
        end)
        row:SetScript("OnEnter", function()
            GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
            GameTooltip:ClearLines()
            GameTooltip:AddLine(slot.label, 1, 0.82, 0)
            if affixFilled then
                local label = affix.rank and (affix.name .. " " .. (rankRoman or "")) or affix.name
                GameTooltip:AddLine(label, 0.7, 0.9, 1)
                if AO then AO.AddTooltipLines(affix, snapshot) end
                GameTooltip:AddLine("Right-click to clear", 0.5, 0.5, 0.5)
            else
                GameTooltip:AddLine("Click to assign an affix", 0.6, 0.6, 0.6)
            end
            GameTooltip:Show()
        end)
        row:SetScript("OnLeave", function() GameTooltip:Hide() end)
        affixListChild._rows[#affixListChild._rows + 1] = row
        rowY = rowY + row:GetHeight()
    end
    affixListChild:SetHeight(math.max(1, rowY))
    if affixListScroll and affixListScroll.UpdateThumb then
        affixListScroll.UpdateThumb()
    end
end

local function RefreshAffixSlots()
    local snapshot = AO and AO.CollectSnapshot() or nil
    for _, slotDef in ipairs(AD.SLOTS) do
        local btn = affixSlotButtons[slotDef.key]
        if btn then RefreshAffixSlotButton(btn, slotDef, snapshot) end
    end
    if affixDollHolder then
        EbonholdHub.AffixDoll.Refresh(affixDollHolder)
    end
    RefreshFormAffixList()
end

local function CreateAffixSlotButton(doll, slotDef)
    local btn = UI.CreateIconButton(doll.dollFrame, AFFIX_SLOT_SIZE)

    local empty = btn:CreateTexture(nil, "OVERLAY")
    empty:SetAllPoints(btn.icon)
    empty:SetTexture(0, 0, 0, 0.25)
    btn._empty = empty

    local rankText = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rankText:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -2, 2)
    rankText:SetTextColor(1, 0.9, 0.4)
    btn._rankText = rankText

    WireAffixSlotTooltip(btn, slotDef)
    btn:SetScript("OnClick", function()
        EbonholdHub.AffixPicker.Show(function(id, name, rank, affixId)
            AL.SetSlot(slotDef.key, { id = id, name = name, rank = rank, affixId = affixId })
            RefreshAffixSlots()
        end, slotDef.key, slotDef.label)
    end)
    btn:SetScript("OnMouseUp", function(_, button)
        if button == "RightButton" then
            AL.SetSlot(slotDef.key, nil)
            RefreshAffixSlots()
        end
    end)

    EbonholdHub.AffixDoll.RegisterSlot(doll, slotDef.key, btn)
    return btn
end

local function BuildAffixColumn(panel)
    local LIST_GAP = 12
    local HEADER_H = 36  -- thin toolbar

    -- Toolbar: full-width thin panel, visually elevated bg + bottom separator
    local toolbar = CreateFrame("Frame", nil, panel)
    toolbar:SetPoint("TOPLEFT",  panel, "TOPLEFT",  0, 0)
    toolbar:SetPoint("TOPRIGHT", panel, "TOPRIGHT", 0, 0)
    toolbar:SetHeight(HEADER_H)
    SW.Fill(toolbar, SC.sidebarBg)
    local toolbarSep = toolbar:CreateTexture(nil, "ARTWORK")
    toolbarSep:SetTexture(ST.FLAT)
    toolbarSep:SetVertexColor(unpack(SC.border))
    toolbarSep:SetHeight(1)
    toolbarSep:SetPoint("BOTTOMLEFT",  toolbar, "BOTTOMLEFT",  0, 0)
    toolbarSep:SetPoint("BOTTOMRIGHT", toolbar, "BOTTOMRIGHT", 0, 0)

    local copyBtn = SW.CreateOutlineButton(toolbar, "Copy from character", 152)
    copyBtn:SetHeight(24)
    copyBtn:SetPoint("LEFT", toolbar, "LEFT", SL.PAD, 0)
    copyBtn:SetPoint("TOP",  toolbar, "TOP",  0, -6)
    copyBtn:SetScript("OnClick", function()
        local count = AL.CopyFromPlayer()
        RefreshAffixSlots()
        if EbonholdHub.Toast and EbonholdHub.Toast.Show then
            if count > 0 then
                EbonholdHub.Toast.Show("Copied " .. count .. " affix" .. (count == 1 and "" or "es") .. " from your gear.")
            else
                EbonholdHub.Toast.Show("No affixes found on your gear. Apply them at the Enchanted Anvil first.")
            end
        end
    end)

    local PAD = SL.PAD  -- content padding (toolbar itself is edge-to-edge)

    -- Left: doll column — starts below toolbar with content padding
    local dollColumn = CreateFrame("Frame", nil, panel)
    dollColumn:SetPoint("TOPLEFT",    panel, "TOPLEFT",    PAD, -HEADER_H)
    dollColumn:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", PAD, PAD)

    local dollShell = CreateFrame("Frame", nil, dollColumn)
    SW.Fill(dollShell, SC.statCellBg)
    SW.ThinBorder(dollShell, SC.border, 1)

    local insetPad = 8
    affixDollHolder = EbonholdHub.AffixDoll.Create(dollShell, { slotSize = AFFIX_SLOT_SIZE, showModel = true })
    affixDollHolder:SetPoint("TOPLEFT",     dollShell, "TOPLEFT",     insetPad, -insetPad)
    affixDollHolder:SetPoint("BOTTOMRIGHT", dollShell, "BOTTOMRIGHT", -insetPad, insetPad)

    -- Right: scrollable list — position and width set explicitly in SyncLayout
    local listScroll, listChild = SW.CreatePlainScroll(panel)
    listScroll:ClearAllPoints()
    listScroll:SetFrameLevel(dollColumn:GetFrameLevel() + 4)
    affixListScroll = listScroll
    affixListChild  = listChild

    -- SyncLayout: toolbar is full-width; content (doll+list) uses PAD insets
    local function SyncLayout()
        local panelW = panel:GetWidth()
        local panelH = panel:GetHeight()
        if not panelW or panelW <= LIST_GAP or not panelH or panelH <= 0 then return end

        local availW = panelW - PAD * 2            -- content width inside padding
        local halfW  = math.floor((availW - LIST_GAP) / 2)
        local listX  = PAD + halfW + LIST_GAP      -- absolute x for list start
        local listW  = availW - halfW - LIST_GAP   -- list width

        -- Doll column
        dollColumn:SetWidth(halfW)
        dollShell:ClearAllPoints()
        dollShell:SetAllPoints(dollColumn)
        if affixDollHolder and affixDollHolder.Layout then
            affixDollHolder:Layout()
        end

        -- Scroll: right side, below toolbar, with right-side PAD
        listScroll:ClearAllPoints()
        listScroll:SetPoint("TOPLEFT",    panel, "TOPLEFT",    listX, -HEADER_H)
        listScroll:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", listX, PAD)
        listScroll:SetWidth(listW)

        RefreshFormAffixList(listW)
    end
    panel:SetScript("OnSizeChanged", SyncLayout)

    affixSlotButtons = {}
    for _, slotDef in ipairs(AD.SLOTS) do
        affixSlotButtons[slotDef.key] = CreateAffixSlotButton(affixDollHolder, slotDef)
    end
    affixDollHolder:Layout()
end

local function BuildLockedSlots(parent)
    local lockedN = LockedN()
    local lockedRow = CreateFrame("Frame", nil, parent)
    lockedRow:SetPoint("TOPLEFT", SL.PAD, parent._contentTop)
    lockedRow:SetSize(SIDEBAR_W - SL.PAD * 2, SL.ICON_LOCKED)
    local ETR = EbonholdHub.EchoTableRows
    slotButtons = {}
    for i = 1, lockedN do
        local btn = ETR.CreateLockedSlot(lockedRow, SL.ICON_LOCKED)
        btn:SetPoint("LEFT", (i - 1) * (SL.ICON_LOCKED + SL.ICON_GAP), 0)
        btn:SetScript("OnClick", function(_, button)
            if button == "RightButton" then
                state.locked[i] = nil
                ETR.ClearLockedSlot(btn)
                RefreshFormLockedCount()
                return
            end
            local allList = EbonholdHub.EchoTableRows.BuildAllQualitiesList()
            EbonholdHub.EchoPicker.Show(function(spellId, quality, name)
                state.locked[i] = spellId
                ETR.SetLockedSlot(btn, spellId, quality)
                RefreshFormLockedCount()
            end, allList)
        end)
        slotButtons[i] = btn
    end
end

local descriptionPlaceholder

local function RefreshDescriptionPlaceholder()
    if not descriptionPlaceholder or not commentsBox then return end
    if commentsBox:HasFocus() then
        descriptionPlaceholder:Hide()
        return
    end
    if (commentsBox:GetText() or "") == "" then
        descriptionPlaceholder:Show()
    else
        descriptionPlaceholder:Hide()
    end
end

local function BuildDescriptionField(parent)
    local insertBtn = SW.CreateOutlineButton(parent, "+ Echo link", 90)
    insertBtn:SetSize(90, 20)
    insertBtn:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -SL.PAD, -SL.PAD + 2)
    insertBtn:SetScript("OnClick", function()
        EbonholdHub.EchoPicker.Show(function(spellId, quality, name)
            local color = QUALITY_COLOR[quality] or "ffffff"
            local link  = "|cff" .. color .. "|Hecho:" .. spellId .. "|h[" .. name .. "]|h|r"
            if commentsBox:HasFocus() then
                commentsBox:Insert(link)
            else
                commentsBox:SetText((commentsBox:GetText() or "") .. link)
            end
        end)
    end)
    insertBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:ClearLines()
        GameTooltip:AddLine("Insert Echo Link", 1, 0.82, 0, 1)
        GameTooltip:AddLine("Inserts a clickable echo reference into the description.", 0.8, 0.8, 0.8, 1)
        GameTooltip:Show()
    end)
    insertBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    local inputArea = CreateFrame("Frame", nil, parent)
    inputArea:SetPoint("TOPLEFT", SL.PAD, parent._contentTop)
    inputArea:SetPoint("BOTTOMRIGHT", -SL.PAD, SL.PAD)
    SW.Fill(inputArea, SC.statCellBg)
    local inputBorder = SW.ThinBorder(inputArea, SC.border, 1)

    local function SetInputBorderFocused(focused)
        local col = focused and SC.accent or SC.border
        SW.SetBorderColor(inputBorder, col)
    end

    local descScroll = CreateFrame("ScrollFrame", nil, inputArea)
    descScroll:SetPoint("TOPLEFT", inputArea, "TOPLEFT", 4, -4)
    descScroll:SetPoint("BOTTOMRIGHT", inputArea, "BOTTOMRIGHT", -14, 4)

    local descScrollChild = CreateFrame("Frame", nil, descScroll)
    descScrollChild:SetWidth(280)
    descScrollChild:SetHeight(1)
    descScroll:SetScrollChild(descScrollChild)

    local track = CreateFrame("Frame", nil, inputArea)
    track:SetPoint("TOPRIGHT", inputArea, "TOPRIGHT", -3, -4)
    track:SetPoint("BOTTOMRIGHT", inputArea, "BOTTOMRIGHT", -3, 4)
    track:SetWidth(6)
    SW.Fill(track, SC.border)

    local thumb = CreateFrame("Frame", nil, track)
    thumb:SetWidth(6)
    thumb:SetPoint("TOP", track, "TOP", 0, 0)
    SW.Fill(thumb, SC.accent)

    local function UpdateDescThumb()
        local range = descScrollChild:GetHeight() - descScroll:GetHeight()
        if range <= 0 then
            thumb:SetHeight(track:GetHeight())
            thumb:SetPoint("TOP", track, "TOP", 0, 0)
            return
        end
        local trackH = track:GetHeight()
        local thumbH = math.max(20, trackH * (descScroll:GetHeight() / descScrollChild:GetHeight()))
        thumb:SetHeight(thumbH)
        local scrollPos = descScroll:GetVerticalScroll()
        local offset = (scrollPos / range) * (trackH - thumbH)
        thumb:SetPoint("TOP", track, "TOP", 0, -offset)
    end

    descScroll:EnableMouseWheel(true)
    descScroll:SetScript("OnMouseWheel", function(self, delta)
        local range = descScrollChild:GetHeight() - self:GetHeight()
        if range <= 0 then return end
        local newPos = self:GetVerticalScroll() - delta * 30
        newPos = math.max(0, math.min(range, newPos))
        self:SetVerticalScroll(newPos)
        UpdateDescThumb()
    end)
    descScroll:SetScript("OnScrollRangeChanged", UpdateDescThumb)

    local box = CreateFrame("EditBox", nil, descScrollChild)
    box:SetPoint("TOPLEFT", descScrollChild, "TOPLEFT", 4, -4)
    box:SetMultiLine(true)
    box:SetMaxLetters(0)
    box:SetAutoFocus(false)
    SW.SetFont(box, 12, "regular")
    box:SetTextColor(unpack(SC.textDim))
    box:SetWidth(260)
    box:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    commentsBox = box

    local descMeasure = box:CreateFontString(nil, "OVERLAY")
    SW.SetFont(descMeasure, 12, "regular")
    descMeasure:SetWidth(250)
    descMeasure:Hide()

    local hint = box:CreateFontString(nil, "OVERLAY")
    hint:SetPoint("TOPLEFT", box, "TOPLEFT", 0, 0)
    hint:SetPoint("TOPRIGHT", box, "TOPRIGHT", 0, 0)
    hint:SetJustifyH("LEFT")
    hint:SetJustifyV("TOP")
    SW.SetFont(hint, 12, "regular")
    hint:SetTextColor(unpack(SC.textMuted))
    hint:SetText("Explain your build strategy here.")
    descriptionPlaceholder = hint

    local function UpdateDescLayout()
        local scrollW = descScroll:GetWidth()
        if scrollW and scrollW > 24 then
            local textW = scrollW - 16
            box:SetWidth(textW)
            descMeasure:SetWidth(textW - 8)
        end

        descMeasure:SetText(box:GetText() or "")
        local textHeight = descMeasure:GetStringHeight() or 0
        local visibleH = descScroll:GetHeight()
        local contentH = math.max(textHeight + 16, visibleH)
        box:SetHeight(contentH)
        descScrollChild:SetHeight(contentH)
        UpdateDescThumb()
    end

    local function ScrollToCursor()
        local cursorByte = box:GetCursorPosition() or 0
        local textBefore = (box:GetText() or ""):sub(1, cursorByte)
        descMeasure:SetText(textBefore)
        local cursorY = descMeasure:GetStringHeight() or 0
        local visibleH = descScroll:GetHeight()
        local maxScroll = math.max(0, descScrollChild:GetHeight() - visibleH)
        local scrollTop = descScroll:GetVerticalScroll()
        local cursorScreenY = cursorY - scrollTop

        if cursorScreenY > visibleH - 20 then
            descScroll:SetVerticalScroll(math.min(maxScroll, cursorY - visibleH + 20))
        elseif cursorScreenY < 4 then
            descScroll:SetVerticalScroll(math.max(0, cursorY - 20))
        end
        UpdateDescThumb()
    end

    box:SetScript("OnEditFocusGained", function()
        descriptionPlaceholder:Hide()
        SetInputBorderFocused(true)
    end)
    box:SetScript("OnEditFocusLost", function(self)
        SetInputBorderFocused(false)
        if (self:GetText() or "") == "" then descriptionPlaceholder:Show() end
    end)
    box:SetScript("OnTextChanged", function(self)
        if self:HasFocus() then
            descriptionPlaceholder:Hide()
        else
            if (self:GetText() or "") == "" then
                descriptionPlaceholder:Show()
            else
                descriptionPlaceholder:Hide()
            end
        end
        UpdateDescLayout()
        if self:HasFocus() then ScrollToCursor() end
    end)

    descScroll:SetScript("OnSizeChanged", function()
        UpdateDescLayout()
    end)

    formUpdateDescLayout = UpdateDescLayout
end


local function CollectFromInputs()
    if titleBox then
        state.title = titleBox:GetText() or ""
    end
    if commentsBox then
        state.comments = commentsBox:GetText() or ""
    end
end

local function BuildPayload()
    local gearPayload = EbonholdHub.EquipmentLoadout and EbonholdHub.EquipmentLoadout.PendingPayload() or {}
    return {
        title = state.title,
        class = state.class,
        spec = state.spec,
        comments = state.comments,
        lockedEchoes = { unpack(state.locked) },
        settings = state.settings,
        isPublic = false,
        affixLoadout = EbonholdHubDB.pendingAffixLoadout,
        equipmentLoadout = gearPayload.equipmentLoadout,
        equipmentSets = gearPayload.equipmentSets,
        equipmentSetIndex = gearPayload.equipmentSetIndex,
        talents = EbonholdHub.Talents and EbonholdHub.Talents.ClonePlan(state.talents) or {},
        talentOrder = EbonholdHub.Talents and EbonholdHub.Talents.CloneOrder(state.talentOrder) or {},
    }
end

local function ReinitPendingFromBuild(build)
    EbonholdHubDB._isEditingBuild = true
    EbonholdHubDB.pendingAffixLoadout = EbonholdHub.AffixLoadout.Clone(build.affixLoadout)
    EbonholdHub.EquipmentLoadout.InitPendingFromBuild(build)
    -- Copy tier data into pending buffers for editing
    if EbonholdHub.TierData then
        EbonholdHub.TierData.BeginEdit(build)
    end
end

local function PersistDraft()
    if not EbonholdHubDB._isEditingBuild then return end
    CollectFromInputs()

    if state.mode == "create" then
        if (state.title or "") == "" then return end
        local b = EbonholdHub.Build.Create(BuildPayload())
        state.mode = "edit"
        state.id = b.id
        EbonholdHub.Build.SetActive(b.id)
        ReinitPendingFromBuild(b)
        if EbonholdHub.BuildTabs and EbonholdHub.BuildTabs.OnDraftPersisted then
            EbonholdHub.BuildTabs.OnDraftPersisted()
        end
        if EbonholdHub.BuildList and EbonholdHub.BuildList.Refresh then
            EbonholdHub.BuildList.Refresh()
        end
        return
    end

    if not state.id then return end
    local saved = EbonholdHub.Build.Save(state.id, BuildPayload())
    if saved and saved.id ~= state.id then
        state.id = saved.id
        if EbonholdHub.BuildTabs and EbonholdHub.BuildTabs.OnDraftPersisted then
            EbonholdHub.BuildTabs.OnDraftPersisted()
        end
    end
end

local function OnSave()
    CollectFromInputs()
    if state.title == "" then return end
    PersistDraft()
    EbonholdHubDB._isEditingBuild = nil
    EbonholdHubDB.pendingAffixLoadout = nil
    EbonholdHub.EquipmentLoadout.ClearPending()
    if EbonholdHub.BuildList and EbonholdHub.BuildList.Refresh then
        EbonholdHub.BuildList.Refresh()
    end
    if EbonholdHub.BuildTabs and EbonholdHub.BuildTabs.OnBuildSaved then
        EbonholdHub.BuildTabs.OnBuildSaved()
    end
    if EbonholdHub.BuildTabs and EbonholdHub.BuildTabs.EnableEchoesTab then
        EbonholdHub.BuildTabs.EnableEchoesTab()
    end
    local active = EbonholdHub.Build.GetActive()
    if active then
        EbonholdHub.ViewRouter.Show("buildOverview", { build = active })
    end
end

local LoadFromBuild, ApplyStateToInputs

local function OnCancel()
    EbonholdHubDB._isEditingBuild = nil
    EbonholdHubDB.pendingAffixLoadout = nil
    EbonholdHub.EquipmentLoadout.ClearPending()
    if EbonholdHub.TierData then
        EbonholdHub.TierData.ClearPending()
    end

    -- Revert state and inputs to original build so dirty edits don't survive Cancel
    if state.mode == "edit" and state.id then
        local build = EbonholdHub.Build.Get(state.id)
        if build then
            LoadFromBuild(build)
            ApplyStateToInputs()
        end
    end

    local active = EbonholdHub.Build.GetActive()
    if active then
        EbonholdHub.ViewRouter.Show("buildOverview", { build = active })
    else
        EbonholdHub.ViewRouter.Show("welcome")
    end
end

local function OnDelete()
    if not state.id then return end
    EbonholdHubDB._isEditingBuild = nil
    EbonholdHubDB.pendingAffixLoadout = nil
    EbonholdHub.EquipmentLoadout.ClearPending()
    EbonholdHub.Build.Delete(state.id)
    if EbonholdHub.BuildList and EbonholdHub.BuildList.Refresh then
        EbonholdHub.BuildList.Refresh()
    end
    local active = EbonholdHub.Build.GetActive()
    if active then
        EbonholdHub.ViewRouter.Show("buildTabs", { mode = "edit", build = active })
    else
        EbonholdHub.ViewRouter.Show("buildTabs", { mode = "create" })
    end
end

EbonholdHub.BuildForm.Save   = OnSave
EbonholdHub.BuildForm.Cancel = OnCancel
EbonholdHub.BuildForm.Delete = OnDelete


ApplyStateToInputs = function()
    titleBox:SetText(state.title or "")
    commentsBox:SetText(state.comments or "")
    RefreshDescriptionPlaceholder()
    RefreshClassSelection()
    RefreshSpecDropdown()
    EbonholdHub.BuildForm.RefreshLockedSlots()
    RefreshAffixSlots()
    RefreshFormSidebar()
end

function EbonholdHub.BuildForm.RefreshLockedSlots()
    for i = 1, LockedN() do
        local id = state.locked[i]
        local btn = slotButtons[i]
        if not btn then return end
        if id then
            local data = ProjectEbonhold.PerkDatabase[id]
            local quality = data and data.quality or 0
            EbonholdHub.EchoTableRows.SetLockedSlot(btn, id, quality)
        else
            EbonholdHub.EchoTableRows.ClearLockedSlot(btn)
        end
    end
    RefreshFormLockedCount()
end

local function CloneSettings(src)
    local dst = EbonholdHub.Build.DefaultSettings()
    if not src then return dst end
    for k, v in pairs(src) do
        if type(v) == "table" then
            dst[k] = dst[k] or {}
            for k2, v2 in pairs(v) do dst[k][k2] = v2 end
        else
            dst[k] = v
        end
    end
    return dst
end

LoadFromBuild = function(build)
    state.mode     = "edit"
    state.id       = build.id
    state.title    = build.title    or ""
    state.class    = build.class
    state.spec     = build.spec     or 1
    state.comments = build.comments or ""
    state.settings = CloneSettings(build.settings)
    state.talents = EbonholdHub.Talents and EbonholdHub.Talents.ClonePlan(build.talents) or {}
    state.talentOrder = EbonholdHub.Talents and EbonholdHub.Talents.CloneOrder(build.talentOrder) or {}
    state.locked = EbonholdHub.Build.NormalizeLockedEchoes(build.lockedEchoes)
    EbonholdHubDB._isEditingBuild = true
    EbonholdHubDB.pendingAffixLoadout = EbonholdHub.AffixLoadout.Clone(build.affixLoadout)
    EbonholdHub.EquipmentLoadout.InitPendingFromBuild(build)
    -- Clear any stale pending tier state from previous sessions.
    -- Without this, a leftover pendingTiers={} from a crash/cancel would
    -- override build.echoTiers in GetTiers(), showing empty tiers and then
    -- wiping the real data when Build.Save calls CommitEdit.
    if EbonholdHub.TierData then
        EbonholdHub.TierData.ClearPending()
    end
end

local function LoadDefaults()
    state.mode     = "create"
    state.id       = nil
    state.title    = ""
    state.class    = EbonholdHub.Build.PlayerClassToken()
    state.spec     = EbonholdHub.Build.PlayerTopTalentTab()
    state.comments = ""
    state.settings = EbonholdHub.Build.DefaultSettings()
    state.talents = {}
    state.talentOrder = {}
    state.locked = EmptyLockedState()
    EbonholdHubDB._isEditingBuild = true
    EbonholdHubDB.pendingAffixLoadout = {}
    EbonholdHub.EquipmentLoadout.InitPendingEmpty()
    -- Clear stale pending tier state so new builds always start clean.
    if EbonholdHub.TierData then
        EbonholdHub.TierData.ClearPending()
    end
end


local function TargetMatchesState(context)
    if not EbonholdHubDB._isEditingBuild then return false end
    if context.mode == "edit" and context.build then
        return state.mode == "edit" and state.id == context.build.id
    end
    if context.mode == "create" then
        return state.mode == "create"
    end
    return false
end

function EbonholdHub.BuildForm.Mount(container, context)
    viewFrame:SetParent(container)
    viewFrame:ClearAllPoints()
    viewFrame:SetAllPoints(container)

    context = context or {}
    local keepState = TargetMatchesState(context)
    if not keepState then
        if context.mode == "edit" and context.build then
            LoadFromBuild(context.build)
        else
            LoadDefaults()
        end
    end

    ApplyStateToInputs()
    NotifyClassChange()
    viewFrame:Show()
end

function EbonholdHub.BuildForm.Unmount()
    CollectFromInputs()
    if UI.CloseAllDropdowns then UI.CloseAllDropdowns() end
    if viewFrame then viewFrame:Hide() end
end

EbonholdHub.BuildForm.PersistDraft = PersistDraft

local function ResizeFormHeader()
    if not formBuildHeader or not formBuildHeader._statsGrid then return end
    local statsGrid = formBuildHeader._statsGrid
    local bottom = statsGrid:GetBottom()
    local top = formBuildHeader:GetTop()
    if bottom and top then
        formBuildHeader:SetHeight((top - bottom) + SITE_HEADER_BOTTOM_PAD)
        formBuildHeader:SetScript("OnUpdate", nil)
    end
end

local function ScheduleResizeFormHeader()
    if not formBuildHeader then return end
    ResizeFormHeader()
    if formBuildHeader:IsShown() and formBuildHeader._statsGrid
       and not formBuildHeader._statsGrid:GetBottom() then
        formBuildHeader:SetScript("OnUpdate", function(self)
            ResizeFormHeader()
        end)
    end
end

RefreshFormHeader = function()
    if not formBuildHeader or not ST then return end
    local classToken = state.class or "PRIEST"
    local classColor = ST.CLASS_COLORS[classToken] or SC.text
    if titleBox then
        titleBox:SetTextColor(unpack(classColor))
    end
    if formBuildHeader._grad then
        ST.ApplyClassBackgroundGradient(formBuildHeader._grad, classColor, {
            SC.sidebarBg[1], SC.sidebarBg[2], SC.sidebarBg[3],
        })
    end
    if formBuildHeader._classStripe then
        ST.ApplyClassStripe(formBuildHeader._classStripe, classColor)
    end
    if formBuildHeader._modeLabel then
        formBuildHeader._modeLabel:SetText(state.mode == "create" and "New build" or "Editing")
    end
    RefreshClassSelection()
    ScheduleResizeFormHeader()
end

local function CountEditTierEchoes()
    local tiers = EbonholdHubDB and EbonholdHubDB.pendingTiers
    if not tiers and state.id then
        local build = EbonholdHub.Build.Get(state.id)
        tiers = build and build.echoTiers
    end
    if not tiers then return 0 end
    local count = 0
    for _, tierId in pairs(tiers) do
        if tierId == "S" or tierId == "A" or tierId == "B" or tierId == "F" then
            count = count + 1
        end
    end
    return count
end

local function RefreshFormStats()
    if not sidebarHost._statLabels then return end
    local labels = sidebarHost._statLabels
    local affixLoadout = EbonholdHubDB.pendingAffixLoadout or {}
    local affixFilled = AL.CountFilled(affixLoadout)
    local affixTotal = #AD.SLOTS
    if labels.affixes then labels.affixes:SetText(affixFilled .. "/" .. affixTotal) end

    local gearPayload = EL and EL.PendingPayload() or {}
    local gearFilled = EL and EL.CountFilled(gearPayload.equipmentLoadout) or 0
    if labels.gear then labels.gear:SetText(tostring(gearFilled)) end

    local talentPts = T and T.TotalPoints(state.talents or {}) or 0
    if labels.talents then labels.talents:SetText(tostring(talentPts)) end

    if labels.tiers then labels.tiers:SetText(tostring(CountEditTierEchoes())) end
end

RefreshFormLockedCount = function()
    if not formLockedCountLabel then return end
    local lockedN = LockedN()
    local filled = 0
    for i = 1, lockedN do
        if state.locked[i] then filled = filled + 1 end
    end
    formLockedCountLabel:SetText(filled .. "/" .. lockedN)
end

RefreshFormSidebar = function()
    RefreshFormHeader()
    RefreshFormStats()
    RefreshFormLockedCount()
    if formUpdateDescLayout then formUpdateDescLayout() end
end

local function BuildEditSidebar(parent, host)
    local sidebar = CreateFrame("Frame", nil, parent)
    sidebar:SetWidth(SIDEBAR_W)
    sidebar:SetPoint("TOPLEFT", 0, 0)
    sidebar:SetPoint("BOTTOMLEFT", 0, 0)
    SW.Fill(sidebar, SC.sidebarBg)

    formBuildHeader = SW.CreateBuildHeader(sidebar, {
        class = "PRIEST",
        spec  = 1,
        title = "Build",
        height = SITE_HEADER_H,
    })
    if formBuildHeader._title then formBuildHeader._title:Hide() end
    if formBuildHeader._subtitle then formBuildHeader._subtitle:Hide() end

    BuildTitleField(formBuildHeader, formBuildHeader._iconHolder)
    BuildClassSpecDropdowns(formBuildHeader)

    formBuildHeader._modeLabel = SW.Label(formBuildHeader, "Editing", 11, SC.textMuted)
    formBuildHeader._modeLabel:SetPoint("TOPLEFT", classDropdown, "BOTTOMLEFT", 0, -SITE_HEADER_GAP_SM)

    local cellW = (SIDEBAR_W - SL.PAD * 2 - 6) / 2
    local statsGrid = CreateFrame("Frame", nil, formBuildHeader)
    statsGrid:SetHeight(SITE_HEADER_STATS_H)
    statsGrid:SetPoint("TOP", formBuildHeader._modeLabel, "BOTTOM", 0, -SITE_HEADER_GAP_SM)
    statsGrid:SetPoint("LEFT", formBuildHeader, "LEFT", SL.PAD, 0)
    statsGrid:SetPoint("RIGHT", formBuildHeader, "RIGHT", -SL.PAD, 0)
    local statDefs = {
        { key = "affixes", label = "Affixes", value = "0/0" },
        { key = "gear",    label = "Gear",    value = "0" },
        { key = "talents", label = "Talents", value = "0" },
        { key = "tiers",   label = "Tiers",   value = "0" },
    }
    local statLabels = {}
    for i, def in ipairs(statDefs) do
        local col = (i - 1) % 2
        local row = math.floor((i - 1) / 2)
        local cell = SW.CreateStatCell(statsGrid, def.label, def.value)
        cell:SetWidth(cellW)
        cell:SetPoint("TOPLEFT", col * (cellW + 6), -row * SITE_HEADER_ROW_STEP)
        statLabels[def.key] = cell._valueLabel
    end
    host._statLabels = statLabels
    formBuildHeader._statsGrid = statsGrid
    ScheduleResizeFormHeader()

    local sideHolder = CreateFrame("Frame", nil, sidebar)
    sideHolder:SetPoint("TOPLEFT", formBuildHeader, "BOTTOMLEFT", 0, 0)
    sideHolder:SetPoint("BOTTOMRIGHT", 0, 0)
    local sideScroll, sideChild = SW.CreatePlainScroll(sideHolder, SIDEBAR_W)
    sideChild:SetWidth(SIDEBAR_W)

    local blockY = 0
    local lockedN = LockedN()
    local lockedBlock = SW.CreateSidebarBlock(sideChild, "Locked echoes")
    lockedBlock:SetPoint("TOPLEFT", 0, blockY)
    lockedBlock:SetHeight(SITE_LOCKED_H)
    formLockedCountLabel = SW.Label(lockedBlock, "0/" .. lockedN, 10, SC.textMuted)
    formLockedCountLabel:SetPoint("TOPRIGHT", -SL.PAD, -SL.PAD)
    BuildLockedSlots(lockedBlock)
    blockY = blockY - SITE_LOCKED_H

    sideChild:SetHeight(math.abs(blockY) + SL.PAD)

    return sidebar
end

local function BuildViewFrame()
    local f = CreateFrame("Frame", nil, UIParent)
    SW.Fill(f, SC.mainBg)
    -- affixPanel fills the full frame; toolbar spans edge-to-edge
    -- content padding (SL.PAD) is handled inside BuildAffixColumn
    local affixPanel = CreateFrame("Frame", nil, f)
    affixPanel:SetAllPoints(f)
    BuildAffixColumn(affixPanel)
    return f
end

function EbonholdHub.BuildForm.InitSidebar(parent)
    if not parent or formBuildHeader then return end
    BuildEditSidebar(parent, sidebarHost)
end

EbonholdHub.BuildForm.RefreshSidebar = RefreshFormSidebar

function EbonholdHub.BuildForm.MountDescriptionTab(container)
    descTabFrame:SetParent(container)
    descTabFrame:ClearAllPoints()
    descTabFrame:SetAllPoints(container)
    descTabFrame:Show()
    if formUpdateDescLayout then formUpdateDescLayout() end
end

function EbonholdHub.BuildForm.UnmountDescriptionTab()
    if descTabFrame then descTabFrame:Hide() end
end

function EbonholdHub.BuildForm.Init()
    viewFrame = BuildViewFrame()
    viewFrame:Hide()

    local descHost = CreateFrame("Frame", nil, UIParent)
    descHost._contentTop = -SL.PAD
    SW.Fill(descHost, SC.mainBg)
    BuildDescriptionField(descHost)
    descTabFrame = descHost
    descTabFrame:Hide()

    InstallLinkHook()
    if AO then
        AO.RegisterRefresh(function()
            if viewFrame and viewFrame:IsShown() then
                RefreshAffixSlots()
            end
        end)
    end
end
