-- =====================================================================
-- Arcfall Market client - polished build.
--
-- Implements the UX-overhaul spec: 5 sidebar tabs, 3-mode listing wizard
-- (Buy Now / Auction / Both) with live preview, balance + town selectors
-- in the buy/bid/fulfill dialogs, server-driven onboarding banner,
-- color-tinted rows by listing mode, smart timer colors, confirm dialogs
-- for destructive actions, denomination-aware coin icons, stacked-label
-- mode cards, and a unified server `error` opcode handler.
--
-- Server protocol opcodes consumed (parseMarket dispatcher):
--   currencies_config, towns_list, balance, buy_token, bid_rejected,
--   outbid, buy_items, sell_items, item_to_sell, buy_offers,
--   my_buy_offers, historic, close, error.
--
-- Outbound protocols:
--   buy_items, sell_items, selectItemToSell, sell_item, cancelsell_item,
--   buy_token (request), buy_item, bid_item, request_balance,
--   request_towns, buy_offers, my_buy_offers, create_buy_offer,
--   fulfill_buy_offer, cancel_buy_offer, historic.
-- =====================================================================

local MainWindow,
    BuyPanel,
    SellPanel,
    HistoricPanel,
    BuyOffersPanel,
    MyBuyOffersPanel,
    sellConfirmWindow,
    createBuyOfferWindow,
    FulfillBuyOfferWindow,
    BuyNowWindow,
    BidWindow,
    marketButton = nil

local Current = {}
Market = {}
Market.opcode = 179
Market.currencies = {}
Market.towns = {}
Market.homeTownId = 0
Market.balance = nil  -- last balance snapshot from server
Market.itemsDatabase = {}
Market.itemsDatabaseLoaded = false

-- Listing mode constants (mirror of server enum)
Market.LISTING_MODE = {
    BUY_NOW = 1,
    AUCTION = 2,
    BOTH    = 3,
}

local LISTING_MODE_LABEL = {
    [1] = "Buy",
    [2] = "Auction",
    [3] = "Both",
}

-- Per-row tint by mode (BG color string)
local MODE_ROW_TINT = {
    [1] = "#0a2a0a55",  -- green-tinted (Buy Now Only)
    [2] = "#332a0855",  -- yellow-tinted (Auction Only)
    [3] = "#28083355",  -- purple-tinted (Both)
}

-- Subtle alternating row tint. White-alpha so it lifts off the near-black
-- panel BG (pure-black low-alpha is invisible on dark themes).
local ROW_STRIPE_EVEN = "#ffffff05"
local ROW_STRIPE_ODD  = "#ffffff10"

-- Truncation widths
local NAME_TRUNC = 30
local PLAYER_TRUNC = 16

local descHolder = ""
local orderId = 4   -- default to nameasc; matches buyPanel sort default
local timerEvent = nil   -- single-shot driver for all timer columns
local globalShowingError = nil

-- Buy-now token cache. Keyed by item_code; entries expire by ttl.
Market.buyTokens = {}

-- =====================================================================
-- Utilities
-- =====================================================================

-- OT-style abbreviated price text. Always reads as "N Gold" with K / KK / KKK
-- magnitude suffixes. Examples:
--   3            -> "3 gold"
--   500          -> "500 gold"
--   5,000        -> "5K gold"
--   250,500      -> "250.5K gold"
--   1,000,000    -> "1KK gold"
--   2,500,000    -> "2.5KK gold"
--   1,000,000,000-> "1KKK gold"
function formatMoney(n)
    n = math.floor(tonumber(n) or 0)
    if n <= 0 then return "free" end

    local function fmtScaled(value, suffix)
        if value == math.floor(value) then
            return math.floor(value) .. suffix
        end
        local s = string.format("%.1f", value)
        if s:sub(-2) == ".0" then s = s:sub(1, -3) end
        return s .. suffix
    end

    if n < 1000 then
        return n .. " gold"
    elseif n < 1000000 then
        return fmtScaled(n / 1000, "K") .. " gold"
    elseif n < 1000000000 then
        return fmtScaled(n / 1000000, "KK") .. " gold"
    else
        return fmtScaled(n / 1000000000, "KKK") .. " gold"
    end
end

-- Same magnitude rules as formatMoney, but no trailing " gold" suffix.
-- Use this when the cell already shows a coin/currency icon.
function formatGoldShort(n)
    n = math.floor(tonumber(n) or 0)
    if n <= 0 then return "0" end

    local function fmtScaled(value, suffix)
        if value == math.floor(value) then
            return math.floor(value) .. suffix
        end
        local s = string.format("%.1f", value)
        if s:sub(-2) == ".0" then s = s:sub(1, -3) end
        return s .. suffix
    end

    if n < 1000 then return tostring(n) end
    if n < 1000000 then return fmtScaled(n / 1000, "K") end
    if n < 1000000000 then return fmtScaled(n / 1000000, "KK") end
    return fmtScaled(n / 1000000000, "KKK")
end

-- Returns the server-id of the coin sprite that best represents the given
-- amount. Use with item:setItemId() on the small "denomination icon" Item
-- widgets next to price labels.
--   1 - 99       -> gold coin    (2148)
--   100 - 9,999  -> platinum     (2152)
--   10,000+      -> crystal      (2160)
function getDenominationSpriteId(n)
    n = math.floor(tonumber(n) or 0)
    if n >= 10000 then return 2160 end
    if n >= 100   then return 2152 end
    return 2148
end

-- Resolve a child by id within a widget tree. OTCv8's getChildById is
-- already recursive, but some sandboxed builds shipped a non-recursive
-- variant. This helper walks every descendant, so the lookup is reliable
-- regardless of which build we're running on.
local function findDescendant(root, id)
    if not root or not id then return nil end
    local direct = root.getChildById and root:getChildById(id)
    if direct then return direct end
    if not root.getChildren then return nil end
    for _, child in ipairs(root:getChildren() or {}) do
        local hit = findDescendant(child, id)
        if hit then return hit end
    end
    return nil
end

local function safeText(widget, text)
    if widget and widget.setText then
        widget:setText(text)
    end
end

local function safeColor(widget, color)
    if widget and widget.setColor then
        widget:setColor(color)
    end
end

local function safeSet(widget, fn, ...)
    if widget and widget[fn] then
        widget[fn](widget, ...)
    end
end

-- Truncate a name with single-glyph ellipsis if it exceeds the limit.
local function truncate(s, maxLen)
    if not s then return "" end
    if #s > maxLen then return s:sub(1, maxLen - 1) .. "\133" end
    return s
end

-- Set up a CurrencyChip child widget with a price + denomination sprite.
-- chipWidget is a UIWidget that has children "icon" (UIItem) and "label"
-- (Label). For non-gold currencies we still use the configured currency
-- sprite; for gold we pick the right coin denomination from the value.
-- ARCFALL: hardcoded server-side id for crystal coin in 7.x items.xml.
-- Used as a last-resort fallback if the server's currencies_config didn't
-- arrive yet, or if currency.clientId came through as 0. OTClient maps this
-- via /mods/game_marketplace/items.otb at startup.
local GOLD_FALLBACK_ITEM_ID = 2160

local CURRENCY_DEBUG_FIRED = {}
local function debugIcon(label, idValue)
    if CURRENCY_DEBUG_FIRED[label] then return end
    CURRENCY_DEBUG_FIRED[label] = true
    print(string.format("[arcfall.market] %s -> setItemId(%s)", label, tostring(idValue)))
end

local function applyCurrencyChip(chipWidget, currencyId, amount)
    if not chipWidget then return end
    local iconWidget  = findDescendant(chipWidget, "icon")
    local labelWidget = findDescendant(chipWidget, "label")
    if not iconWidget or not labelWidget then return end

    local currency = Market.currencies[currencyId]
    local id = nil
    if currency and currency.clientId and currency.clientId > 0 then
        id = currency.clientId
    else
        id = GOLD_FALLBACK_ITEM_ID
    end

    if currency and currency.type == "gold" then
        labelWidget:setText(formatGoldShort(amount or 0))
        if labelWidget.setColor then labelWidget:setColor("#ffd866") end
    elseif currency then
        labelWidget:setText(comma_value(amount or 0) .. " " .. (currency.name or ""))
        if labelWidget.setColor then labelWidget:setColor("#ffffff") end
    else
        labelWidget:setText(formatGoldShort(amount or 0))
        if labelWidget.setColor then labelWidget:setColor("#ffd866") end
    end

    iconWidget:setItemId(id)
end

local function tooltipModule()
    if modules and modules.game_itemtooltip then
        return modules.game_itemtooltip
    end
    return nil
end

local function tooltipToggle()
    local m = tooltipModule()
    if m and m.toggleMarketHover then
        m.toggleMarketHover()
    end
end

local function tooltipNew(t)
    local m = tooltipModule()
    if m and m.newTooltip and t then
        m.newTooltip(t)
    end
end

-- Build a rich multi-line tooltip text from a market_item / buy_offer
-- record. Combines server-rolled item attributes (atk/def/procs/rarity)
-- with listing metadata (seller, mode, prices, time-left). Returns a
-- string suitable for widget:setTooltip — OTCv8's native g_tooltip
-- system shows it on hover automatically.
local function buildItemRichTooltip(dataObj)
    if not dataObj then return "" end
    local SEP_DASH  = "\151"   -- CP1252 em-dash
    local SEP_DOT   = "\149"   -- CP1252 bullet

    -- Decode server-supplied attribute dict (may be empty for plain items)
    local attrs = {}
    if dataObj.tooltip_data and dataObj.tooltip_data ~= "" then
        local ok, decoded = pcall(json.decode, dataObj.tooltip_data)
        if ok and type(decoded) == "table" then attrs = decoded end
    end

    local TIER_BANNER = {
        common    = nil,
        uncommon  = "[ UNCOMMON ]",
        rare      = "* RARE *",
        epic      = "** EPIC **",
        legendary = "*** LEGENDARY ***",
        mythic    = "**** MYTHIC ****",
    }
    local LISTING_MODE_LABEL = {
        [1] = "Buy Now",
        [2] = "Auction",
        [3] = "Buy Now or Auction",
    }
    local function isPos(v)
        local n = tonumber(v); return n and n > 0
    end
    local function fmtSigned(n)
        n = tonumber(n) or 0
        return (n > 0) and ("+" .. n) or tostring(n)
    end
    local function fmtMoneyShort(n)
        n = math.floor(tonumber(n) or 0)
        if n <= 0 then return "0" end
        if n >= 1000000000 then return string.format("%dKKK", math.floor(n / 1000000000)) end
        if n >= 1000000 then
            local v = n / 1000000; return (v == math.floor(v)) and (math.floor(v) .. "KK") or string.format("%.1fKK", v)
        end
        if n >= 1000 then
            local v = n / 1000; return (v == math.floor(v)) and (math.floor(v) .. "K") or string.format("%.1fK", v)
        end
        return tostring(n)
    end
    local function fmtDuration(s)
        if not s or s < 0 then return nil end
        local d = math.floor(s / 86400)
        local h = math.floor((s % 86400) / 3600)
        local m = math.floor((s % 3600) / 60)
        if d > 0 then return string.format("%dd %dh", d, h) end
        if h > 0 then return string.format("%dh %dm", h, m) end
        if m > 0 then return string.format("%dm", m) end
        return string.format("%ds", math.max(0, math.floor(s)))
    end

    local lines = {}
    local function add(s) if s and s ~= "" then lines[#lines+1] = s end end
    local function rule()
        if #lines > 0 and lines[#lines] ~= "" then
            lines[#lines+1] = string.rep(SEP_DASH, 18)
        end
    end

    -- Header
    local headerName = dataObj.item_name or attrs.item_name or attrs.name or ""
    local count = tonumber(dataObj.count) or 1
    if headerName ~= "" then
        add((count > 1) and (headerName .. "  x" .. count) or headerName)
    end

    -- Rarity tier
    local tier = attrs.arcfall_tier or attrs.tier or attrs.rarity
    if tier then
        local key = tostring(tier):lower()
        add(TIER_BANNER[key] or ("[ " .. string.upper(tostring(tier)) .. " ]"))
    end

    -- Description / book
    add(attrs.description); add(attrs.text)
    if attrs.writer and attrs.writer ~= "" then
        add("Written by " .. attrs.writer .. (attrs.date and (" (" .. attrs.date .. ")") or ""))
    end

    -- Combat stats
    local stats = {}
    if isPos(attrs.attack)       then stats[#stats+1] = "Atk " .. tonumber(attrs.attack)        end
    if isPos(attrs.extraattack)  then stats[#stats+1] = "+Atk " .. fmtSigned(attrs.extraattack) end
    if isPos(attrs.defense)      then stats[#stats+1] = "Def " .. tonumber(attrs.defense)       end
    if isPos(attrs.extradefense) then stats[#stats+1] = "+Def " .. fmtSigned(attrs.extradefense)end
    if isPos(attrs.armor)        then stats[#stats+1] = "Arm " .. tonumber(attrs.armor)         end
    if isPos(attrs.attackspeed)  then stats[#stats+1] = "Speed " .. fmtSigned(attrs.attackspeed)end
    if isPos(attrs.hitchance)    then stats[#stats+1] = "Hit " .. fmtSigned(attrs.hitchance) .. "%" end
    if isPos(attrs.shootrange)   then stats[#stats+1] = "Range " .. tonumber(attrs.shootrange)  end
    if #stats > 0 then rule(); add(table.concat(stats, "  " .. SEP_DOT .. "  ")) end

    -- Special procs (Arcfall rolls)
    local procs = {}
    if isPos(attrs.criticalChance) then procs[#procs+1] = "Crit Chance " .. fmtSigned(attrs.criticalChance) .. "%" end
    if isPos(attrs.lifeLeech)      then procs[#procs+1] = "Life Leech "  .. fmtSigned(attrs.lifeLeech)      .. "%" end
    if isPos(attrs.manaLeech)      then procs[#procs+1] = "Mana Leech "  .. fmtSigned(attrs.manaLeech)      .. "%" end
    if #procs > 0 then rule(); for _, p in ipairs(procs) do add(p) end end

    -- Listing metadata
    local seller = dataObj.seller_name or dataObj.buyer_name
    local mode   = dataObj.listing_mode
    if seller or mode or isPos(dataObj.price) or isPos(dataObj.bid) then
        rule()
        if seller and seller ~= "" then add("Listed by " .. seller) end
        local meta = {}
        if mode and LISTING_MODE_LABEL[mode] then meta[#meta+1] = LISTING_MODE_LABEL[mode] end
        if isPos(dataObj.price) then meta[#meta+1] = fmtMoneyShort(dataObj.price) .. " gold buyout" end
        if isPos(dataObj.bid)   then meta[#meta+1] = "bid " .. fmtMoneyShort(dataObj.bid) .. " gold" end
        if #meta > 0 then add(table.concat(meta, "  " .. SEP_DOT .. "  ")) end
        local timeLine = fmtDuration(dataObj.timeleft or dataObj.time_left)
        if timeLine then add(timeLine .. " remaining") end
    end

    if #lines == 0 then return "" end
    return table.concat(lines, "\n")
end

-- Native-OTCv8 hover: set widget.tooltip with the pre-built rich text.
-- The corelib g_tooltip system fires onHoverChange per-widget and reads
-- widget.tooltip (see modules/corelib/ui/tooltip.lua), so we propagate
-- the same string to every direct child of the row. Without this, hovering
-- over the item icon, name label, or any other non-phantom child captures
-- the mouse and misses the row-level tooltip.
local function attachTooltipHover(widget, dataObj)
    if not widget then return end
    local txt = buildItemRichTooltip(dataObj)
    if not txt or txt == "" then return end
    if widget.setTooltip then widget:setTooltip(txt) end
    if widget.getChildren then
        for _, child in ipairs(widget:getChildren() or {}) do
            if child.setTooltip then child:setTooltip(txt) end
            -- One level of grandchildren covers the price column's
            -- inner currencyIcon UIItem.
            if child.getChildren then
                for _, gc in ipairs(child:getChildren() or {}) do
                    if gc.setTooltip then gc:setTooltip(txt) end
                end
            end
        end
    end
end

-- Confirm dialog helper using corelib's displayGeneralBox. When `dangerous`
-- is true the confirm button is tinted red so the user has a visual cue
-- that the action is irreversible (big spend, cancel listing, etc.) — the
-- corelib helper has no per-button color slot, so we reach into the holder
-- after creation and recolor the matching button.
function Market.showConfirm(title, body, onConfirm, confirmLabel, dangerous)
    local box
    local confirmCb = function()
        if onConfirm then onConfirm() end
        if box then box:destroy() end
    end
    local cancelCb = function()
        if box then box:destroy() end
    end
    local confirmText = confirmLabel or (dangerous and "Confirm" or "OK")
    local buttons = {
        { text = confirmText, callback = confirmCb },
        { text = "Cancel",    callback = cancelCb },
    }
    box = displayGeneralBox(title or "Confirm", body or "Are you sure?", buttons, confirmCb, cancelCb)
    if box then
        -- Corelib's MessageBoxButton has hardcoded width: 80, which clips
        -- longer labels like "Create request". Resize each button to fit
        -- its text (with horizontal padding) and re-pack the holder.
        local holder = box:getChildById("buttonHolder")
        if holder then
            local children = holder:getChildren()
            local totalWidth = 0
            for _, child in ipairs(children) do
                if child.getTextSize and child.setWidth then
                    local text = child.getText and child:getText() or ""
                    local textW = child:getTextSize().width
                    local newW = math.max(80, textW + 24)
                    child:setWidth(newW)
                    if dangerous and text == confirmText then
                        child:setColor("#ff6666")
                    end
                end
                totalWidth = totalWidth + child:getWidth() + (child.getMarginLeft and child:getMarginLeft() or 0)
            end
            holder:setWidth(totalWidth)
            -- Box width may need to grow if holder is now wider than label.
            if box.getPaddingLeft and box.getPaddingRight then
                local pad = box:getPaddingLeft() + box:getPaddingRight()
                if box:getWidth() < totalWidth + pad then
                    box:setWidth(totalWidth + pad)
                end
            end
        end
    end
    return box
end

-- Shows an error to the user. Prefers an inline label when one is provided,
-- otherwise falls back to a modal info box.
local function showError(message, inlineWidget)
    if not message or message == "" then return end
    if inlineWidget then
        inlineWidget:setVisible(true)
        inlineWidget:setText(message)
        return
    end
    if displayErrorBox then
        displayErrorBox("Market", message)
    elseif modules.game_textmessage and modules.game_textmessage.displayGameMessage then
        modules.game_textmessage.displayGameMessage(message)
    else
        g_logger.warning("Market: " .. tostring(message))
    end
end

local function clearInlineError(widget)
    if widget then
        widget:setVisible(false)
        widget:setText("")
    end
end

-- True if the listing carries an active auction bid (defensive: server omits
-- the field for Buy-Now-only listings, so a raw `> 0` would crash).
local function isAuction(mi)
    return (mi and (mi.bid or 0) > 0) or false
end
Market.isAuction = isAuction

-- A "big spend" prompts a confirm dialog: > 100k gold absolute, OR > 10% of
-- the player's known total wealth. Either trigger fires confirmation.
local BIG_SPEND_FLOOR = 100000
local BIG_SPEND_PCT   = 0.10
function isBigSpend(amount)
    amount = tonumber(amount) or 0
    if amount > BIG_SPEND_FLOOR then return true end
    local total = 0
    if Market.balance then
        total = (Market.balance.gold_cash or 0) + (Market.balance.gold_bank or 0)
        if total == 0 then total = Market.balance.gold or 0 end
    end
    if total > 0 and amount > total * BIG_SPEND_PCT then return true end
    return false
end

-- Humanized time: "16d 7h", "9h 32m", "5m 12s", "32s".
-- Smart color: white > 1h, yellow 5m-1h, red < 5m.
local function timerTextAndColor(secondsLeft)
    if secondsLeft == nil then return "\151", "#666666" end
    if secondsLeft < 0   then return "Expired", "#888888" end
    local d = math.floor(secondsLeft / 86400)
    local h = math.floor((secondsLeft % 86400) / 3600)
    local m = math.floor((secondsLeft % 3600) / 60)
    local s = math.floor(secondsLeft % 60)
    local txt
    if     d > 0 then txt = string.format("%dd %dh", d, h)
    elseif h > 0 then txt = string.format("%dh %dm", h, m)
    elseif m > 0 then txt = string.format("%dm %02ds", m, s)
    else              txt = string.format("%ds", s)
    end
    local color
    if secondsLeft < 300 then
        color = "#ff6666"
    elseif secondsLeft < 3600 then
        color = "#ffd866"
    else
        color = "#ffffff"
    end
    return txt, color
end

-- =====================================================================
-- Items database (parsed from items.xml) - kept identical behavior
-- =====================================================================

function loadItemsDatabase()
    if Market.itemsDatabaseLoaded then
        return
    end

    local itemsXmlPath = "/data/things/772/items.xml"
    local content = g_resources.readFileContents(itemsXmlPath)

    if not content or content == "" then
        g_logger.warning("Market: Failed to load items.xml from " .. itemsXmlPath)
        return
    end

    Market.itemsDatabase = {}

    for id, article, name in content:gmatch('<item%s+id="(%d+)"%s*article="([^"]*)"%s*name="([^"]+)"') do
        local itemId = tonumber(id)
        if itemId and name ~= "" then
            Market.itemsDatabase[itemId] = {name = name, article = article}
        end
    end

    for id, name in content:gmatch('<item%s+id="(%d+)"%s*name="([^"]+)"') do
        local itemId = tonumber(id)
        if itemId and name ~= "" and not Market.itemsDatabase[itemId] then
            Market.itemsDatabase[itemId] = {name = name, article = ""}
        end
    end

    for fromId, toId, article, name in content:gmatch(
        '<item%s+fromid="(%d+)"%s+toid="(%d+)"%s*article="([^"]*)"%s*name="([^"]+)"'
    ) do
        local startId = tonumber(fromId)
        local endId = tonumber(toId)
        if startId and endId and name ~= "" then
            for itemId = startId, endId do
                if not Market.itemsDatabase[itemId] then
                    Market.itemsDatabase[itemId] = {name = name, article = article}
                end
            end
        end
    end

    for fromId, toId, name in content:gmatch('<item%s+fromid="(%d+)"%s+toid="(%d+)"%s*name="([^"]+)"') do
        local startId = tonumber(fromId)
        local endId = tonumber(toId)
        if startId and endId and name ~= "" then
            for itemId = startId, endId do
                if not Market.itemsDatabase[itemId] then
                    Market.itemsDatabase[itemId] = {name = name, article = ""}
                end
            end
        end
    end

    local count = 0
    for _ in pairs(Market.itemsDatabase) do count = count + 1 end

    Market.itemsDatabaseLoaded = true
    g_logger.info("Market: Loaded " .. count .. " items from items.xml")
end

-- =====================================================================
-- Constants
-- =====================================================================

Market.time = {
    {value = 10,                name = "10 seconds"},
    {value = 60 * 60 * 4,       name = "4 hours"},
    {value = 60 * 60 * 8,       name = "8 hours"},
    {value = 60 * 60 * 12,      name = "12 hours"},
    {value = 60 * 60 * 24,      name = "1 day"},
    {value = 60 * 60 * 24 * 2,  name = "2 days"},
    {value = 60 * 60 * 24 * 7,  name = "1 week"},
}

Market.category = {
    all = 1, armor = 2, weapons = 3, shields = 4, distance = 5,
    ammunition = 6, wands = 7, containers = 8, helmets = 9, legs = 10,
    boots = 11, rings = 12, necklaces = 13,
}

Market.categoryNames = {
    [1]  = "All",
    [2]  = "Armor",
    [3]  = "Weapons",
    [4]  = "Shields",
    [5]  = "Distance Weapons",
    [6]  = "Ammunition",
    [7]  = "Wands & Rods",
    [8]  = "Containers",
    [9]  = "Helmets",
    [10] = "Legs",
    [11] = "Boots",
    [12] = "Rings",
    [13] = "Necklaces",
}

-- Canonical column id base values (matches server Market.order):
--   timedesc = 1, timeasc = 2, itemdesc = 3, itemasc = 4 (item & name share),
--   sellerdesc = 5, sellerasc = 6, amountdesc = 7, amountasc = 8,
--   pricedesc = 9, priceasc = 10, currencydesc = 11, currencyasc = 12
-- For a column key, base = desc id, base+1 = asc id.
local Marketorder = {
    timer    = 1,  -- 1/2 (timer column maps to time order)
    item     = 3,  -- 3/4
    name     = 3,  -- alias
    seller   = 5,  -- 5/6
    buyer    = 5,  -- alias (buy-offers panel)
    amount   = 7,  -- 7/8
    auction  = 9,  -- alias for bid/price column
    price    = 9,  -- 9/10
    currency = 11, -- 11/12
    mode     = 1,  -- no canonical mode sort; fall back to time
}

-- Per-panel sort state. Each entry: { column = "<id>", dir = "asc"|"desc" }
Market.sortState = {
    buyPanel        = { column = "name",  dir = "asc"  },
    sellPanel       = { column = "timer", dir = "desc" },
    buyOffersPanel  = { column = "name",  dir = "asc"  },
    myBuyOffersPanel= { column = "timer", dir = "desc" },
}

-- Translate a sort state to the wire `order` integer expected by the server.
local function sortStateToOrder(state)
    local base = Marketorder[state.column] or Marketorder.name
    return (state.dir == "asc") and (base + 1) or base
end

-- Per-panel registry of wired header widgets. Populated by handleHeaderClick
-- via wireSort in init(). Used to repaint indicators without scanning the
-- full child list (some children are non-MarketTab buttons).
Market._sortHeaders = {}

-- Refresh the sort indicator on every wired header. The active column gets
-- the arrow + gold color; all others reset to default.
local function refreshSortIndicators(panelKey, state)
    local headers = Market._sortHeaders[panelKey]
    if not headers or not state then return end
    for id, hdr in pairs(headers) do
        local indicator = hdr:getChildById("sortIndicator")
        if indicator then
            if id == state.column then
                -- CP1252 0x86 (asc, dagger ascender) / 0x87 (desc, dagger descender)
                -- Compatible with the verdana-11px-antialised CP1252 atlas;
                -- avoids UTF-8 multi-byte glyphs that would render as garbage.
                indicator:setText(state.dir == "asc" and "+" or "-")
                indicator:setColor("#c9a633")
            else
                indicator:setText("")
            end
        end
        if hdr.setColor then
            if id == state.column then
                hdr:setColor("#c9a633")
            else
                hdr:setColor("#d8d8d8")
            end
        end
    end
end

-- Click handler for a header. `panelKey` selects which sort state to mutate.
local function handleHeaderClick(panelKey, header)
    local state = Market.sortState[panelKey]
    if not state then return end
    local id = header:getId()
    if not Marketorder[id] then return end
    if state.column == id then
        state.dir = (state.dir == "asc") and "desc" or "asc"
    else
        state.column = id
        -- Default direction per column type: text asc, numeric/time desc.
        if id == "timer" or id == "amount" or id == "price"
           or id == "auction" or id == "currency" then
            state.dir = "desc"
        else
            state.dir = "asc"
        end
    end
    refreshSortIndicators(panelKey, state)
    return sortStateToOrder(state)
end
Market.handleHeaderClick    = handleHeaderClick
Market.refreshSortIndicators = refreshSortIndicators

-- Backwards-compat shim: some legacy paths call ordering(widget) and expect an
-- integer. Resolve via the buy panel's sort state.
function ordering(widget)
    if widget then
        return handleHeaderClick("buyPanel", widget) or sortStateToOrder(Market.sortState.buyPanel)
    end
    return sortStateToOrder(Market.sortState.buyPanel)
end

-- =====================================================================
-- Module init / terminate
-- =====================================================================

function init()
    connect(LocalPlayer, { onPositionChange = onPositionChange })
    connect(g_game,      { onGameEnd        = marketoffline })

    loadItemsDatabase()

    -- Register the MarketDetailWindow + helper styles from detail.otui.
    -- Detail.lua can't do this from its own init() because OTCv8 sandboxed
    -- modules share one global env and market.lua's init overrides it.
    if Detail and Detail.initStyles then Detail.initStyles() end

    MainWindow = g_ui.displayUI("market")
    if not MainWindow then
        g_logger.error("Failed to load market UI")
        return
    end
    MainWindow:hide()

    BuyPanel         = MainWindow:getChildById("buyPanel")
    SellPanel        = MainWindow:getChildById("sellPanel")
    HistoricPanel    = MainWindow:getChildById("historicPanel")
    BuyOffersPanel   = MainWindow:getChildById("buyOffersPanel")
    MyBuyOffersPanel = MainWindow:getChildById("myBuyOffersPanel")

    -- Column sort handlers (all 4 row-list panels). Per-panel state means
    -- toggling column A then clicking column B no longer inherits A's
    -- direction; each column starts at its natural default (asc for text,
    -- desc for numeric/time).
    local function wireSort(panel, panelKey, columns, refetch)
        if not panel then return end
        Market._sortHeaders[panelKey] = {}
        for _, n in ipairs(columns) do
            local hdr = panel:getChildById(n)
            if hdr then
                Market._sortHeaders[panelKey][n] = hdr
                hdr.onClick = function()
                    local newOrder = handleHeaderClick(panelKey, hdr)
                    if newOrder then orderId = newOrder end
                    if refetch then refetch() end
                end
            end
        end
        -- Initial paint of indicators + active-column highlight.
        refreshSortIndicators(panelKey, Market.sortState[panelKey])
    end
    wireSort(BuyPanel,         "buyPanel",
        {"item", "name", "amount", "auction", "price", "timer", "mode"},
        getBuyItems)
    wireSort(SellPanel,        "sellPanel",
        {"item", "name", "amount", "auction", "price", "timer", "mode"},
        getSellItems)
    wireSort(BuyOffersPanel,   "buyOffersPanel",
        {"item", "name", "buyer", "amount", "price", "timer"},
        getBuyOfferItems)
    wireSort(MyBuyOffersPanel, "myBuyOffersPanel",
        {"item", "name", "amount", "price", "timer"},
        function() sendOpcode({ protocol = "my_buy_offers" }) end)

    -- Build the persistent floating dialogs once.
    -- BuyNow/Bid parent rootWidget (not gameRootPanel) so they sit above the
    -- Detail window, which is also rootWidget-parented for z-order against market.
    -- All action / confirm dialogs parent rootWidget so they sit above the
    -- Detail window (also rootWidget-parented). gameRootPanel siblings
    -- cannot rise above rootWidget siblings, which used to leave dialogs
    -- buried behind Detail when launched from the Detail action strip.
    BuyNowWindow         = g_ui.createWidget("BuyNowWindow",         rootWidget)
    BidWindow            = g_ui.createWidget("BidWindow",            rootWidget)
    FulfillBuyOfferWindow = g_ui.createWidget("FulfillBuyOfferWindow", rootWidget)

    -- Sidebar filter wiring + per-tab sidebar bullet color
    local catsWidget = MainWindow:getChildById("cats")
    if catsWidget then
        local comboBox = catsWidget:getChildById("comboBox")
        if comboBox then
            for id = 1, 13 do
                if Market.categoryNames[id] then
                    comboBox:addOption(Market.categoryNames[id], id)
                end
            end
            comboBox.onOptionChange = function()
                Current.page = 1
                getBuyItems()
            end
        end
        local searchEdit = catsWidget:getChildById("searchEdit")
        if searchEdit then
            -- Debounce search-as-you-type: 300ms idle, skip single-char queries.
            searchEdit.onTextChange = function()
                if Market._searchEvent then
                    removeEvent(Market._searchEvent)
                    Market._searchEvent = nil
                end
                Market._searchEvent = scheduleEvent(function()
                    Market._searchEvent = nil
                    local q = searchEdit:getText() or ""
                    if #q == 1 then return end
                    Current.search_string = q
                    Current.page = 1
                    setBuyEmptyStateText("Searching...")
                    getBuyItems()
                end, 300)
            end
        end

        -- Bullet colors: green / gold / blue / purple / grey
        local bullets = {
            buyTabButton        = "#66dd66",
            sellTabButton       = "#c9a633",
            buyOffersTabButton  = "#66aaff",
            myBuyOffersTabButton= "#cc88ff",
            historicTabButton   = "#999999",
        }
        for tabId, color in pairs(bullets) do
            local btn = catsWidget:getChildById(tabId)
            if btn then
                local bullet = btn:getChildById("bullet")
                if bullet and bullet.setBackgroundColor then bullet:setBackgroundColor(color) end
            end
        end
    end

    -- Onboarding banner wiring
    local banner = MainWindow:getChildById("onboardingBanner")
    local helpPanel = MainWindow:getChildById("onboardingHelpPanel")
    if banner then
        local helpBtn = banner:getChildById("helpButton")
        local dismissBtn = banner:getChildById("dismissButton")
        local HELP_PANEL_HEIGHT = 110
        local function setHelpVisible(show)
            if not helpPanel then return end
            helpPanel:setVisible(show)
            helpPanel:setHeight(show and HELP_PANEL_HEIGHT or 0)
        end
        if helpBtn and helpPanel then
            helpBtn.onClick = function()
                setHelpVisible(not helpPanel:isVisible())
            end
        end
        local contentArea = MainWindow:getChildById("contentArea")
        if dismissBtn then
            dismissBtn.onClick = function()
                if g_settings and g_settings.set then
                    g_settings.set('Market.onboardingDismissed', true)
                end
                banner:setVisible(false)
                banner:setHeight(0)
                setHelpVisible(false)
                if contentArea and contentArea.setMarginTop then
                    contentArea:setMarginTop(0)
                end
            end
        end
        local dismissed = false
        if g_settings and g_settings.getBoolean then
            dismissed = g_settings.getBoolean('Market.onboardingDismissed')
        end
        banner:setVisible(not dismissed)
        if dismissed then
            banner:setHeight(0)
            if contentArea and contentArea.setMarginTop then
                contentArea:setMarginTop(0)
            end
        end
        setHelpVisible(false)
    end

    -- Topmenu button + Ctrl+M binding. Use the larger built-in shop.png
    -- (~3KB, money-bag glyph) instead of the tiny custom 16px coin which
    -- was visually unreadable. Overlay a small "MKT" label so the button
    -- is identifiable even with the icon disabled.
    marketButton = modules.client_topmenu.addRightGameToggleButton(
        "MarketButton",
        tr("Market") .. " (Ctrl+M)",
        "/images/topbuttons/shop",
        marketWindowtoggle,
        false,
        5
    )
    if marketButton then
        marketButton:setOn(false)
        local label = g_ui.createWidget('UILabel', marketButton)
        label:setId('marketBtnLabel')
        label:setText('MKT')
        label:setColor('#ffd866')
        label:setFont('verdana-9px-bold')
        label:setBackgroundColor('alpha')
        label:setTextAlign(AlignBottom)
        label:setPhantom(true)
        label:addAnchor(AnchorLeft, 'parent', AnchorLeft)
        label:addAnchor(AnchorRight, 'parent', AnchorRight)
        label:addAnchor(AnchorBottom, 'parent', AnchorBottom)
        label:setMarginBottom(-2)
    end

    g_keyboard.bindKeyDown("Ctrl+M", marketWindowtoggle)

    ProtocolGame.registerExtendedOpcode(Market.opcode, parseMarket)

    -- Start the unified timer driver (no-op until rows exist)
    if not timerEvent then
        timerEvent = scheduleEvent(updateAllTimers, 1000)
    end
end

function terminate()
    disconnect(LocalPlayer, { onPositionChange = onPositionChange })
    disconnect(g_game,      { onGameEnd        = marketoffline })
    ProtocolGame.unregisterExtendedOpcode(Market.opcode)
    g_keyboard.unbindKeyDown("Ctrl+M")

    if timerEvent then
        removeEvent(timerEvent)
        timerEvent = nil
    end

    if Market._searchEvent then
        removeEvent(Market._searchEvent)
        Market._searchEvent = nil
    end

    -- Per-panel events from older versions (defensive cleanup)
    for _, panel in ipairs({BuyPanel, SellPanel, BuyOffersPanel, MyBuyOffersPanel}) do
        if panel and panel.event then
            removeEvent(panel.event)
            panel.event = nil
        end
    end

    if BuyNowWindow         then BuyNowWindow:destroy();         BuyNowWindow = nil end
    if BidWindow            then BidWindow:destroy();            BidWindow = nil end
    if FulfillBuyOfferWindow then FulfillBuyOfferWindow:destroy(); FulfillBuyOfferWindow = nil end
    if createBuyOfferWindow then createBuyOfferWindow:destroy(); createBuyOfferWindow = nil end
    if sellConfirmWindow    then sellConfirmWindow:destroy();    sellConfirmWindow = nil end
    if MainWindow           then MainWindow:destroy();           MainWindow = nil end
end

function marketoffline()
    if Market._searchEvent then
        removeEvent(Market._searchEvent)
        Market._searchEvent = nil
    end
    if BuyNowWindow          then BuyNowWindow:hide() end
    if BidWindow             then BidWindow:hide() end
    if FulfillBuyOfferWindow then FulfillBuyOfferWindow:hide() end
    if createBuyOfferWindow  then createBuyOfferWindow:hide() end
    if MainWindow            then MainWindow:hide() end
    if sellConfirmWindow     then sellConfirmWindow:hide() end
end

function marketWindowtoggle()
    if not MainWindow then return end
    if MainWindow:isVisible() then
        MainWindow:hide()
        if marketButton then marketButton:setOn(false) end
    else
        if marketButton then marketButton:setOn(true) end
        local catsWidget = MainWindow:getChildById("cats")
        if catsWidget then
            local searchEdit = catsWidget:getChildById("searchEdit")
            if searchEdit then searchEdit:setText("") end
            local comboBox = catsWidget:getChildById("comboBox")
            if comboBox then comboBox:setCurrentOption("All") end
        end
        sendOpcode({ protocol = "request_towns" })
        sendOpcode({ protocol = "request_balance" })
        showBuyPanel()
    end
end

-- =====================================================================
-- Networking helpers
-- =====================================================================

function sendOpcode(tbl)
    local proto = g_game.getProtocolGame()
    if not proto then return end
    proto:sendExtendedOpcode(Market.opcode, json.encode(tbl))
end

-- =====================================================================
-- Pagination
-- =====================================================================

function togglePage(id)
    if id == "first" then
        Current.page = 1
    elseif id == "prev" then
        Current.page = math.max(1, (Current.page or 1) - 1)
    elseif id == "next" then
        Current.page = math.min(Current.max_page or 1, (Current.page or 1) + 1)
    elseif id == "last" then
        Current.page = Current.max_page or 1
    end
    getBuyItems()
end

function toggleBuyOffersPage(id)
    if id == "first" then
        Current.buyOffersPage = 1
    elseif id == "prev" then
        Current.buyOffersPage = math.max(1, (Current.buyOffersPage or 1) - 1)
    elseif id == "next" then
        Current.buyOffersPage = math.min(Current.buyOffersMaxPage or 1, (Current.buyOffersPage or 1) + 1)
    elseif id == "last" then
        Current.buyOffersPage = Current.buyOffersMaxPage or 1
    end
    getBuyOfferItems()
end

function updatePageWidget()
    if not BuyPanel then return end
    local pageWidget = BuyPanel:getChildById("pageWidget")
    if not pageWidget then return end
    local cur, max = Current.page or 1, Current.max_page or 1
    local lc = Current.last_count or 0
    local label = "Page " .. cur .. " of " .. max
    if lc > 0 then
        label = label .. "   \149  " .. lc .. (lc == 1 and " listing" or " listings")
    end
    pageWidget:getChildById("text"):setText(label)
    pageWidget:getChildById("first"):setEnabled(cur > 1)
    pageWidget:getChildById("prev"):setEnabled(cur > 1)
    pageWidget:getChildById("next"):setEnabled(cur < max)
    pageWidget:getChildById("last"):setEnabled(cur < max)
end

-- Clear all browse-tab filters (search + category) and refresh.
function clearMarketFilters()
    if Market._searchEvent then
        removeEvent(Market._searchEvent)
        Market._searchEvent = nil
    end
    if MainWindow then
        local catsWidget = MainWindow:getChildById("cats")
        if catsWidget then
            local searchEdit = catsWidget:getChildById("searchEdit")
            if searchEdit then searchEdit:setText("") end
            local comboBox = catsWidget:getChildById("comboBox")
            if comboBox then comboBox:setCurrentOption("All") end
        end
    end
    Current.search_string = ""
    Current.category = 1
    Current.page = 1
    Current.last_count = 0
    getBuyItems()
end

function updateBuyOffersPageWidget()
    if not BuyOffersPanel then return end
    local pageWidget = BuyOffersPanel:getChildById("pageWidget")
    if not pageWidget then return end
    local cur, max = Current.buyOffersPage or 1, Current.buyOffersMaxPage or 1
    pageWidget:getChildById("text"):setText("Page " .. cur .. " of " .. max)
    pageWidget:getChildById("first"):setEnabled(cur > 1)
    pageWidget:getChildById("prev"):setEnabled(cur > 1)
    pageWidget:getChildById("next"):setEnabled(cur < max)
    pageWidget:getChildById("last"):setEnabled(cur < max)
end

-- =====================================================================
-- Data fetchers (outbound packets)
-- =====================================================================

function getBuyItems()
    if not MainWindow then return end
    local catsWidget = MainWindow:getChildById("cats")
    if not catsWidget then return end
    local comboBox = catsWidget:getChildById("comboBox")
    if not comboBox then return end
    local option = comboBox:getCurrentOption()
    if not option then return end

    sendOpcode({
        protocol      = "buy_items",
        category      = option.data,
        page          = Current.page or 1,
        order         = orderId,
        search_string = Current.search_string,
    })
end

function getSellItems()
    sendOpcode({ protocol = "sell_items" })
end

function getBuyOfferItems()
    if not MainWindow then return end
    local catsWidget = MainWindow:getChildById("cats")
    if not catsWidget then return end
    local comboBox = catsWidget:getChildById("comboBox")
    if not comboBox then return end
    local option = comboBox:getCurrentOption()
    if not option then return end

    sendOpcode({
        protocol      = "buy_offers",
        category      = option.data,
        page          = Current.buyOffersPage or 1,
        order         = orderId,
        search_string = Current.search_string,
    })
end

function getMyBuyOffers()
    sendOpcode({ protocol = "my_buy_offers" })
end

-- =====================================================================
-- Cancel actions (with confirm dialogs)
-- =====================================================================

function cancelSellItem(widget)
    if not widget or not widget.market_item then return end
    local mi = widget.market_item
    Market.showConfirm(
        "Cancel listing?",
        "Cancel " .. (mi.item_name or "this listing") .. "?\nThe item returns to your depot.",
        function()
            sendOpcode({
                protocol  = "cancelsell_item",
                item_code = mi.item_code,
                town_id   = Market.homeTownId,
            })
        end,
        "Cancel listing",
        true
    )
end

function cancelBuyOffer(widget)
    if not widget or not widget.buy_offer then return end
    local bo = widget.buy_offer
    Market.showConfirm(
        "Cancel buy request?",
        "Cancel your request for " .. (bo.item_name or "this item") .. "?\nReserved gold returns to your bank.",
        function()
            sendOpcode({
                protocol   = "cancel_buy_offer",
                offer_code = bo.offer_code,
            })
        end,
        "Cancel request",
        true
    )
end

-- =====================================================================
-- Buy Now flow (with token + town)
-- =====================================================================

function fillTownCombo(combo)
    if not combo then return end
    combo:clearOptions()
    if Market.towns and #Market.towns > 0 then
        for _, t in ipairs(Market.towns) do
            combo:addOption(t.name or ("Town " .. tostring(t.id)), t.id)
        end
        if Market.homeTownId and Market.homeTownId > 0 then
            for _, t in ipairs(Market.towns) do
                if t.id == Market.homeTownId then
                    combo:setCurrentOption(t.name)
                    break
                end
            end
        end
    else
        combo:addOption("(default)", 0)
    end
end

local function getSelectedTown(combo)
    if not combo then return Market.homeTownId or 0 end
    local opt = combo:getCurrentOption()
    return (opt and opt.data) or Market.homeTownId or 0
end

function refreshBalanceLabel()
    local function fmt()
        if not Market.balance then return "Loading balance..." end
        local b = Market.balance
        local parts = {}
        if b.gold_cash ~= nil then table.insert(parts, "Backpack " .. formatMoney(b.gold_cash)) end
        if b.gold_bank ~= nil then table.insert(parts, "Bank " .. formatMoney(b.gold_bank)) end
        if #parts == 0 and b.gold ~= nil then
            table.insert(parts, "Gold " .. formatMoney(b.gold))
        end
        return table.concat(parts, "    ")
    end

    -- ARCFALL: render the "what gets deducted" view as two short lines so
    -- the chip wraps cleanly inside the dialog instead of being cropped on
    -- both ends. Line 1 = Pay (split if both pools), line 2 = After.
    local function fmtBreakdown()
        local b = Market.balance
        if not b or not b.from_cash or not b.from_bank then return "" end
        local total = (b.from_cash or 0) + (b.from_bank or 0)
        if total <= 0 then return "" end
        local payLine
        if (b.from_cash or 0) > 0 and (b.from_bank or 0) > 0 then
            payLine = "Pay: " .. formatMoney(b.from_cash) .. " backpack  +  "
                       .. formatMoney(b.from_bank) .. " bank"
        elseif (b.from_cash or 0) > 0 then
            payLine = "Pay: " .. formatMoney(b.from_cash) .. " from backpack"
        else
            payLine = "Pay: " .. formatMoney(b.from_bank) .. " from bank"
        end
        local afterLine = "After:  backpack " .. formatMoney(b.cash_after or 0)
                          .. "    bank " .. formatMoney(b.bank_after or 0)
        return payLine .. "\n" .. afterLine
    end
    if BuyNowWindow then
        local lbl = BuyNowWindow:getChildById("balanceLabel")
        if lbl then lbl:setText(fmt()) end
        local bd = BuyNowWindow:getChildById("breakdownLabel")
        if bd then
            local s = fmtBreakdown()
            bd:setText(s)
            bd:setVisible(s ~= "")
        end
    end
    if BidWindow then
        local lbl = BidWindow:getChildById("balanceLabel")
        if lbl then lbl:setText(fmt()) end
        local bd = BidWindow:getChildById("breakdownLabel")
        if bd then
            local s = fmtBreakdown()
            bd:setText(s)
            bd:setVisible(s ~= "")
        end
    end
    if FulfillBuyOfferWindow then
        local lbl = FulfillBuyOfferWindow:getChildById("balanceLabel")
        if lbl then lbl:setText(fmt()) end
    end
end

function buyItem()
    if not Current.buyWidget then return end
    local market_item = Current.buyWidget.market_item
    if not market_item then return end

    -- Refresh balance + towns when opening dialog (cheap)
    sendOpcode({ protocol = "request_balance" })
    if not Market.towns or #Market.towns == 0 then
        sendOpcode({ protocol = "request_towns" })
    end

    local rowItem      = BuyNowWindow:getChildById("item")
    local titleLabel   = BuyNowWindow:getChildById("itemTitle")
    local sellerLabel  = BuyNowWindow:getChildById("sellerLabel")
    local rowScrollbar = BuyNowWindow:getChildById("countScrollBar")
    local priceLabel   = BuyNowWindow:getChildById("priceLabel")
    local feeLabel     = BuyNowWindow:getChildById("feeLabel")
    local totalChip    = findDescendant(BuyNowWindow, "totalChip")
    local townCombo    = BuyNowWindow:getChildById("townCombo")
    local errLabel     = BuyNowWindow:getChildById("errorLabel")

    rowItem:setItemId(market_item.clientId)
    rowItem:setItemCount(1)
    attachTooltipHover(rowItem, market_item)
    safeText(titleLabel, market_item.item_name or "")
    safeText(sellerLabel, "Seller: " .. truncate(market_item.seller_name or "--", PLAYER_TRUNC))
    BuyNowWindow:setText(market_item.item_name or "Buy Now")

    local function updateTotals(count)
        local subtotal = (market_item.price or 0) * count
        local fee = getMarketFee(subtotal)
        local total = subtotal + fee
        safeText(priceLabel, "Price/unit: " .. formatMoney(market_item.price or 0))
        applyCurrencyChip(totalChip, market_item.currency, total)
        safeText(feeLabel, fee == 0 and "Fee: free" or ("Fee: " .. formatMoney(fee)))
        -- ARCFALL: ask the server for a fresh balance + breakdown for the
        -- exact total being quoted, so the user sees backpack/bank split.
        sendOpcode({
            protocol     = "request_balance",
            item_code    = market_item.item_code,
            quote_amount = total,
        })
    end

    fillTownCombo(townCombo)
    refreshBalanceLabel()
    clearInlineError(errLabel)

    local okButton     = BuyNowWindow:getChildById("buttonOk")
    local cancelButton = BuyNowWindow:getChildById("buttonCancel")

    -- Initial paint: prime totals + button label so the dialog doesn't
    -- read "0" before the user touches the slider.
    updateTotals(1)
    if okButton then
        okButton:setText("Buy " .. formatMoney((market_item.price or 0) * 1))
    end

    local doBuy = function()
        clearInlineError(errLabel)
        local count = rowItem:getItemCount()
        local townId = getSelectedTown(townCombo)
        Market.pendingBuy = {
            item_code = market_item.item_code,
            itemid    = market_item.itemid,
            count     = count,
            town_id   = townId,
        }
        sendOpcode({
            protocol  = "request_buy_token",
            item_code = market_item.item_code,
        })
        safeText(priceLabel, "Locking price... waiting for server.")
    end
    local buyFunc = function()
        local count = rowItem:getItemCount() or 1
        local subtotal = (market_item.price or 0) * count
        local total = subtotal + getMarketFee(subtotal)
        if isBigSpend(total) then
            Market.showConfirm(
                "Buy now",
                "Buy " .. count .. " x " .. (market_item.item_name or "item") ..
                    " for " .. formatMoney(total) .. "?",
                doBuy,
                "Buy now",
                true
            )
        else
            doBuy()
        end
    end

    local cancelFunc = function()
        Market.pendingBuy = nil
        show()
        if Detail and Detail._hiddenForAction then
            Detail.restoreFromAction()
        end
    end

    -- QuantityPicker drives totals + confirm-button label.
    bindQuantityPicker(rowScrollbar, 1, market_item.count or 1, function(value)
        rowItem:setItemCount(value)
        updateTotals(value)
        if okButton then
            okButton:setText("Buy " .. formatMoney((market_item.price or 0) * value))
        end
    end)

    BuyNowWindow.onEnter  = buyFunc
    BuyNowWindow.onEscape = cancelFunc
    okButton.onClick      = buyFunc
    cancelButton.onClick  = cancelFunc

    MainWindow:hide()
    BuyNowWindow:show()
    BuyNowWindow:focus()
end

-- Called when server replies with a buy_token; finalizes the buy.
local function dispatchBuyWithToken(receive)
    if not Market.pendingBuy then return end
    local p = Market.pendingBuy
    if receive.item_code ~= p.item_code then
        return
    end
    sendOpcode({
        protocol  = "buy_item",
        item_code = p.item_code,
        itemid    = p.itemid,
        buy_count = p.count,
        token     = receive.token,
        town_id   = p.town_id,
    })
    Market.pendingBuy = nil
    show()
    if Detail and Detail._hiddenForAction then
        Detail.closeFromAction()
    end
end

-- =====================================================================
-- Bid flow
-- =====================================================================

local function bidCountdownTick(window, deadlineEpoch)
    if not window or not window:isVisible() then return end
    local lbl = window:getChildById("countdownLabel")
    if not lbl then return end
    local now = os.time()
    local left = (deadlineEpoch or 0) - now
    if left < 0 then
        lbl:setText("Auction ended")
        lbl:setColor("#ff6666")
        return
    end
    local text, color = timerTextAndColor(left)
    lbl:setText("Auction ends in: " .. text)
    lbl:setColor(color)
    window.bidCountdownEvent = scheduleEvent(function() bidCountdownTick(window, deadlineEpoch) end, 1000)
end

function bidItem()
    if not Current.buyWidget then return end
    local market_item = Current.buyWidget.market_item
    if not market_item then return end

    sendOpcode({ protocol = "request_balance" })

    local rowItem      = BidWindow:getChildById("item")
    local titleLabel   = BidWindow:getChildById("itemTitle")
    local minBidLabel  = BidWindow:getChildById("minBidLabel")
    local totalChip    = findDescendant(BidWindow, "totalChip")
    local rowTextEdit  = BidWindow:getChildById("edit")
    local errLabel     = BidWindow:getChildById("errorLabel")

    BidWindow:setText(market_item.item_name or "Place Bid")
    rowItem:setItemId(market_item.clientId)
    rowItem:setItemCount(market_item.count or 1)
    attachTooltipHover(rowItem, market_item)
    safeText(titleLabel, market_item.item_name or "")

    local minNext = market_item.min_next_bid
        or (market_item.bid and market_item.bid > 0 and market_item.bid + 1)
        or 1
    safeText(minBidLabel, "Minimum next bid: " .. formatMoney(minNext))

    if BidWindow.bidCountdownEvent then
        removeEvent(BidWindow.bidCountdownEvent)
        BidWindow.bidCountdownEvent = nil
    end
    local deadline = os.time() + (market_item.timeleft or 0)
    bidCountdownTick(BidWindow, deadline)

    refreshBalanceLabel()
    clearInlineError(errLabel)

    local okButton     = BidWindow:getChildById("buttonOk")
    local cancelButton = BidWindow:getChildById("buttonCancel")

    local function updateChip(bid)
        applyCurrencyChip(totalChip, market_item.currency, bid)
        if okButton then okButton:setText("Place Bid: " .. formatMoney(bid)) end
        -- ARCFALL: refresh balance breakdown for the typed bid amount.
        if bid and bid > 0 then
            sendOpcode({
                protocol     = "request_balance",
                item_code    = market_item.item_code,
                quote_amount = bid,
            })
        end
    end

    rowTextEdit:setText(tostring(minNext))
    updateChip(minNext)
    rowTextEdit.onTextChange = function()
        local bid = tonumber(rowTextEdit:getText()) or 0
        updateChip(bid)
        if bid < minNext then
            errLabel:setVisible(true)
            errLabel:setText("Bid must be at least " .. formatMoney(minNext) .. ".")
        else
            clearInlineError(errLabel)
        end
    end

    local doBid = function(bid)
        sendOpcode({
            protocol  = "bid_item",
            item_code = market_item.item_code,
            bid       = bid,
            town_id   = Market.homeTownId,
        })
        if BidWindow.bidCountdownEvent then
            removeEvent(BidWindow.bidCountdownEvent)
            BidWindow.bidCountdownEvent = nil
        end
        show()
        if Detail and Detail._hiddenForAction then
            Detail.closeFromAction()
        end
    end
    local bidFunc = function()
        local bid = tonumber(rowTextEdit:getText()) or 0
        if bid < minNext then
            errLabel:setVisible(true)
            errLabel:setText("Bid must be at least " .. formatMoney(minNext) .. ".")
            return
        end
        if isBigSpend(bid) then
            Market.showConfirm(
                "Place bid",
                "Place a " .. formatMoney(bid) .. " bid on " ..
                    (market_item.item_name or "this item") .. "?",
                function() doBid(bid) end,
                "Place bid",
                true
            )
        else
            doBid(bid)
        end
    end
    local cancelFunc = function()
        if BidWindow.bidCountdownEvent then
            removeEvent(BidWindow.bidCountdownEvent)
            BidWindow.bidCountdownEvent = nil
        end
        show()
        if Detail and Detail._hiddenForAction then
            Detail.restoreFromAction()
        end
    end

    BidWindow.onEnter  = bidFunc
    BidWindow.onEscape = cancelFunc
    okButton.onClick   = bidFunc
    cancelButton.onClick = cancelFunc

    MainWindow:hide()
    BidWindow:show()
    BidWindow:focus()
end

-- =====================================================================
-- Timer driver: single scheduled event updates all visible rows.
-- =====================================================================

local function tickRowList(panel, dataKey)
    if not panel then return end
    local items = panel:getChildById("items")
    if not items then return end
    local children = items:getChildren()
    if not children then return end
    for _, child in ipairs(children) do
        local data = child[dataKey]
        if data and data.timeleft ~= nil then
            local left = data.timeleft
            local timerWidget = child:getChildById("timer")
            if timerWidget then
                local txt, col = timerTextAndColor(left)
                timerWidget:setText(txt)
                if timerWidget.setColor then timerWidget:setColor(col) end
            end
            data.timeleft = left - 1
        end
    end
end

function updateAllTimers()
    tickRowList(BuyPanel,         "market_item")
    tickRowList(SellPanel,        "market_item")
    tickRowList(BuyOffersPanel,   "buy_offer")
    tickRowList(MyBuyOffersPanel, "buy_offer")
    timerEvent = scheduleEvent(updateAllTimers, 1000)
end

-- =====================================================================
-- Currency / row styling
-- =====================================================================

-- Apply currency style + denomination-aware coin sprite to a row's price
-- column. The widget here is the "price" UIWidget that hosts a child
-- "currencyIcon" UIItem.
function applyCurrencyStyle(widget, currencyId, price)
    local currency = Market.currencies[currencyId]
    local id = (currency and currency.clientId and currency.clientId > 0)
        and currency.clientId or GOLD_FALLBACK_ITEM_ID

    if currency and currency.type ~= "gold" then
        widget:setText(comma_value(price))
        widget:setColor("white")
    else
        widget:setText(formatGoldShort(price))
        widget:setColor("#ffd866")
    end

    local iconWidget = widget:getChildById("currencyIcon")
    if iconWidget then
        iconWidget:setItemId(id)
    elseif currency and currency.image then
        widget:setIcon(currency.image)
    else
        widget:setItemId(id)
    end
end

-- Mode bar color (left-edge accessible stripe). Higher contrast than the
-- old 55-alpha row-bg tint and compatible with $hover/$focus highlights.
local MODE_BAR_COLOR = {
    [1] = "#66dd66",  -- Buy Now
    [2] = "#c9a633",  -- Auction
    [3] = "#cc88ff",  -- Both
}
local MODE_BADGE_COLOR = {
    [1] = "#1a4d1a",
    [2] = "#5c4818",
    [3] = "#3d1352",
}
local MODE_BADGE_GLYPH = {
    [1] = "B",
    [2] = "A",
    [3] = "*",
}
local MODE_BADGE_TOOLTIP = {
    [1] = "Buy Now \151 fixed price.",
    [2] = "Auction \151 bid to win.",
    [3] = "Buy Now or Auction \151 bid, or buy out instantly.",
}

-- Stripe + mode-bar + badge. Replaces the old whole-row background tint
-- (which clashed with $hover/$focus and tested too low on contrast).
local function applyModeTint(rowWidget, mode, rowIndex)
    if not rowWidget then return end

    -- Subtle alternating stripe, kept low alpha so $hover/$focus dominate.
    if rowIndex and rowWidget.setBackgroundColor then
        local stripe = (rowIndex % 2 == 0) and ROW_STRIPE_EVEN or ROW_STRIPE_ODD
        rowWidget:setBackgroundColor(stripe)
    end

    -- Left mode bar (3px). Color encodes listing mode; absent = transparent.
    local bar = rowWidget:getChildById("modeBar")
    if bar then
        local barColor = mode and MODE_BAR_COLOR[mode]
        if bar.setBackgroundColor then
            bar:setBackgroundColor(barColor or "#00000000")
        end
    end

    -- Mode badge (single-letter glyph + colored bg). Used by listings rows
    -- only; buy-offer rows have no badge widget and skip this branch.
    local badge = rowWidget:getChildById("mode")
    if badge and mode then
        if badge.setText then badge:setText(MODE_BADGE_GLYPH[mode] or "?") end
        if badge.setBackgroundColor then
            badge:setBackgroundColor(MODE_BADGE_COLOR[mode] or "#0a0a0a")
        end
        if badge.setColor then
            badge:setColor(MODE_BAR_COLOR[mode] or "#f0f0f0")
        end
        if badge.setTooltip then
            badge:setTooltip(MODE_BADGE_TOOLTIP[mode] or "")
        end
    end
end

-- Bind a QuantityPicker widget so existing callers can use the same
-- HorizontalScrollBar API (setMinimum / setMaximum / setValue / getValue /
-- setText / onValueChange) transparently. The picker has three children:
-- minusBtn, quantityEdit, plusBtn, quantitySlider.
function bindQuantityPicker(picker, minVal, maxVal, onChange)
    if not picker then return end

    -- Idempotency: don't double-wire if init() ran twice on a reload.
    if picker._qpBound and picker._qpRebind then
        picker._qpRebind(minVal, maxVal, onChange)
        return
    end

    local minus  = picker:getChildById("minusBtn")
    local plus   = picker:getChildById("plusBtn")
    local edit   = picker:getChildById("quantityEdit")
    local slider = picker:getChildById("quantitySlider")
    if not (minus and plus and edit and slider) then return end

    picker._qpMin    = minVal or 1
    picker._qpMax    = maxVal or 1
    picker._qpValue  = picker._qpMin
    picker._qpOnChange = onChange
    picker._qpSilent = false

    slider:setMinimum(picker._qpMin)
    slider:setMaximum(picker._qpMax)
    slider:setValue(picker._qpMin)

    local function clamp(n)
        n = tonumber(n) or picker._qpMin
        if n < picker._qpMin then n = picker._qpMin end
        if n > picker._qpMax then n = picker._qpMax end
        return math.floor(n)
    end

    local function setValueInternal(v, source)
        v = clamp(v)
        if v == picker._qpValue then return end
        picker._qpValue = v
        picker._qpSilent = true
        if source ~= "edit"   then edit:setText(tostring(v)) end
        if source ~= "slider" then slider:setValue(v) end
        picker._qpSilent = false
        if picker._qpOnChange then picker._qpOnChange(v) end
    end

    minus.onClick = function() setValueInternal(picker._qpValue - 1, "btn") end
    plus.onClick  = function() setValueInternal(picker._qpValue + 1, "btn") end
    edit.onTextChange = function()
        if picker._qpSilent then return end
        local n = tonumber(edit:getText())
        if n then setValueInternal(n, "edit") end
    end
    slider.onValueChange = function(_, v)
        if picker._qpSilent then return end
        setValueInternal(v, "slider")
    end

    edit:setText(tostring(picker._qpValue))

    -- Shim the HorizontalScrollBar API onto the picker so existing
    -- call sites that use setValue/getValue/onValueChange keep working.
    picker.setMinimum = function(_, n)
        picker._qpMin = math.floor(tonumber(n) or 1)
        slider:setMinimum(picker._qpMin)
        if picker._qpValue < picker._qpMin then setValueInternal(picker._qpMin, "api") end
    end
    picker.setMaximum = function(_, n)
        picker._qpMax = math.floor(tonumber(n) or 1)
        slider:setMaximum(picker._qpMax)
        if picker._qpValue > picker._qpMax then setValueInternal(picker._qpMax, "api") end
    end
    picker.setValue = function(_, n) setValueInternal(n, "api") end
    picker.getValue = function() return picker._qpValue end
    picker.setText  = function(_, t) edit:setText(tostring(t)) end
    picker.getText  = function() return edit:getText() end
    picker.setEnabled = function(_, e)
        minus:setEnabled(e); plus:setEnabled(e); edit:setEnabled(e); slider:setEnabled(e)
    end

    -- onValueChange property: setting it replaces the wrapped callback.
    -- We store the user callback as picker.onValueChange (writable field)
    -- and wrap it so signature matches HorizontalScrollBar (self, value).
    -- picker is a UIWidget userdata; assign via the Lua proxy (rawset/setmetatable
    -- on userdata raises and aborts the click handler).
    picker._qpUserOnValueChange = nil
    picker._qpRebind = function(newMin, newMax, newOnChange)
        picker._qpMin = newMin or picker._qpMin
        picker._qpMax = newMax or picker._qpMax
        picker._qpOnChange = newOnChange
        slider:setMinimum(picker._qpMin)
        slider:setMaximum(picker._qpMax)
        if picker._qpValue < picker._qpMin then setValueInternal(picker._qpMin, "api")
        elseif picker._qpValue > picker._qpMax then setValueInternal(picker._qpMax, "api") end
    end

    picker._qpBound = true
end

-- Adapter for legacy code that assigns picker.onValueChange = function()...end
-- after creation. We poll for the assignment by intercepting __newindex via
-- the picker's onValueChange property and translating it into the wrapped
-- callback. To stay simple, we expose a setter here.
function setQuantityPickerCallback(picker, fn)
    if not picker then return end
    picker._qpOnChange = function(v)
        if fn then fn(picker, v) end
    end
end

-- Refresh empty-state visibility for a panel.
local function setEmptyState(panel, isEmpty, customText)
    if not panel then return end
    local emptyLbl = panel:getChildById("emptyState")
    if not emptyLbl then return end
    emptyLbl:setVisible(isEmpty)
    if customText and isEmpty then emptyLbl:setText(customText) end
    -- Toggle the Clear-filters button on the buy panel when filters are active.
    local clearBtn = panel:getChildById("clearFiltersButton")
    if clearBtn then
        local hasFilter = (Current.search_string and Current.search_string ~= "")
            or (Current.category and Current.category ~= 1)
        clearBtn:setVisible(isEmpty and hasFilter)
    end
end

-- Sets a one-shot empty-state message on the Browse panel (used for the
-- transient "Searching..." state during debounced typing). Only shows the
-- label when the items list is currently empty so we don't overlay results.
function setBuyEmptyStateText(text)
    if not BuyPanel then return end
    local emptyLbl = BuyPanel:getChildById("emptyState")
    if not emptyLbl then return end
    local items = BuyPanel:getChildById("items")
    local hasChildren = items and items.getChildren and #(items:getChildren() or {}) > 0
    if hasChildren then return end
    emptyLbl:setVisible(true)
    emptyLbl:setText(text or "")
end

-- =====================================================================
-- parseMarket: server-side dispatcher
-- =====================================================================

function parseMarket(protocol, opcode, buffer)
    local ok, receive = pcall(json.decode, buffer)
    if not ok or not receive then
        g_logger.warning("Market: malformed packet from server")
        return
    end
    local p = receive.protocol

    -- ----- Lifecycle / config -------------------------------------------
    if p == "close" then
        marketWindowtoggle()

    elseif p == "currencies_config" then
        Market.currencies = receive.currencies or {}
        -- ARCFALL DEBUG: dump received currency table so we can verify the
        -- clientId we'll feed to UIItem:setItemId(). Remove after fix.
        for cid, cur in pairs(Market.currencies) do
            print(string.format("[arcfall.market] currency[%s] type=%s name=%s clientId=%s image=%s",
                tostring(cid), tostring(cur.type), tostring(cur.name),
                tostring(cur.clientId), tostring(cur.image)))
        end

    elseif p == "towns_list" then
        Market.towns = receive.towns or {}
        Market.homeTownId = receive.home_town_id or 0
        if BuyNowWindow then fillTownCombo(BuyNowWindow:getChildById("townCombo")) end
        if FulfillBuyOfferWindow then fillTownCombo(FulfillBuyOfferWindow:getChildById("townCombo")) end
        if createBuyOfferWindow then fillTownCombo(createBuyOfferWindow:getChildById("townCombo")) end

    elseif p == "balance" then
        Market.balance = {
            gold              = receive.gold,
            gold_cash         = receive.gold_cash,
            gold_bank         = receive.gold_bank,
            affordable_count  = receive.affordable_count,
            -- ARCFALL: per-quote breakdown shipped alongside the balance
            -- so the confirm windows can show "From backpack X / From bank Y".
            quote_amount      = receive.quote_amount,
            from_cash         = receive.from_cash,
            from_bank         = receive.from_bank,
            cash_after        = receive.cash_after,
            bank_after        = receive.bank_after,
        }
        refreshBalanceLabel()

    elseif p == "buy_token" then
        dispatchBuyWithToken(receive)

    elseif p == "bid_rejected" then
        if BidWindow and BidWindow:isVisible() then
            local errLabel = BidWindow:getChildById("errorLabel")
            local minNext  = receive.min_next_bid
            if errLabel then
                errLabel:setVisible(true)
                errLabel:setText("Bid rejected. Minimum: " .. formatMoney(minNext or 0))
            end
            local minBidLabel = BidWindow:getChildById("minBidLabel")
            if minBidLabel and minNext then
                safeText(minBidLabel, "Minimum next bid: " .. formatMoney(minNext))
            end
        else
            showError("Your bid was rejected. Minimum was " .. formatMoney(receive.min_next_bid or 0) .. ".")
        end

    elseif p == "outbid" then
        local msg = string.format(
            "Outbid on %s: %s -> %s",
            receive.item_name or "item",
            formatMoney(receive.old_bid or 0),
            formatMoney(receive.new_bid or 0)
        )
        if modules.game_textmessage and modules.game_textmessage.displayGameMessage then
            modules.game_textmessage.displayGameMessage(msg)
        else
            g_logger.info("Market: " .. msg)
        end

    elseif p == "error" then
        showError(receive.message or "An unknown market error occurred.")

    elseif p == "item_history" then
        if Detail and Detail.onItemHistoryReceived then
            Detail.onItemHistoryReceived(receive)
        end

    -- ----- List packets -------------------------------------------------
    elseif p == "buy_items" then
        show()

        if receive.first then
            Current.page          = receive.page
            Current.category      = receive.category
            Current.max_page      = receive.max_page
            Current.search_string = receive.search_string
            Current.last_count    = #(receive.market_items or {})
            updatePageWidget()
            BuyPanel:getChildById("items"):destroyChildren()
            BuyPanel:getChildById("buyButton"):setEnabled(false)
            BuyPanel:getChildById("bidButton"):setEnabled(false)
            BuyPanel:getChildById("buyButton"):setImageColor("red")
            BuyPanel:getChildById("bidButton"):setImageColor("red")
        end

        local function onFocus(widget, market_item, focused)
            if focused then
                local mode = market_item.listing_mode or
                    (isAuction(market_item) and Market.LISTING_MODE.AUCTION or Market.LISTING_MODE.BUY_NOW)
                local canBuy = (mode == Market.LISTING_MODE.BUY_NOW or mode == Market.LISTING_MODE.BOTH)
                local canBid = (mode == Market.LISTING_MODE.AUCTION or mode == Market.LISTING_MODE.BOTH)
                BuyPanel:getChildById("buyButton"):setEnabled(canBuy)
                BuyPanel:getChildById("bidButton"):setEnabled(canBid)
                BuyPanel:getChildById("buyButton"):setImageColor(canBuy and "green" or "red")
                BuyPanel:getChildById("bidButton"):setImageColor(canBid and "green" or "red")
                Current.buyWidget = widget
            end
        end

        local list = receive.market_items or {}
        local emptyText
        local hasFilter = (Current.search_string and Current.search_string ~= "")
            or (Current.category and Current.category ~= 1)
        if #list == 0 and hasFilter then
            emptyText = "No listings match your filters. Try a different category, clear the search, or use the Clear filters button below."
        elseif #list == 0 then
            emptyText = "No listings yet. Be the first to post one with the Sell tab!"
        end
        setEmptyState(BuyPanel, #list == 0, emptyText)

        if #list > 0 then
            for n, market_item in ipairs(list) do
                local widget = g_ui.createWidget("MarketItem", BuyPanel:getChildById("items"))
                widget.market_item = market_item

                local mode = market_item.listing_mode or
                    (isAuction(market_item) and Market.LISTING_MODE.AUCTION or Market.LISTING_MODE.BUY_NOW)
                applyModeTint(widget, mode, n)

                local txt, col = timerTextAndColor(market_item.timeleft)
                local timerW = widget:getChildById("timer")
                timerW:setText(txt)
                if timerW.setColor then timerW:setColor(col) end

                widget:getChildById("item"):setItemId(market_item.clientId)
                widget:getChildById("item"):setItemCount(market_item.count)
                -- Hover tooltip on rows disabled — Detail window covers this.
                -- attachTooltipHover(widget, market_item)

                local nameLbl = widget:getChildById("name")
                nameLbl:setText(truncate(market_item.item_name or "", NAME_TRUNC))
                -- Rich tooltip is set on the row + children by the
                -- attachTooltipHover call below; no per-cell tooltip needed.
                -- Mode badge text/color is wired by applyModeTint above.

                widget:getChildById("amount"):setText(market_item.count)
                local hasBid = market_item.bid and market_item.bid > 0
                local auctionLbl = widget:getChildById("auction")
                auctionLbl:setText(hasBid and formatGoldShort(market_item.bid) or "\151")
                if auctionLbl.setColor then
                    auctionLbl:setColor(hasBid and "#ffd866" or "#666666")
                end
                applyCurrencyStyle(widget:getChildById("price"), market_item.currency, market_item.price)

                widget.onFocusChange = function(_, focused) onFocus(widget, market_item, focused) end
                widget.onClick       = function(_, focused)
                    onFocus(widget, market_item, focused)
                    if Detail and Detail.open then Detail.open(market_item) end
                end
                -- Double-click = primary action: Buy Now if available, else
                -- Place Bid. The bottom buttons remain the discoverable path.
                widget.onDoubleClick = function()
                    onFocus(widget, market_item, true)
                    local m = market_item.listing_mode or
                        (isAuction(market_item) and Market.LISTING_MODE.AUCTION or Market.LISTING_MODE.BUY_NOW)
                    if m == Market.LISTING_MODE.BUY_NOW or m == Market.LISTING_MODE.BOTH then
                        buyItem()
                    else
                        bidItem()
                    end
                end
            end
            local first = BuyPanel:getChildById("items"):getChildByIndex(1)
            if first then first:focus() end
        end

        SellPanel:hide()
        HistoricPanel:hide()
        BuyPanel:show()
        MainWindow:show()

    elseif p == "sell_items" then
        if receive.first then
            SellPanel:getChildById("items"):destroyChildren()
        end

        local list = receive.market_items or {}
        setEmptyState(SellPanel, #list == 0)

        for idx, market_item in ipairs(list) do
            local widget = g_ui.createWidget("MarketSellItem", SellPanel:getChildById("items"))
            widget.market_item = market_item

            local mode = market_item.listing_mode or
                (isAuction(market_item) and Market.LISTING_MODE.AUCTION or Market.LISTING_MODE.BUY_NOW)
            applyModeTint(widget, mode, idx)

            local txt, col = timerTextAndColor(market_item.timeleft)
            local timerW = widget:getChildById("timer")
            timerW:setText(txt)
            if timerW.setColor then timerW:setColor(col) end

            widget:getChildById("item"):setItemId(market_item.clientId)
            widget:getChildById("item"):setItemCount(market_item.count)
            -- attachTooltipHover(widget, market_item)  -- row hover disabled

            local nameLbl = widget:getChildById("name")
            nameLbl:setText(truncate(market_item.item_name or "", NAME_TRUNC))
            -- Mode badge wired by applyModeTint above.

            widget:getChildById("amount"):setText(market_item.count)
            local hasBid = market_item.bid and market_item.bid > 0
            local auctionLbl = widget:getChildById("auction")
            auctionLbl:setText(hasBid and formatGoldShort(market_item.bid) or "\151")
            if auctionLbl.setColor then
                auctionLbl:setColor(hasBid and "#ffd866" or "#666666")
            end
            applyCurrencyStyle(widget:getChildById("price"), market_item.currency, market_item.price)
        end

    elseif p == "buy_offers" then
        show()

        if receive.first then
            Current.buyOffersPage    = receive.page
            Current.buyOffersMaxPage = receive.max_page
            updateBuyOffersPageWidget()
            BuyOffersPanel:getChildById("items"):destroyChildren()
            BuyOffersPanel:getChildById("fulfillButton"):setEnabled(false)
            BuyOffersPanel:getChildById("fulfillButton"):setImageColor("red")
        end

        local function onFocus(widget, buy_offer, focused)
            if focused then
                BuyOffersPanel:getChildById("fulfillButton"):setEnabled(true)
                BuyOffersPanel:getChildById("fulfillButton"):setImageColor("green")
                Current.buyOfferWidget = widget
            end
        end

        local list = receive.buy_offers or {}
        setEmptyState(BuyOffersPanel, #list == 0)

        if #list > 0 then
            for idx, buy_offer in ipairs(list) do
                local widget = g_ui.createWidget("MarketBuyOfferListItem", BuyOffersPanel:getChildById("items"))
                widget.buy_offer = buy_offer
                applyModeTint(widget, nil, idx)

                local txt, col = timerTextAndColor(buy_offer.timeleft)
                local timerW = widget:getChildById("timer")
                timerW:setText(txt)
                if timerW.setColor then timerW:setColor(col) end

                widget:getChildById("item"):setItemId(buy_offer.clientId)
                widget:getChildById("item"):setItemCount(buy_offer.count)
                -- attachTooltipHover(widget, buy_offer)  -- row hover disabled

                local nameLbl = widget:getChildById("name")
                nameLbl:setText(truncate(buy_offer.item_name or "", NAME_TRUNC))

                local buyerLbl = widget:getChildById("buyer")
                buyerLbl:setText(truncate(buy_offer.buyer_name or "", PLAYER_TRUNC))

                widget:getChildById("amount"):setText(buy_offer.count)
                applyCurrencyStyle(widget:getChildById("price"), buy_offer.currency, buy_offer.price)

                widget.onFocusChange = function(_, focused) onFocus(widget, buy_offer, focused) end
                widget.onClick       = function(_, focused) onFocus(widget, buy_offer, focused) end
                widget.onDoubleClick = function()
                    onFocus(widget, buy_offer, true)
                    fulfillBuyOffer()
                end
            end
            local first = BuyOffersPanel:getChildById("items"):getChildByIndex(1)
            if first then first:focus() end
        end

        SellPanel:hide()
        HistoricPanel:hide()
        BuyPanel:hide()
        MyBuyOffersPanel:hide()
        BuyOffersPanel:show()
        MainWindow:show()

    elseif p == "my_buy_offers" then
        if receive.first then
            MyBuyOffersPanel:getChildById("items"):destroyChildren()
        end

        local list = receive.buy_offers or {}
        setEmptyState(MyBuyOffersPanel, #list == 0)

        for idx, buy_offer in ipairs(list) do
            local widget = g_ui.createWidget("MarketBuyOfferItem", MyBuyOffersPanel:getChildById("items"))
            widget.buy_offer = buy_offer
            applyModeTint(widget, nil, idx)

            local txt, col = timerTextAndColor(buy_offer.timeleft)
            local timerW = widget:getChildById("timer")
            timerW:setText(txt)
            if timerW.setColor then timerW:setColor(col) end

            widget:getChildById("item"):setItemId(buy_offer.clientId)
            widget:getChildById("item"):setItemCount(buy_offer.count)
            -- attachTooltipHover(widget, buy_offer)  -- row hover disabled

            local nameLbl = widget:getChildById("name")
            nameLbl:setText(truncate(buy_offer.item_name or "", NAME_TRUNC))

            widget:getChildById("amount"):setText(buy_offer.count)
            applyCurrencyStyle(widget:getChildById("price"), buy_offer.currency, buy_offer.price)
        end

    elseif p == "item_to_sell" then
        createConfirmSellOfferWindow(receive)

    elseif p == "historic" then
        if receive.first then
            HistoricPanel:getChildById("list"):destroyChildren()
        end
        local hs = receive.historic or {}
        setEmptyState(HistoricPanel, #hs == 0)
        for num, h in ipairs(hs) do
            local text = g_ui.createWidget("HistoricLabel", HistoricPanel:getChildById("list"))
            text:setId("historicwidget: " .. num)
            text:setText(" " .. (h.negotiation or ""))
        end
    end
end

-- =====================================================================
-- Listing wizard (3-mode)
-- =====================================================================

-- Set the title + subtitle children of a mode card. The card style now
-- composes two child Labels rather than relying on \n in OTUI text.
local function setModeCardText(card, title, subtitle)
    if not card then return end
    local titleLbl = card:getChildById("title")
    local subLbl   = card:getChildById("subtitle")
    if titleLbl then titleLbl:setText(title or "") end
    if subLbl   then subLbl:setText(subtitle or "") end
end

local function setListingMode(mode)
    if not sellConfirmWindow then return end
    sellConfirmWindow.currentMode = mode

    local cardBuy   = sellConfirmWindow:getChildById("modeBuyNow")
    local cardAuc   = sellConfirmWindow:getChildById("modeAuction")
    local cardBoth  = sellConfirmWindow:getChildById("modeBoth")
    if cardBuy  then cardBuy:setOn(mode == Market.LISTING_MODE.BUY_NOW) end
    if cardAuc  then cardAuc:setOn(mode == Market.LISTING_MODE.AUCTION) end
    if cardBoth then cardBoth:setOn(mode == Market.LISTING_MODE.BOTH) end

    local priceLbl  = sellConfirmWindow:getChildById("pricelabel")
    local priceEdit = sellConfirmWindow:getChildById("priceedit")
    local bidLbl    = sellConfirmWindow:getChildById("bidLabel")
    local bidEdit   = sellConfirmWindow:getChildById("bidEdit")

    -- Conditional fields:
    --   BUY_NOW: price ON, bid OFF
    --   AUCTION: price OFF, bid ON
    --   BOTH:    price ON, bid ON
    local showPrice = (mode == Market.LISTING_MODE.BUY_NOW or mode == Market.LISTING_MODE.BOTH)
    local showBid   = (mode == Market.LISTING_MODE.AUCTION or mode == Market.LISTING_MODE.BOTH)
    if priceLbl  then priceLbl:setVisible(showPrice) end
    if priceEdit then
        priceEdit:setVisible(showPrice)
        priceEdit:setEnabled(showPrice)
        if not showPrice then priceEdit:setText("") end
    end
    if bidLbl  then bidLbl:setVisible(showBid) end
    if bidEdit then
        bidEdit:setVisible(showBid)
        bidEdit:setEnabled(showBid)
        if not showBid then bidEdit:setText("") end
    end

    -- Keep the legacy hidden checkbox in sync so old logic that reads it
    -- still gets the right answer.
    local cb = sellConfirmWindow:getChildById("bidCheckBox")
    if cb then cb:setChecked(showBid) end

    -- Auction-only and Both modes are unique-item only (count = 1).
    -- Force the amount slider to 1 and disable it visually so users can't
    -- accidentally try to auction a stack — server enforces this anyway,
    -- but better to surface in the UI before the form is submitted.
    local amount = sellConfirmWindow:getChildById("amount") or sellConfirmWindow:getChildById("amountScroll")
    local amountHint = sellConfirmWindow:getChildById("amountHint")
    local isUnique = (mode == Market.LISTING_MODE.AUCTION or mode == Market.LISTING_MODE.BOTH)
    if amount then
        if isUnique then
            amount:setValue(1)
            if amount.setText then amount:setText(1) end
            amount:setEnabled(false)
        else
            amount:setEnabled(true)
        end
    end
    if amountHint then
        if isUnique then
            amountHint:setText("Auctions are 1-of-a-kind: count is locked to 1.")
            amountHint:setColor("#c9a633")
        else
            amountHint:setText("Quantity to list. Stackables can list any amount.")
            amountHint:setColor("#888888")
        end
    end

    onSellPriceChange()
end

function createConfirmSellOfferWindow(receive)
    if not sellConfirmWindow then
        sellConfirmWindow = g_ui.createWidget("createConfirmSellWindow", rootWidget)
        sellConfirmWindow.priceedit:setMaxLength(15)
        sellConfirmWindow.bidEdit:setMaxLength(15)
        for _, _time in ipairs(Market.time) do
            sellConfirmWindow.timebox:addOption(_time.name)
        end

        -- Set stacked title + subtitle for each mode card (replaces the
        -- old \n hack that was rendering as literal backslash-n).
        setModeCardText(sellConfirmWindow:getChildById("modeBuyNow"),
            "Buy Now",  "Fixed price, instant")
        setModeCardText(sellConfirmWindow:getChildById("modeAuction"),
            "Auction",  "Highest bid wins")
        setModeCardText(sellConfirmWindow:getChildById("modeBoth"),
            "Both",     "Bid or buy out")

        -- Mode card click handlers
        local cardBuy   = sellConfirmWindow:getChildById("modeBuyNow")
        local cardAuc   = sellConfirmWindow:getChildById("modeAuction")
        local cardBoth  = sellConfirmWindow:getChildById("modeBoth")
        if cardBuy  then cardBuy.onClick  = function() setListingMode(Market.LISTING_MODE.BUY_NOW) end end
        if cardAuc  then cardAuc.onClick  = function() setListingMode(Market.LISTING_MODE.AUCTION) end end
        if cardBoth then cardBoth.onClick = function() setListingMode(Market.LISTING_MODE.BOTH) end end
    end

    -- Currency dropdown
    sellConfirmWindow.currency:clearOptions()
    for id, currency in pairs(Market.currencies) do
        sellConfirmWindow.currency:addOption(currency.name, id)
    end
    sellConfirmWindow.currency:setCurrentIndex(1)

    sellConfirmWindow:show()
    sellConfirmWindow.item.item = receive
    sellConfirmWindow.item:setItemId(receive.clientId)
    sellConfirmWindow.item:setItemCount(1)
    attachTooltipHover(sellConfirmWindow.item, receive)

    sellConfirmWindow.name:setText(receive.item_name or "")
    bindQuantityPicker(sellConfirmWindow.amount, 1, receive.count or 1, function(value)
        sellConfirmWindow.item:setItemCount(value)
        onSellPriceChange()
    end)
    sellConfirmWindow.amount:setEnabled(true)
    sellConfirmWindow.priceedit:clearText()
    sellConfirmWindow.bidEdit:clearText()
    clearInlineError(sellConfirmWindow:getChildById("errorLabel"))

    -- Default to Buy Now
    setListingMode(Market.LISTING_MODE.BUY_NOW)
end

function onSellPriceChange()
    if not sellConfirmWindow then return end
    local mode  = sellConfirmWindow.currentMode or Market.LISTING_MODE.BUY_NOW
    local value = math.max(1, sellConfirmWindow.amount:getValue())
    local price = tonumber(sellConfirmWindow.priceedit:getText())
    local bid   = tonumber(sellConfirmWindow.bidEdit:getText())
    local feeBase = price or bid or 0
    local feeLabel    = findDescendant(sellConfirmWindow, "fee")
    local totalLabel  = findDescendant(sellConfirmWindow, "totalCost")
    local previewLabel = findDescendant(sellConfirmWindow, "previewMode")

    local rawFee = getMarketFee(feeBase * value)
    local total = (feeBase * value) + rawFee
    if feeLabel   then feeLabel:setText("Fee: " .. (rawFee == 0 and "free" or formatMoney(rawFee))) end
    if totalLabel then totalLabel:setText("Total: " .. formatMoney(total)) end

    if previewLabel then
        if mode == Market.LISTING_MODE.BUY_NOW then
            previewLabel:setText(
                "Buy Now @ " .. formatMoney(price or 0) .. " each (" .. value .. ")"
            )
        elseif mode == Market.LISTING_MODE.AUCTION then
            previewLabel:setText(
                "Auction starts at " .. formatMoney(bid or 0) .. " (" .. value .. ")"
            )
        else
            previewLabel:setText(
                "Auction from " .. formatMoney(bid or 0) ..
                ", buy out @ " .. formatMoney(price or 0)
            )
        end
    end
end

function onConfirmSellItem()
    if not sellConfirmWindow then return end
    local errLabel = sellConfirmWindow:getChildById("errorLabel")
    clearInlineError(errLabel)

    local mode = sellConfirmWindow.currentMode or Market.LISTING_MODE.BUY_NOW
    local price = tonumber(sellConfirmWindow.priceedit:getText()) or 0
    local bid   = tonumber(sellConfirmWindow.bidEdit:getText()) or 0
    local count = sellConfirmWindow.amount:getValue()

    -- Validation per mode
    if mode == Market.LISTING_MODE.BUY_NOW and price <= 0 then
        showError("Enter a Buy Now price greater than 0.", errLabel)
        return
    end
    if mode == Market.LISTING_MODE.AUCTION and bid <= 0 then
        showError("Enter a starting bid greater than 0.", errLabel)
        return
    end
    if mode == Market.LISTING_MODE.BOTH then
        if price <= 0 then
            showError("Enter a Buy Now price greater than 0.", errLabel)
            return
        end
        if bid <= 0 then
            showError("Enter a starting bid greater than 0.", errLabel)
            return
        end
        if bid >= price then
            showError("Starting bid must be less than the Buy Now price.", errLabel)
            return
        end
    end

    Market.showConfirm(
        "Confirm listing",
        "List " .. count .. " x " .. (sellConfirmWindow.name:getText() or "item") .. "?",
        function()
            local option     = sellConfirmWindow.currency:getCurrentOption()
            local currencyID = option and option.data or 1

            local buffer = {
                protocol    = "sell_item",
                itemid      = sellConfirmWindow.item.item.itemid,
                item_code   = sellConfirmWindow.item.item.item_code,
                count       = count,
                maxtime     = sellConfirmWindow.timebox:getText(),
                price       = price,
                bid         = bid,
                currency    = currencyID,
                description = descHolder,
                listing_mode = mode,
            }
            descHolder = ""
            for id, _time in ipairs(Market.time) do
                if _time.name == sellConfirmWindow.timebox:getText() then
                    buffer.maxtime = id
                    break
                end
            end
            sendOpcode(buffer)
            sellConfirmWindow:hide()
        end,
        "List Item",
        false
    )
end

-- Legacy hook kept so the previous wiring still works in case other code
-- calls it externally. The mode selector now drives bid visibility, but
-- toggling the hidden checkbox still flips the bid field's enabled state.
function onSellBidCheckChange()
    local cb = sellConfirmWindow:getChildById("bidCheckBox")
    if cb then
        sellConfirmWindow.bidEdit:setEnabled(cb:isChecked())
    end
end

function selectItemToSell(item)
    descHolder = item:getTooltip()
    sendOpcode({
        protocol    = "selectItemToSell",
        pos         = item:getPosition(),
        count       = item:getCount(),
        description = item:getTooltip(),
    })
end

-- =====================================================================
-- Panel switching
-- =====================================================================

function hideAllPanels()
    if BuyPanel         then BuyPanel:hide() end
    if SellPanel        then SellPanel:hide() end
    if HistoricPanel    then HistoricPanel:hide() end
    if BuyOffersPanel   then BuyOffersPanel:hide() end
    if MyBuyOffersPanel then MyBuyOffersPanel:hide() end
end

local WINDOW_WIDTH_DEFAULT  = 920
local WINDOW_HEIGHT_DEFAULT = 540

local function setWindowSize(width, height)
    if MainWindow then
        MainWindow:setSize({width = width, height = height})
    end
end

local function setActiveTab(id)
    local catsWidget = MainWindow and MainWindow:getChildById("cats")
    if not catsWidget then return end
    for _, name in ipairs({"buyTabButton","sellTabButton","buyOffersTabButton",
                           "myBuyOffersTabButton","historicTabButton"}) do
        local b = catsWidget:getChildById(name)
        if b and b.setOn then b:setOn(name == id) end
    end
end

function showBuyPanel()
    hideAllPanels()
    setWindowSize(WINDOW_WIDTH_DEFAULT, WINDOW_HEIGHT_DEFAULT)
    setActiveTab("buyTabButton")
    Current.page = 1
    getBuyItems()
    if BuyPanel then BuyPanel:show() end
    if MainWindow then MainWindow:show(); MainWindow:focus() end
end

function showSellPanel()
    hideAllPanels()
    setWindowSize(WINDOW_WIDTH_DEFAULT, WINDOW_HEIGHT_DEFAULT)
    setActiveTab("sellTabButton")
    getSellItems()
    if SellPanel then SellPanel:show() end
    if MainWindow then MainWindow:show() end
end

function showHistoricPanel()
    hideAllPanels()
    setWindowSize(WINDOW_WIDTH_DEFAULT, WINDOW_HEIGHT_DEFAULT)
    setActiveTab("historicTabButton")
    sendOpcode({ protocol = "historic" })
    if HistoricPanel then HistoricPanel:show() end
    if MainWindow then MainWindow:show() end
end

function showBuyOffersPanel()
    hideAllPanels()
    setWindowSize(WINDOW_WIDTH_DEFAULT, WINDOW_HEIGHT_DEFAULT)
    setActiveTab("buyOffersTabButton")
    Current.buyOffersPage = 1
    getBuyOfferItems()
    if BuyOffersPanel then BuyOffersPanel:show() end
    if MainWindow then MainWindow:show() end
end

function showMyBuyOffersPanel()
    hideAllPanels()
    setWindowSize(WINDOW_WIDTH_DEFAULT, WINDOW_HEIGHT_DEFAULT)
    setActiveTab("myBuyOffersTabButton")
    getMyBuyOffers()
    if MyBuyOffersPanel then MyBuyOffersPanel:show() end
    if MainWindow then MainWindow:show() end
end

-- =====================================================================
-- Buy offer creation
-- =====================================================================

function createBuyOffer()
    if MainWindow then MainWindow:hide() end

    if not createBuyOfferWindow then
        createBuyOfferWindow = g_ui.createWidget("createConfirmBuyOfferWindow", rootWidget)
        createBuyOfferWindow:setId("createBuyOfferWindow")  -- preserve ID for uiitem.lua drag-drop hook

        local priceEdit = createBuyOfferWindow:getChildById("priceedit")
        if priceEdit then priceEdit:setMaxLength(15) end

        local timeCombo = createBuyOfferWindow:getChildById("timebox")
        if timeCombo then
            for _, _time in ipairs(Market.time) do
                timeCombo:addOption(_time.name)
            end
        end

        -- Debounce search: re-typing within 200ms cancels the prior query.
        -- Avoids hammering the items DB on every keystroke.
        local searchEdit = createBuyOfferWindow:getChildById("itemSearch")
        if searchEdit then
            searchEdit.onTextChange = function(self, text)
                if createBuyOfferWindow._searchEvent then
                    removeEvent(createBuyOfferWindow._searchEvent)
                end
                createBuyOfferWindow._searchEvent = scheduleEvent(function()
                    createBuyOfferWindow._searchEvent = nil
                    searchItemsByName(text)
                end, 200)
            end
        end
    end

    local currencyCombo = createBuyOfferWindow:getChildById("currency")
    if currencyCombo then
        currencyCombo:clearOptions()
        for id, currency in pairs(Market.currencies) do
            currencyCombo:addOption(currency.name, id)
        end
        currencyCombo:setCurrentIndex(1)
    end

    fillTownCombo(createBuyOfferWindow:getChildById("townCombo"))

    local itemWidget = createBuyOfferWindow:getChildById("item")
    if itemWidget then
        itemWidget:setItemId(0)
        itemWidget.serverId = nil
        itemWidget.clientId = nil
    end
    local nameLabel = createBuyOfferWindow:getChildById("name")
    if nameLabel then nameLabel:setText("Select an item above") end

    local amountScroll = createBuyOfferWindow:getChildById("amount")
    if amountScroll then
        bindQuantityPicker(amountScroll, 1, 10000, function() onBuyOfferPriceChange() end)
    end

    local priceEdit = createBuyOfferWindow:getChildById("priceedit")
    if priceEdit then priceEdit:clearText() end

    local searchEdit = createBuyOfferWindow:getChildById("itemSearch")
    if searchEdit then searchEdit:setText("") end

    local searchResults = createBuyOfferWindow:getChildById("searchResults")
    if searchResults then searchResults:destroyChildren() end

    -- Reset prior selection bookkeeping; Confirm stays disabled until the
    -- user picks an item from the search results.
    createBuyOfferWindow._selectedRow = nil
    local confirmBtn = createBuyOfferWindow:getChildById("confirm")
    if confirmBtn then
        confirmBtn:setEnabled(false)
        confirmBtn:setTooltip("Search and select an item above first.")
    end

    clearInlineError(createBuyOfferWindow:getChildById("errorLabel"))

    -- Initial preview totals (shows "Total: 0 / Fee: free")
    onBuyOfferPriceChange()

    createBuyOfferWindow:show()
    createBuyOfferWindow:focus()
end

function searchItemsByName(searchText)
    if not createBuyOfferWindow then return end

    local searchResults = createBuyOfferWindow:getChildById("searchResults")
    if not searchResults then return end

    searchResults:destroyChildren()
    if not searchText or searchText:len() < 2 then return end

    if not Market.itemsDatabaseLoaded then loadItemsDatabase() end

    local searchLower = searchText:lower()
    local count = 0
    local maxResults = 50
    local addedNames = {}
    local results = {}

    for serverId, itemData in pairs(Market.itemsDatabase) do
        if count >= maxResults then break end
        local itemName = itemData.name
        if itemName and itemName ~= "" and not addedNames[itemName:lower()] then
            if itemName:lower():find(searchLower, 1, true) then
                local clientId = nil
                local isStackable = false
                local itemType = g_things.getItemType(serverId)
                if itemType then
                    clientId = itemType:getClientId()
                    if itemType.isStackable then isStackable = itemType:isStackable() end
                end
                if not clientId or clientId == 0 then
                    local success, item = pcall(function() return Item.create(serverId) end)
                    if success and item then
                        if item.getClientId then clientId = item:getClientId() end
                        if item.isStackable then isStackable = item:isStackable() end
                    end
                end
                if not clientId or clientId == 0 then
                    local thingType = g_things.getThingType(serverId, ThingCategoryItem)
                    if thingType then
                        clientId = serverId
                        if thingType.isStackable then isStackable = thingType:isStackable() end
                    end
                end
                if clientId and clientId > 0 then
                    local thingType = g_things.getThingType(clientId, ThingCategoryItem)
                    if thingType then
                        addedNames[itemName:lower()] = true
                        table.insert(results, {
                            serverId = serverId,
                            clientId = clientId,
                            name = itemName,
                            isStackable = isStackable,
                        })
                        count = count + 1
                    end
                end
            end
        end
    end

    table.sort(results, function(a, b) return a.name:lower() < b.name:lower() end)

    for _, result in ipairs(results) do
        local widget = g_ui.createWidget("ItemSearchResultItem", searchResults)
        widget.serverId    = result.serverId
        widget.clientId    = result.clientId
        widget.itemName    = result.name
        widget.isStackable = result.isStackable

        local itemWidget = widget:getChildById("itemWidget")
        if itemWidget then
            itemWidget:setItemId(result.clientId)
            if result.isStackable then itemWidget:setItemCount(1) end
        end

        local nameLabel = widget:getChildById("itemName")
        if nameLabel then
            nameLabel:setText(result.name)
            -- Stackable hint: subtle tooltip + small color shift so it's
            -- decodable without color-only reliance.
            if result.isStackable then
                nameLabel:setColor("#aaffaa")
                widget:setTooltip("Stackable item \151 you can buy multiple at once")
            else
                widget:setTooltip("Single-stack item")
            end
        end

        -- Single-click selects (modern UX). Double-click is kept for users
        -- who instinctively double-click; both end up at the same handler.
        local pickFn = function()
            selectBuyOfferItem(result.serverId, result.clientId, result.name,
                               result.isStackable, widget)
        end
        widget.onClick       = pickFn
        widget.onDoubleClick = pickFn
    end

    if count == 0 then
        local noResults = g_ui.createWidget("Label", searchResults)
        noResults:setText("No items found")
        noResults:setTextAlign(AlignCenter)
        noResults:setColor("#888888")
        noResults:setMarginTop(12)
        noResults:setFont("verdana-11px-antialised")
    end
end

function selectBuyOfferItem(serverId, clientId, itemName, isStackable, sourceRow)
    if not createBuyOfferWindow then return end
    local itemWidget = createBuyOfferWindow:getChildById("item")
    if itemWidget then
        itemWidget:setItemId(clientId)
        itemWidget.serverId    = serverId
        itemWidget.clientId    = clientId
        itemWidget.isStackable = isStackable
    end
    local nameLabel = createBuyOfferWindow:getChildById("name")
    if nameLabel then
        local suffix = isStackable and " (stackable)" or ""
        nameLabel:setText((itemName or "Unknown Item") .. suffix)
        nameLabel:setColor(isStackable and "#aaffaa" or "#ffffff")
    end
    local amountScroll = createBuyOfferWindow:getChildById("amount")
    if amountScroll then
        bindQuantityPicker(amountScroll, 1, 10000, function() onBuyOfferPriceChange() end)
    end

    -- Visual selection state: clear $on from the previously-picked row,
    -- set on the new one. Drag-drop callers (no sourceRow) skip this.
    local prev = createBuyOfferWindow._selectedRow
    if prev and prev ~= sourceRow and not prev:isDestroyed() then
        prev:setOn(false)
    end
    if sourceRow and not sourceRow:isDestroyed() then
        sourceRow:setOn(true)
    end
    createBuyOfferWindow._selectedRow = sourceRow

    -- Once an item is picked the Confirm button is actionable. Tooltip
    -- swap removes the "search first" hint.
    local confirmBtn = createBuyOfferWindow:getChildById("confirm")
    if confirmBtn then
        confirmBtn:setEnabled(true)
        confirmBtn:setTooltip("")
    end

    -- Recompute totals immediately so the preview updates the moment an item
    -- is picked, not only after the user touches the price field.
    onBuyOfferPriceChange()
end

function onBuyOfferPriceChange()
    if not createBuyOfferWindow then return end
    local amountScroll = createBuyOfferWindow:getChildById("amount")
    local priceEdit    = createBuyOfferWindow:getChildById("priceedit")
    -- Use deep descendant lookup: fee/totalCost are nested in previewBox,
    -- and some sandboxed builds had a non-recursive getChildById.
    local feeLabel     = findDescendant(createBuyOfferWindow, "fee")
    local totalLabel   = findDescendant(createBuyOfferWindow, "totalCost")
    if not amountScroll or not priceEdit or not feeLabel or not totalLabel then
        g_logger.info("[Market] price-change skipped (missing widget)")
        return
    end

    local value = math.max(1, amountScroll:getValue())
    local priceNumber = tonumber(priceEdit:getText())
    if priceNumber and priceNumber > 0 then
        local rawFee   = getMarketFee(priceNumber * value)
        local rawTotal = (priceNumber * value) + rawFee
        feeLabel:setText("Fee: " .. (rawFee == 0 and "free" or formatMoney(rawFee)))
        totalLabel:setText("Total: " .. formatMoney(rawTotal))
    else
        feeLabel:setText("Fee: free")
        totalLabel:setText("Total: 0 gold")
    end
end

function onConfirmBuyOffer()
    if not createBuyOfferWindow then return end
    local errLabel = createBuyOfferWindow:getChildById("errorLabel")
    clearInlineError(errLabel)

    local itemWidget = createBuyOfferWindow:getChildById("item")
    if not itemWidget or not itemWidget.serverId or itemWidget.serverId == 0 then
        showError("Pick an item from the search results first.", errLabel)
        return
    end

    local currencyCombo = createBuyOfferWindow:getChildById("currency")
    local priceEdit     = createBuyOfferWindow:getChildById("priceedit")
    local amountScroll  = createBuyOfferWindow:getChildById("amount")
    local timeCombo     = createBuyOfferWindow:getChildById("timebox")
    local townCombo     = createBuyOfferWindow:getChildById("townCombo")

    if not currencyCombo or not priceEdit or not amountScroll or not timeCombo then return end

    local option       = currencyCombo:getCurrentOption()
    local currencyID   = option and option.data or 1
    local currencyPrice = tonumber(priceEdit:getText()) or 0

    if currencyPrice <= 0 then
        showError("Enter a price greater than 0.", errLabel)
        return
    end

    Market.showConfirm(
        "Create buy request?",
        "Reserve " .. formatMoney(currencyPrice * amountScroll:getValue()) .. " from your bank for this request?",
        function()
            local buffer = {
                protocol = "create_buy_offer",
                itemid   = itemWidget.serverId,
                count    = amountScroll:getValue(),
                maxtime  = timeCombo:getText(),
                price    = currencyPrice,
                currency = currencyID,
                town_id  = getSelectedTown(townCombo),
            }
            for id, _time in ipairs(Market.time) do
                if _time.name == timeCombo:getText() then
                    buffer.maxtime = id
                    break
                end
            end
            sendOpcode(buffer)
            createBuyOfferWindow:hide()
            getMyBuyOffers()
            getBuyItems()
            showBuyOffersPanel()
            if MainWindow then MainWindow:show() end
        end,
        "Create request",
        false
    )
end

-- =====================================================================
-- Drag-drop hook (called from gamelib/ui/uiitem.lua)
-- =====================================================================

-- self = UIItem dropped onto, item = currentDragThing.
-- We support drops onto FulfillBuyOfferWindow's #item slot to pick the
-- item to use for fulfilling, and onto createBuyOfferWindow's #item slot
-- as a quick "select this kind of item" shortcut.
function onItemDrop(self, item)
    if not item or not self then return end
    local clientId = item:getId()
    local count = item:getCount() or 1
    local pos = item:getPosition()

    -- Walk up to find which window we're in
    local p = self:getParent()
    while p do
        local pid = p:getId()
        if pid == 'FulfillBuyOfferWindow' then
            selectFulfillItem(clientId, clientId, "", count > 1, pos)
            return
        elseif pid == 'createBuyOfferWindow' then
            local name = (Market.itemsDatabase[clientId] and Market.itemsDatabase[clientId].name) or "Selected item"
            selectBuyOfferItem(clientId, clientId, name, count > 1)
            return
        end
        p = p:getParent()
    end
end

-- =====================================================================
-- Fulfill buy offer
-- =====================================================================

function selectFulfillItem(serverId, clientId, itemName, isStackable, itemPos)
    if not FulfillBuyOfferWindow then return end
    local rowItem = FulfillBuyOfferWindow:getChildById("item")
    if rowItem then
        rowItem:setItemId(clientId)
        rowItem.serverId    = serverId
        rowItem.clientId    = clientId
        rowItem.isStackable = isStackable
        rowItem.itemPos     = itemPos
    end
end

function fulfillBuyOffer()
    if not Current.buyOfferWidget then return end

    -- Refresh balance so the seller sees current pools before fulfilling.
    sendOpcode({ protocol = "request_balance" })

    local rowItem    = FulfillBuyOfferWindow:getChildById("item")
    local titleLabel = FulfillBuyOfferWindow:getChildById("itemTitle")
    local buyerLabel = FulfillBuyOfferWindow:getChildById("buyerLabel")
    local townCombo  = FulfillBuyOfferWindow:getChildById("townCombo")
    local errLabel   = FulfillBuyOfferWindow:getChildById("errorLabel")
    local totalChip  = findDescendant(FulfillBuyOfferWindow, "totalChip")
    local buy_offer  = Current.buyOfferWidget.buy_offer

    rowItem.itemPos     = nil
    rowItem.serverId    = nil
    rowItem.clientId    = nil
    rowItem.isStackable = nil

    rowItem:setItemId(buy_offer.clientId)
    rowItem:setItemCount(1)
    attachTooltipHover(rowItem, buy_offer)
    safeText(titleLabel, buy_offer.item_name or "")
    safeText(buyerLabel, "Requested by: " .. truncate(buy_offer.buyer_name or "--", PLAYER_TRUNC))
    FulfillBuyOfferWindow:setText(buy_offer.item_name or "Fulfill Buy Request")

    local rowScrollbar = FulfillBuyOfferWindow:getChildById("countScrollBar")
    bindQuantityPicker(rowScrollbar, 1, buy_offer.count or 1, function(value)
        rowItem:setItemCount(value)
        applyCurrencyChip(totalChip, buy_offer.currency, value * (buy_offer.price or 0))
    end)
    applyCurrencyChip(totalChip, buy_offer.currency, (buy_offer.price or 0) * 1)

    fillTownCombo(townCombo)
    clearInlineError(errLabel)

    local okButton     = FulfillBuyOfferWindow:getChildById("buttonOk")
    local cancelButton = FulfillBuyOfferWindow:getChildById("buttonCancel")

    local doFulfill = function()
        local params = {
            protocol      = "fulfill_buy_offer",
            offer_code    = buy_offer.offer_code,
            fulfill_count = rowItem:getItemCount(),
            town_id       = getSelectedTown(townCombo),
        }
        if rowItem.itemPos then
            params.item_pos = {
                x = rowItem.itemPos.x,
                y = rowItem.itemPos.y,
                z = rowItem.itemPos.z,
            }
        end
        sendOpcode(params)
        show()
    end
    local fulfillFunc = function()
        local count = rowItem:getItemCount() or 1
        local total = (buy_offer.price or 0) * count
        if isBigSpend(total) then
            Market.showConfirm(
                "Fulfill request",
                "Sell " .. count .. " x " .. (buy_offer.item_name or "item") ..
                    " for " .. formatMoney(total) .. "?",
                doFulfill,
                "Fulfill",
                true
            )
        else
            doFulfill()
        end
    end
    local cancelFunc = function() show() end

    FulfillBuyOfferWindow.onEnter  = fulfillFunc
    FulfillBuyOfferWindow.onEscape = cancelFunc
    okButton.onClick     = fulfillFunc
    cancelButton.onClick = cancelFunc

    MainWindow:hide()
    FulfillBuyOfferWindow:show()
    FulfillBuyOfferWindow:focus()
end

-- =====================================================================
-- Window show/hide helpers
-- =====================================================================

function show()
    if BuyNowWindow          then BuyNowWindow:hide() end
    if BidWindow             then BidWindow:hide() end
    if FulfillBuyOfferWindow then FulfillBuyOfferWindow:hide() end
    if sellConfirmWindow     then sellConfirmWindow:hide() end
    if createBuyOfferWindow  then createBuyOfferWindow:hide() end
    if MainWindow then
        MainWindow:show()
        MainWindow:focus()
    end
end

function hide()
    -- Stop legacy per-panel timers if any old code re-enabled them.
    for _, panel in ipairs({BuyPanel, SellPanel, BuyOffersPanel, MyBuyOffersPanel}) do
        if panel and panel.event then
            removeEvent(panel.event)
            panel.event = nil
        end
    end
    marketWindowtoggle()
end

function onPositionChange(player, newPos, oldPos)
    if player and player.isLocalPlayer and player:isLocalPlayer() then
        -- placeholder for future: e.g., close window when leaving market house
    end
end

function getMarketFee(price)
    -- Server is the source of truth for fees; this is just a UI hint.
    -- 1% fee, capped at 100k, floor 0 (cheap heuristic for live preview).
    if not price or price <= 0 then return 0 end
    local fee = math.floor(price * 0.01)
    if fee > 100000 then fee = 100000 end
    return fee
end
