-- RateMyHouse 1.0.2 Library / My Books module.
-- Fourteen books, sixteen cover colors, and one hundred independently saved
-- pages with automatic overflow distribution between pages.
local Books={}
_G.RateMyHouseBooks=Books

Books.MAX_BOOKS=14
Books.MAX_COVERS=15
Books.MAX_PAGES=100
-- A page must fit both the narrower editor and the Library reader without
-- client-dependent text measurement. The byte/line caps prevent ellipses and
-- bottom overflow at every supported UI scale.
Books.MAX_PAGE_TEXT=500
Books.MAX_PAGE_LINES=15
Books.MAX_TITLE_BYTES=48
Books.MAX_AUTHOR_BYTES=36
Books.MAX_FEATURE_LABEL_BYTES=32
Books.CHUNK_BYTES=140

local COVER_TEXTURES={}
for index=1,Books.MAX_COVERS do
    COVER_TEXTURES[index]="Interface\\AddOns\\RateMyHouse\\Media\\book_cover_"..index..".tga"
end
local DEFAULT_BOOK_ICON=0
local BOOK_FONT="Fonts\\ARIALN.TTF"
local COVER_COLORS={
    {0.55,0.03,0.05},{0.31,0.02,0.04},{0.03,0.13,0.48},{0.02,0.27,0.11},
    {0.24,0.04,0.42},{0.62,0.17,0.02},{0.00,0.37,0.39},{0.68,0.12,0.34},
    {0.80,0.70,0.48},{0.03,0.03,0.04},{0.62,0.37,0.02},{0.00,0.43,0.68},
    {0.50,0.48,0.68},{0.34,0.12,0.035},
    {0.22,0.22,0.24},
}

local FALLBACK_ICONS={
    134400,134414,134419,134423,134428,134430,134434,134435,134437,134440,
    134442,134447,134452,134455,134459,134462,134466,134469,134473,134478,
    135129,135130,135149,135152,135154,135162,135164,135166,135168,135172,
}

local function TruncateBytes(text,maxBytes)
    text=tostring(text or "")
    if #text<=maxBytes then return text end
    local cut=maxBytes
    while cut>0 do
        local byte=string.byte(text,cut)
        if not byte then break end
        if byte<128 or byte>=192 then
            local length=byte>=240 and 4 or (byte>=224 and 3 or (byte>=192 and 2 or 1))
            if cut+length-1>maxBytes then cut=cut-1 end
            break
        end
        cut=cut-1
    end
    return string.sub(text,1,math.max(0,cut))
end

local function CleanField(text,maxBytes)
    text=tostring(text or ""):gsub("[%c]"," "):gsub("%s+"," ")
    text=text:match("^%s*(.-)%s*$") or text
    return TruncateBytes(text,maxBytes)
end

local function SanitizeBookText(text)
    text=tostring(text or ""):gsub("\r\n","\n"):gsub("\r","\n"):gsub("\t","    ")
    text=text:gsub("[%z\1-\8\11\12\14-\31\127]","")
    return text
end

local function SplitSafePage(text,maxBytes)
    text=SanitizeBookText(text)
    local head=TruncateBytes(text,maxBytes or Books.MAX_PAGE_TEXT)
    local searchFrom,newlines=1,0
    while true do
        local position=string.find(head,"\n",searchFrom,true)
        if not position then break end
        newlines=newlines+1
        if newlines>=Books.MAX_PAGE_LINES then
            return string.sub(head,1,position-1),string.sub(text,position+1)
        end
        searchFrom=position+1
    end
    return head,string.sub(text,#head+1)
end

local function CleanPage(text)
    local head=SplitSafePage(text,Books.MAX_PAGE_TEXT)
    return head
end

local function CleanBookPaste(text,startPage)
    local pagesLeft=math.max(1,Books.MAX_PAGES-math.max(1,tonumber(startPage) or 1)+1)
    return TruncateBytes(SanitizeBookText(text),Books.MAX_PAGE_TEXT*pagesLeft)
end

local function Clamp(value,low,high)
    value=tonumber(value) or low
    return math.max(low,math.min(high,value))
end

local function TextureValue(value)
    local number=tonumber(value)
    if number and number>0 then return math.floor(number) end
    local text=CleanField(value,90)
    if text=="" or text=="0" then return nil end
    return text
end

function Books.GetBookDisplayTexture(book)
    if type(book)~="table" then return nil end
    local cover=math.floor(Clamp(book.cover or 1,1,Books.MAX_COVERS))
    local iconTexture=TextureValue(book.icon)
    if cover==Books.MAX_COVERS and iconTexture then
        return iconTexture
    end
    return COVER_TEXTURES[cover]
end

local function NewBook(index,cover)
    local pages={}
    for pageIndex=1,Books.MAX_PAGES do pages[pageIndex]="" end
    return {
        cover=math.floor(Clamp(cover or index,1,Books.MAX_COVERS)),
        icon=DEFAULT_BOOK_ICON,
        title="",
        author="",
        revision=0,
        pageFormatVersion=2,
        pages=pages,
    }
end

local function NormalizeBook(source,index)
    if type(source)~="table" then return nil end
    local book=NewBook(index,source.cover)
    book.icon=TextureValue(source.icon) or DEFAULT_BOOK_ICON
    book.title=CleanField(source.title,Books.MAX_TITLE_BYTES)
    book.author=CleanField(source.author,Books.MAX_AUTHOR_BYTES)
    book.revision=tonumber(source.revision) or 0
    book.pageFormatVersion=2
    book._chunks=source._chunks
    local allText={}
    for pageIndex=1,Books.MAX_PAGES do
        local value=type(source.pages)=="table" and source.pages[pageIndex] or nil
        if type(value)=="table" then value=value.body or value.text or "" end
        if value==nil and type(source.chapters)=="table" then
            value=source.chapters[pageIndex]
            if type(value)=="table" then value=value.body or value.text or "" end
        end
        value=SanitizeBookText(value or "")
        if value~="" then
            if #allText>0 then allText[#allText+1]="\f" end
            allText[#allText+1]=value
        end
    end
    -- Reflow on every normalization so packet-delivered books and older local
    -- data obey the exact same safe page limits. Existing page order is kept.
    local combined=TruncateBytes(table.concat(allText),Books.MAX_PAGE_TEXT*Books.MAX_PAGES)
    for pageIndex=1,Books.MAX_PAGES do
        if combined=="" then break end
        local boundary=string.find(combined,"\f",1,true)
        local page,tail
        if boundary and boundary<=Books.MAX_PAGE_TEXT+1 then
            page=string.sub(combined,1,boundary-1); tail=string.sub(combined,boundary+1)
            local safePage,safeTail=SplitSafePage(page,Books.MAX_PAGE_TEXT)
            if safeTail~="" then tail=safeTail.."\f"..tail end
            page=safePage
        else
            page,tail=SplitSafePage(combined,Books.MAX_PAGE_TEXT)
        end
        book.pages[pageIndex]=page; combined=tail
    end
    return book
end

function Books.Normalize(data)
    if type(data)~="table" then return nil end
    local library=type(data.library)=="table" and data.library or {}
    library.revision=tonumber(library.revision) or 0
    library.featuredBook=math.floor(Clamp(library.featuredBook or 0,0,Books.MAX_BOOKS))
    library.featureLabel=CleanField(library.featureLabel,Books.MAX_FEATURE_LABEL_BYTES)
    if library.featureLabel=="" then library.featureLabel="Featured Book" end
    local previous=type(library.books)=="table" and library.books or {}
    local normalized={}
    for index=1,Books.MAX_BOOKS do
        local book=NormalizeBook(previous[index],index)
        if book then normalized[index]=book end
    end
    library.books=normalized
    data.library=library
    return library
end

function Books.GetFeaturedBook(data)
    local library=Books.Normalize(data)
    if not library then return nil,nil end
    local index=tonumber(library.featuredBook) or 0
    return library.books[index],index>0 and index or nil,library.featureLabel
end

-- Testable export used by the add-on's release regression harness. It mirrors
-- the in-game editor's byte-safe page distribution when font metrics are not
-- available outside WoW.
function Books.SplitTextForPages(text,pageBytes,startPage)
    pageBytes=math.max(1,math.min(Books.MAX_PAGE_TEXT,math.floor(tonumber(pageBytes) or Books.MAX_PAGE_TEXT)))
    startPage=math.max(1,math.min(Books.MAX_PAGES,math.floor(tonumber(startPage) or 1)))
    local remaining=CleanBookPaste(text,startPage)
    local result={}
    for page=startPage,Books.MAX_PAGES do
        if remaining=="" then break end
        local head,tail=SplitSafePage(remaining,pageBytes)
        result[page]=head
        remaining=tail
    end
    return result,remaining
end

local function ChunkText(text)
    text=CleanPage(text)
    local chunks={}
    while #text>0 do
        local chunk=TruncateBytes(text,Books.CHUNK_BYTES)
        if chunk=="" then break end
        chunks[#chunks+1]=chunk
        text=string.sub(text,#chunk+1)
    end
    return chunks
end

function Books.RecordCount(data)
    local library=Books.Normalize(data)
    if not library then return 0 end
    local count=1
    for _,book in pairs(library.books) do
        count=count+1
        for pageIndex=1,Books.MAX_PAGES do count=count+#ChunkText(book.pages[pageIndex]) end
    end
    return count
end

function Books.BuildPackets(syncID,targetGUID,data)
    local library=Books.Normalize(data)
    local packets={}
    if not library then return packets end
    local target=targetGUID or ""
    local libraryRevision=tostring(library.revision or 0)
    packets[#packets+1]=table.concat({"B3H",syncID,target,libraryRevision,tostring(library.featuredBook or 0),CleanField(library.featureLabel,Books.MAX_FEATURE_LABEL_BYTES)},"\t")
    for bookIndex=1,Books.MAX_BOOKS do
        local book=library.books[bookIndex]
        if book then
            local bookRevision=tostring(book.revision or 0)
            packets[#packets+1]=table.concat({
                "B3M",syncID,target,libraryRevision,tostring(bookIndex),bookRevision,
                tostring(book.cover or 1),CleanField(book.icon,90),
                CleanField(book.title,Books.MAX_TITLE_BYTES),CleanField(book.author,Books.MAX_AUTHOR_BYTES),
            },"\t")
            for pageIndex=1,Books.MAX_PAGES do
                local chunks=ChunkText(book.pages[pageIndex])
                for chunkIndex,chunk in ipairs(chunks) do
                    packets[#packets+1]=table.concat({"B3T",syncID,target,libraryRevision,tostring(bookIndex),bookRevision,tostring(pageIndex),tostring(chunkIndex),tostring(#chunks),chunk},"\t")
                end
            end
        end
    end
    return packets
end

local function AcceptRevision(data,incoming)
    local library=Books.Normalize(data)
    incoming=tonumber(incoming) or 0
    if not library or incoming<(tonumber(library.revision) or 0) then return nil end
    return library,incoming
end

function Books.ApplyPacket(data,kind,fields)
    local library,incoming=AcceptRevision(data,fields[4])
    if not library then return false end
    if kind=="B3H" then
        if incoming>(tonumber(library.revision) or 0) or library._syncRevision~=incoming then
            library.books={}; library._bookSync={}
        end
        library.revision=incoming
        library.featuredBook=math.floor(Clamp(fields[5],0,Books.MAX_BOOKS))
        library.featureLabel=CleanField(fields[6],Books.MAX_FEATURE_LABEL_BYTES)
        if library.featureLabel=="" then library.featureLabel="Featured Book" end
        library._syncRevision=incoming
        library._uiSerial=(tonumber(library._uiSerial) or 0)+1
        return true
    end
    local bookIndex=math.floor(tonumber(fields[5]) or 0)
    if bookIndex<1 or bookIndex>Books.MAX_BOOKS then return false end
    local bookRevision=tonumber(fields[6]) or 0
    library._bookSync=library._bookSync or {}
    if kind=="B3M" then
        local old=library.books[bookIndex]
        if old and bookRevision<(tonumber(old.revision) or 0) then return false end
        if library._bookSync[bookIndex]~=bookRevision then
            library.books[bookIndex]=NewBook(bookIndex,fields[7])
            library._bookSync[bookIndex]=bookRevision
        end
        local book=library.books[bookIndex]
        book.cover=math.floor(Clamp(fields[7],1,Books.MAX_COVERS))
        book.icon=TextureValue(fields[8]) or DEFAULT_BOOK_ICON
        book.title=CleanField(fields[9],Books.MAX_TITLE_BYTES)
        book.author=CleanField(fields[10],Books.MAX_AUTHOR_BYTES)
        book.revision=bookRevision
        library.revision=incoming
        library._uiSerial=(tonumber(library._uiSerial) or 0)+1
        return true
    end
    if kind~="B3T" then return false end
    local book=library.books[bookIndex]
    if not book or bookRevision<(tonumber(book.revision) or 0) then return false end
    local pageIndex=math.floor(tonumber(fields[7]) or 0)
    local chunkIndex=math.floor(tonumber(fields[8]) or 0)
    local chunkTotal=math.floor(tonumber(fields[9]) or 0)
    if pageIndex<1 or pageIndex>Books.MAX_PAGES or chunkIndex<1 or chunkTotal<1 or chunkIndex>chunkTotal or chunkTotal>64 then return false end
    book._chunks=book._chunks or {}
    local transfer=book._chunks[pageIndex]
    if not transfer or transfer.total~=chunkTotal then transfer={total=chunkTotal,parts={}}; book._chunks[pageIndex]=transfer end
    transfer.parts[chunkIndex]=TruncateBytes(fields[10] or "",Books.CHUNK_BYTES)
    for index=1,chunkTotal do if transfer.parts[index]==nil then return true end end
    book.pages[pageIndex]=CleanPage(table.concat(transfer.parts))
    book._chunks[pageIndex]=nil
    book.revision=bookRevision
    library.revision=incoming
    library._uiSerial=(tonumber(library._uiSerial) or 0)+1
    return true
end

function Books.FinishSync(data)
    local library=Books.Normalize(data)
    if not library then return end
    library._syncRevision=nil; library._bookSync=nil
    for _,book in pairs(library.books) do book._chunks=nil end
end

local function SetAtlasSafe(texture,atlas)
    if texture and texture.SetAtlas then return pcall(texture.SetAtlas,texture,atlas,false) end
    return false
end

local function OnePixel()
    local scale=(UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1
    return 1/math.max(0.01,scale)
end

local function SetBookFont(region,size,flags)
    if not region or not region.SetFont then return end
    pcall(region.SetFont,region,BOOK_FONT,size or 18,flags or "")
    if region.SetShadowColor then region:SetShadowColor(0,0,0,0.78) end
    if region.SetShadowOffset then region:SetShadowOffset(1,-1) end
end

local function MakeSlot(parent,size,selectionAlpha)
    local button=CreateFrame("Button",nil,parent)
    button:SetSize(size,size)
    local bg=button:CreateTexture(nil,"BACKGROUND"); bg:SetAllPoints(); if not SetAtlasSafe(bg,"house-upgrade-reward-icon-background") then bg:SetColorTexture(0.03,0.02,0.02,0.82) end; button.bg=bg
    local icon=button:CreateTexture(nil,"ARTWORK"); icon:SetSize(size-9,size-9); icon:SetPoint("CENTER"); button.icon=icon
    local overlay=button:CreateTexture(nil,"OVERLAY"); overlay:SetAllPoints(); SetAtlasSafe(overlay,"house-upgrade-reward-icon-frame-overlay"); button.overlay=overlay
    local highlight=button:CreateTexture(nil,"HIGHLIGHT"); highlight:SetAllPoints(); highlight:SetTexture("Interface\\Buttons\\CheckButtonHilight"); highlight:SetBlendMode("ADD"); highlight:SetVertexColor(1,0.82,0,0.38)
    local selected=button:CreateTexture(nil,"OVERLAY",nil,2); selected:SetAllPoints(); selected:SetTexture("Interface\\Buttons\\CheckButtonHilight"); selected:SetBlendMode("ADD"); selected:SetVertexColor(1,0.78,0.08,selectionAlpha or 0.55); selected:Hide(); button.selected=selected
    return button
end

local function SetSlotBook(slot,book)
    local texture=Books.GetBookDisplayTexture(book)
    if texture then
        slot.icon:SetTexture(texture)
        slot.icon:Show()
    else
        slot.icon:SetTexture(nil)
        slot.icon:Hide()
    end
end

local function MakeDarkPanel(parent,width,height)
    local frame=CreateFrame("Frame",nil,parent,"BackdropTemplate"); frame:SetSize(width,height)
    frame:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=OnePixel()})
    frame:SetBackdropColor(0,0,0,0.72); frame:SetBackdropBorderColor(0,0,0,0.25)
    return frame
end

local function SetTextQuiet(ui,box,text)
    ui.suppressText=true; box:SetText(text or ""); ui.suppressText=false
end

local function BytePrefix(text,byteCount)
    return TruncateBytes(text,math.max(0,math.floor(tonumber(byteCount) or 0)))
end

local function SplitVisibleText(ui,box,text)
    text=SanitizeBookText(text)
    if text=="" or not box then return text,"" end
    local maxHeight=math.max(80,(box.GetHeight and box:GetHeight() or 300)-14)
    local measure=ui.pageMeasure or box
    if measure.SetWidth and box.GetWidth then measure:SetWidth(math.max(80,(box:GetWidth() or 400)-8)) end
    -- A stored page is always capped at 2,000 bytes even on unusually large
    -- UI scales.  Measure only that safe prefix, then continue the remainder
    -- on subsequent pages.
    local measurable=BytePrefix(text,Books.MAX_PAGE_TEXT)
    SetTextQuiet(ui,measure,measurable)
    local ok,height=measure.GetStringHeight and pcall(measure.GetStringHeight,measure) or false,nil
    if not ok or (tonumber(height) or 0)<=maxHeight then
        return CleanPage(measurable),string.sub(text,#measurable+1)
    end
    local low,high,best=0,#measurable,0
    while low<=high do
        local middle=math.floor((low+high)/2)
        local candidate=BytePrefix(measurable,middle)
        SetTextQuiet(ui,measure,candidate)
        local measuredOK,measured=pcall(measure.GetStringHeight,measure)
        if not measuredOK or (tonumber(measured) or 0)<=maxHeight then best=#candidate; low=middle+1 else high=middle-1 end
    end
    if best<=0 then best=#BytePrefix(text,1) end
    local preferred=best
    local scanStart=math.max(1,best-120)
    local segment=string.sub(text,scanStart,best)
    local relative
    for position in segment:gmatch("()\n") do relative=position end
    if not relative then for position in segment:gmatch("()%s") do relative=position end end
    if relative and scanStart+relative-2>0 then preferred=scanStart+relative-2 end
    -- Preserve the exact pasted text, including boundary whitespace.  This
    -- prevents words or paragraph breaks disappearing between page turns.
    local head=string.sub(text,1,preferred)
    local tail=string.sub(text,preferred+1)
    return CleanPage(head),tail
end

local function DistributePageOverflow(ui,book,startPage,text)
    if not book or startPage<1 or startPage>Books.MAX_PAGES then return startPage end
    local page=startPage
    local carry=CleanBookPaste(text,startPage)
    local finalPage=startPage
    while page<=Books.MAX_PAGES do
        local head,tail=SplitVisibleText(ui,ui.pageEdit,carry)
        book.pages[page]=head
        book.pageFormatVersion=2
        finalPage=page
        if tail=="" then break end
        page=page+1
        if page>Books.MAX_PAGES then break end
        local existing=book.pages[page] or ""
        carry=CleanBookPaste(tail..existing,page)
    end
    return finalPage
end

local function CurrentBook(ui)
    local library=ui.data and Books.Normalize(ui.data)
    return library,library and library.books[ui.bookIndex]
end

local function EnsureBook(ui)
    local library=ui.data and Books.Normalize(ui.data)
    if not library then return nil,nil end
    local book=library.books[ui.bookIndex]
    if not book then book=NewBook(ui.bookIndex,ui.bookIndex); library.books[ui.bookIndex]=book end
    return library,book
end

local function MarkDirty(ui)
    ui.dirty=true
    if ui.saveButton then ui.saveButton:SetText("Save"); ui.saveButton:SetEnabled(ui.canEdit==true) end
    if ui.ctx.OnDirty then ui.ctx.OnDirty() end
end

-- Commit the page EditBox buffer into the saved book model. WoW can report
-- clipboard paste OnTextChanged events with userInput=false, so page saving
-- must rely on suppressText (our programmatic-change guard), not that flag.
local function CommitPageEditorText(ui,markDirty)
    if not ui or ui.suppressText or not ui.canEdit or not ui.pageEdit or (ui.pageIndex or 0)<1 then return false end
    local _,book=EnsureBook(ui)
    if not book then return false end
    local raw=ui.pageEdit:GetText() or ""
    local cleaned=CleanBookPaste(raw,ui.pageIndex)
    local finalPage=DistributePageOverflow(ui,book,ui.pageIndex,cleaned)
    local visible=book.pages[ui.pageIndex] or ""
    if visible~=raw then SetTextQuiet(ui,ui.pageEdit,visible) end
    ui.lastOverflowPage=math.max(ui.lastOverflowPage or ui.pageIndex,finalPage or ui.pageIndex)
    if markDirty~=false then MarkDirty(ui) end
    return true
end

local function IconFileInfo(value)
    local suppliedName
    if type(value)=="table" then
        suppliedName=value.name or value.displayName or value.fileName or value.filename or value.path
        value=value.texture or value.icon or value.fileDataID or value.fileID or value.id
    end
    local texture=TextureValue(value)
    if not texture then return nil end
    local iconNumber=tonumber(texture)
    if not iconNumber and GetFileIDFromPath and type(texture)=="string" then
        local ok,fileID=pcall(GetFileIDFromPath,texture)
        if ok then iconNumber=tonumber(fileID) end
    end
    local name=type(suppliedName)=="string" and suppliedName or nil
    if (not name or name=="") and type(texture)=="string" then
        name=texture:gsub("/","\\"):match("([^\\]+)$") or texture
        name=name:gsub("%.[Bb][Ll][Pp]$",""):gsub("%.[Tt][Gg][Aa]$","")
    end
    -- Retail can expose richer texture metadata. Probe it defensively so this
    -- addon automatically benefits when Blizzard adds names without depending
    -- on a single patch-specific return signature.
    if C_Texture and C_Texture.GetTextureFileInfo and iconNumber then
        local returned={pcall(C_Texture.GetTextureFileInfo,iconNumber)}
        if returned[1] then
            for index=2,#returned do
                local valueInfo=returned[index]
                if type(valueInfo)=="table" then
                    name=valueInfo.name or valueInfo.fileName or valueInfo.filename or valueInfo.path or name
                elseif type(valueInfo)=="string" and valueInfo~="" then
                    name=valueInfo
                end
            end
        end
    end
    name=CleanField(name or (iconNumber and ("Icon "..tostring(iconNumber))) or tostring(texture),80)
    return {texture=texture,number=iconNumber or 0,name=name,search=string.lower(name.." "..tostring(iconNumber or "").." "..tostring(texture))}
end

local function CollectMacroIcons()
    local result,seen={},{}
    local function add(value)
        local info=IconFileInfo(value); if not info then return end
        local key=tostring(info.texture)
        if not seen[key] then seen[key]=true; result[#result+1]=info end
    end
    local function collect(fn)
        if type(fn)~="function" then return end
        local list={}
        local ok,returned=pcall(fn,list)
        if not ok then ok,returned=pcall(fn) end
        if ok then
            if type(returned)=="table" then list=returned end
            for _,value in ipairs(list) do add(value) end
        end
    end
    -- Prefer current namespaced APIs, then retain legacy globals for old 1.0.2
    -- clients. The catalog is rebuilt whenever My Books opens.
    if C_Macro then
        collect(C_Macro.GetMacroIcons)
        collect(C_Macro.GetMacroItemIcons)
    end
    collect(GetMacroIcons); collect(GetMacroItemIcons)
    for _,value in ipairs(FALLBACK_ICONS) do add(value) end
    table.sort(result,function(a,b)
        if a.number~=b.number then return a.number>b.number end
        if a.name~=b.name then return a.name>b.name end
        return tostring(a.texture)>tostring(b.texture)
    end)
    return result
end

local function BuildIconPicker(ui,parent)
    ui.iconChoices={}; ui.iconPage=1
    ui.iconPicker=CreateFrame("Frame",nil,parent,"BackdropTemplate")
    ui.iconPicker:SetSize(150,284); ui.iconPicker:SetPoint("TOPLEFT",548,-122); ui.iconPicker:SetFrameLevel((parent:GetFrameLevel() or 1)+44)
    local title=ui.iconPicker:CreateFontString(nil,"OVERLAY","GameFontNormal"); title:SetPoint("TOP",0,-8); title:SetText("Icons")
    ui.iconSearch=CreateFrame("EditBox",nil,ui.iconPicker,"InputBoxTemplate"); ui.iconSearch:SetSize(132,28); ui.iconSearch:SetPoint("TOP",0,-25); ui.iconSearch:SetAutoFocus(false); ui.iconSearch:SetMaxLetters(40)
    ui.iconSearch:SetScript("OnEscapePressed",function(self) self:ClearFocus() end)
    ui.iconSearch:SetScript("OnEnterPressed",function(self) self:ClearFocus() end)
    ui.iconButtons={}; ui.filteredIconChoices={}
    for index=1,15 do
        local button=MakeSlot(ui.iconPicker,36,0.35); button:SetPoint("TOPLEFT",12+(((index-1)%3)*43),-60-(math.floor((index-1)/3)*37)); button.choiceIndex=index
        button:SetScript("OnClick",function(self)
            local choice=ui.filteredIconChoices[((ui.iconPage-1)*15)+self.choiceIndex]
            if not choice then return end
            local _,book=EnsureBook(ui); if not book then return end
            book.icon=choice.texture; MarkDirty(ui); ui.RefreshEditor()
        end)
        button:SetScript("OnEnter",function(self)
            local choice=ui.filteredIconChoices[((ui.iconPage-1)*15)+self.choiceIndex]
            if not choice then return end
            GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText("#"..tostring(choice.number>0 and choice.number or choice.texture)); GameTooltip:Show()
        end)
        button:SetScript("OnLeave",GameTooltip_Hide)
        ui.iconButtons[index]=button
    end
    ui.iconPrevious=CreateFrame("Button",nil,ui.iconPicker,"UIPanelButtonTemplate"); ui.iconPrevious:SetSize(47,27); ui.iconPrevious:SetPoint("BOTTOMLEFT",3,7); ui.iconPrevious:SetText("Previous")
    ui.iconNext=CreateFrame("Button",nil,ui.iconPicker,"UIPanelButtonTemplate"); ui.iconNext:SetSize(45,27); ui.iconNext:SetPoint("LEFT",ui.iconPrevious,"RIGHT",2,0); ui.iconNext:SetText("Next")
    ui.iconClose=CreateFrame("Button",nil,ui.iconPicker,"UIPanelButtonTemplate"); ui.iconClose:SetSize(47,27); ui.iconClose:SetPoint("LEFT",ui.iconNext,"RIGHT",2,0); ui.iconClose:SetText("Remove")
    for _,button in ipairs({ui.iconPrevious,ui.iconNext,ui.iconClose}) do if button:GetFontString() then button:GetFontString():SetScale(0.74) end end
    ui.RefreshIconCatalog=function()
        ui.iconChoices=CollectMacroIcons(); ui.iconPage=1
        if ui.RefreshIconPicker then ui.RefreshIconPicker() end
    end
    ui.RefreshIconPicker=function()
        local query=string.lower((ui.iconSearch:GetText() or ""):match("^%s*(.-)%s*$") or "")
        ui.filteredIconChoices={}
        for _,choice in ipairs(ui.iconChoices) do
            if query=="" or string.find(choice.search,query,1,true) then ui.filteredIconChoices[#ui.filteredIconChoices+1]=choice end
        end
        local pages=math.max(1,math.ceil(#ui.filteredIconChoices/15)); ui.iconPage=math.max(1,math.min(pages,ui.iconPage or 1))
        local first=((ui.iconPage-1)*15)+1
        for index,button in ipairs(ui.iconButtons) do
            local choice=ui.filteredIconChoices[first+index-1]; button.icon:SetTexture(choice and choice.texture); button.icon:SetShown(choice~=nil); button:SetEnabled(choice~=nil)
        end
        ui.iconPrevious:SetEnabled(ui.iconPage>1); ui.iconNext:SetEnabled(ui.iconPage<pages)
    end
    ui.iconSearch:SetScript("OnTextChanged",function(_,user) if user then ui.iconPage=1; ui.RefreshIconPicker() end end)
    ui.iconPrevious:SetScript("OnClick",function() ui.iconPage=math.max(1,ui.iconPage-1); ui.RefreshIconPicker() end)
    ui.iconNext:SetScript("OnClick",function() ui.iconPage=ui.iconPage+1; ui.RefreshIconPicker() end)
    ui.iconClose:SetScript("OnClick",function()
        if not ui.canEdit or (ui.pageIndex or 0)~=0 then return end
        local _,book=CurrentBook(ui); if not book or not TextureValue(book.icon) then return end
        book.icon=DEFAULT_BOOK_ICON; MarkDirty(ui); ui.RefreshEditor()
    end)
    if parent.HookScript then parent:HookScript("OnShow",function() ui.RefreshIconCatalog() end) end
    ui.RefreshIconCatalog()
end

local function BuildMyBooks(ui,parent)
    ui.bookIndex=1; ui.pageIndex=0; ui.editorMode="cover"; ui.dirty=false
    ui.bookSlots={}
    for index=1,Books.MAX_BOOKS do
        local slot=MakeSlot(parent,34,0.42); slot:SetPoint("TOPLEFT",26+((index-1)*36),-6); slot.bookIndex=index
        slot:SetScript("OnClick",function(self)
            if ui.pageEdit and ui.pageEdit:HasFocus() then ui.pageEdit:ClearFocus() end
            ui.bookIndex=self.bookIndex; ui.pageIndex=0; ui.editorMode="cover"; ui.removeConfirm:Hide(); ui.RefreshEditor()
        end)
        slot:SetScript("OnEnter",function(self)
            GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText("Slot "..tostring(self.bookIndex)); GameTooltip:AddLine("Edit book slot.",1,1,1,true); GameTooltip:Show()
        end)
        slot:SetScript("OnLeave",GameTooltip_Hide); ui.bookSlots[index]=slot
    end
    ui.removeButton=CreateFrame("Button",nil,parent,"UIPanelButtonTemplate"); ui.removeButton:SetSize(80,30); ui.removeButton:SetPoint("TOPRIGHT",-94,-8); ui.removeButton:SetText("Remove")
    ui.saveButton=CreateFrame("Button",nil,parent,"UIPanelButtonTemplate"); ui.saveButton:SetSize(80,30); ui.saveButton:SetPoint("TOPRIGHT",-8,-8); ui.saveButton:SetText("Saved"); ui.saveButton:SetEnabled(false)

    local coversLabel=parent:CreateFontString(nil,"OVERLAY","GameFontNormal"); coversLabel:SetPoint("TOPLEFT",14,-58); coversLabel:SetText("Book Covers")
    ui.coverSlots={}
    for index=1,Books.MAX_COVERS do
        local slot=MakeSlot(parent,42,0.38)
        if index==15 then
            slot:SetSize(88,42)
            slot:SetPoint("TOPLEFT",8,-77-(math.floor((index-1)/2)*45))
            slot.icon:SetSize(1,1)
            slot.icon:SetAlpha(0)
            if slot.bg then slot.bg:SetColorTexture(0.055,0.035,0.02,0.96) end
            if slot.overlay then slot.overlay:SetAlpha(0) end
            if not slot._rmhWideBorderTop then
                slot._rmhWideBorderTop=slot:CreateTexture(nil,"BORDER"); slot._rmhWideBorderTop:SetColorTexture(0.62,0.47,0.14,0.95); slot._rmhWideBorderTop:SetPoint("TOPLEFT",slot,"TOPLEFT",1,-1); slot._rmhWideBorderTop:SetPoint("TOPRIGHT",slot,"TOPRIGHT",-1,-1); slot._rmhWideBorderTop:SetHeight(2)
                slot._rmhWideBorderBottom=slot:CreateTexture(nil,"BORDER"); slot._rmhWideBorderBottom:SetColorTexture(0.62,0.47,0.14,0.95); slot._rmhWideBorderBottom:SetPoint("BOTTOMLEFT",slot,"BOTTOMLEFT",1,1); slot._rmhWideBorderBottom:SetPoint("BOTTOMRIGHT",slot,"BOTTOMRIGHT",-1,1); slot._rmhWideBorderBottom:SetHeight(2)
                slot._rmhWideBorderLeft=slot:CreateTexture(nil,"BORDER"); slot._rmhWideBorderLeft:SetColorTexture(0.62,0.47,0.14,0.95); slot._rmhWideBorderLeft:SetPoint("TOPLEFT",slot,"TOPLEFT",1,-1); slot._rmhWideBorderLeft:SetPoint("BOTTOMLEFT",slot,"BOTTOMLEFT",1,1); slot._rmhWideBorderLeft:SetWidth(2)
                slot._rmhWideBorderRight=slot:CreateTexture(nil,"BORDER"); slot._rmhWideBorderRight:SetColorTexture(0.62,0.47,0.14,0.95); slot._rmhWideBorderRight:SetPoint("TOPRIGHT",slot,"TOPRIGHT",-1,-1); slot._rmhWideBorderRight:SetPoint("BOTTOMRIGHT",slot,"BOTTOMRIGHT",-1,1); slot._rmhWideBorderRight:SetWidth(2)
                slot._rmhIconLabel=slot:CreateFontString(nil,"OVERLAY")
                slot._rmhIconLabel:SetPoint("CENTER",0,0)
                slot._rmhIconLabel:SetWidth(80)
                slot._rmhIconLabel:SetJustifyH("CENTER")
                slot._rmhIconLabel:SetTextColor(1,0.82,0)
                SetBookFont(slot._rmhIconLabel,18,"")
                slot._rmhIconLabel:SetText("Icon")
            end
            if slot._rmhIconLabel then slot._rmhIconLabel:Show() end
        else
            slot:SetPoint("TOPLEFT",8+(((index-1)%2)*46),-77-(math.floor((index-1)/2)*45))
        end
        slot.coverIndex=index; slot.icon:SetTexture(COVER_TEXTURES[index]); if index~=15 then slot.icon:Show() end
        slot:SetScript("OnClick",function(self) if not ui.canEdit then return end; local _,book=EnsureBook(ui); if not book then return end; book.cover=self.coverIndex; MarkDirty(ui); ui.RefreshEditor() end)
        slot:SetScript("OnEnter",function(self)
            GameTooltip:SetOwner(self,"ANCHOR_RIGHT")
            if self.coverIndex==15 then
                GameTooltip:SetText("Icon Cover")
                GameTooltip:AddLine("Uses the selected front-cover icon for the book slot.",1,1,1,true)
            else
                GameTooltip:SetText("Book Color "..self.coverIndex)
                GameTooltip:AddLine("Change background color.",1,1,1,true)
            end
            GameTooltip:Show()
        end)
        slot:SetScript("OnLeave",GameTooltip_Hide); ui.coverSlots[index]=slot
    end
    ui.editorFrame=CreateFrame("Frame",nil,parent); ui.editorFrame:SetPoint("TOPLEFT",106,-52); ui.editorFrame:SetPoint("BOTTOMRIGHT",-172,24)
    ui.editorBorder=CreateFrame("Frame",nil,ui.editorFrame,"BackdropTemplate"); ui.editorBorder:SetAllPoints(); ui.editorBorder:SetBackdrop({edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=OnePixel()}); ui.editorBorder:SetBackdropBorderColor(0,0,0,0.25)
    ui.coverFrame=CreateFrame("Frame",nil,ui.editorFrame); ui.coverFrame:SetAllPoints()
    ui.coverTint=ui.coverFrame:CreateTexture(nil,"BACKGROUND"); ui.coverTint:SetAllPoints(); ui.coverTint:SetColorTexture(0.55,0.03,0.05,0.25)
    local titleLabel=ui.coverFrame:CreateFontString(nil,"OVERLAY","GameFontNormal"); titleLabel:SetPoint("TOP",0,-46); titleLabel:SetText("Title (required)"); SetBookFont(titleLabel,18,"")
    ui.titleEdit=CreateFrame("EditBox",nil,ui.coverFrame,"InputBoxTemplate"); ui.titleEdit:SetSize(320,38); ui.titleEdit:SetPoint("TOP",0,-66); ui.titleEdit:SetAutoFocus(false); ui.titleEdit:SetMaxLetters(Books.MAX_TITLE_BYTES); ui.titleEdit:SetJustifyH("CENTER"); ui.titleEdit:SetTextColor(1,0.82,0); ui.titleEdit:SetTextInsets(8,8,6,6); SetBookFont(ui.titleEdit,24,"")
    ui.coverIconButton=MakeSlot(ui.coverFrame,102,0.35); ui.coverIconButton:SetPoint("CENTER",0,2)
    local authorLabel=ui.coverFrame:CreateFontString(nil,"OVERLAY","GameFontNormal"); authorLabel:SetPoint("BOTTOM",0,100); authorLabel:SetText("Description (required)"); SetBookFont(authorLabel,18,"")
    ui.authorEdit=CreateFrame("EditBox",nil,ui.coverFrame,"InputBoxTemplate"); ui.authorEdit:SetSize(300,38); ui.authorEdit:SetPoint("BOTTOM",0,58); ui.authorEdit:SetAutoFocus(false); ui.authorEdit:SetMaxLetters(Books.MAX_AUTHOR_BYTES); ui.authorEdit:SetJustifyH("CENTER"); ui.authorEdit:SetTextColor(1,0.82,0); ui.authorEdit:SetTextInsets(8,8,6,6); SetBookFont(ui.authorEdit,22,"")

    ui.pageFrame=CreateFrame("Frame",nil,ui.editorFrame); ui.pageFrame:SetAllPoints(); ui.pageFrame:Hide()
    ui.pageTint=ui.pageFrame:CreateTexture(nil,"BACKGROUND"); ui.pageTint:SetAllPoints(); ui.pageTint:SetColorTexture(0.55,0.03,0.05,0.25)
    ui.pageEdit=CreateFrame("EditBox",nil,ui.pageFrame); ui.pageEdit:SetPoint("TOPLEFT",22,-22); ui.pageEdit:SetPoint("BOTTOMRIGHT",-22,62); ui.pageEdit:SetAutoFocus(false); ui.pageEdit:SetMultiLine(true); ui.pageEdit:SetMaxLetters(Books.MAX_PAGE_TEXT*Books.MAX_PAGES); ui.pageEdit:SetTextInsets(4,4,4,4); ui.pageEdit:SetTextColor(0.96,0.96,0.96); SetBookFont(ui.pageEdit,20,"")
    if ui.pageEdit.SetJustifyV then ui.pageEdit:SetJustifyV("TOP") end
    -- A dedicated FontString provides reliable wrapped-text metrics across WoW
    -- builds; EditBox itself does not expose GetStringHeight consistently.
    ui.pageMeasure=ui.pageFrame:CreateFontString(nil,"OVERLAY")
    ui.pageMeasure:SetPoint("TOPLEFT",22,-22); ui.pageMeasure:SetWidth(380); ui.pageMeasure:SetJustifyH("LEFT"); ui.pageMeasure:SetJustifyV("TOP"); ui.pageMeasure:SetWordWrap(true); ui.pageMeasure:SetAlpha(0); SetBookFont(ui.pageMeasure,20,"")
    ui.pageEdit:SetScript("OnEscapePressed",function(self) self:ClearFocus() end)

    ui.editorNavigation=CreateFrame("Frame",nil,ui.editorFrame); ui.editorNavigation:SetSize(330,32); ui.editorNavigation:SetPoint("BOTTOM",0,9); ui.editorNavigation:SetFrameLevel((ui.editorFrame:GetFrameLevel() or 1)+12)
    ui.editorPrevious=CreateFrame("Button",nil,ui.editorNavigation,"UIPanelButtonTemplate"); ui.editorPrevious:SetSize(90,30); ui.editorPrevious:SetPoint("LEFT",0,0); ui.editorPrevious:SetText("Previous")
    ui.editorPageLabel=ui.editorNavigation:CreateFontString(nil,"OVERLAY","GameFontNormal"); ui.editorPageLabel:SetPoint("CENTER",0,0); ui.editorPageLabel:SetWidth(138); ui.editorPageLabel:SetJustifyH("CENTER"); ui.editorPageLabel:SetText("Front Cover")
    ui.editorNext=CreateFrame("Button",nil,ui.editorNavigation,"UIPanelButtonTemplate"); ui.editorNext:SetSize(90,30); ui.editorNext:SetPoint("RIGHT",0,0); ui.editorNext:SetText("Next")

    ui.featureLabel=parent:CreateFontString(nil,"OVERLAY","GameFontNormal"); ui.featureLabel:SetWidth(132); ui.featureLabel:SetJustifyH("CENTER"); ui.featureLabel:SetPoint("TOPLEFT",556,-58); ui.featureLabel:SetText("Featured Text")
    ui.featureEdit=CreateFrame("EditBox",nil,parent,"InputBoxTemplate"); ui.featureEdit:SetSize(132,32); ui.featureEdit:SetPoint("TOPLEFT",556,-76); ui.featureEdit:SetAutoFocus(false); ui.featureEdit:SetMaxLetters(Books.MAX_FEATURE_LABEL_BYTES); ui.featureEdit:SetFrameLevel((parent:GetFrameLevel() or 1)+45)
    BuildIconPicker(ui,parent)

    ui.coverIconButton:SetScript("OnClick",function() if ui.canEdit then ui.iconSearch:SetFocus() end end)
    ui.coverIconButton:SetScript("OnEnter",function(self)
        local _,book=CurrentBook(ui); local icon=book and TextureValue(book.icon)
        if not icon then return end
        GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText("Icon #"..tostring(tonumber(icon) or icon)); GameTooltip:Show()
    end)
    ui.coverIconButton:SetScript("OnLeave",GameTooltip_Hide)

    ui.removeConfirm=MakeDarkPanel(parent,208,76); ui.removeConfirm:SetPoint("TOPRIGHT",ui.removeButton,"BOTTOMRIGHT",0,-4); ui.removeConfirm:SetFrameLevel((parent:GetFrameLevel() or 1)+50); ui.removeConfirm:Hide()
    local confirmText=ui.removeConfirm:CreateFontString(nil,"OVERLAY","GameFontHighlight"); confirmText:SetPoint("TOP",0,-10); confirmText:SetText("Remove this book?")
    local yesButton=CreateFrame("Button",nil,ui.removeConfirm,"UIPanelButtonTemplate"); yesButton:SetSize(78,28); yesButton:SetPoint("BOTTOMLEFT",16,10); yesButton:SetText("Yes")
    local noButton=CreateFrame("Button",nil,ui.removeConfirm,"UIPanelButtonTemplate"); noButton:SetSize(78,28); noButton:SetPoint("BOTTOMRIGHT",-16,10); noButton:SetText("No")
    ui.removeYesButton=yesButton; ui.removeNoButton=noButton
    ui.removeButton:SetScript("OnClick",function() local _,book=CurrentBook(ui); if book and ui.canEdit then ui.removeConfirm:SetShown(not ui.removeConfirm:IsShown()) end end)
    noButton:SetScript("OnClick",function() ui.removeConfirm:Hide() end)
    yesButton:SetScript("OnClick",function()
        local library,book=CurrentBook(ui); if not library or not book or not ui.canEdit then ui.removeConfirm:Hide(); return end
        library.books[ui.bookIndex]=nil; if library.featuredBook==ui.bookIndex then library.featuredBook=0 end
        ui.pageIndex=0; ui.editorMode="cover"; ui.removeConfirm:Hide(); MarkDirty(ui); ui.RefreshEditor()
    end)

    ui.editorPrevious:SetScript("OnClick",function() if (ui.pageIndex or 0)>=1 then CommitPageEditorText(ui,true) end; if ui.pageEdit:HasFocus() then ui.pageEdit:ClearFocus() end; ui.pageIndex=math.max(0,(ui.pageIndex or 0)-1); ui.editorMode=ui.pageIndex==0 and "cover" or "pages"; ui.RefreshEditor() end)
    ui.editorNext:SetScript("OnClick",function() if (ui.pageIndex or 0)>=1 then CommitPageEditorText(ui,true) end; local _,book=CurrentBook(ui); if not book then return end; if ui.pageEdit:HasFocus() then ui.pageEdit:ClearFocus() end; ui.pageIndex=math.min(Books.MAX_PAGES,(ui.pageIndex or 0)+1); ui.editorMode=ui.pageIndex==0 and "cover" or "pages"; ui.RefreshEditor() end)

    ui.titleEdit:SetScript("OnEscapePressed",function(self) self:ClearFocus() end); ui.authorEdit:SetScript("OnEscapePressed",function(self) self:ClearFocus() end); ui.featureEdit:SetScript("OnEscapePressed",function(self) self:ClearFocus() end)
    ui.titleEdit:SetScript("OnTextChanged",function(self,user) if ui.suppressText or not ui.canEdit then return end; local _,book=EnsureBook(ui); if book then book.title=CleanField(self:GetText(),Books.MAX_TITLE_BYTES); MarkDirty(ui) end end)
    ui.authorEdit:SetScript("OnTextChanged",function(self,user) if ui.suppressText or not ui.canEdit then return end; local _,book=EnsureBook(ui); if book then book.author=CleanField(self:GetText(),Books.MAX_AUTHOR_BYTES); MarkDirty(ui) end end)
    ui.featureEdit:SetScript("OnTextChanged",function(self,user) if ui.suppressText or not ui.canEdit then return end; local library=ui.data and Books.Normalize(ui.data); if library then library.featureLabel=CleanField(self:GetText(),Books.MAX_FEATURE_LABEL_BYTES); if library.featureLabel=="" then library.featureLabel="Featured Book" end; MarkDirty(ui) end end)
    ui.pageEdit:SetScript("OnTextChanged",function(self,user)
        if ui.suppressText or not ui.canEdit or (ui.pageIndex or 0)<1 then return end
        CommitPageEditorText(ui,true)
    end)
    ui.pageEdit:SetScript("OnEditFocusLost",function()
        if not ui.suppressText and ui.canEdit and (ui.pageIndex or 0)>=1 then CommitPageEditorText(ui,true) end
    end)

    ui.saveButton:SetScript("OnClick",function()
        if not ui.canEdit then if ui.ctx.SetStatus then ui.ctx.SetStatus("Visit your own house to save My Books.",1,0.55,0.2) end; return end
        -- Flush the current page before focus changes. Then clear focus before
        -- grabbing the final normalized book object so OnEditFocusLost cannot
        -- leave us stamping a stale pre-normalization book table.
        if (ui.pageIndex or 0)>=1 then CommitPageEditorText(ui,false) end
        for _,box in ipairs({ui.pageEdit,ui.titleEdit,ui.authorEdit,ui.featureEdit,ui.iconSearch}) do if box:HasFocus() then box:ClearFocus() end end
        local library,book=CurrentBook(ui)
        if not library then if ui.ctx.SetStatus then ui.ctx.SetStatus("Visit your own house to save My Books.",1,0.55,0.2) end; return end
        if book then book.revision=ui.ctx.RevisionStamp(book.revision); library.featuredBook=ui.bookIndex end
        library.revision=ui.ctx.RevisionStamp(library.revision); ui.dirty=false; ui.saveButton:SetText("Saved"); ui.saveButton:SetEnabled(false)
        if ui.ctx.OnSave then ui.ctx.OnSave(ui.data) end
    end)

    ui.RefreshEditor=function()
        local library,book=CurrentBook(ui); local preview=book or NewBook(ui.bookIndex,ui.bookIndex)
        for index,slot in ipairs(ui.bookSlots) do SetSlotBook(slot,library and library.books[index]); slot.selected:SetShown(index==ui.bookIndex) end
        for index,slot in ipairs(ui.coverSlots) do slot.selected:SetShown(preview.cover==index) end
        local coverMode=(ui.pageIndex or 0)==0; ui.editorMode=coverMode and "cover" or "pages"
        ui.coverFrame:SetShown(coverMode); ui.pageFrame:SetShown(not coverMode); ui.featureLabel:Show(); ui.featureEdit:Show()
        ui.editorPageLabel:SetText(coverMode and "Front Cover" or ("Page "..tostring(ui.pageIndex)))
        ui.editorPrevious:SetEnabled((ui.pageIndex or 0)>0); ui.editorNext:SetEnabled(book~=nil and (ui.pageIndex or 0)<Books.MAX_PAGES)
        local color=COVER_COLORS[math.floor(Clamp(preview.cover,1,Books.MAX_COVERS))] or COVER_COLORS[1]; ui.coverTint:SetColorTexture(color[1],color[2],color[3],0.25); ui.pageTint:SetColorTexture(color[1],color[2],color[3],0.25)
        local coverIcon=TextureValue(preview.icon); ui.coverIconButton.icon:SetTexture(coverIcon); ui.coverIconButton.icon:SetShown(coverIcon~=nil)
        ui.titleEdit:SetEnabled(ui.canEdit); ui.authorEdit:SetEnabled(ui.canEdit); ui.featureEdit:SetEnabled(ui.canEdit); ui.coverIconButton:SetEnabled(ui.canEdit)
        if not ui.titleEdit:HasFocus() then SetTextQuiet(ui,ui.titleEdit,preview.title) end
        if not ui.authorEdit:HasFocus() then SetTextQuiet(ui,ui.authorEdit,preview.author) end
        if not ui.featureEdit:HasFocus() then SetTextQuiet(ui,ui.featureEdit,library and library.featureLabel or "Featured Book") end
        ui.pageEdit:SetEnabled(ui.canEdit and book~=nil and not coverMode)
        if not ui.pageEdit:HasFocus() then SetTextQuiet(ui,ui.pageEdit,book and not coverMode and book.pages[ui.pageIndex] or "") end
        ui.lastOverflowPage=nil
        ui.iconClose:SetEnabled(ui.canEdit and book~=nil and coverMode and coverIcon~=nil); ui.removeButton:SetEnabled(ui.canEdit and book~=nil); ui.saveButton:SetEnabled(ui.canEdit and ui.dirty)
    end
end

local function BuildLibrary(ui,parent)
    ui.readerBook=1; ui.readerPage=0; ui.readerMode="cover"; ui.librarySlots={}
    for index=1,Books.MAX_BOOKS do
        local slot=MakeSlot(parent,42,0.25); slot:SetPoint("TOPLEFT",18+((index-1)*48),-3); slot.bookIndex=index
        slot:SetScript("OnClick",function(self) ui.readerBook=self.bookIndex; ui.readerPage=0; ui.readerMode="cover"; ui.RefreshLibraryView(false) end)
        slot:SetScript("OnEnter",function(self) local library=ui.data and Books.Normalize(ui.data); local book=library and library.books[self.bookIndex]; GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText(book and (book.title~="" and book.title or ("Book "..self.bookIndex)) or ("Empty Book Slot "..self.bookIndex)); GameTooltip:AddLine("Read this book.",1,1,1,true); GameTooltip:Show() end)
        slot:SetScript("OnLeave",GameTooltip_Hide); ui.librarySlots[index]=slot
    end
    ui.readerFrame=CreateFrame("Frame",nil,parent,"BackdropTemplate"); ui.readerFrame:SetPoint("TOPLEFT",18,-52); ui.readerFrame:SetPoint("BOTTOMRIGHT",-18,10); ui.readerFrame:SetBackdrop({edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=OnePixel()}); ui.readerFrame:SetBackdropBorderColor(0,0,0,0.25)
    ui.libraryCover=CreateFrame("Frame",nil,ui.readerFrame); ui.libraryCover:SetAllPoints()
    ui.libraryCoverTint=ui.libraryCover:CreateTexture(nil,"BACKGROUND"); ui.libraryCoverTint:SetAllPoints(); ui.libraryCoverTint:SetColorTexture(0.55,0.03,0.05,0.25)
    ui.libraryCoverTitle=ui.libraryCover:CreateFontString(nil,"OVERLAY"); ui.libraryCoverTitle:SetPoint("TOP",0,-52); ui.libraryCoverTitle:SetWidth(520); ui.libraryCoverTitle:SetHeight(78); ui.libraryCoverTitle:SetJustifyH("CENTER"); ui.libraryCoverTitle:SetJustifyV("TOP"); ui.libraryCoverTitle:SetWordWrap(true); ui.libraryCoverTitle:SetTextColor(1,0.82,0); SetBookFont(ui.libraryCoverTitle,32,"")
    ui.libraryCoverIconFrame=CreateFrame("Frame",nil,ui.libraryCover); ui.libraryCoverIconFrame:SetSize(112,112); ui.libraryCoverIconFrame:SetPoint("CENTER",0,4)
    local iconBG=ui.libraryCoverIconFrame:CreateTexture(nil,"BACKGROUND"); iconBG:SetAllPoints(); SetAtlasSafe(iconBG,"house-upgrade-reward-icon-background")
    ui.libraryCoverIcon=ui.libraryCoverIconFrame:CreateTexture(nil,"ARTWORK"); ui.libraryCoverIcon:SetSize(94,94); ui.libraryCoverIcon:SetPoint("CENTER")
    local iconOverlay=ui.libraryCoverIconFrame:CreateTexture(nil,"OVERLAY"); iconOverlay:SetAllPoints(); SetAtlasSafe(iconOverlay,"house-upgrade-reward-icon-frame-overlay")
    ui.libraryCoverAuthor=ui.libraryCover:CreateFontString(nil,"OVERLAY"); ui.libraryCoverAuthor:SetPoint("BOTTOM",0,70); ui.libraryCoverAuthor:SetWidth(520); ui.libraryCoverAuthor:SetJustifyH("CENTER"); ui.libraryCoverAuthor:SetTextColor(1,0.82,0); SetBookFont(ui.libraryCoverAuthor,24,"")

    ui.libraryPage=CreateFrame("Frame",nil,ui.readerFrame); ui.libraryPage:SetAllPoints(); ui.libraryPage:Hide()
    ui.libraryPageTint=ui.libraryPage:CreateTexture(nil,"BACKGROUND"); ui.libraryPageTint:SetAllPoints(); ui.libraryPageTint:SetColorTexture(0.55,0.03,0.05,0.25)
    ui.readerText=ui.libraryPage:CreateFontString(nil,"OVERLAY"); ui.readerText:SetPoint("TOPLEFT",24,-24); ui.readerText:SetPoint("BOTTOMRIGHT",-24,58); ui.readerText:SetJustifyH("LEFT"); ui.readerText:SetJustifyV("TOP"); ui.readerText:SetWordWrap(true); ui.readerText:SetTextColor(0.96,0.96,0.96); SetBookFont(ui.readerText,20,"")
    ui.readerNavigation=CreateFrame("Frame",nil,ui.readerFrame); ui.readerNavigation:SetSize(260,32); ui.readerNavigation:SetPoint("BOTTOM",0,10); ui.readerNavigation:SetFrameLevel((ui.readerFrame:GetFrameLevel() or 1)+12)
    ui.readerPrevious=CreateFrame("Button",nil,ui.readerNavigation,"UIPanelButtonTemplate"); ui.readerPrevious:SetSize(86,30); ui.readerPrevious:SetPoint("LEFT",-12,0); ui.readerPrevious:SetText("Previous")
    ui.readerPageLabel=ui.readerNavigation:CreateFontString(nil,"OVERLAY","GameFontNormal"); ui.readerPageLabel:SetPoint("CENTER",0,0); ui.readerPageLabel:SetWidth(146); ui.readerPageLabel:SetJustifyH("CENTER"); ui.readerPageLabel:SetText("Front Cover")
    ui.readerNext=CreateFrame("Button",nil,ui.readerNavigation,"UIPanelButtonTemplate"); ui.readerNext:SetSize(86,30); ui.readerNext:SetPoint("RIGHT",12,0); ui.readerNext:SetText("Next")
    ui.readerPrevious:SetScript("OnClick",function() ui.readerPage=math.max(0,(ui.readerPage or 0)-1); ui.readerMode=ui.readerPage==0 and "cover" or "pages"; ui.RefreshLibraryView(false) end)
    ui.readerNext:SetScript("OnClick",function() local library=ui.data and Books.Normalize(ui.data); if not library or not library.books[ui.readerBook] then return end; ui.readerPage=math.min(Books.MAX_PAGES,(ui.readerPage or 0)+1); ui.readerMode=ui.readerPage==0 and "cover" or "pages"; ui.RefreshLibraryView(false) end)
    ui.libraryLoading=parent:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); ui.libraryLoading:SetPoint("CENTER",0,0); ui.libraryLoading:SetText("Loading house library..."); ui.libraryLoading:Hide()
    ui.RefreshLibraryView=function()
        local library=ui.data and Books.Normalize(ui.data); local book=library and library.books[ui.readerBook]
        for index,slot in ipairs(ui.librarySlots) do SetSlotBook(slot,library and library.books[index]); slot.selected:SetShown(index==ui.readerBook) end
        local coverMode=(ui.readerPage or 0)==0; ui.readerMode=coverMode and "cover" or "pages"; ui.libraryCover:SetShown(coverMode); ui.libraryPage:SetShown(not coverMode)
        local preview=book or NewBook(ui.readerBook,ui.readerBook); local color=COVER_COLORS[math.floor(Clamp(preview.cover,1,Books.MAX_COVERS))] or COVER_COLORS[1]; ui.libraryCoverTint:SetColorTexture(color[1],color[2],color[3],0.25); ui.libraryPageTint:SetColorTexture(color[1],color[2],color[3],0.25); ui.libraryCoverTitle:SetText(preview.title); ui.libraryCoverAuthor:SetText(preview.author)
        local coverIcon=TextureValue(preview.icon); ui.libraryCoverIcon:SetTexture(coverIcon); ui.libraryCoverIcon:SetShown(book~=nil and coverIcon~=nil)
        ui.readerPageLabel:SetText(coverMode and "Front Cover" or ("Page "..tostring(ui.readerPage))); ui.readerPrevious:SetEnabled((ui.readerPage or 0)>0); ui.readerNext:SetEnabled(book~=nil and (ui.readerPage or 0)<Books.MAX_PAGES)
        if not coverMode then ui.readerText:SetText(book and book.pages[ui.readerPage] or "") end
    end
end

function Books.BuildUI(context)
    local ui={ctx=context,data=nil,canEdit=false,dirty=false}
    BuildLibrary(ui,context.libraryPage); BuildMyBooks(ui,context.booksPage)
    function ui:Update(data,canEdit,loading)
        local changedHouse=self.data~=data; self.data=data; self.canEdit=canEdit==true and not loading; if data then Books.Normalize(data) end
        if changedHouse then self.bookIndex=1; self.pageIndex=0; self.editorMode="cover"; self.readerBook=1; self.readerPage=0; self.readerMode="cover"; self.dirty=false; self.removeConfirm:Hide() end
        self.libraryLoading:SetShown(loading==true); self.readerFrame:SetShown(loading~=true); for _,slot in ipairs(self.librarySlots) do slot:SetShown(loading~=true) end
        self.RefreshEditor(); if not loading then self.RefreshLibraryView(not changedHouse) end
    end
    function ui:OpenLibraryBook(bookIndex)
        self.readerBook=math.floor(Clamp(bookIndex or 1,1,Books.MAX_BOOKS)); self.readerPage=0; self.readerMode="cover"; self.RefreshLibraryView(false)
    end
    return ui
end
