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 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
local MAX_HOST_DESCRIPTION_BYTES = 96
local MAX_CUSTOM_CATEGORY_BYTES = 32
local MAX_RATED_CATEGORIES = 50
local sessionResetHouses = {}

local ReviewCategories = {}
local ReviewCategoryByKey = {}
local ReviewCategoryOrder = {}

local UpdateUI

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 SamePlayerName(a, b)
    return ShortName(a):lower() == ShortName(b):lower()
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)
    return tostring(HashString(tostring(houseKey or "")))
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 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 IsInsideHouseOrPlot()
    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 IsInsideOwnedHouseOrPlot()
    if C_Housing and C_Housing.IsInsideOwnedHouseOrPlot then
        local ok, inside = pcall(C_Housing.IsInsideOwnedHouseOrPlot)
        if ok then
            return inside == true
        end
    end
    return IsInsideHouseOrPlot() and currentHouse and SamePlayerName(PlayerFullName(), currentHouse.ownerName)
end

local function IsHouseOwner(info)
    if not info then return false end
    if C_Housing and C_Housing.IsInsideOwnedHouseOrPlot then
        local ok, owned = pcall(C_Housing.IsInsideOwnedHouseOrPlot)
        if ok and owned then
            return true
        end
    end
    return SamePlayerName(PlayerFullName(), info.ownerName)
end

-- Account-wide ownership check. Any character on the same account is treated as
-- the host for an owned plot and can never submit a rating to that house.
local function IsAccountOwnedHouse(info, data)
    if IsInsideOwnedHouseOrPlot() then return true end
    if data and data.accountOwned == true then return true end
    local owner = ShortName((info and info.ownerName) or (data and data.ownerName) or "")
    if owner ~= "" and RateMyHouseDB and RateMyHouseDB.accountCharacters and RateMyHouseDB.accountCharacters[owner] then
        if data then data.accountOwned = true 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
    if info.houseGUID and info.houseGUID ~= "" then
        return tostring(info.houseGUID)
    end
    return tostring(info.neighborhoodGUID or "unknown") .. ":" .. tostring(info.plotID or 0)
end

local function EnsureDB()
    RateMyHouseDB = RateMyHouseDB or {}
    RateMyHouseDB.version = 9
    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 {}
    -- 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 {}
        end
    end
    local accountCharacter = ShortName(PlayerFullName())
    if accountCharacter and 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] 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,
        resetBelowEnabled = false,
        resetBelowScore = 0,
        resetAtRatingCount = 1,
        resetAtRatingCountEnabled = 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
    if data.hostSettings.resetBelowEnabled == nil then data.hostSettings.resetBelowEnabled = false end
    data.hostSettings.resetBelowScore = math.max(0, math.min(10, tonumber(data.hostSettings.resetBelowScore) or 0))
    data.hostSettings.resetAtRatingCount = math.max(1, math.min(999, math.floor(tonumber(data.hostSettings.resetAtRatingCount) or 1)))
    if data.hostSettings.resetAtRatingCountEnabled == nil then data.hostSettings.resetAtRatingCountEnabled = false end
    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

    -- 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)
    local ownerShort = ShortName(data.ownerName or "")
    if ownerShort ~= "" and RateMyHouseDB.accountCharacters and RateMyHouseDB.accountCharacters[ownerShort] then
        data.accountOwned = true
    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
            local hs = data.hostSettings or {}
            CapturePlayerPortraitState(hs)
            data.hostSettings = hs
        elseif IsHouseOwner(info) then
            data.accountOwned = true
        end
        data.houseName = info.houseName or data.houseName
        data.plotID = info.plotID or data.plotID
        data.neighborhoodName = info.neighborhoodName or data.neighborhoodName
        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 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
        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 = CleanDescription(finalDescription or vote.finalDescription 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 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
        vote.categories[categoryKey] = {
            rating = rating,
            revision = revision,
            label = CleanTitle(label or categoryKey),
            parent = CleanTitle(parent or ""),
            description = CleanDescription(description 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)
    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 _, house in pairs(RateMyHouseDB.houses or {}) do
        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
    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

-- -----------------------------------------------------------------------------
-- Blizzard Housing catalog categories. The live API is preferred so this list
-- automatically follows Blizzard when Housing adds or renames categories.
-- -----------------------------------------------------------------------------

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

local function CanonicalCategoryLabel(label)
    label = CleanTitle(label or "")
    local lower = label:lower()
    if lower:find("misc", 1, true) then
        return "Misc"
    end
    if lower == "tables and desks" then
        return "Tables & Desks"
    end
    return label
end

local function RefreshReviewCategories()
    wipe(ReviewCategories)
    wipe(ReviewCategoryByKey)
    wipe(ReviewCategoryOrder)

    -- These four broad review areas sit above Blizzard's live catalog categories.
    -- The catalog rows themselves use Blizzard's Housing category/subcategory icons.
    AddReviewCategory({ key = "all", label = "Final Vote", parent = "", kind = "overall", orderIndex = -1000, icon = "dashboard-panel-homestone-teleport-button" })
    AddReviewCategory({ key = "custom", label = "Custom", parent = "", kind = "review", orderIndex = -950, iconTexture = 1505962 })
    AddReviewCategory({ key = "interior", label = "Interior", parent = "", kind = "review", orderIndex = -900, iconTexture = 7439625 })
    AddReviewCategory({ key = "exterior", label = "Exterior", parent = "", kind = "review", orderIndex = -800, iconTexture = "Interface\\Icons\\Achievement_Zone_ElwynnForest" })
    AddReviewCategory({ key = "landscape", label = "Landscape", parent = "", kind = "review", orderIndex = -700, iconTexture = 236712 })
    AddReviewCategory({ key = "furnishings", label = "Furnishings", parent = "", kind = "review", orderIndex = -600, iconTexture = "Interface\\Icons\\INV_Misc_Bag_10" })

    local function FindCategoryByLabel(label)
        label = CanonicalCategoryLabel(label):lower()
        for _, existing in ipairs(ReviewCategories) do
            if CanonicalCategoryLabel(existing.label):lower() == label then
                return existing
            end
        end
        return nil
    end

    local function ApplyLiveHousingIcon(label, icon)
        if not icon then return end
        local existing = FindCategoryByLabel(label)
        if existing then
            existing.icon = icon
            existing.iconTexture = nil
        end
    end

    local dynamic = {}
    local dynamicWorked = false

    if C_HousingCatalog and C_HousingCatalog.SearchCatalogSubcategories and C_HousingCatalog.GetCatalogSubcategoryInfo then
        local ok, ids = pcall(C_HousingCatalog.SearchCatalogSubcategories, {
            withStoredEntriesOnly = false,
            includeFeaturedCategory = false,
        })
        if ok and type(ids) == "table" then
            local parents = {}
            for _, subID in ipairs(ids) do
                local subOK, subInfo = pcall(C_HousingCatalog.GetCatalogSubcategoryInfo, subID)
                if subOK and type(subInfo) == "table" and subInfo.name and subInfo.name ~= "" then
                    local parentID = tonumber(subInfo.parentCategoryID)
                    if parentID and not parents[parentID] and C_HousingCatalog.GetCatalogCategoryInfo then
                        local parentOK, parentInfo = pcall(C_HousingCatalog.GetCatalogCategoryInfo, parentID)
                        if parentOK and type(parentInfo) == "table" then
                            parents[parentID] = parentInfo
                        end
                    end
                    dynamic[#dynamic + 1] = {
                        subID = tonumber(subInfo.ID) or tonumber(subID),
                        subName = subInfo.name,
                        subIcon = subInfo.icon,
                        subOrder = tonumber(subInfo.orderIndex) or 9999,
                        parentID = parentID,
                    }
                end
            end

            table.sort(dynamic, function(a, b)
                local ap = parents[a.parentID] or {}
                local bp = parents[b.parentID] or {}
                local apo = tonumber(ap.orderIndex) or 9999
                local bpo = tonumber(bp.orderIndex) or 9999
                if apo ~= bpo then return apo < bpo end
                local an = tostring(ap.name or "")
                local bn = tostring(bp.name or "")
                if an ~= bn then return an < bn end
                if a.subOrder ~= b.subOrder then return a.subOrder < b.subOrder end
                return tostring(a.subName) < tostring(b.subName)
            end)

            local emittedParents = {}
            local seenLabels = {
                ["final vote"] = true,
                ["custom category"] = true,
                ["interior"] = true,
                ["exterior"] = true,
                ["landscape"] = true,
                ["furnishings"] = true,
            }
            local miscAdded = false

            local function CanAddDisplayLabel(label)
                label = CanonicalCategoryLabel(label)
                if label == "Misc" then
                    if miscAdded then return false, label end
                    miscAdded = true
                    return true, label
                end
                local lower = label:lower()
                if seenLabels[lower] then return false, label end
                seenLabels[lower] = true
                return true, label
            end

            for _, item in ipairs(dynamic) do
                local parentInfo = parents[item.parentID]
                local parentName = CanonicalCategoryLabel(parentInfo and parentInfo.name or "Furnishings")
                if item.parentID and parentInfo and not emittedParents[item.parentID] then
                    emittedParents[item.parentID] = true
                    ApplyLiveHousingIcon(parentName, parentInfo.icon)
                    local canAdd, displayParent = CanAddDisplayLabel(parentName)
                    if canAdd then
                        AddReviewCategory({
                            key = "cat:" .. tostring(item.parentID),
                            label = displayParent,
                            parent = "",
                            kind = "catalogCategory",
                            icon = parentInfo.icon,
                            orderIndex = tonumber(parentInfo.orderIndex) or 9999,
                        })
                    end
                end

                if CanonicalCategoryLabel(item.subName):lower() ~= "rooms - all" then
                    ApplyLiveHousingIcon(item.subName, item.subIcon)
                    local canAddSub, displaySub = CanAddDisplayLabel(item.subName)
                    if canAddSub then
                        AddReviewCategory({
                            key = "sub:" .. tostring(item.subID),
                            label = displaySub,
                            parent = parentName,
                            kind = "catalogSubcategory",
                            icon = item.subIcon,
                            orderIndex = item.subOrder,
                            isSubcategory = true,
                        })
                        dynamicWorked = true
                    end
                end
            end
        end
    end

    if not dynamicWorked then
        local fallback = {
            { "Seating", "Furnishings", "Interface\\Icons\\INV_Chair_Wood_01" },
            { "Tables & Desks", "Furnishings", "Interface\\Icons\\INV_Misc_Book_11" },
            { "Storage", "Furnishings", "Interface\\Icons\\INV_Box_01" },
            { "Beds", "Furnishings", "Interface\\Icons\\INV_Misc_Rug_01" },
            { "Lighting", "Furnishings", "Interface\\Icons\\INV_Misc_Candle_01" },
            { "Rugs", "Furnishings", "Interface\\Icons\\INV_Misc_Rug_02" },
            { "Wall Decor", "Furnishings", "Interface\\Icons\\INV_Misc_Painting_01" },
            { "Plants", "Landscape", "Interface\\Icons\\INV_Misc_Herb_19" },
            { "Outdoor Decor", "Exterior", "Interface\\Icons\\Achievement_Zone_ElwynnForest" },
            { "Structures", "Exterior", "Interface\\Icons\\INV_Misc_EngGizmos_27" },
            { "Utilities", "Furnishings", "Interface\\Icons\\INV_Misc_Tools_01" },
            { "Decorations", "Furnishings", "Interface\\Icons\\INV_Misc_Gift_05" },
            { "Misc", "Furnishings", "Interface\\Icons\\INV_Misc_QuestionMark" },
        }
        for index, item in ipairs(fallback) do
            AddReviewCategory({
                key = "fallback:" .. tostring(index),
                label = item[1],
                parent = item[2],
                kind = "fallback",
                iconTexture = item[3],
                isSubcategory = true,
            })
        end
    end

    if not ReviewCategoryByKey[selectedCategoryKey] or selectedCategoryKey == "all" then
        selectedCategoryKey = ReviewCategoryByKey.custom and "custom" or (ReviewCategories[1] and ReviewCategories[1].key) or "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 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)
    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 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 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 "")
        data.accountOwned = true

        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
    data.accountOwned = true

    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)
    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
    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 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)),
    }, "\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",
        hs.resetBelowEnabled and "1" or "0", tostring(tonumber(hs.resetBelowScore) or 0),
        Utf8SafeTruncate(CleanHostDescription(hs.hostDescription or ""), 60), tostring(math.max(1,math.min(999,tonumber(hs.resetAtRatingCount) or 1))), hs.resetAtRatingCountEnabled and "1" or "0",
    }, "\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)
end

local function RequestPeerSync()
    if not currentHouseKey or not currentChannelID then return end
    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.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)
            SendAddon(table.concat({ "P", SyncHouseID(currentHouseKey), myGUID, bestSender }, "\t"))
        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

-- -----------------------------------------------------------------------------
-- 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 MaybeAutoResetForHost
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,
    SplitTabs = SplitTabs,
    HashString = HashString,
    SyncHouseID = SyncHouseID,
    ChannelForHouse = ChannelForHouse,
    Utf8SafeTruncate = Utf8SafeTruncate,
    CleanText = CleanText,
    CleanDescription = CleanDescription,
    CleanHostDescription = CleanHostDescription,
    CleanCustomCategoryTitle = CleanCustomCategoryTitle,
    CleanTitle = CleanTitle,
    CleanChatText = CleanChatText,
    IsInsideHouseOrPlot = IsInsideHouseOrPlot,
    IsInsideOwnedHouseOrPlot = IsInsideOwnedHouseOrPlot,
    IsHouseOwner = IsHouseOwner,
    IsAccountOwnedHouse = IsAccountOwnedHouse,
    GetHouseInfo = GetHouseInfo,
    MakeHouseKey = MakeHouseKey,
    EnsureDB = EnsureDB,
    SortAndTrimComments = SortAndTrimComments,
    EnforceRatingsLimit = EnforceRatingsLimit,
    MAX_HOUSE_RATINGS = MAX_HOUSE_RATINGS,
    EnsureHouseData = EnsureHouseData,
    ApplyGeneration = ApplyGeneration,
    RemoveVoteCommentsForGUID = RemoveVoteCommentsForGUID,
    GetOrCreateVote = GetOrCreateVote,
    MergeVote = MergeVote,
    MergeCategoryRating = MergeCategoryRating,
    MergeComment = MergeComment,
    RatingStats = RatingStats,
    CategoryAverage = CategoryAverage,
    GetRatingColor = GetRatingColor,
    CriticAverage = CriticAverage,
    CategoryRecordCount = CategoryRecordCount,
    AddReviewCategory = AddReviewCategory,
    CanonicalCategoryLabel = CanonicalCategoryLabel,
    RefreshReviewCategories = RefreshReviewCategories,
    GetSelectedCategory = GetSelectedCategory,
    HideChannelFromChat = HideChannelFromChat,
    LeaveSyncChannel = LeaveSyncChannel,
    SendAddon = SendAddon,
    BroadcastHostPortrait = BroadcastHostPortrait,
    BroadcastVote = BroadcastVote,
    BroadcastCategory = BroadcastCategory,
    BroadcastCategoryDelete = BroadcastCategoryDelete,
    QueuePendingVote = QueuePendingVote,
    QueuePendingReset = QueuePendingReset,
    BroadcastComment = BroadcastComment,
    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,
    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

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")
panel:SetScript("OnDragStart", function(self) self:StartMoving() end)
panel:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() 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)

local navBar = CreateFrame("Frame", nil, panel)
navBar:SetPoint("TOPLEFT", 6, -55)
navBar:SetPoint("TOPRIGHT", -5, -55)
navBar:SetHeight(40)
do
    local navBG = navBar:CreateTexture(nil, "BACKGROUND")
    navBG:SetAllPoints()
    navBG:SetColorTexture(0.035, 0.035, 0.035, 0.92)
    local navBottom = navBar:CreateTexture(nil, "ARTWORK")
    navBottom:SetPoint("BOTTOMLEFT", 0, 0)
    navBottom:SetPoint("BOTTOMRIGHT", 0, 0)
    navBottom:SetHeight(3)
    UIX.SetAtlasSafe(navBottom, "housing-dashboard-divider-horz-tile", false)
end

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

local commentsTab = CreateFrame("Button", "RateMyHouseRatingsTab", navBar, "PanelTopTabButtonTemplate")
commentsTab:SetSize(96, 30)
commentsTab:SetPoint("LEFT", rateTab, "RIGHT", 3, 0)
commentsTab:SetText("Ratings")
commentsTab:SetID(2)

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

local settingsTab = CreateFrame("Button", "RateMyHouseProfileTab", navBar, "PanelTopTabButtonTemplate")
settingsTab:SetSize(96, 30)
settingsTab:SetPoint("LEFT", ratingTab, "RIGHT", 3, 0)
settingsTab:SetText("Profile")
settingsTab:SetID(4)

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

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

-- One owner for page backgrounds. Normal house/review pages use the parchment
-- texture; the scenic Housing background exists only for the true off-plot page.
content._rmhParchmentBG = content:CreateTexture(nil, "BACKGROUND")
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, 1)
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()

-- Top-right window controls. These sit in the open header space rather than
-- occupying the decorative bottom-right housing frame.
local panelMinimized = false
local quickFrame
-- A fixed 54px control group keeps the - / + pair exactly centered in the
-- open right-side header guide area instead of centering each button separately.
local headerControls = CreateFrame("Frame", nil, panel)
headerControls:SetSize(56, 22)
headerControls:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -54, -27)
local minimizeButton = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
minimizeButton:SetSize(24, 22)
minimizeButton:SetPoint("LEFT", headerControls, "LEFT", 0, 0)
minimizeButton:SetText("-")
local maximizeButton = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
maximizeButton:SetSize(24, 22)
maximizeButton:SetPoint("RIGHT", headerControls, "RIGHT", 0, 0)
maximizeButton:SetText("+")

local function SetPanelMinimized(minimized)
    panelMinimized = minimized and true or false
    -- The guide center is 69px left of the window edge. Compact view needs a
    -- 5px lower visual center because the shorter portrait-frame header renders
    -- its button chrome slightly higher.
    headerControls:ClearAllPoints()
    headerControls:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -54, panelMinimized and -31 or -27)
    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=1 })
        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 quickFinalCommentBox, quickSubmitButton, quickResetButton
local quickAdjustButtons = {}

quickFrame = CreateFrame("Frame", nil, panel, "BackdropTemplate")
quickFrame:SetPoint("TOPLEFT", 10, -66)
quickFrame:SetPoint("BOTTOMRIGHT", -10, 10)
quickFrame:SetBackdrop({ edgeFile="Interface\\Buttons\\WHITE8X8", edgeSize=1 })
quickFrame:SetBackdropBorderColor(0,0,0,0.25)
do
    local bg = quickFrame:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetTexture("Interface\\AddOns\\RateMyHouse\\Media\\parchment.tga")
    bg:SetTexCoord(0, 1, 0, 1)
    quickFrame._rmhParchmentBG = bg
    local shade = quickFrame:CreateTexture(nil, "BACKGROUND", nil, 1)
    shade:SetAllPoints()
    shade:SetColorTexture(0,0,0,0.48)
    quickFrame._rmhParchmentShade = shade
end
quickFrame:Hide()

quickOwnerLabel = quickFrame:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge")
quickOwnerLabel:SetPoint("TOPLEFT",18,-10); quickOwnerLabel:SetWidth(260); quickOwnerLabel:SetJustifyH("LEFT")
quickAddressLabel = quickFrame:CreateFontString(nil,"OVERLAY","GameFontNormal")
quickAddressLabel:SetPoint("TOPLEFT",quickOwnerLabel,"BOTTOMLEFT",0,-5); quickAddressLabel:SetWidth(260); quickAddressLabel:SetJustifyH("LEFT")
do
    local quickHouseScoreLabel = quickFrame:CreateFontString(nil,"OVERLAY","GameFontNormal")
    quickHouseScoreLabel:SetWidth(120); quickHouseScoreLabel:SetJustifyH("CENTER"); quickHouseScoreLabel:SetPoint("TOPRIGHT",-18,-12); quickHouseScoreLabel:SetText("House Score")
end
quickHouseScore = quickFrame:CreateFontString(nil,"OVERLAY")
quickHouseScore:SetPoint("TOPRIGHT",-18,-30); quickHouseScore:SetWidth(120); quickHouseScore:SetJustifyH("CENTER")
if STANDARD_TEXT_FONT then quickHouseScore:SetFont(STANDARD_TEXT_FONT,30,"OUTLINE") else quickHouseScore:SetFontObject(GameFontHighlightLarge); quickHouseScore:SetScale(1.35) end
quickHouseCount = quickFrame:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
quickHouseCount:SetPoint("TOPRIGHT",-18,-60); quickHouseCount:SetWidth(120); quickHouseCount:SetJustifyH("CENTER")
quickHouseCount:SetTextColor(0.86,0.86,0.86)
quickHouseCount:SetText("No ratings yet")

do
    local quickRateLabel = quickFrame:CreateFontString(nil,"OVERLAY","GameFontNormalLarge")
    quickRateLabel:SetPoint("TOP",0,-64); quickRateLabel:SetText("Rate")
end
quickRateValue = quickFrame:CreateFontString(nil,"OVERLAY")
quickRateValue:SetPoint("TOP",0,-82); quickRateValue:SetWidth(160); quickRateValue:SetJustifyH("CENTER")
if STANDARD_TEXT_FONT then quickRateValue:SetFont(STANDARD_TEXT_FONT,34,"OUTLINE") else quickRateValue:SetFontObject(GameFontHighlightLarge); quickRateValue:SetScale(1.45) 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

do
    local quickCommentLabel=quickFrame:CreateFontString(nil,"OVERLAY","GameFontNormal")
    quickCommentLabel:SetPoint("TOPLEFT",28,-156); quickCommentLabel:SetText("Final Comment")
end
quickFinalCommentBox=CreateFrame("EditBox",nil,quickFrame,"InputBoxTemplate")
quickFinalCommentBox:SetSize(370,40); quickFinalCommentBox:SetPoint("TOPLEFT",28,-178)
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,-178); quickSubmitButton:SetText("Rate")
if quickSubmitButton:GetFontString() then quickSubmitButton:GetFontString():SetScale(1.12) end
quickResetButton=CreateFrame("Button",nil,quickFrame,"UIPanelButtonTemplate")
quickResetButton:SetSize(66,28); quickResetButton:SetPoint("TOPLEFT",350,-82); quickResetButton:SetText("Reset"); quickResetButton:Hide()
if quickResetButton:GetFontString() then quickResetButton:GetFontString():SetScale(0.95) end

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 ownerLabel = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
ownerLabel:SetPoint("TOPLEFT", 150, -26)
ownerLabel:SetWidth(245)
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(245)
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(245)
ownerHint:SetJustifyH("LEFT")
ownerHint:SetText("")
ownerHint:Hide()
ownerHint:SetTextColor(0.72, 0.72, 0.72)
local hostDescriptionLabel = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
hostDescriptionLabel:SetPoint("TOPLEFT", addressLabel, "BOTTOMLEFT", 0, -10)
hostDescriptionLabel:SetWidth(210)
hostDescriptionLabel:SetHeight(18)
hostDescriptionLabel:SetJustifyH("LEFT")
hostDescriptionLabel:SetJustifyV("TOP")
hostDescriptionLabel:SetWordWrap(false)
hostDescriptionLabel:SetText("")

local scoreBlock = CreateFrame("Frame", nil, content)
scoreBlock:SetSize(285, 126)
scoreBlock:SetPoint("TOPRIGHT", -24, -8)
local score = scoreBlock:CreateFontString(nil, "OVERLAY")
score:SetPoint("TOP", 0, -7)
score:SetJustifyH("CENTER")
if STANDARD_TEXT_FONT then score:SetFont(STANDARD_TEXT_FONT, 54, "OUTLINE") else score:SetFontObject(GameFontHighlightHuge or GameFontHighlightLarge); score:SetScale(1.95) end
score:SetText("—")
local countLabel = scoreBlock:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
countLabel:SetPoint("TOP", score, "BOTTOM", 0, -8)
countLabel:SetText("No ratings yet")
local headerMyRating = scoreBlock:CreateFontString(nil, "OVERLAY")
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")
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")
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(1)
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 settingsPage = CreateFrame("Frame", nil, content)
settingsPage:SetPoint("TOPLEFT", 0, -150)
settingsPage:SetPoint("BOTTOMRIGHT", 0, 0)
settingsPage:Hide()

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!")
content._ownedRateMessage = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
content._ownedRateMessage:SetPoint("CENTER", content, "CENTER", 0, -58)
content._ownedRateMessage:SetWidth(600)
content._ownedRateMessage:SetJustifyH("CENTER")
content._ownedRateMessage:SetText("You can't rate your own House.")
content._ownedRateMessage:SetTextColor(0.95, 0.95, 0.95)
content._ownedRateMessage:Hide()

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 ~= "PROFILE" then page = "RATE" end
    activePage = page
    ratePage:SetShown(page == "RATE")
    commentsPage:SetShown(page == "COMMENTS")
    profilePage:SetShown(page == "RATING")
    settingsPage:SetShown(page == "PROFILE")
    SetTabSelected(rateTab, page == "RATE")
    SetTabSelected(commentsTab, page == "COMMENTS")
    SetTabSelected(ratingTab, page == "RATING")
    SetTabSelected(settingsTab, page == "PROFILE")
end

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

local function ApplySimpleOffPlotState(inside)
    local linkedReview = (not inside) and linkedReviewMode and viewedProfileHouseKey ~= nil and (activePage == "COMMENTS" or activePage == "RATING")
    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()
        content._rmhOffPlotBG:Show(); content._rmhOffPlotShade:Show()
        if content._ownedRateMessage then content._ownedRateMessage:Hide() end
        hostPortraitFrame:Hide(); ownerLabel:Hide(); addressLabel:Hide(); hostDescriptionLabel:Hide(); scoreBlock:Hide(); headerDivider:Hide()
        ratePage:Hide(); commentsPage:Hide(); profilePage:Hide(); settingsPage: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
        if linkedReview and content._ownedRateMessage then content._ownedRateMessage:Hide() end
        hostPortraitFrame:Show(); ownerLabel:Show(); addressLabel:Show(); hostDescriptionLabel:Show(); scoreBlock:Show(); headerDivider:Show()
        if linkedReview then
            ratePage:Hide(); settingsPage:Hide(); commentsPage:SetShown(activePage=="COMMENTS"); profilePage:SetShown(activePage=="RATING")
        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()
    if not linkedReviewMode and UIX.IsInsideHouseOrPlot() and currentHouseKey and currentHouse then
        local d=UIX.EnsureHouseData(currentHouseKey,currentHouse)
        activePage=UIX.IsAccountOwnedHouse(currentHouse,d) and "COMMENTS" or "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(78)
-- No custom background behind the category rail or Rate controls.  Let the
-- Housing-style page artwork remain visible and uncluttered.
ratedSidebar:SetBackdrop(nil)
ratePage._rmhMainPanel=nil

local ratedSidebarScroll = CreateFrame("ScrollFrame", nil, ratedSidebar, "UIPanelScrollFrameTemplate")
ratedSidebarScroll:SetPoint("TOPLEFT", 5, -54)
ratedSidebarScroll:SetPoint("BOTTOMRIGHT", -24, 7)
local ratedSidebarChild = CreateFrame("Frame", nil, ratedSidebarScroll)
ratedSidebarChild:SetSize(48, 1)
ratedSidebarScroll:SetScrollChild(ratedSidebarChild)
local ratedSidebarRows = {}

local categoryIconFrame = CreateFrame("Frame", nil, ratePage)
categoryIconFrame:SetSize(40, 40)
categoryIconFrame:SetPoint("TOPLEFT", 110, -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", 158, -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", 620, -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")
    GameTooltip:AddLine("Deletes the selected saved category rating.", 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(250, 40)
customCategoryBox:SetPoint("TOPLEFT", 158, -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)

local categoryRatingLabel = ratePage:CreateFontString(nil, "OVERLAY", "GameFontNormal")
categoryRatingLabel:SetPoint("TOP", 38, -82)
categoryRatingLabel:SetText("")
categoryRatingLabel:SetTextColor(1, 0.82, 0)
local categoryRatingValue = ratePage:CreateFontString(nil, "OVERLAY")
categoryRatingValue:SetPoint("TOP", 38, -70)
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("—")

-- 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(126, 44)
    button:SetPoint("TOPLEFT", 118 + ((index - 1) * 136), -132)
    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=1 })
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", -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("Post 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, 430); 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()]
        entry = vote and vote.categories and vote.categories[key]
    end

    local isDynamicCustom = UIX.IsCustomCategoryKey(key) and key ~= "custom" and entry ~= nil
    if key == "all" or (not UIX.ReviewCategoryByKey[key] and not isDynamicCustom) then
        key = "custom"
        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

    customCategoryBox:SetShown(isCustom)
    if isCustom then
        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(198, 40)
        categoryButton:SetPoint("TOPLEFT", 416, -18)
        categoryButton:SetText("Categories")
    else
        categoryButton:ClearAllPoints()
        categoryButton:SetSize(456, 40)
        categoryButton:SetPoint("TOPLEFT", 158, -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

-- 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",7,-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",-7,-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 Selected Rating"); GameTooltip:AddLine("Deletes the currently selected saved category rating.",1,1,1,true); GameTooltip:Show() end)
newestDeleteButton:SetScript("OnLeave",GameTooltip_Hide)
newCustomButton:SetScript("OnClick", function()
    selectedRating=nil
    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")
    GameTooltip:SetText("New Custom")
    GameTooltip:AddLine("Start a fresh Custom rating.",1,1,1,true)
    GameTooltip:Show()
end)
newCustomButton:SetScript("OnLeave", GameTooltip_Hide)

RebuildCategoryMenu = function()
    for _, row in ipairs(categoryMenuRows) do row:Hide() end
    local shownIndex, rowHeight = 0, 50
    for _, category in ipairs(UIX.ReviewCategories) do
        if category.key ~= "all" 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 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(1); 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; 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
    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(48,62)
            local sel=row:CreateTexture(nil,"BACKGROUND"); sel:SetSize(54,54); 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(50,50); selectedFrame:SetPoint("TOP",0,0); selectedFrame:SetFrameLevel((row:GetFrameLevel() or 1)+8); selectedFrame:SetBackdrop({edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=2}); selectedFrame:SetBackdropBorderColor(1,0.78,0.08,1); selectedFrame:Hide(); row.selectedFrame=selectedFrame
            local ibg=row:CreateTexture(nil,"BACKGROUND",nil,1); ibg:SetSize(46,46); ibg:SetPoint("TOP",0,-2); 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(46,46); io:SetPoint("CENTER",ibg); UIX.SetAtlasSafe(io,"house-upgrade-reward-icon-frame-overlay",false)
            local val=row:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); val:SetPoint("BOTTOM",0,0); val:SetJustifyH("CENTER"); val:SetScale(1.05); 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
    ratedSidebarChild:SetHeight(math.max(1,math.min(#entries,UIX.MAX_RATED_CATEGORIES)*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) viewedProfileGUID=self.voterGUID; viewedProfileName=self.playerName; viewedProfileHouseKey=self.houseKey or viewedProfileHouseKey or currentHouseKey; linkedReviewMode = linkedReviewMode or not UIX.IsInsideHouseOrPlot(); SetActivePage("RATING"); 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)
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)
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 noProfile=profilePage:CreateFontString(nil,"OVERLAY","GameFontHighlightLarge"); noProfile:SetPoint("TOP",0,-110); noProfile:SetWidth(520); noProfile:SetJustifyH("CENTER"); noProfile:SetText("Open a Player's Rating or Post Review link to view Full Review."); noProfile:Hide()

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 Post Review link to view Full Review.")
        noProfile:Show()
        for _,row in ipairs(profileRows) do row:Hide() end
        return
    end

    if viewedProfileGUID == "__HOST_REVIEW__" then
        profileHeaderCard:Hide()
        profileDivider: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")
        noProfile:SetShown(shownCount==0)
        return
    end

    profileTitle:SetText("")
    profileScroll:ClearAllPoints()
    profileScroll:SetPoint("TOPLEFT",24,-174)
    profileScroll:SetPoint("BOTTOMRIGHT",-38,32)
    profileHeaderCard:Show()
    profileDivider:Show()
    noProfile: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 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 "Housing 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)
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,344,"")
-- 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 on Login/Off")
local hostResetBelowToggle=CreateFrame("CheckButton",nil,hostSection,"UICheckButtonTemplate"); hostResetBelowToggle:SetSize(28,28); hostResetBelowToggle:SetPoint("TOPLEFT",44,-180)
local hostResetBelowText=hostSection:CreateFontString(nil,"OVERLAY","GameFontHighlight"); hostResetBelowText:SetPoint("LEFT",hostResetBelowToggle,"RIGHT",3,0); hostResetBelowText:SetText("Reset Score at:")
local thresholdBox=CreateFrame("EditBox",nil,hostSection,"InputBoxTemplate"); thresholdBox:SetSize(52,36); thresholdBox:SetPoint("LEFT",hostResetBelowText,"RIGHT",8,0); thresholdBox:SetAutoFocus(false); thresholdBox:SetNumeric(true); thresholdBox:SetMaxLetters(2); thresholdBox:SetJustifyH("CENTER")
local hostRatingCountToggle=CreateFrame("CheckButton",nil,hostSection,"UICheckButtonTemplate"); hostRatingCountToggle:SetSize(28,28); hostRatingCountToggle:SetPoint("TOPLEFT",44,-222)
local hostRatingCountText=hostSection:CreateFontString(nil,"OVERLAY","GameFontHighlight"); hostRatingCountText:SetPoint("LEFT",hostRatingCountToggle,"RIGHT",3,0); hostRatingCountText:SetText("Total Number of Ratings")
local ratingCountBox=CreateFrame("EditBox",nil,hostSection,"InputBoxTemplate"); ratingCountBox:SetSize(58,36); ratingCountBox:SetPoint("LEFT",hostRatingCountText,"RIGHT",8,0); ratingCountBox:SetAutoFocus(false); ratingCountBox:SetNumeric(true); ratingCountBox:SetMaxLetters(3); ratingCountBox:SetJustifyH("CENTER")

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(438)
ApplyInputChrome(hostDescBox)
ApplyInputChrome(thresholdBox)
ApplyInputChrome(ratingCountBox)
EnforceLineLimit(hostDescBox, 1, UIX.MAX_HOST_DESCRIPTION_BYTES, nil)


local hostCommentsPending, hostResetLoginPending, hostResetBelowPending, hostResetRatingCountEnabledPending, hostResetRatingCountPending = true,false,false,false,1
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)
end
local function MarkProfileDirty() profileDirty=true; RefreshProfileSaveButton() end

local selectedDisplayHouseKey=""
local displayHouseButton=CreateFrame("Button",nil,hostSection,"UIPanelButtonTemplate")
displayHouseButton:SetSize(160,32); displayHouseButton:SetPoint("TOPRIGHT",hostSection,"TOPRIGHT",-44,-148); displayHouseButton:SetText("House / Plot")
local hostReviewButton=CreateFrame("Button",nil,hostSection,"UIPanelButtonTemplate")
hostReviewButton:SetSize(160,32); hostReviewButton:SetPoint("TOP",displayHouseButton,"BOTTOM",0,-8); hostReviewButton:SetText("Post House Review")
if hostReviewButton:GetFontString() then hostReviewButton:GetFontString():SetScale(0.82) end
resetButton:SetParent(hostSection)
resetButton:ClearAllPoints()
resetButton:SetSize(160,32)
resetButton:SetPoint("TOP",hostReviewButton,"BOTTOM",0,-8)
resetButton:SetText("Reset House Rating")
if resetButton:GetFontString() then resetButton:GetFontString():SetScale(0.84) end
local displayHouseArrow=displayHouseButton:CreateTexture(nil,"OVERLAY"); displayHouseArrow:SetTexture("Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up"); displayHouseArrow:SetSize(18,18); displayHouseArrow:SetPoint("RIGHT",-5,0)
local displayHouseMenu=CreateFrame("Frame",nil,panel,"BackdropTemplate")
displayHouseMenu:SetSize(240,180); displayHouseMenu:SetFrameStrata("FULLSCREEN_DIALOG")
displayHouseMenu: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}})
displayHouseMenu:SetBackdropColor(0.035,0.025,0.015,0.985); displayHouseMenu:SetBackdropBorderColor(0.72,0.56,0.28,1)
displayHouseMenu:SetPoint("TOPLEFT",displayHouseButton,"BOTTOMLEFT",0,-2); displayHouseMenu:Hide()
local displayHouseRows={}
local function IsAccountOwnedHouseData(data)
    if not data then return false end
    if data.accountOwned == true then return true end
    UIX.EnsureDB()
    local owner=UIX.ShortName(data.ownerName or "")
    if owner ~= "" and RateMyHouseDB.accountCharacters and RateMyHouseDB.accountCharacters[owner] then
        data.accountOwned=true
        return true
    end
    return false
end
local function OwnedDisplayHouses()
    UIX.EnsureDB()
    local result={}
    for houseKey,data in pairs(RateMyHouseDB.houses or {}) do
        if IsAccountOwnedHouseData(data) then
            local detail=(data.houseName and data.houseName~="" and data.houseName) or (data.plotID and ("Plot "..tostring(data.plotID))) or "House / Plot"
            local owner=UIX.ShortName(data.ownerName or "")
            result[#result+1]={key=houseKey,detail=detail,title=(owner~="" and (owner.."'s House") or "House")}
        end
    end
    table.sort(result,function(a,b) return tostring(a.detail)<tostring(b.detail) end)
    return result
end
local function SelectedDisplayHouseInfo()
    local houses=OwnedDisplayHouses()
    for _,entry in ipairs(houses) do if entry.key==selectedDisplayHouseKey then return entry end end
    if #houses>0 then selectedDisplayHouseKey=houses[1].key; return houses[1] end
    return nil
end
local function SelectedProfileHouseData(fallbackData)
    UIX.EnsureDB()
    local selected=SelectedDisplayHouseInfo()
    if selected and selected.key then
        return selected.key, UIX.EnsureHouseData(selected.key, RateMyHouseDB.houses[selected.key])
    end
    if currentHouseKey then
        return currentHouseKey, UIX.EnsureHouseData(currentHouseKey,currentHouse)
    end
    return nil, fallbackData
end
local function LoadSelectedProfileHouseSettings()
    local _,data=SelectedProfileHouseData(nil)
    local hs=data and data.hostSettings or {commentsEnabled=true,resetOnLogin=false,resetBelowEnabled=false,resetBelowScore=0,resetAtRatingCount=1,resetAtRatingCountEnabled=false,hostDescription="",autoPortrait=true}
    hostCommentsPending=hs.commentsEnabled~=false
    hostResetLoginPending=hs.resetOnLogin==true
    hostResetBelowPending=hs.resetBelowEnabled==true
    hostResetRatingCountEnabledPending=hs.resetAtRatingCountEnabled==true
    hostResetRatingCountPending=math.max(1,math.min(999,math.floor(tonumber(hs.resetAtRatingCount) or 1)))
    SetCheck(hostCommentsToggle,hostCommentsPending); SetCheck(hostResetLoginToggle,hostResetLoginPending); SetCheck(hostResetBelowToggle,hostResetBelowPending); SetCheck(hostRatingCountToggle,hostResetRatingCountEnabledPending)
    thresholdBox:SetText(tostring(math.floor((tonumber(hs.resetBelowScore) or 0)+0.5)))
    ratingCountBox:SetText(tostring(hostResetRatingCountPending))
    hostDescBox:SetText(hs.hostDescription or "")
    local ownsSelected=IsAccountOwnedHouseData(data)
    hostCommentsToggle:SetEnabled(ownsSelected==true); hostResetLoginToggle:SetEnabled(ownsSelected==true); hostResetBelowToggle:SetEnabled(ownsSelected==true)
    thresholdBox:SetEnabled(ownsSelected==true and hostResetBelowPending); hostRatingCountToggle:SetEnabled(ownsSelected==true); ratingCountBox:SetEnabled(ownsSelected==true and hostResetRatingCountEnabledPending)
    hostDescBox:SetEnabled(ownsSelected==true)
end
local function RebuildDisplayHouseMenu()
    local houses=OwnedDisplayHouses()
    displayHouseMenu:SetHeight(math.max(48,math.min(220,16+(#houses*34))))
    for _,row in ipairs(displayHouseRows) do row:Hide() end
    for index,entry in ipairs(houses) do
        local row=displayHouseRows[index]
        if not row then row=CreateFrame("Button",nil,displayHouseMenu,"UIPanelButtonTemplate"); row:SetSize(216,30); displayHouseRows[index]=row end
        row:ClearAllPoints(); row:SetPoint("TOPLEFT",12,-10-((index-1)*34)); row:SetText(entry.detail); row.houseKey=entry.key
        row:SetScript("OnClick",function(self)
            selectedDisplayHouseKey=self.houseKey or ""
            local selected=SelectedDisplayHouseInfo()
            displayHouseButton:SetText(selected and selected.detail or "House / Plot")
            displayHouseMenu:Hide()
            LoadSelectedProfileHouseSettings()
            MarkProfileDirty()
        end)
        row:Show()
    end
    if #houses==0 then displayHouseButton:SetText("No House / Plot") end
end
displayHouseButton:SetScript("OnClick",function() RebuildDisplayHouseMenu(); displayHouseMenu:SetShown(not displayHouseMenu:IsShown()) end)

hostCommentsToggle:SetScript("OnClick",function(self) hostCommentsPending=self:GetChecked() and true or false; MarkProfileDirty() end)
hostResetLoginToggle:SetScript("OnClick",function(self) hostResetLoginPending=self:GetChecked() and true or false; MarkProfileDirty() end)
hostResetBelowToggle:SetScript("OnClick",function(self) hostResetBelowPending=self:GetChecked() and true or false; thresholdBox:SetEnabled(hostResetBelowPending); MarkProfileDirty() end)
hostRatingCountToggle:SetScript("OnClick",function(self) hostResetRatingCountEnabledPending=self:GetChecked() and true or false; ratingCountBox:SetEnabled(hostResetRatingCountEnabledPending); MarkProfileDirty() end)
hostDescBox:SetScript("OnTextChanged",function(_,user) if user then MarkProfileDirty() end end)
thresholdBox:SetScript("OnTextChanged",function(self,user)
    if not user then return end
    local n=tonumber(self:GetText())
    if n and n>10 then self:SetText("10"); self:SetCursorPosition(2) end
    MarkProfileDirty()
end)
ratingCountBox:SetScript("OnTextChanged",function(self,user)
    if not user then return end
    local n=tonumber(self:GetText())
    if n and n>999 then self:SetText("999"); self:SetCursorPosition(3)
    elseif n and n<1 then self:SetText("1"); self:SetCursorPosition(1) end
    MarkProfileDirty()
end)

local function UpdateProfileSettingsPage(data)
    UIX.EnsureDB()
    local p=RateMyHouseDB.profile or {}
    if not selectedDisplayHouseKey or selectedDisplayHouseKey=="" then selectedDisplayHouseKey=p.displayHouseKey or "" end
    local selectedHouse=SelectedDisplayHouseInfo()
    displayHouseButton:SetText(selectedHouse and selectedHouse.detail or "No House / Plot")

    local profileHouseKey,targetData=SelectedProfileHouseData(data)
    local hs=targetData and targetData.hostSettings or {commentsEnabled=true,resetOnLogin=false,resetBelowEnabled=false,resetBelowScore=0,resetAtRatingCount=1,resetAtRatingCountEnabled=false,hostDescription="",autoPortrait=true}
    hostCommentsPending=hs.commentsEnabled~=false
    hostResetLoginPending=hs.resetOnLogin==true
    hostResetBelowPending=hs.resetBelowEnabled==true
    hostResetRatingCountEnabledPending=hs.resetAtRatingCountEnabled==true
    hostResetRatingCountPending=math.max(1,math.min(999,math.floor(tonumber(hs.resetAtRatingCount) or 1)))
    SetCheck(hostCommentsToggle,hostCommentsPending); SetCheck(hostResetLoginToggle,hostResetLoginPending); SetCheck(hostResetBelowToggle,hostResetBelowPending); SetCheck(hostRatingCountToggle,hostResetRatingCountEnabledPending)
    if not thresholdBox:HasFocus() then thresholdBox:SetText(tostring(math.floor((tonumber(hs.resetBelowScore) or 0)+0.5))) end
    if not ratingCountBox:HasFocus() then ratingCountBox:SetText(tostring(hostResetRatingCountPending)) end
    if not hostDescBox:HasFocus() then hostDescBox:SetText(hs.hostDescription or "") end

    local ownsSelected=IsAccountOwnedHouseData(targetData)
    hostCommentsToggle:SetEnabled(ownsSelected==true); hostResetLoginToggle:SetEnabled(ownsSelected==true); hostResetBelowToggle:SetEnabled(ownsSelected==true)
    thresholdBox:SetEnabled(ownsSelected==true and hostResetBelowPending); hostRatingCountToggle:SetEnabled(ownsSelected==true); ratingCountBox:SetEnabled(ownsSelected==true and hostResetRatingCountEnabledPending)
    hostDescBox:SetEnabled(ownsSelected==true); displayHouseButton:SetEnabled(true); hostReviewButton:SetEnabled(ownsSelected==true and profileHouseKey~=nil)
    profileDirty=false; 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 {}
    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",hs.resetBelowEnabled and "1" or "0",tostring(tonumber(hs.resetBelowScore) or 0),UIX.Utf8SafeTruncate(UIX.CleanHostDescription(hs.hostDescription or ""),60),tostring(math.max(1,math.min(999,tonumber(hs.resetAtRatingCount) or 1))),hs.resetAtRatingCountEnabled and "1" or "0"},"\t"))
    UIX.BroadcastHostPortrait(houseKey,data,nil)
end

profileSave:SetScript("OnClick",function()
    UIX.EnsureDB()
    local profileHouseKey,data=SelectedProfileHouseData(nil)
    local ownsSelected=IsAccountOwnedHouseData(data)
    if ownsSelected and data then
        local hs=data.hostSettings
        hs.commentsEnabled=hostCommentsPending
        hs.resetOnLogin=hostResetLoginPending
        hs.resetBelowEnabled=hostResetBelowPending
        hs.resetBelowScore=math.max(0,math.min(10,tonumber(thresholdBox:GetText()) or 0))
        hs.resetAtRatingCountEnabled=hostResetRatingCountEnabledPending
        hs.resetAtRatingCount=math.max(1,math.min(999,math.floor(tonumber(ratingCountBox:GetText()) or 1)))
        hs.hostDescription=UIX.CleanHostDescription(hostDescBox:GetText())
        hs.autoPortrait=true
        if profileHouseKey == currentHouseKey and currentHouse and UIX.IsAccountOwnedHouse(currentHouse,data) then
            UIX.CapturePlayerPortraitState(hs)
        end
        BroadcastHostSettings(profileHouseKey,data)
        if profileHouseKey==currentHouseKey and hs.resetBelowEnabled then C_Timer.After(0,function() if currentHouseKey and data==UIX.EnsureHouseData(currentHouseKey,currentHouse) then MaybeAutoResetForHost(data) end end) end
    end
    local p=RateMyHouseDB.profile or {}
    p.displayHouseKey=selectedDisplayHouseKey or ""
    RateMyHouseDB.profile=p
    profileDirty=false; RefreshProfileSaveButton(); UpdateUI()
end)


ResetOwnedHousesForLoginLogout = function()
    UIX.EnsureDB()
    for houseKey, data in pairs(RateMyHouseDB.houses or {}) do
        if 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 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 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
MaybeAutoResetForHost = function(data)
    if not data or not currentHouse or not UIX.IsInsideHouseOrPlot() or not UIX.IsAccountOwnedHouse(currentHouse,data) then return false end
    local hs=data.hostSettings or {}
    local avg,count=UIX.RatingStats(data)
    if hs.resetAtRatingCountEnabled then
        local requiredCount=math.max(1,math.min(999,math.floor(tonumber(hs.resetAtRatingCount) or 1)))
        if count>=requiredCount then
            return PerformHouseReset(string.format("Ratings reset after %d total ratings.",requiredCount))
        end
    end
    if hs.resetBelowEnabled and count>0 then
        local threshold=tonumber(hs.resetBelowScore) or 0
        if avg<=threshold then
            return PerformHouseReset(string.format("Ratings reset: house score reached %.0f or lower.",threshold))
        end
    end
    return false
end

local function IsHousingInterior(info)
    -- Prefer a dedicated Housing API when Blizzard exposes one.
    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

    -- CurrentHouseInfo builds may expose the state as a boolean field.
    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

    -- Generic WoW indoor state is the final runtime fallback.
    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 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)
    else
        quickOwnerLabel:SetText("Visit a House Plot")
        quickAddressLabel:SetText("")
    end
    if 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")
    else
        quickHouseScore:SetText("—"); quickHouseScore:SetTextColor(0.9,0.9,0.9)
        quickHouseCount:SetText("No ratings yet")
    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 accountOwned=inside and info~=nil and UIX.IsAccountOwnedHouse(info,data)
    local canEdit=inside and info~=nil and not accountOwned and final==nil
    if accountOwned then quickSelectedRating=nil end
    for _,b in ipairs(quickAdjustButtons) do b:SetEnabled(canEdit) end
    quickSubmitButton:SetEnabled(canEdit and quickSelectedRating~=nil)
    quickFinalCommentBox:SetEnabled(canEdit)
    quickResetButton:SetShown(inside and info~=nil and not accountOwned and final~=nil)
    if not quickFinalCommentBox:HasFocus() then quickFinalCommentBox:SetText(canEdit and (pendingFinalComment or "") or (final and (vote.finalDescription or "") or "")) 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 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")
    local viewingExternalProfile=activePage=="RATING" and viewingLinkedHouse and viewedProfileHouseKey~=currentHouseKey
    local headerData=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")
        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
        hostDescriptionLabel:SetText(UIX.Utf8SafeTruncate(UIX.CleanHostDescription(headerData.hostSettings and headerData.hostSettings.hostDescription or ""), 60))
        UpdateHeaderPortrait(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"); 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; hostDescriptionLabel:SetText(UIX.Utf8SafeTruncate(UIX.CleanHostDescription(data and data.hostSettings and data.hostSettings.hostDescription or ""), 60)); 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

    if count>0 then score:SetFormattedText("%.1f",average); local r,g,b=UIX.GetRatingColor(average); score:SetTextColor(r,g,b); countLabel:SetFormattedText("%d total %s",count,count==1 and "rating" or "ratings") else score:SetText("—"); score:SetTextColor(0.9,0.9,0.9); countLabel:SetText("No ratings yet") end

    local myVote=data and data.votes and data.votes[UIX.PlayerGUID()]; local hasFinalVote=myVote and tonumber(myVote.rating)~=nil
    local categoryAverage, categoryCount = UIX.CategoryAverage(myVote)
    local hasCategoryVotes = (categoryCount or 0) > 0
    postButton:SetShown(not viewingExternalProfile); postButton:SetEnabled(hasFinalVote==true and not viewingExternalProfile)
    postButton:SetText("Post Review")
    finalVoteButton:SetShown(not viewingExternalProfile)
    resetButton:SetEnabled(true)
    local category=UIX.GetSelectedCategory(); if category and category.key=="all" then SetCategorySelection("custom",true); category=UIX.GetSelectedCategory() end
    if UIX.IsCustomCategoryKey(selectedCategoryKey) then SetCustomIconTexture(categoryIcon) else UIX.SetReviewCategoryIcon(categoryIcon,category) end
    local existing=CurrentExistingRating(data)
    if UIX.IsCustomCategoryKey(selectedCategoryKey) then
        customCategoryBox:SetShown(true); categoryButton:SetSize(198,40); categoryButton:ClearAllPoints(); categoryButton:SetPoint("TOPLEFT",416,-18); categoryButton:SetText(existing ~= nil and "Categories  (Edit)" or "Categories")
    else
        customCategoryBox:SetShown(false); categoryButton:SetSize(456,40); categoryButton:ClearAllPoints(); categoryButton:SetPoint("TOPLEFT",158,-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
    local accountOwned=inside and info~=nil and UIX.IsAccountOwnedHouse(info,data)
    score:ClearAllPoints()
    countLabel:ClearAllPoints()
    if myFinal~=nil and not accountOwned then
        score:SetPoint("TOP", scoreBlock, "TOP", 0, -7)
        countLabel:SetPoint("TOP", score, "BOTTOM", 0, -8)
        headerMyRatingLabel:Show(); headerMyRating:Show(); resetMyVoteButton:Show()
        headerMyRating:SetFormattedText("%.1f",myFinal); local r,g,b=UIX.GetRatingColor(myFinal); headerMyRating:SetTextColor(r,g,b)
    else
        score:SetPoint("TOP", scoreBlock, "TOP", 0, -27)
        countLabel:SetPoint("TOP", score, "BOTTOM", 0, -9)
        headerMyRatingLabel:Hide(); headerMyRating:Hide(); resetMyVoteButton:Hide(); headerMyRating:SetText("")
    end
    -- House owners, every character on the owner's account, and all owner alts
    -- are hard-blocked from rating their own house.
    local canEditRating=inside and info~=nil and not accountOwned and myFinal==nil
    if accountOwned then selectedRating=nil; quickSelectedRating=nil end
    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
    local commentsEnabled=not data or not data.hostSettings or data.hostSettings.commentsEnabled~=false
    descriptionBox:SetEnabled(canEditRating and commentsEnabled); descriptionLabel:SetText(commentsEnabled and "Description (optional)" or "Description (comments disabled by host)")
    finalRateCommentBox:SetEnabled(canEditRating)
    if pendingFinalCommentHouseKey ~= currentHouseKey then pendingFinalComment=""; pendingFinalCommentHouseKey=currentHouseKey end
    if not finalRateCommentBox:HasFocus() and myFinal==nil then finalRateCommentBox:SetText(pendingFinalComment or "") end
    if myFinal~=nil and not finalRateCommentBox:HasFocus() then finalRateCommentBox:SetText("") end
    categoryButton:SetEnabled(not viewingExternalProfile and canEditRating)
    customCategoryBox:SetEnabled(UIX.IsCustomCategoryKey(selectedCategoryKey) and canEditRating)
    rateAction:SetEnabled(canEditRating and selectedRating~=nil)
    finalVoteButton:SetEnabled(canEditRating and hasCategoryVotes)
    UpdateRatedCategorySidebar(data); UpdateCommentRows(viewingLinkedHouse and profileData or data, viewingLinkedHouse and viewedProfileHouseKey or currentHouseKey); UpdateProfilePage(profileData); UpdateProfileSettingsPage(data); SetActivePage(activePage)
    if content._ownedRateMessage then
        local showOwnedRateBlock = accountOwned and activePage == "RATE" and not viewingLinkedHouse
        content._ownedRateMessage:SetShown(showOwnedRateBlock)
        if showOwnedRateBlock then ratePage:Hide() end
    end
    ApplySimpleOffPlotState(inside)
    UpdateQuickRateView(data, info, average, count)

    SetStatus("")
end

rateTab:SetScript("OnClick",function() categoryMenu:Hide(); displayHouseMenu:Hide(); linkedReviewMode=false; viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil; SetActivePage("RATE"); UpdateUI() end)
commentsTab:SetScript("OnClick",function()
    categoryMenu:Hide(); displayHouseMenu:Hide()
    if not linkedReviewMode then viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil end
    SetActivePage("COMMENTS"); UpdateUI()
end)
ratingTab:SetScript("OnClick",function()
    categoryMenu:Hide(); displayHouseMenu:Hide()
    if not linkedReviewMode then viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil end
    SetActivePage("RATING"); UpdateUI()
end)
settingsTab:SetScript("OnClick",function() categoryMenu:Hide(); displayHouseMenu:Hide(); linkedReviewMode=false; viewedProfileGUID=nil; viewedProfileName=nil; viewedProfileHouseKey=nil; SetActivePage("PROFILE"); UpdateUI() end)

local function AdjustFineRating(delta)
    if not UIX.IsInsideHouseOrPlot() or not currentHouse then return end
    local data=currentHouseKey and UIX.EnsureHouseData(currentHouseKey,currentHouse) or nil
    if UIX.IsAccountOwnedHouse(currentHouse,data) then
        selectedRating=nil
        UpdateUI()
        SetStatus("You cannot rate a house owned by your account.",1,0.35,0.35)
        return
    end
    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)
    UIX.EnsureDB()
    local p=RateMyHouseDB.profile or {}
    local selected=SelectedDisplayHouseInfo()
    vote.criticName=UIX.CleanText(selected and selected.title or "",UIX.MAX_CRITIC_NAME_BYTES)
    vote.criticDescription=UIX.CleanText(selected and selected.detail or "",UIX.MAX_CRITIC_DESCRIPTION_BYTES)
    vote.showCriticScore=false
    vote.criticScore=nil
end

local function AdjustQuickRating(delta)
    if not UIX.IsInsideHouseOrPlot() or not currentHouse then return end
    local data=currentHouseKey and UIX.EnsureHouseData(currentHouseKey,currentHouse) or nil
    if UIX.IsAccountOwnedHouse(currentHouse,data) then
        quickSelectedRating=nil
        UpdateUI()
        SetStatus("You cannot rate a house owned by your account.",1,0.35,0.35)
        return
    end
    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 not UIX.IsInsideHouseOrPlot() or not currentHouse or not currentHouseKey then return end
    if quickSelectedRating==nil then return end
    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse)
    if UIX.IsAccountOwnedHouse(currentHouse,data) then
        quickSelectedRating=nil
        UpdateUI()
        SetStatus("You cannot rate a house owned by your account.",1,0.35,0.35)
        return
    end
    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)
    vote.finalDescription=UIX.CleanDescription((pendingFinalComment and pendingFinalComment~="") and pendingFinalComment or quickFinalCommentBox:GetText())
    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)
    if not MaybeAutoResetForHost(data) then UpdateUI() end
end
quickSubmitButton:SetScript("OnClick",SubmitQuickRating)

local function SubmitCategoryRating()
    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
    if selectedCategoryKey=="all" then SetCategorySelection("custom",true) end

    local data=UIX.EnsureHouseData(currentHouseKey,currentHouse)
    if UIX.IsAccountOwnedHouse(currentHouse,data) then
        selectedRating=nil
        UpdateUI()
        SetStatus("You cannot rate a house owned by your account.",1,0.35,0.35)
        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 commentsEnabled=data.hostSettings.commentsEnabled~=false
    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 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)
    if UIX.IsAccountOwnedHouse(currentHouse,data) then
        selectedRating=nil
        UpdateUI()
        SetStatus("You cannot rate a house owned by your account.",1,0.35,0.35)
        return
    end
    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 finalComment=UIX.CleanDescription((pendingFinalComment and pendingFinalComment~="") and pendingFinalComment or finalRateCommentBox:GetText())
    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); if not MaybeAutoResetForHost(data) then UpdateUI() end
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 posting 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("Post Review"); GameTooltip:AddLine("Places your Full Review link into chat.",1,1,1,true); GameTooltip:AddLine("Final Vote must exist before posting.",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 then return end
    selectedDisplayHouseKey=houseKey
    local shareText=ShareX.BuildHostShareText(houseKey,data)
    if ChatFrame_OpenChat and shareText then ChatFrame_OpenChat(shareText,DEFAULT_CHAT_FRAME) end
end)
hostReviewButton:SetScript("OnEnter",function(self) GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetText("Post House Review"); GameTooltip:AddLine("Full House Review link with All Players Rating to share with others.",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)
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 selectedCategoryKey="custom"; 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 function RefreshHouseState()
    local inside = IsInsideHouseOrPlot()
    local info = inside and GetHouseInfo() or nil
    local key = info and MakeHouseKey(info) or nil

    if key ~= currentHouseKey then
        if currentHouseKey then LeaveSyncChannel() end
        currentHouse = info
        currentHouseKey = key
        selectedRating = nil
        selectedCategoryKey = ReviewCategoryByKey.custom and "custom" or selectedCategoryKey
        linkedReviewMode = false
        viewedProfileGUID = nil
        viewedProfileName = nil
        viewedProfileHouseKey = nil
        descriptionBox:SetText("")
        if key then
            local data = EnsureHouseData(key, info)
            activePage = IsAccountOwnedHouse(info,data) and "COMMENTS" or "RATE"
            JoinSyncChannel(key)
            if IsInsideHouseOrPlot() and IsAccountOwnedHouse(info,data) and 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
    else
        currentHouse = info
        if key then
            local data = EnsureHouseData(key, info)
            if not linkedReviewMode and panel and panel:IsShown() and (activePage=="RATE" or activePage=="COMMENTS") then
                activePage = IsAccountOwnedHouse(info,data) and "COMMENTS" or "RATE"
            end
            if not currentChannelName then JoinSyncChannel(key) end
            if IsInsideHouseOrPlot() and IsAccountOwnedHouse(info,data) and 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
    end

    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()

    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,
        }

    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)
            if not MaybeAutoResetForHost(data) then UpdateUI() end
        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)
            if not MaybeAutoResetForHost(data) then UpdateUI() end
        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 currentHouse and not SamePlayerName(sender or "", currentHouse.ownerName or "") then return end
        local hs = data.hostSettings or {}
        hs.commentsEnabled = f[4] ~= "0"
        hs.resetOnLogin = f[5] == "1"
        hs.resetBelowEnabled = f[6] == "1"
        hs.resetBelowScore = math.max(0, math.min(10, tonumber(f[7]) or 0))
        hs.hostDescription = CleanHostDescription(f[8] or hs.hostDescription or "")
        hs.resetAtRatingCount = math.max(1,math.min(999,math.floor(tonumber(f[9]) or tonumber(hs.resetAtRatingCount) or 1)))
        hs.resetAtRatingCountEnabled = f[10] == "1"
        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.resetBelowEnabled = f[7] == "1"
        hs.resetBelowScore = math.max(0, math.min(10, tonumber(f[8]) or 0))
        hs.hostDescription = CleanHostDescription(f[9] or hs.hostDescription or "")
        hs.resetAtRatingCount = math.max(1,math.min(999,math.floor(tonumber(f[10]) or tonumber(hs.resetAtRatingCount) or 1)))
        hs.resetAtRatingCountEnabled = f[11] == "1"
        hs.autoPortrait = true
        data.hostSettings = hs
        UpdateUI()

    elseif kind == "F" then
        local targetGUID = f[3] or ""
        if targetGUID ~= "" and targetGUID ~= myGUID then return end
        if currentHouse and not SamePlayerName(sender or "", currentHouse.ownerName or "") 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("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: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()

    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
        if C_Housing and C_Housing.RequestCurrentHouseInfo then
            pcall(C_Housing.RequestCurrentHouseInfo)
        end
        C_Timer.After(0.5, RefreshHouseState)

    elseif event == "PLAYER_ENTERING_WORLD" or event == "HOUSE_PLOT_ENTERED" or event == "CURRENT_HOUSE_INFO_RECIEVED" or event == "CURRENT_HOUSE_INFO_UPDATED" then
        C_Timer.After(0.15, 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 == "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
        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 selectedCategoryKey="custom"; selectedRating=nil; panel:Show() end
end
