-- Engine.lua - host-authoritative Blackjack game logic + intent handling.
local ADDON, ns = ...
local Engine = ns.Engine
local PHASE = ns.PHASE

-- The local view of the game. On the host this is authoritative; on a client
-- it is the last state received from the host.
Engine.state = nil

-- Multi-table support. A client must explicitly JOIN a table before it adopts
-- that host's state; until then it only collects the lobby listing.
Engine.joinedHost = nil   -- host name of the table we've joined (nil = in the lobby)
Engine.lobby = {}         -- [hostName] = { host, phase, seated, max, lastSeen, full }

-- ─── Role helpers ──────────────────────────────────────────────────────────
function Engine.IsHost()
    local s = Engine.state
    return s ~= nil and s.host == ns.Me()
end

-- A network-safe view of the state: drop the shoe (clients must not see it and
-- it is huge) and keep only the tail of the log.
local function PublicState(s)
    local pub = {}
    for k, v in pairs(s) do
        if k ~= "shoe" and k ~= "bank" then pub[k] = v end
    end
    -- Slim the bank: clients only need each player's balance for display (the
    -- host keeps the full ledger locally for the cashier). Smaller = fewer chunks.
    if s.bank then
        local b = {}
        for name, e in pairs(s.bank) do b[name] = { balance = e.balance } end
        pub.bank = b
    end
    if s.log then
        -- The client only shows the last couple of lines; sending fewer keeps the
        -- broadcast small (fewer chunks = less rate-limit pressure / latency).
        -- Non-addon followers still get the full narration in chat.
        local tail = {}
        for i = math.max(1, #s.log - 1), #s.log do tail[#tail + 1] = s.log[i] end
        pub.log = tail
    end
    return pub
end

-- Called after any host-side mutation: broadcast + re-render locally.
local function Changed()
    if Engine.IsHost() then
        ns.Comm.BroadcastState(PublicState(Engine.state))
    end
    if ns.UI and ns.UI.Render then ns.UI.Render(Engine.state) end
end
Engine.Changed = Changed

-- ─── Shoe ──────────────────────────────────────────────────────────────────
-- The shoe is a DETERMINISTIC, seeded permutation shared by every client (built
-- from a /roll the whole group witnessed). We never mutate it: a cursor tracks
-- how many cards have been drawn, so the original order survives for clients to
-- reconstruct hands locally from (seed, cursor) — no card identities on the wire.
local function shoeRemaining(s)
    return (s.shoe and (#s.shoe - (s.cursor or 0))) or 0
end

-- Build a shoe right now from a local seed (fallback / solo / safety net). The
-- shared, witnessed seed arrives asynchronously via RollNewShoe + OnSeedRoll.
local function localShoe(s)
    s.seed = math.random(1, ns.SEED_ROLL_MAX)
    s.shoe = ns.NewShoeSeeded(s.seed)
    s.cursor = 0
end

-- Guarantee a usable shoe exists before a deal (synchronous). Reshuffles for a
-- fresh SHARED seed happen at round boundaries (OpenTable / NextRound), not here.
local function EnsureShoe(s)
    if not s.shoe or shoeRemaining(s) <= 0 then localShoe(s) end
end

local function Draw(s)
    s.cursor = (s.cursor or 0) + 1
    if not s.shoe or s.cursor > #s.shoe then localShoe(s); s.cursor = 1 end
    return s.shoe[s.cursor]
end

-- Host asks for a fresh shared seed by rolling: the result is witnessed by the
-- whole group (reliable, off the addon channel) and lands in OnSeedRoll. Only
-- adopted between rounds so we never swap the shoe mid-hand.
local awaitingSeed = false
function Engine.RollNewShoe()
    if not Engine.IsHost() then return end
    awaitingSeed = true
    if RandomRoll then
        pcall(RandomRoll, 1, ns.SEED_ROLL_MAX)
    else
        -- No roll API: seed locally so the table still works (loses provably-fair).
        Engine.OnSeedRoll(ns.Me(), math.random(1, ns.SEED_ROLL_MAX), 1, ns.SEED_ROLL_MAX)
    end
end

-- Every client sees the system roll; only the host acts on its OWN seed roll,
-- and only while betting (a safe point to (re)build the shoe). The new seed is
-- then broadcast in state so clients rebuild the identical shoe.
function Engine.OnSeedRoll(sender, value, low, high)
    if not Engine.IsHost() or not awaitingSeed then return end
    if sender ~= ns.Me() or not value then return end
    if high and high ~= ns.SEED_ROLL_MAX then return end   -- not our shoe-seed roll
    awaitingSeed = false
    local s = Engine.state
    if not s or s.phase ~= PHASE.BETTING then return end
    s.seed = value
    s.shoe = ns.NewShoeSeeded(value)
    s.cursor = 0
    Changed()
end

-- ─── Table lifecycle ───────────────────────────────────────────────────────
function Engine.OpenTable()
    Engine.joinedHost = nil               -- a host runs its own table, not a joined one
    Engine.state = {
        host = ns.Me(),
        phase = PHASE.BETTING,
        seats = {},
        dealer = { cards = {}, holeHidden = true },
        round = 0,
        seed = 0, cursor = 0,               -- shared deterministic shoe (set below)
        log = {},
        bank = {},                          -- [playerName] = chip ledger entry
        house = { float = 0, tips = 0 },    -- gold held by the house + tips taken
        sideBets = (ns.db and ns.db.sideBets) or false,   -- Perfect Pairs / 21+3 offered?
    }
    localShoe(Engine.state)        -- provisional shoe so the table works instantly
    Engine.RollNewShoe()           -- upgrade to a shared, witnessed seed (async)
    Engine.Log("Table is now open. Take a seat and place your bet.")
    ns.Print("You are now the |cffffd700dealer|r. Wait for players to sit, or click "
        .. "|cffffd700Add Player|r to add a practice bot, then |cffffd700Deal|r.")
    Changed()
end

function Engine.CloseTable()
    local s = Engine.state
    if s and Engine.IsHost() then
        -- Tell everyone explicitly the table is gone so clients return to the
        -- lobby (a bare phase change isn't enough — clients must drop the state).
        s.phase = PHASE.IDLE
        s.closed = true
        Engine.Say("The table has closed.")   -- logs locally + narrates to chat
        -- Send the "closed" state now and a couple more times: there is no
        -- heartbeat once the table is gone, so resending guards against a lost
        -- chunk leaving a client stranded at a dead table.
        local pub = PublicState(s)
        ns.Comm.BroadcastState(pub)
        if C_Timer and C_Timer.After then
            C_Timer.After(0.6, function() ns.Comm.BroadcastState(pub) end)
            C_Timer.After(1.6, function() ns.Comm.BroadcastState(pub) end)
        end
    end
    Engine.state = nil
    Engine.joinedHost = nil
    if ns.UI and ns.UI.Render then ns.UI.Render(nil) end
end

-- ─── Lobby / table discovery (clients) ─────────────────────────────────────
local function lobbySummary(state)
    local seated = 0
    if state.seats then
        for i = 1, ns.MAX_SEATS do if state.seats[i] then seated = seated + 1 end end
    end
    return {
        host = state.host, phase = state.phase, seated = seated, max = ns.MAX_SEATS,
        lastSeen = (GetTime and GetTime()) or 0, full = state,
    }
end

-- Ask every host in the group to announce its table (populates the lobby).
function Engine.RequestDiscover()
    if IsInGroup() then ns.Comm.SendIntent("discover") end
end

-- Client joins a listed table: adopt its last-known state, then ask the host
-- for a fresh authoritative copy.
function Engine.JoinTable(host)
    if not host then return end
    Engine.joinedHost = host
    local entry = Engine.lobby[host]
    Engine.state = (entry and entry.full) or nil
    if IsInGroup() then ns.Comm.SendIntent("sync", nil, host) end
    if ns.UI and ns.UI.Render then ns.UI.Render(Engine.state) end
end

-- Client leaves the table it joined and returns to the lobby (frees its seat).
function Engine.LeaveTable()
    if Engine.state and not Engine.IsHost() and Engine.joinedHost then
        ns.Comm.SendIntent("leave", nil, Engine.joinedHost)
    end
    Engine.joinedHost = nil
    Engine.state = nil
    if ns.UI and ns.UI.Render then ns.UI.Render(nil) end
end

function Engine.Log(line)
    local s = Engine.state
    if not s then return end
    s.log = s.log or {}
    s.log[#s.log + 1] = line
    while #s.log > 12 do table.remove(s.log, 1) end
end

-- Like Log, but also narrates the event to the group chat so that players
-- WITHOUT the addon can follow the table (the "chat" multiplayer model).
-- Engine mutations only ever run on the host, so only the host announces.
function Engine.Say(line)
    Engine.Log(line)
    local ch = ns.AnnounceChannel()
    if ch and SendChatMessage then
        SendChatMessage(line, ch)
    end
end

-- Host toggles whether this table offers the Perfect Pairs / 21+3 side bets.
-- Off by default (a "classic" table). Disabling also clears any pending side
-- stakes so nothing is charged at the deal.
function Engine.SetSideBets(on)
    if not Engine.IsHost() then return end
    local s = Engine.state
    if not s then return end
    s.sideBets = on and true or false
    if ns.db then ns.db.sideBets = s.sideBets end
    if not s.sideBets then
        for i = 1, ns.MAX_SEATS do
            local seat = s.seats[i]
            if seat then seat.sidePP = 0; seat.side213 = 0 end
        end
    end
    Engine.Say("Side bets are now " .. (s.sideBets
        and "ENABLED (Perfect Pairs & 21+3)." or "disabled."))
    Changed()
end

-- ─── Cashier / chip ledger (host-authoritative) ────────────────────────────
-- Each player has a ledger entry; the house tracks total gold held + tips.
local function bankOf(s, name)
    s.bank = s.bank or {}
    local e = s.bank[name]
    if not e then
        e = { balance = 0, buyIn = 0, cashedOut = 0, tips = 0 }
        s.bank[name] = e
    end
    return e
end
Engine.bankOf = bankOf

function Engine.Balance(name)
    local s = Engine.state
    if not s or not s.bank or not s.bank[name] then return 0 end
    return s.bank[name].balance
end

-- Player hands real gold to the house and receives chips.
function Engine.BuyIn(name, amount)
    local s = Engine.state
    if not s then return end
    amount = math.floor(tonumber(amount) or 0)
    if amount <= 0 then return end
    local e = bankOf(s, name)
    e.balance = e.balance + amount
    e.buyIn = e.buyIn + amount
    s.house.float = s.house.float + amount
    Engine.Say(ns.ShortName(name) .. " buys in for " .. amount .. "g (balance " .. e.balance .. "g).")
    Changed()
end

-- Player returns chips and the house hands back gold.
function Engine.CashOut(name, amount)
    local s = Engine.state
    if not s then return end
    local e = bankOf(s, name)
    amount = math.min(math.floor(tonumber(amount) or 0), e.balance)
    if amount <= 0 then return end
    e.balance = e.balance - amount
    e.cashedOut = e.cashedOut + amount
    s.house.float = s.house.float - amount
    Engine.Say(ns.ShortName(name) .. " cashes out " .. amount .. "g (balance " .. e.balance .. "g).")
    Changed()
end

-- Player tips the dealer from their chips.
function Engine.TipDealer(name, amount)
    local s = Engine.state
    if not s then return end
    local e = bankOf(s, name)
    amount = math.min(math.floor(tonumber(amount) or 0), e.balance)
    if amount <= 0 then return end
    e.balance = e.balance - amount
    e.tips = e.tips + amount
    s.house.tips = s.house.tips + amount
    Engine.Say(ns.ShortName(name) .. " tips the dealer " .. amount .. "g. Thank you!")
    Changed()
end

-- Dealer manually gives chips to a player (a comp / correction).
function Engine.DealerCredit(target, amount)
    if not Engine.IsHost() then return end
    local s = Engine.state
    if not s then return end
    amount = math.floor(tonumber(amount) or 0)
    if amount <= 0 then return end
    local e = bankOf(s, target)
    e.balance = e.balance + amount
    Engine.Say("The dealer credits " .. ns.ShortName(target) .. " " .. amount
        .. "g (balance " .. e.balance .. "g).")
    Changed()
end

-- Dealer manually takes chips from a player.
function Engine.DealerCollect(target, amount)
    if not Engine.IsHost() then return end
    local s = Engine.state
    if not s then return end
    local e = bankOf(s, target)
    amount = math.min(math.floor(tonumber(amount) or 0), e.balance)
    if amount <= 0 then return end
    e.balance = e.balance - amount
    Engine.Say("The dealer collects " .. amount .. "g from " .. ns.ShortName(target)
        .. " (balance " .. e.balance .. "g).")
    Changed()
end

-- ─── Seating / betting (host applies) ──────────────────────────────────────
local function findSeatOf(s, name)
    for i, seat in pairs(s.seats) do
        if seat.name == name then return i end
    end
end

function Engine.Sit(name, seatIndex)
    local s = Engine.state
    if not s or s.phase ~= PHASE.BETTING then return end
    if s.seats[seatIndex] then return end          -- taken
    if findSeatOf(s, name) then return end          -- already seated
    s.seats[seatIndex] = { name = name, bet = 0, committed = false, hands = {} }
    bankOf(s, name)                       -- ensure a ledger entry exists
    Engine.Say(ns.ShortName(name) .. " takes seat " .. seatIndex .. ".")
    Changed()
end

function Engine.Leave(name)
    local s = Engine.state
    if not s then return end
    local i = findSeatOf(s, name)
    if i then
        s.seats[i] = nil
        Engine.Say(ns.ShortName(name) .. " leaves the table.")
        Changed()
    end
end

function Engine.PlaceBet(name, amount, pp, tt)
    local s = Engine.state
    if not s or s.phase ~= PHASE.BETTING then return end
    local i = findSeatOf(s, name)
    if not i then return end
    local seat = s.seats[i]
    amount = math.max(ns.MIN_BET, math.floor(tonumber(amount) or 0))
    pp = math.max(0, math.floor(tonumber(pp) or 0))     -- Perfect Pairs side bet
    tt = math.max(0, math.floor(tonumber(tt) or 0))     -- 21+3 side bet
    if not s.sideBets then pp, tt = 0, 0 end            -- side bets disabled at this table
    -- Real players can only wager what they hold in chips (bots are exempt and
    -- never place side bets).
    if not seat.isBot and (amount + pp + tt) > bankOf(s, name).balance then return end
    seat.bet = amount
    seat.sidePP = pp
    seat.side213 = tt
    seat.committed = true
    local extra = ""
    if pp > 0 or tt > 0 then
        local parts = {}
        if pp > 0 then parts[#parts + 1] = "Pair " .. pp .. "g" end
        if tt > 0 then parts[#parts + 1] = "21+3 " .. tt .. "g" end
        extra = " (+ " .. table.concat(parts, ", ") .. ")"
    end
    Engine.Say(ns.ShortName(name) .. " places a " .. amount .. "g bet" .. extra .. ".")
    Changed()
end

-- ─── Dealing ───────────────────────────────────────────────────────────────
local function committedSeats(s)
    local list = {}
    for i = 1, ns.MAX_SEATS do
        if s.seats[i] and s.seats[i].committed and s.seats[i].bet > 0 then
            list[#list + 1] = i
        end
    end
    return list
end

-- Resolve Perfect Pairs / 21+3 on the opening cards (player's two cards + the
-- dealer up card). Winnings are paid immediately; losing stakes were already
-- taken at the deal. Results are stored on the seat for the UI.
function Engine.ResolveSideBets(seats)
    local s = Engine.state
    if not s then return end
    local dUp = s.dealer.cards and s.dealer.cards[1]
    for _, i in ipairs(seats) do
        local seat = s.seats[i]
        local h = seat and not seat.isBot and seat.hands and seat.hands[1]
        if h then
            local c1, c2 = h.cards[1], h.cards[2]
            local res = nil
            local function settle(stake, cat, mult, family)
                if not res then res = {} end
                if cat then
                    local payout = stake * (mult + 1)
                    bankOf(s, seat.name).balance = bankOf(s, seat.name).balance + payout
                    res[family] = { outcome = "win", cat = cat, payout = payout, bet = stake }
                    Engine.Say(ns.ShortName(seat.name) .. " wins " .. (payout - stake) .. "g on "
                        .. ns.SIDE_LABEL[cat] .. " (" .. mult .. ":1)!")
                else
                    res[family] = { outcome = "lose", payout = 0, bet = stake }
                end
            end
            if (seat.sidePP or 0) > 0 then
                local cat, mult = ns.EvalPerfectPairs(c1, c2)
                settle(seat.sidePP, cat, mult, "pp")
            end
            if (seat.side213 or 0) > 0 then
                local cat, mult = ns.Eval21p3(c1, c2, dUp)
                settle(seat.side213, cat, mult, "tt")
            end
            seat.sideResult = res
        end
    end
end

-- Once every card is out: flag naturals, then start the first player's turn.
local function FinishDeal(seats)
    local s = Engine.state
    if not s then return end
    for _, i in ipairs(seats) do
        local seat = s.seats[i]
        local h = seat and seat.hands[1]
        if h then
            local _, _, bj = ns.HandValue(h.cards)
            if bj then h.done = true; h.blackjack = true end
        end
    end
    Engine.ResolveSideBets(seats)   -- pay/clear side bets on the opening cards
    Engine.Log("Cards dealt. Round " .. s.round .. ".")

    -- One compact chat line for followers (replaces the old per-card narration).
    local up = s.dealer.cards[1]
    if up then Engine.Say("Round " .. s.round .. " dealt. The dealer shows the " .. ns.CardName(up) .. ".") end

    -- Insurance: classic rule, offered whenever the dealer's up card is an Ace.
    if up and ns.CardRank(up) == "A" then
        s.phase = PHASE.INSURANCE
        s.active = nil
        for _, i in ipairs(seats) do
            local seat = s.seats[i]
            if seat then
                seat.insurance = 0
                seat.insuranceDone = seat.isBot and true or false   -- bots never insure
            end
        end
        Engine.Say("The dealer shows an Ace. Insurance is open — up to half your bet, pays 2:1.")
        Changed()
        Engine.MaybeEndInsurance()
        return
    end

    s.phase = PHASE.PLAYER_TURN
    s.active = nil
    Engine.advanceTurn(true)   -- may go straight to dealer if all naturals
    Changed()
end

-- ─── Insurance (dealer shows an Ace) ───────────────────────────────────────
local function allInsuranceDecided(s)
    for i = 1, ns.MAX_SEATS do
        local seat = s.seats[i]
        if seat and seat.committed and seat.hands and #seat.hands > 0 and not seat.insuranceDone then
            return false
        end
    end
    return true
end

-- Leave the insurance window and start the players' turns.
function Engine.ProceedFromInsurance()
    local s = Engine.state
    if not s or s.phase ~= PHASE.INSURANCE then return end
    s.phase = PHASE.PLAYER_TURN
    s.active = nil
    Engine.advanceTurn(true)
    Changed()
end

-- Host auto-advances once every seated player has decided.
function Engine.MaybeEndInsurance()
    local s = Engine.state
    if not s or s.phase ~= PHASE.INSURANCE or not Engine.IsHost() then return end
    if allInsuranceDecided(s) then Engine.ProceedFromInsurance() end
end

function Engine.Insure(name)
    local s = Engine.state
    if not s or s.phase ~= PHASE.INSURANCE then return end
    local i = findSeatOf(s, name)
    if not i then return end
    local seat = s.seats[i]
    if seat.isBot or seat.insuranceDone then return end
    local mainBet = (seat.hands and seat.hands[1] and seat.hands[1].bet) or seat.bet or 0
    local amount = math.min(math.floor(mainBet / 2), bankOf(s, name).balance)
    if amount > 0 then
        bankOf(s, name).balance = bankOf(s, name).balance - amount
        seat.insurance = amount
        Engine.Say(ns.ShortName(name) .. " takes insurance for " .. amount .. "g.")
    else
        seat.insurance = 0
    end
    seat.insuranceDone = true
    Changed()
    Engine.MaybeEndInsurance()
end

function Engine.DeclineInsurance(name)
    local s = Engine.state
    if not s or s.phase ~= PHASE.INSURANCE then return end
    local i = findSeatOf(s, name)
    if not i then return end
    local seat = s.seats[i]
    if seat.isBot or seat.insuranceDone then return end
    seat.insurance = 0
    seat.insuranceDone = true
    Engine.Say(ns.ShortName(name) .. " declines insurance.")
    Changed()
    Engine.MaybeEndInsurance()
end

function Engine.Deal()
    local s = Engine.state
    if not s or s.phase ~= PHASE.BETTING then return end
    local seats = committedSeats(s)
    if #seats == 0 then return end
    EnsureShoe(s)
    s.round = s.round + 1
    s.dealer = { cards = {}, holeHidden = false }

    -- One empty hand per committed seat; take the main wager AND any side bets
    -- from the player's chips up front (side bets resolve right after the deal).
    for _, i in ipairs(seats) do
        local seat = s.seats[i]
        seat.hands = { { cards = {}, bet = seat.bet, stood = false, doubled = false, done = false } }
        seat.result = nil
        seat.sideResult = nil
        if not seat.isBot then
            local total = seat.bet + (seat.sidePP or 0) + (seat.side213 or 0)
            bankOf(s, seat.name).balance = bankOf(s, seat.name).balance - total
        end
    end

    -- European "no hole card" deal: two cards to each player, but only ONE to
    -- the dealer. The dealer draws the rest after every player has acted. Same
    -- ordered sequence a client replays off the shared shoe (ns.DealSteps).
    local steps = ns.DealSteps(seats)

    s.phase = PHASE.DEALING
    Engine.Say("Dealing — Round " .. s.round .. ".")
    Changed()   -- show the empty table about to be dealt

    -- Hand cards out one at a time on a timer so the deal is animated.
    local idx = 0
    local function dealNext()
        -- Abort if the table was closed or a new round started underneath us.
        if Engine.state ~= s or s.phase ~= PHASE.DEALING then return end
        idx = idx + 1
        local step = steps[idx]
        if not step then
            FinishDeal(seats)
            return
        end
        -- Deal silently (no per-card chat — that stream saturated the channel).
        -- The host sees the animation locally; a compact summary is narrated once
        -- in FinishDeal so chat followers still get the key info.
        if step.kind == "seat" then
            local seat = s.seats[step.seat]
            if seat and seat.hands[1] then
                table.insert(seat.hands[1].cards, Draw(s))
            end
        else
            table.insert(s.dealer.cards, Draw(s))
        end
        -- Animate locally on the host only; do NOT broadcast per card (that burst
        -- is what overflowed the addon-message rate limit). Clients see the empty
        -- table from the deal-start broadcast, then jump to the finished hands
        -- when FinishDeal broadcasts. The host still narrates each card to chat.
        if ns.UI and ns.UI.Render then ns.UI.Render(s) end
        if C_Timer and C_Timer.After then
            C_Timer.After(ns.DEAL_INTERVAL, dealNext)
        else
            dealNext()   -- no timer API: fall back to instant deal
        end
    end
    dealNext()
end

-- ─── Turn progression ──────────────────────────────────────────────────────
-- Returns true if an actionable hand was found; false if we moved to dealer.
function Engine.advanceTurn(fromDeal)
    local s = Engine.state
    for i = 1, ns.MAX_SEATS do
        local seat = s.seats[i]
        if seat and seat.committed and seat.hands then
            for hi, h in ipairs(seat.hands) do
                if not h.done then
                    s.active = { seat = i, hand = hi }
                    if seat.isBot then Engine.ScheduleBot() end   -- auto-play bot hands
                    return true
                end
            end
        end
    end
    -- No actionable hands left -> dealer's turn.
    s.active = nil
    Engine.DealerPlay()
    return false
end

-- ─── Practice bot (lets a lone dealer run a full round) ────────────────────
local botPending = false

-- Drive the currently active bot hand: hit below 17, otherwise stand, paced on
-- a timer. Re-schedules itself while a bot still has the turn.
function Engine.ScheduleBot()
    if botPending then return end
    botPending = true
    local function tick()
        botPending = false
        local s = Engine.state
        if not s or s.phase ~= PHASE.PLAYER_TURN or not s.active then return end
        local seat = s.seats[s.active.seat]
        if not seat or not seat.isBot then return end
        local h = seat.hands[s.active.hand]
        if ns.HandValue(h.cards) < 17 then
            Engine.Hit(seat.name)
        else
            Engine.Stand(seat.name)
        end
        -- If a bot still has the turn (same hand after a hit, or a next bot
        -- after standing), keep going.
        local s2 = Engine.state
        if s2 and s2.phase == PHASE.PLAYER_TURN and s2.active then
            local nx = s2.seats[s2.active.seat]
            if nx and nx.isBot then Engine.ScheduleBot() end
        end
    end
    if C_Timer and C_Timer.After then C_Timer.After(ns.DEAL_INTERVAL, tick) else tick() end
end

local botCounter = 0

-- Host adds an automated practice player to a free seat.
function Engine.AddBot()
    if not Engine.IsHost() then return end
    local s = Engine.state
    if not s or s.phase ~= PHASE.BETTING then return end
    local idx
    for i = 1, ns.MAX_SEATS do
        if not s.seats[i] then idx = i; break end
    end
    if not idx then return end
    botCounter = botCounter + 1
    local name = "Practice " .. botCounter
    s.seats[idx] = { name = name, bet = 50, committed = true, hands = {}, isBot = true }
    Engine.Say(name .. " sits in seat " .. idx .. " and bets 50g.")
    Changed()
end

local function currentHand(s)
    if not s.active then return nil end
    local seat = s.seats[s.active.seat]
    if not seat then return nil end
    return seat, seat.hands[s.active.hand]
end

-- Verify the acting player owns the active hand (host-side validation).
local function actorOwnsTurn(s, name)
    if not s.active then return false end
    local seat = s.seats[s.active.seat]
    return seat ~= nil and seat.name == name
end

function Engine.Hit(name)
    local s = Engine.state
    if not s or s.phase ~= PHASE.PLAYER_TURN or not actorOwnsTurn(s, name) then return end
    local seat, h = currentHand(s)
    local card = Draw(s)
    table.insert(h.cards, card)
    local total = ns.HandValue(h.cards)
    Engine.Say(ns.ShortName(seat.name) .. " hits — the " .. ns.CardName(card)
        .. " (" .. ns.HandValueText(h.cards) .. ").")
    if total >= 21 then
        h.done = true
        if total > 21 then h.bust = true end
        Engine.advanceTurn()
    end
    Changed()
end

function Engine.Stand(name)
    local s = Engine.state
    if not s or s.phase ~= PHASE.PLAYER_TURN or not actorOwnsTurn(s, name) then return end
    local seat, h = currentHand(s)
    h.done = true; h.stood = true
    Engine.Say(ns.ShortName(seat.name) .. " stands on " .. ns.HandValueText(h.cards) .. ".")
    Engine.advanceTurn()
    Changed()
end

function Engine.Double(name)
    local s = Engine.state
    if not s or s.phase ~= PHASE.PLAYER_TURN or not actorOwnsTurn(s, name) then return end
    local seat, h = currentHand(s)
    if #h.cards ~= 2 then return end
    if not seat.isBot then bankOf(s, seat.name).balance = bankOf(s, seat.name).balance - h.bet end
    h.bet = h.bet * 2
    h.doubled = true
    local card = Draw(s)
    table.insert(h.cards, card)
    local total = ns.HandValue(h.cards)
    h.done = true
    if total > 21 then h.bust = true end
    Engine.Say(ns.ShortName(seat.name) .. " doubles down for " .. h.bet .. "g — the "
        .. ns.CardName(card) .. " (" .. ns.HandValueText(h.cards) .. ").")
    Engine.advanceTurn()
    Changed()
end

function Engine.Split(name)
    local s = Engine.state
    if not s or s.phase ~= PHASE.PLAYER_TURN or not actorOwnsTurn(s, name) then return end
    local seat, h = currentHand(s)
    if #h.cards ~= 2 or #seat.hands >= 2 then return end
    -- Split only on matching rank value (e.g. 10/J/Q/K all 10).
    local v1 = ns.HandValue({ h.cards[1] })
    local v2 = ns.HandValue({ h.cards[2] })
    if v1 ~= v2 then return end
    if not seat.isBot then bankOf(s, seat.name).balance = bankOf(s, seat.name).balance - h.bet end
    local moved = table.remove(h.cards)            -- second card -> new hand
    local newHand = { cards = { moved }, bet = h.bet, stood = false, doubled = false, done = false }
    table.insert(seat.hands, s.active.hand + 1, newHand)
    -- Draw one fresh card to each split hand.
    table.insert(h.cards, Draw(s))
    table.insert(newHand.cards, Draw(s))
    Engine.Say(ns.ShortName(seat.name) .. " splits into two hands.")
    -- Stay on the (now first) split hand; re-evaluate for 21.
    local total = ns.HandValue(h.cards)
    if total >= 21 then h.done = true; Engine.advanceTurn() end
    Changed()
end

-- ─── Dealer play + settlement ──────────────────────────────────────────────
-- True if any player hand is still live (not busted) and worth playing against.
local function anyLiveHand(s)
    for i = 1, ns.MAX_SEATS do
        local seat = s.seats[i]
        if seat and seat.hands then
            for _, h in ipairs(seat.hands) do
                if not h.bust then return true end
            end
        end
    end
    return false
end

local function anyInsurance(s)
    for i = 1, ns.MAX_SEATS do
        local seat = s.seats[i]
        if seat and (seat.insurance or 0) > 0 then return true end
    end
    return false
end

-- Begin the dealer's turn. The dealer holds a single up card; the HOST (the
-- croupier) now draws the rest manually via DealerHit / DealerStand.
function Engine.DealerPlay()
    local s = Engine.state
    s.phase = PHASE.DEALER_TURN
    s.dealer.holeHidden = false

    -- If every player busted, the house needn't draw — settle straight away.
    -- Exception: if insurance is live and the up card is an Ace, reveal one card
    -- to determine whether it's a dealer blackjack (so insurance can resolve).
    if not anyLiveHand(s) then
        if anyInsurance(s) and #s.dealer.cards == 1 and ns.CardRank(s.dealer.cards[1]) == "A" then
            local card = Draw(s)
            table.insert(s.dealer.cards, card)
            Engine.Say("The dealer checks for blackjack — the " .. ns.CardName(card)
                .. " (" .. ns.HandValueText(s.dealer.cards) .. ").")
        end
        Changed()
        Engine.Settle()
        return
    end
    Engine.Say("All players have acted — the dealer plays.")
    Changed()
end

-- Dealer must draw on 16 or less (and on soft 17 only if configured).
local function dealerMustHit(s)
    local total, soft = ns.HandValue(s.dealer.cards)
    return total < 17 or (total == 17 and soft and ns.DEALER_HITS_SOFT_17)
end

-- Host draws one card for the dealer (rule-enforced).
function Engine.DealerHit()
    local s = Engine.state
    if not s or s.phase ~= PHASE.DEALER_TURN then return end
    if not dealerMustHit(s) then return end   -- cannot draw on 17+
    local card = Draw(s)
    table.insert(s.dealer.cards, card)
    local total = ns.HandValue(s.dealer.cards)
    Engine.Say("The dealer draws the " .. ns.CardName(card)
        .. " (" .. ns.HandValueText(s.dealer.cards) .. ").")
    Changed()
    if total > 21 then
        Engine.Settle()
    end
end

-- Host stands the dealer and settles the round (only legal on 17+).
function Engine.DealerStand()
    local s = Engine.state
    if not s or s.phase ~= PHASE.DEALER_TURN then return end
    if dealerMustHit(s) then return end       -- must keep drawing below 17
    Engine.Settle()
end

-- Send a list of lines to group chat, one every 0.3s, so a burst of results
-- never trips Blizzard's chat throttle. (Logging already happened immediately.)
local function PaceToChat(lines, i)
    i = i or 1
    local line = lines[i]
    if not line then return end
    local ch = ns.AnnounceChannel()
    if ch and SendChatMessage then SendChatMessage(line, ch) end
    if C_Timer and C_Timer.After then
        C_Timer.After(0.3, function() PaceToChat(lines, i + 1) end)
    else
        PaceToChat(lines, i + 1)
    end
end

function Engine.Settle()
    local s = Engine.state
    local dTotal, _, dBJ = ns.HandValue(s.dealer.cards)
    local dBust = dTotal > 21
    local lines = {}
    local function note(line) Engine.Log(line); lines[#lines + 1] = line end

    if dBust then
        note("The dealer busts with " .. dTotal .. ".")
    else
        note("The dealer stands on " .. ns.HandValueText(s.dealer.cards) .. ".")
    end

    -- Insurance pays 2:1 when the dealer has a two-card blackjack (Ace + ten).
    for i = 1, ns.MAX_SEATS do
        local seat = s.seats[i]
        if seat and (seat.insurance or 0) > 0 then
            if dBJ then
                local pay = seat.insurance * 3      -- 2:1 profit + the stake back
                if not seat.isBot then bankOf(s, seat.name).balance = bankOf(s, seat.name).balance + pay end
                seat.insuranceResult = { outcome = "win", payout = pay, bet = seat.insurance }
                note(ns.ShortName(seat.name) .. "'s insurance pays " .. (pay - seat.insurance) .. "g.")
            else
                seat.insuranceResult = { outcome = "lose", payout = 0, bet = seat.insurance }
                note(ns.ShortName(seat.name) .. " loses insurance (" .. seat.insurance .. "g).")
            end
        end
    end

    for i = 1, ns.MAX_SEATS do
        local seat = s.seats[i]
        if seat and seat.hands and #seat.hands > 0 then
            seat.result = {}
            for hi, h in ipairs(seat.hands) do
                local total, _, bj = ns.HandValue(h.cards)
                local outcome, payout
                if total > 21 then
                    outcome, payout = "lose", 0
                elseif bj and not dBJ then
                    outcome, payout = "blackjack", h.bet * (1 + ns.BLACKJACK_PAYOUT)
                elseif dBJ and not bj then
                    outcome, payout = "lose", 0
                elseif dBust then
                    outcome, payout = "win", h.bet * 2
                elseif total > dTotal then
                    outcome, payout = "win", h.bet * 2
                elseif total < dTotal then
                    outcome, payout = "lose", 0
                else
                    outcome, payout = "push", h.bet
                end
                seat.result[hi] = { outcome = outcome, payout = payout, bet = h.bet }
                -- Return the payout (bet already taken at deal) to the player's chips.
                if not seat.isBot and payout > 0 then
                    bankOf(s, seat.name).balance = bankOf(s, seat.name).balance + payout
                end

                -- Narrate the outcome for spectators.
                local who = ns.ShortName(seat.name) .. (#seat.hands > 1 and (" (hand " .. hi .. ")") or "")
                local profit = payout - h.bet
                if outcome == "blackjack" then
                    note(who .. " hits Blackjack and wins " .. profit .. "g!")
                elseif outcome == "win" then
                    note(who .. " wins " .. profit .. "g.")
                elseif outcome == "push" then
                    note(who .. " pushes — bet returned.")
                else
                    note(who .. " loses " .. h.bet .. "g.")
                end
            end
        end
    end

    s.phase = PHASE.PAYOUT
    Changed()                       -- UI + log update immediately
    if ns.AnnounceChannel() then    -- only stream to chat when actually grouped
        PaceToChat(lines)
    end
end

function Engine.NextRound()
    local s = Engine.state
    if not s then return end
    s.phase = PHASE.BETTING
    s.dealer = { cards = {}, holeHidden = true }
    s.active = nil
    for i = 1, ns.MAX_SEATS do
        local seat = s.seats[i]
        if seat then
            seat.hands = {}
            seat.result = nil
            seat.sideResult = nil
            seat.sidePP = 0
            seat.side213 = 0
            seat.insurance = 0
            seat.insuranceDone = false
            seat.insuranceResult = nil
            if seat.isBot then           -- bots automatically re-bet each round
                seat.committed = true
                seat.bet = 50
            else
                seat.committed = false
                seat.bet = 0
            end
        end
    end
    -- Reshuffle at the round boundary (a safe BETTING moment): roll a fresh
    -- shared seed so the next shoe is witnessed by the whole group too.
    if shoeRemaining(s) < math.floor(ns.NUM_DECKS * 52 * ns.RESHUFFLE_AT) then
        Engine.RollNewShoe()
    end
    Engine.Log("Place your bets for the next round.")
    Changed()
end

-- ─── Intent routing (modern addon protocol) ────────────────────────────────
-- Local actions go through here. If we're the host we apply directly; otherwise
-- we forward the intent to the host over the addon channel.
local APPLY = {
    sit      = function(name, d) Engine.Sit(name, d.seat) end,
    leave    = function(name)    Engine.Leave(name) end,
    bet      = function(name, d) Engine.PlaceBet(name, d.amount, d.pp, d.tt) end,
    hit      = function(name)    Engine.Hit(name) end,
    stand    = function(name)    Engine.Stand(name) end,
    double   = function(name)    Engine.Double(name) end,
    split    = function(name)    Engine.Split(name) end,
    -- Crediting chips is dealer-only; players cannot self-credit. Tipping the
    -- dealer (giving chips away) stays available to players.
    tip      = function(name, d) Engine.TipDealer(name, d.amount) end,
    insure          = function(name) Engine.Insure(name) end,
    declineInsurance = function(name) Engine.DeclineInsurance(name) end,
    -- A newcomer asks the host to re-broadcast the current table state.
    sync     = function()        if Engine.IsHost() then Engine.Changed() end end,
}

-- Ask a nearby host (if any) for the current table state.
function Engine.RequestSync()
    if not Engine.IsHost() and IsInGroup() then
        ns.Comm.SendIntent("sync")
    end
end

-- Host-only table-control actions (deal / next round) issued by the host UI.
function Engine.HostAction(action)
    if not Engine.IsHost() then return end
    if action == "deal" then Engine.Deal()
    elseif action == "next" then Engine.NextRound()
    elseif action == "proceed" then Engine.ProceedFromInsurance()
    elseif action == "dealerHit" then Engine.DealerHit()
    elseif action == "dealerStand" then Engine.DealerStand() end
end

-- Called by UI buttons. name is always the local player. Intents are addressed
-- to the table we've joined so other hosts in the group ignore them.
function Engine.Intent(action, data)
    if Engine.IsHost() then
        if APPLY[action] then APPLY[action](ns.Me(), data or {}) end
    else
        ns.Comm.SendIntent(action, data, Engine.joinedHost)
    end
end

-- Host receives an intent from a remote player.
local lastDiscoverAnswer = 0
function Engine.OnIntentReceived(sender, msg)
    if not msg or not msg.action then return end
    -- Normalise the sender to a bare name (drop the realm) so seat names match
    -- what each client calls itself via UnitName("player"). Without this, the
    -- player can't recognise their own seat and can never bet.
    sender = ns.ShortName(sender)
    local action = msg.action
    -- Discovery is answered by every host so the requester can list open tables.
    -- Throttled to avoid a broadcast storm when several clients poll at once
    -- (one fresh broadcast already informs everyone in the group).
    if action == "discover" then
        if Engine.IsHost() then
            local now = (GetTime and GetTime()) or 0
            if now == 0 or (now - lastDiscoverAnswer) > 2 then
                lastDiscoverAnswer = now
                ns.Comm.BroadcastState(PublicState(Engine.state))
            end
        end
        return
    end
    if not Engine.IsHost() then return end
    -- Targeted intents must address THIS table (prevents cross-table bleed when
    -- several dealers host in the same group).
    if msg.to and msg.to ~= ns.Me() then return end
    local fn = APPLY[action]
    if fn then fn(sender, msg.data or {}) end
end

-- ─── Client-side deal animation (replay off the shared shoe) ───────────────
-- When a client adopts a DEALING state, it ANIMATES the deal locally by popping
-- its own copy of the shared seeded shoe in the same ns.DealSteps order — so the
-- remote player sees the cards fall one by one instead of an empty table that
-- snaps to full hands. Purely cosmetic: the host's authoritative FinishDeal
-- state arrives ~when the animation ends and replaces these reconstructed cards.
-- No per-card network traffic at all.
local replayGen = 0
function Engine.ClientReplayDeal(s)
    if not s or s.phase ~= PHASE.DEALING or not s.seed then return end
    replayGen = replayGen + 1
    local myGen = replayGen
    -- Seats in play this round are those the host gave an (empty) hand to.
    local seats = {}
    for i = 1, ns.MAX_SEATS do
        local seat = s.seats[i]
        if seat and seat.hands and #seat.hands > 0 then seats[#seats + 1] = i end
    end
    local steps = ns.DealSteps(seats)
    local shoe = ns.NewShoeSeeded(s.seed)
    local drawStart = s.cursor or 0          -- cursor in the DEALING broadcast = deal start
    -- Begin from empty hands/dealer so cards appear one at a time.
    for _, i in ipairs(seats) do
        if s.seats[i].hands[1] then s.seats[i].hands[1].cards = {} end
    end
    s.dealer = s.dealer or {}
    s.dealer.cards = {}

    local idx = 0
    local function tick()
        -- Stop if the table changed, the round moved on, or a newer deal started.
        if Engine.state ~= s or s.phase ~= PHASE.DEALING or myGen ~= replayGen then return end
        idx = idx + 1
        local step = steps[idx]
        if not step then return end          -- done; authoritative state will land next
        local card = shoe[drawStart + idx]
        if step.kind == "seat" then
            local seat = s.seats[step.seat]
            if seat and seat.hands[1] then table.insert(seat.hands[1].cards, card) end
        else
            table.insert(s.dealer.cards, card)
        end
        if ns.UI and ns.UI.Render then ns.UI.Render(s) end
        if C_Timer and C_Timer.After then C_Timer.After(ns.DEAL_INTERVAL, tick) end
    end
    tick()
end

-- Client receives an authoritative state from a host. We always update the
-- lobby listing; we only adopt the state as our active table once we've joined.
function Engine.OnStateReceived(sender, state)
    if not state then return end
    sender = ns.ShortName(sender)
    local host = state.host or sender

    -- The table closed: drop it from the lobby and, if we were there, go back.
    if state.closed or state.phase == PHASE.IDLE then
        Engine.lobby[host] = nil
        if Engine.joinedHost == host then
            Engine.joinedHost = nil
            Engine.state = nil
            ns.Print("The table hosted by " .. ns.ShortName(host) .. " has closed.")
            if ns.UI and ns.UI.Render then ns.UI.Render(nil) end
        elseif ns.UI and ns.UI.RefreshLobby then
            ns.UI.RefreshLobby()
        end
        return
    end

    -- Record/refresh this table in the lobby listing.
    Engine.lobby[host] = lobbySummary(state)

    if Engine.joinedHost == host then
        Engine.state = state
        -- A fresh DEALING snapshot: render the (empty) table now, then animate the
        -- deal locally off the shared shoe instead of snapping to full hands.
        -- ClientReplayDeal is a no-op without a seed, so the render still happens.
        if ns.UI and ns.UI.Render then ns.UI.Render(state) end
        if state.phase == PHASE.DEALING and not Engine.IsHost() then
            Engine.ClientReplayDeal(state)
        end
    elseif ns.UI and ns.UI.RefreshLobby then
        ns.UI.RefreshLobby()
    end
end
