-- TOG Tools — Gathering Log (engine)
--
-- Logs what you gather (mining / herbalism / skinning / fishing): the
-- item, how many, where, and when — grouped per gather action so the
-- sub-tab can report nodes-mined, per-node yield, per-zone breakdowns,
-- and items/hour. Account-wide store keyed per-alt via `player`.
--
-- Detection recipe (the non-obvious part):
--   1. UNIT_SPELLCAST_SUCCEEDED (unit == "player") — gathering a node is
--      a real spell cast. If the cast is a gather spell we "arm" a short
--      window and stamp a unique action id.
--   2. CHAT_MSG_LOOT — the items you pick up. If one arrives inside the
--      armed window we attribute it to that gather action.
-- This is mining/herb/skinning-specific by construction: mob loot,
-- vendor buys, mail, etc. are never preceded by a gather cast, so they
-- never get logged. We deliberately DON'T use GetLootSourceInfo / a
-- GameObject-source filter — that can't tell an ore node from an herb
-- node or a chest, and we want the profession attribution.
--
-- Cross-version cast identification: every rank of a gather skill shares
-- its rank-1 localized NAME ("Mining", "Herb Gathering", "Skinning",
-- "Fishing"), so we resolve a handful of well-known rank-1 spell IDs to
-- names at login and match the cast spell's name — that covers all ranks
-- on every flavour without an exhaustive per-expansion ID table. The raw
-- anchor IDs are also matched directly as a fallback. When account debug
-- is on, a loot with no armed gather logs the last player cast's id/name
-- so an unrecognised gather spell on some flavour is trivial to spot and
-- add to GATHER_ANCHORS.
--
-- Fishing is the odd one: the cast completes (bobber lands) long before
-- the catch loots, so it gets a much longer pre-loot window than the
-- instant gather casts. Multi-item drops from a single node/catch are
-- grouped under one action via a short post-loot grouping window.
--
-- Sub-category registration mirrors WhisperLog: NOT in addon.modules;
-- only in addon.logCategories["gathering"]. Sub-tab Draw is merged on by
-- GUI/GatheringLogSubTab.lua.

local _, addon = ...

local GatheringLog = {}
addon.logCategories = addon.logCategories or {}
addon.logCategories["gathering"] = GatheringLog

GatheringLog.subKey    = "gathering"
GatheringLog.label     = "Gathering"
GatheringLog.configKey = "gatheringLog"

-- Display order + labels. PROF_NOUN is the per-action unit shown in the
-- stats panel ("12 nodes", "8 catches", "20 skins").
GatheringLog.PROF_ORDER = { "mining", "herbalism", "skinning", "fishing" }
GatheringLog.PROF_LABEL = {
    mining     = "Mining",
    herbalism  = "Herbalism",
    skinning   = "Skinning",
    fishing    = "Fishing",
}
GatheringLog.PROF_NOUN = {
    mining     = "node",
    herbalism  = "node",
    skinning   = "skin",
    fishing    = "catch",
}

GatheringLog.help = {
    title = "Logs > Gathering",
    lines = {
        "Every node you gather is logged automatically — mining, herbalism, skinning, and fishing — with the item, quantity, zone, and time.",
        "|cffffd700How it works:|r gathering a node is a spell cast; the log arms on that cast and attributes the loot that follows it. Mob drops, vendor buys, and mail are never logged because nothing gathered them.",
        "|cffffd700Stats panel:|r above the table — total items and nodes worked (with average yield per node), a per-item breakdown, a by-zone breakdown, and items/hour over the selected range.",
        "|cffffd700Filters:|r Character (per-alt), Profession, Zone, and Date range. Click a column header to sort; click again to reverse.",
        "|cffffd700Clear data:|r centralised in |cffffd700ESC > Options > AddOns > TOG Tools > Clear Data > Clear Gathering Log|r.",
    },
}

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

local function DB()
    return addon.DB and addon.DB.global and addon.DB.global.gatheringLog
end

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

-- Version-safe spell-name lookup. Retail 11.0+ removed the GetSpellInfo
-- global in favour of C_Spell.GetSpellInfo; Classic flavours still carry
-- the global. Prefer the namespaced form, fall back to the global.
local function spellName(spellID)
    if not spellID then return nil end
    if C_Spell and C_Spell.GetSpellInfo then
        local info = C_Spell.GetSpellInfo(spellID)
        return info and info.name
    elseif GetSpellInfo then
        return (GetSpellInfo(spellID))
    end
    return nil
end

-- Refresh the Gathering sub-tab when it's the active sub-tab inside an open
-- Logs tab. Cheap and idempotent — re-reads entries from SV.
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 == "gathering" and mw.Refresh then
        mw:Refresh()
    end
end

-- ---------------------------------------------------------------------------
-- Gather-cast detection tables
-- ---------------------------------------------------------------------------
-- Anchor rank-1 gather-cast spell IDs → profession. Resolved to localized
-- names at login (see buildDetectionTables); the exact name match covers
-- every rank on the Classic flavours, where all ranks are named exactly
-- after the action ("Mining", "Herb Gathering", "Skinning", "Fishing").
local GATHER_ANCHORS = {
    [2575] = "mining",     -- Mining (Classic rank 1)
    [2366] = "herbalism",  -- Herb Gathering (Classic rank 1)
    [8613] = "skinning",   -- Skinning (Classic rank 1)
    [7620] = "fishing",    -- Fishing (Classic rank 1)
}

-- Retail gives each EXPANSION its own gather cast with an expansion-
-- flavoured name that never equals the Classic base name — verified in
-- game, the Midnight mining cast is spell 471013 "Midnight Mining"; past
-- expansions ("Khaz Algar Mining", etc.) follow the same shape. So beyond
-- the exact anchor-name match we also match the profession KEYWORD at a
-- word boundary: anything ending in "Mining" resolves to mining. Word-
-- boundary (equals, or ends with " <kw>") — not a bare substring — so
-- unrelated casts like "Examining" don't false-match. enUS keywords; the
-- exact anchor-name match still handles localized Classic casts.
local PROF_KEYWORDS = {
    mining     = { "Mining" },
    herbalism  = { "Herbalism", "Herb Gathering" },
    skinning   = { "Skinning" },
    fishing    = { "Fishing" },
}

local _idToProf   = {}   -- spellID            -> profKey (direct anchor match)
local _nameToProf = {}   -- localized castName -> profKey (covers Classic ranks)

local function buildDetectionTables()
    wipe(_idToProf)
    wipe(_nameToProf)
    for id, prof in pairs(GATHER_ANCHORS) do
        _idToProf[id] = prof
        local nm = spellName(id)
        if nm and nm ~= "" then _nameToProf[nm] = prof end
    end
end

-- True when `nm` equals `kw`, or ends with " <kw>" (a word boundary),
-- case-insensitive. "Mining" and "Midnight Mining" match keyword "Mining";
-- "Examining" does not.
local function nameEndsWithKeyword(nm, kw)
    nm = nm:lower()
    kw = kw:lower()
    if nm == kw then return true end
    return nm:sub(-(#kw + 1)) == (" " .. kw)
end

local function profForCast(spellID)
    if not spellID then return nil end
    local p = _idToProf[spellID]
    if p then return p end
    local nm = spellName(spellID)
    if not nm or nm == "" then return nil end
    p = _nameToProf[nm]
    if p then return p end
    -- Keyword fallback for Retail's expansion-flavoured cast names.
    for _, prof in ipairs(GatheringLog.PROF_ORDER) do
        local kws = PROF_KEYWORDS[prof]
        if kws then
            for _, kw in ipairs(kws) do
                if nameEndsWithKeyword(nm, kw) then return prof end
            end
        end
    end
    return nil
end

-- ---------------------------------------------------------------------------
-- Loot-text parsing
-- ---------------------------------------------------------------------------
-- CHAT_MSG_LOOT arg1 is the localized sentence carrying an item hyperlink,
-- e.g. "You receive loot: |cff..|Hitem:2770:..|h[Copper Ore]|h|r x3." We
-- pull the itemID + display name straight from the embedded link (locale-
-- independent markup) and the stack count from Blizzard's own
-- LOOT_ITEM_SELF_MULTIPLE format string turned into a pattern (locale-safe).
local _multiPattern  -- "You receive loot: (.+)x(%d+)" equivalent, built at load

local function buildLootPatterns()
    local fmt = LOOT_ITEM_SELF_MULTIPLE or "You receive loot: %sx%d"
    -- Escape Lua pattern magic, then turn the %s / %d format specifiers
    -- back into captures. (%) isn't in the escape class, so the "%s"/"%d"
    -- specifiers survive the escape pass intact.
    local p = fmt:gsub("([%^%$%(%)%.%[%]%*%+%-%?])", "%%%1")
    p = p:gsub("%%s", "(.+)"):gsub("%%d", "(%%d+)")
    _multiPattern = p
end

-- Returns itemID, link, displayName, count (or nil if no item link found).
local function parseLoot(text)
    if not text then return nil end
    local itemID = tonumber(text:match("Hitem:(%d+)"))
    if not itemID then return nil end
    local link = text:match("(|c%x+|Hitem:.-|h.-|h|r)") or text:match("(|Hitem:.-|h.-|h)")
    local displayName = text:match("|h%[(.-)%]|h")
    local count = 1
    if _multiPattern then
        local _, n = text:match(_multiPattern)
        count = tonumber(n) or 1
    end
    return itemID, link, displayName, count
end

-- ---------------------------------------------------------------------------
-- Armed-window state (in-memory; not persisted)
-- ---------------------------------------------------------------------------
-- Pre-loot window: how long after the gather cast the first drop may arrive.
-- Instant gathers loot within ~1s; fishing's bobber bite is variable and
-- slow, so it gets a much longer leash.
local PRE_LOOT_WINDOW = {
    mining     = 3,
    herbalism  = 3,
    skinning   = 3,
    fishing    = 30,
}
-- After the first drop lands, keep attributing further drops to the SAME
-- action for this long — a single node can drop ore + stone + a gem on
-- separate CHAT_MSG_LOOT lines within a second of each other.
local GROUP_WINDOW = 3

local _armed     = nil   -- { prof, castTs, lastLootTs, action }
local _lastCast  = { id = nil, name = nil }   -- diagnostics for unmatched loot

-- ---------------------------------------------------------------------------
-- Capture
-- ---------------------------------------------------------------------------
local function nextActionID()
    local db = DB()
    if not db then return 0 end
    -- Persisted monotonic counter so action ids stay unique across sessions
    -- (per-node yield stats group by action; a per-session reset would let
    -- ids from different play sessions collide).
    db.actionSeq = (db.actionSeq or 0) + 1
    return db.actionSeq
end

local function append(prof, action, itemID, link, displayName, count)
    local db = DB()
    if not db then return end
    db.entries = db.entries or {}

    local name, quality, expac = displayName, nil, nil
    if GetItemInfo then
        -- Just-looted items are virtually always cached; use the canonical
        -- name + quality when available, fall back to the parsed display name.
        -- expansionID (15th return) lets the graph group ore by expansion.
        local info = { GetItemInfo(itemID) }
        if info[1] then name = info[1] end
        quality = info[3]
        expac   = info[15]
    end

    tinsert(db.entries, {
        ts      = GetServerTime() or time(),
        player  = CharKey(),
        prof    = prof,
        action  = action,
        itemID  = itemID,
        link    = link,
        name    = name or ("item:" .. itemID),
        quality = quality,
        expac   = expac,
        count   = count or 1,
        zone    = (GetRealZoneText and GetRealZoneText()) or nil,
        subzone = (GetSubZoneText and GetSubZoneText() ~= "" and GetSubZoneText()) or nil,
    })

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

-- ---------------------------------------------------------------------------
-- Event wiring
-- ---------------------------------------------------------------------------
local _evtFrame = CreateFrame("Frame")
_evtFrame:RegisterEvent("PLAYER_LOGIN")
_evtFrame:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
_evtFrame:RegisterEvent("CHAT_MSG_LOOT")
_evtFrame:SetScript("OnEvent", function(_, event, arg1, _, arg3)
    if event == "PLAYER_LOGIN" then
        buildDetectionTables()
        buildLootPatterns()

    elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
        -- Payload: unitTarget, castGUID, spellID (confirmed against the docs
        -- mirror, UnitDocumentation.lua UNIT_SPELLCAST_SUCCEEDED).
        if arg1 ~= "player" then return end
        -- Gated here too: arming bumps the persisted action counter, so a
        -- disabled log shouldn't touch the SV on every gather cast.
        if not isEnabled() then return end
        local spellID = arg3
        _lastCast.id   = spellID
        _lastCast.name = spellName(spellID)
        local prof = profForCast(spellID)
        if prof then
            _armed = {
                prof       = prof,
                castTs     = GetTime(),
                lastLootTs = nil,
                action     = nextActionID(),
            }
        end

    elseif event == "CHAT_MSG_LOOT" then
        if not isEnabled() then return end
        local now = GetTime()
        local logged = false
        if _armed then
            local within   = (now - _armed.castTs) <= (PRE_LOOT_WINDOW[_armed.prof] or 3)
            local grouping = _armed.lastLootTs and (now - _armed.lastLootTs) <= GROUP_WINDOW
            if within or grouping then
                local itemID, link, displayName, count = parseLoot(arg1)
                if itemID then
                    _armed.lastLootTs = now
                    append(_armed.prof, _armed.action, itemID, link, displayName, count)
                    logged = true
                end
            end
        end
        -- Any loot we didn't log: surface the last player cast id/name and the
        -- raw loot text. This is the triage line for "it missed one" — it
        -- reveals whether the gather cast wasn't recognised (add its id/name
        -- to GATHER_ANCHORS) or the loot text didn't parse. Fires whether or
        -- not anything was armed, so a totally-unrecognised gather cast still
        -- leaves a trace.
        if not logged and addon.Debug then
            addon:Debug(string.format(
                "[gathering] unlogged loot; armed=%s lastCast id=%s name=%s | text=%s",
                tostring(_armed and _armed.prof or nil),
                tostring(_lastCast.id), tostring(_lastCast.name),
                tostring(arg1)))
        end
    end
end)

-- ---------------------------------------------------------------------------
-- Read API consumed by GUI/GatheringLogSubTab.lua
-- ---------------------------------------------------------------------------
--- Newest-first list of entries matching the filters.
---   filterPlayer string|nil   character key (Name-Realm), nil = all alts
---   filterProf   string|nil   "mining"/"herbalism"/"skinning"/"fishing", nil = all
---   filterZone   string|nil   exact zone name, nil = all
---   fromTs,toTs  number|nil   inclusive bounds (nil = unbounded)
function GatheringLog:GetEntries(filterPlayer, filterProf, filterZone, fromTs, toTs)
    local db = DB()
    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 filterProf and e.prof ~= filterProf then ok = false end
        if ok and filterZone and filterZone ~= "" and (e.zone or "") ~= filterZone 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

--- Unique character keys seen in the log; alphabetically sorted.
function GatheringLog:GetPlayerKeys()
    local db = DB()
    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

--- Profession keys present in the log, in canonical PROF_ORDER.
function GatheringLog:GetProfsPresent()
    local db = DB()
    if not db or not db.entries then return {} end
    local seen = {}
    for _, e in ipairs(db.entries) do
        if e.prof then seen[e.prof] = true end
    end
    local list = {}
    for _, p in ipairs(self.PROF_ORDER) do
        if seen[p] then tinsert(list, p) end
    end
    return list
end

--- Unique zone names seen in the log; alphabetically sorted.
function GatheringLog:GetZones()
    local db = DB()
    if not db or not db.entries then return {} end
    local seen = {}
    for _, e in ipairs(db.entries) do
        if e.zone and e.zone ~= "" then seen[e.zone] = true end
    end
    local list = {}
    for k in pairs(seen) do tinsert(list, k) end
    table.sort(list)
    return list
end

--- Aggregate stats over a list of entries (as returned by GetEntries).
--- Returns a table the sub-tab renders directly:
---   totalItems  number              sum of stack counts
---   actions     number              distinct gather actions (nodes/skins/catches)
---   perItem     { {name,itemID,link,quality,count}, ... }  desc by count
---   perZone     { {zone,count}, ... }                      desc by count
---   perProf     { [prof] = { items, actions } }
---   firstTs,lastTs number|nil       bounds of the set
---   spanHours   number              (lastTs-firstTs)/3600, min 0
function GatheringLog:ComputeStats(entries)
    local totalItems, firstTs, lastTs = 0, nil, nil
    local itemAgg, zoneAgg, profAgg = {}, {}, {}
    local actionSet, profActionSets = {}, {}

    for _, e in ipairs(entries) do
        local c = e.count or 1
        totalItems = totalItems + c

        if e.ts then
            if not firstTs or e.ts < firstTs then firstTs = e.ts end
            if not lastTs  or e.ts > lastTs  then lastTs  = e.ts end
        end

        local iname = e.name or ("item:" .. tostring(e.itemID))
        local ia = itemAgg[iname]
        if not ia then
            ia = { name = iname, itemID = e.itemID, link = e.link, quality = e.quality, count = 0 }
            itemAgg[iname] = ia
        end
        ia.count = ia.count + c

        local z = e.zone or "Unknown"
        zoneAgg[z] = (zoneAgg[z] or 0) + c

        local prof = e.prof or "?"
        local pa = profAgg[prof]
        if not pa then pa = { items = 0, actions = 0 }; profAgg[prof] = pa end
        pa.items = pa.items + c

        -- Distinct gather actions, both overall and per profession.
        if e.action ~= nil then
            if not actionSet[e.action] then actionSet[e.action] = true end
            profActionSets[prof] = profActionSets[prof] or {}
            profActionSets[prof][e.action] = true
        end
    end

    local actions = 0
    for _ in pairs(actionSet) do actions = actions + 1 end
    for prof, set in pairs(profActionSets) do
        local n = 0
        for _ in pairs(set) do n = n + 1 end
        if profAgg[prof] then profAgg[prof].actions = n end
    end

    local perItem = {}
    for _, v in pairs(itemAgg) do tinsert(perItem, v) end
    table.sort(perItem, function(a, b)
        if a.count ~= b.count then return a.count > b.count end
        return (a.name or "") < (b.name or "")
    end)

    local perZone = {}
    for z, n in pairs(zoneAgg) do tinsert(perZone, { zone = z, count = n }) end
    table.sort(perZone, function(a, b)
        if a.count ~= b.count then return a.count > b.count end
        return (a.zone or "") < (b.zone or "")
    end)

    local spanHours = 0
    if firstTs and lastTs and lastTs > firstTs then
        spanHours = (lastTs - firstTs) / 3600
    end

    return {
        totalItems = totalItems,
        actions    = actions,
        perItem    = perItem,
        perZone    = perZone,
        perProf    = profAgg,
        firstTs    = firstTs,
        lastTs     = lastTs,
        spanHours  = spanHours,
    }
end

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