-- UI.lua - the table window and all widgets, driven by Engine.state.
local ADDON, ns = ...
local UI = ns.UI
local Engine = ns.Engine
local PHASE = ns.PHASE

local FRAME_W, FRAME_H = 740, 500
local CW, CH, STEP = 34, 68, 21   -- card width/height and overlap step
local UITEX = "Interface\\AddOns\\" .. ADDON .. "\\Textures\\UI\\"

-- ─── small builders ────────────────────────────────────────────────────────
-- A thin gold/colour frame drawn from 4 edge textures (crisp at any size,
-- unlike a stretched border texture). Returns the 4 textures for later toggling.
local function AddBorder(f, r, g, b, a, t)
    t = t or 1
    local function edge()
        local e = f:CreateTexture(nil, "BORDER")
        e:SetColorTexture(r, g, b, a)
        return e
    end
    local top, bot = edge(), edge()
    local left, right = edge(), edge()
    top:SetPoint("TOPLEFT");    top:SetPoint("TOPRIGHT");    top:SetHeight(t)
    bot:SetPoint("BOTTOMLEFT"); bot:SetPoint("BOTTOMRIGHT"); bot:SetHeight(t)
    left:SetPoint("TOPLEFT");   left:SetPoint("BOTTOMLEFT");  left:SetWidth(t)
    right:SetPoint("TOPRIGHT"); right:SetPoint("BOTTOMRIGHT"); right:SetWidth(t)
    return { top, bot, left, right }
end

local function SetBorderShown(list, shown)
    if not list then return end
    for _, e in ipairs(list) do e:SetShown(shown) end
end

local function Panel(parent, w, h, r, g, b, a)
    local f = CreateFrame("Frame", nil, parent)
    if w then f:SetSize(w, h) end
    local bg = f:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(true)
    bg:SetColorTexture(r or 0, g or 0, b or 0, a or 0.6)
    f.bg = bg
    return f
end

local function FS(parent, size, layer)
    local t = parent:CreateFontString(nil, layer or "OVERLAY", "GameFontHighlight")
    if size then t:SetFont("Fonts\\FRIZQT__.TTF", size, "OUTLINE") end
    return t
end

local function Button(parent, w, h, text)
    local b = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    b:SetSize(w, h)
    b:SetText(text)
    return b
end

-- A reusable hand: overlapping card textures + a total label.
local function NewHand(parent)
    local h = CreateFrame("Frame", nil, parent)
    h:SetSize(CW, CH)
    h.pool = {}
    -- cw/ch/step optional: smaller cards when two split hands share a seat.
    function h:SetCards(cards, hideIndex, cw, ch, step)
        cw, ch, step = cw or CW, ch or CH, step or STEP
        cards = cards or {}
        for _, tex in ipairs(self.pool) do tex:Hide() end
        for i, code in ipairs(cards) do
            local tex = self.pool[i]
            if not tex then
                tex = self:CreateTexture(nil, "ARTWORK")
                self.pool[i] = tex
            end
            tex:SetSize(cw, ch)
            tex:ClearAllPoints()
            tex:SetPoint("TOPLEFT", self, "TOPLEFT", (i - 1) * step, 0)
            ns.Cards.Apply(tex, (hideIndex and i == hideIndex) and nil or code)
            tex:Show()
        end
        local n = #cards
        self:SetSize(n > 0 and ((n - 1) * step + cw) or cw, ch)
    end
    return h
end

-- ─── build the window ──────────────────────────────────────────────────────
local frame, seats, dealerHand, dealerTotal, statusFS, logFS, bankrollFS
local controls = {}
local cashDesk    -- the Cash Desk overlay panel (built lazily)
local lobby       -- the entry lobby overlay: open-table list + host button
local lobbyActive = false   -- whether the lobby overlay is currently driving discovery

-- Refresh the Cash Desk: chip balance from the host ledger, local stats, and
-- (for the dealer) the house cashier with per-player credit/collect rows.
local function UpdateCashDesk()
    if not cashDesk or not ns.db then return end
    local cd = cashDesk
    local state = Engine.state
    local me = ns.Me()

    if state then
        cd.balance:SetText("Your chips: |cffffd700" .. Engine.Balance(me) .. "g|r")
    else
        cd.balance:SetText("|cffaaaaaaOpen or join a table to buy in.|r")
    end

    local st = ns.db.stats
    cd.hands:SetText("Hands played: " .. st.hands)
    cd.wagered:SetText("Total wagered: |cffffffff" .. st.wagered .. "g|r")
    cd.won:SetText("Total won: |cff44ff44" .. st.won .. "g|r")
    cd.lost:SetText("Total lost: |cffff5555" .. st.lost .. "g|r")
    local profit = st.won - st.lost
    cd.profit:SetText("Net profit: " .. (profit >= 0 and "|cff44ff44+" or "|cffff5555") .. profit .. "g|r")

    -- Cashier section is only meaningful for the dealer (host) at a live table.
    local showCashier = state ~= nil and Engine.IsHost()
    for _, w in ipairs(cd.cashierWidgets) do w:SetShown(showCashier) end

    if showCashier then
        cd.house:SetText("House float: |cffffd700" .. (state.house.float or 0)
            .. "g|r   Tips: |cffffd700" .. (state.house.tips or 0) .. "g|r")
        -- One clickable row per seated player (bots included, for testing).
        local seated = {}
        for i = 1, ns.MAX_SEATS do
            if state.seats[i] then seated[#seated + 1] = state.seats[i] end
        end
        for r = 1, 5 do
            local b = cd.rows[r]
            local seat = seated[r]
            if seat then
                b.pname = seat.name
                local bal = Engine.Balance(seat.name)
                b:SetText(ns.ShortName(seat.name) .. ":  " .. bal .. "g"
                    .. (seat.isBot and "  (practice)" or ""))
                b:Show()
            else
                b.pname = nil
                b:Hide()
            end
        end
    else
        for r = 1, 5 do cd.rows[r]:Hide() end
    end
end

local function BuildSeat(parent, index)
    local s = Panel(parent, 138, 168, 0.02, 0.06, 0.04, 0.45)
    AddBorder(s, 0.55, 0.45, 0.22, 0.45)              -- static subtle gold trim
    s.hi = AddBorder(s, 1.0, 0.84, 0.30, 0.95, 2)     -- active-turn highlight
    SetBorderShown(s.hi, false)
    s.name = FS(s, 12); s.name:SetPoint("TOP", 0, -6)
    s.hand1 = NewHand(s)
    s.hand2 = NewHand(s)
    s.total  = FS(s, 12); s.total:SetPoint("BOTTOM", 0, 38)    -- hand value (hand 1 when split)
    s.total2 = FS(s, 12)                                        -- hand 2 value when split
    s.balance = FS(s, 11); s.balance:SetPoint("BOTTOM", 0, 6)   -- chip balance, distinct style
    s.bet = FS(s, 11); s.bet:SetPoint("BOTTOM", 0, 22)          -- betting prompt only
    s.result = FS(s, 12); s.result:SetPoint("BOTTOM", 0, 38)
    s.side = FS(s, 10); s.side:SetPoint("BOTTOM", 0, 52)        -- side-bet stake / win
    s.sit = Button(s, 70, 20, "Sit Here")
    s.sit:SetPoint("CENTER", 0, 0)
    s.sit:SetScript("OnClick", function() Engine.Intent("sit", { seat = index }) end)

    -- Physical bet chips in the betting circle just above the seat, with a
    -- fade-in when a wager is placed.
    s.chips = CreateFrame("Frame", nil, s)
    s.chips:SetSize(44, 56)
    s.chips:SetPoint("BOTTOM", s, "TOP", 0, 0)
    s.chips.tex = {}
    for c = 1, 6 do
        local t = s.chips:CreateTexture(nil, "ARTWORK")
        t:SetSize(22, 22)
        t:SetTexture(UITEX .. "chip.tga")
        t:SetPoint("BOTTOM", s.chips, "BOTTOM", 0, 14 + (c - 1) * 5)   -- stacked upward
        t:Hide()
        s.chips.tex[c] = t
    end
    s.chips.amount = FS(s.chips, 11); s.chips.amount:SetPoint("BOTTOM", s.chips, "BOTTOM", 0, 0)
    s.chips.ag = s.chips:CreateAnimationGroup()
    local a = s.chips.ag:CreateAnimation("Alpha")
    a:SetFromAlpha(0); a:SetToAlpha(1); a:SetDuration(0.3); a:SetSmoothing("OUT")
    s.chips:Hide()
    s.shownBet = 0
    return s
end

-- A labelled editbox + action button row. onAccept(amount) is called on click.
local function MoneyRow(parent, y, label, btnText, onAccept)
    local fs = FS(parent, 12); fs:SetPoint("TOPLEFT", 16, y); fs:SetText(label)
    local box = CreateFrame("EditBox", nil, parent, "InputBoxTemplate")
    box:SetSize(70, 20); box:SetAutoFocus(false); box:SetNumeric(true)
    box:SetPoint("TOPLEFT", 120, y + 2); box:SetText("100")
    local btn = Button(parent, 80, 22, btnText)
    btn:SetPoint("TOPLEFT", 200, y + 3)
    btn:SetScript("OnClick", function()
        local amt = tonumber(box:GetText()) or 0
        if amt <= 0 then return end
        onAccept(amt)
    end)
    return box
end

local function sep(parent, y)
    local t = parent:CreateTexture(nil, "ARTWORK")
    t:SetPoint("TOPLEFT", 16, y); t:SetPoint("TOPRIGHT", -16, y)
    t:SetHeight(1); t:SetColorTexture(0.3, 0.4, 0.3, 0.7)
    return t
end

local function BuildCashDesk(parent)
    local cd = Panel(parent, 380, 462, 0.03, 0.05, 0.04, 0.98)
    cd:SetPoint("CENTER")
    cd:SetFrameStrata("DIALOG")
    cd:EnableMouse(true)
    AddBorder(cd, 0.85, 0.70, 0.32, 0.9, 2)

    local title = FS(cd, 15); title:SetPoint("TOP", 0, -10); title:SetText("|cffffd700Cash Desk|r")
    local close = CreateFrame("Button", nil, cd, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", -2, -2)
    close:SetScript("OnClick", function() cd:Hide() end)

    cd.balance = FS(cd, 13); cd.balance:SetPoint("TOP", 0, -34)
    cd.note = FS(cd, 11); cd.note:SetPoint("TOP", 0, -54)
    cd.note:SetText("|cff999999Chips are managed by the dealer at this table.|r")

    -- Players may tip the dealer from their own chips (not a self-credit).
    cd.tipBox = MoneyRow(cd, -74, "Tip Dealer:", "Tip", function(amt)
        if Engine.state then Engine.Intent("tip", { amount = amt }) end
    end)

    sep(cd, -104)
    local histTitle = FS(cd, 12); histTitle:SetPoint("TOPLEFT", 16, -112)
    histTitle:SetText("|cffaaccaaYour session history|r")
    cd.hands   = FS(cd, 12); cd.hands:SetPoint("TOPLEFT", 16, -130)
    cd.wagered = FS(cd, 12); cd.wagered:SetPoint("TOPLEFT", 16, -148)
    cd.won     = FS(cd, 12); cd.won:SetPoint("TOPLEFT", 16, -166)
    cd.lost    = FS(cd, 12); cd.lost:SetPoint("TOPLEFT", 16, -184)
    cd.profit  = FS(cd, 12); cd.profit:SetPoint("TOPLEFT", 16, -202)

    -- Dealer cashier (host only): manage each player's chips manually.
    cd.cashierSep = sep(cd, -226)
    cd.cashierTitle = FS(cd, 12); cd.cashierTitle:SetPoint("TOPLEFT", 16, -234)
    cd.cashierTitle:SetText("|cffffd700Cashier — manage player chips|r")
    cd.house = FS(cd, 11); cd.house:SetPoint("TOPLEFT", 16, -252)
    cd.cashierHint = FS(cd, 11); cd.cashierHint:SetPoint("TOPLEFT", 16, -268)
    cd.cashierHint:SetText("|cff999999Click a player, set an amount, then Credit or Collect.|r")

    -- Clickable list of seated players (fills the target field below).
    cd.rows = {}
    for r = 1, 5 do
        local b = Button(cd, 348, 20, "")
        b:SetPoint("TOPLEFT", 16, -286 - (r - 1) * 22)
        b:SetScript("OnClick", function()
            if b.pname then cd.targetBox:SetText(b.pname); cd.targetBox:SetCursorPosition(0) end
        end)
        cd.rows[r] = b
    end

    -- Target player + amount, then Credit / Collect (works for any name typed).
    cd.targetLabel = FS(cd, 11); cd.targetLabel:SetPoint("TOPLEFT", 16, -402); cd.targetLabel:SetText("Player:")
    cd.targetBox = CreateFrame("EditBox", nil, cd, "InputBoxTemplate")
    cd.targetBox:SetSize(118, 20); cd.targetBox:SetAutoFocus(false)
    cd.targetBox:SetPoint("TOPLEFT", 62, -400)
    cd.amtLabel = FS(cd, 11); cd.amtLabel:SetPoint("TOPLEFT", 192, -402); cd.amtLabel:SetText("Amount:")
    cd.adjBox = CreateFrame("EditBox", nil, cd, "InputBoxTemplate")
    cd.adjBox:SetSize(56, 20); cd.adjBox:SetAutoFocus(false); cd.adjBox:SetNumeric(true)
    cd.adjBox:SetPoint("TOPLEFT", 246, -400); cd.adjBox:SetText("100")

    cd.creditBtn = Button(cd, 90, 22, "Credit chips")
    cd.creditBtn:SetPoint("TOPLEFT", 16, -424)
    cd.creditBtn:SetScript("OnClick", function()
        local target = cd.targetBox:GetText()
        if target and target ~= "" then Engine.DealerCredit(target, tonumber(cd.adjBox:GetText()) or 0) end
    end)
    cd.collectBtn = Button(cd, 90, 22, "Collect chips")
    cd.collectBtn:SetPoint("TOPLEFT", 112, -424)
    cd.collectBtn:SetScript("OnClick", function()
        local target = cd.targetBox:GetText()
        if target and target ~= "" then Engine.DealerCollect(target, tonumber(cd.adjBox:GetText()) or 0) end
    end)

    cd.cashierWidgets = { cd.cashierSep, cd.cashierTitle, cd.house, cd.cashierHint,
        cd.targetLabel, cd.targetBox, cd.amtLabel, cd.adjBox, cd.creditBtn, cd.collectBtn }

    cd:Hide()
    cashDesk = cd
    return cd
end

local PHASE_LABEL = {
    idle = "closed", betting = "betting", dealing = "dealing",
    player = "in play", dealer = "dealer's turn", payout = "payout",
}

-- Entry lobby: a branded overlay listing the open tables in your group, with a
-- button to host your own. Fully covers the table behind it until you join.
local pollDiscover   -- forward decl (self-rescheduling discovery while shown)

local function BuildLobby(parent)
    local w = CreateFrame("Frame", nil, parent)
    w:SetAllPoints(parent)
    w:SetFrameStrata("FULLSCREEN_DIALOG")
    w:EnableMouse(true)               -- swallow clicks meant for the table behind
    w:EnableKeyboard(false)

    -- dark veil + the felt scene faded behind it, for ambiance
    local veil = w:CreateTexture(nil, "BACKGROUND")
    veil:SetAllPoints(true)
    veil:SetColorTexture(0.02, 0.03, 0.03, 0.97)
    local scene = w:CreateTexture(nil, "BACKGROUND", nil, 1)
    scene:SetPoint("TOPLEFT", 4, -4); scene:SetPoint("BOTTOMRIGHT", -4, 4)
    scene:SetTexture(UITEX .. "felt.tga")
    scene:SetAlpha(0.40)
    local darken = w:CreateTexture(nil, "BACKGROUND", nil, 2)
    darken:SetAllPoints(scene)
    darken:SetColorTexture(0, 0, 0, 0.55)
    AddBorder(w, 0.85, 0.70, 0.32, 0.9, 2)

    -- close the whole window from the lobby
    local close = CreateFrame("Button", nil, w, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", -4, -4)
    close:SetScript("OnClick", function() if frame then frame:Hide() end end)

    -- title block
    local title = FS(w, 40); title:SetPoint("TOP", 0, -26)
    title:SetText("|cffffd700Blackjack|r |cffe8c46cby Ash|r")
    local sub = FS(w, 15); sub:SetPoint("TOP", title, "BOTTOM", 0, -10)
    sub:SetText("|cffaaccaaOpen tables|r")
    local rule = w:CreateTexture(nil, "ARTWORK")
    rule:SetPoint("TOP", sub, "BOTTOM", 0, -8); rule:SetSize(440, 1)
    rule:SetColorTexture(0.6, 0.5, 0.28, 0.7)

    -- one clickable row per discovered table
    w.rows = {}
    for i = 1, 6 do
        local b = Button(w, 440, 26, "")
        b:SetPoint("TOP", rule, "BOTTOM", 0, -10 - (i - 1) * 30)
        local fs = b:GetFontString()
        if fs then
            fs:ClearAllPoints(); fs:SetPoint("LEFT", 12, 0); fs:SetPoint("RIGHT", -12, 0)
            fs:SetJustifyH("LEFT")
        end
        b:SetScript("OnClick", function()
            if b.host then
                Engine.JoinTable(b.host)
                UI.HideLobby()
            end
        end)
        b:Hide()
        w.rows[i] = b
    end

    -- empty-state hint (shown when no tables are listed)
    w.empty = FS(w, 13); w.empty:SetPoint("TOP", rule, "BOTTOM", 0, -50)
    w.empty:SetText("|cff999999No open tables in your group.\nHost one below, or click Refresh.|r")
    w.empty:SetJustifyH("CENTER")

    -- action buttons
    local hostBtn = Button(w, 150, 30, "Host a Table")
    hostBtn:SetPoint("BOTTOM", -85, 42)
    hostBtn:SetScript("OnClick", function()
        Engine.OpenTable()
        UI.HideLobby()
    end)
    local refreshBtn = Button(w, 110, 30, "Refresh")
    refreshBtn:SetPoint("BOTTOM", 85, 42)
    refreshBtn:SetScript("OnClick", function()
        if Engine.RequestDiscover then Engine.RequestDiscover() end
        UI.RefreshLobby()
    end)

    w.hint = FS(w, 11); w.hint:SetPoint("BOTTOM", 0, 24)
    w.hint:SetText("|cff777777Join the dealer's party or raid to see their table.|r")
    local version = FS(w, 10); version:SetPoint("BOTTOMRIGHT", -8, 8)
    version:SetText("|cff777777v" .. (ns.VERSION or "?") .. "|r")

    w:Hide()
    lobby = w
    return w
end

-- Rebuild the table list from the discovered lobby (Engine.lobby).
function UI.RefreshLobby()
    if not lobby then return end
    local now = (GetTime and GetTime()) or nil
    local list = {}
    for host, e in pairs(Engine.lobby or {}) do
        -- best-effort prune of stale tables (only when GetTime is available)
        if now and e.lastSeen and e.lastSeen > 0 and (now - e.lastSeen) > 12 then
            Engine.lobby[host] = nil
        else
            list[#list + 1] = e
        end
    end
    table.sort(list, function(a, b) return (a.host or "") < (b.host or "") end)
    for i = 1, #lobby.rows do
        local row = lobby.rows[i]
        local e = list[i]
        if e then
            row.host = e.host
            local ph = PHASE_LABEL[e.phase] or e.phase or "?"
            row:SetText("|cffffffff" .. ns.ShortName(e.host) .. "|r   |cffaaaaaa("
                .. (e.seated or 0) .. "/" .. (e.max or ns.MAX_SEATS) .. " seated · " .. ph .. ")|r")
            row:Show()
        else
            row.host = nil
            row:Hide()
        end
    end
    lobby.empty:SetShown(#list == 0)
end

-- Self-rescheduling discovery: re-poll for tables every few seconds while the
-- lobby is on screen, so newly opened tables appear without a manual refresh.
pollDiscover = function()
    if not lobbyActive then return end
    if Engine.RequestDiscover then Engine.RequestDiscover() end
    UI.RefreshLobby()
    if C_Timer and C_Timer.After then C_Timer.After(4, pollDiscover) end
end

function UI.ShowLobby()
    if not frame or not lobby then return end
    lobby:Show()
    UI.RefreshLobby()
    if not lobbyActive then
        lobbyActive = true
        pollDiscover()    -- kick off discovery + periodic refresh
    end
end

function UI.HideLobby()
    lobbyActive = false
    if lobby then lobby:Hide() end
end

local function BuildUI()
    if not ns.db then ns.InitDB() end   -- never rely on init ordering
    frame = CreateFrame("Frame", "BlackjackTableFrame", UIParent, "BackdropTemplate")
    frame:SetSize(FRAME_W, FRAME_H)
    frame:SetPoint("CENTER")
    frame:SetMovable(true); frame:EnableMouse(true); frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", frame.StartMoving)
    frame:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        if not ns.db then return end
        local p, _, rp, x, y = self:GetPoint()
        ns.db.framePos = { p, rp, x, y }
    end)
    frame:SetClampedToScreen(true)

    local bg = frame:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(true)
    bg:SetColorTexture(0.03, 0.04, 0.04, 0.96)
    -- felt table art (rail + radial felt + dealer arc)
    local felt = frame:CreateTexture(nil, "BACKGROUND", nil, 1)
    felt:SetPoint("TOPLEFT", 4, -4); felt:SetPoint("BOTTOMRIGHT", -4, 4)
    felt:SetTexture(UITEX .. "felt.tga")

    -- title bar
    local title = FS(frame, 15); title:SetPoint("TOP", 0, -10)
    title:SetText("|cffffd700Blackjack|r |cffe8c46cby Ash|r")
    local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", -4, -4)
    close:SetScript("OnClick", function() frame:Hide() end)

    -- dealer
    local dealerLabel = FS(frame, 12); dealerLabel:SetPoint("TOP", 0, -34)
    dealerLabel:SetText("|cffcfcfcfDealer|r")
    dealerHand = NewHand(frame)
    dealerTotal = FS(frame, 13); dealerTotal:SetPoint("TOP", dealerLabel, "BOTTOM", 0, -78)

    -- seats row
    seats = {}
    for i = 1, ns.MAX_SEATS do
        seats[i] = BuildSeat(frame, i)
        seats[i]:SetPoint("TOPLEFT", 16 + (i - 1) * 142, -176)
    end

    -- status + log
    statusFS = FS(frame, 12); statusFS:SetPoint("TOP", 0, -352)
    logFS = FS(frame, 11); logFS:SetPoint("TOPLEFT", 16, -370)
    logFS:SetJustifyH("LEFT"); logFS:SetWidth(FRAME_W - 32); logFS:SetJustifyV("TOP")
    logFS:SetHeight(48); logFS:SetTextColor(0.8, 0.85, 0.8)

    -- control bar
    local chip = frame:CreateTexture(nil, "ARTWORK")
    chip:SetSize(20, 20); chip:SetPoint("BOTTOMLEFT", 14, 12)
    chip:SetTexture(UITEX .. "chip.tga")
    bankrollFS = FS(frame, 12); bankrollFS:SetPoint("LEFT", chip, "RIGHT", 5, 0)

    controls.open = Button(frame, 120, 24, "Open Table")
    controls.open:SetPoint("CENTER", 0, 0)
    controls.open:SetScript("OnClick", function() Engine.OpenTable() end)

    controls.deal = Button(frame, 90, 24, "Deal")
    controls.deal:SetPoint("BOTTOM", -150, 14)
    controls.deal:SetScript("OnClick", function() Engine.HostAction("deal") end)

    controls.next = Button(frame, 110, 24, "Next Round")
    controls.next:SetPoint("BOTTOM", -150, 14)
    controls.next:SetScript("OnClick", function() Engine.HostAction("next") end)

    -- Betting row: main bet + two optional side bets (Perfect Pairs, 21+3).
    local function NumBox(w, default)
        local b = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
        b:SetSize(w, 22); b:SetAutoFocus(false); b:SetNumeric(true); b:SetText(default)
        return b
    end
    controls.betLabel = FS(frame, 11); controls.betLabel:SetPoint("BOTTOM", -150, 20)
    controls.betLabel:SetText("Bet:")
    controls.betBox = NumBox(50, "10")
    controls.betBox:SetPoint("LEFT", controls.betLabel, "RIGHT", 4, -2)
    controls.ppLabel = FS(frame, 11); controls.ppLabel:SetPoint("LEFT", controls.betBox, "RIGHT", 12, 2)
    controls.ppLabel:SetText("|cffd8b4f8Pair:|r")
    controls.ppBox = NumBox(40, "0")
    controls.ppBox:SetPoint("LEFT", controls.ppLabel, "RIGHT", 4, -2)
    controls.ttLabel = FS(frame, 11); controls.ttLabel:SetPoint("LEFT", controls.ppBox, "RIGHT", 12, 2)
    controls.ttLabel:SetText("|cffd8b4f821+3:|r")
    controls.ttBox = NumBox(40, "0")
    controls.ttBox:SetPoint("LEFT", controls.ttLabel, "RIGHT", 4, -2)
    controls.bet = Button(frame, 54, 24, "Place")
    controls.bet:SetPoint("LEFT", controls.ttBox, "RIGHT", 10, 1)
    controls.bet:SetScript("OnClick", function()
        local amount = tonumber(controls.betBox:GetText()) or ns.MIN_BET
        local pp = tonumber(controls.ppBox:GetText()) or 0
        local tt = tonumber(controls.ttBox:GetText()) or 0
        if (amount + pp + tt) > Engine.Balance(ns.Me()) then
            ns.Print("Not enough chips for that wager (" .. (amount + pp + tt)
                .. "g total). Ask the dealer to credit you at the Cash Desk.")
            return
        end
        Engine.Intent("bet", { amount = amount, pp = pp, tt = tt })
    end)

    controls.hit = Button(frame, 60, 24, "Hit");    controls.hit:SetPoint("BOTTOM", -90, 14)
    controls.stand = Button(frame, 60, 24, "Stand"); controls.stand:SetPoint("BOTTOM", -26, 14)
    controls.double = Button(frame, 70, 24, "Double");controls.double:SetPoint("BOTTOM", 42, 14)
    controls.split = Button(frame, 60, 24, "Split");  controls.split:SetPoint("BOTTOM", 114, 14)
    -- Returns the bet of our currently active hand, or nil. Used to check that
    -- the player can afford a double / split top-up.
    local function activeHandBet()
        local s = Engine.state
        if not s or not s.active then return nil end
        local seat = s.seats[s.active.seat]
        if not seat or seat.name ~= ns.Me() then return nil end
        local h = seat.hands and seat.hands[s.active.hand]
        return h and h.bet or nil
    end
    local function affordTopup()
        local bet = activeHandBet()
        if bet and bet > Engine.Balance(ns.Me()) then
            ns.Print("Not enough chips for that (needs " .. bet .. "g more). Buy in at the Cash Desk.")
            return false
        end
        return true
    end

    controls.hit:SetScript("OnClick", function() Engine.Intent("hit") end)
    controls.stand:SetScript("OnClick", function() Engine.Intent("stand") end)
    controls.double:SetScript("OnClick", function() if affordTopup() then Engine.Intent("double") end end)
    controls.split:SetScript("OnClick", function() if affordTopup() then Engine.Intent("split") end end)

    -- Dealer (host) controls the house hand manually during the dealer's turn.
    controls.dealerHit = Button(frame, 90, 24, "Dealer Hit")
    controls.dealerHit:SetPoint("BOTTOM", -60, 14)
    controls.dealerHit:SetScript("OnClick", function() Engine.HostAction("dealerHit") end)
    controls.dealerStand = Button(frame, 90, 24, "Dealer Stand")
    controls.dealerStand:SetPoint("BOTTOM", 40, 14)
    controls.dealerStand:SetScript("OnClick", function() Engine.HostAction("dealerStand") end)

    -- Insurance window: players decide, the host can force the round onward.
    controls.insure = Button(frame, 90, 24, "Insurance")
    controls.insure:SetPoint("BOTTOM", -50, 14)
    controls.insure:SetScript("OnClick", function() Engine.Intent("insure") end)
    controls.decline = Button(frame, 100, 24, "No Insurance")
    controls.decline:SetPoint("BOTTOM", 50, 14)
    controls.decline:SetScript("OnClick", function() Engine.Intent("declineInsurance") end)
    controls.proceed = Button(frame, 110, 24, "Continue")
    controls.proceed:SetPoint("BOTTOM", -150, 14)
    controls.proceed:SetScript("OnClick", function() Engine.HostAction("proceed") end)

    controls.leave = Button(frame, 90, 24, "Leave Seat")
    controls.leave:SetPoint("BOTTOMRIGHT", -16, 14)
    controls.leave:SetScript("OnClick", function() Engine.Intent("leave") end)

    controls.closeTable = Button(frame, 90, 24, "Close Table")
    controls.closeTable:SetPoint("BOTTOMRIGHT", -112, 14)
    controls.closeTable:SetScript("OnClick", function() Engine.CloseTable() end)

    controls.cashDesk = Button(frame, 100, 22, "Cash Desk")
    controls.cashDesk:SetPoint("TOPLEFT", 12, -10)
    controls.cashDesk:SetScript("OnClick", function()
        if cashDesk:IsShown() then cashDesk:Hide()
        else UpdateCashDesk(); cashDesk:Show() end
    end)

    controls.addBot = Button(frame, 110, 22, "Add Player")
    controls.addBot:SetPoint("TOPLEFT", controls.cashDesk, "TOPRIGHT", 6, 0)
    controls.addBot:SetScript("OnClick", function() Engine.AddBot() end)

    -- Clients use this to leave the table and return to the lobby (hosts use
    -- "Close Table" instead, which closes it for everyone).
    controls.lobby = Button(frame, 110, 22, "Leave Table")
    controls.lobby:SetPoint("TOPLEFT", controls.cashDesk, "TOPRIGHT", 6, 0)
    controls.lobby:SetScript("OnClick", function() Engine.LeaveTable() end)

    -- Dealer-only toggle: offer Perfect Pairs / 21+3 at this table (off = classic).
    controls.sideToggle = CreateFrame("CheckButton", nil, frame, "UICheckButtonTemplate")
    controls.sideToggle:SetSize(22, 22)
    controls.sideToggle:SetPoint("TOPLEFT", controls.addBot, "TOPRIGHT", 8, 0)
    controls.sideToggle:SetScript("OnClick", function(self)
        Engine.SetSideBets(self:GetChecked())
    end)
    controls.sideToggleLabel = FS(frame, 11)
    controls.sideToggleLabel:SetPoint("LEFT", controls.sideToggle, "RIGHT", 2, 0)
    controls.sideToggleLabel:SetText("Side bets")

    BuildCashDesk(frame)
    BuildLobby(frame)

    -- Stop lobby discovery polling when the window is closed.
    frame:HookScript("OnHide", function() UI.HideLobby() end)

    if ns.db and ns.db.framePos then
        local p, rp, x, y = unpack(ns.db.framePos)
        frame:ClearAllPoints(); frame:SetPoint(p, UIParent, rp, x, y)
    end

    frame:Hide()
    UI.frame = frame
end

-- ─── bankroll reconciliation (local, per round) ────────────────────────────
-- Bets (including doubles / splits) are deducted from the local bankroll as
-- soon as they are committed for our seat; payouts are credited once at payout.
local acctBet = {}      -- [round] = total bet already deducted for my seat
local paidRound = {}    -- [round] = true once the payout has been credited
local lastRound = 0     -- detects a fresh table (round counter goes backwards)
local function Reconcile(state)
    if not state or not ns.db then return end
    local round = state.round
    if not round or round == 0 then return end
    if round < lastRound then        -- a new table started: clear stale accounting
        acctBet = {}; paidRound = {}
    end
    lastRound = round
    local me = ns.Me()
    local mySeat
    for _, seat in pairs(state.seats) do
        if seat.name == me then mySeat = seat end
    end
    if not mySeat then return end

    -- Chip balances are tracked by the host ledger now; here we only accumulate
    -- the local player's personal session statistics.
    if state.phase ~= PHASE.BETTING and mySeat.hands then
        local total = 0
        for _, h in ipairs(mySeat.hands) do total = total + (h.bet or 0) end
        local already = acctBet[round] or 0
        if total > already then
            ns.db.stats.wagered = ns.db.stats.wagered + (total - already)
            acctBet[round] = total
        end
    end

    if state.phase == PHASE.PAYOUT and mySeat.result and not paidRound[round] then
        paidRound[round] = true
        for _, r in pairs(mySeat.result) do
            if r.payout > r.bet then ns.db.stats.won = ns.db.stats.won + (r.payout - r.bet)
            elseif r.payout < r.bet then ns.db.stats.lost = ns.db.stats.lost + (r.bet - r.payout) end
        end
        ns.db.stats.hands = ns.db.stats.hands + 1
    end
end

-- ─── render ────────────────────────────────────────────────────────────────
local RESULT_COLOR = {
    win = "|cff44ff44", blackjack = "|cffffd700", lose = "|cffff5555", push = "|cffcccccc",
}
local RESULT_TEXT = {
    win = "WIN +", blackjack = "BLACKJACK +", lose = "LOSE", push = "PUSH",
}

local function Hideall(...)
    for i = 1, select("#", ...) do select(i, ...):Hide() end
end

function UI.Render(state)
    if not frame then return end
    Reconcile(state)
    UpdateCashDesk()

    -- Lobby vs table: the lobby overlay covers the window whenever we're not at
    -- a table (hosting or joined). Render drives the switch on every update.
    if frame:IsShown() then
        if state then UI.HideLobby() else UI.ShowLobby() end
    end

    -- No active table.
    if not state then
        dealerHand:SetCards({})
        dealerTotal:SetText("")
        for i = 1, ns.MAX_SEATS do seats[i]:Hide() end
        statusFS:SetText("")
        logFS:SetText("")
        Hideall(controls.deal, controls.next, controls.bet, controls.betBox, controls.betLabel,
            controls.ppBox, controls.ppLabel, controls.ttBox, controls.ttLabel,
            controls.hit, controls.stand, controls.double, controls.split, controls.leave, controls.closeTable,
            controls.dealerHit, controls.dealerStand, controls.addBot, controls.open, controls.lobby,
            controls.sideToggle, controls.sideToggleLabel,
            controls.insure, controls.decline, controls.proceed)
        bankrollFS:SetText("")
        return
    end

    controls.open:Hide()
    local me = ns.Me()
    local amHost = Engine.IsHost()
    local phase = state.phase

    -- dealer rule: must draw on 16 or less (and soft 17 only if configured)
    local dealerMustHit = false
    if phase == PHASE.DEALER_TURN then
        local dt, dsoft = ns.HandValue(state.dealer.cards)
        dealerMustHit = dt < 17 or (dt == 17 and dsoft and ns.DEALER_HITS_SOFT_17)
    end

    -- dealer
    local hideIndex = state.dealer.holeHidden and 2 or nil
    dealerHand:SetCards(state.dealer.cards, hideIndex)
    local dw = dealerHand:GetWidth()
    dealerHand:ClearAllPoints()
    dealerHand:SetPoint("TOP", frame, "TOP", -dw / 2, -52)
    if #state.dealer.cards == 0 then
        dealerTotal:SetText("")
    elseif state.dealer.holeHidden then
        dealerTotal:SetText("Showing " .. ns.HandValueText({ state.dealer.cards[1] }))
    else
        dealerTotal:SetText("|cffffffff" .. ns.HandValueText(state.dealer.cards) .. "|r")
    end

    -- my seat / turn
    local mySeatIndex
    for i = 1, ns.MAX_SEATS do
        if state.seats[i] and state.seats[i].name == me then mySeatIndex = i end
    end
    local isMyTurn = phase == PHASE.PLAYER_TURN and state.active
        and state.seats[state.active.seat] and state.seats[state.active.seat].name == me

    -- seats
    for i = 1, ns.MAX_SEATS do
        local seat = state.seats[i]
        local sf = seats[i]
        sf:Show()
        if not seat then
            sf.name:SetText("|cff777777Seat " .. i .. "|r")
            sf.hand1:SetCards({}); sf.hand2:SetCards({})
            sf.total:SetText(""); sf.total2:SetText(""); sf.bet:SetText("")
            sf.result:SetText(""); sf.balance:SetText(""); sf.side:SetText("")
            sf.chips:Hide(); sf.shownBet = 0
            SetBorderShown(sf.hi, false)
            if phase == PHASE.BETTING and not mySeatIndex then sf.sit:Show() else sf.sit:Hide() end
        else
            sf.sit:Hide()
            local isActiveSeat = state.active and state.active.seat == i and phase == PHASE.PLAYER_TURN
            SetBorderShown(sf.hi, isActiveSeat)
            local nameColor = (seat.name == me) and "|cff5ec7e5" or "|cffffffff"
            sf.name:SetText(nameColor .. ns.ShortName(seat.name) .. "|r")

            local h1 = seat.hands and seat.hands[1]
            local h2 = seat.hands and seat.hands[2]
            local function handLabel(hh, hi)
                local txt = ns.HandValueText(hh.cards)
                if isActiveSeat and state.active.hand == hi then return "|cffffd700> " .. txt .. " <|r" end
                return "|cffdddddd" .. txt .. "|r"
            end

            sf.hand1:ClearAllPoints(); sf.hand2:ClearAllPoints(); sf.total2:SetText("")
            if h2 then
                -- Split: two smaller hands side by side, each with its own total.
                sf.hand1:SetCards(h1.cards, nil, 22, 44, 13); sf.hand1:SetPoint("TOPLEFT", 8, -24)
                sf.hand2:SetCards(h2.cards, nil, 22, 44, 13); sf.hand2:SetPoint("TOPRIGHT", -8, -24)
                if phase ~= PHASE.PAYOUT then
                    sf.total:ClearAllPoints();  sf.total:SetPoint("BOTTOMLEFT", 8, 38)
                    sf.total2:ClearAllPoints(); sf.total2:SetPoint("BOTTOMRIGHT", -8, 38)
                    sf.total:SetText(handLabel(h1, 1)); sf.total2:SetText(handLabel(h2, 2))
                else
                    sf.total:SetText("")
                end
            else
                sf.hand1:SetCards(h1 and h1.cards or {}); sf.hand1:SetPoint("TOPLEFT", 6, -26)
                sf.hand2:SetCards({})
                sf.total:ClearAllPoints(); sf.total:SetPoint("BOTTOM", 0, 38)
                sf.total:SetText((h1 and #h1.cards > 0 and phase ~= PHASE.PAYOUT) and handLabel(h1, 1) or "")
            end

            -- chip balance: distinct cyan label, set apart from the gold bet chips
            sf.balance:SetText("|cff7fd5ffChips |r|cffffffff" .. Engine.Balance(seat.name) .. "g|r")

            -- side bets / insurance: show the win once resolved, else the stakes
            local sideTxt = ""
            local sr = seat.sideResult
            local ir = seat.insuranceResult
            if phase ~= PHASE.BETTING and ((sr and (sr.pp or sr.tt)) or ir) then
                local wins = {}
                if sr and sr.pp and sr.pp.outcome == "win" then wins[#wins + 1] = "PP +" .. (sr.pp.payout - sr.pp.bet) .. "g" end
                if sr and sr.tt and sr.tt.outcome == "win" then wins[#wins + 1] = "21+3 +" .. (sr.tt.payout - sr.tt.bet) .. "g" end
                if ir and ir.outcome == "win" then wins[#wins + 1] = "Ins +" .. (ir.payout - ir.bet) .. "g" end
                if #wins > 0 then sideTxt = "|cffffd700" .. table.concat(wins, "  ") .. "|r" end
            elseif (seat.sidePP or 0) > 0 or (seat.side213 or 0) > 0 then
                local parts = {}
                if (seat.sidePP or 0) > 0 then parts[#parts + 1] = "PP " .. seat.sidePP end
                if (seat.side213 or 0) > 0 then parts[#parts + 1] = "21+3 " .. seat.side213 end
                sideTxt = "|cffd8b4f8" .. table.concat(parts, " · ") .. "|r"
            end
            sf.side:SetText(sideTxt)

            -- physical bet chips (fade in when a wager first appears)
            local betShown = (seat.committed and seat.bet > 0) or
                (seat.hands and seat.hands[1] and phase ~= PHASE.PAYOUT and phase ~= PHASE.BETTING)
            local betAmt = 0
            if seat.hands and seat.hands[1] then
                for _, h in ipairs(seat.hands) do betAmt = betAmt + (h.bet or 0) end
            elseif seat.committed then
                betAmt = seat.bet
            end
            if betShown and betAmt > 0 then
                local n = math.min(6, math.max(1, math.ceil(betAmt / 100)))
                for c = 1, 6 do sf.chips.tex[c]:SetShown(c <= n) end
                sf.chips.amount:SetText("|cffffd700" .. betAmt .. "g|r")
                if sf.shownBet == 0 then sf.chips.ag:Stop(); sf.chips.ag:Play() end
                sf.chips:Show()
                sf.shownBet = betAmt
            else
                sf.chips:Hide()
                sf.shownBet = 0
            end

            -- bottom line: betting prompt, or the insurance decision status
            if phase == PHASE.BETTING and not (seat.committed and seat.bet > 0) then
                sf.bet:SetText(seat.name == me and "|cffaaaaaaplace your bet|r" or "|cff888888waiting…|r")
            elseif phase == PHASE.INSURANCE and seat.hands and #seat.hands > 0 then
                if not seat.insuranceDone then
                    sf.bet:SetText("|cffaaaaaadeciding…|r")
                elseif (seat.insurance or 0) > 0 then
                    sf.bet:SetText("|cff7fd5ffinsured " .. seat.insurance .. "g|r")
                else
                    sf.bet:SetText("|cff888888no insurance|r")
                end
            else
                sf.bet:SetText("")
            end

            if phase == PHASE.PAYOUT and seat.result then
                local parts = {}
                for hi, r in ipairs(seat.result) do
                    local c = RESULT_COLOR[r.outcome] or "|cffffffff"
                    local profit = r.payout - r.bet
                    local txt = RESULT_TEXT[r.outcome]
                    if r.outcome == "win" or r.outcome == "blackjack" then txt = txt .. profit .. "g" end
                    parts[#parts + 1] = c .. txt .. "|r"
                end
                sf.result:SetText(table.concat(parts, "  "))
            else
                sf.result:SetText("")
            end
        end
    end

    -- status line
    if phase == PHASE.BETTING then
        local anySeated = false
        for i = 1, ns.MAX_SEATS do if state.seats[i] then anySeated = true end end
        if amHost and not anySeated then
            statusFS:SetText("|cffffd700You are the dealer. Click 'Add Player' for a practice bot, or wait for players to sit.|r")
        else
            statusFS:SetText("|cff9fe09fBetting — place your bets" .. (amHost and ", then Deal." or ".") .. "|r")
        end
    elseif phase == PHASE.DEALING then
        statusFS:SetText("|cffddddddDealing…|r")
    elseif phase == PHASE.INSURANCE then
        statusFS:SetText("|cffffd700Dealer shows an Ace — insurance? Pays 2:1 if the dealer has Blackjack."
            .. (amHost and " Click 'Continue' when ready." or "") .. "|r")
    elseif phase == PHASE.PLAYER_TURN then
        local who = state.active and state.seats[state.active.seat]
        statusFS:SetText(isMyTurn and "|cffffd700Your turn.|r"
            or ("|cffdddddd" .. (who and ns.ShortName(who.name) or "?") .. " is playing…|r"))
    elseif phase == PHASE.DEALER_TURN then
        if amHost then
            local t = ns.HandValueText(state.dealer.cards)
            statusFS:SetText(dealerMustHit
                and ("|cffffd700Dealer on " .. t .. " — you must draw (16 or less).|r")
                or ("|cffffd700Dealer on " .. t .. " — you must stand (17 or more).|r"))
        else
            statusFS:SetText("|cffddddddThe dealer is playing…|r")
        end
    elseif phase == PHASE.PAYOUT then
        statusFS:SetText("|cff9fe09fRound complete." .. (amHost and " Click 'Next Round'." or "") .. "|r")
    else
        statusFS:SetText("")
    end

    -- log (last few lines)
    local log = state.log or {}
    local lines = {}
    for i = math.max(1, #log - 3), #log do lines[#lines + 1] = log[i] end
    logFS:SetText(table.concat(lines, "\n"))

    -- controls
    local committedCount = 0
    for i = 1, ns.MAX_SEATS do
        if state.seats[i] and state.seats[i].committed and state.seats[i].bet > 0 then committedCount = committedCount + 1 end
    end

    local freeSeat = false
    for i = 1, ns.MAX_SEATS do if not state.seats[i] then freeSeat = true end end

    controls.closeTable:SetShown(amHost)
    controls.lobby:SetShown(not amHost)   -- clients can leave back to the lobby
    controls.deal:SetShown(amHost and phase == PHASE.BETTING and committedCount >= 1)
    controls.next:SetShown(amHost and phase == PHASE.PAYOUT)
    -- Rule-enforced: only the correct action is offered.
    controls.dealerHit:SetShown(amHost and phase == PHASE.DEALER_TURN and dealerMustHit)
    controls.dealerStand:SetShown(amHost and phase == PHASE.DEALER_TURN and not dealerMustHit)
    controls.addBot:SetShown(amHost and phase == PHASE.BETTING and freeSeat)

    -- Dealer-only side-bet switch (reflects the synced table setting).
    controls.sideToggle:SetShown(amHost)
    controls.sideToggleLabel:SetShown(amHost)
    controls.sideToggle:SetChecked(state.sideBets and true or false)

    local canBet = (mySeatIndex and phase == PHASE.BETTING) and true or false
    local showSide = canBet and state.sideBets and true or false
    controls.betBox:SetShown(canBet); controls.betLabel:SetShown(canBet); controls.bet:SetShown(canBet)
    controls.ppBox:SetShown(showSide); controls.ppLabel:SetShown(showSide)
    controls.ttBox:SetShown(showSide); controls.ttLabel:SetShown(showSide)
    controls.leave:SetShown(mySeatIndex ~= nil and phase == PHASE.BETTING)

    controls.hit:SetShown(isMyTurn)
    controls.stand:SetShown(isMyTurn)
    -- double / split availability
    local canDouble, canSplit = false, false
    if isMyTurn then
        local seat = state.seats[state.active.seat]
        local h = seat.hands[state.active.hand]
        canDouble = (#h.cards == 2)
        if #h.cards == 2 and #seat.hands < 2 then
            local v1 = ns.HandValue({ h.cards[1] })
            local v2 = ns.HandValue({ h.cards[2] })
            canSplit = (v1 == v2)
        end
    end
    controls.double:SetShown(canDouble)
    controls.split:SetShown(canSplit)

    -- Insurance window: seated player who hasn't decided gets Insure/Decline;
    -- the host gets a Continue button to move the round on.
    local mySeat = mySeatIndex and state.seats[mySeatIndex]
    local canInsure = phase == PHASE.INSURANCE and mySeat ~= nil
        and mySeat.committed and mySeat.hands and #mySeat.hands > 0 and not mySeat.insuranceDone
    controls.insure:SetShown(canInsure)
    controls.decline:SetShown(canInsure)
    controls.proceed:SetShown(amHost and phase == PHASE.INSURANCE)

    -- Bottom-left readout: the dealer sees the house; a player sees their chips.
    if amHost and not mySeatIndex then
        bankrollFS:SetText("House: |cffffd700" .. (state.house and state.house.float or 0)
            .. "g|r  Tips: |cffffd700" .. (state.house and state.house.tips or 0) .. "g|r")
    else
        bankrollFS:SetText("Your chips: |cffffd700" .. Engine.Balance(me) .. "g|r")
    end
end

function UI.Toggle()
    if not frame then BuildUI() end
    if frame:IsShown() then
        frame:Hide()
    else
        frame:Show()
        UI.Render(Engine.state)   -- Render shows the lobby or the table as needed
    end
end

function UI.Show()
    if not frame then BuildUI() end
    frame:Show()
    UI.Render(Engine.state)
end

function UI.Build()
    if not frame then BuildUI() end
end
