-- TOG Tools — Trade Log (engine)
--
-- Captures every completed player-to-player trade into a flat list under
-- `db.global.tradeLog.entries`. Two-sided entry: separate `given` /
-- `received` item lists, separate money totals, and a dedicated
-- `enchantGiven` / `enchantReceived` pair for trade slot 7 (the enchant
-- slot — applying an enchant goes there, not in the regular item list).
--
-- Entry shape:
--   { ts, player, other, given = {{link,count},...}, received = {{...}},
--     moneyGiven, moneyReceived, enchantGiven, enchantReceived, zone }
--
-- Hook recipe (standard WoW trade-API pattern):
--   * TRADE_SHOW                       open the staging cache
--   * TRADE_PLAYER_ITEM_CHANGED(slot)  player side, slot 1-6 = items, 7 = enchant
--   * TRADE_TARGET_ITEM_CHANGED(slot)  target side, same slot semantics
--   * TRADE_MONEY_CHANGED              both money fields refresh
--   * TRADE_ACCEPT_UPDATE(p,t)         re-read everything (covers any slot
--                                      we missed an event for) AND record
--                                      whether BOTH sides have accepted
--   * TRADE_CLOSED                     commit if both had accepted (the
--                                      completion signal on Retail — its
--                                      trade frame no longer surfaces
--                                      ERR_TRADE_COMPLETE); otherwise
--                                      discard. TRADE_CLOSED fires for both
--                                      completion AND cancel, so the
--                                      both-accepted flag is what tells
--                                      them apart.
--   * UI_INFO_MESSAGE → ERR_TRADE_COMPLETE  Classic completion signal;
--                                      commits the same way. When both this
--                                      and TRADE_CLOSED fire, the first nils
--                                      the staging and the second is a no-op.
--
-- Sub-category registration mirrors MailLog: NOT in addon.modules; only
-- in addon.logCategories["trade"]. Sub-tab Draw is merged on by
-- GUI/TradeLogSubTab.lua.

local _, addon = ...

-- Fingerprint global so the user can verify which TradeLog.lua is loaded
-- via `/run print(TOGTOOLS_TRADELOG_VERSION)`. (Diagnostic only.)
_G.TOGTOOLS_TRADELOG_VERSION = "v0.6.4-trade-complete-on-close-2026-06-04"

local TradeLog = {}
addon.logCategories = addon.logCategories or {}
addon.logCategories["trade"] = TradeLog

TradeLog.subKey    = "trade"
TradeLog.label     = "Trade"
TradeLog.configKey = "tradeLog"
TradeLog.help = {
    title = "Logs > Trade",
    lines = {
        "Every completed player-to-player trade is appended here automatically. Arena/BG trades are skipped on purpose — they're noisy and rarely useful.",
        "|cffffd700Two-sided entries:|r |cffffd700Given|r is what you handed over (items + money); |cffffd700Received|r is what you got back.",
        "|cffffd700Enchant column:|r trade slot 7 is the enchant slot. |cffaa44ff->|r = enchant applied to partner's item; |cffaa44ff<-|r = partner applied an enchant to your item; both arrows = mutual enchant trade.",
        "|cffffd700Filters:|r Character (per-alt), Date range. Click any column header to re-sort; newest first by default.",
        "|cffffd700Inspection-only opens|r (both sides empty, no money, no enchant) are discarded — only completed trades that actually moved something are logged.",
        "|cffffd700Clear data:|r two-step confirm — see Mail tab for details.",
    },
}

-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function CharKey()
    local realm = (GetRealmName() or ""):gsub("%s+", "")
    return (UnitName("player") or "?") .. "-" .. realm
end

local function TradeLogDB()
    return addon.DB and addon.DB.global and addon.DB.global.tradeLog
end

local function isEnabled()
    return addon:IsLogCategoryEnabled("trade")
end

local function inPvPInstance()
    -- Arena/BG returns "pvp" or "arena" as the second IsInInstance value.
    -- Logging trades there is noisy and rarely useful — skip.
    if not IsInInstance then return false end
    local _, instType = IsInInstance()
    return instType == "pvp" or instType == "arena"
end

local function refreshSubTab()
    local mw = addon.MainWindow
    if not (mw and mw.activeTab == "logs") then return end
    local frames = addon.DB and addon.DB.char and addon.DB.char.frames
    if frames and frames.logsActiveSubTab == "trade" and mw.Refresh then
        mw:Refresh()
    end
end

-- ---------------------------------------------------------------------------
-- Per-trade staging cache. Built on TRADE_SHOW, mutated by slot/money
-- changes, committed to db.entries on completion (TRADE_CLOSED after both
-- sides accepted, or ERR_TRADE_COMPLETE on Classic), discarded otherwise.
-- Two slot-keyed maps (1-6 items + 7 enchant) keep the last-known state of
-- each slot regardless of update order.
-- ---------------------------------------------------------------------------
local _staging = nil
-- Set on each TRADE_ACCEPT_UPDATE to whether BOTH sides currently have the
-- trade accepted. Read at TRADE_CLOSED to decide completion vs cancel.
-- Driven purely by the accept-update args (never cleared on item/money
-- changes) because a successful trade may fire no further events between
-- the both-accepted update and TRADE_CLOSED.
local _bothAccepted = false

local function newStaging()
    return {
        playerItems = {},     -- [slot] = { link, count }
        targetItems = {},     -- [slot] = { link, count }
        playerMoney = 0,
        targetMoney = 0,
    }
end

local function readPlayerSlot(slot)
    if not GetTradePlayerItemLink or not GetTradePlayerItemInfo then return nil end
    local link = GetTradePlayerItemLink(slot)
    if not link then return nil end
    -- Classic / Retail signature shifts: name, texture, count, quality, enchant?
    -- The count field is consistently the 3rd return.
    local _, _, count = GetTradePlayerItemInfo(slot)
    return { link = link, count = count or 1 }
end

local function readTargetSlot(slot)
    if not GetTradeTargetItemLink or not GetTradeTargetItemInfo then return nil end
    local link = GetTradeTargetItemLink(slot)
    if not link then return nil end
    local _, _, count = GetTradeTargetItemInfo(slot)
    return { link = link, count = count or 1 }
end

local function snapshotAllSlots()
    if not _staging then return end
    for slot = 1, 7 do
        _staging.playerItems[slot] = readPlayerSlot(slot)
        _staging.targetItems[slot] = readTargetSlot(slot)
    end
    if GetPlayerTradeMoney then _staging.playerMoney = GetPlayerTradeMoney() or 0 end
    if GetTargetTradeMoney then _staging.targetMoney = GetTargetTradeMoney() or 0 end
    -- Capture the partner name NOW, while the trade window is open and the
    -- "npc" unit is valid. On Retail we commit at TRADE_CLOSED, by which
    -- point the "npc" unit can already be gone — reading it there would log
    -- every trade's partner as "?". Refreshed on every snapshot (TRADE_SHOW
    -- + each TRADE_ACCEPT_UPDATE) so the last valid read is the one kept.
    if UnitName then
        local n, r = UnitName("npc")
        if n then
            _staging.partner = (r and r ~= "") and (n .. "-" .. r) or n
        end
    end
end

-- Reduce the slot-keyed staging map to an ordered list with same-link
-- counts merged (mirrors MailLog's same-link merge for compact rendering).
local function slotsToList(slotMap)
    if not slotMap then return {} end
    local seen = {}
    local list = {}
    for slot = 1, 6 do   -- slots 1-6 only; 7 is enchant and handled separately
        local entry = slotMap[slot]
        if entry and entry.link then
            local existing = seen[entry.link]
            if existing then
                existing.count = (existing.count or 0) + (entry.count or 1)
            else
                local copy = { link = entry.link, count = entry.count or 1 }
                seen[entry.link] = copy
                tinsert(list, copy)
            end
        end
    end
    return list
end

-- Commit the staged trade to db.entries. Called from whichever completion
-- signal arrives first: TRADE_CLOSED-after-both-accepted (every flavour,
-- and the ONLY signal on Retail) or UI_INFO_MESSAGE/ERR_TRADE_COMPLETE
-- (Classic). Nils _staging on the way out, so the second signal — when
-- both fire on Classic — finds nothing and is a no-op (the dedupe).
local function commitTrade()
    if not (_staging and isEnabled()) then return end
    local db = TradeLogDB()
    if not db then return end
    db.entries = db.entries or {}

    local given    = slotsToList(_staging.playerItems)
    local received = slotsToList(_staging.targetItems)
    local enchantGiven    = _staging.playerItems[7] and _staging.playerItems[7].link or nil
    local enchantReceived = _staging.targetItems[7] and _staging.targetItems[7].link or nil

    -- Don't log truly empty trades (no items either side, no money).
    -- Common when players open a trade window to inspect each other.
    if #given == 0 and #received == 0
       and (_staging.playerMoney or 0) == 0
       and (_staging.targetMoney or 0) == 0
       and not enchantGiven and not enchantReceived then
        _staging = nil
        return
    end

    -- Partner name was captured by snapshotAllSlots() while the trade
    -- window was open (the "npc" unit type is the player on the other side).
    -- Read it from staging — NOT live here, since on Retail we commit at
    -- TRADE_CLOSED when "npc" may already be invalid. Realm is appended on
    -- connected-realm clusters; bare name otherwise.
    local partner = _staging.partner or "?"

    tinsert(db.entries, {
        ts              = GetServerTime() or time(),
        player          = CharKey(),
        other           = partner,
        given           = given,
        received        = received,
        moneyGiven      = _staging.playerMoney or 0,
        moneyReceived   = _staging.targetMoney or 0,
        enchantGiven    = enchantGiven,
        enchantReceived = enchantReceived,
        zone            = (GetRealZoneText and GetRealZoneText()) or nil,
    })

    local Logs = addon.modules and addon.modules["logs"]
    if Logs and Logs.PruneEntries then Logs:PruneEntries(db.entries) end
    _staging = nil
    refreshSubTab()
end

-- ---------------------------------------------------------------------------
-- Event wiring
-- ---------------------------------------------------------------------------
local _evtFrame = CreateFrame("Frame")
_evtFrame:RegisterEvent("TRADE_SHOW")
_evtFrame:RegisterEvent("TRADE_PLAYER_ITEM_CHANGED")
_evtFrame:RegisterEvent("TRADE_TARGET_ITEM_CHANGED")
_evtFrame:RegisterEvent("TRADE_MONEY_CHANGED")
_evtFrame:RegisterEvent("TRADE_ACCEPT_UPDATE")
_evtFrame:RegisterEvent("TRADE_CLOSED")
_evtFrame:RegisterEvent("UI_INFO_MESSAGE")
_evtFrame:SetScript("OnEvent", function(_, event, ...)
    if event == "TRADE_SHOW" then
        if not isEnabled() or inPvPInstance() then return end
        _staging = newStaging()
        _bothAccepted = false
        snapshotAllSlots()

    elseif event == "TRADE_PLAYER_ITEM_CHANGED" then
        if not _staging then return end
        local slot = ...
        if slot then _staging.playerItems[slot] = readPlayerSlot(slot) end

    elseif event == "TRADE_TARGET_ITEM_CHANGED" then
        if not _staging then return end
        local slot = ...
        if slot then _staging.targetItems[slot] = readTargetSlot(slot) end

    elseif event == "TRADE_MONEY_CHANGED" then
        if not _staging then return end
        if GetPlayerTradeMoney then _staging.playerMoney = GetPlayerTradeMoney() or 0 end
        if GetTargetTradeMoney then _staging.targetMoney = GetTargetTradeMoney() or 0 end

    elseif event == "TRADE_ACCEPT_UPDATE" then
        if not _staging then return end
        -- args: (playerAccepted, targetAccepted), each 1 or 0. Record
        -- whether both sides are accepted right now — this is the flag
        -- TRADE_CLOSED reads to distinguish a completed trade from a
        -- cancelled one. Adding/removing an item resets both accept
        -- states (firing this with a 0), so the flag self-clears.
        local playerAccepted, targetAccepted = ...
        _bothAccepted = (playerAccepted == 1 and targetAccepted == 1)
        -- Re-snapshot everything so we don't miss any slot whose change
        -- event we never received (e.g. drag-and-drop sequences that fire
        -- multiple state changes per frame). Captures the final contents
        -- at the moment the trade locks in.
        snapshotAllSlots()

    elseif event == "TRADE_CLOSED" then
        -- TRADE_CLOSED fires for BOTH completion and cancellation — even
        -- Blizzard's own frame can't tell them apart. We treat "both sides
        -- had accepted at close time" as a completed trade. This is the
        -- only completion signal on Retail. On Classic, if ERR_TRADE_COMPLETE
        -- already committed, _staging is nil and commitTrade is a no-op.
        if _bothAccepted then commitTrade() end
        _staging = nil
        _bothAccepted = false

    elseif event == "UI_INFO_MESSAGE" then
        -- Classic's "Trade complete." confirmation.
        local _, msg = ...
        if msg == ERR_TRADE_COMPLETE then commitTrade() end
    end
end)

-- ---------------------------------------------------------------------------
-- Read API consumed by GUI/TradeLogSubTab.lua
-- ---------------------------------------------------------------------------
function TradeLog:GetEntries(filterPlayer, fromTs, toTs)
    local db = TradeLogDB()
    if not db or not db.entries then return {} end
    local out = {}
    for _, e in ipairs(db.entries) do
        local ok = true
        if filterPlayer and filterPlayer ~= "" and e.player ~= filterPlayer then ok = false end
        if ok and fromTs and (e.ts or 0) < fromTs then ok = false end
        if ok and toTs   and (e.ts or 0) > toTs   then ok = false end
        if ok then tinsert(out, e) end
    end
    table.sort(out, function(a, b) return (a.ts or 0) > (b.ts or 0) end)
    return out
end

function TradeLog:GetPlayerKeys()
    local db = TradeLogDB()
    if not db or not db.entries then return {} end
    local seen = {}
    for _, e in ipairs(db.entries) do
        if e.player then seen[e.player] = true end
    end
    local list = {}
    for k in pairs(seen) do tinsert(list, k) end
    table.sort(list)
    return list
end

function TradeLog:ClearAll()
    local db = TradeLogDB()
    if db then db.entries = {} end
    refreshSubTab()
end
