-- HousingDecorGuide/UI/Projects/HDG_ArchitectCanvas.lua
-- Renders the Architect blueprint as a uniform grid of atlas tiles.
--
-- Separation of concerns:
--   • Layout (HDG_AutoLayout) decides which rooms touch which, on a unit grid.
--     One cell per room, regardless of physical size. Pure topology.
--   • Render (this file) draws each cell as a fixed-size atlas tile. Atlases
--     are already drawn at a consistent visual scale in the master texture
--     (Layout_Cross_S_Icon, Layout_Octogon_M_Icon, etc. all sit in 100×100
--     atlas regions), so we just SetAtlas at our chosen tile size and the
--     visual proportions come out right by construction.

local _, privateDB = ...

local IDs        = privateDB.HDG_ProjectsIDs
local Store      = privateDB.HDG_ProjectsStore
local AutoLayout = privateDB.HDG_AutoLayout
local ShapesDB   = privateDB.HDG_RoomShapesDB
local Atlas      = privateDB.HDG_RoomShapeAtlas

local M = {}

local TILE_PX  = 80   -- pixel size of every room tile (and the cell pitch)
local ORB_SIZE = 12

local function _EventBus()
    local g = _G.HousingDecorGuide
    return g and g.EventBus
end

local function _faction()
    local f = UnitFactionGroup("player")
    if f == "Alliance" then return "alliance" elseif f == "Horde" then return "horde" end
    return nil
end

local function _currentHouseID()
    local f = _faction()
    if not f then return nil end
    return "house:" .. f
end

-- Wall-midpoint offset (in cells, relative to a 1×1 tile) for orb placement.
local DIR_OFFSET = {
    N = {  0, -0.5 },
    E = {  0.5, 0 },
    S = {  0,  0.5 },
    W = { -0.5, 0 },
}

-- Read all rooms for a floor and compute their topology layout. Shared
-- between the current floor render and the lower-floor backdrop.
local OPPOSITE = { N = "S", E = "W", S = "N", W = "E" }

local function _floorLayout(houseID, floor)
    local floorID = IDs.makeFloorID(houseID, floor)
    if not floorID then return nil end
    local entries = Store.GetRoomsForFloor(floorID)
    local roomsInput = {}
    for _, e in ipairs(entries) do
        roomsInput[e.id] = {
            shape             = e.room.shape,
            name              = e.room.name,
            occupiedCardinals = e.room.occupiedCardinals,
            doorCardinals     = e.room.doorCardinals,
            doors             = e.room.doors,
            isBase            = e.room.isBase,
            canMove           = e.room.canMove,
            canRemove         = e.room.canRemove,
        }
    end
    if not next(roomsInput) then return nil end

    -- Cardinal-match with degree priority + no-double-edge constraint.
    local function _degree(rid) return #(roomsInput[rid].occupiedCardinals or {}) end
    local openEnds = {}
    for roomID, room in pairs(roomsInput) do
        for _, c in ipairs(room.occupiedCardinals or {}) do
            openEnds[#openEnds + 1] = { roomID = roomID, cardinal = c }
        end
    end
    -- Sort DESCENDING by degree so hubs match first. Cardinal-alone
    -- matching has a fundamental ambiguity ceiling: when multiple
    -- equal-degree corridors compete for a hub's cardinal, the choice is
    -- arbitrary. Hubs-first + alpha tiebreak is the most robust default
    -- — it produces a connected tree-valid layout (may not match the
    -- actual physical arrangement on ambiguous houses, but every room
    -- is reachable, which beats orphaned components from leaves-first).
    table.sort(openEnds, function(a, b)
        local da, db = _degree(a.roomID), _degree(b.roomID)
        if da ~= db then return da > db end
        if a.roomID ~= b.roomID then return a.roomID < b.roomID end
        return a.cardinal < b.cardinal
    end)

    local connections = {}
    local usedEnds = {}
    local edgeSet  = {}
    local function _edgeKey(a, b)
        if a < b then return a .. "|" .. b else return b .. "|" .. a end
    end

    -- Seed user-asserted connections first. Any open-end that matches a
    -- saved assertion is consumed before the cardinal-matching algorithm
    -- runs, so manual overrides always win.
    local HDGstore = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    local floorIDForAssert = IDs.makeFloorID(houseID, floor)
    local asserts = (HDGstore and HDGstore.Connection_ListForFloor and floorIDForAssert)
                    and HDGstore:Connection_ListForFloor(floorIDForAssert) or {}

    local function _findEnd(roomID, dir)
        for i, e in ipairs(openEnds) do
            if not usedEnds[i] and e.roomID == roomID and e.cardinal == dir then
                return i
            end
        end
    end
    for _, a in ipairs(asserts) do
        if a.blocked then
            -- Disconnect record: claim the endpoint so the algorithm skips
            -- it, but don't add a connection. The orb stays open and the
            -- player can assert a new one.
            local i = _findEnd(a.fromID, a.fromDir)
            if i then usedEnds[i] = true end
        else
            local i = _findEnd(a.fromID, a.fromDir)
            local j = _findEnd(a.toID,   a.toDir)
            if i and j and i ~= j then
                usedEnds[i] = true
                usedEnds[j] = true
                edgeSet[_edgeKey(a.fromID, a.toID)] = true
                connections[#connections + 1] = {
                    fromID = a.fromID, fromDir = a.fromDir,
                    toID   = a.toID,   toDir   = a.toDir,
                    manual = true,
                }
            end
        end
    end

    for i, endA in ipairs(openEnds) do
        if not usedEnds[i] then
            local opp = OPPOSITE[endA.cardinal]
            local bestJ, bestDeg = nil, -1
            for j = i + 1, #openEnds do
                if not usedEnds[j] then
                    local endB = openEnds[j]
                    if endB.roomID ~= endA.roomID
                       and endB.cardinal == opp
                       and not edgeSet[_edgeKey(endA.roomID, endB.roomID)] then
                        local deg = _degree(endB.roomID)
                        local better = deg > bestDeg
                            or (deg == bestDeg and (bestJ == nil or endB.roomID < openEnds[bestJ].roomID))
                        if better then bestJ, bestDeg = j, deg end
                    end
                end
            end
            if bestJ then
                usedEnds[i] = true
                usedEnds[bestJ] = true
                edgeSet[_edgeKey(endA.roomID, openEnds[bestJ].roomID)] = true
                connections[#connections + 1] = {
                    fromID  = endA.roomID,
                    fromDir = endA.cardinal,
                    toID    = openEnds[bestJ].roomID,
                    toDir   = openEnds[bestJ].cardinal,
                }
            end
        end
    end

    local result = AutoLayout.compute({ rooms = roomsInput, connections = connections })

    -- Build human-readable labels with disambiguation. If multiple rooms
    -- share the same localized name (e.g. three Hallways), append " #1",
    -- " #2", etc. by capture-deterministic ordering of room IDs.
    -- Strip the redundant "Room" word from labels — every tile is already
    -- visibly a room. "Cross-Shaped Room" → "Cross-Shaped",
    -- "Octagon Room (Large)" → "Octagon (Large)".
    local function _trimRoom(name)
        if type(name) ~= "string" then return name end
        local s = name:gsub("%s*Room%s*", " "):gsub("%s+", " ")
        return (s:gsub("^%s+", ""):gsub("%s+$", ""))
    end

    local nameCount = {}
    for _, room in pairs(roomsInput) do
        local n = _trimRoom(room.name)
        if n then nameCount[n] = (nameCount[n] or 0) + 1 end
    end
    local labels, idsByName = {}, {}
    for id, room in pairs(roomsInput) do
        local n = _trimRoom(room.name) or room.shape or "Room"
        idsByName[n] = idsByName[n] or {}
        idsByName[n][#idsByName[n] + 1] = id
    end
    for name, ids in pairs(idsByName) do
        if #ids == 1 then
            labels[ids[1]] = name
        else
            table.sort(ids)
            for i, id in ipairs(ids) do labels[id] = name .. " #" .. i end
        end
    end

    return roomsInput, result, labels
end

function M:Create(parent)
    if self.frame then return self.frame end
    self.parent = parent
    self.zoom = 1
    self.currentFloor = 1
    self.pools = { rooms = {}, orbs = {}, backdrop = {} }
    self.onRoomClick = nil
    self.mode = (Store.GetUI and Store.GetUI().activeMode) or "reflect"

    local frame = CreateFrame("Frame", nil, parent)
    frame:SetAllPoints(parent)

    local bg = frame:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.04, 0.10, 0.18, 1)

    -- Host anchored at the bottom so Entry (cell 0,0) sits at canvas south
    -- and the rest of the house fans north (upward).
    local host = CreateFrame("Frame", nil, frame)
    host:SetSize(1, 1)
    host:SetPoint("BOTTOM", frame, "BOTTOM", 0, 80)
    self.host = host
    self.frame = frame

    local rescanBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    rescanBtn:SetSize(110, 22)
    rescanBtn:SetPoint("TOPLEFT", frame, "TOPLEFT", 8, -8)
    rescanBtn:SetText("Re-capture")
    rescanBtn:SetScript("OnClick", function()
        local cap = privateDB.HDG_TopologyCapture
        if cap and cap.CaptureAllFloors then cap.CaptureAllFloors() end
    end)
    self.rescanBtn = rescanBtn

    -- Mode pill: Reflect (cyan) | Plan (purple). Toggles between read-only
    -- auto-layout and drag-to-position editing of the current floor.
    local function _mkModePill(label, anchor, accent, modeKey)
        local p = CreateFrame("Button", nil, frame)
        p:SetSize(60, 22)
        p:SetPoint("TOPLEFT", anchor, "TOPRIGHT", 4, 0)
        local pbg = p:CreateTexture(nil, "BACKGROUND")
        pbg:SetAllPoints()
        local pborder = p:CreateTexture(nil, "BORDER")
        pborder:SetAllPoints()
        pborder:SetColorTexture(accent.r, accent.g, accent.b, 1)
        pbg:SetColorTexture(0.04, 0.10, 0.18, 1)
        local fill = p:CreateTexture(nil, "ARTWORK")
        fill:SetPoint("TOPLEFT", 1, -1)
        fill:SetPoint("BOTTOMRIGHT", -1, 1)
        local txt = p:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        txt:SetPoint("CENTER", 0, 0)
        txt:SetText(label)
        p._modeKey = modeKey
        p._accent  = accent
        p._fill    = fill
        p._txt     = txt
        p:SetScript("OnClick", function() self:SetMode(modeKey) end)
        return p
    end

    self.reflectPill = _mkModePill("Reflect", rescanBtn,
        { r = 0.40, g = 0.85, b = 1.00 }, "reflect")
    self.planPill    = _mkModePill("Plan", self.reflectPill,
        { r = 0.78, g = 0.50, b = 0.95 }, "plan")
    self:_PaintModePills()

    -- "Reset to auto-layout" — only visible in Plan mode. Clears every
    -- room's manual cell.locked on the current floor; auto-layout takes
    -- over again on next render.
    local resetBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    resetBtn:SetSize(150, 22)
    resetBtn:SetPoint("TOPLEFT", self.planPill, "TOPRIGHT", 8, 0)
    resetBtn:SetText("Reset auto-layout")
    resetBtn:SetScript("OnClick", function() self:_ResetAutoLayout() end)
    resetBtn:Hide()
    self.resetBtn = resetBtn

    -- Floor room-count chip. Updates per-floor on every Refresh.
    local countLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    countLabel:SetPoint("LEFT", resetBtn, "RIGHT", 14, 0)
    countLabel:SetTextColor(0.7, 0.78, 0.86, 1)
    self.countLabel = countLabel

    local bus = _EventBus()
    if bus and bus.Register then
        bus:Register("HDG_PROJECTS_TOPOLOGY_UPDATED", function() self:Refresh(self.currentFloor) end)
        bus:Register("HDG_PROJECTS_CAPTURE_COMPLETE", function() self:Refresh(self.currentFloor) end)
        bus:Register("HDG_PROJECT_ROOM_UPDATED",      function() self:Refresh(self.currentFloor) end)
    end

    return frame
end

function M:_PaintModePills()
    local active = self.mode or "reflect"
    for _, pill in ipairs({ self.reflectPill, self.planPill }) do
        if pill then
            local on = pill._modeKey == active
            local a  = pill._accent
            if on then
                pill._fill:SetColorTexture(a.r * 0.5, a.g * 0.5, a.b * 0.5, 1)
                pill._txt:SetTextColor(1, 1, 1, 1)
            else
                pill._fill:SetColorTexture(0.06, 0.13, 0.21, 1)
                pill._txt:SetTextColor(0.7, 0.78, 0.86, 1)
            end
        end
    end
end

function M:SetMode(modeKey)
    if modeKey ~= "reflect" and modeKey ~= "plan" then return end
    self.mode = modeKey
    if Store.SetUIField then Store.SetUIField("activeMode", modeKey) end
    self:_PaintModePills()
    if self.resetBtn then
        if modeKey == "plan" then self.resetBtn:Show() else self.resetBtn:Hide() end
    end
    local bus = _EventBus()
    if bus and bus.Trigger then
        bus:Trigger("HDG_PROJECTS_MODE_CHANGED", { mode = modeKey })
    end
    self:Refresh(self.currentFloor)
end

function M:_ResetAutoLayout()
    local houseID = _currentHouseID()
    if not houseID then return end
    local floorID = IDs.makeFloorID(houseID, self.currentFloor or 1)
    if not floorID then return end
    local entries = Store.GetRoomsForFloor(floorID)
    for _, e in ipairs(entries) do
        if e.room.cell and e.room.cell.locked then
            -- Strip locked + persisted manual cell. Pass a fresh cell record
            -- with locked=false so UpsertRoom's merge-only behavior overwrites
            -- the prior manual coords on next save cycle.
            Store.UpsertRoom(e.id, { cell = { x = 0, y = 0, rotation = 0, locked = false } })
        end
    end
    self:Refresh(self.currentFloor)
end

-- Read manual-locked cells for the current floor. Returns map roomID → {x,y}.
local function _manualCells(houseID, floor)
    local floorID = IDs.makeFloorID(houseID, floor)
    local out = {}
    if not floorID then return out end
    local entries = Store.GetRoomsForFloor(floorID)
    for _, e in ipairs(entries) do
        local c = e.room.cell
        if c and c.locked then
            out[e.id] = { x = c.x or 0, y = c.y or 0 }
        end
    end
    return out
end

-- Reflect-mode right-click: show each cardinal's connection state with
-- a Disconnect action for currently-paired doors. "Open" cardinals appear
-- as disabled rows (the orb itself is the way to assert connections).
function M:_ShowConnectionsMenu(owner, roomID)
    if not (MenuUtil and MenuUtil.CreateContextMenu) then return end
    local store   = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    local houseID = _currentHouseID()
    local floorID = houseID and IDs.makeFloorID(houseID, self.currentFloor or 1)
    if not (store and floorID and self._lastConnections and self._lastRoomsInput) then return end

    local entry = self._lastRoomsInput[roomID]
    if not entry then return end

    local partners = {}  -- dir → { partnerID, partnerDir, isManual }
    for _, c in ipairs(self._lastConnections) do
        if c.fromID == roomID then
            partners[c.fromDir] = { id = c.toID, dir = c.toDir, manual = c.manual }
        end
        if c.toID == roomID then
            partners[c.toDir]   = { id = c.fromID, dir = c.fromDir, manual = c.manual }
        end
    end

    local labels = self._lastLabels or {}
    local doorList = {}
    do
        local seen = {}
        for _, d in ipairs(entry.doorCardinals or entry.occupiedCardinals or {}) do
            if not seen[d] then
                seen[d] = true
                doorList[#doorList + 1] = d
            end
        end
    end

    MenuUtil.CreateContextMenu(owner, function(_, root)
        root:CreateTitle(string.format("Connections — %s", labels[roomID] or roomID))
        for _, dir in ipairs(doorList) do
            local p = partners[dir]
            if p then
                local tag = p.manual and "manual" or "auto"
                root:CreateButton(string.format("(%s) |A:common-icon-forwardarrow:12:12|a %s |cff8c8c8c[%s]|r — Disconnect",
                    dir, labels[p.id] or p.id, tag), function()
                    if store.Connection_Block then
                        store:Connection_Block(floorID, roomID, dir)
                    end
                    self:Refresh(self.currentFloor)
                end)
            else
                local b = root:CreateButton(string.format("(%s) open", dir), function() end)
                if b and b.SetEnabled then b:SetEnabled(false) end
            end
        end
    end)
end

-- Pop a context menu listing every other open-door orb on the current floor
-- whose cardinal is the opposite of (roomID, dir). Selecting one writes a
-- Connection_Assert override; both endpoints become "filled" on next refresh.
function M:_AssertConnectionMenu(owner, roomID, dir)
    if not (MenuUtil and MenuUtil.CreateContextMenu) then return end
    local opp = OPPOSITE[dir]
    if not opp then return end

    local store   = _G.HousingDecorGuide and _G.HousingDecorGuide.Store
    local houseID = _currentHouseID()
    local floorID = houseID and IDs.makeFloorID(houseID, self.currentFloor or 1)
    if not (store and floorID) then return end

    local candidates = {}
    for _, orb in ipairs(self._openOrbs or {}) do
        if orb ~= owner and orb._dir == opp and orb._roomID ~= roomID then
            candidates[#candidates + 1] = orb
        end
    end
    table.sort(candidates, function(a, b)
        return (a._label or "") < (b._label or "")
    end)

    MenuUtil.CreateContextMenu(owner, function(_, root)
        root:CreateTitle(string.format("Connect %s door to…", dir))
        if #candidates == 0 then
            local btn = root:CreateButton("(no compatible open doors)", function() end)
            if btn and btn.SetEnabled then btn:SetEnabled(false) end
            return
        end
        for _, orb in ipairs(candidates) do
            root:CreateButton(string.format("%s (%s door)", orb._label, opp), function()
                if store.Connection_Assert then
                    store:Connection_Assert(floorID, roomID, dir, orb._roomID, opp)
                end
                self:Refresh(self.currentFloor)
            end)
        end
    end)
end

function M:_RotateRoom(roomID)
    local room = Store.GetRoom(roomID)
    if not room then return end
    local cell = room.cell or { x = 0, y = 0 }
    local nextRot = (((cell.rotation or 0) + 1) % 4)
    Store.UpsertRoom(roomID, {
        cell = {
            x = cell.x or 0, y = cell.y or 0,
            rotation = nextRot,
            locked   = cell.locked or false,
        },
    })
end

function M:_RemoveRoom(roomID)
    if not roomID then return end
    Store.DeleteRoom(roomID)
end

function M:_ResetRoomPosition(roomID)
    local room = Store.GetRoom(roomID)
    if not room then return end
    Store.UpsertRoom(roomID, {
        cell = { x = 0, y = 0, rotation = 0, locked = false },
    })
end

function M:_ShowRoomMenu(owner, roomID)
    if not (MenuUtil and MenuUtil.CreateContextMenu) then return end
    local room = Store.GetRoom(roomID)
    local plannedOnly = room and room.plannedOnly == true

    MenuUtil.CreateContextMenu(owner, function(_, root)
        root:CreateTitle(plannedOnly and "Planned room" or "Captured room")
        root:CreateButton("|A:common-icon-rotateright:14:14|a Rotate", function() self:_RotateRoom(roomID) end)
        if plannedOnly then
            root:CreateButton("|cffff8c5cRemove room|r", function() self:_RemoveRoom(roomID) end)
        else
            root:CreateButton("Reset position",          function() self:_ResetRoomPosition(roomID) end)
        end
    end)
end

-- Snapped + collision-checked drop. canvas-relative pixel coords → cell.
function M:_DropRoom(roomID, btn, tile)
    if not (self.host and btn) then return end
    local hostX, hostY = self.host:GetCenter()
    local btnX, btnY   = btn:GetCenter()
    if not (hostX and btnX) then return end

    local cellX = math.floor((btnX - hostX) / tile + 0.5)
    local cellY = math.floor((hostY - btnY) / tile + 0.5)  -- y inverts (we render -cell.y * tile)

    -- Collision check against other rooms' rendered cells on this floor.
    for otherID, other in pairs(self._renderedCells or {}) do
        if otherID ~= roomID and other.x == cellX and other.y == cellY then
            -- Snap back: re-render restores original position.
            self:Refresh(self.currentFloor)
            return
        end
    end

    local prev = (Store.GetRoom(roomID) and Store.GetRoom(roomID).cell) or {}
    Store.UpsertRoom(roomID, {
        cell = {
            x = cellX, y = cellY,
            rotation = prev.rotation or 0,
            locked   = true,
        },
    })
    -- UpsertRoom fires HDG_PROJECT_ROOM_UPDATED → Refresh runs.
end

function M:SetOnRoomClick(fn)
    self.onRoomClick = fn
end

local function _hideAllInPool(pool)
    if type(pool) == "table" then
        for _, f in pairs(pool) do
            if type(f) == "table" and f.Hide then f:Hide() end
        end
    end
end

-- A "stair-leaf" room is a degree-1 room whose canMove == 8 (Unreachable-
-- WithoutRoom from HousingLayoutRestriction). For a deg-1 room, removing it
-- normally disconnects nothing — unless it has stairs to another floor, in
-- which case the OTHER floor's rooms become unreachable. So canMove==8 on a
-- leaf is the API's tell that this is a stairwell connector.
local function _findStairLeaf(roomsInput)
    for id, room in pairs(roomsInput) do
        local deg = #(room.occupiedCardinals or {})
        if deg == 1 and room.canMove == 8 then return id, room end
    end
    return nil
end

function M:Refresh(floor)
    if not self.frame then return end
    floor = floor or self.currentFloor or 1
    self.currentFloor = floor

    local houseID = _currentHouseID()
    if not houseID then return end

    _hideAllInPool(self.pools.rooms)
    _hideAllInPool(self.pools.orbs)
    _hideAllInPool(self.pools.backdrop)

    local tile = TILE_PX * self.zoom

    -- Lower-floor backdrop: when viewing F2+, render the floor below at low
    -- alpha behind the current floor so stair alignment is visible.
    if floor > 1 then
        self:_RenderBackdrop(houseID, floor - 1, tile)
    end

    local roomsInput, result, labels = _floorLayout(houseID, floor)
    if not roomsInput or not result then
        if self.countLabel then self.countLabel:SetText("") end
        return
    end

    if self.countLabel then
        local n = 0
        for _ in pairs(roomsInput) do n = n + 1 end
        local total = #Store.GetRoomsForHouse(houseID)
        self.countLabel:SetText(string.format(
            "F%d: |cffffffff%d|r rooms · |cff8c8c8chouse %d|r", floor, n, total))
    end

    -- Cell override: any room with cell.locked replaces auto-layout's pick.
    local manual = _manualCells(houseID, floor)
    for roomID, mc in pairs(manual) do
        if result.layout[roomID] then
            result.layout[roomID].cell = { x = mc.x, y = mc.y }
        end
    end

    -- Rotation override: rotation persists on room.cell.rotation regardless
    -- of whether the cell itself was auto-laid-out or manually placed.
    -- Render reads it directly from room.cell to apply SetRotation.
    local rotations = {}
    do
        local floorID = IDs.makeFloorID(houseID, floor)
        if floorID then
            for _, e in ipairs(Store.GetRoomsForFloor(floorID)) do
                rotations[e.id] = (e.room.cell and e.room.cell.rotation) or 0
            end
        end
    end

    -- Track rendered cells for collision checks during Plan-mode drops.
    self._renderedCells = {}
    -- Cache the resolved connections so the Reflect-mode "Connections"
    -- menu can read each door's current partner without re-running layout.
    self._lastConnections = result.connections
    self._lastRoomsInput  = roomsInput
    self._lastLabels      = labels

    local isPlanMode = self.mode == "plan"

    -- 1. Render rooms — every room is a uniform TILE_PX × TILE_PX tile.
    for roomID, info in pairs(result.layout) do
        local entry = roomsInput[roomID]
        local atlas = (ShapesDB and ShapesDB.GetAtlas(entry.shape)) or Atlas.GetAtlas(entry.shape)

        local btn = self.pools.rooms[roomID]
        if not btn then
            btn = CreateFrame("Button", nil, self.host)
            btn.tex   = btn:CreateTexture(nil, "ARTWORK")
            btn.tex:SetAllPoints()
            btn.label = btn:CreateFontString(nil, "OVERLAY", "GameFontWhiteSmall")
            btn.label:SetPoint("BOTTOM", btn, "BOTTOM", 0, 4)
            btn.lockMark = btn:CreateTexture(nil, "OVERLAY")
            btn.lockMark:SetSize(14, 14)
            btn.lockMark:SetPoint("TOPLEFT", btn, "TOPLEFT", 4, -4)
            btn.lockMark:SetAtlas("communities-icon-lock", false)
            btn.lockMark:SetVertexColor(0.96, 0.80, 0.36, 1)
            btn.lockMark:Hide()
            btn:SetMovable(true)
            self.pools.rooms[roomID] = btn
        end

        local capturedID = roomID

        -- In Reflect mode, click opens the room side panel. In Plan mode
        -- left-click is reserved for drag-to-position and right-click cycles
        -- the room's rotation through 0|1|2|3 quarter-turns.
        if isPlanMode then
            btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
            btn:SetScript("OnClick", function(b, button)
                if button == "RightButton" then self:_ShowRoomMenu(b, capturedID) end
            end)
            btn:RegisterForDrag("LeftButton")
            btn:SetScript("OnDragStart", function(b) b:StartMoving() end)
            btn:SetScript("OnDragStop", function(b)
                b:StopMovingOrSizing()
                self:_DropRoom(capturedID, b, tile)
            end)
        else
            btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
            btn:SetScript("OnClick", function(b, button)
                if button == "RightButton" then
                    self:_ShowConnectionsMenu(b, capturedID)
                else
                    if self.onRoomClick then self.onRoomClick(capturedID) end
                end
            end)
            btn:RegisterForDrag()
            btn:SetScript("OnDragStart", nil)
            btn:SetScript("OnDragStop",  nil)
        end

        btn:SetSize(tile, tile)
        btn:ClearAllPoints()
        btn:SetPoint("CENTER", self.host, "CENTER",
            info.cell.x * tile,
            -info.cell.y * tile)

        if atlas then
            btn.tex:SetAtlas(atlas)
            btn.tex:SetVertexColor(1, 1, 1, 1)
            btn.tex:SetTexCoord(0, 1, 0, 1)
        else
            btn.tex:SetTexture(nil)
            btn.tex:SetColorTexture(0.18, 0.30, 0.45, 1)
            btn.tex:SetTexCoord(0, 1, 0, 1)
        end

        -- Rotation: tiles are square so SetRotation can't clip. Quarter-
        -- turn maps to multiples of π/2.
        btn.tex:SetRotation((rotations[roomID] or 0) * (math.pi / 2))
        btn.label:SetText(labels and labels[roomID] or roomID)
        if btn.lockMark then
            if manual[roomID] then btn.lockMark:Show() else btn.lockMark:Hide() end
        end
        btn:Show()

        self._renderedCells[roomID] = { x = info.cell.x, y = info.cell.y }
    end

    -- 2. Open-door orbs at wall midpoints (doorCardinals minus consumed
    --    connections). In Reflect mode each orb is a clickable Button —
    --    clicking it opens a context menu of compatible rooms (same floor,
    --    matching opposite cardinal, also unfilled). Selecting one writes
    --    a Connection_Assert override that wins over the cardinal-matching
    --    algorithm on next refresh.
    local openOrbs = {}  -- list of { roomID, dir, label } for menu population
    local orbIdx = 0
    for roomID, entry in pairs(roomsInput) do
        local layoutInfo = result.layout[roomID]
        if layoutInfo then
            local total = {}
            for _, d in ipairs(entry.doorCardinals or entry.occupiedCardinals or {}) do
                total[d] = (total[d] or 0) + 1
            end
            local used = {}
            for _, c in ipairs(result.connections) do
                if c.fromID == roomID then used[c.fromDir] = (used[c.fromDir] or 0) + 1 end
                if c.toID   == roomID then used[c.toDir]   = (used[c.toDir]   or 0) + 1 end
            end
            for dir, n in pairs(total) do
                local unfilled = n - (used[dir] or 0)
                for _ = 1, unfilled do
                    orbIdx = orbIdx + 1
                    local orb = self.pools.orbs[orbIdx]
                    if not orb then
                        orb = CreateFrame("Button", nil, self.host)
                        orb:SetSize(ORB_SIZE, ORB_SIZE)
                        orb.tex = orb:CreateTexture(nil, "OVERLAY")
                        orb.tex:SetAllPoints()
                        orb.tex:SetColorTexture(0.96, 0.80, 0.36, 1)
                        self.pools.orbs[orbIdx] = orb
                    end
                    local off = DIR_OFFSET[dir] or { 0, 0 }
                    orb:ClearAllPoints()
                    orb:SetPoint("CENTER", self.host, "CENTER",
                        (layoutInfo.cell.x + off[1]) * tile,
                        -(layoutInfo.cell.y + off[2]) * tile)
                    orb._roomID = roomID
                    orb._dir    = dir
                    orb._label  = labels and labels[roomID] or roomID
                    if isPlanMode then
                        orb:RegisterForClicks()
                        orb:SetScript("OnClick", nil)
                    else
                        orb:RegisterForClicks("LeftButtonUp")
                        orb:SetScript("OnClick", function(b)
                            self:_AssertConnectionMenu(b, b._roomID, b._dir)
                        end)
                    end
                    orb:Show()
                    openOrbs[#openOrbs + 1] = orb
                end
            end
        end
    end
    self._openOrbs = openOrbs
end

-- Render the floor below the current one as a dimmed atlas-only backdrop.
-- Anchored so the lower floor's stair-leaf (the room with stairs going up)
-- aligns with the current floor's stair-leaf, giving cross-floor positional
-- continuity. If either floor lacks a stair-leaf, fall back to plain (0,0)-
-- aligned overlay (better than nothing).
function M:_RenderBackdrop(houseID, lowerFloor, tile)
    local lowerInput, lowerResult = _floorLayout(houseID, lowerFloor)
    if not lowerInput or not lowerResult then return end

    -- Apply manual cell overrides to the lower floor so a Plan-mode-fixed
    -- F1 layout shows correctly behind F2.
    local lowerManual = _manualCells(houseID, lowerFloor)
    for roomID, mc in pairs(lowerManual) do
        if lowerResult.layout[roomID] then
            lowerResult.layout[roomID].cell = { x = mc.x, y = mc.y }
        end
    end

    -- Find stair-leaf in both floors and compute alignment offset so the
    -- lower floor's stair sits under the upper floor's stair.
    local offsetX, offsetY = 0, 0
    local upperInput, upperResult = _floorLayout(houseID, lowerFloor + 1)
    if upperInput and upperResult then
        -- Upper floor also needs its manual overrides for the alignment
        -- math (we read the rendered upper stair cell, not the auto one).
        local upperManual = _manualCells(houseID, lowerFloor + 1)
        for roomID, mc in pairs(upperManual) do
            if upperResult.layout[roomID] then
                upperResult.layout[roomID].cell = { x = mc.x, y = mc.y }
            end
        end

        local lowerStairID = _findStairLeaf(lowerInput)
        local upperStairID = _findStairLeaf(upperInput)
        if lowerStairID and upperStairID then
            local lowerCell = lowerResult.layout[lowerStairID]
                              and lowerResult.layout[lowerStairID].cell
            local upperCell = upperResult.layout[upperStairID]
                              and upperResult.layout[upperStairID].cell
            if lowerCell and upperCell then
                offsetX = upperCell.x - lowerCell.x
                offsetY = upperCell.y - lowerCell.y
            end
        end
    end

    for roomID, info in pairs(lowerResult.layout) do
        local entry = lowerInput[roomID]
        local atlas = (ShapesDB and ShapesDB.GetAtlas(entry.shape)) or Atlas.GetAtlas(entry.shape)

        local btn = self.pools.backdrop[roomID]
        if not btn then
            btn = CreateFrame("Frame", nil, self.host)
            btn.tex = btn:CreateTexture(nil, "BACKGROUND")
            btn.tex:SetAllPoints()
            self.pools.backdrop[roomID] = btn
        end

        btn:SetSize(tile, tile)
        btn:ClearAllPoints()
        btn:SetPoint("CENTER", self.host, "CENTER",
            (info.cell.x + offsetX) * tile,
            -(info.cell.y + offsetY) * tile)

        if atlas then
            btn.tex:SetAtlas(atlas)
            btn.tex:SetTexCoord(0, 1, 0, 1)
            btn.tex:SetVertexColor(0.5, 0.55, 0.7, 0.28)   -- dim cool tint
        else
            btn.tex:SetTexture(nil)
            btn.tex:SetColorTexture(0.18, 0.30, 0.45, 0.25)
        end
        btn:Show()
    end
end

privateDB.HDG_ArchitectCanvas = M
