local ADDON_NAME = ...
local PREFIX = "RateMyHouse"

local RMH = CreateFrame("Frame")
local currentHouse
local currentHouseKey
local currentChannelName
local currentChannelID
local pendingOffers = {}
local offerTimer
local suppressChannelName
local pendingDeliveryTicker
local pendingInbound = {}
local pendingTransferTimers = {}
local currentHouseOwned = false
local houseStateSettling = false
local pendingHouseSignature
local pendingHouseSince = 0
local pendingHouseRefreshToken = 0
local houseSyncPending = false
local houseSyncFallbackTimer

-- Direct addon whispers cannot determine whether a character is online. Sending
-- them to an offline homeowner makes WoW print "No player named ... is currently
-- playing". Exact-house synchronization is therefore restricted to the temporary
-- channel shared by RMH users who are confirmed to be on the same plot.
local ENABLE_UNCONFIRMED_WHISPER_DELIVERY = false

local selectedRating
local selectedCategoryKey = "custom"
local viewedProfileGUID
local viewedProfileName
local viewedProfileHouseKey
local linkedReviewMode = false
local activePage = "RATE"
local customCategoryTitle = ""
local pendingFinalComment = ""
local pendingFinalCommentHouseKey

local MAX_DESCRIPTION_BYTES = 120
local MAX_TITLE_BYTES = 48
local MAX_COMMENTS = 500
local MAX_CRITIC_NAME_BYTES = 32
local MAX_CRITIC_DESCRIPTION_BYTES = 140
-- Must fit the one-line house header without Blizzard adding an ellipsis.
-- The Profile input, SavedVariables cleanup, sync payload and header all share
-- this exact limit.
local MAX_HOST_DESCRIPTION_BYTES = 32
local MAX_CUSTOM_CATEGORY_BYTES = 32
local MAX_RATED_CATEGORIES = 50
local MAX_MARKET_TITLE_BYTES = 32
local MAX_MARKET_HEADER_DESCRIPTION_BYTES = 96
local MAX_MARKET_ITEM_NAME_BYTES = 48
local MAX_MARKET_DESCRIPTION_BYTES = 64
local MAX_MARKET_SLOTS = 6
local MAX_MARKET_PRICE_COPPER = 999999999
local sessionResetHouses = {}
local BookX = _G.RateMyHouseBooks
local libraryUIRefreshQueued = false

local ReviewCategories = {}
local ReviewCategoryByKey = {}
local ReviewCategoryOrder = {}
local CORE_CATEGORY_DEFS = {
    { key="custom",      label="Custom",      iconTexture=1505962 },
    { key="interior",    label="Interior",    iconTexture=7439625 },
    { key="exterior",    label="Exterior",    iconTexture="Interface\\Icons\\Achievement_Zone_ElwynnForest" },
    { key="landscape",   label="Landscape",   iconTexture=236712 },
    { key="market",      label="Market",      iconTexture="Interface\\Icons\\INV_Misc_Coin_01" },
    { key="library",     label="Library",     iconTexture="Interface\\Icons\\INV_Misc_Book_09" },
    { key="pets",        label="Pets",        iconTexture="Interface\\Icons\\INV_Box_PetCarrier_01" },
    { key="creativity",  label="Creativity",  iconTexture="Interface\\Icons\\INV_Inscription_Tradeskill01" },
    { key="theme",       label="Theme",       iconTexture=454046 }, -- trade_archaeology_chestoftinyglassanimals
    { key="hospitality", label="Hospitality", iconTexture="Interface\\Icons\\INV_Drink_18" },
}
local CORE_CATEGORY_INDEX = {}
for index,definition in ipairs(CORE_CATEGORY_DEFS) do CORE_CATEGORY_INDEX[definition.key]=index end
local DEFAULT_CATEGORY_MASK = (2 ^ #CORE_CATEGORY_DEFS) - 1

local function NormalizeCategoryMask(value)
    value=math.floor(tonumber(value) or DEFAULT_CATEGORY_MASK)
    value=math.max(0,math.min(DEFAULT_CATEGORY_MASK,value))
    if value==0 then value=1 end
    return value
end

local function CategoryMaskHas(mask,key)
    local index=CORE_CATEGORY_INDEX[tostring(key or "")]
    if not index then return false end
    local bit=2^(index-1); mask=NormalizeCategoryMask(mask)
    return math.floor(mask/bit)%2==1
end

local function CategoryMaskSet(mask,key,enabled)
    local index=CORE_CATEGORY_INDEX[tostring(key or "")]
    if not index then return NormalizeCategoryMask(mask) end
    mask=NormalizeCategoryMask(mask); local bit=2^(index-1); local active=math.floor(mask/bit)%2==1
    if enabled and not active then mask=mask+bit elseif not enabled and active then mask=mask-bit end
    return NormalizeCategoryMask(mask)
end

local function EnabledCategoryCount(mask)
    mask=NormalizeCategoryMask(mask); local count=0
    for index=1,#CORE_CATEGORY_DEFS do if math.floor(mask/(2^(index-1)))%2==1 then count=count+1 end end
    return count
end

local function UpdateRatingStarRow(stars,rating,hasRatings)
    local normalized=math.max(0,math.min(5,(tonumber(rating) or 0)/2))
    for index,star in ipairs(stars or {}) do
        local fill=hasRatings and math.max(0,math.min(1,normalized-(index-1))) or 0
        star:SetDesaturated(fill<=0.02)
        star:SetAlpha(0.18+(0.82*fill))
        if fill<=0.02 then star:SetVertexColor(0.58,0.55,0.50) else star:SetVertexColor(1,0.82,0.08) end
    end
end

local UpdateUI

local function SetHouseSyncPending(pending, timeout)
    houseSyncPending = pending and true or false
    if houseSyncFallbackTimer and houseSyncFallbackTimer.Cancel then houseSyncFallbackTimer:Cancel() end
    houseSyncFallbackTimer = nil
    if houseSyncPending and timeout and C_Timer and C_Timer.NewTimer then
        houseSyncFallbackTimer = C_Timer.NewTimer(math.max(1, timeout), function()
            houseSyncFallbackTimer = nil
            houseSyncPending = false
            if UpdateUI then UpdateUI() end
        end)
    end
end

local function QueueLibraryUIRefresh()
    if libraryUIRefreshQueued then return end
    libraryUIRefreshQueued = true
    if C_Timer and C_Timer.After then
        C_Timer.After(0.10, function()
            libraryUIRefreshQueued = false
            if UpdateUI then UpdateUI() end
        end)
    else
        libraryUIRefreshQueued = false
        if UpdateUI then UpdateUI() end
    end
end

local function Now()
    if GetServerTime then
        return GetServerTime()
    end
    return time()
end

local function RevisionStamp(oldRevision)
    local base = Now() * 1000
    if GetTime then
        base = base + math.floor((GetTime() % 1) * 999)
    end
    oldRevision = tonumber(oldRevision) or 0
    if base <= oldRevision then
        base = oldRevision + 1
    end
    return base
end

local function PlayerFullName()
    local name, realm = UnitFullName("player")
    name = name or UnitName("player") or "Unknown"
    if realm and realm ~= "" then
        return name .. "-" .. realm
    end
    return name
end

local function PlayerGUID()
    return UnitGUID("player") or PlayerFullName()
end

local function ShortName(name)
    if not name then return "" end
    if Ambiguate then
        local ok, value = pcall(Ambiguate, name, "short")
        if ok and value then
            return value
        end
    end
    return (name:match("^([^%-]+)") or name)
end

local function CanonicalPlayerName(name)
    name = tostring(name or ""):gsub("[%c%s]", ""):lower()
    return string.sub(name, 1, 64)
end

local function SamePlayerName(a, b)
    local canonicalA, canonicalB = CanonicalPlayerName(a), CanonicalPlayerName(b)
    local shortA, realmA = canonicalA:match("^([^%-]+)%-(.+)$")
    local shortB, realmB = canonicalB:match("^([^%-]+)%-(.+)$")
    shortA, shortB = shortA or canonicalA, shortB or canonicalB
    if shortA == "" or shortA ~= shortB then return false end
    -- When both sides carry a realm, require an exact realm match. A short
    -- Housing owner name may still be compared with a realm-qualified sender.
    if realmA and realmB then return realmA == realmB end
    return true
end

local function IsKnownAccountCharacter(name)
    local canonical = CanonicalPlayerName(name)
    -- A short name is not account identity: another realm can have a character
    -- with the same short name. Only use this cache when the Housing API supplies
    -- a realm-qualified name; live own-plot detection remains authoritative.
    if canonical == "" or not canonical:find("-", 1, true) then return false end
    return RateMyHouseDB and RateMyHouseDB.accountCharacters and RateMyHouseDB.accountCharacters[canonical] == true
end

local function SplitTabs(message)
    local fields = {}
    local start = 1
    while true do
        local pos = string.find(message, "\t", start, true)
        if not pos then
            fields[#fields + 1] = string.sub(message, start)
            break
        end
        fields[#fields + 1] = string.sub(message, start, pos - 1)
        start = pos + 1
    end
    return fields
end

local function HashString(text)
    local hash = 5381
    for i = 1, #text do
        hash = (hash * 33 + string.byte(text, i)) % 2147483647
    end
    return hash
end

local function SyncHouseID(houseKey)
    local key = tostring(houseKey or "")
    -- Two independent compact hashes keep the temporary channel and every
    -- payload plot-specific without exposing the full owner/plot key. This also
    -- makes an accidental cross-plot hash collision vanishingly unlikely.
    return tostring(HashString(key)) .. "x" .. tostring(HashString("rmh2|" .. key))
end

local function ChannelForHouse(houseKey)
    return "RMH" .. SyncHouseID(houseKey)
end

local function Utf8SafeTruncate(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 = 1
            if byte >= 240 then
                length = 4
            elseif byte >= 224 then
                length = 3
            elseif byte >= 192 then
                length = 2
            end
            if cut + length - 1 > maxBytes then
                cut = cut - 1
            end
            break
        end
        cut = cut - 1
    end
    return string.sub(text, 1, math.max(cut, 0))
end

local function CleanText(text, maxBytes)
    text = tostring(text or "")
    text = text:gsub("[%c]", " ")
    text = text:gsub("%s+", " ")
    if strtrim then
        text = strtrim(text)
    else
        text = text:match("^%s*(.-)%s*$") or text
    end
    return Utf8SafeTruncate(text, maxBytes)
end

local function CleanDescription(text)
    return CleanText(text, MAX_DESCRIPTION_BYTES)
end

local function CleanHostDescription(text)
    return CleanText(text, MAX_HOST_DESCRIPTION_BYTES)
end

local function CleanCustomCategoryTitle(text)
    return CleanText(text, MAX_CUSTOM_CATEGORY_BYTES)
end

local function CleanTitle(text)
    return CleanText(text, MAX_TITLE_BYTES)
end

local function CleanMarketTitle(text, fallback)
    text = CleanText(text, MAX_MARKET_TITLE_BYTES)
    if text == "" then text = fallback or "" end
    return text
end

local function CleanItemName(text, itemID)
    local canonical
    itemID = math.floor(tonumber(itemID) or 0)
    if itemID > 0 and C_Item and C_Item.GetItemNameByID then
        local ok, value = pcall(C_Item.GetItemNameByID, itemID)
        if ok and type(value) == "string" and value ~= "" then canonical = value end
    end
    if not canonical and itemID > 0 and GetItemInfo then
        local ok, value = pcall(GetItemInfo, itemID)
        if ok and type(value) == "string" and value ~= "" then canonical = value end
    end
    text = tostring(canonical or text or "Item")
    -- Profession quality atlases and other inline hyperlink markup are display
    -- instructions, not part of the item's real name. Strip every supported
    -- WoW escape form before the Market stores or synchronizes the title.
    text = text:gsub("|H.-|h(.-)|h", "%1")
    text = text:gsub("|A.-|a", "")
    text = text:gsub("|T.-|t", "")
    text = text:gsub("|c%x%x%x%x%x%x%x%x", "")
    text = text:gsub("|r", "")
    text = text:gsub("^%[", ""):gsub("%]$", "")
    text = CleanText(text, MAX_MARKET_ITEM_NAME_BYTES)
    if text == "" then text = itemID > 0 and ("Item " .. tostring(itemID)) or "Item" end
    return text
end

local function RefreshMarketItemMetadata(slot)
    if type(slot)~="table" then return slot end
    local itemID=math.floor(tonumber(slot.itemID) or 0); if itemID<=0 then return slot end
    local name,icon
    if C_Item and C_Item.GetItemNameByID then local ok,value=pcall(C_Item.GetItemNameByID,itemID); if ok then name=value end end
    if C_Item and C_Item.GetItemIconByID then local ok,value=pcall(C_Item.GetItemIconByID,itemID); if ok then icon=tonumber(value) end end
    if GetItemInfo then
        local ok,loadedName,_,_,_,_,_,_,_,_,loadedIcon=pcall(GetItemInfo,itemID)
        if ok then name=loadedName or name; icon=tonumber(loadedIcon) or icon end
    end
    if (not name or name=="") and C_Item and C_Item.RequestLoadItemDataByID then pcall(C_Item.RequestLoadItemDataByID,itemID) end
    slot.itemName=CleanItemName(name or slot.itemName,itemID)
    slot.icon=(icon and icon>0) and icon or tonumber(slot.icon) or 134400
    return slot
end

local function NormalizeMarketData(data)
    if not data then return nil end
    local market = type(data.market) == "table" and data.market or {}
    market.revision = tonumber(market.revision) or 0
    market.featureTitle = CleanMarketTitle(market.featureTitle, "Featured Item")
    market.marketplaceTitle = CleanMarketTitle(market.marketplaceTitle, "Today's Specials")
    market.description = CleanText(market.description or "", MAX_MARKET_HEADER_DESCRIPTION_BYTES)
    local oldSlots = type(market.slots) == "table" and market.slots or {}
    local slots = {}
    for index = 1, MAX_MARKET_SLOTS do
        local source = oldSlots[index]
        local itemID = source and math.floor(tonumber(source.itemID) or 0) or 0
        if itemID > 0 then
            slots[index] = RefreshMarketItemMetadata({
                itemID = itemID,
                itemName = CleanItemName(source.itemName, itemID),
                icon = tonumber(source.icon) or 134400,
                quantity = math.max(1, math.min(999, math.floor(tonumber(source.quantity) or 1))),
                priceCopper = math.max(0, math.min(MAX_MARKET_PRICE_COPPER, math.floor(tonumber(source.priceCopper) or 0))),
                description = CleanText(source.description or "", MAX_MARKET_DESCRIPTION_BYTES),
            })
        end
    end
    market.slots = slots
    data.market = market
    return market
end

local function CopyMarketSlots(slots)
    local copy = {}
    for index = 1, MAX_MARKET_SLOTS do
        local source = slots and slots[index]
        if source and tonumber(source.itemID) and tonumber(source.itemID) > 0 then
            copy[index] = {
                itemID = math.floor(tonumber(source.itemID) or 0),
                itemName = CleanItemName(source.itemName, source.itemID),
                icon = tonumber(source.icon) or 134400,
                quantity = math.max(1, math.min(999, math.floor(tonumber(source.quantity) or 1))),
                priceCopper = math.max(0, math.min(MAX_MARKET_PRICE_COPPER, math.floor(tonumber(source.priceCopper) or 0))),
                description = CleanText(source.description or "", MAX_MARKET_DESCRIPTION_BYTES),
            }
        end
    end
    return copy
end

local function ScanTradeableBackpackItems()
    local items, bags, seenBags = {}, {}, {}
    local function AddBag(bag)
        bag = tonumber(bag)
        if bag and not seenBags[bag] then seenBags[bag] = true; bags[#bags + 1] = bag end
    end
    AddBag(0)
    for bag = 1, (tonumber(NUM_BAG_SLOTS) or 4) do AddBag(bag) end
    if Enum and Enum.BagIndex and Enum.BagIndex.ReagentBag then AddBag(Enum.BagIndex.ReagentBag) end

    for _, bag in ipairs(bags) do
        local slots = 0
        if C_Container and C_Container.GetContainerNumSlots then
            local ok, value = pcall(C_Container.GetContainerNumSlots, bag)
            if ok then slots = tonumber(value) or 0 end
        end
        for slot = 1, (tonumber(slots) or 0) do
            local info
            if C_Container and C_Container.GetContainerItemInfo then
                local ok, value = pcall(C_Container.GetContainerItemInfo, bag, slot)
                if ok then info = value end
            end
            local itemID = info and tonumber(info.itemID) or 0
            local bound = info and info.isBound == true
            if itemID > 0 and not bound then
                local location
                if ItemLocation and ItemLocation.CreateFromBagAndSlot then
                    local ok, value = pcall(function() return ItemLocation:CreateFromBagAndSlot(bag, slot) end)
                    if ok then location = value end
                end
                if location and C_Item and C_Item.IsBound then
                    local ok, value = pcall(C_Item.IsBound, location)
                    if ok and value == true then bound = true end
                end
                local canTrade = true
                if location and C_Item and C_Item.CanBeTraded then
                    local ok, value = pcall(C_Item.CanBeTraded, location)
                    if ok and value == false then canTrade = false end
                end
                local questItem = false
                if C_Container and C_Container.GetContainerItemQuestInfo then
                    local ok, value = pcall(C_Container.GetContainerItemQuestInfo, bag, slot)
                    if ok and type(value) == "table" then questItem = value.isQuestItem == true end
                end
                if not bound and not questItem and canTrade then
                    local link = info.hyperlink
                    local itemName
                    local icon = tonumber(info.iconFileID) or 0
                    if C_Item and C_Item.GetItemNameByID then
                        local ok, value = pcall(C_Item.GetItemNameByID, itemID)
                        if ok then itemName = value end
                    end
                    if (not itemName or itemName == "") and GetItemInfo then
                        local loadedName, _, _, _, _, _, _, _, _, loadedIcon = GetItemInfo(itemID)
                        itemName = loadedName or itemName
                        icon = tonumber(loadedIcon) or icon
                    end
                    if (not itemName or itemName == "") and C_Item and C_Item.RequestLoadItemDataByID then
                        pcall(C_Item.RequestLoadItemDataByID, itemID)
                    end
                    items[#items + 1] = {
                        bag = bag,
                        slot = slot,
                        itemID = itemID,
                        itemName = CleanItemName(itemName or link, itemID),
                        icon = icon > 0 and icon or 134400,
                        stackCount = math.max(1, math.floor(tonumber(info.stackCount) or 1)),
                    }
                end
            end
        end
    end
    return items
end

local function CoinText(priceCopper)
    priceCopper = math.max(0, math.floor(tonumber(priceCopper) or 0))
    if GetCoinTextureString then return GetCoinTextureString(priceCopper, 13) end
    local gold = math.floor(priceCopper / 10000)
    local silver = math.floor((priceCopper % 10000) / 100)
    local copper = priceCopper % 100
    return string.format("%dg %02ds %02dc", gold, silver, copper)
end

local function CleanChatText(text)
    text = CleanDescription(text)
    -- A literal pipe begins WoW chat escape sequences. Never put user-entered
    -- pipes into ChatFrame_OpenChat, otherwise SendChatMessage can reject it.
    text = text:gsub("|", "")
    return text
end

local function QueryInsideHouseOrPlot()
    if C_Housing and C_Housing.IsInsideHouseOrPlot then
        local ok, inside = pcall(C_Housing.IsInsideHouseOrPlot)
        if ok then
            return inside == true
        end
    end
    return currentHouse ~= nil
end

local function QueryInsideOwnedHouseOrPlot()
    if C_Housing and C_Housing.IsInsideOwnedHouseOrPlot then
        local ok, inside = pcall(C_Housing.IsInsideOwnedHouseOrPlot)
        if ok then
            return inside == true
        end
    end
    return false
end

local function IsInsideHouseOrPlot()
    if houseStateSettling then return false end
    return currentHouse ~= nil and currentHouseKey ~= nil
end

local function IsInsideOwnedHouseOrPlot()
    return currentHouse ~= nil and currentHouseKey ~= nil and currentHouseOwned == true
end

local function IsHouseOwner(info)
    if not info then return false end
    return info == currentHouse and currentHouseKey ~= nil and currentHouseOwned == true
end

-- Account-wide ownership check. Ownership controls Profile editing and house
-- administration only; owners and their alts are allowed to submit ratings.
local function IsAccountOwnedHouse(info, data)
    if IsInsideOwnedHouseOrPlot() then return true end
    if data and data.accountOwned == true and data.ownershipConfirmedVersion == 13 then return true end
    local owner = (info and info.ownerName) or (data and data.ownerName) or ""
    if IsKnownAccountCharacter(owner) then
        if data then
            data.accountOwned = true
            data.ownershipConfirmedVersion = 13
        end
        return true
    end
    return false
end

local function GetHouseInfo()
    if not C_Housing or not C_Housing.GetCurrentHouseInfo then
        return nil
    end

    local ok, info = pcall(C_Housing.GetCurrentHouseInfo)
    if not ok or type(info) ~= "table" then
        return nil
    end

    if not info.houseGUID and not info.plotID then
        return nil
    end

    return info
end

local function MakeHouseKey(info)
    if not info then return nil end

    -- No single Housing field is treated as globally unique. In particular,
    -- houseGUID can be an opaque/reusable house identifier, while plot numbers
    -- repeat in other neighborhoods. The complete storage identity must always
    -- include neighborhood + plot + owner + house identifier.
    local plotID = tostring(info.plotID or "")
    local ownerName = CanonicalPlayerName(info.ownerName or "")
    local neighborhoodGUID = tostring(info.neighborhoodGUID or "")
    local neighborhoodName = CleanText(info.neighborhoodName or "", 96):lower()
    local houseGUID = tostring(info.houseGUID or "")
    if plotID == "" or ownerName == "" then return nil end
    if neighborhoodGUID == "" and neighborhoodName == "" then return nil end

    local function KeyPart(value)
        value = tostring(value or "")
        return tostring(#value) .. ":" .. value
    end
    local neighborhood = neighborhoodGUID ~= "" and ("guid:" .. neighborhoodGUID) or ("name:" .. neighborhoodName)
    local house = houseGUID ~= "" and ("guid:" .. houseGUID) or "guid:none"
    return "plot:v3|n=" .. KeyPart(neighborhood)
        .. "|p=" .. KeyPart(plotID)
        .. "|o=" .. KeyPart(ownerName)
        .. "|h=" .. KeyPart(house)
end

local function IsCurrentStorageKey(houseKey)
    return type(houseKey) == "string" and houseKey:sub(1, 8) == "plot:v3|"
end

local function EnsureDB()
    RateMyHouseDB = RateMyHouseDB or {}
    local previousVersion = tonumber(RateMyHouseDB.version) or 0
    RateMyHouseDB.version = 14
    RateMyHouseDB.minimap = RateMyHouseDB.minimap or { angle = 90 }
    RateMyHouseDB.houses = RateMyHouseDB.houses or {}
    RateMyHouseDB.profile = RateMyHouseDB.profile or {}
    RateMyHouseDB.accountCharacters = RateMyHouseDB.accountCharacters or {}
    RateMyHouseDB.pendingDeliveries = RateMyHouseDB.pendingDeliveries or {}
    if previousVersion < 10 then
        -- Legacy records may use the non-unique "unknown:<plotID>" key and may
        -- already contain mixed-house data. Keep those house tables untouched for
        -- recovery, but never deliver their queued payloads into a newly isolated
        -- plot. Versioned keys start clean and cannot inherit contaminated ratings.
        wipe(RateMyHouseDB.pendingDeliveries)
    end
    if previousVersion < 11 then
        -- Versions through 1.0.1 could permanently save a transient owned-plot
        -- result onto the next visited house. Remove only that derived ownership
        -- cache; ratings, reviews, comments and host settings remain untouched.
        for _, houseData in pairs(RateMyHouseDB.houses) do
            if type(houseData) == "table" then
                houseData.accountOwned = nil
                houseData.ownershipConfirmedVersion = nil
            end
        end
        RateMyHouseDB.profile.displayHouseKey = ""
    end
    if previousVersion < 12 then
        -- v1/v2 keys could use houseGUID alone or another incomplete identity.
        -- Never migrate those ratings into v3: a legacy table may already contain
        -- multiple houses. Preserve it for recovery, but make it unreachable from
        -- live plots and clear every delivery/profile pointer that could revive it.
        wipe(RateMyHouseDB.pendingDeliveries)
        RateMyHouseDB.profile.displayHouseKey = ""
        for _, houseData in pairs(RateMyHouseDB.houses) do
            if type(houseData) == "table" then
                houseData.accountOwned = nil
                houseData.ownershipConfirmedVersion = nil
            end
        end
    end
    if previousVersion < 13 then
        -- The score-threshold and rating-count reset options were removed in
        -- 1.0.2. Preserve all ratings and descriptions while deleting only the
        -- retired host settings. Previously confirmed homes remain confirmed.
        for _, houseData in pairs(RateMyHouseDB.houses) do
            if type(houseData) == "table" then
                local hs = houseData.hostSettings
                if type(hs) == "table" then
                    hs.resetBelowEnabled = nil
                    hs.resetBelowScore = nil
                    hs.resetAtRatingCount = nil
                    hs.resetAtRatingCountEnabled = nil
                end
                if houseData.accountOwned == true and houseData.ownershipConfirmedVersion == 12 then
                    houseData.ownershipConfirmedVersion = 13
                end
            end
        end
    end
    if previousVersion < 14 then
        -- Category visibility is additive and house-specific. Existing ratings,
        -- reviews, books and market data remain untouched; every existing house
        -- begins with the new ten-category catalog fully enabled.
        for _,houseData in pairs(RateMyHouseDB.houses) do
            if type(houseData)=="table" then
                houseData.hostSettings=houseData.hostSettings or {}
                houseData.hostSettings.categoryMask=NormalizeCategoryMask(houseData.hostSettings.categoryMask)
            end
        end
    end
    -- Migrate core house tables immediately so login/logout and sync cleanup paths
    -- are safe even for SavedVariables created before pending-delivery tombstones existed.
    for _, houseData in pairs(RateMyHouseDB.houses) do
        if type(houseData) == "table" then
            houseData.votes = houseData.votes or {}
            houseData.comments = houseData.comments or {}
            houseData.voteTombstones = houseData.voteTombstones or {}
            NormalizeMarketData(houseData)
            if BookX and BookX.Normalize then BookX.Normalize(houseData) end
        end
    end
    local accountCharacter = CanonicalPlayerName(PlayerFullName())
    if accountCharacter ~= "" and accountCharacter ~= "unknown" then
        RateMyHouseDB.accountCharacters[accountCharacter] = true
    end
    if RateMyHouseDB.profile.finalComment == nil then RateMyHouseDB.profile.finalComment = "" end
    if RateMyHouseDB.profile.displayHouseKey == nil then RateMyHouseDB.profile.displayHouseKey = "" end
    if RateMyHouseDB.profile.customIconKey == nil then RateMyHouseDB.profile.customIconKey = "" end
    local savedOwnedKey = RateMyHouseDB.profile.displayHouseKey
    if savedOwnedKey and savedOwnedKey ~= "" and RateMyHouseDB.houses[savedOwnedKey] and RateMyHouseDB.houses[savedOwnedKey].ownershipConfirmedVersion == 13 then
        RateMyHouseDB.houses[savedOwnedKey].accountOwned = true
    end
    -- Legacy critic fields remain only for SavedVariables/protocol compatibility.
    if RateMyHouseDB.profile.criticName == nil then RateMyHouseDB.profile.criticName = "" end
    if RateMyHouseDB.profile.criticDescription == nil then RateMyHouseDB.profile.criticDescription = "" end
    if RateMyHouseDB.profile.showCriticScore == nil then RateMyHouseDB.profile.showCriticScore = false end
end

local MAX_HOUSE_RATINGS = 999

local function SortAndTrimComments(data)
    data.comments = data.comments or {}
    table.sort(data.comments, function(a, b)
        local at = tonumber(a.timestamp) or 0
        local bt = tonumber(b.timestamp) or 0
        if at == bt then
            return (tonumber(a.revision) or 0) > (tonumber(b.revision) or 0)
        end
        return at > bt
    end)
end

local function EnforceRatingsLimit(data)
    if not data then return end
    data.votes = data.votes or {}
    data.comments = data.comments or {}
    data.voteTombstones = data.voteTombstones or {}
    local finalized = {}
    for guid, vote in pairs(data.votes) do
        if vote and tonumber(vote.rating) ~= nil then
            finalized[#finalized + 1] = { guid=guid, vote=vote, stamp=tonumber(vote.timestamp) or tonumber(vote.revision) or 0 }
        end
    end
    if #finalized <= MAX_HOUSE_RATINGS then return end
    table.sort(finalized, function(a,b)
        if a.stamp == b.stamp then return tostring(a.guid) < tostring(b.guid) end
        return a.stamp < b.stamp
    end)
    for i = 1, (#finalized - MAX_HOUSE_RATINGS) do
        local item = finalized[i]
        local guid, vote = item.guid, item.vote
        data.voteTombstones[guid] = math.max(tonumber(data.voteTombstones[guid]) or 0, tonumber(vote.revision) or 0)
        data.votes[guid] = nil
        for c = #data.comments, 1, -1 do
            if data.comments[c] and data.comments[c].voterGUID == guid then table.remove(data.comments, c) end
        end
    end
    SortAndTrimComments(data)
end

local portraitCaptureFrame, portraitCaptureTexture, portraitCaptureModel

local function CapturePlayerPortraitState(hostSettings)
    if type(hostSettings) ~= "table" then return end
    hostSettings.autoPortrait = true
    hostSettings.portraitCharacterName = PlayerFullName()
    hostSettings.portraitCharacterGUID = PlayerGUID() or ""

    local displayID = 0
    if UnitCreatureDisplayID then
        local ok, value = pcall(UnitCreatureDisplayID, "player")
        if ok and tonumber(value) then displayID = tonumber(value) or 0 end
    end

    -- Some Retail clients do not expose a reusable 2D portrait asset for an
    -- offline player. Capture both the generated portrait file (when one is
    -- exposed) and a player-model display ID so there are multiple fallbacks.
    if CreateFrame and UIParent then
        if not portraitCaptureFrame then
            portraitCaptureFrame = CreateFrame("Frame", nil, UIParent)
            portraitCaptureFrame:SetSize(64,64)
            portraitCaptureFrame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -200, 200)
            portraitCaptureFrame:Hide()
            portraitCaptureTexture = portraitCaptureFrame:CreateTexture(nil, "ARTWORK")
            portraitCaptureTexture:SetAllPoints()
        end
        if portraitCaptureTexture and SetPortraitTexture then
            local ok = pcall(SetPortraitTexture, portraitCaptureTexture, "player")
            if ok then
                local fileID = 0
                if portraitCaptureTexture.GetTextureFileID then
                    local okID, value = pcall(portraitCaptureTexture.GetTextureFileID, portraitCaptureTexture)
                    if okID then fileID = tonumber(value) or 0 end
                end
                local value = portraitCaptureTexture.GetTexture and portraitCaptureTexture:GetTexture() or nil
                if fileID > 0 then
                    hostSettings.portraitFileID = fileID
                elseif type(value) == "number" and value > 0 then
                    hostSettings.portraitFileID = value
                elseif type(value) == "string" and value ~= "" and not value:find("RateMyHouse\\Media\\icon", 1, true) then
                    hostSettings.portraitTexture = value
                end
            end
        end

        if displayID <= 0 then
            if not portraitCaptureModel then
                portraitCaptureModel = CreateFrame("PlayerModel", nil, UIParent)
                portraitCaptureModel:SetSize(64,64)
                portraitCaptureModel:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -300, 200)
                portraitCaptureModel:Hide()
            end
            if portraitCaptureModel.SetUnit then
                pcall(portraitCaptureModel.SetUnit, portraitCaptureModel, "player")
                if portraitCaptureModel.GetDisplayInfo then
                    local okID, value = pcall(portraitCaptureModel.GetDisplayInfo, portraitCaptureModel)
                    if okID and tonumber(value) then displayID = tonumber(value) or 0 end
                end
            end
        end
    end

    if displayID > 0 then hostSettings.portraitDisplayID = displayID end
    hostSettings.portraitFileID = tonumber(hostSettings.portraitFileID) or 0
    if type(hostSettings.portraitTexture) ~= "string" and type(hostSettings.portraitTexture) ~= "number" then
        hostSettings.portraitTexture = ""
    end
    hostSettings.portraitCapturedAt = tonumber(time and time() or 0) or 0
end

local function EnsureHouseData(houseKey, info)
    if not houseKey then return nil end
    EnsureDB()
    local data = RateMyHouseDB.houses[houseKey]
    if not data then
        data = {
            generation = 0,
            votes = {},
            comments = {},
        }
        RateMyHouseDB.houses[houseKey] = data
    end

    data.generation = tonumber(data.generation) or 0
    data.votes = data.votes or {}
    data.comments = data.comments or {}
    data.voteTombstones = data.voteTombstones or {}
    data.hostSettings = data.hostSettings or {
        commentsEnabled = true,
        resetOnLogin = false,
        hostDescription = "",
        autoPortrait = true,
    }
    if data.hostSettings.commentsEnabled == nil then data.hostSettings.commentsEnabled = true end
    if data.hostSettings.resetOnLogin == nil then data.hostSettings.resetOnLogin = false end
    data.hostSettings.categoryMask = NormalizeCategoryMask(data.hostSettings.categoryMask)
    if data.hostSettings.commentsEnabled==false and EnabledCategoryCount(data.hostSettings.categoryMask)==1 and CategoryMaskHas(data.hostSettings.categoryMask,"custom") then
        data.hostSettings.categoryMask=CategoryMaskSet(data.hostSettings.categoryMask,"interior",true)
    end
    data.hostSettings.resetBelowEnabled = nil
    data.hostSettings.resetBelowScore = nil
    data.hostSettings.resetAtRatingCount = nil
    data.hostSettings.resetAtRatingCountEnabled = nil
    data.hostSettings.hostDescription = CleanHostDescription(data.hostSettings.hostDescription or "")
    data.hostSettings.autoPortrait = true
    if data.hostSettings.portraitCharacterName == nil then data.hostSettings.portraitCharacterName = "" end
    if data.hostSettings.portraitCharacterGUID == nil then data.hostSettings.portraitCharacterGUID = "" end
    data.hostSettings.portraitDisplayID = tonumber(data.hostSettings.portraitDisplayID) or 0
    data.hostSettings.portraitFileID = tonumber(data.hostSettings.portraitFileID) or 0
    if type(data.hostSettings.portraitTexture) ~= "string" and type(data.hostSettings.portraitTexture) ~= "number" then data.hostSettings.portraitTexture = "" end
    data.hostSettings.portraitCapturedAt = tonumber(data.hostSettings.portraitCapturedAt) or 0
    NormalizeMarketData(data)
    if BookX and BookX.Normalize then BookX.Normalize(data) end

    -- Migrate older vote records forward without destroying them.
    for _, vote in pairs(data.votes) do
        vote.categories = vote.categories or {}
        vote.categoryTombstones = vote.categoryTombstones or {}
        -- Older RMH builds stored every custom review under one literal "custom" key.
        -- Promote that legacy row into a unique custom key so new custom reviews can coexist.
        local legacyCustom = vote.categories.custom
        if legacyCustom then
            local legacyRevision = tonumber(legacyCustom.revision) or RevisionStamp(0)
            local legacyKey = string.format("custom:%.0f", legacyRevision)
            if not vote.categories[legacyKey] then vote.categories[legacyKey] = legacyCustom end
            vote.categories.custom = nil
        end
    end

    for _, entry in ipairs(data.comments) do
        entry.title = CleanTitle(entry.title or "Final Vote")
        entry.comment = CleanDescription(entry.comment or entry.description or "")
        entry.categoryKey = entry.categoryKey or "all"
    end

    SortAndTrimComments(data)
    if IsKnownAccountCharacter(data.ownerName or "") then
        data.accountOwned = true
        data.ownershipConfirmedVersion = 13
    end

    if info then
        data.ownerName = info.ownerName or data.ownerName
        local liveOwnedHouse = (houseKey == currentHouseKey and info == currentHouse and IsHouseOwner(info))
        if liveOwnedHouse then
            data.accountOwned = true
            data.ownershipConfirmedVersion = 13
            local hs = data.hostSettings or {}
            CapturePlayerPortraitState(hs)
            data.hostSettings = hs
        elseif IsHouseOwner(info) then
            data.accountOwned = true
            data.ownershipConfirmedVersion = 13
        end
        data.houseName = info.houseName or data.houseName
        data.plotID = info.plotID or data.plotID
        data.neighborhoodName = info.neighborhoodName or data.neighborhoodName
        data.neighborhoodGUID = info.neighborhoodGUID or data.neighborhoodGUID
        data.houseGUID = info.houseGUID or data.houseGUID
        if IsHouseOwner(info) and UnitFactionGroup then
            local faction = select(1, UnitFactionGroup("player"))
            if faction == "Alliance" or faction == "Horde" then data.houseFaction = faction end
        end
    end
    return data
end

local function FactionForOwnedNeighborhood(neighborhoodGUID, existingFaction)
    local playerFaction = UnitFactionGroup and select(1, UnitFactionGroup("player")) or nil
    if C_Housing and C_Housing.GetNeighborhoodTextureSuffix and neighborhoodGUID then
        local ok, suffix = pcall(C_Housing.GetNeighborhoodTextureSuffix, neighborhoodGUID)
        suffix = ok and type(suffix) == "string" and suffix:lower() or ""
        if suffix:find("alliance", 1, true) then return "Alliance" end
        if suffix:find("horde", 1, true) then return "Horde" end
    end
    if playerFaction ~= "Alliance" and playerFaction ~= "Horde" then
        if existingFaction == "Alliance" or existingFaction == "Horde" then return existingFaction end
        return nil
    end
    if C_Housing and C_Housing.DoesFactionMatchNeighborhood and neighborhoodGUID then
        local ok, matches = pcall(C_Housing.DoesFactionMatchNeighborhood, neighborhoodGUID)
        if ok and type(matches) == "boolean" then
            if matches then return playerFaction end
            return playerFaction == "Alliance" and "Horde" or "Alliance"
        end
    end
    if existingFaction == "Alliance" or existingFaction == "Horde" then return existingFaction end
    return nil
end

local function StoreOwnedHouseList(houseInfos)
    if type(houseInfos) ~= "table" then return false end
    EnsureDB()
    local changed = false
    for _, info in ipairs(houseInfos) do
        if type(info) == "table" then
            local houseKey = MakeHouseKey(info)
            if houseKey then
                local data = EnsureHouseData(houseKey, info)
                data.accountOwned = true
                data.ownershipConfirmedVersion = 13
                data.houseFaction = FactionForOwnedNeighborhood(info.neighborhoodGUID, data.houseFaction)
                changed = true
            end
        end
    end
    return changed
end

local function RequestOwnedHouseList()
    -- If Blizzard's Housing Dashboard is visible, let Blizzard own its house-list
    -- request cycle. RMH can request its copy the next time its own editor needs it.
    local dashboard = _G.HousingDashboardFrame
    if dashboard and dashboard.IsShown and dashboard:IsShown() then return end
    if C_Housing and C_Housing.GetPlayerOwnedHouses then
        local ok, houseInfos = pcall(C_Housing.GetPlayerOwnedHouses)
        if ok and type(houseInfos) == "table" then StoreOwnedHouseList(houseInfos) end
    end
end

local function ClearHouseRatingState(data)
    if not data then return end
    data.votes = data.votes or {}
    data.comments = data.comments or {}
    data.voteTombstones = data.voteTombstones or {}
    wipe(data.votes)
    wipe(data.comments)
    wipe(data.voteTombstones)
end

local function ApplyGeneration(data, generation)
    generation = tonumber(generation) or 0
    if generation > (data.generation or 0) then
        data.generation = generation
        ClearHouseRatingState(data)
        return true
    end
    return false
end

local function RemoveVoteCommentsForGUID(data, guid)
    if not data or not data.comments or not guid then return end
    for i = #data.comments, 1, -1 do
        if data.comments[i] and data.comments[i].voterGUID == guid then table.remove(data.comments, i) end
    end
end

local function GetOrCreateVote(data, voterGUID)
    local vote = data.votes[voterGUID]
    if not vote then
        vote = { categories = {} }
        data.votes[voterGUID] = vote
    end
    vote.categories = vote.categories or {}
    vote.categoryTombstones = vote.categoryTombstones or {}
    return vote
end

local function MergeVote(data, generation, voterGUID, rating, revision, playerName, finalDescription, voteTimestamp, criticName, criticDescription, showCriticScore, criticScore, faction)
    generation = tonumber(generation) or 0
    rating = tonumber(rating)
    revision = tonumber(revision) or 0
    if not voterGUID or voterGUID == "" or rating == nil or rating < 0 or rating > 10 then
        return false
    end
    if generation < (data.generation or 0) then
        return false
    end
    local tombstoneRevision = tonumber(data.voteTombstones and data.voteTombstones[voterGUID]) or 0
    if revision <= tombstoneRevision then
        return false
    end

    ApplyGeneration(data, generation)
    local vote = GetOrCreateVote(data, voterGUID)
    if not vote.revision or revision > (tonumber(vote.revision) or 0) then
        local commentsEnabled = not data.hostSettings or data.hostSettings.commentsEnabled ~= false
        vote.rating = rating
        vote.revision = revision
        vote.timestamp = tonumber(voteTimestamp) or vote.timestamp or Now()
        vote.playerName = CleanTitle(playerName or vote.playerName or "Unknown")
        vote.finalDescription = commentsEnabled and CleanDescription(finalDescription or vote.finalDescription or "") or ""
        vote.criticName = CleanText(criticName or vote.criticName or "", MAX_CRITIC_NAME_BYTES)
        vote.criticDescription = CleanText(criticDescription or vote.criticDescription or "", MAX_CRITIC_DESCRIPTION_BYTES)
        if showCriticScore ~= nil and showCriticScore ~= "" then
            vote.showCriticScore = (showCriticScore == true or showCriticScore == 1 or showCriticScore == "1" or showCriticScore == "true")
        elseif vote.showCriticScore == nil then
            vote.showCriticScore = true
        end
        if criticScore ~= nil and criticScore ~= "" then
            vote.criticScore = tonumber(criticScore)
        end
        faction = tostring(faction or vote.faction or "")
        if faction == "Alliance" or faction == "Horde" then vote.faction = faction end
        for _, entry in ipairs(data.comments or {}) do
            if entry.voterGUID == voterGUID and entry.categoryKey == "all" then
                entry.rating = rating
            end
        end
        data.lastUpdated = Now()
        EnforceRatingsLimit(data)
        return true
    end
    return false
end

local function MergeCategoryRating(data, generation, voterGUID, categoryKey, rating, revision, label, parent, description, playerName, iconKey)
    generation = tonumber(generation) or 0
    rating = tonumber(rating)
    revision = tonumber(revision) or 0
    categoryKey = tostring(categoryKey or "")
    if not voterGUID or voterGUID == "" or categoryKey == "" or categoryKey == "all" or rating == nil or rating < 0 or rating > 10 then
        return false
    end
    if data and data.hostSettings and data.hostSettings.commentsEnabled == false
        and (categoryKey == "custom" or categoryKey:match("^custom:%d+$")) then
        return false
    end
    if generation < (data.generation or 0) then
        return false
    end
    local tombstoneRevision = tonumber(data.voteTombstones and data.voteTombstones[voterGUID]) or 0
    if revision <= tombstoneRevision then
        return false
    end

    ApplyGeneration(data, generation)
    local vote = GetOrCreateVote(data, voterGUID)
    local categoryTombstone = tonumber(vote.categoryTombstones and vote.categoryTombstones[categoryKey]) or 0
    if revision <= categoryTombstone then return false end
    vote.playerName = CleanTitle(playerName or vote.playerName or "Unknown")
    local old = vote.categories[categoryKey]
    if not old or revision > (tonumber(old.revision) or 0) then
        local commentsEnabled = not data.hostSettings or data.hostSettings.commentsEnabled ~= false
        vote.categories[categoryKey] = {
            rating = rating,
            revision = revision,
            label = CleanTitle(label or categoryKey),
            parent = CleanTitle(parent or ""),
            description = commentsEnabled and CleanDescription(description or "") or "",
            iconKey = CleanText(iconKey or "", 48),
        }
        data.lastUpdated = Now()
        return true
    end
    return false
end

local function MergeComment(data, generation, voterGUID, rating, revision, timestamp, playerName, categoryKey, title, commentText)
    if data and data.hostSettings and data.hostSettings.commentsEnabled == false then return false end
    generation = tonumber(generation) or 0
    rating = tonumber(rating)
    revision = tonumber(revision) or 0
    timestamp = tonumber(timestamp) or Now()
    commentText = CleanDescription(commentText)
    title = CleanTitle(title or "Final Vote")
    categoryKey = tostring(categoryKey or "all")

    if generation < (data.generation or 0) or not voterGUID or voterGUID == "" then
        return false
    end
    if rating == nil or rating < 0 or rating > 10 or commentText == "" then
        return false
    end

    ApplyGeneration(data, generation)

    local existingIndex
    local existingRevision = -1
    for index, entry in ipairs(data.comments or {}) do
        if entry.voterGUID == voterGUID then
            existingIndex = index
            existingRevision = tonumber(entry.revision) or 0
            break
        end
    end
    if existingRevision >= revision then
        return false
    end

    if existingIndex then
        table.remove(data.comments, existingIndex)
    end
    table.insert(data.comments, 1, {
        voterGUID = voterGUID,
        rating = rating,
        revision = revision,
        timestamp = timestamp,
        playerName = ShortName(playerName or "Unknown"),
        categoryKey = categoryKey,
        title = title,
        comment = commentText,
    })
    SortAndTrimComments(data)
    return true
end

local function RatingStats(data)
    if not data then return 0, 0 end
    EnforceRatingsLimit(data)
    local total, count = 0, 0
    for _, vote in pairs(data.votes or {}) do
        local rating = tonumber(vote.rating)
        if rating ~= nil and rating >= 0 and rating <= 10 then
            total = total + rating
            count = count + 1
        end
    end
    if count == 0 then return 0, 0 end
    return total / count, count
end

local function CategoryAverage(vote)
    if not vote or not vote.categories then return nil, 0 end
    local total, count = 0, 0
    for _, entry in pairs(vote.categories) do
        local rating = tonumber(entry.rating)
        if rating ~= nil and rating >= 0 and rating <= 10 then
            total = total + rating
            count = count + 1
        end
    end
    if count == 0 then return nil, 0 end
    return total / count, count
end

local function GetRatingColor(value)
    value = tonumber(value)
    if value == nil then return 1, 1, 1 end
    value = math.max(0, math.min(10, value))

    if value <= 3.333333 then
        local t = value / 3.333333
        return 1.00, 0.05 + (0.45 * t), 0.03
    elseif value <= 6.666667 then
        local t = (value - 3.333333) / 3.333334
        return 1.00, 0.50 + (0.45 * t), 0.03 + (0.03 * t)
    else
        local t = (value - 6.666667) / 3.333333
        return 1.00 - (0.82 * t), 0.95 + (0.05 * t), 0.06 + (0.12 * t)
    end
end

local function CriticAverage(voterGUID)
    EnsureDB()
    voterGUID = voterGUID or PlayerGUID()
    local total, count = 0, 0
    for houseKey, house in pairs(RateMyHouseDB.houses or {}) do
        if IsCurrentStorageKey(houseKey) then
            local vote = house and house.votes and house.votes[voterGUID]
            local rating = vote and tonumber(vote.rating)
            if rating ~= nil and rating >= 0 and rating <= 10 then
                total = total + rating
                count = count + 1
            end
        end
    end
    if count == 0 then return nil, 0 end
    return total / count, count
end

local function CategoryRecordCount(data)
    local count = 0
    for _, vote in pairs(data and data.votes or {}) do
        for _ in pairs(vote.categories or {}) do
            count = count + 1
        end
    end
    return count
end

-- -----------------------------------------------------------------------------
-- Stable RateMyHouse category registry. Legacy labels may still be sanitized
-- while importing old SavedVariables, but they are never inserted into the UI.
-- -----------------------------------------------------------------------------

local function AddReviewCategory(entry)
    if not entry or not entry.key or ReviewCategoryByKey[entry.key] then return end
    ReviewCategories[#ReviewCategories + 1] = entry
    ReviewCategoryByKey[entry.key] = entry
    ReviewCategoryOrder[entry.key] = #ReviewCategories
end

-- RateMyHouse 1.0.2 uses a deliberately small review vocabulary. Blizzard's
-- live Housing editor catalog is intentionally not exposed as rating rows: new
-- Housing decorations/items are discovered elsewhere, while ratings stay
-- stable across patches and saved reviews never change meaning.
local function RefreshReviewCategories()
    wipe(ReviewCategories); wipe(ReviewCategoryByKey); wipe(ReviewCategoryOrder)
    AddReviewCategory({key="all",label="Final Vote",parent="",kind="overall",icon="dashboard-panel-homestone-teleport-button"})
    for index,definition in ipairs(CORE_CATEGORY_DEFS) do
        AddReviewCategory({
            key=definition.key,label=definition.label,parent="",kind="review",
            orderIndex=index,iconTexture=definition.iconTexture,
        })
    end
    if selectedCategoryKey=="all" or (not ReviewCategoryByKey[selectedCategoryKey] and not tostring(selectedCategoryKey or ""):match("^custom:%d+$")) then
        selectedCategoryKey="custom"
    end
end

local function IsCustomCategoryKey(key)
    key = tostring(key or "")
    return key == "custom" or key:match("^custom:%d+$") ~= nil
end

local function GetSelectedCategory()
    if IsCustomCategoryKey(selectedCategoryKey) then return ReviewCategoryByKey.custom end
    return ReviewCategoryByKey[selectedCategoryKey] or ReviewCategoryByKey.custom or ReviewCategories[1]
end

local function HouseCategoryMask(data)
    local hs=data and data.hostSettings
    return NormalizeCategoryMask(hs and hs.categoryMask)
end

local function IsReviewCategoryEnabled(data,key)
    if IsCustomCategoryKey(key) then key="custom" end
    return CategoryMaskHas(HouseCategoryMask(data),key)
end

local function FirstEnabledCategoryKey(data,allowCustom)
    local mask=HouseCategoryMask(data)
    for _,definition in ipairs(CORE_CATEGORY_DEFS) do
        if CategoryMaskHas(mask,definition.key) and (allowCustom or definition.key~="custom") then return definition.key end
    end
    return "custom"
end

local function CountRatedCategories(vote)
    local count = 0
    for _, entry in pairs(vote and vote.categories or {}) do
        if tonumber(entry and entry.rating) ~= nil then count = count + 1 end
    end
    return count
end

local function MergeCategoryDelete(data, generation, voterGUID, categoryKey, revision)
    generation = tonumber(generation) or 0
    revision = tonumber(revision) or 0
    categoryKey = tostring(categoryKey or "")
    if not voterGUID or voterGUID == "" or categoryKey == "" or categoryKey == "all" then return false end
    if generation < (tonumber(data and data.generation) or 0) then return false end
    ApplyGeneration(data, generation)
    local vote = GetOrCreateVote(data, voterGUID)
    local previousTombstone = tonumber(vote.categoryTombstones and vote.categoryTombstones[categoryKey]) or 0
    if revision <= previousTombstone then return false end
    local old = vote.categories and vote.categories[categoryKey]
    if old and (tonumber(old.revision) or 0) > revision then return false end
    vote.categoryTombstones = vote.categoryTombstones or {}
    vote.categoryTombstones[categoryKey] = revision
    if vote.categories then vote.categories[categoryKey] = nil end
    return true
end

-- -----------------------------------------------------------------------------
-- Pending delivery synchronization
-- Ratings are created on the house plot, then may be delivered later by direct
-- addon whisper when the homeowner installs RateMyHouse and is online.
-- -----------------------------------------------------------------------------

local function SendWhisperAddon(payload, target)
    if not ENABLE_UNCONFIRMED_WHISPER_DELIVERY then return false end
    target = tostring(target or "")
    if target == "" or not C_ChatInfo or not C_ChatInfo.SendAddonMessage then return false end
    local ok, result = pcall(C_ChatInfo.SendAddonMessage, PREFIX, payload, "WHISPER", target)
    if not ok then return false end
    if result == nil then return true end
    if Enum and Enum.SendAddonMessageResult and Enum.SendAddonMessageResult.Success then
        return result == Enum.SendAddonMessageResult.Success
    end
    return result == 0 or result == true
end

local function PendingDeliveryID(houseKey, voterGUID)
    return tostring(HashString(tostring(houseKey or "") .. "|" .. tostring(voterGUID or "")))
end

local function PendingOwnerName(data)
    return CleanText(data and data.ownerName or "", 48)
end

local function QueuePendingVote(houseKey, data, voterGUID)
    if not ENABLE_UNCONFIRMED_WHISPER_DELIVERY then return end
    if not houseKey or not data or not voterGUID then return end
    EnsureDB()
    local vote = data.votes and data.votes[voterGUID]
    local ownerName = PendingOwnerName(data)
    if not vote or tonumber(vote.rating) == nil or ownerName == "" or SamePlayerName(ownerName, PlayerFullName()) then return end
    local id = PendingDeliveryID(houseKey, voterGUID)
    local old = RateMyHouseDB.pendingDeliveries[id] or {}
    RateMyHouseDB.pendingDeliveries[id] = {
        id = id,
        action = "vote",
        houseKey = tostring(houseKey),
        ownerName = ownerName,
        houseName = CleanTitle(data.houseName or ""),
        plotID = tostring(data.plotID or ""),
        neighborhoodName = CleanTitle(data.neighborhoodName or ""),
        generation = tonumber(data.generation) or 0,
        voterGUID = tostring(voterGUID),
        revision = tonumber(vote.revision) or 0,
        status = "pending",
        createdAt = tonumber(old.createdAt) or Now(),
        updatedAt = Now(),
        lastAttempt = 0,
        attempts = tonumber(old.attempts) or 0,
    }
end

local function QueuePendingReset(houseKey, data, voterGUID, resetRevision)
    if not ENABLE_UNCONFIRMED_WHISPER_DELIVERY then return end
    if not houseKey or not data or not voterGUID then return end
    EnsureDB()
    local ownerName = PendingOwnerName(data)
    if ownerName == "" or SamePlayerName(ownerName, PlayerFullName()) then return end
    local id = PendingDeliveryID(houseKey, voterGUID)
    local old = RateMyHouseDB.pendingDeliveries[id] or {}
    RateMyHouseDB.pendingDeliveries[id] = {
        id = id,
        action = "reset",
        houseKey = tostring(houseKey),
        ownerName = ownerName,
        houseName = CleanTitle(data.houseName or ""),
        plotID = tostring(data.plotID or ""),
        neighborhoodName = CleanTitle(data.neighborhoodName or ""),
        generation = tonumber(data.generation) or 0,
        voterGUID = tostring(voterGUID),
        revision = tonumber(resetRevision) or 0,
        status = "pending",
        createdAt = tonumber(old.createdAt) or Now(),
        updatedAt = Now(),
        lastAttempt = 0,
        attempts = tonumber(old.attempts) or 0,
    }
end

local function SendPendingAck(target, id, revision, state)
    if not target or target == "" then return end
    SendWhisperAddon(table.concat({"A", tostring(id or ""), tostring(revision or 0), tostring(state or "delivered")}, "\t"), target)
end

local function SchedulePendingWhisper(delay, target, payload)
    if C_Timer and C_Timer.After then
        C_Timer.After(delay or 0, function() SendWhisperAddon(payload, target) end)
    else
        SendWhisperAddon(payload, target)
    end
end

local function SendPendingPayload(record, target)
    if not record or not target or target == "" then return end
    EnsureDB()
    local data = RateMyHouseDB.houses and RateMyHouseDB.houses[record.houseKey]
    if not data then return end
    local id = tostring(record.id or PendingDeliveryID(record.houseKey, record.voterGUID))
    local generation = tonumber(record.generation) or tonumber(data.generation) or 0

    if record.action == "reset" then
        local payload = table.concat({
            "B", id, tostring(generation), tostring(record.voterGUID or ""), tostring(record.revision or 0),
        }, "\t")
        SchedulePendingWhisper(0.03, target, payload)
        return
    end

    local vote = data.votes and data.votes[record.voterGUID]
    if not vote or tonumber(vote.rating) == nil then return end
    local delay = 0.03
    local votePayload = table.concat({
        "W", id, tostring(generation), tostring(record.voterGUID or ""), tostring(vote.rating), tostring(vote.revision or 0),
        Utf8SafeTruncate(CleanTitle(vote.playerName or "Unknown"), 16),
        Utf8SafeTruncate(CleanDescription(vote.finalDescription or ""), 50), tostring(vote.timestamp or Now()), tostring(vote.faction or ""),
    }, "\t")
    SchedulePendingWhisper(delay, target, votePayload); delay = delay + 0.04

    local metaPayload = table.concat({
        "M", id, Utf8SafeTruncate(CleanTitle(data.houseName or record.houseName or ""), 40),
        tostring(data.plotID or record.plotID or ""), Utf8SafeTruncate(CleanTitle(data.neighborhoodName or record.neighborhoodName or ""), 40),
    }, "\t")
    SchedulePendingWhisper(delay, target, metaPayload); delay = delay + 0.04

    local criticPayload = table.concat({
        "I", id, tostring(record.voterGUID or ""),
        Utf8SafeTruncate(CleanText(vote.criticName or "", MAX_CRITIC_NAME_BYTES), 32),
        Utf8SafeTruncate(CleanText(vote.criticDescription or "", MAX_CRITIC_DESCRIPTION_BYTES), 50),
        vote.showCriticScore == false and "0" or "1", vote.criticScore and string.format("%.2f", vote.criticScore) or "",
    }, "\t")
    SchedulePendingWhisper(delay, target, criticPayload); delay = delay + 0.04

    local categoryCount = 0
    for categoryKey, entry in pairs(vote.categories or {}) do
        categoryCount = categoryCount + 1
        local categoryPayload = table.concat({
            "Y", id, tostring(generation), tostring(record.voterGUID or ""), tostring(categoryKey or ""),
            tostring(entry.rating), tostring(entry.revision or 0), Utf8SafeTruncate(CleanTitle(entry.label or categoryKey), 24),
            Utf8SafeTruncate(CleanDescription(entry.description or ""), 35), Utf8SafeTruncate(CleanText(entry.iconKey or "", 32), 32),
        }, "\t")
        SchedulePendingWhisper(delay, target, categoryPayload)
        delay = delay + 0.04
    end

    local endPayload = table.concat({"E", id, tostring(categoryCount), tostring(vote.revision or record.revision or 0)}, "\t")
    SchedulePendingWhisper(delay, target, endPayload)
end

local function TryCompletePendingInbound(id)
    local session = pendingInbound[id]
    if not session or not session.endReceived then return end
    if session.action == "reset" then return end
    if not session.voteReceived then return end
    if (tonumber(session.receivedCount) or 0) < (tonumber(session.expectedCategories) or 0) then return end
    -- A pending-delivery vote is a complete snapshot. Remove older categories that
    -- are no longer present so a deleted category cannot reappear on the homeowner.
    local snapshotData = EnsureHouseData(session.houseKey, nil)
    local snapshotVote = snapshotData and snapshotData.votes and snapshotData.votes[session.voterGUID]
    if snapshotVote and snapshotVote.categories then
        for categoryKey in pairs(snapshotVote.categories) do
            if not session.receivedCategories[categoryKey] then snapshotVote.categories[categoryKey] = nil end
        end
    end
    SendPendingAck(session.sender, id, session.revision or 0, "delivered")
    pendingInbound[id] = nil
    if UpdateUI then UpdateUI() end
end

local function HandlePendingWhisperMessage(message, sender)
    local f = SplitTabs(message)
    local kind = f[1]
    if kind ~= "N" and kind ~= "G" and kind ~= "W" and kind ~= "M" and kind ~= "I" and kind ~= "Y" and kind ~= "E" and kind ~= "A" and kind ~= "B" then
        return false
    end
    EnsureDB()

    if kind == "N" then
        local id, action, ownerName, houseKey = f[2], f[3], f[4], f[5]
        local generation, voterGUID, revision = tonumber(f[6]) or 0, f[7], tonumber(f[8]) or 0
        if not id or id == "" or not houseKey or houseKey == "" or not voterGUID or voterGUID == "" then return true end
        if not SamePlayerName(ownerName or "", PlayerFullName()) then return true end
        local data = EnsureHouseData(houseKey, nil)
        data.ownerName = ownerName or data.ownerName
        data.houseName = CleanTitle(f[9] or data.houseName or "")
        data.plotID = (f[10] and f[10] ~= "") and f[10] or data.plotID
        data.neighborhoodName = CleanTitle(f[11] or data.neighborhoodName or "")

        local localGeneration = tonumber(data.generation) or 0
        local tombstone = tonumber(data.voteTombstones and data.voteTombstones[voterGUID]) or 0
        local localVote = data.votes and data.votes[voterGUID]
        local localRevision = localVote and tonumber(localVote.revision) or 0
        if localGeneration > generation then
            SendPendingAck(sender, id, revision, "stale")
            return true
        end
        if action == "vote" and localGeneration == generation and localVote and localRevision >= revision then
            SendPendingAck(sender, id, localRevision, "known")
            return true
        end
        if action == "reset" and localGeneration == generation and tombstone >= revision then
            SendPendingAck(sender, id, tombstone, "known")
            return true
        end

        pendingInbound[id] = {
            id = id, action = action, sender = sender, ownerName = ownerName, houseKey = houseKey,
            generation = generation, voterGUID = voterGUID, revision = revision,
            receivedCategories = {}, receivedCount = 0, voteReceived = false, endReceived = false,
        }
        SendWhisperAddon(table.concat({"G", id}, "\t"), sender)
        return true

    elseif kind == "G" then
        local id = f[2]
        local record = RateMyHouseDB.pendingDeliveries and RateMyHouseDB.pendingDeliveries[id]
        if not record or record.status == "delivered" then return true end
        if not SamePlayerName(sender or "", record.ownerName or "") then return true end
        record.status = "sending"
        record.lastAttempt = Now()
        record.attempts = (tonumber(record.attempts) or 0) + 1
        SendPendingPayload(record, sender)
        return true

    elseif kind == "A" then
        local id, revision, state = f[2], tonumber(f[3]) or 0, f[4] or "delivered"
        local record = RateMyHouseDB.pendingDeliveries and RateMyHouseDB.pendingDeliveries[id]
        if not record or not SamePlayerName(sender or "", record.ownerName or "") then return true end
        if revision >= (tonumber(record.revision) or 0) then
            record.status = "delivered"
            record.deliveryResult = state
            record.deliveredAt = Now()
            record.lastRecipient = sender
        end
        return true
    end

    local id = f[2]
    local session = id and pendingInbound[id]
    if not session or not SamePlayerName(sender or "", session.sender or "") then return true end
    local data = EnsureHouseData(session.houseKey, nil)
    data.ownerName = session.ownerName or data.ownerName

    if kind == "W" then
        local generation, voterGUID = tonumber(f[3]) or 0, f[4]
        local rating, revision = tonumber(f[5]), tonumber(f[6]) or 0
        MergeVote(data, generation, voterGUID, rating, revision, f[7], f[8], f[9], nil, nil, nil, nil, f[10])
        local vote = data.votes and data.votes[voterGUID]
        local tombstone = tonumber(data.voteTombstones and data.voteTombstones[voterGUID]) or 0
        if tombstone >= revision then
            SendPendingAck(sender, id, tombstone, "stale")
            pendingInbound[id] = nil
            return true
        end
        if vote and tonumber(vote.revision) and tonumber(vote.revision) >= revision then
            session.voteReceived = true
            session.revision = math.max(tonumber(session.revision) or 0, revision)
        end
        TryCompletePendingInbound(id)
        return true

    elseif kind == "M" then
        data.houseName = CleanTitle(f[3] or data.houseName or "")
        data.plotID = (f[4] and f[4] ~= "") and f[4] or data.plotID
        data.neighborhoodName = CleanTitle(f[5] or data.neighborhoodName or "")
        return true

    elseif kind == "I" then
        local voterGUID = f[3]
        local vote = data.votes and data.votes[voterGUID]
        if vote then
            vote.criticName = CleanText(f[4] or "", MAX_CRITIC_NAME_BYTES)
            vote.criticDescription = CleanText(f[5] or "", MAX_CRITIC_DESCRIPTION_BYTES)
            vote.showCriticScore = f[6] ~= "0"
            vote.criticScore = tonumber(f[7])
        end
        return true

    elseif kind == "Y" then
        local generation, voterGUID, categoryKey = tonumber(f[3]) or 0, f[4], f[5]
        local rating, revision = tonumber(f[6]), tonumber(f[7]) or 0
        local tombstone = tonumber(data.voteTombstones and data.voteTombstones[voterGUID]) or 0
        if revision > tombstone then
            MergeCategoryRating(data, generation, voterGUID, categoryKey, rating, revision, f[8], "", f[9], nil, f[10])
        end
        if not session.receivedCategories[categoryKey] then
            session.receivedCategories[categoryKey] = true
            session.receivedCount = (tonumber(session.receivedCount) or 0) + 1
        end
        TryCompletePendingInbound(id)
        return true

    elseif kind == "E" then
        session.expectedCategories = tonumber(f[3]) or 0
        session.revision = math.max(tonumber(session.revision) or 0, tonumber(f[4]) or 0)
        session.endReceived = true
        TryCompletePendingInbound(id)
        return true

    elseif kind == "B" then
        local generation, voterGUID, resetRevision = tonumber(f[3]) or 0, f[4], tonumber(f[5]) or 0
        if generation < (tonumber(data.generation) or 0) then
            SendPendingAck(sender, id, resetRevision, "stale")
            pendingInbound[id] = nil
            return true
        end
        if generation > (tonumber(data.generation) or 0) then ApplyGeneration(data, generation) end
        local localVote = data.votes and data.votes[voterGUID]
        local localRevision = localVote and tonumber(localVote.revision) or 0
        data.voteTombstones = data.voteTombstones or {}
        data.voteTombstones[voterGUID] = math.max(tonumber(data.voteTombstones[voterGUID]) or 0, resetRevision)
        if not localVote or localRevision <= resetRevision then
            if data.votes then data.votes[voterGUID] = nil end
            RemoveVoteCommentsForGUID(data, voterGUID)
        end
        SendPendingAck(sender, id, resetRevision, "delivered")
        pendingInbound[id] = nil
        if UpdateUI then UpdateUI() end
        return true
    end

    return true
end

local function ProcessPendingDeliveries(force)
    if not ENABLE_UNCONFIRMED_WHISPER_DELIVERY then return end
    EnsureDB()
    local now = Now()
    local sent = 0
    for id, record in pairs(RateMyHouseDB.pendingDeliveries or {}) do
        if record and record.status ~= "delivered" and record.ownerName and record.ownerName ~= "" then
            local lastAttempt = tonumber(record.lastAttempt) or 0
            if force or (now - lastAttempt) >= 120 then
                local payload = table.concat({
                    "N", tostring(id), tostring(record.action or "vote"), Utf8SafeTruncate(CleanText(record.ownerName or "", 48), 48),
                    tostring(record.houseKey or ""), tostring(record.generation or 0), tostring(record.voterGUID or ""), tostring(record.revision or 0),
                }, "\t")
                if SendWhisperAddon(payload, record.ownerName) then
                    record.lastAttempt = now
                    record.attempts = (tonumber(record.attempts) or 0) + 1
                    if record.status == "sending" then record.status = "pending" end
                end
                sent = sent + 1
                if sent >= 5 then break end
            end
        end
    end
end

local function StartPendingDeliveryTicker()
    if pendingDeliveryTicker and pendingDeliveryTicker.Cancel then pendingDeliveryTicker:Cancel() end
    pendingDeliveryTicker = nil
    if not ENABLE_UNCONFIRMED_WHISPER_DELIVERY then
        EnsureDB()
        wipe(RateMyHouseDB.pendingDeliveries)
        return
    end
    if C_Timer and C_Timer.NewTicker then
        pendingDeliveryTicker = C_Timer.NewTicker(60, function() ProcessPendingDeliveries(false) end)
    end
    if C_Timer and C_Timer.After then
        C_Timer.After(3, function() ProcessPendingDeliveries(true) end)
    else
        ProcessPendingDeliveries(true)
    end
end

-- -----------------------------------------------------------------------------
-- Peer synchronization
-- -----------------------------------------------------------------------------

local function HideChannelFromChat(channelName)
    if not channelName or not ChatFrame_RemoveChannel then return end
    local count = NUM_CHAT_WINDOWS or 10
    for i = 1, count do
        local frame = _G["ChatFrame" .. i]
        if frame then pcall(ChatFrame_RemoveChannel, frame, channelName) end
    end
end

local function LeaveSyncChannel()
    if currentChannelName and LeaveChannelByName then
        pcall(LeaveChannelByName, currentChannelName)
    end
    currentChannelName = nil
    currentChannelID = nil
    wipe(pendingOffers)
    if offerTimer and offerTimer.Cancel then offerTimer:Cancel() end
    offerTimer = nil
end

local BroadcastHostPortrait

local function SendAddon(payload)
    if not currentChannelID or not C_ChatInfo or not C_ChatInfo.SendAddonMessage then
        return false
    end
    local ok, result = pcall(C_ChatInfo.SendAddonMessage, PREFIX, payload, "CHANNEL", currentChannelID)
    if not ok then return false end
    if result == nil then return true end
    if Enum and Enum.SendAddonMessageResult and Enum.SendAddonMessageResult.Success then
        return result == Enum.SendAddonMessageResult.Success
    end
    return result == 0 or result == true
end

BroadcastHostPortrait = function(houseKey, data, targetGUID)
    local hs = data and data.hostSettings or nil
    if not houseKey or not hs then return false end
    local payload = table.concat({
        "F", SyncHouseID(houseKey), targetGUID or "",
        Utf8SafeTruncate(CleanText(hs.portraitCharacterName or "", 48), 48),
        tostring(tonumber(hs.portraitDisplayID) or 0),
        tostring(tonumber(hs.portraitFileID) or 0),
        tostring(tonumber(hs.portraitCapturedAt) or 0),
    }, "\t")
    return SendAddon(payload)
end

local function BroadcastVote(houseKey, data, voterGUID)
    local vote = data and data.votes and data.votes[voterGUID]
    if not vote or vote.rating == nil then return end
    SendAddon(table.concat({
        "V", SyncHouseID(houseKey), tostring(data.generation or 0), voterGUID,
        tostring(vote.rating), tostring(vote.revision or 0),
        Utf8SafeTruncate(CleanTitle(vote.playerName or "Unknown"), 16), Utf8SafeTruncate(CleanDescription(vote.finalDescription or ""), 50),
        tostring(vote.timestamp or Now()), Utf8SafeTruncate(CleanText(vote.criticName or "", MAX_CRITIC_NAME_BYTES), MAX_CRITIC_NAME_BYTES),
        Utf8SafeTruncate(CleanText(vote.criticDescription or "", MAX_CRITIC_DESCRIPTION_BYTES), 50), vote.showCriticScore == false and "0" or "1",
        vote.criticScore and string.format("%.2f", vote.criticScore) or "", tostring(vote.faction or ""),
    }, "\t"))
end

local function BroadcastCategory(houseKey, data, voterGUID, categoryKey)
    local vote = data and data.votes and data.votes[voterGUID]
    local entry = vote and vote.categories and vote.categories[categoryKey]
    if not entry then return end
    SendAddon(table.concat({
        "K", SyncHouseID(houseKey), tostring(data.generation or 0), voterGUID, categoryKey,
        tostring(entry.rating), tostring(entry.revision or 0),
        Utf8SafeTruncate(CleanTitle(entry.label or categoryKey), 24),
        Utf8SafeTruncate(CleanDescription(entry.description or ""), 50), Utf8SafeTruncate(CleanTitle(vote.playerName or "Unknown"), 16),
        Utf8SafeTruncate(CleanText(entry.iconKey or "", 48), 48),
    }, "\t"))
end

local function BroadcastCategoryDelete(houseKey, data, voterGUID, categoryKey, revision)
    if not houseKey or not data or not voterGUID or not categoryKey then return end
    SendAddon(table.concat({
        "L", SyncHouseID(houseKey), tostring(data.generation or 0), voterGUID, tostring(categoryKey), tostring(revision or 0),
    }, "\t"))
end

local function BroadcastComment(houseKey, data, entry)
    if not entry then return end
    SendAddon(table.concat({
        "C", SyncHouseID(houseKey), tostring(data.generation or 0), entry.voterGUID,
        tostring(entry.rating), tostring(entry.revision or 0), tostring(entry.timestamp or Now()),
        Utf8SafeTruncate(CleanTitle(entry.playerName or "Unknown"), 16), tostring(entry.categoryKey or "all"),
        Utf8SafeTruncate(CleanTitle(entry.title or "Final Vote"), 24), Utf8SafeTruncate(CleanDescription(entry.comment or ""), 50),
    }, "\t"))
end

local function SendMarketHeader(houseKey, data, targetGUID)
    local market = NormalizeMarketData(data)
    if not houseKey or not market then return false end
    return SendAddon(table.concat({
        "MH", SyncHouseID(houseKey), targetGUID or "", tostring(market.revision or 0),
        Utf8SafeTruncate(CleanMarketTitle(market.featureTitle, "Featured Item"), MAX_MARKET_TITLE_BYTES),
        Utf8SafeTruncate(CleanMarketTitle(market.marketplaceTitle, "Today's Specials"), MAX_MARKET_TITLE_BYTES),
        Utf8SafeTruncate(CleanText(market.description or "", MAX_MARKET_HEADER_DESCRIPTION_BYTES), MAX_MARKET_HEADER_DESCRIPTION_BYTES),
    }, "\t"))
end

local function SendMarketSlot(houseKey, data, targetGUID, index)
    local market = NormalizeMarketData(data)
    if not houseKey or not market or index < 1 or index > MAX_MARKET_SLOTS then return false end
    local slot = market.slots[index]
    return SendAddon(table.concat({
        "MS", SyncHouseID(houseKey), targetGUID or "", tostring(market.revision or 0), tostring(index),
        tostring(slot and slot.itemID or 0), tostring(slot and slot.quantity or 0), tostring(slot and slot.priceCopper or 0),
        tostring(slot and slot.icon or 0), Utf8SafeTruncate(CleanItemName(slot and slot.itemName or "", slot and slot.itemID), MAX_MARKET_ITEM_NAME_BYTES),
        Utf8SafeTruncate(CleanText(slot and slot.description or "", MAX_MARKET_DESCRIPTION_BYTES), MAX_MARKET_DESCRIPTION_BYTES),
    }, "\t"))
end

local function BroadcastMarket(houseKey, data, targetGUID)
    if not houseKey or not data then return end
    local market=NormalizeMarketData(data)
    SendMarketHeader(houseKey, data, targetGUID)
    for index = 1, MAX_MARKET_SLOTS do
        local slot=market.slots[index]
        local payload=table.concat({
            "MS",SyncHouseID(houseKey),targetGUID or "",tostring(market.revision or 0),tostring(index),
            tostring(slot and slot.itemID or 0),tostring(slot and slot.quantity or 0),tostring(slot and slot.priceCopper or 0),
            tostring(slot and slot.icon or 0),Utf8SafeTruncate(CleanItemName(slot and slot.itemName or "",slot and slot.itemID),MAX_MARKET_ITEM_NAME_BYTES),
            Utf8SafeTruncate(CleanText(slot and slot.description or "",MAX_MARKET_DESCRIPTION_BYTES),MAX_MARKET_DESCRIPTION_BYTES),
        },"\t")
        if C_Timer and C_Timer.After then
            C_Timer.After(index*0.04,function() if currentHouseKey==houseKey then SendAddon(payload) end end)
        else
            SendAddon(payload)
        end
    end
end

local function BroadcastLibrary(houseKey,data,targetGUID)
    if not houseKey or not data or not BookX or not BookX.BuildPackets then return 0 end
    local packets=BookX.BuildPackets(SyncHouseID(houseKey),targetGUID or "",data)
    for index,payload in ipairs(packets) do
        if C_Timer and C_Timer.After then
            C_Timer.After(index*0.035,function() if currentHouseKey==houseKey then SendAddon(payload) end end)
        else
            SendAddon(payload)
        end
    end
    return #packets
end

local function SendOffer(targetGUID)
    if not currentHouseKey or not targetGUID then return end
    local data = EnsureHouseData(currentHouseKey, currentHouse)
    local _, count = RatingStats(data)
    SendAddon(table.concat({
        "O", SyncHouseID(currentHouseKey), targetGUID, tostring(count), tostring(data.generation or 0),
        tostring(#(data.comments or {})), tostring(CategoryRecordCount(data)), tostring((NormalizeMarketData(data) or {}).revision or 0),
        tostring(BookX and BookX.Normalize and (BookX.Normalize(data).revision or 0) or 0), tostring(BookX and BookX.RecordCount and BookX.RecordCount(data) or 0),
        IsAccountOwnedHouse(currentHouse,data) and "1" or "0",
    }, "\t"))
end

local function SendSnapshot(targetGUID)
    if not currentHouseKey or not targetGUID then return end
    local data = EnsureHouseData(currentHouseKey, currentHouse)
    local snapshotHouseKey = currentHouseKey
    local delay = 0

    for voterGUID, vote in pairs(data.votes) do
        if vote.rating ~= nil then
            local payload = table.concat({
                "D", SyncHouseID(currentHouseKey), targetGUID, tostring(data.generation or 0), voterGUID,
                tostring(vote.rating), tostring(vote.revision or 0),
                Utf8SafeTruncate(CleanTitle(vote.playerName or "Unknown"), 16), Utf8SafeTruncate(CleanDescription(vote.finalDescription or ""), 50),
                tostring(vote.timestamp or Now()), tostring(vote.faction or ""),
            }, "\t")
            C_Timer.After(delay, function()
                if currentHouseKey == snapshotHouseKey then SendAddon(payload) end
            end)
            delay = delay + 0.035

            local criticPayload = table.concat({
                "X", SyncHouseID(currentHouseKey), targetGUID, tostring(data.generation or 0), voterGUID,
                Utf8SafeTruncate(CleanText(vote.criticName or "", MAX_CRITIC_NAME_BYTES), MAX_CRITIC_NAME_BYTES),
                Utf8SafeTruncate(CleanText(vote.criticDescription or "", MAX_CRITIC_DESCRIPTION_BYTES), 50),
                vote.showCriticScore == false and "0" or "1", vote.criticScore and string.format("%.2f", vote.criticScore) or "",
            }, "\t")
            C_Timer.After(delay, function()
                if currentHouseKey == snapshotHouseKey then SendAddon(criticPayload) end
            end)
            delay = delay + 0.035
        end

        for categoryKey, entry in pairs(vote.categories or {}) do
            local payload = table.concat({
                "J", SyncHouseID(currentHouseKey), targetGUID, tostring(data.generation or 0), voterGUID, categoryKey,
                tostring(entry.rating), tostring(entry.revision or 0),
                Utf8SafeTruncate(CleanTitle(entry.label or categoryKey), 24),
                Utf8SafeTruncate(CleanDescription(entry.description or ""), 50), Utf8SafeTruncate(CleanTitle(vote.playerName or "Unknown"), 16),
                Utf8SafeTruncate(CleanText(entry.iconKey or "", 48), 48),
            }, "\t")
            C_Timer.After(delay, function()
                if currentHouseKey == snapshotHouseKey then SendAddon(payload) end
            end)
            delay = delay + 0.035
        end
    end

    for _, entry in ipairs(data.comments or {}) do
        local payload = table.concat({
            "Q", SyncHouseID(currentHouseKey), targetGUID, tostring(data.generation or 0), entry.voterGUID,
            tostring(entry.rating), tostring(entry.revision or 0), tostring(entry.timestamp or Now()),
            Utf8SafeTruncate(CleanTitle(entry.playerName or "Unknown"), 16), tostring(entry.categoryKey or "all"),
            Utf8SafeTruncate(CleanTitle(entry.title or "Final Vote"), 24), Utf8SafeTruncate(CleanDescription(entry.comment or ""), 50),
        }, "\t")
        C_Timer.After(delay, function()
            if currentHouseKey == snapshotHouseKey then SendAddon(payload) end
        end)
        delay = delay + 0.035
    end

    local hs = data.hostSettings or {}
    local payload = table.concat({
        "T", SyncHouseID(currentHouseKey), targetGUID, tostring(data.generation or 0),
        hs.commentsEnabled == false and "0" or "1", hs.resetOnLogin and "1" or "0",
        "0", "0", Utf8SafeTruncate(CleanHostDescription(hs.hostDescription or ""), MAX_HOST_DESCRIPTION_BYTES), "1", "0", tostring(NormalizeCategoryMask(hs.categoryMask)),
    }, "\t")
    C_Timer.After(delay, function()
        if currentHouseKey == snapshotHouseKey then SendAddon(payload) end
    end)
    delay = delay + 0.04
    C_Timer.After(delay, function()
        if currentHouseKey == snapshotHouseKey then BroadcastHostPortrait(snapshotHouseKey, data, targetGUID) end
    end)
    delay = delay + 0.04

    local market = NormalizeMarketData(data)
    local marketHeader = table.concat({
        "MH", SyncHouseID(snapshotHouseKey), targetGUID or "", tostring(market.revision or 0),
        Utf8SafeTruncate(CleanMarketTitle(market.featureTitle, "Featured Item"), MAX_MARKET_TITLE_BYTES),
        Utf8SafeTruncate(CleanMarketTitle(market.marketplaceTitle, "Today's Specials"), MAX_MARKET_TITLE_BYTES),
        Utf8SafeTruncate(CleanText(market.description or "", MAX_MARKET_HEADER_DESCRIPTION_BYTES), MAX_MARKET_HEADER_DESCRIPTION_BYTES),
    }, "\t")
    C_Timer.After(delay, function()
        if currentHouseKey == snapshotHouseKey then SendAddon(marketHeader) end
    end)
    delay = delay + 0.04
    for index = 1, MAX_MARKET_SLOTS do
        local slot = market.slots[index]
        local payload = table.concat({
            "MS", SyncHouseID(snapshotHouseKey), targetGUID or "", tostring(market.revision or 0), tostring(index),
            tostring(slot and slot.itemID or 0), tostring(slot and slot.quantity or 0), tostring(slot and slot.priceCopper or 0),
            tostring(slot and slot.icon or 0), Utf8SafeTruncate(CleanItemName(slot and slot.itemName or "", slot and slot.itemID), MAX_MARKET_ITEM_NAME_BYTES),
            Utf8SafeTruncate(CleanText(slot and slot.description or "", MAX_MARKET_DESCRIPTION_BYTES), MAX_MARKET_DESCRIPTION_BYTES),
        }, "\t")
        C_Timer.After(delay, function()
            if currentHouseKey == snapshotHouseKey then SendAddon(payload) end
        end)
        delay = delay + 0.04
    end
    if BookX and BookX.BuildPackets then
        local bookPackets=BookX.BuildPackets(SyncHouseID(snapshotHouseKey),targetGUID or "",data)
        for _,bookPayload in ipairs(bookPackets) do
            local payload=bookPayload
            C_Timer.After(delay,function() if currentHouseKey==snapshotHouseKey then SendAddon(payload) end end)
            delay=delay+0.04
        end
    end
    local snapshotEnd = table.concat({"SE", SyncHouseID(snapshotHouseKey), targetGUID or "", tostring(data.generation or 0), tostring(market.revision or 0)}, "\t")
    C_Timer.After(delay, function()
        if currentHouseKey == snapshotHouseKey then SendAddon(snapshotEnd) end
    end)
end

local function RequestPeerSync()
    if not currentHouseKey or not currentChannelID then return end
    SetHouseSyncPending(true, 4)
    wipe(pendingOffers)
    local myGUID = PlayerGUID()
    SendAddon(table.concat({ "H", SyncHouseID(currentHouseKey), myGUID }, "\t"))

    if offerTimer and offerTimer.Cancel then offerTimer:Cancel() end
    offerTimer = C_Timer.NewTimer(0.8, function()
        offerTimer = nil
        local bestSender, best = nil, nil
        for sender, offer in pairs(pendingOffers) do
            local better = false
            if not best then
                better = true
            elseif offer.generation ~= best.generation then
                better = offer.generation > best.generation
            elseif offer.marketRevision ~= best.marketRevision then
                better = offer.marketRevision > best.marketRevision
            elseif offer.libraryRevision ~= best.libraryRevision then
                better = offer.libraryRevision > best.libraryRevision
            elseif offer.count ~= best.count then
                better = offer.count > best.count
            elseif offer.categoryCount ~= best.categoryCount then
                better = offer.categoryCount > best.categoryCount
            elseif offer.commentCount ~= best.commentCount then
                better = offer.commentCount > best.commentCount
            else
                better = sender < bestSender
            end
            if better then
                bestSender = sender
                best = offer
            end
        end
        if bestSender and best then
            local data = EnsureHouseData(currentHouseKey, currentHouse)
            ApplyGeneration(data, best.generation)
            local expectedSeconds = 2 + ((best.count or 0) + (best.categoryCount or 0) + (best.commentCount or 0) + (best.libraryCount or 0) + MAX_MARKET_SLOTS + 4) * 0.045
            -- A fully populated 14-book/100-page library can exceed a minute at
            -- the intentionally throttled addon-message rate. Keep every tab in
            -- its Loading state until SE arrives, with a bounded safety fallback.
            SetHouseSyncPending(true, math.min(1200, math.max(4, expectedSeconds + 8)))
            SendAddon(table.concat({ "P", SyncHouseID(currentHouseKey), myGUID, bestSender }, "\t"))
        else
            SetHouseSyncPending(false)
            if UpdateUI then UpdateUI() end
        end
    end)
end

local function ResolveChannel()
    if not currentChannelName then return end
    local id = GetChannelName and GetChannelName(currentChannelName) or 0
    if type(id) == "number" and id > 0 then
        currentChannelID = id
        HideChannelFromChat(currentChannelName)
        RequestPeerSync()
    end
end

local function JoinSyncChannel(houseKey)
    local wanted = ChannelForHouse(houseKey)
    if currentChannelName == wanted and currentChannelID then return end
    LeaveSyncChannel()
    currentChannelName = wanted
    suppressChannelName = wanted
    if JoinTemporaryChannel then
        pcall(JoinTemporaryChannel, wanted)
        C_Timer.After(0.25, ResolveChannel)
        C_Timer.After(1.0, ResolveChannel)
    end
end

-- -----------------------------------------------------------------------------
-- Blizzard Housing Dashboard compatibility
-- -----------------------------------------------------------------------------
-- RateMyHouse must never own, hide, reparent, or restyle Blizzard's dashboard
-- controls. While the Blizzard dashboard is open, let it exclusively process its
-- house-list refresh event. A tiny visibility guard repairs the House Information
-- tab if another refresh/taint path ever leaves that Blizzard-owned button hidden.
local housingDashboardGuardInstalled = false

local function IsBlizzardHousingDashboardOpen()
    local dashboard = _G.HousingDashboardFrame
    return dashboard and dashboard.IsShown and dashboard:IsShown() or false
end

local function RestoreBlizzardHousingInformationTab()
    local dashboard = _G.HousingDashboardFrame
    if not dashboard then return end

    if dashboard.HouseInfoTabButton and dashboard.HouseInfoTabButton.Show then
        dashboard.HouseInfoTabButton:Show()
    end

    -- Only restore the content when Blizzard itself considers House Information
    -- the active tab. Never force the user away from Catalog or Collection.
    if dashboard.activeTab and dashboard.houseInfoTab and dashboard.activeTab == dashboard.houseInfoTab then
        if dashboard.HouseInfoContent and dashboard.HouseInfoContent.Show then
            dashboard.HouseInfoContent:Show()
        end
    end
end

local function InstallBlizzardHousingDashboardGuard()
    if housingDashboardGuardInstalled then return end
    local dashboard = _G.HousingDashboardFrame
    if not dashboard then return end

    housingDashboardGuardInstalled = true
    if dashboard.HookScript then
        dashboard:HookScript("OnShow", function()
            if C_Timer and C_Timer.After then
                C_Timer.After(0, RestoreBlizzardHousingInformationTab)
                C_Timer.After(0.10, RestoreBlizzardHousingInformationTab)
            else
                RestoreBlizzardHousingInformationTab()
            end
        end)
    end
    RestoreBlizzardHousingInformationTab()
end

-- -----------------------------------------------------------------------------
-- Housing Dashboard styled UI
-- -----------------------------------------------------------------------------

local function SetAtlasSafe(texture, atlas, useAtlasSize)
    if not texture or not texture.SetAtlas or not atlas or atlas == "" then return false end
    return pcall(texture.SetAtlas, texture, atlas, useAtlasSize == true)
end

local function SetReviewCategoryIcon(texture, category)
    if not texture then return end
    texture:SetTexCoord(0, 1, 0, 1)
    if category then
        if type(category.icon) == "string" and category.icon ~= "" and SetAtlasSafe(texture, category.icon, false) then
            return
        elseif type(category.icon) == "number" then
            texture:SetTexture(category.icon)
            return
        elseif category.iconTexture and category.iconTexture ~= "" then
            texture:SetTexture(category.iconTexture)
            return
        end
    end
    texture:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\icon.tga")
end

local panel
local descriptionBox
local RebuildCategoryMenu
local SetStatus
local UpdateCommentRows
local UpdateProfilePage
local PerformHouseReset
local RatingChatFilter
local HandleRatingAddonLink
local ResetOwnedHousesForLoginLogout
local ShareX = {}


-- Consolidate UI-builder dependencies into one captured table. WoW Lua has a low upvalue ceiling.
local UIX = {
    Now = Now,
    RevisionStamp = RevisionStamp,
    PlayerFullName = PlayerFullName,
    PlayerGUID = PlayerGUID,
    ShortName = ShortName,
    SamePlayerName = SamePlayerName,
    CanonicalPlayerName = CanonicalPlayerName,
    IsKnownAccountCharacter = IsKnownAccountCharacter,
    SplitTabs = SplitTabs,
    HashString = HashString,
    SyncHouseID = SyncHouseID,
    ChannelForHouse = ChannelForHouse,
    Utf8SafeTruncate = Utf8SafeTruncate,
    CleanText = CleanText,
    CleanDescription = CleanDescription,
    CleanHostDescription = CleanHostDescription,
    CleanCustomCategoryTitle = CleanCustomCategoryTitle,
    CleanTitle = CleanTitle,
    CleanMarketTitle = CleanMarketTitle,
    CleanItemName = CleanItemName,
    CleanChatText = CleanChatText,
    IsInsideHouseOrPlot = IsInsideHouseOrPlot,
    IsInsideOwnedHouseOrPlot = IsInsideOwnedHouseOrPlot,
    IsHouseOwner = IsHouseOwner,
    IsAccountOwnedHouse = IsAccountOwnedHouse,
    GetHouseInfo = GetHouseInfo,
    MakeHouseKey = MakeHouseKey,
    IsCurrentStorageKey = IsCurrentStorageKey,
    EnsureDB = EnsureDB,
    NormalizeMarketData = NormalizeMarketData,
    CopyMarketSlots = CopyMarketSlots,
    ScanTradeableBackpackItems = ScanTradeableBackpackItems,
    CoinText = CoinText,
    SortAndTrimComments = SortAndTrimComments,
    EnforceRatingsLimit = EnforceRatingsLimit,
    MAX_HOUSE_RATINGS = MAX_HOUSE_RATINGS,
    EnsureHouseData = EnsureHouseData,
    FactionForOwnedNeighborhood = FactionForOwnedNeighborhood,
    RequestOwnedHouseList = RequestOwnedHouseList,
    ApplyGeneration = ApplyGeneration,
    RemoveVoteCommentsForGUID = RemoveVoteCommentsForGUID,
    GetOrCreateVote = GetOrCreateVote,
    MergeVote = MergeVote,
    MergeCategoryRating = MergeCategoryRating,
    MergeComment = MergeComment,
    RatingStats = RatingStats,
    CategoryAverage = CategoryAverage,
    GetRatingColor = GetRatingColor,
    CriticAverage = CriticAverage,
    CategoryRecordCount = CategoryRecordCount,
    AddReviewCategory = AddReviewCategory,
    RefreshReviewCategories = RefreshReviewCategories,
    GetSelectedCategory = GetSelectedCategory,
    HouseCategoryMask = HouseCategoryMask,
    IsReviewCategoryEnabled = IsReviewCategoryEnabled,
    FirstEnabledCategoryKey = FirstEnabledCategoryKey,
    NormalizeCategoryMask = NormalizeCategoryMask,
    CategoryMaskHas = CategoryMaskHas,
    CategoryMaskSet = CategoryMaskSet,
    EnabledCategoryCount = EnabledCategoryCount,
    CORE_CATEGORY_DEFS = CORE_CATEGORY_DEFS,
    DEFAULT_CATEGORY_MASK = DEFAULT_CATEGORY_MASK,
    HideChannelFromChat = HideChannelFromChat,
    LeaveSyncChannel = LeaveSyncChannel,
    SendAddon = SendAddon,
    BroadcastHostPortrait = BroadcastHostPortrait,
    BroadcastVote = BroadcastVote,
    BroadcastCategory = BroadcastCategory,
    BroadcastCategoryDelete = BroadcastCategoryDelete,
    QueuePendingVote = QueuePendingVote,
    QueuePendingReset = QueuePendingReset,
    BroadcastComment = BroadcastComment,
    BroadcastMarket = BroadcastMarket,
    BroadcastLibrary = BroadcastLibrary,
    BookX = BookX,
    SendOffer = SendOffer,
    SendSnapshot = SendSnapshot,
    RequestPeerSync = RequestPeerSync,
    ResolveChannel = ResolveChannel,
    JoinSyncChannel = JoinSyncChannel,
    SetAtlasSafe = SetAtlasSafe,
    SetReviewCategoryIcon = SetReviewCategoryIcon,
    CapturePlayerPortraitState = CapturePlayerPortraitState,
    MAX_DESCRIPTION_BYTES = MAX_DESCRIPTION_BYTES,
    MAX_TITLE_BYTES = MAX_TITLE_BYTES,
    MAX_COMMENTS = MAX_COMMENTS,
    MAX_CRITIC_NAME_BYTES = MAX_CRITIC_NAME_BYTES,
    MAX_CRITIC_DESCRIPTION_BYTES = MAX_CRITIC_DESCRIPTION_BYTES,
    MAX_HOST_DESCRIPTION_BYTES = MAX_HOST_DESCRIPTION_BYTES,
    MAX_CUSTOM_CATEGORY_BYTES = MAX_CUSTOM_CATEGORY_BYTES,
    MAX_RATED_CATEGORIES = MAX_RATED_CATEGORIES,
    MAX_MARKET_TITLE_BYTES = MAX_MARKET_TITLE_BYTES,
    MAX_MARKET_HEADER_DESCRIPTION_BYTES = MAX_MARKET_HEADER_DESCRIPTION_BYTES,
    MAX_MARKET_ITEM_NAME_BYTES = MAX_MARKET_ITEM_NAME_BYTES,
    MAX_MARKET_DESCRIPTION_BYTES = MAX_MARKET_DESCRIPTION_BYTES,
    MAX_MARKET_SLOTS = MAX_MARKET_SLOTS,
    MAX_MARKET_PRICE_COPPER = MAX_MARKET_PRICE_COPPER,
    ReviewCategories = ReviewCategories,
    ReviewCategoryByKey = ReviewCategoryByKey,
    ReviewCategoryOrder = ReviewCategoryOrder,
    IsCustomCategoryKey = IsCustomCategoryKey,
    CountRatedCategories = CountRatedCategories,
    MergeCategoryDelete = MergeCategoryDelete,
    PREFIX = PREFIX,
}

UIX.ClearHouseRatingState = ClearHouseRatingState

RMH.PlaceHeaderIcon = function(targetPanel)
    if not targetPanel then return end

    -- Use Blizzard's PortraitFrameTemplate exactly as designed: the built-in
    -- circular frame stays in its native position and size, while our custom
    -- borderless RMH portrait art fills the portrait texture beneath it.
    local templatePortrait = targetPanel.Portrait or _G["RateMyHouseFramePortrait"]
    if targetPanel.PortraitContainer then
        targetPanel.PortraitContainer:SetAlpha(1)
        targetPanel.PortraitContainer:Show()
    end
    if templatePortrait then
        templatePortrait:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\header_icon_inner.tga")
        if templatePortrait.SetTexCoord then templatePortrait:SetTexCoord(0, 1, 0, 1) end
        templatePortrait:SetAlpha(1)
        templatePortrait:Show()
        -- Intentionally do not ClearAllPoints/SetPoint/SetSize here. The native
        -- PortraitFrameTemplate anchors are the exact center of the circle slot.
    end

    -- Remove the temporary manually-positioned holder used by earlier revisions.
    if targetPanel._rmhHeaderIconHolder then
        targetPanel._rmhHeaderIconHolder:Hide()
    end
end

-- Scenic neighborhood wallpaper helpers live outside BuildAndWireUI because
-- WoW's Lua compiler caps a function at 200 active local variables.
RMH.SetCroppedTextureFill = function(texture, targetWidth, targetHeight, sourceWidth, sourceHeight, flipVertical)
    if not texture then return end
    targetWidth = math.max(1, tonumber(targetWidth) or 1)
    targetHeight = math.max(1, tonumber(targetHeight) or 1)
    sourceWidth = math.max(1, tonumber(sourceWidth) or 1)
    sourceHeight = math.max(1, tonumber(sourceHeight) or 1)

    local frameRatio = targetWidth / targetHeight
    local sourceRatio = sourceWidth / sourceHeight
    local left, right, top, bottom = 0, 1, 0, 1

    if frameRatio > sourceRatio then
        local visibleHeight = sourceRatio / frameRatio
        local inset = math.max(0, (1 - visibleHeight) * 0.5)
        top = inset
        bottom = 1 - inset
    elseif frameRatio < sourceRatio then
        local visibleWidth = frameRatio / sourceRatio
        local inset = math.max(0, (1 - visibleWidth) * 0.5)
        left = inset
        right = 1 - inset
    end

    if flipVertical then
        texture:SetTexCoord(left, right, bottom, top)
    else
        texture:SetTexCoord(left, right, top, bottom)
    end
end

RMH.ApplyNeighborhoodCrop = function(texture, targetFrame)
    if not texture or not targetFrame then return end
    local width = (targetFrame.GetWidth and targetFrame:GetWidth()) or 0
    local height = (targetFrame.GetHeight and targetFrame:GetHeight()) or 0
    if width <= 0 or height <= 0 then return end
    RMH.SetCroppedTextureFill(texture, width, height, 1024, 410, true)
end

RMH.SetNeighborhoodTexture = function(texture, kind)
    if not texture then return false end
    if kind == "interior" then
        texture:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\header_house_interior.tga")
        return true
    elseif kind == "founders" then
        texture:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\header_founders_point.tga")
        return true
    elseif kind == "razorwind" then
        texture:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\header_razorwind_shores.tga")
        return true
    end
    texture:SetTexture(nil)
    return false
end

RMH.IsHousingInterior = function(info)
    if C_Housing then
        local candidates = {
            "IsInsideHouse",
            "IsInsideHouseInterior",
            "IsInHouseInterior",
        }
        for _, name in ipairs(candidates) do
            local fn = C_Housing[name]
            if type(fn) == "function" then
                local ok, result = pcall(fn)
                if ok and type(result) == "boolean" then return result end
            end
        end
    end

    if type(info) == "table" then
        local fields = {
            "isInsideHouse", "isInsideHouseInterior", "isInterior",
            "insideHouse", "insideInterior", "isIndoors",
        }
        for _, key in ipairs(fields) do
            if type(info[key]) == "boolean" then return info[key] end
        end
    end

    if _G.IsIndoors then
        local ok, result = pcall(_G.IsIndoors)
        if ok and result == true then return true end
    end
    if _G.IsOutdoors then
        local ok, result = pcall(_G.IsOutdoors)
        if ok and type(result) == "boolean" then return result == false end
    end
    return false
end

local function BuildAndWireUI()
panel = CreateFrame("Frame", "RateMyHouseFrame", UIParent, "PortraitFrameTemplate")
panel:SetSize(720, 700)
panel:SetPoint("CENTER")
panel:SetFrameStrata("DIALOG")
panel:SetClampedToScreen(true)
panel:EnableMouse(true)
panel:SetMovable(true)
panel:RegisterForDrag("LeftButton")
UIX.ONE_PIXEL=1/math.max(0.01,(UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1)
panel:SetScript("OnDragStart", function(self)
    if not GetCursorPosition or not self.GetLeft or not self.GetBottom then self._rmhNativeMove=true; self:StartMoving(); return end
    local cursorX,cursorY=GetCursorPosition(); local scale=(UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1
    self._rmhDragCursorX=cursorX/scale; self._rmhDragCursorY=cursorY/scale; self._rmhDragLeft=self:GetLeft(); self._rmhDragBottom=self:GetBottom(); self._rmhDragging=true
end)
panel:SetScript("OnUpdate",function(self)
    if not self._rmhDragging or not GetCursorPosition then return end
    local cursorX,cursorY=GetCursorPosition(); local scale=(UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1
    local left=(self._rmhDragLeft or 0)+(cursorX/scale-(self._rmhDragCursorX or 0)); local bottom=(self._rmhDragBottom or 0)+(cursorY/scale-(self._rmhDragCursorY or 0))
    local physicalScale=(self.GetEffectiveScale and self:GetEffectiveScale()) or scale
    left=math.floor((left*physicalScale)+0.5)/physicalScale; bottom=math.floor((bottom*physicalScale)+0.5)/physicalScale
    self:ClearAllPoints(); self:SetPoint("BOTTOMLEFT",UIParent,"BOTTOMLEFT",left,bottom)
end)
panel:SetScript("OnDragStop", function(self)
    if self._rmhNativeMove then self:StopMovingOrSizing(); self._rmhNativeMove=nil end
    self._rmhDragging=false
end)
panel:Hide()

local function SetPanelTitle(text)
    if panel.SetTitle then
        local ok = pcall(panel.SetTitle, panel, text)
        if ok then return end
    end
    local fs = (panel.TitleContainer and panel.TitleContainer.TitleText) or panel.TitleText or _G["RateMyHouseFrameTitleText"]
    if fs and fs.SetText then fs:SetText(text) end
end

RMH.PlaceHeaderIcon(panel)
panel._rmhTopHeaderCover = panel:CreateTexture(nil, "ARTWORK", nil, 7)
panel._rmhTopHeaderCover:SetPoint("TOPLEFT", panel, "TOPLEFT", 42, -3)
panel._rmhTopHeaderCover:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -34, -3)
panel._rmhTopHeaderCover:SetHeight(22)
panel._rmhTopHeaderCover:SetColorTexture(0.04, 0.04, 0.04, 0.0)
panel._rmhHeaderSeamCover = panel:CreateTexture(nil, "ARTWORK", nil, 7)
panel._rmhHeaderSeamCover:SetPoint("TOPLEFT", panel, "TOPLEFT", 42, -24)
panel._rmhHeaderSeamCover:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -34, -24)
panel._rmhHeaderSeamCover:SetHeight(8)
panel._rmhHeaderSeamCover:SetColorTexture(0.02, 0.02, 0.02, 0.0)

-- Plot/neighborhood artwork uses centered crop-to-fill through RMH helpers so
-- the UI builder stays below WoW Lua's 200-active-local limit.
local headerNeighborhoodWallpaper = panel:CreateTexture(nil, "BACKGROUND", nil, 6)
headerNeighborhoodWallpaper:SetPoint("TOPLEFT", panel, "TOPLEFT", 5, -4)
headerNeighborhoodWallpaper:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -5, -4)
headerNeighborhoodWallpaper:SetHeight(64)
headerNeighborhoodWallpaper:Hide()
local headerNeighborhoodShade = panel:CreateTexture(nil, "BACKGROUND", nil, 7)
headerNeighborhoodShade:SetAllPoints(headerNeighborhoodWallpaper)
headerNeighborhoodShade:SetColorTexture(0, 0, 0, 0.18)
headerNeighborhoodShade:Hide()

local content

local navBar = CreateFrame("Frame", nil, panel)
navBar:SetPoint("TOPLEFT", 43, -34)
navBar:SetPoint("TOPRIGHT", -5, -34)
navBar:SetHeight(35)
local navBG
do
    navBG = navBar:CreateTexture(nil, "BACKGROUND")
    navBG:SetPoint("TOPLEFT", navBar, "TOPLEFT", 0, 7)
    navBG:SetPoint("TOPRIGHT", navBar, "TOPRIGHT", 0, 7)
    navBG:SetPoint("BOTTOMLEFT", navBar, "BOTTOMLEFT", 0, 0)
    navBG:SetPoint("BOTTOMRIGHT", navBar, "BOTTOMRIGHT", 0, 0)
    navBG:SetColorTexture(0.035, 0.035, 0.035, 0.92)
end

local function HeaderNeighborhoodKind(info, data)
    local names={}
    local function AddName(value) if type(value)=="string" and value~="" then names[#names+1]=value end end
    AddName(info and info.neighborhoodName); AddName(info and info.neighborhoodDisplayName)
    AddName(info and info.zoneName); AddName(data and data.neighborhoodName)
    if GetSubZoneText then local ok,value=pcall(GetSubZoneText); if ok then AddName(value) end end
    if GetZoneText then local ok,value=pcall(GetZoneText); if ok then AddName(value) end end
    local combined=string.lower(table.concat(names," ")):gsub("[’`]","'")
    if combined:find("founder",1,true) and combined:find("point",1,true) then return "founders" end
    if combined:find("razorwind",1,true) and combined:find("shore",1,true) then return "razorwind" end

    -- Outside the two housing neighborhoods, keep the header scenic instead of
    -- leaving the title/tab strip blank. Use the player's faction as the stable
    -- fallback everywhere (world, instances, battlegrounds, raids, dungeons, etc.).
    local faction = UnitFactionGroup and select(1, UnitFactionGroup("player")) or nil
    if faction == "Horde" then return "razorwind" end
    return "founders"
end

local function UpdateNeighborhoodHeader(info,data,inside)
    local kind = RMH.IsHousingInterior(info) and "interior" or HeaderNeighborhoodKind(info, data)
    local shown = RMH.SetNeighborhoodTexture(headerNeighborhoodWallpaper, kind)
    local headerShown = shown and not panel._rmhPanelMinimized
    headerNeighborhoodWallpaper:SetShown(headerShown)
    headerNeighborhoodShade:SetShown(headerShown)
    if headerShown then RMH.ApplyNeighborhoodCrop(headerNeighborhoodWallpaper, headerNeighborhoodWallpaper) end
    navBG:SetColorTexture(0.035,0.035,0.035,shown and 0.26 or 0.92)

    if content and content._rmhNeighborhoodBG then
        local contentShown = inside and RMH.SetNeighborhoodTexture(content._rmhNeighborhoodBG, kind)
        content._rmhNeighborhoodBG:SetShown(contentShown)
        if content._rmhNeighborhoodShade then content._rmhNeighborhoodShade:SetShown(contentShown) end
        if contentShown then
            RMH.ApplyNeighborhoodCrop(content._rmhNeighborhoodBG, content)
            if content._rmhParchmentBG then content._rmhParchmentBG:SetAlpha(0.9) end
            if content._rmhParchmentShade then content._rmhParchmentShade:SetColorTexture(0, 0, 0, 0.15) end
        else
            if content._rmhParchmentBG then content._rmhParchmentBG:SetAlpha(1.0) end
            if content._rmhParchmentShade then content._rmhParchmentShade:SetColorTexture(0, 0, 0, 0.48) end
        end
    end

    if panel._rmhQuickFrame and panel._rmhQuickFrame._rmhNeighborhoodBG then
        local quickShown = RMH.SetNeighborhoodTexture(panel._rmhQuickFrame._rmhNeighborhoodBG, kind)
        panel._rmhQuickFrame._rmhNeighborhoodBG:SetShown(quickShown)
        if panel._rmhQuickFrame._rmhNeighborhoodShade then panel._rmhQuickFrame._rmhNeighborhoodShade:SetShown(quickShown) end
        if panel._rmhQuickFrame._rmhParchmentBG then panel._rmhQuickFrame._rmhParchmentBG:Show() end
        if panel._rmhQuickFrame._rmhParchmentShade then panel._rmhQuickFrame._rmhParchmentShade:Show() end
        if quickShown then RMH.ApplyNeighborhoodCrop(panel._rmhQuickFrame._rmhNeighborhoodBG, panel._rmhQuickFrame) end
    end
end

local rateTab = CreateFrame("Button", "RateMyHouseRateTab", navBar, "PanelTopTabButtonTemplate")
rateTab:SetSize(64, 30)
rateTab:SetPoint("BOTTOMLEFT", 4, 1)
rateTab:SetText("Rate")
rateTab:SetID(1)

local marketTab = CreateFrame("Button", "RateMyHouseMarketTab", navBar, "PanelTopTabButtonTemplate")
marketTab:SetSize(66, 30)
marketTab:SetPoint("LEFT", rateTab, "RIGHT", 3, 0)
marketTab:SetText("Market")
marketTab:SetID(2)

local libraryTab = CreateFrame("Button", "RateMyHouseLibraryTab", navBar, "PanelTopTabButtonTemplate")
libraryTab:SetSize(66, 30)
libraryTab:SetPoint("LEFT", marketTab, "RIGHT", 3, 0)
libraryTab:SetText("Library")
libraryTab:SetID(3)

local commentsTab = CreateFrame("Button", "RateMyHouseRatingsTab", navBar, "PanelTopTabButtonTemplate")
commentsTab:SetSize(68, 30)
commentsTab:SetPoint("LEFT", libraryTab, "RIGHT", 3, 0)
commentsTab:SetText("Ratings")
commentsTab:SetID(4)

local ratingTab = CreateFrame("Button", "RateMyHouseFullReviewTab", navBar, "PanelTopTabButtonTemplate")
ratingTab:SetSize(88, 30)
ratingTab:SetPoint("LEFT", commentsTab, "RIGHT", 3, 0)
ratingTab:SetText("Full Review")
ratingTab:SetID(5)

local settingsTab = CreateFrame("Button", "RateMyHouseProfileTab", navBar, "PanelTopTabButtonTemplate")
settingsTab:SetSize(66, 30)
settingsTab:SetPoint("LEFT", ratingTab, "RIGHT", 10, 0)
settingsTab:SetText("Profile")
settingsTab:SetID(6)

local itemsTab = CreateFrame("Button", "RateMyHouseMyItemsTab", navBar, "PanelTopTabButtonTemplate")
itemsTab:SetSize(74, 30)
itemsTab:SetPoint("LEFT", settingsTab, "RIGHT", 3, 0)
itemsTab:SetText("My Items")
itemsTab:SetID(7)

local booksTab = CreateFrame("Button", "RateMyHouseMyBooksTab", navBar, "PanelTopTabButtonTemplate")
booksTab:SetSize(78, 30)
booksTab:SetPoint("LEFT", itemsTab, "RIGHT", 3, 0)
booksTab:SetText("My Books")
booksTab:SetID(8)

local resetButton = CreateFrame("Button", nil, navBar, "UIPanelButtonTemplate")
resetButton:SetSize(72, 22)
resetButton:SetPoint("RIGHT", navBar, "RIGHT", -10, 1)
resetButton:SetText("Reset")
resetButton:Hide()

content = CreateFrame("Frame", nil, panel)
content:SetPoint("TOPLEFT", 5, -68)
content:SetPoint("BOTTOMRIGHT", -3, 3)

-- Normal house/review pages can optionally show a scenic neighborhood wallpaper
-- behind the parchment, matching the compact view treatment.
content._rmhNeighborhoodBG = content:CreateTexture(nil, "BACKGROUND")
content._rmhNeighborhoodBG:SetAllPoints()
content._rmhNeighborhoodBG:Hide()
content._rmhNeighborhoodShade = content:CreateTexture(nil, "BACKGROUND", nil, 1)
content._rmhNeighborhoodShade:SetAllPoints()
content._rmhNeighborhoodShade:SetColorTexture(0, 0, 0, 0.10)
content._rmhNeighborhoodShade:Hide()

content._rmhParchmentBG = content:CreateTexture(nil, "BACKGROUND", nil, 2)
content._rmhParchmentBG:SetAllPoints()
content._rmhParchmentBG:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\parchment.tga")
content._rmhParchmentBG:SetTexCoord(0, 1, 0, 1)
content._rmhParchmentBG:Show()
content._rmhParchmentShade = content:CreateTexture(nil, "BACKGROUND", nil, 3)
content._rmhParchmentShade:SetAllPoints()
content._rmhParchmentShade:SetColorTexture(0, 0, 0, 0.48)
content._rmhParchmentShade:Show()

content._rmhOffPlotBG = content:CreateTexture(nil, "BACKGROUND")
content._rmhOffPlotBG:SetAllPoints()
if not UIX.SetAtlasSafe(content._rmhOffPlotBG, "housing-dashboard-bg-elwynn", false) then
    content._rmhOffPlotBG:SetTexture("Interface\\FrameGeneral\\UI-Background-Rock")
    content._rmhOffPlotBG:SetHorizTile(true)
    content._rmhOffPlotBG:SetVertTile(true)
end
content._rmhOffPlotBG:Hide()
content._rmhOffPlotShade = content:CreateTexture(nil, "BACKGROUND", nil, 1)
content._rmhOffPlotShade:SetAllPoints()
content._rmhOffPlotShade:SetColorTexture(0, 0, 0, 0.18)
content._rmhOffPlotShade:Hide()
content:HookScript("OnSizeChanged", function(self)
    if self._rmhNeighborhoodBG and self._rmhNeighborhoodBG:IsShown() then
        RMH.ApplyNeighborhoodCrop(self._rmhNeighborhoodBG, self)
    end
end)

-- Top-right window controls. Keep them outside the working view and anchor the
-- pair from the close button so the full and compact layouts share the exact
-- same location.
local panelMinimized = false
panel._rmhPanelMinimized = false
local quickFrame
local headerControls = CreateFrame("Frame", nil, UIParent)
headerControls:SetSize(56, 22)
headerControls:SetFrameStrata("FULLSCREEN_DIALOG")
headerControls:SetFrameLevel((panel:GetFrameLevel() or 1)+200)
headerControls:Hide()
local minimizeButton = CreateFrame("Button", nil, headerControls, "UIPanelButtonTemplate")
minimizeButton:SetSize(24, 22)
minimizeButton:SetPoint("LEFT", headerControls, "LEFT", 0, 0)
minimizeButton:SetText("-")
minimizeButton:SetFrameStrata("FULLSCREEN_DIALOG")
minimizeButton:SetFrameLevel((panel:GetFrameLevel() or 1)+201)
local maximizeButton = CreateFrame("Button", nil, headerControls, "UIPanelButtonTemplate")
maximizeButton:SetSize(24, 22)
maximizeButton:SetPoint("RIGHT", headerControls, "RIGHT", 0, 0)
maximizeButton:SetText("+")
maximizeButton:SetFrameStrata("FULLSCREEN_DIALOG")
maximizeButton:SetFrameLevel((panel:GetFrameLevel() or 1)+201)
if minimizeButton.GetFontString and minimizeButton:GetFontString() then minimizeButton:GetFontString():SetTextColor(1,0.82,0); minimizeButton:GetFontString():SetScale(1.15) end
if maximizeButton.GetFontString and maximizeButton:GetFontString() then maximizeButton:GetFontString():SetTextColor(1,0.82,0); maximizeButton:GetFontString():SetScale(1.15) end

panel._rmhPositionHeaderControls = function()
    headerControls:ClearAllPoints()
    if panel and panel.CloseButton then
        headerControls:SetPoint("RIGHT", panel.CloseButton, "LEFT", -10, 0)
    else
        headerControls:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -58, -7)
    end
end
panel._rmhPositionHeaderControls()
panel:HookScript("OnShow", function()
    if headerControls then
        panel._rmhPositionHeaderControls()
        headerControls:Show()
    end
end)
panel:HookScript("OnHide", function()
    if headerControls then headerControls:Hide() end
end)

local function SetPanelMinimized(minimized)
    panelMinimized = minimized and true or false
    panel._rmhPanelMinimized = panelMinimized
    panel._rmhPositionHeaderControls()
    if panelMinimized then
        navBar:Hide()
        content:Hide()
        panel:SetSize(560, 330)
        if quickFrame then quickFrame:Show() end
    else
        panel:SetSize(720, 700)
        navBar:Show()
        content:Show()
        if quickFrame then quickFrame:Hide() end
    end
    minimizeButton:SetEnabled(not panelMinimized)
    maximizeButton:SetEnabled(panelMinimized)
    if UpdateUI then UpdateUI() end
end
minimizeButton:SetScript("OnClick", function() SetPanelMinimized(true) end)
maximizeButton:SetScript("OnClick", function() SetPanelMinimized(false) end)
minimizeButton:SetScript("OnEnter", function(self) GameTooltip:SetOwner(self,"ANCHOR_TOP"); GameTooltip:SetText("Quick Rate"); GameTooltip:AddLine("Open the compact RateMyHouse voting view.",1,1,1,true); GameTooltip:Show() end)
minimizeButton:SetScript("OnLeave", GameTooltip_Hide)
maximizeButton:SetScript("OnEnter", function(self) GameTooltip:SetOwner(self,"ANCHOR_TOP"); GameTooltip:SetText("Full RateMyHouse"); GameTooltip:AddLine("Restore the full RateMyHouse window.",1,1,1,true); GameTooltip:Show() end)
maximizeButton:SetScript("OnLeave", GameTooltip_Hide)

local function ApplyInputChrome(box)
    if not box then return end
    -- 25% black transparent input background. Keep the native EditBox caret visible.
    if box.NineSlice then box.NineSlice:Hide() end
    if box.Left then box.Left:Hide() end
    if box.Middle then box.Middle:Hide() end
    if box.Right then box.Right:Hide() end
    if box._rmhDescriptionSurface then box._rmhDescriptionSurface:Hide() end
    if box._rmhInputBase then box._rmhInputBase:Hide() end
    if box._rmhInputFill then box._rmhInputFill:Hide() end
    if box._chrome and box._chrome.frame then box._chrome.frame:Hide() end

    local outline = box._rmhOutline
    if not outline then
        outline = CreateFrame("Frame", nil, box:GetParent(), "BackdropTemplate")
        outline:EnableMouse(false)
        outline:SetBackdrop({ bgFile="Interface\\Buttons\\WHITE8X8", edgeFile="Interface\\Buttons\\WHITE8X8", edgeSize=UIX.ONE_PIXEL })
        box._rmhOutline = outline
    end
    outline:ClearAllPoints()
    outline:SetAllPoints(box)
    if outline.SetFrameStrata and box.GetFrameStrata then
        outline:SetFrameStrata(box:GetFrameStrata())
    end
    if outline.SetFrameLevel and box.GetFrameLevel then
        outline:SetFrameLevel(math.max(0,(box:GetFrameLevel() or 1)-1))
    end
    outline:SetBackdropColor(0,0,0,0.25)
    outline:SetBackdropBorderColor(0,0,0,0.25)
    outline:Show()
    box._rmhInputChrome = true
    box._chrome = { frame=outline }
    if box.SetTextInsets then box:SetTextInsets(11,11,8,8) end
end
-- QUICK RATE ------------------------------------------------------------------
-- Minimized mode is a compact direct overall vote: no categories, just one score
-- and Final Comment, while still showing the host house's current overall score.
local quickSelectedRating
local quickHouseKey
local quickOwnerLabel, quickAddressLabel, quickHouseScore, quickHouseCount, quickRateValue
local quickFinalCommentLabel, quickFinalCommentBox, quickSubmitButton, quickResetButton
local quickAdjustButtons = {}

quickFrame = CreateFrame("Frame", nil, panel, "BackdropTemplate")
panel._rmhQuickFrame = quickFrame
quickFrame:SetPoint("TOPLEFT", 12, -44)
quickFrame:SetPoint("BOTTOMRIGHT", -12, 12)
quickFrame:SetBackdrop({ edgeFile="Interface\\Buttons\\WHITE8X8", edgeSize=UIX.ONE_PIXEL })
quickFrame:SetBackdropBorderColor(0,0,0,0.25)
do
    local scenic = quickFrame:CreateTexture(nil, "BACKGROUND")
    scenic:SetAllPoints()
    scenic:Hide()
    quickFrame._rmhNeighborhoodBG = scenic
    local scenicShade = quickFrame:CreateTexture(nil, "BACKGROUND", nil, 1)
    scenicShade:SetAllPoints()
    scenicShade:SetColorTexture(0,0,0,0.10)
    scenicShade:Hide()
    quickFrame._rmhNeighborhoodShade = scenicShade

    local bg = quickFrame:CreateTexture(nil, "BACKGROUND", nil, 2)
    bg:SetAllPoints()
    bg:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\parchment.tga")
    bg:SetTexCoord(0, 1, 0, 1)
    bg:SetAlpha(0.9)
    quickFrame._rmhParchmentBG = bg
    local shade = quickFrame:CreateTexture(nil, "BACKGROUND", nil, 3)
    shade:SetAllPoints()
    shade:SetColorTexture(0,0,0,0.15)
    quickFrame._rmhParchmentShade = shade
end

quickFrame:Hide()
quickFrame:HookScript("OnSizeChanged", function(self)
    if self._rmhNeighborhoodBG and self._rmhNeighborhoodBG:IsShown() then
        RMH.ApplyNeighborhoodCrop(self._rmhNeighborhoodBG, self)
    end
end)
panel:HookScript("OnSizeChanged", function()
    panel._rmhPositionHeaderControls()
    if headerNeighborhoodWallpaper and headerNeighborhoodWallpaper:IsShown() then
        RMH.ApplyNeighborhoodCrop(headerNeighborhoodWallpaper, headerNeighborhoodWallpaper)
    end
    if quickFrame and quickFrame._rmhNeighborhoodBG and quickFrame._rmhNeighborhoodBG:IsShown() then
        RMH.ApplyNeighborhoodCrop(quickFrame._rmhNeighborhoodBG, quickFrame)
    end
end)

quickOwnerLabel = quickFrame:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge")
quickOwnerLabel:SetPoint("TOPLEFT",36,-25); quickOwnerLabel:SetWidth(168); quickOwnerLabel:SetJustifyH("LEFT")
quickAddressLabel = quickFrame:CreateFontString(nil,"OVERLAY","GameFontNormal")
quickAddressLabel:SetPoint("TOPLEFT",quickOwnerLabel,"BOTTOMLEFT",0,-4); quickAddressLabel:SetWidth(168); quickAddressLabel:SetJustifyH("LEFT")
local quickDescriptionLabel=quickFrame:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
quickDescriptionLabel:SetPoint("TOPLEFT",quickAddressLabel,"BOTTOMLEFT",0,-5); quickDescriptionLabel:SetWidth(168); quickDescriptionLabel:SetHeight(18); quickDescriptionLabel:SetJustifyH("LEFT"); quickDescriptionLabel:SetWordWrap(false)
do
    local quickHouseScoreLabel = quickFrame:CreateFontString(nil,"OVERLAY","GameFontNormal")
    quickHouseScoreLabel:SetWidth(112); quickHouseScoreLabel:SetJustifyH("CENTER"); quickHouseScoreLabel:SetPoint("TOPRIGHT",-20,-18); quickHouseScoreLabel:SetText("House Score")
end
quickHouseScore = quickFrame:CreateFontString(nil,"OVERLAY")
quickHouseScore:SetPoint("TOPRIGHT",-20,-36); quickHouseScore:SetWidth(112); quickHouseScore:SetJustifyH("CENTER")
local QUICK_HOUSE_SCORE_FONT_SIZE = 30
local QUICK_EDIT_RATE_FONT_SIZE = QUICK_HOUSE_SCORE_FONT_SIZE * 2
if STANDARD_TEXT_FONT then quickHouseScore:SetFont(STANDARD_TEXT_FONT,QUICK_HOUSE_SCORE_FONT_SIZE,"OUTLINE") else quickHouseScore:SetFontObject(GameFontHighlightLarge); quickHouseScore:SetScale(1.35) end
quickFrame.scoreStars={}
for index=1,5 do
    local star=quickFrame:CreateTexture(nil,"OVERLAY")
    star:SetSize(12,12); star:SetPoint("TOP",quickHouseScore,"BOTTOM",(index-3)*13,-1)
    star:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\rating_star.tga"); star:SetTexCoord(0,1,1,0); star:SetAlpha(0.18); star:SetDesaturated(true)
    quickFrame.scoreStars[index]=star
end
quickHouseCount = quickFrame:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
quickHouseCount:SetPoint("TOPRIGHT",-20,-82); quickHouseCount:SetWidth(112); quickHouseCount:SetJustifyH("CENTER")
quickHouseCount:SetTextColor(0.86,0.86,0.86)
quickHouseCount:SetText("No ratings yet")

do
    local quickRateLabel = quickFrame:CreateFontString(nil,"OVERLAY","GameFontNormalLarge")
    -- Keep the label's existing font, but center the complete label/value block
    -- high in the open area above the adjustment buttons.
    quickRateLabel:SetPoint("TOP",0,-20); quickRateLabel:SetText("Rate")
end
quickRateValue = quickFrame:CreateFontString(nil,"OVERLAY")
quickRateValue:SetPoint("TOP",0,-38); quickRateValue:SetWidth(180); quickRateValue:SetJustifyH("CENTER")
if STANDARD_TEXT_FONT then quickRateValue:SetFont(STANDARD_TEXT_FONT,QUICK_EDIT_RATE_FONT_SIZE,"OUTLINE") else quickRateValue:SetFontObject(GameFontHighlightLarge); quickRateValue:SetScale(2.70) end
quickRateValue:SetText("-")

do
    local quickAdjustSpecs={{text="-1.0",delta=-1.0},{text="-0.1",delta=-0.1},{text="+0.1",delta=0.1},{text="+1.0",delta=1.0}}
    for i,spec in ipairs(quickAdjustSpecs) do
        local b=CreateFrame("Button",nil,quickFrame,"UIPanelButtonTemplate")
        b:SetSize(92,30); b:SetPoint("TOPLEFT",66+((i-1)*105),-118); b:SetText(spec.text); b.delta=spec.delta
        quickAdjustButtons[#quickAdjustButtons+1]=b
    end
end

quickFinalCommentLabel=quickFrame:CreateFontString(nil,"OVERLAY","GameFontNormal")
quickFinalCommentLabel:SetPoint("TOPLEFT",28,-164); quickFinalCommentLabel:SetText("Final Comment (optional)")
quickFinalCommentBox=CreateFrame("EditBox",nil,quickFrame,"InputBoxTemplate")
quickFinalCommentBox:SetSize(370,40); quickFinalCommentBox:SetPoint("TOPLEFT",28,-188)
quickFinalCommentBox:SetAutoFocus(false); quickFinalCommentBox:SetMaxLetters(UIX.MAX_DESCRIPTION_BYTES); quickFinalCommentBox:SetFontObject(GameFontHighlight); quickFinalCommentBox:SetTextInsets(11,11,8,8)
quickFinalCommentBox:SetScript("OnEscapePressed",function(self) self:ClearFocus() end)
quickFinalCommentBox:SetScript("OnEnterPressed",function(self) self:ClearFocus() end)
ApplyInputChrome(quickFinalCommentBox)
-- Compact Final Comment must match the full-view Final Comment field exactly.
-- Keep the same 25% black fill/border, but explicitly layer it above the
-- compact parchment background and below the EditBox text/caret.
quickFinalCommentBox:SetFrameLevel((quickFrame:GetFrameLevel() or 1)+3)
if quickFinalCommentBox._rmhOutline then
    quickFinalCommentBox._rmhOutline:SetFrameLevel((quickFrame:GetFrameLevel() or 1)+2)
    quickFinalCommentBox._rmhOutline:SetBackdropColor(0,0,0,0.25)
    quickFinalCommentBox._rmhOutline:SetBackdropBorderColor(0,0,0,0.25)
end
quickFinalCommentBox:SetScript("OnTextChanged",function(self,user)
    if user then
        pendingFinalComment=UIX.Utf8SafeTruncate(self:GetText() or "",UIX.MAX_DESCRIPTION_BYTES)
        pendingFinalCommentHouseKey=currentHouseKey
    end
end)

quickSubmitButton=CreateFrame("Button",nil,quickFrame,"UIPanelButtonTemplate")
quickSubmitButton:SetSize(90,36); quickSubmitButton:SetPoint("TOPRIGHT",-18,-188); quickSubmitButton:SetText("Rate")
if quickSubmitButton:GetFontString() then quickSubmitButton:GetFontString():SetScale(1.12) end
quickResetButton=CreateFrame("Button",nil,quickFrame,"UIPanelButtonTemplate")
quickResetButton:SetSize(90,36); quickResetButton:SetPoint("TOPRIGHT",quickSubmitButton,"BOTTOMRIGHT",0,-10); quickResetButton:SetText("Reset")
if quickResetButton:GetFontString() then quickResetButton:GetFontString():SetScale(1.12) end
quickResetButton:SetEnabled(false)

local function TrimToLineCount(text, maxLines)
    text = tostring(text or "")
    if maxLines and maxLines > 0 then
        local count = 1
        local out = {}
        for line in (text .. "\n"):gmatch("(.-)\n") do
            if count > maxLines then break end
            out[#out + 1] = line
            count = count + 1
        end
        text = table.concat(out, "\n")
    end
    return text
end

local function EnforceLineLimit(box, maxLines, maxBytes, cleanFunc)
    if not box then return end
    box:HookScript("OnTextChanged", function(self, user)
        if self._clamping then return end
        local txt = self:GetText() or ""
        local limited = TrimToLineCount(txt, maxLines)
        if cleanFunc then limited = cleanFunc(limited) end
        if maxBytes then limited = UIX.Utf8SafeTruncate(limited, maxBytes) end
        if limited ~= txt then
            self._clamping = true
            local pos = math.min(self:GetCursorPosition() or 0, #limited)
            self:SetText(limited)
            if self.SetCursorPosition then self:SetCursorPosition(pos) end
            self._clamping = false
        end
    end)
end

-- Quick Rate is built above; attach its line limiter only after this local helper exists.
EnforceLineLimit(quickFinalCommentBox, 1, UIX.MAX_DESCRIPTION_BYTES, nil)

local function CreateCorner(atlas, sizeX, sizeY, point, x, y)
    local t = content:CreateTexture(nil, "BORDER")
    t:SetSize(sizeX, sizeY)
    t:SetPoint(point, x, y)
    UIX.SetAtlasSafe(t, atlas, false)
    return t
end
CreateCorner("housing-dashboard-filigree-corner-TL", 54, 42, "TOPLEFT", -4, 2)
CreateCorner("housing-dashboard-filigree-corner-TR", 54, 42, "TOPRIGHT", 4, 2)
CreateCorner("housing-dashboard-filigree-corner-BL", 66, 50, "BOTTOMLEFT", -6, -2)
CreateCorner("housing-dashboard-filigree-corner-BR", 66, 50, "BOTTOMRIGHT", 5, -2)

-- Large host portrait at the left of the shared header.
local hostPortraitFrame = CreateFrame("Frame", nil, content)
hostPortraitFrame:SetSize(112, 112)
hostPortraitFrame:SetPoint("TOPLEFT", 20, -15)
do
    hostPortraitFrame._rmhBG = hostPortraitFrame:CreateTexture(nil, "BACKGROUND")
    hostPortraitFrame._rmhBG:SetAllPoints()
    UIX.SetAtlasSafe(hostPortraitFrame._rmhBG, "house-upgrade-reward-icon-background", false)
end
local hostPortrait = hostPortraitFrame:CreateTexture(nil, "ARTWORK")
hostPortrait:SetSize(94, 94)
hostPortrait:SetPoint("CENTER")
do
    hostPortraitFrame._rmhOverlay = hostPortraitFrame:CreateTexture(nil, "OVERLAY")
    hostPortraitFrame._rmhOverlay:SetAllPoints()
    UIX.SetAtlasSafe(hostPortraitFrame._rmhOverlay, "house-upgrade-reward-icon-frame-overlay", false)
end
-- Never expose any legacy/stale character portrait while house state resolves.
hostPortrait:SetTexture(236712)
hostPortrait:SetTexCoord(0, 1, 0, 1)
hostPortraitFrame:Hide()

local marketUI = { profileSlots={}, viewSlots={}, bagItems={}, selectedSlot=1, bagIndex=1, quantity=1, bookFeature={}, dirty=false, itemDraftDirty=false }
local bookUI

local ownerLabel = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
ownerLabel:SetPoint("TOPLEFT", 142, -30)
ownerLabel:SetWidth(184)
ownerLabel:SetJustifyH("LEFT")
ownerLabel:SetScale(1.12)
ownerLabel:SetText("Visit a House Plot")
local addressLabel = content:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
addressLabel:SetPoint("TOPLEFT", ownerLabel, "BOTTOMLEFT", 0, -7)
addressLabel:SetWidth(184)
addressLabel:SetJustifyH("LEFT")
addressLabel:SetScale(1.08)
addressLabel:SetText("")
local ownerHint = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
ownerHint:SetPoint("TOPLEFT", addressLabel, "BOTTOMLEFT", 0, -9)
ownerHint:SetWidth(184)
ownerHint:SetJustifyH("LEFT")
ownerHint:SetText("")
ownerHint:Hide()
ownerHint:SetTextColor(0.72, 0.72, 0.72)
local hostDescriptionLabel = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
hostDescriptionLabel:SetPoint("TOPLEFT", addressLabel, "BOTTOMLEFT", 0, -10)
hostDescriptionLabel:SetWidth(184)
hostDescriptionLabel:SetHeight(18)
hostDescriptionLabel:SetJustifyH("LEFT")
hostDescriptionLabel:SetJustifyV("TOP")
hostDescriptionLabel:SetWordWrap(false)
hostDescriptionLabel:SetText("")

local function FitHeaderText(region,maxWidth,preferredScale,minScale)
    if not region then return end
    preferredScale=preferredScale or 1; minScale=minScale or 0.76
    region:SetScale(preferredScale)
    if not region.GetStringWidth then return end
    local width=tonumber(region:GetStringWidth()) or 0
    if width>maxWidth and width>0 then region:SetScale(math.max(minScale,preferredScale*(maxWidth/width))) end
end

marketUI.featureFrame = CreateFrame("Frame", nil, content)
marketUI.featureFrame:SetSize(100, 132)
marketUI.featureFrame:SetPoint("TOPLEFT", 354, -12)
marketUI.featureTitle = marketUI.featureFrame:CreateFontString(nil,"OVERLAY","GameFontNormal")
marketUI.featureTitle:SetPoint("TOP",0,-3); marketUI.featureTitle:SetWidth(98); marketUI.featureTitle:SetJustifyH("CENTER"); marketUI.featureTitle:SetText("Featured Item")
marketUI.featureIconFrame = CreateFrame("Frame",nil,marketUI.featureFrame)
marketUI.featureIconFrame:SetSize(62,62); marketUI.featureIconFrame:SetPoint("TOP",0,-24)
marketUI.featureIconBG = marketUI.featureIconFrame:CreateTexture(nil,"BACKGROUND"); marketUI.featureIconBG:SetAllPoints(); UIX.SetAtlasSafe(marketUI.featureIconBG,"house-upgrade-reward-icon-background",false)
marketUI.featureIcon = marketUI.featureIconFrame:CreateTexture(nil,"ARTWORK"); marketUI.featureIcon:SetSize(49,49); marketUI.featureIcon:SetPoint("CENTER")
marketUI.featureIconOverlay = marketUI.featureIconFrame:CreateTexture(nil,"OVERLAY"); marketUI.featureIconOverlay:SetAllPoints(); UIX.SetAtlasSafe(marketUI.featureIconOverlay,"house-upgrade-reward-icon-frame-overlay",false)
marketUI.featureHover = marketUI.featureIconFrame:CreateTexture(nil,"HIGHLIGHT"); marketUI.featureHover:SetAllPoints(); marketUI.featureHover:SetTexture("Interface\\Buttons\\CheckButtonHilight"); marketUI.featureHover:SetBlendMode("ADD"); marketUI.featureHover:SetVertexColor(1,0.82,0,0.38)
marketUI.featureHover:Hide()
marketUI.featureName = marketUI.featureFrame:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
marketUI.featureName:SetPoint("TOP",marketUI.featureIconFrame,"BOTTOM",0,-3); marketUI.featureName:SetWidth(98); marketUI.featureName:SetHeight(30); marketUI.featureName:SetJustifyH("CENTER"); marketUI.featureName:SetJustifyV("TOP"); marketUI.featureName:SetWordWrap(true); marketUI.featureName:SetText("")
marketUI.featureFrame:Show()
marketUI.featureIconFrame:EnableMouse(true)
marketUI.featureIconFrame:SetScript("OnEnter",function(self)
    if not self.itemID then return end
    marketUI.featureHover:Show()
    GameTooltip:SetOwner(self,"ANCHOR_RIGHT")
    local shown=GameTooltip.SetItemByID and pcall(GameTooltip.SetItemByID,GameTooltip,self.itemID)
    if not shown then GameTooltip:SetText(self.itemName or "Featured Item") end
    GameTooltip:Show()
end)
marketUI.featureIconFrame:SetScript("OnLeave",function() marketUI.featureHover:Hide(); GameTooltip_Hide() end)

marketUI.bookFeature.frame=CreateFrame("Frame",nil,content)
marketUI.bookFeature.frame:SetSize(100,132); marketUI.bookFeature.frame:SetPoint("TOPLEFT",462,-12)
marketUI.bookFeature.title=marketUI.bookFeature.frame:CreateFontString(nil,"OVERLAY","GameFontNormal"); marketUI.bookFeature.title:SetPoint("TOP",0,-3); marketUI.bookFeature.title:SetWidth(98); marketUI.bookFeature.title:SetJustifyH("CENTER"); marketUI.bookFeature.title:SetText("Featured Book")
marketUI.bookFeature.iconFrame=CreateFrame("Frame",nil,marketUI.bookFeature.frame); marketUI.bookFeature.iconFrame:SetSize(62,62); marketUI.bookFeature.iconFrame:SetPoint("TOP",0,-24); marketUI.bookFeature.iconFrame:EnableMouse(true)
marketUI.bookFeature.iconBG=marketUI.bookFeature.iconFrame:CreateTexture(nil,"BACKGROUND"); marketUI.bookFeature.iconBG:SetAllPoints(); UIX.SetAtlasSafe(marketUI.bookFeature.iconBG,"house-upgrade-reward-icon-background",false)
marketUI.bookFeature.icon=marketUI.bookFeature.iconFrame:CreateTexture(nil,"ARTWORK"); marketUI.bookFeature.icon:SetSize(49,49); marketUI.bookFeature.icon:SetPoint("CENTER")
marketUI.bookFeature.iconOverlay=marketUI.bookFeature.iconFrame:CreateTexture(nil,"OVERLAY"); marketUI.bookFeature.iconOverlay:SetAllPoints(); UIX.SetAtlasSafe(marketUI.bookFeature.iconOverlay,"house-upgrade-reward-icon-frame-overlay",false)
marketUI.bookFeature.hover=marketUI.bookFeature.iconFrame:CreateTexture(nil,"HIGHLIGHT"); marketUI.bookFeature.hover:SetAllPoints(); marketUI.bookFeature.hover:SetTexture("Interface\\Buttons\\CheckButtonHilight"); marketUI.bookFeature.hover:SetBlendMode("ADD"); marketUI.bookFeature.hover:SetVertexColor(1,0.82,0,0.38)
marketUI.bookFeature.hover:Hide()
marketUI.bookFeature.name=marketUI.bookFeature.frame:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); marketUI.bookFeature.name:SetPoint("TOP",marketUI.bookFeature.iconFrame,"BOTTOM",0,-3); marketUI.bookFeature.name:SetWidth(98); marketUI.bookFeature.name:SetHeight(30); marketUI.bookFeature.name:SetJustifyH("CENTER"); marketUI.bookFeature.name:SetJustifyV("TOP"); marketUI.bookFeature.name:SetWordWrap(true); marketUI.bookFeature.name:SetText("")
marketUI.bookFeature.iconFrame:SetScript("OnEnter",function(self) if not self.bookIndex then return end; marketUI.bookFeature.hover:Show(); GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText(self.bookTitle or "Featured Book"); GameTooltip:AddLine("Open this exact book in the Library.",1,1,1,true); GameTooltip:Show() end)
marketUI.bookFeature.iconFrame:SetScript("OnLeave",function() marketUI.bookFeature.hover:Hide(); GameTooltip_Hide() end)
marketUI.bookFeature.frame:Show()

local scoreBlock = CreateFrame("Frame", nil, content)
scoreBlock:SetSize(136, 126)
scoreBlock:SetPoint("TOPRIGHT", -6, -20)
local score = scoreBlock:CreateFontString(nil, "OVERLAY")
scoreBlock.score = score
score:SetPoint("TOP", 0, -7)
score:SetJustifyH("CENTER")
if STANDARD_TEXT_FONT then score:SetFont(STANDARD_TEXT_FONT, 50, "OUTLINE") else score:SetFontObject(GameFontHighlightHuge or GameFontHighlightLarge); score:SetScale(1.80) end
score:SetText("-")
scoreBlock.stars={}
for index=1,5 do
    local star=scoreBlock:CreateTexture(nil,"OVERLAY")
    star:SetSize(17,17)
    star:SetPoint("TOP",score,"BOTTOM",(index-3)*18,-2)
    star:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\rating_star.tga")
    star:SetTexCoord(0,1,1,0)
    star:SetAlpha(0.18); star:SetDesaturated(true)
    scoreBlock.stars[index]=star
end
local countLabel = scoreBlock:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
scoreBlock.countLabel = countLabel
countLabel:SetPoint("TOP", score, "BOTTOM", 0, -22)
countLabel:SetText("No ratings yet")
local headerMyRating = scoreBlock:CreateFontString(nil, "OVERLAY")
scoreBlock.myRating = headerMyRating
headerMyRating:SetPoint("BOTTOM", scoreBlock, "BOTTOM", 0, 3)
headerMyRating:SetWidth(90); headerMyRating:SetJustifyH("CENTER")
if STANDARD_TEXT_FONT then headerMyRating:SetFont(STANDARD_TEXT_FONT, 29, "OUTLINE") else headerMyRating:SetFontObject(GameFontHighlightLarge); headerMyRating:SetScale(1.35) end
headerMyRating:SetText("")
headerMyRating:Hide()
local headerMyRatingLabel = scoreBlock:CreateFontString(nil, "OVERLAY", "GameFontNormal")
scoreBlock.myRatingLabel = headerMyRatingLabel
headerMyRatingLabel:SetPoint("RIGHT", headerMyRating, "LEFT", -8, 0)
headerMyRatingLabel:SetWidth(50)
headerMyRatingLabel:SetJustifyH("RIGHT")
headerMyRatingLabel:SetText("Score")
headerMyRatingLabel:SetTextColor(1,0.82,0)
headerMyRatingLabel:Hide()
local resetMyVoteButton = CreateFrame("Button", nil, scoreBlock, "UIPanelButtonTemplate")
scoreBlock.resetMyVoteButton = resetMyVoteButton
resetMyVoteButton:SetSize(74, 24)
resetMyVoteButton:SetPoint("LEFT", headerMyRating, "RIGHT", 8, 0)
resetMyVoteButton:SetText("Reset")
if resetMyVoteButton:GetFontString() then resetMyVoteButton:GetFontString():SetScale(0.95) end
resetMyVoteButton:Hide()

local headerDivider = content:CreateTexture(nil, "ARTWORK")
headerDivider:SetPoint("TOPLEFT", 12, -145)
headerDivider:SetPoint("TOPRIGHT", -12, -145)
headerDivider:SetHeight(UIX.ONE_PIXEL)
headerDivider:SetTexture("Interface\\Buttons\\WHITE8X8")
headerDivider:SetColorTexture(0,0,0,0.25)

local ratePage = CreateFrame("Frame", nil, content)
ratePage:SetPoint("TOPLEFT", 0, -150)
ratePage:SetPoint("BOTTOMRIGHT", 0, 0)
local commentsPage = CreateFrame("Frame", nil, content)
commentsPage:SetPoint("TOPLEFT", 0, -150)
commentsPage:SetPoint("BOTTOMRIGHT", 0, 0)
commentsPage:Hide()
local profilePage = CreateFrame("Frame", nil, content)
profilePage:SetPoint("TOPLEFT", 0, -150)
profilePage:SetPoint("BOTTOMRIGHT", 0, 0)
profilePage:Hide()
local marketPage = CreateFrame("Frame", nil, content)
marketPage:SetPoint("TOPLEFT", 0, -150)
marketPage:SetPoint("BOTTOMRIGHT", 0, 0)
marketPage:Hide()
local settingsPage = CreateFrame("Frame", nil, content)
settingsPage:SetPoint("TOPLEFT", 0, -150)
settingsPage:SetPoint("BOTTOMRIGHT", 0, 0)
settingsPage:Hide()
local itemsPage = CreateFrame("Frame", nil, content)
itemsPage:SetPoint("TOPLEFT", 0, -150)
itemsPage:SetPoint("BOTTOMRIGHT", 0, 0)
itemsPage:Hide()
local libraryPage = CreateFrame("Frame", nil, content)
libraryPage:SetPoint("TOPLEFT", 0, -150)
libraryPage:SetPoint("BOTTOMRIGHT", 0, 0)
libraryPage:Hide()
local booksPage = CreateFrame("Frame", nil, content)
booksPage:SetPoint("TOPLEFT", 0, -150)
booksPage:SetPoint("BOTTOMRIGHT", 0, 0)
booksPage:Hide()

-- MARKET PAGE -----------------------------------------------------------------
marketUI.marketTitleCard=CreateFrame("Frame",nil,marketPage,"BackdropTemplate")
marketUI.marketTitleCard:SetSize(330,38); marketUI.marketTitleCard:SetPoint("TOP",0,-4)
marketUI.marketTitleCard:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=UIX.ONE_PIXEL})
marketUI.marketTitleCard:SetBackdropColor(0.035,0.025,0.015,0.82); marketUI.marketTitleCard:SetBackdropBorderColor(0.42,0.28,0.12,0.92)
marketUI.marketTitleBG=marketUI.marketTitleCard:CreateTexture(nil,"BACKGROUND")
marketUI.marketTitleBG:SetAllPoints()
if UIX.SetAtlasSafe(marketUI.marketTitleBG,"housing-dashboard-initiatives-tasks-listitem-bg",false) then
    marketUI.marketTitleCard:SetBackdropColor(0,0,0,0); marketUI.marketTitleCard:SetBackdropBorderColor(0,0,0,0)
else
    marketUI.marketTitleBG:SetColorTexture(0.035,0.025,0.015,0.82)
end
marketUI.marketTitle = marketUI.marketTitleCard:CreateFontString(nil,"OVERLAY","GameFontNormalLarge")
marketUI.marketTitle:SetPoint("CENTER",0,0); marketUI.marketTitle:SetWidth(306); marketUI.marketTitle:SetJustifyH("CENTER"); marketUI.marketTitle:SetText("Today's Specials")
marketUI.marketDescriptionCard=CreateFrame("Frame",nil,marketPage,"BackdropTemplate")
marketUI.marketDescriptionCard:SetSize(650,34); marketUI.marketDescriptionCard:SetPoint("TOP",0,-46)
marketUI.marketDescriptionCard:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=UIX.ONE_PIXEL})
marketUI.marketDescriptionCard:SetBackdropColor(0,0,0,0); marketUI.marketDescriptionCard:SetBackdropBorderColor(0,0,0,0)
marketUI.marketDescriptionBG=marketUI.marketDescriptionCard:CreateTexture(nil,"BACKGROUND"); marketUI.marketDescriptionBG:SetAllPoints()
if not UIX.SetAtlasSafe(marketUI.marketDescriptionBG,"housing-dashboard-initiatives-tasks-listitem-bg",false) then marketUI.marketDescriptionBG:SetColorTexture(0,0,0,0.25) end
marketUI.marketDescription=marketUI.marketDescriptionCard:CreateFontString(nil,"OVERLAY","GameFontHighlight")
marketUI.marketDescription:SetPoint("CENTER",0,0); marketUI.marketDescription:SetWidth(620); marketUI.marketDescription:SetJustifyH("CENTER"); marketUI.marketDescription:SetText("")
marketUI.loading = marketPage:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge")
marketUI.loading:SetPoint("CENTER",0,10); marketUI.loading:SetWidth(520); marketUI.loading:SetJustifyH("CENTER"); marketUI.loading:SetText("Loading house data..."); marketUI.loading:Hide()

for index=1,UIX.MAX_MARKET_SLOTS do
    local card=CreateFrame("Frame",nil,marketPage,"BackdropTemplate")
    card:SetSize(320,104)
    card:SetPoint("TOPLEFT",20+(((index-1)%2)*336),-88-(math.floor((index-1)/2)*116))
    card:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=UIX.ONE_PIXEL})
    card:SetBackdropColor(0,0,0,0); card:SetBackdropBorderColor(0,0,0,0)
    local cardBG=card:CreateTexture(nil,"BACKGROUND"); cardBG:SetAllPoints(); card.bg=cardBG
    if not UIX.SetAtlasSafe(cardBG,"housing-dashboard-initiatives-tasks-listitem-bg",false) then cardBG:SetColorTexture(0,0,0,0.25) end
    local iconFrame=CreateFrame("Frame",nil,card); iconFrame:SetSize(78,78); iconFrame:SetPoint("LEFT",10,0); iconFrame:EnableMouse(true); card.iconFrame=iconFrame
    local iconBG=iconFrame:CreateTexture(nil,"BACKGROUND"); iconBG:SetAllPoints(); UIX.SetAtlasSafe(iconBG,"house-upgrade-reward-icon-background",false); card.iconBG=iconBG
    local icon=iconFrame:CreateTexture(nil,"ARTWORK"); icon:SetSize(64,64); icon:SetPoint("CENTER"); card.icon=icon
    local iconOverlay=iconFrame:CreateTexture(nil,"OVERLAY"); iconOverlay:SetAllPoints(); UIX.SetAtlasSafe(iconOverlay,"house-upgrade-reward-icon-frame-overlay",false); card.iconOverlay=iconOverlay
    local quantity=card:CreateFontString(nil,"OVERLAY","GameFontHighlight"); quantity:SetPoint("TOPRIGHT",-10,-10); quantity:SetWidth(42); quantity:SetJustifyH("RIGHT"); card.quantity=quantity
    local name=card:CreateFontString(nil,"OVERLAY","GameFontNormalLarge"); name:SetPoint("TOPLEFT",iconFrame,"TOPRIGHT",12,-8); name:SetPoint("RIGHT",quantity,"LEFT",-6,0); name:SetHeight(22); name:SetJustifyH("LEFT"); name:SetJustifyV("TOP"); name:SetWordWrap(false); card.name=name
    local description=card:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); description:SetPoint("TOPLEFT",name,"BOTTOMLEFT",0,-4); description:SetPoint("RIGHT",-10,0); description:SetHeight(34); description:SetJustifyH("LEFT"); description:SetJustifyV("TOP"); description:SetWordWrap(true); card.description=description
    local price=card:CreateFontString(nil,"OVERLAY","GameFontHighlight"); price:SetPoint("BOTTOMLEFT",iconFrame,"BOTTOMRIGHT",12,8); price:SetPoint("RIGHT",-10,0); price:SetJustifyH("LEFT"); card.price=price
    iconFrame:SetScript("OnEnter",function(self)
        if not self.itemID then return end
        GameTooltip:SetOwner(self,"ANCHOR_RIGHT")
        local shown=GameTooltip.SetItemByID and pcall(GameTooltip.SetItemByID,GameTooltip,self.itemID)
        if not shown then GameTooltip:SetText(self.itemName or "Market Item") end
        GameTooltip:Show()
    end)
    iconFrame:SetScript("OnLeave",GameTooltip_Hide)
    iconFrame:SetScript("OnMouseUp",function(self,button)
        if button~="LeftButton" or not self.itemID then return end
        local link
        if C_Item and C_Item.GetItemLinkByID then local ok,value=pcall(C_Item.GetItemLinkByID,self.itemID); if ok then link=value end end
        if not link and GetItemInfo then local returned={pcall(GetItemInfo,self.itemID)}; if returned[1] then link=returned[3] end end
        if link and ChatEdit_InsertLink then
            local editBox=ChatEdit_GetActiveWindow and ChatEdit_GetActiveWindow() or nil
            if not editBox and ChatFrame_OpenChat then ChatFrame_OpenChat(""); editBox=ChatEdit_GetActiveWindow and ChatEdit_GetActiveWindow() or nil end
            if editBox then pcall(ChatEdit_InsertLink,link) elseif ChatFrame_OpenChat then ChatFrame_OpenChat(link) end
        elseif link and ChatFrame_OpenChat then ChatFrame_OpenChat(link) end
    end)
    marketUI.viewSlots[index]=card
end

local offPlotLogo = content:CreateTexture(nil, "ARTWORK")
offPlotLogo:SetSize(235, 235)
offPlotLogo:SetPoint("CENTER", content, "CENTER", 0, 60)
offPlotLogo:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\logo.tga")
offPlotLogo:SetTexCoord(0, 1, 0, 1)
offPlotLogo:Hide()
local offPlotMessage = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
offPlotMessage:SetPoint("TOP", offPlotLogo, "BOTTOM", 0, -12)
offPlotMessage:SetWidth(610)
offPlotMessage:SetJustifyH("CENTER")
offPlotMessage:SetJustifyV("MIDDLE")
offPlotMessage:SetTextColor(0.95, 0.95, 0.95)
offPlotMessage:Hide()

content._rmhTipFrame = CreateFrame("Frame", nil, content)
content._rmhTipFrame:SetSize(560, 80)
content._rmhTipFrame:SetPoint("TOP", offPlotMessage, "BOTTOM", 0, -14)
content._rmhTipFrame:Hide()

content._rmhTipOpen = CreateFrame("Button", nil, content._rmhTipFrame, "UIPanelButtonTemplate")
content._rmhTipOpen:SetSize(230, 28)
content._rmhTipOpen:SetPoint("TOP", 0, -2)
content._rmhTipOpen:SetText("Rmh - Moon Guard - Alliance")
content._rmhTipOpen:SetScript("OnClick", function()
    if RMH.PrepareTipMail then
        RMH:PrepareTipMail("Rmh-MoonGuard", "Open Mailbox")
    end
end)
content._rmhTipOpen:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:SetText("Rmh - Moon Guard - Alliance")
    GameTooltip:AddLine("Open a nearby mailbox first, then click to autofill the recipient.", 1, 1, 1, true)
    GameTooltip:Show()
end)
content._rmhTipOpen:SetScript("OnLeave", GameTooltip_Hide)

content._rmhTipMessage = content._rmhTipFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
content._rmhTipMessage:SetPoint("TOP", content._rmhTipOpen, "BOTTOM", 0, -10)
content._rmhTipMessage:SetWidth(500)
content._rmhTipMessage:SetJustifyH("CENTER")
content._rmhTipMessage:SetTextColor(0.92, 0.84, 0.62)
content._rmhTipMessage:SetText("Tips are welcome! Thanks for supporting RateMyHouse!")
local status = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
status:SetPoint("BOTTOMLEFT", 18, 10)
status:SetPoint("BOTTOMRIGHT", -18, 10)
status:SetJustifyH("CENTER")
status:SetText("You must be at a player house to rate.")
status:Hide()
SetStatus = function(text, r, g, b)
    status:SetText(text or "")
    status:SetTextColor(r or 0.82, g or 0.82, b or 0.82)
    status:Hide()
end

local function SetTabSelected(tab, selected)
    if selected then
        if PanelTemplates_SelectTab then pcall(PanelTemplates_SelectTab, tab) else tab:SetButtonState("PUSHED", true); tab:SetEnabled(false) end
    else
        if PanelTemplates_DeselectTab then pcall(PanelTemplates_DeselectTab, tab) else tab:SetButtonState("NORMAL"); tab:SetEnabled(true) end
    end
end
local function SetActivePage(page)
    if page ~= "COMMENTS" and page ~= "RATING" and page ~= "MARKET" and page ~= "LIBRARY" and page ~= "BOOKS" and page ~= "PROFILE" and page ~= "ITEMS" then page = "RATE" end
    activePage = page
    ratePage:SetShown(page == "RATE")
    commentsPage:SetShown(page == "COMMENTS")
    profilePage:SetShown(page == "RATING")
    marketPage:SetShown(page == "MARKET")
    libraryPage:SetShown(page == "LIBRARY")
    booksPage:SetShown(page == "BOOKS")
    settingsPage:SetShown(page == "PROFILE")
    itemsPage:SetShown(page == "ITEMS")
    SetTabSelected(rateTab, page == "RATE")
    SetTabSelected(commentsTab, page == "COMMENTS")
    SetTabSelected(ratingTab, page == "RATING")
    SetTabSelected(marketTab, page == "MARKET")
    SetTabSelected(libraryTab, page == "LIBRARY")
    SetTabSelected(booksTab, page == "BOOKS")
    SetTabSelected(settingsTab, page == "PROFILE")
    SetTabSelected(itemsTab, page == "ITEMS")
end

local function SetReviewOnlyTabs(reviewOnly)
    rateTab:ClearAllPoints(); marketTab:ClearAllPoints(); commentsTab:ClearAllPoints(); ratingTab:ClearAllPoints(); libraryTab:ClearAllPoints(); booksTab:ClearAllPoints(); settingsTab:ClearAllPoints(); itemsTab:ClearAllPoints()
    if reviewOnly then
        rateTab:Hide(); booksTab:Hide(); settingsTab:Hide(); itemsTab:Hide(); marketTab:Show(); commentsTab:Show(); ratingTab:Show(); libraryTab:Show()
        marketTab:SetPoint("BOTTOMLEFT", navBar, "BOTTOMLEFT", 4, 1)
        libraryTab:SetPoint("LEFT", marketTab, "RIGHT", 3, 0)
        commentsTab:SetPoint("LEFT", libraryTab, "RIGHT", 3, 0)
        ratingTab:SetPoint("LEFT", commentsTab, "RIGHT", 3, 0)
    else
        rateTab:Show(); marketTab:Show(); commentsTab:Show(); ratingTab:Show(); libraryTab:Show(); booksTab:Show(); settingsTab:Show(); itemsTab:Show()
        rateTab:SetPoint("BOTTOMLEFT", navBar, "BOTTOMLEFT", 4, 1)
        marketTab:SetPoint("LEFT", rateTab, "RIGHT", 3, 0)
        libraryTab:SetPoint("LEFT", marketTab, "RIGHT", 3, 0)
        commentsTab:SetPoint("LEFT", libraryTab, "RIGHT", 3, 0)
        ratingTab:SetPoint("LEFT", commentsTab, "RIGHT", 3, 0)
        settingsTab:SetPoint("LEFT", ratingTab, "RIGHT", 10, 0)
        itemsTab:SetPoint("LEFT", settingsTab, "RIGHT", 3, 0)
        booksTab:SetPoint("LEFT", itemsTab, "RIGHT", 3, 0)
    end
end

local function ApplySimpleOffPlotState(inside)
    local linkedReview = (not inside) and linkedReviewMode and viewedProfileHouseKey ~= nil and (activePage == "COMMENTS" or activePage == "RATING" or activePage == "MARKET" or activePage == "LIBRARY")
    local simple = (not inside) and not linkedReview
    SetReviewOnlyTabs(linkedReview)

    content._rmhOffPlotToken = (content._rmhOffPlotToken or 0) + 1
    if simple then
        content._rmhParchmentBG:Hide(); content._rmhParchmentShade:Hide()
        if content._rmhNeighborhoodBG then content._rmhNeighborhoodBG:Hide() end; if content._rmhNeighborhoodShade then content._rmhNeighborhoodShade:Hide() end
        content._rmhOffPlotBG:Show(); content._rmhOffPlotShade:Show()
        hostPortraitFrame:Hide(); ownerLabel:Hide(); addressLabel:Hide(); hostDescriptionLabel:Hide(); marketUI.featureFrame:Hide(); marketUI.bookFeature.frame:Hide(); scoreBlock:Hide(); headerDivider:Hide()
        ratePage:Hide(); commentsPage:Hide(); profilePage:Hide(); marketPage:Hide(); libraryPage:Hide(); booksPage:Hide(); settingsPage:Hide(); itemsPage:Hide()
        offPlotLogo:Show()
        offPlotMessage:SetText("Visit a House Plot")
        offPlotMessage:ClearAllPoints(); offPlotMessage:SetPoint("TOP", offPlotLogo, "BOTTOM", 0, -12); offPlotMessage:Show()
        if content._rmhTipFrame then content._rmhTipFrame:Show() end

        if C_Timer and C_Timer.After then
            local token = content._rmhOffPlotToken
            C_Timer.After(0.05, function()
                if token ~= content._rmhOffPlotToken then return end
                if panel and panel:IsShown() and not UIX.IsInsideHouseOrPlot() and not linkedReviewMode then
                    offPlotLogo:Show()
                    offPlotMessage:Show()
                    if content._rmhTipFrame then content._rmhTipFrame:Show() end
                end
            end)
        end
    else
        content._rmhOffPlotBG:Hide(); content._rmhOffPlotShade:Hide()
        content._rmhParchmentBG:Show(); content._rmhParchmentShade:Show()
        offPlotLogo:Hide(); offPlotMessage:Hide(); if content._rmhTipFrame then content._rmhTipFrame:Hide() end
        hostPortraitFrame:Show(); ownerLabel:Show(); addressLabel:Show(); hostDescriptionLabel:Show(); marketUI.featureFrame:Show(); marketUI.bookFeature.frame:Show(); scoreBlock:Show(); headerDivider:Show()
        if linkedReview then
            ratePage:Hide(); booksPage:Hide(); settingsPage:Hide(); commentsPage:SetShown(activePage=="COMMENTS"); profilePage:SetShown(activePage=="RATING"); marketPage:SetShown(activePage=="MARKET"); libraryPage:SetShown(activePage=="LIBRARY")
        end
    end
end

panel:HookScript("OnHide", function()
    -- Linked reviews are temporary. Closing RMH returns the next open to the normal off-plot preview.
    linkedReviewMode = false
    viewedProfileGUID = nil
    viewedProfileName = nil
    viewedProfileHouseKey = nil
    SetReviewOnlyTabs(false)
    SetActivePage("RATE")
end)
panel:HookScript("OnShow", function()
    UIX.RequestOwnedHouseList()
    if not linkedReviewMode and UIX.IsInsideHouseOrPlot() and currentHouseKey and currentHouse then
        activePage="RATE"
        SetActivePage(activePage)
    end
    if UpdateUI then UpdateUI() end
end)

local function FindUnitTokenByName(name)
    name = UIX.ShortName(name or "")
    if name == "" then return nil end
    local fixed = { "player", "target", "focus", "mouseover" }
    for _, unit in ipairs(fixed) do
        if UnitExists(unit) and UnitIsPlayer(unit) and UIX.SamePlayerName(UnitName(unit) or "", name) then return unit end
    end
    for i = 1, 40 do local u="nameplate"..i; if UnitExists(u) and UnitIsPlayer(u) and UIX.SamePlayerName(UnitName(u) or "",name) then return u end end
    for i = 1, 4 do local u="party"..i; if UnitExists(u) and UnitIsPlayer(u) and UIX.SamePlayerName(UnitName(u) or "",name) then return u end end
    for i = 1, 40 do local u="raid"..i; if UnitExists(u) and UnitIsPlayer(u) and UIX.SamePlayerName(UnitName(u) or "",name) then return u end end
    for i = 1, 5 do local u="arena"..i; if UnitExists(u) and UnitIsPlayer(u) and UIX.SamePlayerName(UnitName(u) or "",name) then return u end end
    return nil
end

local function FindUnitTokenByGUID(guid)
    if not guid or guid == "" then return nil end
    local fixed = { "player", "target", "focus", "mouseover" }
    for _, unit in ipairs(fixed) do if UnitExists(unit) and UnitIsPlayer(unit) and UnitGUID(unit)==guid then return unit end end
    for i = 1, 40 do local u="nameplate"..i; if UnitExists(u) and UnitIsPlayer(u) and UnitGUID(u)==guid then return u end end
    for i = 1, 4 do local u="party"..i; if UnitExists(u) and UnitIsPlayer(u) and UnitGUID(u)==guid then return u end end
    for i = 1, 40 do local u="raid"..i; if UnitExists(u) and UnitIsPlayer(u) and UnitGUID(u)==guid then return u end end
    for i = 1, 5 do local u="arena"..i; if UnitExists(u) and UnitIsPlayer(u) and UnitGUID(u)==guid then return u end end
    return nil
end

local ALLIANCE_RACES = {
    Human=true, Dwarf=true, NightElf=true, Gnome=true, Draenei=true, Worgen=true,
    LightforgedDraenei=true, VoidElf=true, DarkIronDwarf=true, KulTiran=true, Mechagnome=true,
}
local HORDE_RACES = {
    Orc=true, Scourge=true, Undead=true, Tauren=true, Troll=true, BloodElf=true, Goblin=true,
    Nightborne=true, HighmountainTauren=true, MagharOrc=true, ZandalariTroll=true, Vulpera=true,
}
local function InferOfflineFaction(guid, faction)
    faction=tostring(faction or "")
    if faction=="Alliance" or faction=="Horde" then return faction end
    if guid and guid~="" and GetPlayerInfoByGUID then
        local ok, _, _, _, englishRace = pcall(GetPlayerInfoByGUID, guid)
        if ok and englishRace then
            englishRace=tostring(englishRace):gsub("[%s%-']","")
            if ALLIANCE_RACES[englishRace] then return "Alliance" end
            if HORDE_RACES[englishRace] then return "Horde" end
        end
    end
    return ""
end

local function SetCharacterPortrait(texture,guid,name,faction)
    texture:SetTexCoord(0,1,0,1)
    local unit=guid and FindUnitTokenByGUID(guid) or nil
    if not unit and name then unit=FindUnitTokenByName(name) end
    if unit and UnitExists(unit) and UnitIsPlayer(unit) and (not UnitIsConnected or UnitIsConnected(unit)) and SetPortraitTexture then
        local ok=pcall(SetPortraitTexture,texture,unit)
        if ok then
            local resolved=UnitFactionGroup and select(1,UnitFactionGroup(unit)) or faction
            if resolved~="Alliance" and resolved~="Horde" then resolved="" end
            return true,resolved
        end
    end
    faction=InferOfflineFaction(guid, faction)
    texture:Show(); texture:SetTexCoord(0,1,0,1)
    if faction=="Alliance" then
        texture:SetTexture(2173919) -- ui_allianceicon
        texture:SetTexCoord(0,1,0,1)
        texture:SetVertexColor(1,1,1,1)
        return false,"Alliance"
    elseif faction=="Horde" then
        texture:SetTexture(2173920) -- ui_hordeicon
        texture:SetTexCoord(0,1,0,1)
        texture:SetVertexColor(1,1,1,1)
        return false,"Horde"
    end
    texture:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\icon.tga")
    return false,""
end

-- RATE PAGE --------------------------------------------------------------------
local ratedSidebar = CreateFrame("Frame", nil, ratePage, "BackdropTemplate")
ratedSidebar:SetPoint("TOPLEFT", 3, -4)
ratedSidebar:SetPoint("BOTTOMLEFT", 3, 30)
ratedSidebar:SetWidth(92)
ratedSidebar:SetBackdrop(nil)
ratePage._rmhMainPanel=nil

local ratedSidebarScroll = CreateFrame("ScrollFrame", nil, ratedSidebar, "UIPanelScrollFrameTemplate")
ratedSidebarScroll:SetPoint("TOPLEFT", 7, -54)
ratedSidebarScroll:SetPoint("BOTTOMRIGHT", -7, 7)
if ratedSidebarScroll.ScrollBar then
    ratedSidebarScroll.ScrollBar:SetAlpha(0)
    ratedSidebarScroll.ScrollBar:EnableMouse(false)
end
local ratedSidebarChild = CreateFrame("Frame", nil, ratedSidebarScroll)
ratedSidebarChild:SetSize(74, 1)
ratedSidebarScroll:SetScrollChild(ratedSidebarChild)
ratedSidebarScroll:EnableMouseWheel(true)
ratedSidebarScroll:SetScript("OnMouseWheel",function(self,delta)
    local range=(self.GetVerticalScrollRange and self:GetVerticalScrollRange()) or 0
    self:SetVerticalScroll(math.max(0,math.min(range,(self:GetVerticalScroll() or 0)-(delta*48))))
end)
local ratedSidebarRows = {}
local ratedSidebarPlaceholders = {}
local ratedSidebarCapacity = 7

local categoryIconFrame = CreateFrame("Frame", nil, ratePage)
categoryIconFrame:SetSize(40, 40)
categoryIconFrame:SetPoint("TOPLEFT", 118, -18)
local categoryIconBG = categoryIconFrame:CreateTexture(nil, "BACKGROUND")
categoryIconBG:SetAllPoints(); UIX.SetAtlasSafe(categoryIconBG, "house-upgrade-reward-icon-background", false)
local categoryIcon = categoryIconFrame:CreateTexture(nil, "ARTWORK")
categoryIcon:SetSize(28, 28); categoryIcon:SetPoint("CENTER")
local categoryIconOverlay = categoryIconFrame:CreateTexture(nil, "OVERLAY")
categoryIconOverlay:SetAllPoints(); UIX.SetAtlasSafe(categoryIconOverlay, "house-upgrade-reward-icon-frame-overlay", false)

local categoryButton = CreateFrame("Button", nil, ratePage, "UIPanelButtonTemplate")
categoryButton:SetSize(500, 40)
categoryButton:SetPoint("TOPLEFT", 166, -18)
categoryButton:SetText("Custom")
if categoryButton:GetFontString() then categoryButton:GetFontString():SetScale(1.15) end
local categoryArrow = categoryButton:CreateTexture(nil, "OVERLAY")
categoryArrow:SetTexture("Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up")
categoryArrow:SetSize(20,20); categoryArrow:SetPoint("RIGHT", -6, 0)

-- Delete only becomes active when the currently selected category already has a saved rating.
local deleteCategoryButton = CreateFrame("Button", nil, ratePage, "UIPanelButtonTemplate")
deleteCategoryButton:SetSize(40, 40)
deleteCategoryButton:SetPoint("TOPLEFT", 628, -18)
deleteCategoryButton:SetText("X")
deleteCategoryButton:SetEnabled(false)
if deleteCategoryButton:GetFontString() then deleteCategoryButton:GetFontString():SetScale(1.18) end
deleteCategoryButton:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:SetText("Delete Category Rate")
    GameTooltip:AddLine("Delete the selected category rate saving.", 1, 1, 1, true)
    GameTooltip:Show()
end)
deleteCategoryButton:SetScript("OnLeave", GameTooltip_Hide)

-- Custom Category uses the same total horizontal footprint as the normal selector:
-- text input on the left, category dropdown on the right.
local customCategoryBox = CreateFrame("EditBox", nil, ratePage, "InputBoxTemplate")
customCategoryBox:SetSize(220, 40)
customCategoryBox:SetPoint("TOPLEFT", 166, -18)
customCategoryBox:SetAutoFocus(false)
customCategoryBox:SetMaxLetters(UIX.MAX_CUSTOM_CATEGORY_BYTES)
customCategoryBox:SetTextInsets(10,10,7,7)
customCategoryBox:Hide()
customCategoryBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
customCategoryBox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
customCategoryBox:SetScript("OnTextChanged", function(self, user)
    if user then customCategoryTitle = UIX.CleanCustomCategoryTitle(self:GetText()) end
end)
ApplyInputChrome(customCategoryBox)
EnforceLineLimit(customCategoryBox, 1, UIX.MAX_CUSTOM_CATEGORY_BYTES, nil)

-- ApplyInputChrome uses a sibling frame so the fill can sit behind the EditBox
-- text and caret. A sibling does not automatically hide with the EditBox, so
-- always toggle both layers together to prevent the input surface from showing
-- behind built-in category buttons.
local function SetCustomCategoryFieldShown(shown)
    if shown then
        customCategoryBox:Show()
        if customCategoryBox._rmhOutline then customCategoryBox._rmhOutline:Show() end
    else
        if customCategoryBox:HasFocus() then customCategoryBox:ClearFocus() end
        customCategoryBox:Hide()
        if customCategoryBox._rmhOutline then customCategoryBox._rmhOutline:Hide() end
    end
end
SetCustomCategoryFieldShown(false)

local categoryRatingLabel = ratePage:CreateFontString(nil, "OVERLAY", "GameFontNormal")
categoryRatingLabel:SetPoint("TOP", 38, -82)
categoryRatingLabel:SetText("")
categoryRatingLabel:SetTextColor(1, 0.82, 0)

-- Keep the live value mathematically centered in both directions:
--   horizontal: the 10px inner gap between the -0.1 and +0.1 buttons
--   vertical:   the open space between the category selector and rating row
-- These shared layout constants keep the value responsive to the Rate controls
-- instead of offsetting it from the page's changing horizontal center.
local RATING_BUTTON_LEFT = 118
local RATING_BUTTON_STEP = 136
local RATING_BUTTON_WIDTH = 126
local RATING_BUTTON_HEIGHT = 44
local RATING_BUTTON_TOP = -132
local CATEGORY_SELECTOR_TOP = -18
local CATEGORY_SELECTOR_HEIGHT = 40
local RATING_VALUE_CENTER_X = RATING_BUTTON_LEFT + RATING_BUTTON_STEP + RATING_BUTTON_WIDTH + ((RATING_BUTTON_STEP - RATING_BUTTON_WIDTH) / 2)
local RATING_VALUE_CENTER_Y = ((CATEGORY_SELECTOR_TOP - CATEGORY_SELECTOR_HEIGHT) + RATING_BUTTON_TOP) / 2
local categoryRatingValue = ratePage:CreateFontString(nil, "OVERLAY")
categoryRatingValue:SetPoint("CENTER", ratePage, "TOPLEFT", RATING_VALUE_CENTER_X, RATING_VALUE_CENTER_Y)
categoryRatingValue:SetWidth(260); categoryRatingValue:SetJustifyH("CENTER")
if STANDARD_TEXT_FONT then categoryRatingValue:SetFont(STANDARD_TEXT_FONT, 42, "OUTLINE") else categoryRatingValue:SetFontObject(GameFontHighlightLarge); categoryRatingValue:SetScale(1.55) end
categoryRatingValue:SetText("-")

-- After Final Vote, reuse the exact live-rating center instead of shrinking the
-- shared header score. This keeps the House Overall score stable and replaces
-- the old dash/value with the player's completed score and Reset action.
local rateFinalSummary = CreateFrame("Frame", nil, ratePage)
rateFinalSummary:SetSize(350, 54)
rateFinalSummary:SetPoint("CENTER", ratePage, "TOPLEFT", RATING_VALUE_CENTER_X, RATING_VALUE_CENTER_Y)
rateFinalSummary:Hide()
local rateFinalLabel = rateFinalSummary:CreateFontString(nil, "OVERLAY", "GameFontNormal")
rateFinalSummary.label = rateFinalLabel
rateFinalLabel:SetPoint("RIGHT", rateFinalSummary, "CENTER", -58, 0)
rateFinalLabel:SetWidth(102); rateFinalLabel:SetJustifyH("RIGHT")
rateFinalLabel:SetText("Average Score")
local rateFinalValue = rateFinalSummary:CreateFontString(nil, "OVERLAY")
rateFinalSummary.value = rateFinalValue
rateFinalValue:SetPoint("CENTER", rateFinalSummary, "CENTER", 0, 0)
rateFinalValue:SetWidth(90); rateFinalValue:SetJustifyH("CENTER")
if STANDARD_TEXT_FONT then rateFinalValue:SetFont(STANDARD_TEXT_FONT, 42, "OUTLINE") else rateFinalValue:SetFontObject(GameFontHighlightLarge); rateFinalValue:SetScale(1.55) end
local rateFinalReset = CreateFrame("Button", nil, rateFinalSummary, "UIPanelButtonTemplate")
rateFinalSummary.reset = rateFinalReset
rateFinalReset:SetSize(68, 25)
rateFinalReset:SetPoint("LEFT", rateFinalValue, "RIGHT", 8, 0)
rateFinalReset:SetText("Reset")
if rateFinalReset:GetFontString() then rateFinalReset:GetFontString():SetScale(0.92) end

-- Four clean adjustment buttons replace the 0-10 number row.
local ratingButtons = {}
local adjustSpecs = {
    { text = "-1.0", delta = -1.0 },
    { text = "-0.1", delta = -0.1 },
    { text = "+0.1", delta = 0.1 },
    { text = "+1.0", delta = 1.0 },
}
for index, spec in ipairs(adjustSpecs) do
    local button = CreateFrame("Button", nil, ratePage, "UIPanelButtonTemplate")
    button:SetSize(RATING_BUTTON_WIDTH, RATING_BUTTON_HEIGHT)
    button:SetPoint("TOPLEFT", RATING_BUTTON_LEFT + ((index - 1) * RATING_BUTTON_STEP), RATING_BUTTON_TOP)
    button:SetText(spec.text)
    button.delta = spec.delta
    if button:GetFontString() then button:GetFontString():SetScale(1.18) end
    ratingButtons[#ratingButtons + 1] = button
end

local function CreateRateTextField(labelText, labelY, boxY, multiline, maxLines)
    local label = ratePage:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    label:SetPoint("TOPLEFT", 104, labelY)
    label:SetText(labelText)

    local box = CreateFrame("EditBox", nil, ratePage, "InputBoxTemplate")
    local fieldHeight = (multiline == true) and 84 or 40
    box:SetSize(550, fieldHeight)
    box:SetPoint("TOPLEFT", 104, boxY)
    box:SetAutoFocus(false)
    box:SetMultiLine(multiline == true)
    box:SetMaxLetters(UIX.MAX_DESCRIPTION_BYTES)
    box:SetFontObject(GameFontHighlight)
    box:SetTextInsets(11, 11, 8, 8)
    if multiline == true and box.SetJustifyV then box:SetJustifyV("TOP") end
    box:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    ApplyInputChrome(box)
    EnforceLineLimit(box, maxLines, UIX.MAX_DESCRIPTION_BYTES, nil)
    return label, box
end

-- Description uses a dedicated visible container plus a plain multiline EditBox.
-- This avoids InputBoxTemplate and guarantees the rendered field keeps its full height.
local descriptionLabel = ratePage:CreateFontString(nil, "OVERLAY", "GameFontNormal")
descriptionLabel:SetPoint("TOPLEFT", 104, -190)
descriptionLabel:SetText("Description (optional)")

local descriptionField = CreateFrame("Frame", nil, ratePage, "BackdropTemplate")
descriptionField:SetSize(550, 96)
descriptionField:SetPoint("TOPLEFT", 104, -212)
descriptionField:SetBackdrop({ bgFile="Interface\\Buttons\\WHITE8X8", edgeFile="Interface\\Buttons\\WHITE8X8", edgeSize=UIX.ONE_PIXEL })
descriptionField:SetBackdropColor(0, 0, 0, 0.25)
descriptionField:SetBackdropBorderColor(0, 0, 0, 0.25)

descriptionBox = CreateFrame("EditBox", nil, descriptionField)
descriptionBox:SetPoint("TOPLEFT", 10, -8)
descriptionBox:SetPoint("BOTTOMRIGHT", -10, 8)
descriptionBox:SetAutoFocus(false)
descriptionBox:SetMultiLine(true)
descriptionBox:SetMaxLetters(UIX.MAX_DESCRIPTION_BYTES)
descriptionBox:SetFontObject(GameFontHighlight)
descriptionBox:SetJustifyH("LEFT")
if descriptionBox.SetJustifyV then descriptionBox:SetJustifyV("TOP") end
descriptionBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
descriptionBox:SetScript("OnShow", function(self)
    if descriptionField then descriptionField:SetSize(550, 96) end
end)
EnforceLineLimit(descriptionBox, 5, UIX.MAX_DESCRIPTION_BYTES, nil)

local finalRateCommentLabel, finalRateCommentBox = CreateRateTextField("Final Comment (optional)", -326, -348, false, 1)
if finalRateCommentBox._rmhOutline then
    finalRateCommentBox._rmhOutline:SetBackdropColor(0,0,0,0.25)
    finalRateCommentBox._rmhOutline:SetBackdropBorderColor(0,0,0,0.25)
end
finalRateCommentBox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
finalRateCommentBox:SetScript("OnTextChanged", function(self, user)
    if user then
        pendingFinalComment = UIX.Utf8SafeTruncate(self:GetText() or "", UIX.MAX_DESCRIPTION_BYTES)
        pendingFinalCommentHouseKey = currentHouseKey
    end
end)

local rateAction = CreateFrame("Button", nil, ratePage, "UIPanelButtonTemplate")
rateAction:SetSize(150, 46); rateAction:SetPoint("TOPLEFT", 116, -398); rateAction:SetText("Rate")
if rateAction:GetFontString() then rateAction:GetFontString():SetScale(1.25) end
local finalVoteButton = CreateFrame("Button", nil, ratePage, "UIPanelButtonTemplate")
finalVoteButton:SetSize(150,46); finalVoteButton:SetPoint("TOP", 35, -398); finalVoteButton:SetText("Final Vote")
if finalVoteButton:GetFontString() then finalVoteButton:GetFontString():SetScale(1.25) end
local postButton = CreateFrame("Button", nil, ratePage, "UIPanelButtonTemplate")
postButton:SetSize(150,46); postButton:SetPoint("TOPRIGHT", -57, -398); postButton:SetText("Share Review")
if postButton:GetFontString() then postButton:GetFontString():SetScale(1.15) end


-- Housing-editor-inspired category dropdown. Final Vote is intentionally not a category.
local categoryMenu = CreateFrame("Frame", "RateMyHouseCategoryMenu", panel, "BackdropTemplate")
categoryMenu:SetSize(500, 360); categoryMenu:SetFrameStrata("FULLSCREEN_DIALOG")
categoryMenu:SetBackdrop({ bgFile="Interface\\DialogFrame\\UI-DialogBox-Background-Dark", edgeFile="Interface\\Tooltips\\UI-Tooltip-Border", tile=true, tileSize=16, edgeSize=16, insets={left=4,right=4,top=4,bottom=4} })
categoryMenu:SetBackdropColor(0.035,0.025,0.015,0.985); categoryMenu:SetBackdropBorderColor(0.72,0.56,0.28,1)
categoryMenu:SetPoint("TOPLEFT", categoryIconFrame, "BOTTOMLEFT", 0, -4); categoryMenu:Hide()
local categoryMenuTitle = categoryMenu:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
categoryMenuTitle:SetPoint("TOPLEFT",16,-14); categoryMenuTitle:SetText("Categories"); categoryMenuTitle:SetScale(1.22)
local categoryScroll = CreateFrame("ScrollFrame", nil, categoryMenu, "UIPanelScrollFrameTemplate")
categoryScroll:SetPoint("TOPLEFT",10,-44); categoryScroll:SetPoint("BOTTOMRIGHT",-31,10)
local categoryScrollChild = CreateFrame("Frame", nil, categoryScroll); categoryScrollChild:SetSize(448,1); categoryScroll:SetScrollChild(categoryScrollChild)
local categoryMenuRows = {}

-- Custom Category always uses a fixed Note icon.
local selectedCustomIconKey = ""
local function SetCustomIconTexture(texture)
    UIX.SetReviewCategoryIcon(texture, UIX.ReviewCategoryByKey.custom)
end
categoryIconFrame:EnableMouse(false)
categoryIconFrame:SetScript("OnMouseUp", nil)
categoryIconFrame:SetScript("OnEnter", nil)
categoryIconFrame:SetScript("OnLeave", nil)

local function SetCategorySelection(key, loadExisting)
    local data, vote, entry
    if currentHouseKey then
        data = UIX.EnsureHouseData(currentHouseKey, currentHouse)
        vote = data and data.votes and data.votes[UIX.PlayerGUID()]
    end

    entry = vote and vote.categories and vote.categories[key]
    local isDynamicCustom = UIX.IsCustomCategoryKey(key) and key ~= "custom" and entry ~= nil
    local commentsAllowed=not data or not data.hostSettings or data.hostSettings.commentsEnabled~=false
    local valid=(UIX.ReviewCategoryByKey[key]~=nil) or isDynamicCustom
    if key=="all" or not valid or not UIX.IsReviewCategoryEnabled(data,key) or (UIX.IsCustomCategoryKey(key) and not commentsAllowed) then
        key=UIX.FirstEnabledCategoryKey(data,commentsAllowed)
        entry=vote and vote.categories and vote.categories[key]
        isDynamicCustom=UIX.IsCustomCategoryKey(key) and key~="custom" and entry~=nil
    end

    selectedCategoryKey = key
    selectedRating = nil
    local isCustom = UIX.IsCustomCategoryKey(key)
    local category = isCustom and UIX.ReviewCategoryByKey.custom or UIX.GetSelectedCategory()
    if isCustom then SetCustomIconTexture(categoryIcon) else UIX.SetReviewCategoryIcon(categoryIcon, category) end

    if isCustom then
        SetCustomCategoryFieldShown(true)
        if loadExisting and entry then
            customCategoryTitle = UIX.CleanCustomCategoryTitle(entry.label or "")
            if customCategoryTitle == "Custom Category" or customCategoryTitle == "Custom" then customCategoryTitle = "" end
        elseif key == "custom" then
            customCategoryTitle = ""
        end
        customCategoryBox:SetText(customCategoryTitle or "")
        categoryButton:ClearAllPoints()
        categoryButton:SetSize(216, 40)
        categoryButton:SetPoint("TOPLEFT", 398, -18)
        categoryButton:SetText("Categories")
    else
        SetCustomCategoryFieldShown(false)
        categoryButton:ClearAllPoints()
        categoryButton:SetSize(456, 40)
        categoryButton:SetPoint("TOPLEFT", 166, -18)
        categoryButton:SetText(category and category.label or "Custom")
    end

    if loadExisting and entry then descriptionBox:SetText(entry.description or "") else descriptionBox:SetText("") end
    descriptionBox:ClearFocus()
end

local function CurrentHouseCommentsEnabled()
    if not currentHouseKey then return true end
    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse)
    return not data or not data.hostSettings or data.hostSettings.commentsEnabled~=false
end

-- Disabled-Comments mode uses only built-in Housing categories. Move forward
-- in display order, skipping rated categories. Once every built-in category is
-- rated, cycle normally from the current category and wrap bottom-to-top.
local function NextBuiltInCategoryKey()
    local keys={}
    local data=currentHouseKey and UIX.EnsureHouseData(currentHouseKey,currentHouse) or nil
    for _,category in ipairs(UIX.ReviewCategories) do
        if category.key~="all" and category.key~="custom" and not UIX.IsCustomCategoryKey(category.key) and UIX.IsReviewCategoryEnabled(data,category.key) then
            keys[#keys+1]=category.key
        end
    end
    if #keys==0 then return UIX.FirstEnabledCategoryKey(data,true) end

    local currentIndex=0
    for index,key in ipairs(keys) do if key==selectedCategoryKey then currentIndex=index; break end end
    local vote=data and data.votes and data.votes[UIX.PlayerGUID()] or nil

    for offset=1,#keys do
        local index=((currentIndex+offset-1)%#keys)+1
        local key=keys[index]
        local entry=vote and vote.categories and vote.categories[key]
        if not entry or tonumber(entry.rating)==nil then return key end
    end

    return keys[(currentIndex%#keys)+1]
end

local categoryMode={}

-- One New Custom control lives at the top of the rated-category rail. Clicking it
-- only returns to a single fresh Custom draft; repeated clicks never create rows.
local newCustomButton = CreateFrame("Button", nil, ratedSidebar, "UIPanelButtonTemplate")
newCustomButton:SetSize(30,30)
newCustomButton:SetPoint("TOPLEFT",ratedSidebar,"TOPLEFT",12,-8)
newCustomButton:SetText("+")
if newCustomButton:GetFontString() then newCustomButton:GetFontString():SetScale(1.35) end
local newestDeleteButton=CreateFrame("Button",nil,ratedSidebar,"UIPanelButtonTemplate")
newestDeleteButton:SetSize(30,30)
newestDeleteButton:SetPoint("TOPRIGHT",ratedSidebar,"TOPRIGHT",-12,-8)
newestDeleteButton:SetText("-")
newestDeleteButton:SetEnabled(false)
if newestDeleteButton:GetFontString() then newestDeleteButton:GetFontString():SetScale(1.35) end
newestDeleteButton:SetScript("OnEnter",function(self) GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText("Delete Category Rate"); GameTooltip:AddLine("Delete the selected category rate saving.",1,1,1,true); GameTooltip:Show() end)
newestDeleteButton:SetScript("OnLeave",GameTooltip_Hide)
newCustomButton:SetScript("OnClick", function()
    selectedRating=nil
    if not CurrentHouseCommentsEnabled() then
        SetCategorySelection(NextBuiltInCategoryKey(),true)
        if UpdateUI then UpdateUI() end
        return
    end
    customCategoryTitle=""
    SetCategorySelection("custom", false)
    if UpdateUI then UpdateUI() end
    local function FocusFreshCustom()
        if customCategoryBox and customCategoryBox:IsShown() then
            customCategoryBox:SetText("")
            customCategoryBox:SetFocus()
            if customCategoryBox.SetCursorPosition then customCategoryBox:SetCursorPosition(0) end
        end
    end
    if C_Timer and C_Timer.After then C_Timer.After(0, FocusFreshCustom) else FocusFreshCustom() end
end)
newCustomButton:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self,"ANCHOR_RIGHT")
    if CurrentHouseCommentsEnabled() then
        GameTooltip:SetText("New Custom")
        GameTooltip:AddLine("Start a fresh Custom rating.",1,1,1,true)
    else
        GameTooltip:SetText("Next Category")
        GameTooltip:AddLine("Moves to the next unrated enabled category.",1,1,1,true)
    end
    GameTooltip:Show()
end)
newCustomButton:SetScript("OnLeave", GameTooltip_Hide)

RebuildCategoryMenu = function()
    for _, row in ipairs(categoryMenuRows) do row:Hide() end
    local shownIndex, rowHeight = 0, 50
    local selectedMenuKey = UIX.IsCustomCategoryKey(selectedCategoryKey) and "custom" or selectedCategoryKey
    local data=currentHouseKey and UIX.EnsureHouseData(currentHouseKey,currentHouse) or nil
    local commentsAllowed=not data or not data.hostSettings or data.hostSettings.commentsEnabled~=false
    for _, category in ipairs(UIX.ReviewCategories) do
        if category.key ~= "all" and UIX.IsReviewCategoryEnabled(data,category.key) and (category.key~="custom" or commentsAllowed) then
            shownIndex = shownIndex + 1
            local row = categoryMenuRows[shownIndex]
            if not row then
                row = CreateFrame("Button", nil, categoryScrollChild); row:SetHeight(rowHeight); row:SetPoint("LEFT",0,0); row:SetPoint("RIGHT",0,0)
                local hl=row:CreateTexture(nil,"HIGHLIGHT"); hl:SetAllPoints(); hl:SetColorTexture(0.55,0.38,0.12,0.32)
                local selected=row:CreateTexture(nil,"BACKGROUND",nil,2); selected:SetAllPoints(); selected:SetColorTexture(0.42,0.27,0.08,0.75); selected:Hide(); row.selected=selected
                local ibg=row:CreateTexture(nil,"BACKGROUND"); ibg:SetSize(42,42); ibg:SetPoint("LEFT",5,0); UIX.SetAtlasSafe(ibg,"house-upgrade-reward-icon-background",false)
                local icon=row:CreateTexture(nil,"ARTWORK"); icon:SetSize(34,34); icon:SetPoint("CENTER",ibg); row.icon=icon
                local io=row:CreateTexture(nil,"OVERLAY"); io:SetSize(42,42); io:SetPoint("CENTER",ibg); UIX.SetAtlasSafe(io,"house-upgrade-reward-icon-frame-overlay",false)
                local text=row:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); text:SetPoint("LEFT",ibg,"RIGHT",12,0); text:SetPoint("RIGHT",-10,0); text:SetJustifyH("LEFT"); text:SetScale(1.12); row.text=text
                local div=row:CreateTexture(nil,"BACKGROUND"); div:SetPoint("BOTTOMLEFT",6,0); div:SetPoint("BOTTOMRIGHT",-6,0); div:SetHeight(UIX.ONE_PIXEL); div:SetColorTexture(0.35,0.28,0.17,0.58)
                categoryMenuRows[shownIndex]=row
            end
            row:ClearAllPoints(); row:SetPoint("TOPLEFT",0,-((shownIndex-1)*rowHeight)); row:SetPoint("RIGHT",categoryScrollChild,"RIGHT",0,0)
            row.categoryKey=category.key; row.selected:SetShown(category.key==selectedMenuKey); if category.key=="custom" then SetCustomIconTexture(row.icon) else UIX.SetReviewCategoryIcon(row.icon,category) end
            row.text:SetText((category.isSubcategory and "  " or "")..category.label)
            row.text:SetTextColor(category.isSubcategory and 0.94 or 1, category.isSubcategory and 0.92 or 0.92, category.isSubcategory and 0.84 or 0.72)
            row:SetScript("OnClick",function(self) categoryMenu:Hide(); SetCategorySelection(self.categoryKey,true); if UpdateUI then UpdateUI() end end)
            row:Show()
        end
    end
    categoryScrollChild:SetHeight(math.max(1,shownIndex*rowHeight))
end

local function UpdateRatedCategorySidebar(data)
    local vote = data and data.votes and data.votes[UIX.PlayerGUID()]
    local entries = {}
    if vote and vote.categories then
        -- Custom reviews are separate saved rows. Keep them in creation order, then
        -- append Blizzard/Housing categories in their normal category order.
        local customEntries = {}
        for key, rated in pairs(vote.categories) do
            if UIX.IsCustomCategoryKey(key) and key ~= "custom" and rated and tonumber(rated.rating) ~= nil then
                customEntries[#customEntries + 1] = {
                    key=key, rating=tonumber(rated.rating), category=UIX.ReviewCategoryByKey.custom,
                    label=rated.label, iconKey=rated.iconKey, revision=tonumber(rated.revision) or 0,
                }
            end
        end
        table.sort(customEntries, function(a,b) return (a.revision or 0) < (b.revision or 0) end)
        for _, entry in ipairs(customEntries) do entries[#entries+1] = entry end

        for _, category in ipairs(UIX.ReviewCategories) do
            if category.key ~= "all" and category.key ~= "custom" then
                local rated=vote.categories[category.key]
                if rated and tonumber(rated.rating)~=nil then
                    entries[#entries+1]={key=category.key,rating=tonumber(rated.rating),category=category,label=rated.label,iconKey=rated.iconKey}
                end
            end
        end
    end
    for _,row in ipairs(ratedSidebarRows) do row:Hide() end
    for _,row in pairs(ratedSidebarPlaceholders) do row:Hide() end
    local rowHeight=66
    for index,entry in ipairs(entries) do
        if index > UIX.MAX_RATED_CATEGORIES then break end
        local row=ratedSidebarRows[index]
        if not row then
            row=CreateFrame("Button",nil,ratedSidebarChild); row:SetSize(72,62)
            local sel=row:CreateTexture(nil,"BACKGROUND"); sel:SetSize(62,62); sel:SetPoint("TOP",0,3); sel:SetTexture("Interface\\Buttons\\CheckButtonHilight"); sel:SetBlendMode("ADD"); sel:SetVertexColor(1,0.82,0,1); sel:Hide(); row.selected=sel
            local selectedFrame=CreateFrame("Frame",nil,row,"BackdropTemplate"); selectedFrame:SetSize(58,58); selectedFrame:SetPoint("TOP",0,0); selectedFrame:SetFrameLevel((row:GetFrameLevel() or 1)+8); selectedFrame:SetBackdrop({edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=UIX.ONE_PIXEL}); selectedFrame:SetBackdropBorderColor(1,0.78,0.08,1); selectedFrame:Hide(); row.selectedFrame=selectedFrame
            local ibg=row:CreateTexture(nil,"BACKGROUND",nil,1); ibg:SetSize(54,54); ibg:SetPoint("TOP",0,-2); UIX.SetAtlasSafe(ibg,"house-upgrade-reward-icon-background",false)
            local icon=row:CreateTexture(nil,"ARTWORK"); icon:SetSize(44,44); icon:SetPoint("CENTER",ibg); row.icon=icon
            local io=row:CreateTexture(nil,"OVERLAY"); io:SetSize(54,54); io:SetPoint("CENTER",ibg); UIX.SetAtlasSafe(io,"house-upgrade-reward-icon-frame-overlay",false)
            local val=row:CreateFontString(nil,"OVERLAY"); val:SetPoint("CENTER",ibg,"CENTER",0,0); val:SetWidth(48); val:SetJustifyH("CENTER"); if STANDARD_TEXT_FONT then val:SetFont(STANDARD_TEXT_FONT,16,"OUTLINE") else val:SetFontObject(GameFontNormalLarge); val:SetScale(1.15) end; row.value=val
            ratedSidebarRows[index]=row
        end
        row:ClearAllPoints(); row:SetPoint("TOP",ratedSidebarChild,"TOP",0,-((index-1)*rowHeight)); row.categoryKey=entry.key; row.categoryLabel=(entry.label and entry.label ~= "" and (entry.label=="Custom Category" and "Custom" or entry.label)) or (entry.category and entry.category.label) or entry.key; row.rating=entry.rating
        if UIX.IsCustomCategoryKey(entry.key) then SetCustomIconTexture(row.icon) else UIX.SetReviewCategoryIcon(row.icon,entry.category) end
        row.value:SetFormattedText("%.1f",entry.rating); local r,g,b=UIX.GetRatingColor(entry.rating); row.value:SetTextColor(r,g,b); local isSelected=(entry.key==selectedCategoryKey); row.selected:SetShown(isSelected); if row.selectedFrame then row.selectedFrame:SetShown(isSelected) end
        row:SetScript("OnClick",function(self) SetCategorySelection(self.categoryKey,true); if UpdateUI then UpdateUI() end end)
        row:SetScript("OnEnter",function(self) GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText(self.categoryLabel or "Rating"); GameTooltip:AddLine(string.format("Your rating: %.1f / 10",self.rating or 0),1,1,1); GameTooltip:Show() end)
        row:SetScript("OnLeave",GameTooltip_Hide); row:Show()
    end
    local shownEntries=math.min(#entries,UIX.MAX_RATED_CATEGORIES)
    ratedSidebarCapacity=math.max(7,math.min(UIX.MAX_RATED_CATEGORIES,math.max(ratedSidebarCapacity,shownEntries+1)))
    for index=shownEntries+1,ratedSidebarCapacity do
        local row=ratedSidebarPlaceholders[index]
        if not row then
            row=CreateFrame("Button",nil,ratedSidebarChild); row:SetSize(72,62)
            local bg=row:CreateTexture(nil,"BACKGROUND"); bg:SetSize(54,54); bg:SetPoint("TOP",0,-2); UIX.SetAtlasSafe(bg,"house-upgrade-reward-icon-background",false)
            local plus=row:CreateFontString(nil,"OVERLAY","GameFontNormalLarge"); plus:SetPoint("CENTER",bg,"CENTER",0,0); plus:SetText("+"); plus:SetScale(1.25)
            local overlay=row:CreateTexture(nil,"OVERLAY"); overlay:SetSize(54,54); overlay:SetPoint("CENTER",bg); UIX.SetAtlasSafe(overlay,"house-upgrade-reward-icon-frame-overlay",false)
            ratedSidebarPlaceholders[index]=row
        end
        row:ClearAllPoints(); row:SetPoint("TOP",ratedSidebarChild,"TOP",0,-((index-1)*rowHeight)); row.slotIndex=index
        row:SetScript("OnClick",function(self)
            if self.slotIndex==ratedSidebarCapacity and ratedSidebarCapacity<UIX.MAX_RATED_CATEGORIES then ratedSidebarCapacity=ratedSidebarCapacity+1 end
            if newCustomButton and newCustomButton.Click then newCustomButton:Click() end
            if UpdateUI then UpdateUI() end
        end)
        row:SetScript("OnEnter",nil); row:SetScript("OnLeave",nil); row:Show()
    end
    ratedSidebarChild:SetHeight(math.max(1,ratedSidebarCapacity*rowHeight))
end

categoryButton:SetScript("OnClick",function() if categoryMenu:IsShown() then categoryMenu:Hide() else RebuildCategoryMenu(); categoryMenu:Show() end end)

-- RATINGS PAGE -----------------------------------------------------------------
local commentsTitle = commentsPage:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge")
commentsTitle:SetPoint("TOP",0,-10); commentsTitle:SetText(""); commentsTitle:Hide(); commentsTitle:SetScale(1.25)
local commentsScroll=CreateFrame("ScrollFrame",nil,commentsPage,"UIPanelScrollFrameTemplate"); commentsScroll:SetPoint("TOPLEFT",20,-14); commentsScroll:SetPoint("BOTTOMRIGHT",-38,32)
local commentsScrollChild=CreateFrame("Frame",nil,commentsScroll); commentsScrollChild:SetSize(640,1); commentsScroll:SetScrollChild(commentsScrollChild)
local commentRows={}
local noCommentsFrame=CreateFrame("Frame",nil,commentsPage,"BackdropTemplate")
noCommentsFrame:SetPoint("TOPLEFT",20,-14); noCommentsFrame:SetPoint("TOPRIGHT",-20,-14); noCommentsFrame:SetHeight(132)
noCommentsFrame:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=true,tileSize=8,edgeSize=12,insets={left=2,right=2,top=2,bottom=2}})
noCommentsFrame:SetBackdropColor(0.05,0.035,0.02,0.82); noCommentsFrame:SetBackdropBorderColor(0.42,0.28,0.12,1)
local noComments=noCommentsFrame:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); noComments:SetPoint("CENTER",0,0); noComments:SetText("No ratings yet"); noComments:SetTextColor(0.82,0.82,0.82)
noCommentsFrame:Hide()

local function GetOrCreateRatingRow(index)
    local row=commentRows[index]
    if row then return row end
    row=CreateFrame("Button",nil,commentsScrollChild); row:SetSize(636,150)
    local bg=row:CreateTexture(nil,"BACKGROUND"); bg:SetAllPoints(); if not UIX.SetAtlasSafe(bg,"housing-dashboard-initiatives-tasks-listitem-bg",false) then bg:SetColorTexture(0.05,0.035,0.02,0.82) end
    local hl=row:CreateTexture(nil,"HIGHLIGHT"); hl:SetAllPoints(); hl:SetColorTexture(0.5,0.36,0.12,0.25)
    local pf=CreateFrame("Frame",nil,row); pf:SetSize(76,76); pf:SetPoint("LEFT",14,0)
    local pbg=pf:CreateTexture(nil,"BACKGROUND"); pbg:SetAllPoints(); UIX.SetAtlasSafe(pbg,"house-upgrade-reward-icon-background",false)
    local portrait=pf:CreateTexture(nil,"ARTWORK"); portrait:SetSize(62,62); portrait:SetPoint("CENTER"); row.portrait=portrait
    local po=pf:CreateTexture(nil,"OVERLAY"); po:SetAllPoints(); UIX.SetAtlasSafe(po,"house-upgrade-reward-icon-frame-overlay",false)
    local name=row:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); name:SetPoint("TOPLEFT",pf,"TOPRIGHT",14,-10); name:SetWidth(165); name:SetJustifyH("LEFT"); name:SetScale(1.18); row.name=name
    local critic=row:CreateFontString(nil,"OVERLAY","GameFontNormal"); critic:SetPoint("TOPLEFT",name,"TOPRIGHT",8,-1); critic:SetWidth(170); critic:SetJustifyH("LEFT"); critic:SetTextColor(0.55,0.82,1); critic:Hide(); row.critic=critic
    local criticScore=row:CreateFontString(nil,"OVERLAY","GameFontNormal"); criticScore:SetPoint("LEFT",critic,"RIGHT",6,0); criticScore:SetWidth(80); criticScore:SetJustifyH("LEFT"); criticScore:SetTextColor(1,0.82,0); criticScore:Hide(); row.criticScore=criticScore
    local meta=row:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); meta:SetPoint("TOPLEFT",name,"BOTTOMLEFT",0,-2); meta:SetWidth(350); meta:SetJustifyH("LEFT"); meta:Hide(); row.meta=meta
    local comment=row:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); comment:SetPoint("TOPLEFT",name,"BOTTOMLEFT",0,-10); comment:SetWidth(390); comment:SetHeight(72); comment:SetJustifyH("LEFT"); comment:SetJustifyV("TOP"); comment:SetWordWrap(true); row.comment=comment
    local myRatingLabel=row:CreateFontString(nil,"OVERLAY","GameFontNormal"); myRatingLabel:SetPoint("TOPRIGHT",-38,-20); myRatingLabel:SetText(""); myRatingLabel:Hide(); row.myRatingLabel=myRatingLabel
    local rating=row:CreateFontString(nil,"OVERLAY"); rating:SetPoint("RIGHT",-40,0); rating:SetWidth(120); rating:SetJustifyH("CENTER"); if STANDARD_TEXT_FONT then rating:SetFont(STANDARD_TEXT_FONT,30,"OUTLINE") else rating:SetFontObject(GameFontHighlightLarge); rating:SetScale(1.35) end; row.rating=rating
    local view=row:CreateFontString(nil,"OVERLAY","GameFontNormal"); view:SetPoint("BOTTOMRIGHT",-34,13); view:SetText(""); view:Hide(); view:SetTextColor(1,0.82,0)
    commentRows[index]=row
    return row
end

UpdateCommentRows = function(data, sourceHouseKey)
    local entries={}
    for guid,vote in pairs(data and data.votes or {}) do
        if vote and tonumber(vote.rating)~=nil then entries[#entries+1]={guid=guid,vote=vote} end
    end
    table.sort(entries,function(a,b)
        local at=tonumber(a.vote.timestamp) or tonumber(a.vote.revision) or 0
        local bt=tonumber(b.vote.timestamp) or tonumber(b.vote.revision) or 0
        if at==bt then return tostring(a.vote.playerName or "") < tostring(b.vote.playerName or "") end
        return at>bt
    end)
    for _,row in ipairs(commentRows) do row:Hide() end
    local rowHeight=156
    for index,item in ipairs(entries) do
        if index > UIX.MAX_HOUSE_RATINGS then break end
        local row=GetOrCreateRatingRow(index); local vote=item.vote
        row:ClearAllPoints(); row:SetPoint("TOPLEFT",0,-((index-1)*rowHeight)); row.voterGUID=item.guid; row.playerName=vote.playerName; row.houseKey=sourceHouseKey or currentHouseKey
        local _, resolvedFaction = SetCharacterPortrait(row.portrait,item.guid,vote.playerName,vote.faction)
        if resolvedFaction ~= "" then vote.faction = resolvedFaction end
        row.name:SetText(UIX.ShortName(vote.playerName or "Unknown"))
        row.critic:SetText("")
        row.criticScore:SetText("")
        row.meta:SetText("")
        local desc=UIX.CleanDescription(vote.finalDescription or "")
        if desc=="" then desc="No final comment" end
        row.comment:SetText(TrimToLineCount(desc, 1))
        row.rating:SetFormattedText("%.1f",tonumber(vote.rating) or 0); local r,g,b=UIX.GetRatingColor(vote.rating); row.rating:SetTextColor(r,g,b)
        row:SetScript("OnClick",function(self)
            if activePage=="RATING" and viewedProfileGUID==self.voterGUID then
                viewedProfileGUID=nil; viewedProfileName=nil
                if not linkedReviewMode then viewedProfileHouseKey=nil end
                SetActivePage("COMMENTS")
            else
                viewedProfileGUID=self.voterGUID; viewedProfileName=self.playerName; viewedProfileHouseKey=self.houseKey or viewedProfileHouseKey or currentHouseKey
                linkedReviewMode=linkedReviewMode or not UIX.IsInsideHouseOrPlot(); SetActivePage("RATING")
            end
            if UpdateUI then UpdateUI() end
        end)
        row:Show()
    end
    local shownCount=math.min(#entries,UIX.MAX_HOUSE_RATINGS); commentsScrollChild:SetHeight(math.max(1,shownCount*rowHeight)); noCommentsFrame:SetShown(shownCount==0); commentsScroll:SetShown(shownCount>0)
end

-- FULL REVIEW PAGE -------------------------------------------------------------
local profileTitle=profilePage:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); profileTitle:SetPoint("TOP",0,-8); profileTitle:SetText(""); profileTitle:SetScale(1.25)
local profileHeaderCard=CreateFrame("Frame",nil,profilePage)
profileHeaderCard:SetPoint("TOPLEFT",16,-12); profileHeaderCard:SetPoint("TOPRIGHT",-16,-12); profileHeaderCard:SetHeight(138)
profileHeaderCard:EnableMouse(true)
profileHeaderCard:SetScript("OnMouseUp",function(_,button)
    if button~="LeftButton" or not viewedProfileGUID then return end
    viewedProfileGUID=nil; viewedProfileName=nil
    if not linkedReviewMode then viewedProfileHouseKey=nil end
    SetActivePage("COMMENTS")
    if UpdateUI then UpdateUI() end
end)
profileHeaderCard:SetScript("OnEnter",function(self)
    if not viewedProfileGUID then return end
    GameTooltip:SetOwner(self,"ANCHOR_CURSOR")
    GameTooltip:SetText("Close Character Rating Profile")
    GameTooltip:AddLine("Click this profile again to return to Ratings.",1,1,1,true)
    GameTooltip:Show()
end)
profileHeaderCard:SetScript("OnLeave",GameTooltip_Hide)
do
    local profileHeaderBG=profileHeaderCard:CreateTexture(nil,"BACKGROUND"); profileHeaderBG:SetAllPoints(); if not UIX.SetAtlasSafe(profileHeaderBG,"housing-dashboard-initiatives-tasks-listitem-bg",false) then profileHeaderBG:SetColorTexture(0.05,0.035,0.02,0.82) end
end
local profilePortraitFrame=CreateFrame("Frame",nil,profileHeaderCard); profilePortraitFrame:SetSize(76,76); profilePortraitFrame:SetPoint("LEFT",14,0)
do local ppbg=profilePortraitFrame:CreateTexture(nil,"BACKGROUND"); ppbg:SetAllPoints(); UIX.SetAtlasSafe(ppbg,"house-upgrade-reward-icon-background",false) end
local profilePortrait=profilePortraitFrame:CreateTexture(nil,"ARTWORK"); profilePortrait:SetSize(62,62); profilePortrait:SetPoint("CENTER")
do local ppo=profilePortraitFrame:CreateTexture(nil,"OVERLAY"); ppo:SetAllPoints(); UIX.SetAtlasSafe(ppo,"house-upgrade-reward-icon-frame-overlay",false) end
local profileName=profileHeaderCard:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); profileName:SetPoint("TOPLEFT",profilePortraitFrame,"TOPRIGHT",14,-10); profileName:SetWidth(260); profileName:SetJustifyH("LEFT"); profileName:SetScale(1.18)
local profileCritic=profileHeaderCard:CreateFontString(nil,"OVERLAY","GameFontNormalLarge"); profileCritic:SetText(""); profileCritic:Hide()
local profileSub=profileHeaderCard:CreateFontString(nil,"OVERLAY","GameFontHighlight"); profileSub:SetText(""); profileSub:Hide()
local profileDescription=profileHeaderCard:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); profileDescription:SetPoint("TOPLEFT",profileName,"BOTTOMLEFT",0,-10); profileDescription:SetWidth(390); profileDescription:SetHeight(72); profileDescription:SetJustifyH("LEFT"); profileDescription:SetJustifyV("TOP"); profileDescription:SetWordWrap(true)
local profileOverall=profileHeaderCard:CreateFontString(nil,"OVERLAY"); profileOverall:SetPoint("RIGHT",-40,0); profileOverall:SetWidth(120); profileOverall:SetJustifyH("CENTER"); if STANDARD_TEXT_FONT then profileOverall:SetFont(STANDARD_TEXT_FONT,30,"OUTLINE") else profileOverall:SetFontObject(GameFontHighlightLarge); profileOverall:SetScale(1.35) end
local profileDivider=profilePage:CreateTexture(nil,"ARTWORK"); profileDivider:SetPoint("TOPLEFT",16,-160); profileDivider:SetPoint("TOPRIGHT",-16,-160); profileDivider:SetHeight(5); UIX.SetAtlasSafe(profileDivider,"housing-dashboard-divider-horz-tile",false)
profileDivider:SetHeight(UIX.ONE_PIXEL); profileDivider:SetColorTexture(0,0,0,0.25)
local profileScroll=CreateFrame("ScrollFrame",nil,profilePage,"UIPanelScrollFrameTemplate"); profileScroll:SetPoint("TOPLEFT",24,-174); profileScroll:SetPoint("BOTTOMRIGHT",-38,32)
local profileScrollChild=CreateFrame("Frame",nil,profileScroll); profileScrollChild:SetSize(620,1); profileScroll:SetScrollChild(profileScrollChild)
local profileRows={}
local noProfileCard=CreateFrame("Frame",nil,profilePage,"BackdropTemplate"); noProfileCard:SetPoint("TOPLEFT",20,-14); noProfileCard:SetPoint("TOPRIGHT",-20,-14); noProfileCard:SetHeight(132); noProfileCard:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=true,tileSize=8,edgeSize=12,insets={left=2,right=2,top=2,bottom=2}}); noProfileCard:SetBackdropColor(0.05,0.035,0.02,0.82); noProfileCard:SetBackdropBorderColor(0.42,0.28,0.12,1); noProfileCard:Hide()
local noProfileBG=noProfileCard:CreateTexture(nil,"BACKGROUND",nil,1); noProfileBG:SetAllPoints(); noProfileBG:Hide()
local noProfile=noProfileCard:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); noProfile:SetPoint("CENTER",0,0); noProfile:SetWidth(520); noProfile:SetJustifyH("CENTER"); noProfile:SetText("Open a Player's Rating or Share Review link to view Full Review.")

local function GetProfileName(data,guid)
    local vote=data and data.votes and data.votes[guid]
    if vote and vote.playerName then return UIX.ShortName(vote.playerName) end
    if guid==UIX.PlayerGUID() then return UIX.ShortName(UIX.PlayerFullName()) end
    return UIX.ShortName(viewedProfileName or "Unknown")
end

UpdateProfilePage = function(data)
    if not viewedProfileGUID then
        profileHeaderCard:Hide()
        profileDivider:Hide()
        profileTitle:SetText("")
        profileScroll:Hide()
        noProfile:SetText("Open a Player's Rating or Share Review link to view Full Review.")
        noProfileCard:Show()
        for _,row in ipairs(profileRows) do row:Hide() end
        return
    end

    if viewedProfileGUID == "__HOST_REVIEW__" then
        profileHeaderCard:Hide()
        profileDivider:Hide()
        noProfileCard:Hide()
        profileTitle:SetText("Player Ratings")
        profileTitle:Show()
        profileScroll:ClearAllPoints()
        profileScroll:SetPoint("TOPLEFT",24,-42)
        profileScroll:SetPoint("BOTTOMRIGHT",-38,32)
        local entries={}
        for guid,vote in pairs(data and data.votes or {}) do
            if vote and tonumber(vote.rating)~=nil then
                entries[#entries+1]={guid=guid,vote=vote,stamp=tonumber(vote.timestamp) or tonumber(vote.revision) or 0}
            end
        end
        table.sort(entries,function(a,b)
            if a.stamp==b.stamp then return tostring(a.vote.playerName or "")<tostring(b.vote.playerName or "") end
            return a.stamp>b.stamp
        end)
        for _,row in ipairs(profileRows) do row:Hide() end
        local rowHeight=126
        for index,item in ipairs(entries) do
            if index>UIX.MAX_HOUSE_RATINGS then break end
            local row=profileRows[index]
            if not row then
                row=CreateFrame("Frame",nil,profileScrollChild,"BackdropTemplate")
                row:SetHeight(116)
                row:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=true,tileSize=8,edgeSize=12,insets={left=2,right=2,top=2,bottom=2}})
                row:SetBackdropColor(0.05,0.035,0.02,0.82); row:SetBackdropBorderColor(0.42,0.28,0.12,1)
                local ibg=row:CreateTexture(nil,"BACKGROUND"); ibg:SetSize(52,52); ibg:SetPoint("LEFT",6,0); UIX.SetAtlasSafe(ibg,"house-upgrade-reward-icon-background",false)
                local icon=row:CreateTexture(nil,"ARTWORK"); icon:SetSize(40,40); icon:SetPoint("CENTER",ibg); row.icon=icon
                local io=row:CreateTexture(nil,"OVERLAY"); io:SetSize(52,52); io:SetPoint("CENTER",ibg); UIX.SetAtlasSafe(io,"house-upgrade-reward-icon-frame-overlay",false)
                local label=row:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); label:SetPoint("TOPLEFT",ibg,"TOPRIGHT",12,-3); label:SetWidth(410); label:SetJustifyH("LEFT"); row.label=label
                local parent=row:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); parent:SetPoint("TOPLEFT",label,"BOTTOMLEFT",0,-3); parent:SetWidth(410); parent:SetJustifyH("LEFT"); row.parent=parent
                local desc=row:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); desc:SetPoint("TOPLEFT",parent,"BOTTOMLEFT",0,-4); desc:SetWidth(445); desc:SetHeight(50); desc:SetJustifyH("LEFT"); desc:SetJustifyV("TOP"); desc:SetWordWrap(true); row.description=desc
                local rating=row:CreateFontString(nil,"OVERLAY"); rating:SetPoint("RIGHT",-18,0); rating:SetWidth(160); rating:SetJustifyH("CENTER"); if STANDARD_TEXT_FONT then rating:SetFont(STANDARD_TEXT_FONT,31,"OUTLINE") else rating:SetFontObject(GameFontNormalLarge); rating:SetScale(1.5) end; row.rating=rating
                profileRows[index]=row
            end
            row:ClearAllPoints(); row:SetPoint("TOPLEFT",4,-((index-1)*rowHeight)); row:SetPoint("RIGHT",profileScrollChild,"RIGHT",-4,0)
            local vote=item.vote
            local _,resolvedFaction=SetCharacterPortrait(row.icon,item.guid,vote.playerName,vote.faction); if resolvedFaction~="" then vote.faction=resolvedFaction end
            row.label:SetText(UIX.ShortName(vote.playerName or "Unknown"))
            row.parent:SetText("")
            local finalComment=UIX.CleanDescription(vote.finalDescription or ""); if finalComment=="" then finalComment="No final comment" end
            row.description:SetText(TrimToLineCount(finalComment,1))
            row.rating:SetFormattedText("%.1f",tonumber(vote.rating) or 0); local r,g,b=UIX.GetRatingColor(vote.rating); row.rating:SetTextColor(r,g,b)
            row:Show()
        end
        local shownCount=math.min(#entries,UIX.MAX_HOUSE_RATINGS)
        profileScrollChild:SetHeight(math.max(1,shownCount*rowHeight))
        profileScroll:SetShown(shownCount>0)
        noProfile:SetText("No ratings yet")
        noProfileCard:SetShown(shownCount==0)
        return
    end

    profileTitle:SetText("")
    profileScroll:ClearAllPoints()
    profileScroll:SetPoint("TOPLEFT",24,-174)
    profileScroll:SetPoint("BOTTOMRIGHT",-38,32)
    profileHeaderCard:Show()
    profileDivider:Show()
    noProfileCard:Hide()

    local guid=viewedProfileGUID
    local vote=data and data.votes and data.votes[guid]
    local name=GetProfileName(data,guid)
    profileName:SetText(name)
    local _, resolvedFaction = SetCharacterPortrait(profilePortrait,guid,name,vote and vote.faction)
    if vote and resolvedFaction ~= "" then vote.faction = resolvedFaction end
    if vote then
        profileCritic:SetText("")
        profileSub:SetText("")
        if tonumber(vote.rating) then
            profileOverall:SetFormattedText("%.1f",vote.rating)
            local r,g,b=UIX.GetRatingColor(vote.rating); profileOverall:SetTextColor(r,g,b)
        else
            profileOverall:SetText("—"); profileOverall:SetTextColor(0.9,0.9,0.9)
        end
        local finalComment=UIX.CleanDescription(vote.finalDescription or "")
        if finalComment=="" then finalComment="No final comment" end
        profileDescription:SetText(TrimToLineCount(finalComment,1))
    else
        profileCritic:SetText(""); profileSub:SetText(""); profileOverall:SetText("—"); profileOverall:SetTextColor(0.9,0.9,0.9); profileDescription:SetText("No final comment")
    end
    local entries={}
    if vote and vote.categories then
        for key,entry in pairs(vote.categories) do
            local rating=tonumber(entry.rating)
            if rating~=nil and (UIX.ReviewCategoryByKey[key] or UIX.IsCustomCategoryKey(key)) then entries[#entries+1]={key=key,rating=rating,label=entry.label or (UIX.ReviewCategoryByKey[key] and UIX.ReviewCategoryByKey[key].label) or key,parent=entry.parent or "",description=entry.description or "",category=UIX.ReviewCategoryByKey[key],iconKey=entry.iconKey} end
        end
    end
    table.sort(entries,function(a,b) local ao=UIX.ReviewCategoryOrder[a.key] or 99999; local bo=UIX.ReviewCategoryOrder[b.key] or 99999; if ao==bo then return tostring(a.label)<tostring(b.label) end return ao<bo end)
    for _,row in ipairs(profileRows) do row:Hide() end
    local rowHeight=126
    for index,entry in ipairs(entries) do
        local row=profileRows[index]
        if not row then
            row=CreateFrame("Frame",nil,profileScrollChild,"BackdropTemplate")
            row:SetHeight(116)
            row:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=true,tileSize=8,edgeSize=12,insets={left=2,right=2,top=2,bottom=2}})
            row:SetBackdropColor(0.05,0.035,0.02,0.82)
            row:SetBackdropBorderColor(0.42,0.28,0.12,1)
            local ibg=row:CreateTexture(nil,"BACKGROUND"); ibg:SetSize(52,52); ibg:SetPoint("LEFT",6,0); UIX.SetAtlasSafe(ibg,"house-upgrade-reward-icon-background",false)
            local icon=row:CreateTexture(nil,"ARTWORK"); icon:SetSize(40,40); icon:SetPoint("CENTER",ibg); row.icon=icon
            local io=row:CreateTexture(nil,"OVERLAY"); io:SetSize(52,52); io:SetPoint("CENTER",ibg); UIX.SetAtlasSafe(io,"house-upgrade-reward-icon-frame-overlay",false)
            local label=row:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); label:SetPoint("TOPLEFT",ibg,"TOPRIGHT",12,-3); label:SetWidth(410); label:SetJustifyH("LEFT"); row.label=label
            local parent=row:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); parent:SetPoint("TOPLEFT",label,"BOTTOMLEFT",0,-3); parent:SetWidth(410); parent:SetJustifyH("LEFT"); row.parent=parent
            local desc=row:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); desc:SetPoint("TOPLEFT",parent,"BOTTOMLEFT",0,-4); desc:SetWidth(445); desc:SetHeight(50); desc:SetJustifyH("LEFT"); desc:SetJustifyV("TOP"); desc:SetWordWrap(true); row.description=desc
            local rating=row:CreateFontString(nil,"OVERLAY"); rating:SetPoint("RIGHT",-18,0); rating:SetWidth(160); rating:SetJustifyH("CENTER"); if STANDARD_TEXT_FONT then rating:SetFont(STANDARD_TEXT_FONT,31,"OUTLINE") else rating:SetFontObject(GameFontNormalLarge); rating:SetScale(1.5) end; row.rating=rating
            profileRows[index]=row
        end
        row:ClearAllPoints(); row:SetPoint("TOPLEFT",4,-((index-1)*rowHeight)); row:SetPoint("RIGHT",profileScrollChild,"RIGHT",-4,0); if UIX.IsCustomCategoryKey(entry.key) then SetCustomIconTexture(row.icon) else UIX.SetReviewCategoryIcon(row.icon,entry.category) end; row.label:SetText(entry.label=="Custom Category" and "Custom" or entry.label); row.parent:SetText(entry.parent~="" and entry.parent or "RateMyHouse Category"); row.description:SetText(entry.description~="" and entry.description or "No description"); row.rating:SetFormattedText("%.1f",entry.rating); local r,g,b=UIX.GetRatingColor(entry.rating); row.rating:SetTextColor(r,g,b); row:Show()
    end
    profileScrollChild:SetHeight(math.max(1,#entries*rowHeight))
    profileScroll:SetShown(#entries>0)
end

-- PROFILE SETTINGS PAGE --------------------------------------------------------
local settingsScroll=CreateFrame("ScrollFrame",nil,settingsPage,"UIPanelScrollFrameTemplate")
settingsScroll:SetPoint("TOPLEFT",10,-4); settingsScroll:SetPoint("BOTTOMRIGHT",-30,8)
if settingsScroll.ScrollBar then settingsScroll.ScrollBar:SetAlpha(0); settingsScroll.ScrollBar:EnableMouse(false) end
settingsScroll:EnableMouseWheel(true)
settingsScroll:SetScript("OnMouseWheel",function(self,delta) local range=(self.GetVerticalScrollRange and self:GetVerticalScrollRange()) or 0; self:SetVerticalScroll(math.max(0,math.min(range,(self:GetVerticalScroll() or 0)-(delta*48)))) end)
local settingsScrollChild=CreateFrame("Frame",nil,settingsScroll); settingsScrollChild:SetSize(650,700); settingsScroll:SetScrollChild(settingsScrollChild)

local function MakeSection(parent,top,height,title)
    local f=CreateFrame("Frame",nil,parent,"BackdropTemplate"); f:SetPoint("TOPLEFT",24,top); f:SetPoint("TOPRIGHT",-24,top); f:SetHeight(height)
    f:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=true,tileSize=8,edgeSize=12,insets={left=3,right=3,top=3,bottom=3}}); f:SetBackdropColor(0.035,0.025,0.015,0.72); f:SetBackdropBorderColor(0.52,0.39,0.18,1)
    local t=f:CreateFontString(nil,"OVERLAY","GameFontNormalLarge"); t:SetPoint("TOPLEFT",14,-12); t:SetText(title); t:SetScale(1.15)
    return f
end
local hostSection=MakeSection(settingsScrollChild,-12,276,"")
-- No custom Profile-section background/border.
hostSection:SetBackdrop(nil)
-- Host settings. Character portrait assignment was removed; the house header now uses live/context portraits.
local hostDescLabel=hostSection:CreateFontString(nil,"OVERLAY","GameFontNormal"); hostDescLabel:SetPoint("TOPLEFT",44,-34); hostDescLabel:SetText("Description")
local hostDescBox=CreateFrame("EditBox",nil,hostSection,"InputBoxTemplate"); hostDescBox:SetSize(540,40); hostDescBox:SetPoint("TOPLEFT",44,-56); hostDescBox:SetAutoFocus(false); hostDescBox:SetMaxLetters(UIX.MAX_HOST_DESCRIPTION_BYTES); hostDescBox:SetTextInsets(10,10,8,8)
local hostCommentsToggle=CreateFrame("CheckButton",nil,hostSection,"UICheckButtonTemplate"); hostCommentsToggle:SetSize(28,28); hostCommentsToggle:SetPoint("TOPLEFT",44,-112)
local hostCommentsText=hostSection:CreateFontString(nil,"OVERLAY","GameFontHighlight"); hostCommentsText:SetPoint("LEFT",hostCommentsToggle,"RIGHT",3,0); hostCommentsText:SetText("Comments")
local hostResetLoginToggle=CreateFrame("CheckButton",nil,hostSection,"UICheckButtonTemplate"); hostResetLoginToggle:SetSize(28,28); hostResetLoginToggle:SetPoint("TOPLEFT",44,-146)
local hostResetLoginText=hostSection:CreateFontString(nil,"OVERLAY","GameFontHighlight"); hostResetLoginText:SetPoint("LEFT",hostResetLoginToggle,"RIGHT",3,0); hostResetLoginText:SetText("Reset Rating on Login/Off")

local profileSave=CreateFrame("Button",nil,settingsScrollChild,"UIPanelButtonTemplate"); profileSave:SetSize(300,42); profileSave:SetPoint("TOP",hostSection,"BOTTOM",0,-16); profileSave:SetText("Save"); profileSave:SetEnabled(false)
settingsScrollChild:SetHeight(370)
ApplyInputChrome(hostDescBox)
EnforceLineLimit(hostDescBox, 1, UIX.MAX_HOST_DESCRIPTION_BYTES, nil)


local hostCommentsPending, hostResetLoginPending = true,false
local profileDirty=false
local function SetCheck(button,value) if button and button.SetChecked then button:SetChecked(value and true or false) end end
local function RefreshProfileSaveButton()
    profileSave:SetText(profileDirty and "Save" or "Saved")
    profileSave:SetEnabled(profileDirty and not houseSyncPending)
end
local function MarkProfileDirty() profileDirty=true; RefreshProfileSaveButton() end

local hostReviewButton=CreateFrame("Button",nil,hostSection,"UIPanelButtonTemplate")
hostReviewButton:SetSize(180,32); hostReviewButton:SetPoint("TOPRIGHT",hostSection,"TOPRIGHT",-24,-76); hostReviewButton:SetText("Share House Review")
if hostReviewButton:GetFontString() then hostReviewButton:GetFontString():SetScale(0.82) end
resetButton:SetParent(hostSection)
resetButton:ClearAllPoints()
resetButton:SetSize(180,32)
resetButton:SetPoint("TOP",hostReviewButton,"BOTTOM",0,-8)
resetButton:SetText("Reset House Rating")
if resetButton:GetFontString() then resetButton:GetFontString():SetScale(0.84) end
-- 1.0.2 Market targets only the exact owned house currently being visited.
-- The retired Alliance/Horde chooser is intentionally not constructed.
hostSection:SetHeight(150)
hostDescLabel:ClearAllPoints(); hostDescLabel:SetPoint("TOPLEFT",24,2)
hostDescBox:ClearAllPoints(); hostDescBox:SetSize(596,36); hostDescBox:SetPoint("TOPLEFT",24,-16)
hostCommentsToggle:ClearAllPoints(); hostCommentsToggle:SetPoint("TOPLEFT",24,-66)
hostResetLoginToggle:ClearAllPoints(); hostResetLoginToggle:SetPoint("TOPLEFT",190,-66)
hostReviewButton:ClearAllPoints(); hostReviewButton:SetSize(180,30); hostReviewButton:SetPoint("TOPRIGHT",hostSection,"TOPRIGHT",-24,-65)
resetButton:ClearAllPoints(); resetButton:SetPoint("TOP",hostReviewButton,"BOTTOM",0,-6)
hostSection:SetHeight(100)
settingsScrollChild:SetHeight(470)

marketUI.profileCategoryButtons={}
marketUI.profileCategoryLabel=settingsScrollChild:CreateFontString(nil,"OVERLAY","GameFontNormalLarge")
marketUI.profileCategoryLabel:SetPoint("TOPLEFT",24,-114); marketUI.profileCategoryLabel:SetText("Categories")
for index,definition in ipairs(UIX.CORE_CATEGORY_DEFS) do
    local column=index<=5 and 0 or 1; local row=(index-1)%5
    local button=CreateFrame("Button",nil,settingsScrollChild,"BackdropTemplate")
    button:SetSize(292,42); button:SetPoint("TOPLEFT",24+(column*306),-138-(row*48)); button.categoryKey=definition.key
    button:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=UIX.ONE_PIXEL})
    button:SetBackdropColor(0,0,0,0.25); button:SetBackdropBorderColor(0,0,0,0.35)
    local iconFrame=button:CreateTexture(nil,"BACKGROUND"); iconFrame:SetSize(34,34); iconFrame:SetPoint("LEFT",4,0); UIX.SetAtlasSafe(iconFrame,"house-upgrade-reward-icon-background",false)
    local icon=button:CreateTexture(nil,"ARTWORK"); icon:SetSize(27,27); icon:SetPoint("CENTER",iconFrame); UIX.SetReviewCategoryIcon(icon,{iconTexture=definition.iconTexture})
    local text=button:CreateFontString(nil,"OVERLAY","GameFontHighlight"); text:SetPoint("LEFT",iconFrame,"RIGHT",9,0); text:SetText(definition.label); button.label=text
    local state=button:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); state:SetPoint("RIGHT",-10,0); state:SetText("Enabled"); button.state=state
    local selected=button:CreateTexture(nil,"OVERLAY"); selected:SetAllPoints(); selected:SetColorTexture(1,0.72,0.12,0.12); button.selected=selected
    button:SetScript("OnClick",function(self)
        local mask=UIX.NormalizeCategoryMask(marketUI.pendingCategoryMask)
        local enabled=UIX.CategoryMaskHas(mask,self.categoryKey)
        if enabled and UIX.EnabledCategoryCount(mask)<=1 then
            SetStatus("At least one Rate category must remain enabled.",1,0.55,0.2); return
        end
        marketUI.pendingCategoryMask=UIX.CategoryMaskSet(mask,self.categoryKey,not enabled)
        for _,categoryButton in ipairs(marketUI.profileCategoryButtons) do
            local active=UIX.CategoryMaskHas(marketUI.pendingCategoryMask,categoryButton.categoryKey)
            categoryButton.selected:SetShown(active); categoryButton.state:SetText(active and "Enabled" or "Disabled")
            categoryButton.state:SetTextColor(active and 1 or 0.56,active and 0.82 or 0.56,active and 0 or 0.56)
        end
        MarkProfileDirty()
    end)
    button:SetScript("OnEnter",function(self) GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText(self.label:GetText()); GameTooltip:AddLine("Enable or disable this category for visitors rating this house.",1,1,1,true); GameTooltip:Show() end)
    button:SetScript("OnLeave",GameTooltip_Hide)
    marketUI.profileCategoryButtons[index]=button
end
marketUI.RefreshProfileCategoryButtons=function(owns)
    marketUI.pendingCategoryMask=UIX.NormalizeCategoryMask(marketUI.pendingCategoryMask)
    for _,button in ipairs(marketUI.profileCategoryButtons) do
        local active=UIX.CategoryMaskHas(marketUI.pendingCategoryMask,button.categoryKey)
        button.selected:SetShown(active); button.state:SetText(active and "Enabled" or "Disabled"); button.state:SetTextColor(active and 1 or 0.56,active and 0.82 or 0.56,active and 0 or 0.56); button:SetEnabled(owns==true)
    end
end
profileSave:ClearAllPoints(); profileSave:SetPoint("TOP",settingsScrollChild,"TOP",0,-392)

marketUI.featureEditLabel=itemsPage:CreateFontString(nil,"OVERLAY","GameFontNormal"); marketUI.featureEditLabel:SetPoint("TOPLEFT",30,-24); marketUI.featureEditLabel:SetText("Feature")
marketUI.featureEdit=CreateFrame("EditBox",nil,itemsPage,"InputBoxTemplate"); marketUI.featureEdit:SetSize(228,36); marketUI.featureEdit:SetPoint("TOPLEFT",88,-13); marketUI.featureEdit:SetAutoFocus(false); marketUI.featureEdit:SetMaxLetters(UIX.MAX_MARKET_TITLE_BYTES); marketUI.featureEdit:SetTextInsets(8,8,7,7)
marketUI.marketplaceEditLabel=itemsPage:CreateFontString(nil,"OVERLAY","GameFontNormal"); marketUI.marketplaceEditLabel:SetPoint("TOPLEFT",390,-24); marketUI.marketplaceEditLabel:SetText("Title")
marketUI.marketplaceEdit=CreateFrame("EditBox",nil,itemsPage,"InputBoxTemplate"); marketUI.marketplaceEdit:SetSize(210,36); marketUI.marketplaceEdit:SetPoint("TOPLEFT",438,-13); marketUI.marketplaceEdit:SetAutoFocus(false); marketUI.marketplaceEdit:SetMaxLetters(UIX.MAX_MARKET_TITLE_BYTES); marketUI.marketplaceEdit:SetTextInsets(8,8,7,7)
ApplyInputChrome(marketUI.featureEdit); ApplyInputChrome(marketUI.marketplaceEdit)
EnforceLineLimit(marketUI.featureEdit,1,UIX.MAX_MARKET_TITLE_BYTES,nil); EnforceLineLimit(marketUI.marketplaceEdit,1,UIX.MAX_MARKET_TITLE_BYTES,nil)

marketUI.marketDescriptionLabel=itemsPage:CreateFontString(nil,"OVERLAY","GameFontNormal"); marketUI.marketDescriptionLabel:SetPoint("TOPLEFT",30,-64); marketUI.marketDescriptionLabel:SetText("About")
marketUI.marketDescriptionEdit=CreateFrame("EditBox",nil,itemsPage,"InputBoxTemplate"); marketUI.marketDescriptionEdit:SetSize(560,36); marketUI.marketDescriptionEdit:SetPoint("TOPLEFT",88,-53); marketUI.marketDescriptionEdit:SetAutoFocus(false); marketUI.marketDescriptionEdit:SetMaxLetters(UIX.MAX_MARKET_HEADER_DESCRIPTION_BYTES); marketUI.marketDescriptionEdit:SetTextInsets(8,8,7,7)
ApplyInputChrome(marketUI.marketDescriptionEdit); EnforceLineLimit(marketUI.marketDescriptionEdit,1,UIX.MAX_MARKET_HEADER_DESCRIPTION_BYTES,nil)

marketUI.slotLabel=itemsPage:CreateFontString(nil,"OVERLAY","GameFontNormal"); marketUI.slotLabel:SetPoint("TOPLEFT",100,-304); marketUI.slotLabel:SetText("Market Slots")
for index=1,UIX.MAX_MARKET_SLOTS do
    local button=CreateFrame("Button",nil,itemsPage)
    button:SetSize(64,64); button:SetPoint("TOPLEFT",100+((index-1)*92),-324); button.slotIndex=index
    local selected=button:CreateTexture(nil,"OVERLAY"); selected:SetAllPoints(); selected:SetTexture("Interface\\Buttons\\CheckButtonHilight"); selected:SetBlendMode("ADD"); selected:SetVertexColor(1,0.76,0.08,1); selected:Hide(); button.selected=selected
    local bg=button:CreateTexture(nil,"BACKGROUND",nil,1); bg:SetAllPoints(); UIX.SetAtlasSafe(bg,"house-upgrade-reward-icon-background",false); button.bg=bg
    local icon=button:CreateTexture(nil,"ARTWORK"); icon:SetSize(52,52); icon:SetPoint("CENTER"); button.icon=icon
    local emptyLabel=button:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); emptyLabel:SetPoint("CENTER",0,0); emptyLabel:SetWidth(58); emptyLabel:SetJustifyH("CENTER"); emptyLabel:SetText("Slot "..tostring(index)); button.emptyLabel=emptyLabel
    local overlay=button:CreateTexture(nil,"OVERLAY"); overlay:SetAllPoints(); UIX.SetAtlasSafe(overlay,"house-upgrade-reward-icon-frame-overlay",false); button.overlay=overlay
    button:RegisterForClicks("LeftButtonUp","RightButtonUp")
    marketUI.profileSlots[index]=button
end

marketUI.previousItem=CreateFrame("Button",nil,itemsPage,"UIPanelButtonTemplate"); marketUI.previousItem:SetSize(42,42); marketUI.previousItem:SetPoint("TOPLEFT",64,-133); marketUI.previousItem:SetText("<")
marketUI.browserIconFrame=CreateFrame("Frame",nil,itemsPage); marketUI.browserIconFrame:SetSize(92,92); marketUI.browserIconFrame:SetPoint("TOPLEFT",128,-108)
marketUI.browserIconBG=marketUI.browserIconFrame:CreateTexture(nil,"BACKGROUND"); marketUI.browserIconBG:SetAllPoints(); UIX.SetAtlasSafe(marketUI.browserIconBG,"house-upgrade-reward-icon-background",false)
marketUI.browserIcon=marketUI.browserIconFrame:CreateTexture(nil,"ARTWORK"); marketUI.browserIcon:SetSize(78,78); marketUI.browserIcon:SetPoint("CENTER")
marketUI.browserIconOverlay=marketUI.browserIconFrame:CreateTexture(nil,"OVERLAY"); marketUI.browserIconOverlay:SetAllPoints(); UIX.SetAtlasSafe(marketUI.browserIconOverlay,"house-upgrade-reward-icon-frame-overlay",false)
marketUI.nextItem=CreateFrame("Button",nil,itemsPage,"UIPanelButtonTemplate"); marketUI.nextItem:SetSize(42,42); marketUI.nextItem:SetPoint("TOPLEFT",242,-133); marketUI.nextItem:SetText(">")
marketUI.browserName=itemsPage:CreateFontString(nil,"OVERLAY","GameFontNormal"); marketUI.browserName:SetPoint("TOP",marketUI.browserIconFrame,"BOTTOM",0,-5); marketUI.browserName:SetWidth(240); marketUI.browserName:SetJustifyH("CENTER"); marketUI.browserName:SetText("No tradeable backpack items")

marketUI.descriptionLabel=itemsPage:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); marketUI.descriptionLabel:SetPoint("TOP",174,-211); marketUI.descriptionLabel:SetText("Details")
marketUI.descriptionEdit=CreateFrame("EditBox",nil,itemsPage,"InputBoxTemplate"); marketUI.descriptionEdit:SetSize(274,36); marketUI.descriptionEdit:SetPoint("TOPLEFT",38,-241); marketUI.descriptionEdit:SetAutoFocus(false); marketUI.descriptionEdit:SetMaxLetters(UIX.MAX_MARKET_DESCRIPTION_BYTES); marketUI.descriptionEdit:SetTextInsets(8,8,7,7)
marketUI.descriptionLabel:ClearAllPoints(); marketUI.descriptionLabel:SetPoint("BOTTOM",marketUI.descriptionEdit,"TOP",0,4)
ApplyInputChrome(marketUI.descriptionEdit); EnforceLineLimit(marketUI.descriptionEdit,1,UIX.MAX_MARKET_DESCRIPTION_BYTES,nil)

marketUI.quantityLabel=itemsPage:CreateFontString(nil,"OVERLAY","GameFontNormal"); marketUI.quantityLabel:SetPoint("TOPLEFT",414,-116); marketUI.quantityLabel:SetText("Quantity")
marketUI.quantityMinus=CreateFrame("Button","RateMyHouseMyItemsQuantityMinus",itemsPage,"UIPanelButtonTemplate"); marketUI.quantityMinus:SetSize(34,30); marketUI.quantityMinus:SetPoint("TOPLEFT",414,-138); marketUI.quantityMinus:SetText("-")
marketUI.quantityValue=itemsPage:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); marketUI.quantityValue:SetPoint("LEFT",marketUI.quantityMinus,"RIGHT",8,0); marketUI.quantityValue:SetWidth(44); marketUI.quantityValue:SetJustifyH("CENTER"); marketUI.quantityValue:SetText("1")
marketUI.quantityPlus=CreateFrame("Button","RateMyHouseMyItemsQuantityPlus",itemsPage,"UIPanelButtonTemplate"); marketUI.quantityPlus:SetSize(34,30); marketUI.quantityPlus:SetPoint("LEFT",marketUI.quantityValue,"RIGHT",8,0); marketUI.quantityPlus:SetText("+")
marketUI.priceLabel=itemsPage:CreateFontString(nil,"OVERLAY","GameFontNormal"); marketUI.priceLabel:SetPoint("TOPLEFT",414,-221); marketUI.priceLabel:SetText("Price")
marketUI.goldEdit=CreateFrame("EditBox",nil,itemsPage,"InputBoxTemplate"); marketUI.goldEdit:SetSize(62,34); marketUI.goldEdit:SetPoint("TOPLEFT",414,-241); marketUI.goldEdit:SetAutoFocus(false); marketUI.goldEdit:SetMaxLetters(6); marketUI.goldEdit:SetNumeric(true); marketUI.goldEdit:SetText("0"); marketUI.goldEdit:SetTextInsets(7,7,6,6)
marketUI.silverEdit=CreateFrame("EditBox",nil,itemsPage,"InputBoxTemplate"); marketUI.silverEdit:SetSize(46,34); marketUI.silverEdit:SetPoint("LEFT",marketUI.goldEdit,"RIGHT",18,0); marketUI.silverEdit:SetAutoFocus(false); marketUI.silverEdit:SetMaxLetters(2); marketUI.silverEdit:SetNumeric(true); marketUI.silverEdit:SetText("0"); marketUI.silverEdit:SetTextInsets(7,7,6,6)
marketUI.copperEdit=CreateFrame("EditBox",nil,itemsPage,"InputBoxTemplate"); marketUI.copperEdit:SetSize(46,34); marketUI.copperEdit:SetPoint("LEFT",marketUI.silverEdit,"RIGHT",18,0); marketUI.copperEdit:SetAutoFocus(false); marketUI.copperEdit:SetMaxLetters(2); marketUI.copperEdit:SetNumeric(true); marketUI.copperEdit:SetText("0"); marketUI.copperEdit:SetTextInsets(7,7,6,6)
ApplyInputChrome(marketUI.goldEdit); ApplyInputChrome(marketUI.silverEdit); ApplyInputChrome(marketUI.copperEdit)
marketUI.goldMark=itemsPage:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); marketUI.goldMark:SetPoint("LEFT",marketUI.goldEdit,"RIGHT",3,0); marketUI.goldMark:SetText("|TInterface\\MoneyFrame\\UI-GoldIcon:14:14:0:0|t")
marketUI.silverMark=itemsPage:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); marketUI.silverMark:SetPoint("LEFT",marketUI.silverEdit,"RIGHT",3,0); marketUI.silverMark:SetText("|TInterface\\MoneyFrame\\UI-SilverIcon:14:14:0:0|t")
marketUI.copperMark=itemsPage:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); marketUI.copperMark:SetPoint("LEFT",marketUI.copperEdit,"RIGHT",3,0); marketUI.copperMark:SetText("|TInterface\\MoneyFrame\\UI-CopperIcon:14:14:0:0|t")
marketUI.addButton=CreateFrame("Button","RateMyHouseMyItemsAddButton",itemsPage,"UIPanelButtonTemplate"); marketUI.addButton:SetSize(150,36); marketUI.addButton:SetPoint("BOTTOM",itemsPage,"BOTTOM",-160,20); marketUI.addButton:SetText("Add")
marketUI.removeButton=CreateFrame("Button","RateMyHouseMyItemsRemoveButton",itemsPage,"UIPanelButtonTemplate"); marketUI.removeButton:SetSize(150,36); marketUI.removeButton:SetPoint("BOTTOM",itemsPage,"BOTTOM",0,20); marketUI.removeButton:SetText("Remove"); marketUI.removeButton:SetEnabled(false)
marketUI.saveButton=CreateFrame("Button","RateMyHouseMyItemsSaveButton",itemsPage,"UIPanelButtonTemplate"); marketUI.saveButton:SetSize(150,36); marketUI.saveButton:SetPoint("BOTTOM",itemsPage,"BOTTOM",160,20); marketUI.saveButton:SetText("Saved"); marketUI.saveButton:SetEnabled(false)
marketUI.MarkDirty=function(itemDraft)
    if itemDraft then marketUI.itemDraftDirty=true else marketUI.dirty=true end
    if marketUI.UpdateEditor then marketUI.UpdateEditor() end
end
local function IsAccountOwnedHouseData(data)
    if not data then return false end
    if data.accountOwned == true and data.ownershipConfirmedVersion == 13 then return true end
    UIX.EnsureDB()
    if UIX.IsKnownAccountCharacter(data.ownerName or "") then
        data.accountOwned=true
        data.ownershipConfirmedVersion=13
        return true
    end
    return false
end
local function SelectedProfileHouseData(fallbackData)
    UIX.EnsureDB()
    if currentHouseKey and currentHouse then
        local currentData=UIX.EnsureHouseData(currentHouseKey,currentHouse)
        if IsAccountOwnedHouseData(currentData) then return currentHouseKey,currentData end
    end
    return nil,nil
end

local function LoadMarketPending(data)
    local holder=data or {market={}}
    local market=UIX.NormalizeMarketData(holder)
    marketUI.pendingFeatureTitle=market.featureTitle
    marketUI.pendingMarketplaceTitle=market.marketplaceTitle
    marketUI.pendingMarketDescription=market.description or ""
    marketUI.pendingSlots=UIX.CopyMarketSlots(market.slots)
    marketUI.draftDescriptions={}
    for index=1,UIX.MAX_MARKET_SLOTS do
        marketUI.draftDescriptions[index]=marketUI.pendingSlots[index] and (marketUI.pendingSlots[index].description or "") or ""
    end
    if not marketUI.featureEdit:HasFocus() then marketUI.featureEdit:SetText(market.featureTitle) end
    if not marketUI.marketplaceEdit:HasFocus() then marketUI.marketplaceEdit:SetText(market.marketplaceTitle) end
    if not marketUI.marketDescriptionEdit:HasFocus() then marketUI.marketDescriptionEdit:SetText(market.description or "") end
    if not next(marketUI.pendingSlots) or not marketUI.selectedSlot or marketUI.selectedSlot<1 or marketUI.selectedSlot>UIX.MAX_MARKET_SLOTS then marketUI.selectedSlot=1 end
    marketUI.dirty=false; marketUI.itemDraftDirty=false; marketUI.loadedHouseKey=marketUI.profileHouseKey
    marketUI.loadedMarketRevision=tonumber(market.revision) or 0
end

marketUI.RefreshBagItems=function()
    marketUI.bagItems=UIX.ScanTradeableBackpackItems()
    if #marketUI.bagItems==0 then marketUI.bagIndex=1 else marketUI.bagIndex=math.max(1,math.min(#marketUI.bagItems,tonumber(marketUI.bagIndex) or 1)) end
    marketUI.quantity=1
    if marketUI.UpdateEditor then marketUI.UpdateEditor() end
end
RMH.RefreshMarketBagItems=function() if marketUI.RefreshBagItems then marketUI.RefreshBagItems() end end

marketUI.UpdateEditor=function()
    local _,data=SelectedProfileHouseData(nil)
    local owns=IsAccountOwnedHouseData(data) and not houseSyncPending
    local pending=marketUI.pendingSlots or {}
    for index,button in ipairs(marketUI.profileSlots) do
        local slot=pending[index]
        button.selected:SetShown(index==marketUI.selectedSlot)
        if slot then button.icon:SetTexture(slot.icon or 134400); button.icon:Show(); button.emptyLabel:Hide() else button.icon:SetTexture(nil); button.icon:Hide(); button.emptyLabel:Show() end
        button:SetEnabled(owns)
    end
    local item=marketUI.bagItems and marketUI.bagItems[marketUI.bagIndex]
    if item then
        marketUI.browserIcon:SetTexture(item.icon or 134400); marketUI.browserIcon:Show(); marketUI.browserName:SetText(item.itemName or "Item")
        marketUI.quantity=math.max(1,math.min(item.stackCount or 1,tonumber(marketUI.quantity) or 1))
    else
        marketUI.browserIcon:SetTexture(nil); marketUI.browserIcon:Hide(); marketUI.browserName:SetText("No tradeable backpack items"); marketUI.quantity=1
    end
    marketUI.quantityValue:SetText(tostring(marketUI.quantity or 1))
    marketUI.previousItem:SetEnabled(owns and #marketUI.bagItems>1); marketUI.nextItem:SetEnabled(owns and #marketUI.bagItems>1)
    marketUI.quantityMinus:SetEnabled(owns and item~=nil and marketUI.quantity>1)
    marketUI.quantityPlus:SetEnabled(owns and item~=nil and marketUI.quantity<(item and item.stackCount or 1))
    marketUI.addButton:SetEnabled(owns and item~=nil and marketUI.itemDraftDirty==true)
    marketUI.removeButton:SetEnabled(owns and pending[marketUI.selectedSlot]~=nil)
    marketUI.saveButton:SetText(marketUI.dirty and "Save" or "Saved"); marketUI.saveButton:SetEnabled(owns and marketUI.dirty==true)
    marketUI.featureEdit:SetEnabled(owns); marketUI.marketplaceEdit:SetEnabled(owns); marketUI.marketDescriptionEdit:SetEnabled(owns)
    marketUI.goldEdit:SetEnabled(owns); marketUI.silverEdit:SetEnabled(owns); marketUI.copperEdit:SetEnabled(owns); marketUI.descriptionEdit:SetEnabled(owns)
    if not marketUI.descriptionEdit:HasFocus() then
        local selected=pending[marketUI.selectedSlot]
        marketUI.descriptionEdit:SetText((selected and selected.description) or (marketUI.draftDescriptions and marketUI.draftDescriptions[marketUI.selectedSlot]) or "")
    end
end

local function LoadSelectedProfileHouseSettings()
    local _,data=SelectedProfileHouseData(nil)
    local hs=data and data.hostSettings or {commentsEnabled=true,resetOnLogin=false,hostDescription="",autoPortrait=true}
    hostCommentsPending=hs.commentsEnabled~=false
    hostResetLoginPending=hs.resetOnLogin==true
    SetCheck(hostCommentsToggle,hostCommentsPending); SetCheck(hostResetLoginToggle,hostResetLoginPending)
    hostDescBox:SetText(hs.hostDescription or "")
    marketUI.pendingCategoryMask=UIX.NormalizeCategoryMask(hs.categoryMask)
    if not marketUI.dirty then LoadMarketPending(data) end
    local ownsSelected=IsAccountOwnedHouseData(data) and not houseSyncPending
    hostCommentsToggle:SetEnabled(ownsSelected==true); hostResetLoginToggle:SetEnabled(ownsSelected==true)
    hostDescBox:SetEnabled(ownsSelected==true)
    marketUI.RefreshProfileCategoryButtons(ownsSelected)
    marketUI.scannedHouseKey=currentHouseKey
    marketUI.RefreshBagItems()
end
hostCommentsToggle:SetScript("OnClick",function(self)
    hostCommentsPending=self:GetChecked() and true or false
    if not hostCommentsPending and UIX.EnabledCategoryCount(marketUI.pendingCategoryMask)==1 and UIX.CategoryMaskHas(marketUI.pendingCategoryMask,"custom") then
        marketUI.pendingCategoryMask=UIX.CategoryMaskSet(marketUI.pendingCategoryMask,"interior",true)
        marketUI.RefreshProfileCategoryButtons(true)
    end
    MarkProfileDirty()
end)
hostResetLoginToggle:SetScript("OnClick",function(self) hostResetLoginPending=self:GetChecked() and true or false; MarkProfileDirty() end)
hostDescBox:SetScript("OnTextChanged",function(_,user) if user then MarkProfileDirty() end end)
marketUI.featureEdit:SetScript("OnTextChanged",function(self,user) if user then marketUI.pendingFeatureTitle=UIX.CleanMarketTitle(self:GetText(),"Featured Item"); marketUI.MarkDirty(false) end end)
marketUI.marketplaceEdit:SetScript("OnTextChanged",function(self,user) if user then marketUI.pendingMarketplaceTitle=UIX.CleanMarketTitle(self:GetText(),"Today's Specials"); marketUI.MarkDirty(false) end end)
marketUI.marketDescriptionEdit:SetScript("OnTextChanged",function(self,user) if user then marketUI.pendingMarketDescription=UIX.CleanText(self:GetText(),UIX.MAX_MARKET_HEADER_DESCRIPTION_BYTES); marketUI.MarkDirty(false) end end)
marketUI.descriptionEdit:SetScript("OnTextChanged",function(self,user)
    if not user then return end
    local text=UIX.CleanText(self:GetText() or "",UIX.MAX_MARKET_DESCRIPTION_BYTES)
    marketUI.draftDescriptions=marketUI.draftDescriptions or {}; marketUI.draftDescriptions[marketUI.selectedSlot]=text
    marketUI.MarkDirty(true)
end)
for _,priceBox in ipairs({marketUI.goldEdit,marketUI.silverEdit,marketUI.copperEdit}) do
    priceBox:SetScript("OnTextChanged",function(_,user) if user then marketUI.MarkDirty(true) end end)
end
for _,box in ipairs({marketUI.featureEdit,marketUI.marketplaceEdit,marketUI.marketDescriptionEdit,marketUI.goldEdit,marketUI.silverEdit,marketUI.copperEdit,marketUI.descriptionEdit}) do
    box:SetScript("OnEscapePressed",function(self) self:ClearFocus() end)
    box:SetScript("OnEnterPressed",function(self) self:ClearFocus() end)
end
local function RemoveSelectedMarketSlot()
    local index=tonumber(marketUI.selectedSlot) or 1
    if not marketUI.pendingSlots or not marketUI.pendingSlots[index] then return end
    if marketUI.descriptionEdit:HasFocus() then marketUI.descriptionEdit:ClearFocus() end
    marketUI.pendingSlots[index]=nil
    marketUI.draftDescriptions=marketUI.draftDescriptions or {}
    marketUI.draftDescriptions[index]=""
    marketUI.dirty=true; marketUI.itemDraftDirty=false
    marketUI.UpdateEditor()
end
for _,button in ipairs(marketUI.profileSlots) do
    button:SetScript("OnClick",function(self,mouseButton)
        if marketUI.descriptionEdit:HasFocus() then marketUI.descriptionEdit:ClearFocus() end
        marketUI.selectedSlot=self.slotIndex
        local selected=marketUI.pendingSlots and marketUI.pendingSlots[self.slotIndex]
        if mouseButton=="RightButton" and selected then
            RemoveSelectedMarketSlot()
        elseif selected then
            -- Selecting an existing listing also restores its controls, making
            -- price/quantity edits deterministic instead of depending on the
            -- previously browsed bag item.
            for index,item in ipairs(marketUI.bagItems or {}) do
                if item.itemID==selected.itemID then marketUI.bagIndex=index; break end
            end
            marketUI.quantity=selected.quantity or 1
            local price=math.max(0,math.floor(tonumber(selected.priceCopper) or 0))
            marketUI.goldEdit:SetText(tostring(math.floor(price/10000)))
            marketUI.silverEdit:SetText(tostring(math.floor((price%10000)/100)))
            marketUI.copperEdit:SetText(tostring(price%100))
        end
        marketUI.UpdateEditor()
    end)
    button:SetScript("OnEnter",function(self) GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); local slot=marketUI.pendingSlots and marketUI.pendingSlots[self.slotIndex]; GameTooltip:SetText(slot and slot.itemName or ("Market Slot "..tostring(self.slotIndex))); GameTooltip:AddLine("Left-click to select. Right-click to clear.",1,1,1,true); GameTooltip:Show() end)
    button:SetScript("OnLeave",GameTooltip_Hide)
end
marketUI.previousItem:SetScript("OnClick",function() local count=#marketUI.bagItems; if count>0 then marketUI.bagIndex=((marketUI.bagIndex-2)%count)+1; marketUI.quantity=1; marketUI.MarkDirty(true) end end)
marketUI.nextItem:SetScript("OnClick",function() local count=#marketUI.bagItems; if count>0 then marketUI.bagIndex=(marketUI.bagIndex%count)+1; marketUI.quantity=1; marketUI.MarkDirty(true) end end)
marketUI.quantityMinus:SetScript("OnClick",function() marketUI.quantity=math.max(1,(marketUI.quantity or 1)-1); marketUI.MarkDirty(true) end)
marketUI.quantityPlus:SetScript("OnClick",function() local item=marketUI.bagItems[marketUI.bagIndex]; marketUI.quantity=math.min(item and item.stackCount or 1,(marketUI.quantity or 1)+1); marketUI.MarkDirty(true) end)
local function ApplyMarketItem(item)
    if not item then return end
    local freshItems=UIX.ScanTradeableBackpackItems()
    local fresh
    for _,candidate in ipairs(freshItems) do if candidate.bag==item.bag and candidate.slot==item.slot and candidate.itemID==item.itemID then fresh=candidate; break end end
    if not fresh then marketUI.bagItems=freshItems; marketUI.bagIndex=1; marketUI.UpdateEditor(); SetStatus("That item is no longer available or tradeable.",1,0.55,0.2); return end
    item=fresh
    local gold=math.max(0,math.floor(tonumber(marketUI.goldEdit:GetText()) or 0))
    local silver=math.max(0,math.min(99,math.floor(tonumber(marketUI.silverEdit:GetText()) or 0)))
    local copper=math.max(0,math.min(99,math.floor(tonumber(marketUI.copperEdit:GetText()) or 0)))
    local price=math.min(UIX.MAX_MARKET_PRICE_COPPER,(gold*10000)+(silver*100)+copper)
    marketUI.pendingSlots=marketUI.pendingSlots or {}
    local description=UIX.CleanText((marketUI.draftDescriptions and marketUI.draftDescriptions[marketUI.selectedSlot]) or marketUI.descriptionEdit:GetText() or "",UIX.MAX_MARKET_DESCRIPTION_BYTES)
    marketUI.pendingSlots[marketUI.selectedSlot]={itemID=item.itemID,itemName=UIX.CleanItemName(item.itemName,item.itemID),icon=item.icon,quantity=math.max(1,math.min(item.stackCount or 1,marketUI.quantity or 1)),priceCopper=price,description=description}
    if marketUI.descriptionEdit:HasFocus() then marketUI.descriptionEdit:ClearFocus() end
    local nextSlot
    for offset=1,UIX.MAX_MARKET_SLOTS do local index=((marketUI.selectedSlot+offset-1)%UIX.MAX_MARKET_SLOTS)+1; if not marketUI.pendingSlots[index] then nextSlot=index; break end end
    marketUI.selectedSlot=nextSlot or ((marketUI.selectedSlot%UIX.MAX_MARKET_SLOTS)+1)
    marketUI.dirty=true; marketUI.itemDraftDirty=false; marketUI.UpdateEditor()
end
marketUI.addButton:SetScript("OnClick",function() ApplyMarketItem(marketUI.bagItems[marketUI.bagIndex]) end)
marketUI.removeButton:SetScript("OnClick",RemoveSelectedMarketSlot)

-- The center selector accepts the same tradeable-only items as the backpack
-- scan. A dragged item is resolved against a fresh scan before it can be added,
-- so bound, quest, missing, or newly untradeable items are never persisted.
marketUI.browserIconFrame:EnableMouse(true)
marketUI.browserIconFrame:RegisterForDrag("LeftButton")
local function ReceiveMarketDrag()
    if not GetCursorInfo then return end
    local kind,itemID=GetCursorInfo()
    if kind~="item" then return end
    itemID=tonumber(itemID) or 0
    local items=UIX.ScanTradeableBackpackItems(); local found,foundIndex
    for index,candidate in ipairs(items) do if candidate.itemID==itemID then found=candidate; foundIndex=index; break end end
    if ClearCursor then ClearCursor() end
    marketUI.bagItems=items
    if not found then SetStatus("Only tradeable backpack items can be added.",1,0.55,0.2); marketUI.UpdateEditor(); return end
    marketUI.bagIndex=foundIndex; marketUI.quantity=1; marketUI.itemDraftDirty=true; marketUI.UpdateEditor(); ApplyMarketItem(found)
end
marketUI.browserIconFrame:SetScript("OnReceiveDrag",ReceiveMarketDrag)
marketUI.browserIconFrame:SetScript("OnMouseUp",function(_,button) if button=="LeftButton" then ReceiveMarketDrag() end end)
marketUI.browserIconFrame:SetScript("OnEnter",function(self)
    GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText("Backpack Item")
    GameTooltip:AddLine("Use the arrows or drag a tradeable item here.",1,1,1,true); GameTooltip:Show()
end)
marketUI.browserIconFrame:SetScript("OnLeave",GameTooltip_Hide)

local function UpdateProfileSettingsPage(data)
    local profileHouseKey,targetData=SelectedProfileHouseData(data)
    if marketUI.profileHouseKey~=profileHouseKey then
        marketUI.profileHouseKey=profileHouseKey
        profileDirty=false
        marketUI.dirty=false; marketUI.itemDraftDirty=false; marketUI.loadedHouseKey=nil
        marketUI.scannedHouseKey=nil
    end
    local hs=targetData and targetData.hostSettings or {commentsEnabled=true,resetOnLogin=false,hostDescription="",autoPortrait=true}
    -- House/zone/addon events can refresh the page while the player is typing.
    -- Never overwrite an unsaved description or pending checkmark state.
    if not profileDirty then
        hostCommentsPending=hs.commentsEnabled~=false
        hostResetLoginPending=hs.resetOnLogin==true
        marketUI.pendingCategoryMask=UIX.NormalizeCategoryMask(hs.categoryMask)
        SetCheck(hostCommentsToggle,hostCommentsPending); SetCheck(hostResetLoginToggle,hostResetLoginPending)
        if not hostDescBox:HasFocus() then hostDescBox:SetText(hs.hostDescription or "") end
    end
    local marketRevision=targetData and targetData.market and (tonumber(targetData.market.revision) or 0) or 0
    if not marketUI.dirty and (marketUI.loadedHouseKey~=profileHouseKey or marketUI.loadedMarketRevision~=marketRevision) then LoadMarketPending(targetData) end

    local ownsSelected=IsAccountOwnedHouseData(targetData) and not houseSyncPending
    hostCommentsToggle:SetEnabled(ownsSelected==true); hostResetLoginToggle:SetEnabled(ownsSelected==true)
    hostDescBox:SetEnabled(ownsSelected==true); hostReviewButton:SetEnabled(ownsSelected==true and profileHouseKey~=nil)
    marketUI.RefreshProfileCategoryButtons(ownsSelected)
    if marketUI.scannedHouseKey~=currentHouseKey then marketUI.scannedHouseKey=currentHouseKey; marketUI.RefreshBagItems() else marketUI.UpdateEditor() end
    RefreshProfileSaveButton()
end

local function BroadcastHostSettings(houseKey,data)
    if not houseKey or not data then return end
    if houseKey ~= currentHouseKey then return end
    local hs=data.hostSettings or {}
    -- Keep the legacy field positions so older RMH clients can still receive the
    -- description, but retired automatic-reset options are always disabled.
    UIX.SendAddon(table.concat({"S",UIX.SyncHouseID(houseKey),tostring(data.generation or 0),hs.commentsEnabled==false and "0" or "1",hs.resetOnLogin and "1" or "0","0","0",UIX.Utf8SafeTruncate(UIX.CleanHostDescription(hs.hostDescription or ""),UIX.MAX_HOST_DESCRIPTION_BYTES),"1","0",tostring(UIX.NormalizeCategoryMask(hs.categoryMask))},"\t"))
    UIX.BroadcastHostPortrait(houseKey,data,nil)
end

profileSave:SetScript("OnClick",function()
    UIX.EnsureDB()
    local profileHouseKey,data=SelectedProfileHouseData(nil)
    local ownsSelected=IsAccountOwnedHouseData(data) and not houseSyncPending
    if ownsSelected and data then
        local hs=data.hostSettings
        hs.commentsEnabled=hostCommentsPending
        hs.resetOnLogin=hostResetLoginPending
        hs.hostDescription=UIX.CleanHostDescription(hostDescBox:GetText())
        hs.categoryMask=UIX.NormalizeCategoryMask(marketUI.pendingCategoryMask)
        hs.autoPortrait=true
        if profileHouseKey == currentHouseKey and currentHouse and UIX.IsAccountOwnedHouse(currentHouse,data) then
            UIX.CapturePlayerPortraitState(hs)
        end
        BroadcastHostSettings(profileHouseKey,data)
    else
        SetStatus("Visit your own house or plot to edit its Profile.",1,0.55,0.2)
        return
    end
    profileDirty=false; RefreshProfileSaveButton(); UpdateUI()
end)

marketUI.saveButton:SetScript("OnClick",function()
    UIX.EnsureDB(); local houseKey,data=SelectedProfileHouseData(nil)
    if not houseKey or not data or not IsAccountOwnedHouseData(data) or houseSyncPending then
        SetStatus("Visit your own house or plot to save My Items.",1,0.55,0.2); return
    end
    for _,box in ipairs({marketUI.featureEdit,marketUI.marketplaceEdit,marketUI.marketDescriptionEdit,marketUI.goldEdit,marketUI.silverEdit,marketUI.copperEdit,marketUI.descriptionEdit}) do if box:HasFocus() then box:ClearFocus() end end
    local market=UIX.NormalizeMarketData(data)
    market.featureTitle=UIX.CleanMarketTitle(marketUI.featureEdit:GetText(),"Featured Item")
    market.marketplaceTitle=UIX.CleanMarketTitle(marketUI.marketplaceEdit:GetText(),"Today's Specials")
    market.description=UIX.CleanText(marketUI.marketDescriptionEdit:GetText(),UIX.MAX_MARKET_HEADER_DESCRIPTION_BYTES)
    market.slots=UIX.CopyMarketSlots(marketUI.pendingSlots)
    market.revision=UIX.RevisionStamp(market.revision)
    UIX.BroadcastMarket(houseKey,data,nil)
    marketUI.dirty=false; marketUI.itemDraftDirty=false; marketUI.loadedHouseKey=houseKey
    marketUI.loadedMarketRevision=tonumber(market.revision) or 0
    marketUI.UpdateEditor(); SetStatus("My Items saved and shared with this house.",0.55,0.9,0.55); UpdateUI()
end)

if UIX.BookX and UIX.BookX.BuildUI then
    bookUI=UIX.BookX.BuildUI({
        libraryPage=libraryPage,
        booksPage=booksPage,
        RevisionStamp=UIX.RevisionStamp,
        SetStatus=function(...) if SetStatus then SetStatus(...) end end,
        OnDirty=function() end,
        OnSave=function(data)
            local houseKey,ownedData=SelectedProfileHouseData(nil)
            if not houseKey or ownedData~=data or houseSyncPending then
                SetStatus("Visit your own house to save My Books.",1,0.55,0.2)
                return
            end
            UIX.BroadcastLibrary(houseKey,data,nil)
            SetStatus("My Books saved and shared with this house.",0.55,0.9,0.55)
            if UpdateUI then UpdateUI() end
        end,
    })
end

marketUI.featureIconFrame:SetScript("OnMouseUp",function(self,button)
    if button~="LeftButton" or not self.itemID then return end
    SetActivePage("MARKET")
    if UpdateUI then UpdateUI() end
end)
marketUI.bookFeature.iconFrame:SetScript("OnMouseUp",function(self,button)
    if button~="LeftButton" or not self.bookIndex then return end
    SetActivePage("LIBRARY")
    if UpdateUI then UpdateUI() end
    if bookUI and bookUI.OpenLibraryBook then bookUI:OpenLibraryBook(self.bookIndex) end
end)


ResetOwnedHousesForLoginLogout = function()
    UIX.EnsureDB()
    for houseKey, data in pairs(RateMyHouseDB.houses or {}) do
        if UIX.IsCurrentStorageKey(houseKey) and data and IsAccountOwnedHouseData(data) and data.hostSettings and data.hostSettings.resetOnLogin then
            data.generation = (tonumber(data.generation) or 0) + 1
            ClearHouseRatingState(data)
            sessionResetHouses[houseKey] = true
        end
    end
end

ShareX.HouseDisplayName = function(data)
    if not data then return "House / Plot" end
    if data.houseName and data.houseName ~= "" then return UIX.CleanChatText(data.houseName) end
    if data.plotID then return "Plot "..tostring(data.plotID) end
    return "House / Plot"
end
ShareX.HouseLinkToken = function(houseKey)
    return tostring(UIX.SyncHouseID(houseKey or ""))
end
ShareX.FindCachedHouseByToken = function(token)
    UIX.EnsureDB(); token=tostring(token or "")
    if token=="" then return nil end
    for houseKey,data in pairs(RateMyHouseDB.houses or {}) do
        if UIX.IsCurrentStorageKey(houseKey) and data and ShareX.HouseLinkToken(houseKey)==token then return houseKey,data end
    end
    return nil
end
ShareX.BuildShareText = function(houseKey,data)
    local vote=data and data.votes and data.votes[UIX.PlayerGUID()]; if not vote or tonumber(vote.rating)==nil then return nil end
    local voterName=UIX.CleanChatText(UIX.ShortName(UIX.PlayerFullName()))
    local ownerName=UIX.CleanChatText(UIX.ShortName((currentHouse and currentHouse.ownerName) or (data and data.ownerName) or "Unknown"))
    local houseName=ShareX.HouseDisplayName(data)
    return string.format("%s - %s's %s - RateMyHouse",voterName,ownerName,houseName)
end
ShareX.BuildHostShareText = function(houseKey,data)
    if not data then return nil end
    local ownerName=UIX.CleanChatText(UIX.ShortName(data.ownerName or "Unknown"))
    local houseName=ShareX.HouseDisplayName(data)
    return string.format("%s's %s - RateMyHouse",ownerName,houseName)
end
ShareX.FindVoteGUIDByName = function(data,playerName)
    playerName=UIX.ShortName(playerName or ""); if playerName=="" then return nil end
    for guid,vote in pairs(data and data.votes or {}) do if vote and UIX.SamePlayerName(vote.playerName or "",playerName) then return guid end end
    return nil
end
ShareX.FindCachedHouseForOwner = function(ownerName,houseName)
    UIX.EnsureDB(); ownerName=UIX.ShortName(ownerName or ""); houseName=UIX.CleanChatText(houseName or ""); if ownerName=="" then return nil end
    local fallbackKey,fallbackData
    for houseKey,data in pairs(RateMyHouseDB.houses or {}) do
        if UIX.IsCurrentStorageKey(houseKey) and data and UIX.SamePlayerName(data.ownerName or "",ownerName) then
            if houseName~="" then
                if ShareX.HouseDisplayName(data)==houseName then return houseKey,data end
            elseif not fallbackKey then
                fallbackKey,fallbackData=houseKey,data
            end
        end
    end
    if houseName~="" then return nil end
    return fallbackKey,fallbackData
end
ShareX.OpenSharedRatingProfile = function(voterName,houseToken,legacyOwner)
    local houseKey,data
    if houseToken and houseToken~="" then houseKey,data=ShareX.FindCachedHouseByToken(houseToken) end
    if not data and legacyOwner then houseKey,data=ShareX.FindCachedHouseForOwner(legacyOwner,"") end
    if not data then panel:Show(); SetStatus("That RateMyHouse review is not cached yet. Visit that house with RateMyHouse enabled first.",1,0.55,0.2); return end
    local guid=ShareX.FindVoteGUIDByName(data,voterName); if not guid then panel:Show(); SetStatus("That player's Full Review is not cached yet.",1,0.55,0.2); return end
    viewedProfileGUID=guid; viewedProfileName=voterName; viewedProfileHouseKey=houseKey; linkedReviewMode=true; SetActivePage("RATING"); if panelMinimized then SetPanelMinimized(false) end; panel:Show(); if UpdateUI then UpdateUI() end
end
ShareX.OpenSharedHostProfile = function(houseToken)
    local houseKey,data=ShareX.FindCachedHouseByToken(houseToken)
    if not data then panel:Show(); SetStatus("That house review is not cached yet.",1,0.55,0.2); return end
    viewedProfileGUID=nil; viewedProfileName=""; viewedProfileHouseKey=houseKey; linkedReviewMode=true; SetActivePage("COMMENTS"); if panelMinimized then SetPanelMinimized(false) end; panel:Show(); if UpdateUI then UpdateUI() end
end
ShareX.LinkEncode = function(text)
    return (tostring(text or ""):gsub("([^%w_%.%-])", function(ch) return string.format("%%%02X", string.byte(ch)) end))
end
ShareX.LinkDecode = function(text)
    return (tostring(text or ""):gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex,16) or 32) end))
end
ShareX.OpenSharedHostProfileByName = function(ownerName,houseName)
    ownerName=ShareX.LinkDecode(ownerName); houseName=ShareX.LinkDecode(houseName)
    local houseKey,data=ShareX.FindCachedHouseForOwner(ownerName,houseName)
    if not data then panel:Show(); SetStatus("That house review is not cached yet. Visit that house with RateMyHouse enabled first.",1,0.55,0.2); return end
    viewedProfileGUID=nil; viewedProfileName=""; viewedProfileHouseKey=houseKey; linkedReviewMode=true; SetActivePage("COMMENTS"); if panelMinimized then SetPanelMinimized(false) end; panel:Show(); if UpdateUI then UpdateUI() end
end
ShareX.OpenSharedRatingProfileByName = function(voterName,ownerName,houseName)
    voterName=ShareX.LinkDecode(voterName); ownerName=ShareX.LinkDecode(ownerName); houseName=ShareX.LinkDecode(houseName)
    local houseKey,data=ShareX.FindCachedHouseForOwner(ownerName,houseName)
    if not data then panel:Show(); SetStatus("That RateMyHouse review is not cached yet. Visit that house with RateMyHouse enabled first.",1,0.55,0.2); return end
    local guid=ShareX.FindVoteGUIDByName(data,voterName)
    if not guid then panel:Show(); SetStatus("That player's Full Review is not cached yet.",1,0.55,0.2); return end
    viewedProfileGUID=guid; viewedProfileName=voterName; viewedProfileHouseKey=houseKey; linkedReviewMode=true; SetActivePage("RATING"); if panelMinimized then SetPanelMinimized(false) end; panel:Show(); if UpdateUI then UpdateUI() end
end
RatingChatFilter = function(_,_,message,sender,...)
    if type(message)~="string" then return false end
    -- Player Post Review: Player - Host's House Name - RateMyHouse.
    local voterName,ownerName,houseName=message:match("^%s*(.-)%s+%-%s+(.-)'s%s+(.+)%s+%-%s+RateMyHouse%s*$")
    if voterName and voterName~="" and ownerName and ownerName~="" and houseName and houseName~="" then
        local display=string.format("%s - %s's %s - RateMyHouse",voterName,ownerName,houseName)
        local link=string.format("\124cffffd100\124Haddon:RateMyHouse:profilenamed:%s:%s:%s\124h%s\124h\124r",ShareX.LinkEncode(voterName),ShareX.LinkEncode(ownerName),ShareX.LinkEncode(houseName),display)
        return false,link,sender,...
    end

    -- Host Post House Review: Host's House Name - RateMyHouse.
    local hostOwner,hostHouse=message:match("^%s*(.-)'s%s+(.+)%s+%-%s+RateMyHouse%s*$")
    if hostOwner and hostOwner~="" and hostHouse and hostHouse~="" then
        local display=string.format("%s's %s - RateMyHouse",hostOwner,hostHouse)
        local link=string.format("\124cffffd100\124Haddon:RateMyHouse:hostnamed:%s:%s\124h%s\124h\124r",ShareX.LinkEncode(hostOwner),ShareX.LinkEncode(hostHouse),display)
        return false,link,sender,...
    end

    local legacyVoter,legacyOwner=message:match("^%s*(.-)%s+%-%s+(.+)'s House%s+%-%s+RateMyHouse%s*$")
    if not legacyVoter or legacyVoter=="" or not legacyOwner or legacyOwner=="" then return false end
    local display=string.format("%s - %s's House - RateMyHouse",legacyVoter,legacyOwner)
    local link=string.format("\124cffffd100\124Haddon:RateMyHouse:legacy:%s:%s\124h%s\124h\124r",UIX.ShortName(legacyVoter),UIX.ShortName(legacyOwner),display)
    return false,link,sender,...
end

HandleRatingAddonLink = function(link)
    if type(link)~="string" then return end
    local linkType,addonName,action,arg1,arg2,arg3=strsplit(":",link)
    if linkType~="addon" or addonName~="RateMyHouse" then return end
    if action=="profile" then ShareX.OpenSharedRatingProfile(arg1,arg2,nil)
    elseif action=="profilenamed" then ShareX.OpenSharedRatingProfileByName(arg1,arg2,arg3)
    elseif action=="host" then ShareX.OpenSharedHostProfile(arg1)
    elseif action=="hostnamed" then ShareX.OpenSharedHostProfileByName(arg1,arg2)
    elseif action=="legacy" then ShareX.OpenSharedRatingProfile(arg1,nil,arg2) end
end

local function CurrentExistingRating(data)
    local vote=data and data.votes and data.votes[UIX.PlayerGUID()]; if not vote then return nil end
    local entry=vote.categories and vote.categories[selectedCategoryKey]; return entry and tonumber(entry.rating) or nil
end

PerformHouseReset = function(reason)
    if not currentHouseKey or not currentHouse then return false end
    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse); data.generation=(tonumber(data.generation) or 0)+1; UIX.ClearHouseRatingState(data); selectedRating=nil; viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil; descriptionBox:SetText("")
    UIX.SendAddon(table.concat({"R",UIX.SyncHouseID(currentHouseKey),tostring(data.generation)},"\t")); SetStatus(reason or "House ratings and comments reset.",0.55,0.9,0.55); if UpdateUI then UpdateUI() end; return true
end
local IsHousingInterior = RMH.IsHousingInterior

local function UpdateHeaderPortrait(info,data)
    local ownerName=info and info.ownerName or (data and data.ownerName) or ""
    local ownerUnit=FindUnitTokenByName(ownerName)
    hostPortrait:SetSize(82,82); hostPortrait:SetTexCoord(0,1,0,1)
    local livePortrait=false
    if ownerUnit and UnitExists(ownerUnit) and UnitIsPlayer(ownerUnit) and (not UnitIsConnected or UnitIsConnected(ownerUnit)) and SetPortraitTexture then
        livePortrait=pcall(SetPortraitTexture,hostPortrait,ownerUnit)==true
    end
    if not livePortrait then
        local indoors=IsHousingInterior(info)
        local category=indoors and UIX.ReviewCategoryByKey.interior or UIX.ReviewCategoryByKey.exterior
        if category then UIX.SetReviewCategoryIcon(hostPortrait,category) else hostPortrait:SetTexture(indoors and 7439625 or 236712) end
    end
    if hostPortraitFrame._rmhBG then hostPortraitFrame._rmhBG:Show() end; if hostPortraitFrame._rmhOverlay then hostPortraitFrame._rmhOverlay:Show() end; hostPortraitFrame:Show()
end

local function UpdateQuickRateView(data, info, average, count)
    if not quickFrame then return end
    if quickHouseKey ~= currentHouseKey then
        quickHouseKey = currentHouseKey
        quickSelectedRating = nil
    end
    local inside = UIX.IsInsideHouseOrPlot()
    if inside and info then
        local owner=UIX.ShortName(info.ownerName or (data and data.ownerName) or "Unknown")
        quickOwnerLabel:SetText(owner.."'s House")
        local detail=(info.houseName and info.houseName~="" and info.houseName) or (data and data.houseName) or (info.plotID and ("Plot "..tostring(info.plotID))) or ""
        quickAddressLabel:SetText(detail)
        quickDescriptionLabel:SetText(UIX.CleanHostDescription(data and data.hostSettings and data.hostSettings.hostDescription or ""))
        FitHeaderText(quickOwnerLabel,168,1,0.55); FitHeaderText(quickAddressLabel,168,1,0.55); FitHeaderText(quickDescriptionLabel,168,1,0.52)
    else
        quickOwnerLabel:SetText("Visit a House Plot")
        quickAddressLabel:SetText("")
        quickDescriptionLabel:SetText("")
    end
    if houseSyncPending and inside then
        quickHouseScore:SetText("-"); quickHouseScore:SetTextColor(0.9,0.9,0.9)
        quickHouseCount:SetText("Loading house data...")
        UpdateRatingStarRow(quickFrame.scoreStars,0,false)
    elseif count and count>0 then
        quickHouseScore:SetFormattedText("%.1f",average or 0)
        local r,g,b=UIX.GetRatingColor(average or 0); quickHouseScore:SetTextColor(r,g,b)
        quickHouseCount:SetFormattedText("%d total %s",count,count==1 and "rating" or "ratings")
        UpdateRatingStarRow(quickFrame.scoreStars,average,true)
    else
        quickHouseScore:SetText("-"); quickHouseScore:SetTextColor(0.9,0.9,0.9)
        quickHouseCount:SetText("No ratings yet")
        UpdateRatingStarRow(quickFrame.scoreStars,0,false)
    end
    local vote=data and data.votes and data.votes[UIX.PlayerGUID()]
    local final=vote and tonumber(vote.rating) or nil
    local shown=quickSelectedRating
    if shown==nil then shown=final end
    if shown~=nil then
        quickRateValue:SetFormattedText("%.1f",shown)
        local r,g,b=UIX.GetRatingColor(shown); quickRateValue:SetTextColor(r,g,b)
    else
        quickRateValue:SetText("-"); quickRateValue:SetTextColor(0.82,0.82,0.82)
    end
    local canEdit=inside and info~=nil and final==nil and not houseSyncPending
    local commentsEnabled=not data or not data.hostSettings or data.hostSettings.commentsEnabled~=false
    for _,b in ipairs(quickAdjustButtons) do b:SetEnabled(canEdit) end
    quickSubmitButton:SetEnabled(canEdit and quickSelectedRating~=nil)
    quickFinalCommentLabel:SetText(commentsEnabled and "Final Comment (optional)" or "Final Comment (comments disabled)")
    quickFinalCommentBox:SetEnabled(canEdit and commentsEnabled)
    quickResetButton:Show()
    quickResetButton:SetEnabled(inside and info~=nil and final~=nil and not houseSyncPending)
    if not commentsEnabled then
        if quickFinalCommentBox:HasFocus() then quickFinalCommentBox:ClearFocus() end
        quickFinalCommentBox:SetText("")
    elseif not quickFinalCommentBox:HasFocus() then
        quickFinalCommentBox:SetText(canEdit and (pendingFinalComment or "") or (final and (vote.finalDescription or "") or ""))
    end
end

marketUI.UpdateFeatured=function(data)
    local market=data and UIX.NormalizeMarketData(data) or nil
    local slot=market and market.slots and market.slots[1]
    ownerLabel:SetWidth(184); addressLabel:SetWidth(184); ownerHint:SetWidth(184); hostDescriptionLabel:SetWidth(184); scoreBlock:SetSize(136,126)
    if houseSyncPending or not slot then
        marketUI.featureTitle:SetText(market and market.featureTitle or "Featured Item")
        marketUI.featureName:SetText("")
        marketUI.featureIcon:SetTexture(nil); marketUI.featureIcon:Hide()
        marketUI.featureIconFrame.itemID=nil; marketUI.featureIconFrame.itemName=nil
    else
        marketUI.featureTitle:SetText(market.featureTitle or "Featured Item")
        marketUI.featureIcon:SetTexture(slot.icon or 134400); marketUI.featureIcon:Show()
        marketUI.featureName:SetText(slot.itemName or "Item")
        marketUI.featureIconFrame.itemID=slot.itemID
        marketUI.featureIconFrame.itemName=slot.itemName
    end
    marketUI.featureFrame:Show()
end

marketUI.bookFeature.UpdateFeatured=function(data)
    local library=data and BookX and BookX.Normalize and BookX.Normalize(data) or nil
    local book,index,label
    if library and BookX and BookX.GetFeaturedBook then book,index,label=BookX.GetFeaturedBook(data) end
    marketUI.bookFeature.title:SetText(label or "Featured Book")
    if houseSyncPending or not book then
        marketUI.bookFeature.icon:SetTexture(nil); marketUI.bookFeature.icon:Hide(); marketUI.bookFeature.name:SetText("")
        marketUI.bookFeature.iconFrame.bookIndex=nil; marketUI.bookFeature.iconFrame.bookTitle=nil
    else
        local displayTexture = BookX and BookX.GetBookDisplayTexture and BookX.GetBookDisplayTexture(book) or nil
        if displayTexture then
            marketUI.bookFeature.icon:SetTexture(displayTexture)
            marketUI.bookFeature.icon:Show()
        else
            marketUI.bookFeature.icon:SetTexture(nil)
            marketUI.bookFeature.icon:Hide()
        end
        marketUI.bookFeature.name:SetText((book.title and book.title~="") and book.title or "Untitled Book")
        marketUI.bookFeature.iconFrame.bookIndex=index; marketUI.bookFeature.iconFrame.bookTitle=(book.title and book.title~="") and book.title or "Untitled Book"
    end
    marketUI.bookFeature.frame:Show()
end

marketUI.UpdatePage=function(data)
    local market=data and UIX.NormalizeMarketData(data) or nil
    local loading=houseSyncPending and currentHouseKey~=nil
    marketUI.loading:SetShown(loading)
    marketUI.marketTitleCard:SetShown(not loading)
    marketUI.marketDescriptionCard:SetShown(not loading)
    if market then marketUI.marketTitle:SetText(market.marketplaceTitle or "Today's Specials"); marketUI.marketDescription:SetText(market.description or "") end
    for index,card in ipairs(marketUI.viewSlots) do
        card:SetShown(not loading)
        local slot=market and market.slots and market.slots[index]
        if slot then
            card.icon:SetTexture(slot.icon or 134400); card.icon:Show(); card.iconFrame.itemID=slot.itemID; card.iconFrame.itemName=slot.itemName
            card.name:SetText(slot.itemName or "Item"); card.quantity:SetText("x"..tostring(slot.quantity or 1)); card.description:SetText(slot.description or ""); card.price:SetText(UIX.CoinText(slot.priceCopper or 0))
        else
            card.icon:SetTexture(nil); card.icon:Hide(); card.iconFrame.itemID=nil; card.iconFrame.itemName=nil
            card.name:SetText("Empty Slot"); card.quantity:SetText(""); card.description:SetText(""); card.price:SetText("")
        end
    end
end

UpdateUI = function()
    local inside=UIX.IsInsideHouseOrPlot(); local info=currentHouse; local houseKey=currentHouseKey; local data=houseKey and UIX.EnsureHouseData(houseKey,info) or nil
    local profileData=data
    if activePage=="PROFILE" then
        local _,selectedProfileData=SelectedProfileHouseData(nil)
        profileData=selectedProfileData
    end
    if viewedProfileHouseKey and RateMyHouseDB and RateMyHouseDB.houses then profileData=RateMyHouseDB.houses[viewedProfileHouseKey] or profileData end
    local viewingLinkedHouse = viewedProfileHouseKey and profileData~=nil and (activePage=="COMMENTS" or activePage=="RATING" or activePage=="MARKET" or activePage=="LIBRARY")
    local viewingExternalProfile=activePage=="RATING" and viewingLinkedHouse and viewedProfileHouseKey~=currentHouseKey
    local headerData=(activePage=="PROFILE" and profileData) or (viewingLinkedHouse and profileData) or data
    if not headerData and activePage=="RATING" and profileData then headerData=profileData end
    local average,count=UIX.RatingStats(headerData)

    if headerData and headerData.ownerName then
        addressLabel:SetFontObject(GameFontNormalLarge); addressLabel:SetScale(1.08); addressLabel:SetHeight(20); addressLabel:SetWordWrap(false)
        local owner=UIX.ShortName(headerData.ownerName or "Unknown"); SetPanelTitle(owner.."'s House"); ownerLabel:SetText(owner.."'s House"); FitHeaderText(ownerLabel,184,1.12,0.76)
        if headerData.houseName and headerData.houseName~="" then addressLabel:SetText(headerData.houseName) elseif headerData.plotID then addressLabel:SetFormattedText("Plot %s",tostring(headerData.plotID)) else addressLabel:SetText("") end
        FitHeaderText(addressLabel,184,1.08,0.74)
        hostDescriptionLabel:SetText(UIX.CleanHostDescription(headerData.hostSettings and headerData.hostSettings.hostDescription or ""))
        FitHeaderText(hostDescriptionLabel,184,1.0,0.72)
        UpdateHeaderPortrait(activePage=="PROFILE" and nil or info,headerData)
    elseif inside and info then
        addressLabel:SetFontObject(GameFontNormalLarge); addressLabel:SetScale(1.08); addressLabel:SetHeight(20); addressLabel:SetWordWrap(false)
        local owner=UIX.ShortName(info.ownerName or "Unknown"); SetPanelTitle(owner.."'s House"); ownerLabel:SetText(owner.."'s House"); FitHeaderText(ownerLabel,184,1.12,0.76); if info.houseName and info.houseName~="" then addressLabel:SetText(info.houseName) elseif info.plotID then addressLabel:SetFormattedText("Plot %s",tostring(info.plotID)) else addressLabel:SetText("") end; FitHeaderText(addressLabel,184,1.08,0.74); hostDescriptionLabel:SetText(UIX.CleanHostDescription(data and data.hostSettings and data.hostSettings.hostDescription or "")); FitHeaderText(hostDescriptionLabel,184,1.0,0.72); UpdateHeaderPortrait(info,data)
    else
        SetPanelTitle("House Rating"); ownerLabel:SetText("Visit a House Plot")
        addressLabel:SetFontObject(GameFontHighlightSmall); addressLabel:SetScale(1.0); addressLabel:SetHeight(20); addressLabel:SetWordWrap(false)
        addressLabel:SetText("")
        hostDescriptionLabel:SetText("")
        if hostPortraitFrame._rmhBG then hostPortraitFrame._rmhBG:Hide() end
        if hostPortraitFrame._rmhOverlay then hostPortraitFrame._rmhOverlay:Hide() end
        hostPortraitFrame:Hide()
    end

    UpdateNeighborhoodHeader(info,data,inside)
    if houseSyncPending and inside then scoreBlock.score:SetText("-"); scoreBlock.score:SetTextColor(0.9,0.9,0.9); scoreBlock.countLabel:SetText("Loading house data..."); UpdateRatingStarRow(scoreBlock.stars,0,false) elseif count>0 then scoreBlock.score:SetFormattedText("%.1f",average); local r,g,b=UIX.GetRatingColor(average); scoreBlock.score:SetTextColor(r,g,b); scoreBlock.countLabel:SetFormattedText("%d total %s",count,count==1 and "rating" or "ratings"); UpdateRatingStarRow(scoreBlock.stars,average,true) else scoreBlock.score:SetText("-"); scoreBlock.score:SetTextColor(0.9,0.9,0.9); scoreBlock.countLabel:SetText("No ratings yet"); UpdateRatingStarRow(scoreBlock.stars,0,false) end

    local myVote=data and data.votes and data.votes[UIX.PlayerGUID()]; local hasFinalVote=myVote and tonumber(myVote.rating)~=nil
    local commentsEnabled=not data or not data.hostSettings or data.hostSettings.commentsEnabled~=false
    local categoryAverage, categoryCount = UIX.CategoryAverage(myVote)
    local hasCategoryVotes = (categoryCount or 0) > 0
    postButton:SetShown(not viewingExternalProfile); postButton:SetEnabled(hasFinalVote==true and not viewingExternalProfile and not houseSyncPending)
    postButton:SetText("Share Review")
    finalVoteButton:SetShown(not viewingExternalProfile)
    resetButton:SetEnabled(true)
    if categoryMode.houseKey~=currentHouseKey or categoryMode.commentsEnabled~=commentsEnabled then
        categoryMode.houseKey=currentHouseKey
        categoryMode.commentsEnabled=commentsEnabled
        SetCategorySelection(UIX.FirstEnabledCategoryKey(data,commentsEnabled),commentsEnabled==false)
    end
    local category=UIX.GetSelectedCategory(); if category and (category.key=="all" or not UIX.IsReviewCategoryEnabled(data,selectedCategoryKey) or (UIX.IsCustomCategoryKey(selectedCategoryKey) and not commentsEnabled)) then SetCategorySelection(UIX.FirstEnabledCategoryKey(data,commentsEnabled),true); category=UIX.GetSelectedCategory() end
    local isCustomSelected=UIX.IsCustomCategoryKey(selectedCategoryKey)
    if isCustomSelected then SetCustomIconTexture(categoryIcon) else UIX.SetReviewCategoryIcon(categoryIcon,category) end
    local existing=CurrentExistingRating(data)
    if isCustomSelected then
        SetCustomCategoryFieldShown(true); categoryButton:SetSize(216,40); categoryButton:ClearAllPoints(); categoryButton:SetPoint("TOPLEFT",398,-18); categoryButton:SetText(existing ~= nil and "Categories  (Edit)" or "Categories")
    else
        SetCustomCategoryFieldShown(false); categoryButton:SetSize(456,40); categoryButton:ClearAllPoints(); categoryButton:SetPoint("TOPLEFT",166,-18); local categoryText=(category and category.label or "Custom"); categoryButton:SetText(existing ~= nil and (categoryText.."  (Edit)") or categoryText)
    end

    local displayRating=selectedRating
    if displayRating==nil then displayRating=existing end
    if displayRating~=nil then categoryRatingValue:SetFormattedText("%.1f",displayRating); local r,g,b=UIX.GetRatingColor(displayRating); categoryRatingValue:SetTextColor(r,g,b) else categoryRatingValue:SetText("-"); categoryRatingValue:SetTextColor(0.82,0.82,0.82) end
    local myFinal=myVote and tonumber(myVote.rating) or nil
    scoreBlock.score:ClearAllPoints()
    scoreBlock.countLabel:ClearAllPoints()
    -- Overall House score never changes size/position when this player finalizes.
    scoreBlock.score:SetPoint("TOP", scoreBlock, "TOP", 0, -8)
    scoreBlock.countLabel:SetPoint("TOP", scoreBlock.score, "BOTTOM", 0, -22)
    scoreBlock.myRatingLabel:Hide(); scoreBlock.myRating:Hide(); scoreBlock.resetMyVoteButton:Hide(); scoreBlock.myRating:SetText("")
    -- A Compact View submission is already a completed overall vote, even though
    -- it has no per-category ratings. Full RateMyHouse must therefore show that
    -- saved final score and its Reset button exactly like a normal Final Vote.
    local showScoreSummary = (myFinal~=nil or hasCategoryVotes) and selectedRating==nil and not viewingExternalProfile
    rateFinalSummary:SetShown(showScoreSummary)
    categoryRatingValue:SetShown(not showScoreSummary)
    if showScoreSummary then
        local summaryValue=myFinal or categoryAverage or 0
        rateFinalSummary.label:SetText(myFinal~=nil and "Final Vote" or "Average Score")
        rateFinalSummary.value:SetFormattedText("%.1f",summaryValue)
        local r,g,b=UIX.GetRatingColor(summaryValue); rateFinalSummary.value:SetTextColor(r,g,b)
        rateFinalSummary.reset:SetEnabled(myFinal~=nil and not houseSyncPending)
    end
    local canEditRating=inside and info~=nil and myFinal==nil and not houseSyncPending
    deleteCategoryButton:SetEnabled(canEditRating and existing ~= nil)
    if newCustomButton then newCustomButton:SetEnabled(canEditRating) end
    if newestDeleteButton then newestDeleteButton:SetEnabled(canEditRating and existing ~= nil) end
    for _,button in ipairs(ratingButtons) do button:SetEnabled(canEditRating) end
    if pendingFinalCommentHouseKey ~= currentHouseKey then pendingFinalComment=""; pendingFinalCommentHouseKey=currentHouseKey end
    descriptionLabel:SetText(commentsEnabled and "Description (optional)" or "Description (comments disabled)")
    finalRateCommentLabel:SetText(commentsEnabled and "Final Comment (optional)" or "Final Comment (comments disabled)")
    descriptionBox:SetEnabled(canEditRating and commentsEnabled)
    finalRateCommentBox:SetEnabled(canEditRating and commentsEnabled)
    if not commentsEnabled then
        pendingFinalComment=""
        if descriptionBox:HasFocus() then descriptionBox:ClearFocus() end
        if finalRateCommentBox:HasFocus() then finalRateCommentBox:ClearFocus() end
        descriptionBox:SetText("")
        finalRateCommentBox:SetText("")
    else
        if not finalRateCommentBox:HasFocus() and myFinal==nil then finalRateCommentBox:SetText(pendingFinalComment or "") end
        if myFinal~=nil and not finalRateCommentBox:HasFocus() then finalRateCommentBox:SetText("") end
    end
    if isCustomSelected then
        if commentsEnabled then
            if customCategoryBox:GetText()=="Custom Category (disabled)" then customCategoryBox:SetText(customCategoryTitle or "") end
        else
            if customCategoryBox:HasFocus() then customCategoryBox:ClearFocus() end
            if customCategoryBox:GetText()~="Custom Category (disabled)" then customCategoryBox:SetText("Custom Category (disabled)") end
        end
    end
    categoryButton:SetEnabled(not viewingExternalProfile and canEditRating)
    customCategoryBox:SetEnabled(isCustomSelected and canEditRating and commentsEnabled)
    customCategoryBox:SetTextColor(commentsEnabled and 1 or 0.45,commentsEnabled and 1 or 0.45,commentsEnabled and 1 or 0.45)
    rateAction:SetEnabled(canEditRating and selectedRating~=nil and (commentsEnabled or not isCustomSelected))
    finalVoteButton:SetEnabled(canEditRating and hasCategoryVotes)
    if categoryMenu:IsShown() then RebuildCategoryMenu() end
    marketUI.UpdateFeatured(headerData)
    marketUI.bookFeature.UpdateFeatured(headerData)
    marketUI.UpdatePage(viewingLinkedHouse and profileData or data)
    if bookUI then
        local libraryData=viewingLinkedHouse and profileData or data
        local _,ownedBookData=SelectedProfileHouseData(nil)
        bookUI:Update(libraryData,ownedBookData~=nil and ownedBookData==libraryData and not houseSyncPending,houseSyncPending and libraryData~=nil)
    end
    UpdateRatedCategorySidebar(data); UpdateCommentRows(viewingLinkedHouse and profileData or data, viewingLinkedHouse and viewedProfileHouseKey or currentHouseKey); UpdateProfilePage(profileData); UpdateProfileSettingsPage(data); SetActivePage(activePage)
    ApplySimpleOffPlotState(inside)
    UpdateQuickRateView(data, info, average, count)

    SetStatus("")
end

rateTab:SetScript("OnClick",function() categoryMenu:Hide(); linkedReviewMode=false; viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil; SetActivePage("RATE"); UpdateUI() end)
commentsTab:SetScript("OnClick",function()
    categoryMenu:Hide()
    if not linkedReviewMode then viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil end
    SetActivePage("COMMENTS"); UpdateUI()
end)
ratingTab:SetScript("OnClick",function()
    categoryMenu:Hide()
    if not linkedReviewMode then viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil end
    SetActivePage("RATING"); UpdateUI()
end)
marketTab:SetScript("OnClick",function()
    categoryMenu:Hide()
    if not linkedReviewMode then viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil end
    SetActivePage("MARKET"); UpdateUI()
end)
libraryTab:SetScript("OnClick",function()
    categoryMenu:Hide()
    if not linkedReviewMode then viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil end
    SetActivePage("LIBRARY"); UpdateUI()
end)
booksTab:SetScript("OnClick",function()
    categoryMenu:Hide(); linkedReviewMode=false; viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil
    UIX.RequestOwnedHouseList(); SetActivePage("BOOKS"); UpdateUI()
end)
settingsTab:SetScript("OnClick",function() categoryMenu:Hide(); linkedReviewMode=false; viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil; UIX.RequestOwnedHouseList(); SetActivePage("PROFILE"); UpdateUI() end)
itemsTab:SetScript("OnClick",function() categoryMenu:Hide(); linkedReviewMode=false; viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil; UIX.RequestOwnedHouseList(); SetActivePage("ITEMS"); UpdateUI() end)

local function AdjustFineRating(delta)
    if houseSyncPending or not UIX.IsInsideHouseOrPlot() or not currentHouse then return end
    local data=currentHouseKey and UIX.EnsureHouseData(currentHouseKey,currentHouse) or nil
    local base=selectedRating
    if base==nil then base=CurrentExistingRating(data) end
    if base==nil then base=0 end
    base=math.floor(((base+delta)*10)+0.5)/10
    selectedRating=math.max(0,math.min(10,base))
    UpdateUI()
end
for _,button in ipairs(ratingButtons) do
    button:SetScript("OnClick",function(self) AdjustFineRating(self.delta or 0) end)
end

local function StampCriticProfile(vote)
    vote.criticName=""
    vote.criticDescription=""
    vote.showCriticScore=false
    vote.criticScore=nil
end

local function AdjustQuickRating(delta)
    if houseSyncPending or not UIX.IsInsideHouseOrPlot() or not currentHouse then return end
    local data=currentHouseKey and UIX.EnsureHouseData(currentHouseKey,currentHouse) or nil
    local vote=data and data.votes and data.votes[UIX.PlayerGUID()]
    if vote and tonumber(vote.rating)~=nil then return end
    local base=quickSelectedRating
    if base==nil then base=0 end
    base=math.floor(((base+(delta or 0))*10)+0.5)/10
    quickSelectedRating=math.max(0,math.min(10,base))
    UpdateUI()
end
for _,b in ipairs(quickAdjustButtons) do b:SetScript("OnClick",function(self) AdjustQuickRating(self.delta or 0) end) end

local function SubmitQuickRating()
    if houseSyncPending or not UIX.IsInsideHouseOrPlot() or not currentHouse or not currentHouseKey then return end
    if quickSelectedRating==nil then return end
    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse)
    local voterGUID=UIX.PlayerGUID()
    local vote=UIX.GetOrCreateVote(data,voterGUID)
    if tonumber(vote.rating)~=nil then return end
    local tombstone=tonumber(data.voteTombstones and data.voteTombstones[voterGUID]) or 0
    vote.playerName=UIX.ShortName(UIX.PlayerFullName())
    vote.faction=(UnitFactionGroup and select(1,UnitFactionGroup("player"))) or vote.faction or ""
    vote.rating=quickSelectedRating
    vote.posted=true
    vote.revision=UIX.RevisionStamp(math.max(tonumber(vote.revision) or 0,tombstone))
    vote.timestamp=UIX.Now()
    StampCriticProfile(vote)
    local commentsEnabled=data.hostSettings.commentsEnabled~=false
    vote.finalDescription=commentsEnabled and UIX.CleanDescription((pendingFinalComment and pendingFinalComment~="") and pendingFinalComment or quickFinalCommentBox:GetText()) or ""
    data.lastUpdated=UIX.Now()
    pendingFinalComment=""; pendingFinalCommentHouseKey=currentHouseKey
    quickSelectedRating=nil; quickFinalCommentBox:SetText("")
    UIX.EnforceRatingsLimit(data)
    UIX.QueuePendingVote(currentHouseKey,data,voterGUID)
    UIX.BroadcastVote(currentHouseKey,data,voterGUID)
    SetStatus("Rating saved. Use Reset to rate again.",0.55,0.9,0.55)
    UpdateUI()
end
quickSubmitButton:SetScript("OnClick",SubmitQuickRating)

local function SubmitCategoryRating()
    if houseSyncPending then SetStatus("House data is still loading. Please wait.",1,0.72,0.25); return end
    if not UIX.IsInsideHouseOrPlot() or not currentHouse or not currentHouseKey then SetStatus("You must be inside the house or on its plot to rate.",1,0.35,0.35); return end
    if selectedRating==nil then SetStatus("Select a rating from 0 to 10 first.",1,0.55,0.2); return end
    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse)
    local commentsEnabled=data.hostSettings.commentsEnabled~=false
    if selectedCategoryKey=="all" or not UIX.IsReviewCategoryEnabled(data,selectedCategoryKey) then
        selectedRating=nil
        SetCategorySelection(UIX.FirstEnabledCategoryKey(data,commentsEnabled),true)
        SetStatus("That category is disabled by the host.",1,0.55,0.2)
        UpdateUI()
        return
    end
    if not commentsEnabled and UIX.IsCustomCategoryKey(selectedCategoryKey) then
        selectedRating=nil
        SetCategorySelection(UIX.FirstEnabledCategoryKey(data,false),true)
        SetStatus("Custom categories are disabled by the host's Comments setting.",1,0.55,0.2)
        UpdateUI()
        return
    end
    local voterGUID=UIX.PlayerGUID()
    local vote=UIX.GetOrCreateVote(data,voterGUID)
    vote.playerName=UIX.ShortName(UIX.PlayerFullName())
    vote.faction=(UnitFactionGroup and select(1,UnitFactionGroup("player"))) or vote.faction or ""
    StampCriticProfile(vote)

    local isCustom = UIX.IsCustomCategoryKey(selectedCategoryKey)
    local isFreshCustom = selectedCategoryKey == "custom"
    local existingEntry = vote.categories[selectedCategoryKey]
    if not existingEntry and UIX.CountRatedCategories(vote) >= UIX.MAX_RATED_CATEGORIES then
        SetStatus("You can rate up to 50 categories per house.",1,0.55,0.2)
        return
    end

    local category=UIX.GetSelectedCategory()
    local tombstone=tonumber(data.voteTombstones and data.voteTombstones[voterGUID]) or 0
    local oldRevision=tonumber(existingEntry and existingEntry.revision) or 0
    local revision=UIX.RevisionStamp(math.max(oldRevision,tombstone))
    local storeKey=selectedCategoryKey
    if isFreshCustom then storeKey=string.format("custom:%.0f",revision) end

    vote.categoryTombstones = vote.categoryTombstones or {}
    local categoryTombstone = tonumber(vote.categoryTombstones[storeKey]) or 0
    if revision <= categoryTombstone then revision = UIX.RevisionStamp(categoryTombstone) end

    local description=commentsEnabled and UIX.CleanDescription(descriptionBox:GetText()) or ""
    local savedLabel=category and category.label or storeKey
    if isCustom then
        customCategoryTitle=UIX.CleanCustomCategoryTitle(customCategoryBox:GetText())
        if customCategoryTitle=="" then customCategoryTitle="Custom" end
        savedLabel=customCategoryTitle
    end

    vote.categories[storeKey]={rating=selectedRating,revision=revision,label=UIX.CleanTitle(savedLabel),parent=UIX.CleanTitle(category and category.parent or ""),description=description,iconKey=""}
    UIX.EnsureDB()
    UIX.BroadcastCategory(currentHouseKey,data,voterGUID,storeKey)

    selectedRating=nil
    descriptionBox:ClearFocus()

    if isCustom then
        -- Every submitted custom review becomes its own saved category. Immediately
        -- return to a blank Custom Category draft for the next custom review.
        customCategoryTitle=""
        customCategoryBox:SetText("")
        descriptionBox:SetText("")
        selectedCategoryKey="custom"
    end

    SetStatus((isCustom and "Custom" or (category and category.label or "Category")).." rating saved.",0.55,0.9,0.55)
    UpdateUI()
end

local function SubmitFinalVote()
    if houseSyncPending then SetStatus("House data is still loading. Please wait.",1,0.72,0.25); return end
    if not UIX.IsInsideHouseOrPlot() or not currentHouse or not currentHouseKey then SetStatus("You must be inside the house or on its plot to rate.",1,0.35,0.35); return end
    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse)
    local voterGUID=UIX.PlayerGUID(); local vote=UIX.GetOrCreateVote(data,voterGUID); local avg, count = UIX.CategoryAverage(vote)
    if not avg or (count or 0) <= 0 then SetStatus("Rate at least one category before using Final Vote.",1,0.55,0.2); return end
    local commentsEnabled=data.hostSettings.commentsEnabled~=false
    local finalComment=commentsEnabled and UIX.CleanDescription((pendingFinalComment and pendingFinalComment~="") and pendingFinalComment or finalRateCommentBox:GetText()) or ""
    local tombstone=tonumber(data.voteTombstones and data.voteTombstones[voterGUID]) or 0; vote.playerName=UIX.ShortName(UIX.PlayerFullName()); vote.faction=(UnitFactionGroup and select(1,UnitFactionGroup("player"))) or vote.faction or ""; vote.rating=avg; vote.posted=true; vote.revision=UIX.RevisionStamp(math.max(tonumber(vote.revision) or 0,tombstone)); vote.timestamp=UIX.Now(); StampCriticProfile(vote)
    vote.finalDescription = finalComment
    data.lastUpdated=UIX.Now()
    pendingFinalComment=""; pendingFinalCommentHouseKey=currentHouseKey; finalRateCommentBox:SetText("")
    vote.criticScore=nil; UIX.EnforceRatingsLimit(data); UIX.QueuePendingVote(currentHouseKey,data,voterGUID); UIX.BroadcastVote(currentHouseKey,data,voterGUID); selectedRating=nil; descriptionBox:ClearFocus(); finalRateCommentBox:ClearFocus(); SetStatus("Final Vote saved. Use Reset to rate this house again.",0.55,0.9,0.55); UpdateUI()
end
local function DeleteSelectedCategory()
    if not UIX.IsInsideHouseOrPlot() or not currentHouse or not currentHouseKey then return end
    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse)
    local voterGUID=UIX.PlayerGUID()
    local vote=data and data.votes and data.votes[voterGUID]
    if not vote or tonumber(vote.rating)~=nil then return end
    local key=selectedCategoryKey
    local entry=vote.categories and vote.categories[key]
    if not entry or tonumber(entry.rating)==nil then return end

    vote.categoryTombstones = vote.categoryTombstones or {}
    local revision=UIX.RevisionStamp(math.max(tonumber(entry.revision) or 0, tonumber(vote.categoryTombstones[key]) or 0))
    vote.categoryTombstones[key]=revision
    vote.categories[key]=nil
    UIX.BroadcastCategoryDelete(currentHouseKey,data,voterGUID,key,revision)

    selectedRating=nil
    descriptionBox:SetText("")
    if UIX.IsCustomCategoryKey(key) then
        customCategoryTitle=""
        customCategoryBox:SetText("")
        selectedCategoryKey="custom"
    end
    SetStatus("Category rating deleted.",0.9,0.72,0.3)
    UpdateUI()
end

local function DeleteNewestCategory()
    if not UIX.IsInsideHouseOrPlot() or not currentHouse or not currentHouseKey then return end
    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse)
    local vote=data.votes and data.votes[UIX.PlayerGUID()]
    if not vote or tonumber(vote.rating)~=nil then return end
    local newestKey,newestRevision=nil,-1
    for key,entry in pairs(vote.categories or {}) do
        if entry and tonumber(entry.rating)~=nil then
            local revision=tonumber(entry.revision) or 0
            if revision>newestRevision then newestRevision=revision; newestKey=key end
        end
    end
    if not newestKey then return end
    selectedCategoryKey=newestKey
    DeleteSelectedCategory()
    selectedCategoryKey="custom"; customCategoryTitle=""; customCategoryBox:SetText(""); selectedRating=nil; descriptionBox:SetText("")
    UpdateUI()
end

rateAction:SetScript("OnClick",SubmitCategoryRating); finalVoteButton:SetScript("OnClick",SubmitFinalVote); deleteCategoryButton:SetScript("OnClick",DeleteSelectedCategory)
if newestDeleteButton then newestDeleteButton:SetScript("OnClick",DeleteSelectedCategory) end

postButton:SetScript("OnClick",function()
    if not currentHouse or not currentHouseKey or not UIX.IsInsideHouseOrPlot() then SetStatus("Visit a player house first.",1,0.55,0.2); return end
    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse); local vote=data and data.votes and data.votes[UIX.PlayerGUID()]
    if not vote or tonumber(vote.rating)==nil then SetStatus("Submit your Final Vote before sharing your review.",1,0.55,0.2); return end
    vote.posted = true
    local shareText=ShareX.BuildShareText(currentHouseKey,data)
    if ChatFrame_OpenChat and shareText then ChatFrame_OpenChat(shareText,DEFAULT_CHAT_FRAME) end
    SetStatus("Full Review link placed in chat.",0.55,0.9,0.55)
    UpdateUI()
end)
postButton:SetScript("OnEnter",function(self) GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText("Share Review"); GameTooltip:AddLine("Link your Full Review in chat.",1,1,1,true); GameTooltip:AddLine("Final Vote required.",0.8,0.8,0.8,true); GameTooltip:Show() end); postButton:SetScript("OnLeave",GameTooltip_Hide)
hostReviewButton:SetScript("OnClick",function()
    local houseKey,data=SelectedProfileHouseData(nil)
    if not houseKey or not data or not IsAccountOwnedHouseData(data) then SetStatus("Visit your own house or plot before sharing its review.",1,0.55,0.2); return end
    local shareText=ShareX.BuildHostShareText(houseKey,data)
    if not shareText or shareText=="" then SetStatus("House review could not be prepared.",1,0.35,0.35); return end
    if ChatFrame_OpenChat then
        local ok=pcall(ChatFrame_OpenChat,shareText,DEFAULT_CHAT_FRAME)
        if ok then SetStatus("House Review link placed in chat.",0.55,0.9,0.55); return end
    end
    SetStatus("Open a chat box, then try Share House Review again.",1,0.55,0.2)
end)
hostReviewButton:SetScript("OnEnter",function(self) GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText("Share House Review"); GameTooltip:AddLine("Share a link in chat for others to view.",1,1,1,true); GameTooltip:Show() end)
hostReviewButton:SetScript("OnLeave",GameTooltip_Hide)

local function ShowNotYourPlot()
    SetStatus("This is not your plot.",1,0.25,0.25); if UIErrorsFrame and UIErrorsFrame.AddMessage then UIErrorsFrame:AddMessage("This is not your plot.",1,0.15,0.15,1) end
end
local function BroadcastVoteReset(houseKey, data, voterGUID, resetRevision)
    if not houseKey or not data or not voterGUID then return end
    UIX.SendAddon(table.concat({"Z", UIX.SyncHouseID(houseKey), tostring(data.generation or 0), voterGUID, tostring(resetRevision or 0)}, "	"))
end

local function ResetMyVote()
    if not currentHouseKey or not currentHouse then return end
    local data = UIX.EnsureHouseData(currentHouseKey, currentHouse)
    local guid = UIX.PlayerGUID()
    local oldVote = data.votes and data.votes[guid]
    if not oldVote then return end
    local resetRevision = UIX.RevisionStamp(math.max(tonumber(oldVote.revision) or 0, tonumber(data.voteTombstones and data.voteTombstones[guid]) or 0))
    data.voteTombstones = data.voteTombstones or {}
    data.voteTombstones[guid] = resetRevision
    UIX.QueuePendingReset(currentHouseKey, data, guid, resetRevision)
    data.votes[guid] = nil
    data.lastUpdated = UIX.Now()
    UIX.RemoveVoteCommentsForGUID(data, guid)
    selectedRating = nil
    pendingFinalComment=""; pendingFinalCommentHouseKey=currentHouseKey
    if finalRateCommentBox then finalRateCommentBox:SetText("") end
    if descriptionBox then descriptionBox:SetText("") end
    BroadcastVoteReset(currentHouseKey, data, guid, resetRevision)
    SetStatus("Your rating was reset. You can rate this house again.", 0.55, 0.9, 0.55)
    UpdateUI()
end

resetMyVoteButton:SetScript("OnClick", ResetMyVote)
rateFinalReset:SetScript("OnClick", ResetMyVote)
quickResetButton:SetScript("OnClick", ResetMyVote)
resetButton:SetScript("OnClick",function() if not currentHouse or not currentHouseKey or not UIX.IsInsideHouseOrPlot() or not UIX.IsAccountOwnedHouse(currentHouse,UIX.EnsureHouseData(currentHouseKey,currentHouse)) then ShowNotYourPlot(); return end; PerformHouseReset("House ratings and comments reset.") end)
resetButton:SetScript("OnEnter",function(self) GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText("Reset House Rating"); GameTooltip:AddLine("Clears all ratings and comments for your house while you are on your own plot.",1,1,1,true); GameTooltip:Show() end); resetButton:SetScript("OnLeave",GameTooltip_Hide)

SetPanelMinimized(false)
end
BuildAndWireUI()

-- Tip-mail helper. WoW does not expose an addon API that remotely opens a mailbox;
-- this queues the exact recipient and fills the Send Mail recipient after MAIL_SHOW.
RMH.ApplyPendingTipMail = function(self)
    if not self._pendingTipRecipient or self._pendingTipRecipient == "" then return false end
    if not _G.MailFrame or not _G.MailFrame:IsShown() then return false end

    local sendTab = _G.MailFrameTab2 or (_G.MailFrame and _G.MailFrame.SendMailTab)
    if sendTab and sendTab.Click then pcall(sendTab.Click, sendTab) end

    local recipientBox = _G.SendMailNameEditBox
    if not recipientBox and _G.SendMailFrame then
        recipientBox = _G.SendMailFrame.NameEditBox or _G.SendMailFrame.SendMailNameEditBox
    end
    local subjectBox = _G.SendMailSubjectEditBox
    if not subjectBox and _G.SendMailFrame then
        subjectBox = _G.SendMailFrame.SubjectEditBox or _G.SendMailFrame.SendMailSubjectEditBox
    end
    if recipientBox and recipientBox.SetText then
        recipientBox:SetText(self._pendingTipRecipient)
        if recipientBox.SetCursorPosition then recipientBox:SetCursorPosition(#self._pendingTipRecipient) end
        if subjectBox and subjectBox.SetText then
            subjectBox:SetText(self._pendingTipSubject or "RateMyHouse")
            if subjectBox.SetCursorPosition then subjectBox:SetCursorPosition(#(self._pendingTipSubject or "RateMyHouse")) end
        end
        if recipientBox.SetFocus then recipientBox:SetFocus() end
        self._pendingTipRecipient = nil
        self._pendingTipSubject = nil
        self._pendingTipLabel = nil
        return true
    end
    return false
end

RMH.PrepareTipMail = function(self, recipient, displayLabel)
    self._pendingTipRecipient = recipient
    self._pendingTipSubject = "RateMyHouse"
    self._pendingTipLabel = displayLabel or recipient
    if self:ApplyPendingTipMail() then
        print("|cffffd200RateMyHouse:|r Open Mailbox to autofill Rmh - MoonGuard - Alliance and tip to support RateMyHouse. Thanks!")
    else
        print("|cffffd200RateMyHouse:|r Open Mailbox to autofill Rmh - MoonGuard - Alliance and tip to support RateMyHouse. Thanks!")
    end
end

local minimapButton = CreateFrame("Button", "RateMyHouseMinimapButton", Minimap)
minimapButton:SetSize(30, 30)
minimapButton:SetFrameStrata("MEDIUM")
minimapButton:SetFrameLevel(8)
minimapButton:RegisterForClicks("LeftButtonUp", "RightButtonUp")
minimapButton:RegisterForDrag("LeftButton")

local minimapIcon = minimapButton:CreateTexture(nil, "ARTWORK")
minimapIcon:SetAllPoints(minimapButton)
minimapIcon:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\minimap_icon.tga")
minimapIcon:SetTexCoord(0, 1, 0, 1)

local minimapHighlight = minimapButton:CreateTexture(nil, "HIGHLIGHT")
minimapHighlight:SetTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
minimapHighlight:SetBlendMode("ADD")
minimapHighlight:SetAllPoints()

local function PositionMinimapButton()
    EnsureDB()
    local angle = tonumber(RateMyHouseDB.minimap.angle) or 90
    local radians = math.rad(angle)
    local radius = (Minimap:GetWidth() / 2) + 8
    minimapButton:ClearAllPoints()
    minimapButton:SetPoint("CENTER", Minimap, "CENTER", math.cos(radians) * radius, math.sin(radians) * radius)
end

local function UpdateMinimapDrag()
    local mx, my = Minimap:GetCenter()
    local scale = Minimap:GetEffectiveScale()
    local cx, cy = GetCursorPosition()
    cx, cy = cx / scale, cy / scale
    local angle = math.deg(math.atan2(cy - my, cx - mx))
    RateMyHouseDB.minimap.angle = angle
    PositionMinimapButton()
end

minimapButton:SetScript("OnDragStart", function(self)
    self.dragging = true
    self:SetScript("OnUpdate", UpdateMinimapDrag)
end)
minimapButton:SetScript("OnDragStop", function(self)
    self.dragging = false
    self:SetScript("OnUpdate", nil)
end)
minimapButton:SetScript("OnClick", function()
    if panel:IsShown() then
        panel:Hide()
    else
        local data=currentHouseKey and UIX.EnsureHouseData(currentHouseKey,currentHouse) or nil
        local commentsEnabled=not data or not data.hostSettings or data.hostSettings.commentsEnabled~=false
        selectedCategoryKey=commentsEnabled and "custom" or "interior"
        selectedRating=nil
        panel:Show()
    end
end)
minimapButton:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_LEFT")
    GameTooltip:SetText("RateMyHouse")
    GameTooltip:AddLine("Click to open RateMyHouse.", 1, 1, 1)
    GameTooltip:AddLine("Drag to rotate around the minimap.", 0.7, 0.7, 0.7)
    GameTooltip:Show()
end)
minimapButton:SetScript("OnLeave", GameTooltip_Hide)

-- -----------------------------------------------------------------------------
-- House state and peer sync handlers
-- -----------------------------------------------------------------------------

local HOUSE_STATE_SETTLE_SECONDS = 1.25
local RefreshHouseState

local function HouseStateClock()
    return GetTime and GetTime() or time()
end

local function ResetTransientHouseView()
    selectedRating = nil
    selectedCategoryKey = ReviewCategoryByKey.custom and "custom" or selectedCategoryKey
    linkedReviewMode = false
    viewedProfileGUID = nil
    viewedProfileName = nil
    viewedProfileHouseKey = nil
    if descriptionBox then descriptionBox:SetText("") end
end

local function BeginHouseStateTransition()
    pendingHouseRefreshToken = pendingHouseRefreshToken + 1
    pendingHouseSignature = nil
    pendingHouseSince = 0
    houseStateSettling = true
    SetHouseSyncPending(true, 8)
    if currentHouseKey then LeaveSyncChannel() end
    currentHouse = nil
    currentHouseKey = nil
    currentHouseOwned = false
    ResetTransientHouseView()
    if UpdateUI then UpdateUI() end
end

local function ScheduleHouseStateRecheck(delay)
    pendingHouseRefreshToken = pendingHouseRefreshToken + 1
    local token = pendingHouseRefreshToken
    C_Timer.After(math.max(0.05, delay or HOUSE_STATE_SETTLE_SECONDS), function()
        if token == pendingHouseRefreshToken and RefreshHouseState then RefreshHouseState() end
    end)
end

local function ApplyOwnedHouseLoginReset(key, data)
    if not key or not data or not IsAccountOwnedHouse(currentHouse, data) then return end
    if data.hostSettings and data.hostSettings.resetOnLogin and not sessionResetHouses[key] then
        sessionResetHouses[key] = true
        C_Timer.After(0.35, function()
            if currentHouseKey == key and currentHouse and IsInsideHouseOrPlot() and IsAccountOwnedHouse(currentHouse, EnsureHouseData(key, currentHouse)) then
                PerformHouseReset("Ratings reset for this login.")
            end
        end)
    end
end

RefreshHouseState = function()
    -- Never consume a delayed event's captured HouseInfo table. Query both house
    -- identity and ownership together at execution time, then require that exact
    -- pair to remain unchanged before accepting it as the current plot.
    local inside = QueryInsideHouseOrPlot()
    local info = inside and GetHouseInfo() or nil
    local key = info and MakeHouseKey(info) or nil

    if not inside or not info or not key then
        pendingHouseRefreshToken = pendingHouseRefreshToken + 1
        pendingHouseSignature = nil
        pendingHouseSince = 0
        houseStateSettling = false
        if currentHouseKey then LeaveSyncChannel() end
        currentHouse = nil
        currentHouseKey = nil
        currentHouseOwned = false
        SetHouseSyncPending(false)
        ResetTransientHouseView()
        UpdateUI()
        return
    end

    local owned = QueryInsideOwnedHouseOrPlot() or IsKnownAccountCharacter(info.ownerName or "")
    local signature = tostring(key) .. "|owned:" .. (owned and "1" or "0")

    if not houseStateSettling and currentHouseKey == key and currentHouse and currentHouseOwned == owned then
        currentHouse = info
        local data = EnsureHouseData(key, info)
        if not linkedReviewMode and panel and panel:IsShown() and (activePage == "RATE" or activePage == "COMMENTS") then
            activePage = "RATE"
        end
        if not currentChannelName then JoinSyncChannel(key) end
        ApplyOwnedHouseLoginReset(key, data)
        UpdateUI()
        return
    end

    local now = HouseStateClock()
    if pendingHouseSignature ~= signature then
        pendingHouseSignature = signature
        pendingHouseSince = now
        houseStateSettling = true
        if currentHouseKey then LeaveSyncChannel() end
        currentHouse = nil
        currentHouseKey = nil
        currentHouseOwned = false
        ResetTransientHouseView()
        ScheduleHouseStateRecheck(HOUSE_STATE_SETTLE_SECONDS)
        UpdateUI()
        return
    end

    local stableFor = now - (tonumber(pendingHouseSince) or now)
    if stableFor < HOUSE_STATE_SETTLE_SECONDS then
        houseStateSettling = true
        ScheduleHouseStateRecheck(HOUSE_STATE_SETTLE_SECONDS - stableFor)
        UpdateUI()
        return
    end

    pendingHouseRefreshToken = pendingHouseRefreshToken + 1
    pendingHouseSignature = nil
    pendingHouseSince = 0
    houseStateSettling = false
    currentHouse = info
    currentHouseKey = key
    currentHouseOwned = owned == true
    SetHouseSyncPending(true, 8)
    ResetTransientHouseView()

    local data = EnsureHouseData(key, info)
    if currentHouseOwned then
        data.accountOwned = true
        data.ownershipConfirmedVersion = 13
    end
    activePage = "RATE"
    JoinSyncChannel(key)
    ApplyOwnedHouseLoginReset(key, data)
    UpdateUI()
end

local function HandleAddonMessage(message, distribution, sender)
    if distribution == "WHISPER" then
        if HandlePendingWhisperMessage(message, sender) then return end
    end
    if distribution ~= "CHANNEL" or not currentHouseKey then return end
    local f = SplitTabs(message)
    local kind = f[1]
    local houseKey = f[2]
    if houseKey ~= SyncHouseID(currentHouseKey) then return end

    local data = EnsureHouseData(currentHouseKey, currentHouse)
    local myGUID = PlayerGUID()
    local senderOffer=pendingOffers[sender or ""]
    local authorizedHouseEditor=(currentHouse and SamePlayerName(sender or "",currentHouse.ownerName or "")) or (senderOffer and senderOffer.isEditor==true)

    if kind == "H" then
        local targetGUID = f[3]
        if targetGUID and targetGUID ~= myGUID then
            C_Timer.After(0.08 + ((HashString(PlayerGUID()) % 20) / 100), function()
                if currentHouseKey and SyncHouseID(currentHouseKey) == houseKey then SendOffer(targetGUID) end
            end)
        end

    elseif kind == "O" then
        local targetGUID = f[3]
        if targetGUID ~= myGUID then return end
        pendingOffers[sender or ""] = {
            count = tonumber(f[4]) or 0,
            generation = tonumber(f[5]) or 0,
            commentCount = tonumber(f[6]) or 0,
            categoryCount = tonumber(f[7]) or 0,
            marketRevision = tonumber(f[8]) or 0,
            libraryRevision = tonumber(f[9]) or 0,
            libraryCount = tonumber(f[10]) or 0,
            isEditor = f[11] == "1",
        }

    elseif kind == "P" then
        local targetGUID = f[3]
        local chosenSender = f[4]
        if chosenSender and SamePlayerName(chosenSender, PlayerFullName()) and targetGUID ~= myGUID then
            SendSnapshot(targetGUID)
        end

    elseif kind == "D" then
        local targetGUID = f[3]
        if targetGUID ~= myGUID then return end
        local voterGUID=f[5]
        if MergeVote(data, tonumber(f[4]), voterGUID, tonumber(f[6]), tonumber(f[7]), f[8], f[9], f[10], nil, nil, nil, nil, f[11]) then
            QueuePendingVote(currentHouseKey,data,voterGUID)
            UpdateUI()
        end

    elseif kind == "X" then
        local targetGUID = f[3]
        if targetGUID ~= myGUID then return end
        local generation = tonumber(f[4]) or 0
        if generation < (data.generation or 0) then return end
        ApplyGeneration(data, generation)
        local vote = GetOrCreateVote(data, f[5])
        vote.criticName = CleanText(f[6] or "", MAX_CRITIC_NAME_BYTES)
        vote.criticDescription = CleanText(f[7] or "", MAX_CRITIC_DESCRIPTION_BYTES)
        vote.showCriticScore = f[8] ~= "0"
        vote.criticScore = tonumber(f[9])
        UpdateUI()

    elseif kind == "V" then
        local voterGUID = f[4]
        local revision = tonumber(f[6]) or 0
        if MergeVote(data, tonumber(f[3]), voterGUID, tonumber(f[5]), revision, f[7], f[8], f[9], f[10], f[11], f[12], f[13], f[14]) then
            -- Store-and-forward relay: any RMH client that receives this exact-house
            -- vote can later deliver it to the homeowner. Revisions make duplicates safe.
            QueuePendingVote(currentHouseKey,data,voterGUID)
            UpdateUI()
        end
        if currentHouse and IsHouseOwner(currentHouse) and voterGUID and voterGUID ~= myGUID then
            local localVote = data.votes and data.votes[voterGUID]
            local localRevision = localVote and tonumber(localVote.revision) or 0
            if localRevision >= revision then
                SendPendingAck(sender, PendingDeliveryID(currentHouseKey, voterGUID), localRevision, "channel")
            end
        end

    elseif kind == "J" then
        local targetGUID = f[3]
        if targetGUID ~= myGUID then return end
        local categoryKey = f[6]
        local localCategory = ReviewCategoryByKey[categoryKey]
        local parent = localCategory and localCategory.parent or ""
        if MergeCategoryRating(data, tonumber(f[4]), f[5], categoryKey, tonumber(f[7]), tonumber(f[8]), f[9], parent, f[10], f[11], f[12]) then UpdateUI() end

    elseif kind == "Z" then
        local generation = tonumber(f[3]) or 0
        local voterGUID = f[4]
        local resetRevision = tonumber(f[5]) or 0
        if generation == (tonumber(data.generation) or 0) and voterGUID and voterGUID ~= "" then
            local localVote = data.votes and data.votes[voterGUID]
            local localRevision = localVote and (tonumber(localVote.revision) or 0) or 0
            if not localVote or localRevision <= resetRevision then
                data.voteTombstones = data.voteTombstones or {}
                data.voteTombstones[voterGUID] = math.max(tonumber(data.voteTombstones[voterGUID]) or 0, resetRevision)
                if data.votes then data.votes[voterGUID] = nil end
                RemoveVoteCommentsForGUID(data, voterGUID)
                if viewedProfileGUID == voterGUID then viewedProfileGUID = nil; viewedProfileName = nil end
                UpdateUI()
            end
        end

    elseif kind == "L" then
        local generation = tonumber(f[3]) or 0
        local voterGUID = f[4]
        local categoryKey = f[5]
        local revision = tonumber(f[6]) or 0
        if MergeCategoryDelete(data, generation, voterGUID, categoryKey, revision) then UpdateUI() end

    elseif kind == "K" then
        local categoryKey = f[5]
        local localCategory = ReviewCategoryByKey[categoryKey]
        local parent = localCategory and localCategory.parent or ""
        if MergeCategoryRating(data, tonumber(f[3]), f[4], categoryKey, tonumber(f[6]), tonumber(f[7]), f[8], parent, f[9], f[10], f[11]) then UpdateUI() end

    elseif kind == "C" then
        if MergeComment(data, tonumber(f[3]), f[4], tonumber(f[5]), tonumber(f[6]), tonumber(f[7]), f[8], f[9], f[10], f[11]) then UpdateUI() end

    elseif kind == "Q" then
        local targetGUID = f[3]
        if targetGUID ~= myGUID then return end
        if MergeComment(data, tonumber(f[4]), f[5], tonumber(f[6]), tonumber(f[7]), tonumber(f[8]), f[9], f[10], f[11], f[12]) then UpdateUI() end

    elseif kind == "S" then
        if not authorizedHouseEditor then return end
        local hs = data.hostSettings or {}
        hs.commentsEnabled = f[4] ~= "0"
        hs.resetOnLogin = f[5] == "1"
        hs.hostDescription = CleanHostDescription(f[8] or hs.hostDescription or "")
        hs.categoryMask = NormalizeCategoryMask(f[11] or hs.categoryMask)
        if hs.commentsEnabled==false and EnabledCategoryCount(hs.categoryMask)==1 and CategoryMaskHas(hs.categoryMask,"custom") then hs.categoryMask=CategoryMaskSet(hs.categoryMask,"interior",true) end
        hs.resetBelowEnabled = nil; hs.resetBelowScore = nil; hs.resetAtRatingCount = nil; hs.resetAtRatingCountEnabled = nil
        hs.autoPortrait = true
        data.hostSettings = hs
        UpdateUI()

    elseif kind == "T" then
        local targetGUID = f[3]
        if targetGUID ~= myGUID then return end
        local hs = data.hostSettings or {}
        hs.commentsEnabled = f[5] ~= "0"
        hs.resetOnLogin = f[6] == "1"
        hs.hostDescription = CleanHostDescription(f[9] or hs.hostDescription or "")
        hs.categoryMask = NormalizeCategoryMask(f[12] or hs.categoryMask)
        if hs.commentsEnabled==false and EnabledCategoryCount(hs.categoryMask)==1 and CategoryMaskHas(hs.categoryMask,"custom") then hs.categoryMask=CategoryMaskSet(hs.categoryMask,"interior",true) end
        hs.resetBelowEnabled = nil; hs.resetBelowScore = nil; hs.resetAtRatingCount = nil; hs.resetAtRatingCountEnabled = nil
        hs.autoPortrait = true
        data.hostSettings = hs
        UpdateUI()

    elseif kind == "MH" then
        local targetGUID = f[3] or ""
        if targetGUID ~= "" and targetGUID ~= myGUID then return end
        if targetGUID == "" and not authorizedHouseEditor then return end
        local incomingRevision = tonumber(f[4]) or 0
        local market = NormalizeMarketData(data)
        if incomingRevision >= (tonumber(market.revision) or 0) then
            if incomingRevision > (tonumber(market.revision) or 0) then wipe(market.slots) end
        market.revision = incomingRevision
        market.featureTitle = CleanMarketTitle(f[5], "Featured Item")
        market.marketplaceTitle = CleanMarketTitle(f[6], "Today's Specials")
        market.description = CleanText(f[7] or "", MAX_MARKET_HEADER_DESCRIPTION_BYTES)
            UpdateUI()
        end

    elseif kind == "MS" then
        local targetGUID = f[3] or ""
        if targetGUID ~= "" and targetGUID ~= myGUID then return end
        if targetGUID == "" and not authorizedHouseEditor then return end
        local incomingRevision = tonumber(f[4]) or 0
        local index = math.floor(tonumber(f[5]) or 0)
        local market = NormalizeMarketData(data)
        if index >= 1 and index <= MAX_MARKET_SLOTS and incomingRevision >= (tonumber(market.revision) or 0) then
            if incomingRevision > (tonumber(market.revision) or 0) then wipe(market.slots); market.revision = incomingRevision end
            local itemID = math.floor(tonumber(f[6]) or 0)
            if itemID > 0 then
                market.slots[index] = {
                    itemID = itemID,
                    quantity = math.max(1, math.min(999, math.floor(tonumber(f[7]) or 1))),
                    priceCopper = math.max(0, math.min(MAX_MARKET_PRICE_COPPER, math.floor(tonumber(f[8]) or 0))),
                    icon = tonumber(f[9]) or 134400,
                    itemName = CleanItemName(f[10] or "Item", itemID),
                    description = CleanText(f[11] or "", MAX_MARKET_DESCRIPTION_BYTES),
                }
            else
                market.slots[index] = nil
            end
            UpdateUI()
        end

    elseif kind == "B3H" or kind == "B3M" or kind == "B3T" then
        local targetGUID=f[3] or ""
        if targetGUID~="" and targetGUID~=myGUID then return end
        if targetGUID=="" and not authorizedHouseEditor then return end
        if BookX and BookX.ApplyPacket and BookX.ApplyPacket(data,kind,f) then QueueLibraryUIRefresh() end

    elseif kind == "SE" then
        local targetGUID = f[3] or ""
        if targetGUID ~= myGUID then return end
        if BookX and BookX.FinishSync then BookX.FinishSync(data) end
        SetHouseSyncPending(false)
        UpdateUI()

    elseif kind == "F" then
        local targetGUID = f[3] or ""
        if targetGUID ~= "" and targetGUID ~= myGUID then return end
        if not authorizedHouseEditor then return end
        local hs = data.hostSettings or {}
        local incomingCapturedAt = tonumber(f[7]) or 0
        local currentCapturedAt = tonumber(hs.portraitCapturedAt) or 0
        if incomingCapturedAt >= currentCapturedAt then
            if f[4] and f[4] ~= "" then hs.portraitCharacterName = CleanText(f[4],48) end
            hs.portraitDisplayID = tonumber(f[5]) or hs.portraitDisplayID or 0
            hs.portraitFileID = tonumber(f[6]) or hs.portraitFileID or 0
            hs.portraitCapturedAt = incomingCapturedAt
            hs.autoPortrait = true
            data.hostSettings = hs
            UpdateUI()
        end

    elseif kind == "R" then
        local generation = tonumber(f[3]) or 0
        if currentHouse and SamePlayerName(sender, currentHouse.ownerName) and generation > (data.generation or 0) then
            data.generation = generation
            ClearHouseRatingState(data)
            selectedRating = nil
            viewedProfileGUID = nil
            viewedProfileName = nil
            viewedProfileHouseKey = nil
            descriptionBox:SetText("")
            SetStatus("House owner reset the ratings and comments.", 0.9, 0.75, 0.35)
            UpdateUI()
        end
    end
end

local function ChannelNoticeFilter(_, _, ...)
    if not suppressChannelName then return false end
    for i = 1, select("#", ...) do
        local value = select(i, ...)
        if type(value) == "string" and value == suppressChannelName then return true end
    end
    return false
end

local ratingChatLinksRegistered = false
local function RegisterRatingChatLinks()
    if ratingChatLinksRegistered then return end
    ratingChatLinksRegistered = true

    if ChatFrame_AddMessageEventFilter then
        local events = {
            "CHAT_MSG_SAY", "CHAT_MSG_YELL", "CHAT_MSG_PARTY", "CHAT_MSG_PARTY_LEADER",
            "CHAT_MSG_RAID", "CHAT_MSG_RAID_LEADER", "CHAT_MSG_GUILD", "CHAT_MSG_OFFICER",
            "CHAT_MSG_WHISPER", "CHAT_MSG_WHISPER_INFORM", "CHAT_MSG_CHANNEL",
            "CHAT_MSG_INSTANCE_CHAT", "CHAT_MSG_INSTANCE_CHAT_LEADER",
        }
        for _, eventName in ipairs(events) do
            pcall(ChatFrame_AddMessageEventFilter, eventName, RatingChatFilter)
        end
    end

    -- Blizzard recommends handling custom chat hyperlinks at the chat-frame
    -- hyperlink layer rather than insecurely replacing SetItemRef. This is a
    -- secure post-hook and leaves Blizzard's own hyperlink behavior untouched.
    if hooksecurefunc and ChatFrame_OnHyperlinkShow then
        hooksecurefunc("ChatFrame_OnHyperlinkShow", function(_, link)
            HandleRatingAddonLink(link)
        end)
    elseif EventRegistry and EventRegistry.RegisterCallback then
        pcall(EventRegistry.RegisterCallback, EventRegistry, "SetItemRef", function(_, link)
            HandleRatingAddonLink(link)
        end, RMH)
    end
end

RMH:RegisterEvent("ADDON_LOADED")
RMH:RegisterEvent("PLAYER_LOGIN")
RMH:RegisterEvent("PLAYER_ENTERING_WORLD")
RMH:RegisterEvent("ZONE_CHANGED")
RMH:RegisterEvent("ZONE_CHANGED_INDOORS")
RMH:RegisterEvent("ZONE_CHANGED_NEW_AREA")
RMH:RegisterEvent("PLAYER_LOGOUT")
RMH:RegisterEvent("HOUSE_PLOT_ENTERED")
RMH:RegisterEvent("HOUSE_PLOT_EXITED")
RMH:RegisterEvent("CURRENT_HOUSE_INFO_RECIEVED")
RMH:RegisterEvent("CURRENT_HOUSE_INFO_UPDATED")
RMH:RegisterEvent("PLAYER_HOUSE_LIST_UPDATED")
RMH:RegisterEvent("CHAT_MSG_ADDON")
RMH:RegisterEvent("CHAT_MSG_CHANNEL_NOTICE")
RMH:RegisterEvent("NAME_PLATE_UNIT_ADDED")
RMH:RegisterEvent("NAME_PLATE_UNIT_REMOVED")
RMH:RegisterEvent("PLAYER_TARGET_CHANGED")
RMH:RegisterEvent("PLAYER_FOCUS_CHANGED")
RMH:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
RMH:RegisterEvent("UNIT_PORTRAIT_UPDATE")
RMH:RegisterEvent("GROUP_ROSTER_UPDATE")
RMH:RegisterEvent("MAIL_SHOW")
RMH:RegisterEvent("BAG_UPDATE_DELAYED")
RMH:RegisterEvent("GET_ITEM_INFO_RECEIVED")

RMH:SetScript("OnEvent", function(self, event, arg1, ...)
    if event == "ADDON_LOADED" and arg1 == ADDON_NAME then
        EnsureDB()
        RefreshReviewCategories()
        RebuildCategoryMenu()
        PositionMinimapButton()
        if ChatFrame_AddMessageEventFilter then
            ChatFrame_AddMessageEventFilter("CHAT_MSG_CHANNEL_NOTICE", ChannelNoticeFilter)
        end
        RegisterRatingChatLinks()
        InstallBlizzardHousingDashboardGuard()

    elseif event == "ADDON_LOADED" and arg1 == "Blizzard_HousingDashboard" then
        InstallBlizzardHousingDashboardGuard()

    elseif event == "PLAYER_LOGIN" then
        if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
            pcall(C_ChatInfo.RegisterAddonMessagePrefix, PREFIX)
        end
        RefreshReviewCategories()
        RebuildCategoryMenu()
        StartPendingDeliveryTicker()
        if ResetOwnedHousesForLoginLogout then ResetOwnedHousesForLoginLogout() end
        -- Do not preemptively request Blizzard Housing Dashboard data here.
        -- RMH reads the current plot state on its own delayed refresh and requests
        -- the owned-house list only when an RMH page actually needs it.
        C_Timer.After(0.5, RefreshHouseState)

    elseif event == "PLAYER_HOUSE_LIST_UPDATED" then
        -- Blizzard_HousingDashboard owns this event while its panel is visible.
        -- Avoid C_Housing lookups from RMH during the same dispatch so the default
        -- House Information page can initialize/refresh without addon interference.
        if not IsBlizzardHousingDashboardOpen() then
            if StoreOwnedHouseList(arg1) and UpdateUI then UpdateUI() end
        end

    elseif event == "PLAYER_ENTERING_WORLD" or event == "HOUSE_PLOT_ENTERED" then
        BeginHouseStateTransition()
        C_Timer.After(0.05, RefreshHouseState)

    elseif event == "CURRENT_HOUSE_INFO_RECIEVED" or event == "CURRENT_HOUSE_INFO_UPDATED" then
        C_Timer.After(0.05, RefreshHouseState)

    elseif event == "ZONE_CHANGED" or event == "ZONE_CHANGED_INDOORS" or event == "ZONE_CHANGED_NEW_AREA" then
        C_Timer.After(0.05, function()
            RefreshHouseState()
            if UpdateUI then UpdateUI() end
        end)

    elseif event == "MAIL_SHOW" then
        if RMH.ApplyPendingTipMail then RMH:ApplyPendingTipMail() end

    elseif event == "BAG_UPDATE_DELAYED" or event == "GET_ITEM_INFO_RECEIVED" then
        if activePage=="ITEMS" and RMH.RefreshMarketBagItems then RMH.RefreshMarketBagItems() end

    elseif event == "PLAYER_LOGOUT" then
        if pendingDeliveryTicker and pendingDeliveryTicker.Cancel then pendingDeliveryTicker:Cancel() end
        pendingDeliveryTicker = nil
        if ResetOwnedHousesForLoginLogout then ResetOwnedHousesForLoginLogout() end

    elseif event == "HOUSE_PLOT_EXITED" then
        BeginHouseStateTransition()
        C_Timer.After(0.10, RefreshHouseState)

    elseif event == "CHAT_MSG_ADDON" then
        local prefix, message, distribution, sender = arg1, ...
        if prefix == PREFIX and type(message) == "string" then
            HandleAddonMessage(message, distribution, sender)
        end

    elseif event == "CHAT_MSG_CHANNEL_NOTICE" then
        C_Timer.After(0.10, function()
            if currentChannelName then ResolveChannel() end
        end)

    elseif event == "NAME_PLATE_UNIT_ADDED" or event == "NAME_PLATE_UNIT_REMOVED" or event == "PLAYER_TARGET_CHANGED" or event == "PLAYER_FOCUS_CHANGED" or event == "UPDATE_MOUSEOVER_UNIT" or event == "UNIT_PORTRAIT_UPDATE" or event == "GROUP_ROSTER_UPDATE" then
        if panel:IsShown() then UpdateUI() end
    end
end)

SLASH_RATEMYHOUSE1 = "/rmh"
SLASH_RATEMYHOUSE2 = "/ratemyhouse"
SlashCmdList.RATEMYHOUSE = function()
    if panel:IsShown() then
        panel:Hide()
    else
        local data=currentHouseKey and EnsureHouseData(currentHouseKey,currentHouse) or nil
        local commentsEnabled=not data or not data.hostSettings or data.hostSettings.commentsEnabled~=false
        selectedCategoryKey=commentsEnabled and "custom" or "interior"
        selectedRating=nil
        panel:Show()
    end
end
