-- =====================================================================
-- Arcfall Market Detail Window - controller
--
-- Single-click on a Browse row opens a 560x460 detail window with
-- header, stats column, history column (sparkline + sales list),
-- and an action strip (Place Bid / Buy Now / Close).
--
-- Public API (sandbox-global, accessible as
-- modules.game_marketplace.Detail):
--     Detail.open(market_item)
--     Detail.close()
--     Detail.onItemHistoryReceived(receive)   -- called from parseMarket
--     Detail.onPlaceBidClick()                -- bound from .otui
--     Detail.onBuyNowClick()                  -- bound from .otui
--
-- Owned by: detail agent. Consumes Sparkline (Agent C) and re-uses
-- buyItem / bidItem / formatGoldShort / formatMoney / applyCurrencyStyle
-- / truncate / Market.showConfirm exported from market.lua.
-- =====================================================================

Detail = {}

local detailWindow = nil
local currentItem  = nil
local tickerEvent  = nil
local PLAYER_TRUNC = 16

-- CP1252 separators (single-byte glyphs) - safe with OTCv8 fonts.
local SEP_DASH = "\151"
local SEP_DOT  = "\149"
local ELLIPSIS = "\133"

local TIER_BANNER = {
    common    = nil,
    uncommon  = "[ UNCOMMON ]",
    rare      = "* RARE *",
    epic      = "** EPIC **",
    legendary = "*** LEGENDARY ***",
    mythic    = "**** MYTHIC ****",
}

local TIER_COLOR = {
    common    = { fg = "#cccccc", bg = "#1a1a1a" },
    uncommon  = { fg = "#66dd66", bg = "#0d2a0d" },
    rare      = { fg = "#66aaff", bg = "#0d1a3a" },
    epic      = { fg = "#cc88ff", bg = "#2a0d3a" },
    legendary = { fg = "#ff9933", bg = "#3a1a08" },
    mythic    = { fg = "#ff6666", bg = "#3a0d0d" },
}

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

-- ---------------------------------------------------------------------
-- Local helpers
-- ---------------------------------------------------------------------

-- Recursive descendant lookup (mirror of market.lua's findDescendant).
-- Some OTCv8 builds ship a non-recursive getChildById, so the priceSparkline
-- nested inside detailSparklineFrame would be missed. This walks the tree.
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 or "") end
end

local function safeVisible(widget, visible)
    if widget and widget.setVisible then widget:setVisible(visible and true or false) end
end

local function fmtSigned(n)
    n = tonumber(n) or 0
    return (n > 0) and ("+" .. n) or tostring(n)
end

local function isPos(v)
    local n = tonumber(v); return n and n > 0
end

-- "5d 12h", "9h 32m", "5m 12s", "32s"; returns "Expired" if <= 0.
local function fmtRemaining(s)
    s = tonumber(s) or 0
    if s <= 0 then return "Expired" end
    local d = math.floor(s / 86400)
    local h = math.floor((s % 86400) / 3600)
    local m = math.floor((s % 3600) / 60)
    local sec = math.floor(s % 60)
    if     d > 0 then return string.format("%dd %dh", d, h)
    elseif h > 0 then return string.format("%dh %dm", h, m)
    elseif m > 0 then return string.format("%dm %02ds", m, sec)
    else              return string.format("%ds", sec)
    end
end

-- "May 06" style short date from a unix timestamp.
local function fmtShortDate(ts)
    ts = tonumber(ts)
    if not ts or ts <= 0 then return "--" end
    return os.date("%b %d", ts)
end

-- ---------------------------------------------------------------------
-- Stats rendering (left column)
-- ---------------------------------------------------------------------

local function addStatRow(parent, name, value)
    if not parent then return end
    local row = g_ui.createWidget("DetailStatRow", parent)
    if not row then return end
    local nameLbl  = row:getChildById("name")
    local valueLbl = row:getChildById("value")
    safeText(nameLbl, name)
    safeText(valueLbl, value)
    return row
end

-- Multi-line description / book-text row. Uses DetailStatBlock which has
-- text-wrap + text-auto-resize on the value label.
local function addStatBlock(parent, name, value)
    if not parent then return end
    local row = g_ui.createWidget("DetailStatBlock", parent)
    if not row then return end
    local nameLbl  = row:getChildById("name")
    local valueLbl = row:getChildById("value")
    safeText(nameLbl, name)
    safeText(valueLbl, value)
    return row
end

local function addStatSeparator(parent)
    if not parent then return end
    local row = g_ui.createWidget("UIWidget", parent)
    if not row then return end
    if row.setHeight then row:setHeight(4) end
    if row.setBackgroundColor then row:setBackgroundColor("#1a1a1a") end
    return row
end

local function renderStats(market_item)
    if not detailWindow or not market_item then return end
    local statsBox   = detailWindow:getChildById("detailStatsBox")
    local statsEmpty = detailWindow:getChildById("detailStatsEmpty")
    if not statsBox then return end
    statsBox:destroyChildren()

    -- Decode tooltip_data (server-merged base + rolled).
    local attrs = {}
    local raw = market_item.tooltip_data
    if raw and raw ~= "" then
        local ok, decoded = pcall(json.decode, raw)
        if ok and type(decoded) == "table" then attrs = decoded end
    end

    local combatRows = 0
    if isPos(attrs.attack)       then addStatRow(statsBox, "Attack",  tostring(tonumber(attrs.attack)));        combatRows = combatRows + 1 end
    if isPos(attrs.extraattack)  then addStatRow(statsBox, "+Attack", fmtSigned(attrs.extraattack));            combatRows = combatRows + 1 end
    if isPos(attrs.defense)      then addStatRow(statsBox, "Defense", tostring(tonumber(attrs.defense)));       combatRows = combatRows + 1 end
    if isPos(attrs.extradefense) then addStatRow(statsBox, "+Defense", fmtSigned(attrs.extradefense));          combatRows = combatRows + 1 end
    if isPos(attrs.armor)        then addStatRow(statsBox, "Armor",   tostring(tonumber(attrs.armor)));         combatRows = combatRows + 1 end
    if isPos(attrs.attackspeed)  then addStatRow(statsBox, "Speed",   fmtSigned(attrs.attackspeed));            combatRows = combatRows + 1 end
    if isPos(attrs.hitchance)    then addStatRow(statsBox, "Hit",     fmtSigned(attrs.hitchance) .. "%");       combatRows = combatRows + 1 end
    if isPos(attrs.shootrange)   then addStatRow(statsBox, "Range",   tostring(tonumber(attrs.shootrange)));    combatRows = combatRows + 1 end

    local procRows = 0
    local hasAnyProc = isPos(attrs.criticalChance) or isPos(attrs.lifeLeech) or isPos(attrs.manaLeech)
    if hasAnyProc then
        if combatRows > 0 then addStatSeparator(statsBox) end
        if isPos(attrs.criticalChance) then addStatRow(statsBox, "Crit",     fmtSigned(attrs.criticalChance) .. "%"); procRows = procRows + 1 end
        if isPos(attrs.lifeLeech)      then addStatRow(statsBox, "Life Lch", fmtSigned(attrs.lifeLeech)      .. "%"); procRows = procRows + 1 end
        if isPos(attrs.manaLeech)      then addStatRow(statsBox, "Mana Lch", fmtSigned(attrs.manaLeech)      .. "%"); procRows = procRows + 1 end
    end

    if isPos(attrs.weight) then
        if combatRows > 0 or procRows > 0 then addStatSeparator(statsBox) end
        local oz = tonumber(attrs.weight) or 0
        addStatRow(statsBox, "Weight", string.format("%.2f oz", oz / 100))
    end

    if attrs.description and attrs.description ~= "" then
        if combatRows > 0 or procRows > 0 or isPos(attrs.weight) then addStatSeparator(statsBox) end
        addStatBlock(statsBox, "Desc", tostring(attrs.description))
    end

    local empty = (combatRows + procRows == 0) and not isPos(attrs.weight) and not (attrs.description and attrs.description ~= "")
    safeVisible(statsEmpty, empty)
end

-- ---------------------------------------------------------------------
-- Header rendering
-- ---------------------------------------------------------------------

local function renderListingHeader(market_item)
    if not detailWindow or not market_item then return end

    local iconW   = detailWindow:getChildById("detailItemIcon")
    local nameW   = detailWindow:getChildById("detailItemName")
    local banner  = detailWindow:getChildById("detailRarityBanner")
    local metaW   = detailWindow:getChildById("detailListingMeta")

    if iconW then
        iconW:setItemId(market_item.clientId or 0)
        iconW:setItemCount(market_item.count or 1)
    end

    local headerName = market_item.item_name or "(unknown item)"
    local count = tonumber(market_item.count) or 1
    if count > 1 then headerName = headerName .. "  x" .. count end
    safeText(nameW, headerName)
    detailWindow:setText(market_item.item_name or "Item Details")

    -- Rarity banner: show only if tier is known and not 'common'.
    local attrs = {}
    if market_item.tooltip_data and market_item.tooltip_data ~= "" then
        local ok, decoded = pcall(json.decode, market_item.tooltip_data)
        if ok and type(decoded) == "table" then attrs = decoded end
    end
    local tier = attrs.arcfall_tier or attrs.tier or attrs.rarity
    if tier then
        local key = tostring(tier):lower()
        local label = TIER_BANNER[key] or ("[ " .. string.upper(tostring(tier)) .. " ]")
        local color = TIER_COLOR[key]
        if banner then
            banner:setText(label)
            banner:setVisible(true)
            if color then
                if banner.setColor then banner:setColor(color.fg) end
                if banner.setBackgroundColor then banner:setBackgroundColor(color.bg) end
            end
        end
    else
        safeVisible(banner, false)
    end

    -- Listed by + listing-mode line.
    local seller = market_item.seller_name or "--"
    local mode   = market_item.listing_mode or 1
    local modeLabel = LISTING_MODE_LABEL[mode] or "Listing"
    local sellerTrunc = (seller ~= "" and #seller > PLAYER_TRUNC) and (seller:sub(1, PLAYER_TRUNC - 1) .. ELLIPSIS) or seller
    local metaText = "Listed by " .. sellerTrunc .. "  " .. SEP_DOT .. "  " .. modeLabel
    safeText(metaW, metaText)
end

-- ---------------------------------------------------------------------
-- Action strip (bid input + Buy Now + Place Bid + min next bid)
-- ---------------------------------------------------------------------

local function minNextBid(market_item)
    -- Server doesn't ship a precomputed minimum; fall back to current bid+1
    -- (or 1 if no bid has been placed yet). The server validates on submit.
    local currentBid = tonumber(market_item.bid) or 0
    if currentBid > 0 then return currentBid + 1 end
    return 1
end

local function renderActions(market_item)
    if not detailWindow or not market_item then return end

    local timeLbl    = detailWindow:getChildById("detailTimeLabel")
    local minBidLbl  = detailWindow:getChildById("detailMinBidLabel")
    local bidLbl     = detailWindow:getChildById("detailBidLabel")
    local bidEdit    = detailWindow:getChildById("detailBidEdit")
    local placeBtn   = detailWindow:getChildById("detailPlaceBidButton")
    local buyBtn     = detailWindow:getChildById("detailBuyNowButton")

    safeText(timeLbl, "Time: " .. fmtRemaining(market_item.timeleft))

    local mode = market_item.listing_mode or 1
    local Market = modules.game_marketplace and modules.game_marketplace.Market
    local LM = Market and Market.LISTING_MODE or { BUY_NOW = 1, AUCTION = 2, BOTH = 3 }
    local canBuy = (mode == LM.BUY_NOW or mode == LM.BOTH)
    local canBid = (mode == LM.AUCTION or mode == LM.BOTH)

    safeVisible(buyBtn,   canBuy)
    safeVisible(placeBtn, canBid)
    safeVisible(bidLbl,   canBid)
    safeVisible(bidEdit,  canBid)

    if canBid then
        local minNext = minNextBid(market_item)
        local fmt = (modules.game_marketplace and modules.game_marketplace.formatMoney)
            or function(n) return tostring(n) end
        safeText(minBidLbl, "Min next bid: " .. fmt(minNext))
        if bidEdit and bidEdit.setText then bidEdit:setText(tostring(minNext)) end
    else
        local price = tonumber(market_item.price) or 0
        local fmt = (modules.game_marketplace and modules.game_marketplace.formatMoney)
            or function(n) return tostring(n) end
        safeText(minBidLbl, "Buy Now price: " .. fmt(price))
    end
end

-- ---------------------------------------------------------------------
-- Live time-left ticker
-- ---------------------------------------------------------------------

local function tickTimer()
    tickerEvent = nil
    if not detailWindow or not detailWindow:isVisible() then return end
    if not currentItem then return end
    -- Do NOT mutate currentItem.timeleft here. market.lua's updateAllTimers
    -- already ticks that field once per second on the same shared table
    -- reference; a second decrement here would halve the visible duration.
    local timeLbl = detailWindow:getChildById("detailTimeLabel")
    safeText(timeLbl, "Time: " .. fmtRemaining(currentItem.timeleft))
    tickerEvent = scheduleEvent(tickTimer, 1000)
end

-- ---------------------------------------------------------------------
-- History rendering (right column)
-- ---------------------------------------------------------------------

local function clearHistory(emptyText)
    if not detailWindow then return end
    local items = findDescendant(detailWindow, "detailHistoryItems")
    local empty = findDescendant(detailWindow, "detailHistoryEmpty")
    if items then items:destroyChildren() end
    if empty then
        empty:setText(emptyText or "Loading...")
        empty:setVisible(true)
    end
    -- Wipe sparkline plot until a fresh batch arrives.
    local spark = findDescendant(detailWindow, "priceSparkline")
    local Sparkline = modules.game_marketplace and modules.game_marketplace.Sparkline
    if spark and Sparkline and Sparkline.clear then
        Sparkline.clear(spark)
    end
end

local function renderHistory(rows)
    if not detailWindow then return end
    local items = findDescendant(detailWindow, "detailHistoryItems")
    local empty = findDescendant(detailWindow, "detailHistoryEmpty")
    if items then items:destroyChildren() end

    rows = rows or {}
    if #rows == 0 then
        if empty then
            empty:setText("No previous sales.")
            empty:setVisible(true)
        end
        local spark = findDescendant(detailWindow, "priceSparkline")
        local Sparkline = modules.game_marketplace and modules.game_marketplace.Sparkline
        if spark and Sparkline and Sparkline.clear then Sparkline.clear(spark) end
        return
    end

    safeVisible(empty, false)

    -- Sort newest-first for the list display; sparkline takes oldest->newest.
    local sortedDesc = {}
    for i, r in ipairs(rows) do sortedDesc[i] = r end
    table.sort(sortedDesc, function(a, b)
        return (tonumber(a.ts) or 0) > (tonumber(b.ts) or 0)
    end)

    local fmtShort = (modules.game_marketplace and modules.game_marketplace.formatGoldShort)
        or function(n) return tostring(n) end

    for _, r in ipairs(sortedDesc) do
        local row = g_ui.createWidget("DetailHistoryRow", items)
        if row then
            local dateLbl  = row:getChildById("date")
            local countLbl = row:getChildById("count")
            local priceLbl = row:getChildById("price")
            safeText(dateLbl,  fmtShortDate(r.ts))
            safeText(countLbl, "x" .. tostring(tonumber(r.item_count) or 1))
            safeText(priceLbl, fmtShort(tonumber(r.total_price) or 0))
            -- Color sold vs bought differently for at-a-glance scanning.
            local txType = tostring(r.tx_type or ""):lower()
            if priceLbl and priceLbl.setColor then
                if txType == "sold" then
                    priceLbl:setColor("#ffd866")
                else
                    priceLbl:setColor("#9fdfff")
                end
            end
        end
    end

    -- Build sparkline points (oldest-first; unit price = total / count).
    local points = {}
    local sortedAsc = {}
    for i, r in ipairs(rows) do sortedAsc[i] = r end
    table.sort(sortedAsc, function(a, b)
        return (tonumber(a.ts) or 0) < (tonumber(b.ts) or 0)
    end)
    for _, r in ipairs(sortedAsc) do
        local cnt = tonumber(r.item_count) or 1
        if cnt < 1 then cnt = 1 end
        local total = tonumber(r.total_price) or 0
        local unit = total / cnt
        points[#points + 1] = { ts = tonumber(r.ts) or 0, value = unit }
    end

    local spark = findDescendant(detailWindow, "priceSparkline")
    local Sparkline = modules.game_marketplace and modules.game_marketplace.Sparkline
    if spark and Sparkline and Sparkline.render then
        Sparkline.render(spark, points)
    end
end

-- ---------------------------------------------------------------------
-- Open / close
-- ---------------------------------------------------------------------

local function ensureWindow()
    if detailWindow then return detailWindow end
    -- Parent to rootWidget (OTCv8 top-level UI) so detail is always above
    -- gameRootPanel siblings like the market MainWindow. Falls back to the
    -- game-interface root panel if rootWidget isn't exposed in this build.
    local parent = rootWidget or (modules.game_interface and modules.game_interface.getRootPanel and modules.game_interface.getRootPanel())
    if not parent then
        g_logger.warning("Detail: no UI root available")
        return nil
    end
    detailWindow = g_ui.createWidget("MarketDetailWindow", parent)
    if not detailWindow then
        g_logger.error("Detail: failed to create MarketDetailWindow")
        return nil
    end
    detailWindow:hide()
    return detailWindow
end

local function openDetail(market_item)
    if not market_item then return end
    if not ensureWindow() then return end

    currentItem = market_item

    renderListingHeader(market_item)
    renderStats(market_item)
    renderActions(market_item)
    clearHistory("Loading...")

    detailWindow:show()
    detailWindow:raise()
    detailWindow:focus()
    -- Belt-and-braces: re-raise via scheduleEvent at 0ms (next-frame queue)
    -- AND at 50ms (after any click-bubble auto-raise on the market window).
    -- Combined with the rootWidget parent, this guarantees detail is on top.
    addEvent(function()
        if detailWindow and detailWindow:isVisible() then
            detailWindow:raise()
            detailWindow:focus()
        end
    end)
    scheduleEvent(function()
        if detailWindow and detailWindow:isVisible() then
            detailWindow:raise()
            detailWindow:focus()
        end
    end, 50)

    -- Ask server for per-item history. Reply lands in parseMarket and
    -- routes to Detail.onItemHistoryReceived.
    local sendOpcodeFn = modules.game_marketplace and modules.game_marketplace.sendOpcode
    if sendOpcodeFn then
        sendOpcodeFn({
            protocol = "request_item_history",
            itemid   = tonumber(market_item.itemid) or 0,
        })
    end

    -- Kick off the live time-left ticker.
    if tickerEvent then removeEvent(tickerEvent); tickerEvent = nil end
    tickerEvent = scheduleEvent(tickTimer, 1000)
end

local function closeDetail()
    if tickerEvent then removeEvent(tickerEvent); tickerEvent = nil end
    if detailWindow then detailWindow:hide() end
    currentItem = nil
end

-- ---------------------------------------------------------------------
-- Action button handlers (re-use existing market.lua flows)
-- ---------------------------------------------------------------------

-- Buy Now uses market.lua's existing buyItem() flow, which reads the
-- currently-focused row out of Current.buyWidget. We trust the caller
-- (single-click) has already set the focus, but re-set it defensively
-- in case a stale row is focused.
function Detail.onBuyNowClick()
    if not currentItem then return end
    local buyFn = modules.game_marketplace and modules.game_marketplace.buyItem
    if not buyFn then return end
    Detail._hiddenForAction = true
    if detailWindow then detailWindow:hide() end
    buyFn()
end

function Detail.onPlaceBidClick()
    if not currentItem then return end
    local bidFn = modules.game_marketplace and modules.game_marketplace.bidItem
    if not bidFn then return end
    Detail._hiddenForAction = true
    if detailWindow then detailWindow:hide() end
    bidFn()
end

-- ---------------------------------------------------------------------
-- Server response: item_history payload
-- ---------------------------------------------------------------------

Detail.onItemHistoryReceived = function(receive)
    if not detailWindow or not detailWindow:isVisible() then return end
    if not receive then return end
    -- If the user has clicked through to a different item, ignore stale
    -- packets so the panel stays in sync with the currently-open item.
    if currentItem and receive.itemid and tonumber(receive.itemid) ~= tonumber(currentItem.itemid) then
        return
    end
    renderHistory(receive.history or {})
end

Detail.open  = openDetail
Detail.close = closeDetail

-- Bug B Solution B helpers: when Detail launches BuyNow/Bid we hide it
-- to reduce the on-screen window count (was 3 stacked). market.lua's
-- cancel path calls restoreFromAction; success path calls closeFromAction.
Detail.restoreFromAction = function()
    Detail._hiddenForAction = false
    if detailWindow and currentItem then
        detailWindow:show()
        detailWindow:raise()
        detailWindow:focus()
    end
end
Detail.closeFromAction = function()
    Detail._hiddenForAction = false
    closeDetail()
end

-- ---------------------------------------------------------------------
-- Module lifecycle
-- ---------------------------------------------------------------------

-- This init is shadowed by market.lua's own init() because OTCv8
-- sandboxed modules share a single global env across all scripts in
-- the scripts: list, and @onLoad: init() resolves to whichever init
-- was declared last (market.lua, loaded after detail.lua). market.lua's
-- init explicitly calls importStyle('detail.otui') instead — see there.
function Detail.initStyles()
    g_ui.importStyle('detail.otui')
end

function terminate()
    if tickerEvent then removeEvent(tickerEvent); tickerEvent = nil end
    if detailWindow then detailWindow:destroy(); detailWindow = nil end
    currentItem = nil
end
