-- TOG Tools — LogGraph (reusable LibGraph-2.0 line-chart component)
--
-- Generalised from FastGuildInvite's Statistics tab. Any log can be graphed
-- by handing Render() a "graph spec": a set of named series (each with a
-- colour, a match predicate, and a value function), an entry provider, and
-- an optional sub-filter (e.g. the Gathering chart scopes its per-item
-- series to one expansion). The component buckets the entries by time over
-- the selected period and draws one LibGraph line per visible series, with
-- per-series checkboxes, a period dropdown, an optional sub-filter dropdown,
-- and a totals line.
--
-- Spec contract:
--   spec.key        string   stable key (used for prefs + the graph frame)
--   spec.getEntries function() -> { {ts=<epoch>, ...}, ... }
--   spec.getSeries  function(subValue) -> {
--                       { key, label, color={r,g,b,a}|nil, match(entry)->bool,
--                         value(entry)->number|nil }, ... }
--                   color may be nil to auto-assign from the palette; value
--                   defaults to 1 (counting).
--   spec.subFilter  table|nil {
--                       label      string,
--                       getOptions function() -> { {value=, text=}, ... },
--                       default    function() -> value,
--                   }
--   spec.unit       string   noun for the totals line ("items", "mails", ...)
--
-- Frame lifecycle: one persistent frame set, parked on UIParent when idle and
-- reparented onto the caller's host frame on Render. AceGUI pools the host
-- container frames, so creating fresh children each draw would orphan frames
-- onto recycled widgets — reusing a singleton set avoids that (the RowList
-- approach). Within a single log, period / series / sub-filter changes
-- repopulate the existing graph in place; only switching logs re-specs.

local _, addon = ...
local Graph = LibStub("LibGraph-2.0")

local LogGraph = {}
addon.LogGraph = LogGraph

-- {label, daysBack, points, xAxisLabel}. daysBack 0 = "All time": the window
-- spans from the earliest entry to now, split into `points` buckets.
local PERIODS = {
    { label = "Last 24 hours", days = 1,  points = 24, xlabel = "Hours" },
    { label = "Last 7 days",   days = 7,  points = 7,  xlabel = "Days"  },
    { label = "Last 14 days",  days = 14, points = 14, xlabel = "Days"  },
    { label = "Last 30 days",  days = 30, points = 30, xlabel = "Days"  },
    { label = "All time",      days = 0,  points = 30, xlabel = ""      },
}

-- Distinct colours for auto-assigned (dynamic) series. Series past #PALETTE
-- cycle — but MAX_SERIES caps the count first so the chart stays readable.
local PALETTE = {
    {0.00, 0.80, 1.00, 0.9}, {0.30, 1.00, 0.30, 0.9}, {1.00, 0.85, 0.00, 0.9},
    {1.00, 0.35, 0.35, 0.9}, {0.80, 0.40, 1.00, 0.9}, {1.00, 0.60, 0.20, 0.9},
    {0.40, 0.80, 1.00, 0.9}, {0.60, 1.00, 0.60, 0.9}, {1.00, 0.50, 0.80, 0.9},
    {0.70, 0.70, 0.70, 0.9}, {0.50, 1.00, 0.85, 0.9}, {1.00, 1.00, 0.55, 0.9},
}
local MAX_SERIES = 12

local TOP_ROW_H   = 30   -- log + period + sub-filter dropdown row
local CHECK_ROW_H = 22
local CHECK_COL_W = 150
local BOTTOM_H    = 56   -- x-axis label + (wrapping) totals line, below the graph

-- ---------------------------------------------------------------------------
-- Singleton UI + live state
-- ---------------------------------------------------------------------------
local _ui     -- frame set, created lazily
local _state = { spec = nil, prefs = nil }

local function hexColor(c)
    return string.format("ff%02x%02x%02x",
        math.floor((c[1] or 1) * 255),
        math.floor((c[2] or 1) * 255),
        math.floor((c[3] or 1) * 255))
end

-- Native Blizzard dropdown (same widget the chat combo boxes use). items is a
-- {value,text} list; onSelect(value) fires on pick.
local function makeDropdown(name, parent, width)
    local dd = CreateFrame("Frame", name, parent, "UIDropDownMenuTemplate")
    UIDropDownMenu_SetWidth(dd, width)
    return dd
end

local function setDropdown(dd, items, current, onSelect)
    local function init()
        for _, it in ipairs(items) do
            local info = UIDropDownMenu_CreateInfo()
            info.text         = it.text
            info.value        = it.value
            info.notCheckable = true
            info.func         = function()
                UIDropDownMenu_SetSelectedValue(dd, it.value)
                UIDropDownMenu_SetText(dd, it.text)
                onSelect(it.value)
            end
            UIDropDownMenu_AddButton(info)
        end
    end
    UIDropDownMenu_Initialize(dd, init)
    -- Resolve the label for the current value (falling back to the first item).
    local label = items[1] and items[1].text or ""
    for _, it in ipairs(items) do
        if it.value == current then label = it.text break end
    end
    UIDropDownMenu_SetSelectedValue(dd, current)
    UIDropDownMenu_SetText(dd, label)
end

local function ensureUI()
    if _ui then return _ui end
    local root = CreateFrame("Frame", "TOGToolsLogGraphRoot", UIParent)
    root:Hide()

    -- Single control row: Log picker, Period, Sub-filter — left to right. The
    -- ~12px of built-in padding on UIDropDownMenu frames is taken up with the
    -- negative offsets so the three sit snug rather than gapped.
    local logDD = makeDropdown("TOGToolsLogGraphLogDD", root, 120)
    logDD:SetPoint("TOPLEFT", root, "TOPLEFT", -12, 4)

    local periodDD = makeDropdown("TOGToolsLogGraphPeriodDD", root, 110)
    periodDD:SetPoint("LEFT", logDD, "RIGHT", -10, 0)

    local subDD = makeDropdown("TOGToolsLogGraphSubDD", root, 130)
    subDD:SetPoint("LEFT", periodDD, "RIGHT", -10, 0)

    -- Graph holder; precise anchors are set per-refresh once the series count
    -- (and thus the checkbox-row height) is known.
    local graphHolder = CreateFrame("Frame", nil, root)

    local graph = Graph:CreateGraphLine("TOGToolsLogGraphLine", graphHolder,
        "TOPLEFT", "TOPLEFT", 0, 0, 100, 100)
    graph:SetGridColor({0.5, 0.5, 0.5, 0.3})
    graph:SetAxisDrawing(true, true)
    graph:SetAxisColor({1, 1, 1, 1})
    graph:SetAutoScale(false)
    graph:LockYMin(0)

    -- Time axis: we render our own anchors (oldest at left .. "now" at right)
    -- below the plot instead of LibGraph's bare 1..N numbers — those read
    -- backwards on a past->now axis and collided with the label. Disabled in
    -- refresh via XLabelsEnabled=false.
    local xLeft = root:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    xLeft:SetPoint("TOPLEFT", graphHolder, "BOTTOMLEFT", 4, -3)
    xLeft:SetJustifyH("LEFT")
    local xMid = root:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    xMid:SetPoint("TOP", graphHolder, "BOTTOM", 0, -3)
    xMid:SetJustifyH("CENTER")
    local xRight = root:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    xRight:SetPoint("TOPRIGHT", graphHolder, "BOTTOMRIGHT", -4, -3)
    xRight:SetJustifyH("RIGHT")

    local totals = root:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    totals:SetPoint("TOPLEFT", xLeft, "BOTTOMLEFT", 0, -4)
    totals:SetPoint("RIGHT", root, "RIGHT", -4, 0)
    totals:SetJustifyH("LEFT")

    local empty = root:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    empty:SetPoint("CENTER", graphHolder, "CENTER", 0, 0)
    empty:SetText("")

    -- Transparent mouse layer over the plot. LibGraph has no per-point hover
    -- (the lines are just textures), so we map the cursor's X to a time bucket
    -- ourselves and show a GameTooltip. Scripts are attached in Render (where
    -- the data helpers are in scope); above the graph's frame level so it
    -- receives mouse without the graph needing EnableMouse.
    local hover = CreateFrame("Frame", nil, graphHolder)
    hover:SetAllPoints(graphHolder)
    hover:EnableMouse(true)
    hover:SetFrameLevel(graph:GetFrameLevel() + 5)

    _ui = {
        root = root, logDD = logDD, periodDD = periodDD, subDD = subDD,
        graphHolder = graphHolder, graph = graph, hover = hover,
        xLeft = xLeft, xMid = xMid, xRight = xRight, totals = totals, empty = empty,
        checks = {},
    }

    graphHolder:HookScript("OnSizeChanged", function(_, w, h)
        if w > 1 and h > 1 then _ui.graph:SetSize(w, h) end
    end)

    return _ui
end

-- Reuse-or-create the i-th series checkbox. Coloured label = series colour.
local function getCheck(i)
    local ui = _ui
    local cb = ui.checks[i]
    if cb then return cb end
    cb = CreateFrame("CheckButton", nil, ui.root, "UICheckButtonTemplate")
    cb:SetSize(20, 20)
    cb.label = cb:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    cb.label:SetPoint("LEFT", cb, "RIGHT", 1, 0)
    cb.label:SetWidth(CHECK_COL_W - 24)
    cb.label:SetJustifyH("LEFT")
    ui.checks[i] = cb
    return cb
end

-- ---------------------------------------------------------------------------
-- Bucketing
-- ---------------------------------------------------------------------------
-- For each visible series, sum value(entry) into `points` time buckets across
-- the period window. Returns { perSeries = { [i] = {bucketsTable, total} }, maxY }.
local function bucketize(entries, series, period)
    local now = (GetServerTime and GetServerTime()) or time()
    local points = period.points
    local startTs, bucketSec

    if period.days > 0 then
        startTs   = now - period.days * 86400
        bucketSec = (period.days * 86400) / points
    else
        -- All time: span from the earliest entry to now.
        local minTs = now
        for _, e in ipairs(entries) do
            if e.ts and e.ts < minTs then minTs = e.ts end
        end
        startTs   = minTs
        bucketSec = math.max((now - startTs) / points, 1)
    end

    local perSeries = {}
    for i = 1, #series do perSeries[i] = { buckets = {}, total = 0 } end
    for i = 1, #series do
        for b = 1, points do perSeries[i].buckets[b] = 0 end
    end

    for _, e in ipairs(entries) do
        local ts = e.ts
        if ts and ts >= startTs and ts <= now then
            local b = math.floor((ts - startTs) / bucketSec) + 1
            if b < 1 then b = 1 elseif b > points then b = points end
            for i = 1, #series do
                local s = series[i]
                if s.match(e) then
                    local v = s.value and s.value(e) or 1
                    perSeries[i].buckets[b] = perSeries[i].buckets[b] + v
                    perSeries[i].total = perSeries[i].total + v
                end
            end
        end
    end

    local maxY = 0
    for i = 1, #series do
        for b = 1, points do
            if perSeries[i].buckets[b] > maxY then maxY = perSeries[i].buckets[b] end
        end
    end
    return perSeries, maxY, points, startTs, bucketSec
end

-- ---------------------------------------------------------------------------
-- Refresh (repopulate the existing graph from current spec + prefs)
-- ---------------------------------------------------------------------------
-- Time-axis anchor text for a point `ts` relative to `now`: "Nh ago" / "now"
-- on the 24h view, a date on the day / all-time views.
local function axisText(period, ts, now)
    if period.days == 1 then
        local h = math.floor((now - ts) / 3600 + 0.5)
        return h <= 0 and "now" or (h .. "h ago")
    elseif period.days == 0 then
        return date("%m/%d/%y", ts)
    end
    return date("%m/%d", ts)
end

local function seriesVisible(prefs, key)
    if not prefs.series then return true end
    local v = prefs.series[key]
    if v == nil then return true end
    return v and true or false
end

local function refresh()
    local ui = _ui
    local spec, prefs = _state.spec, _state.prefs
    if not (ui and spec and prefs) then return end

    local idx = prefs.period or 1
    if idx < 1 or idx > #PERIODS then idx = 1 end
    local period = PERIODS[idx]

    -- Resolve series for the current sub-filter, capped + auto-coloured.
    local series = spec.getSeries(prefs.sub) or {}
    if #series > MAX_SERIES then
        for i = #series, MAX_SERIES + 1, -1 do series[i] = nil end
    end
    for i, s in ipairs(series) do
        if not s.color then s.color = PALETTE[((i - 1) % #PALETTE) + 1] end
    end

    -- ---- Series checkboxes (grid; reuse pooled frames) ------------------
    local width = math.max(ui.root:GetWidth(), 200)
    local cols  = math.max(1, math.floor(width / CHECK_COL_W))
    for i, s in ipairs(series) do
        local cb = getCheck(i)
        local col = (i - 1) % cols
        local row = math.floor((i - 1) / cols)
        cb:ClearAllPoints()
        cb:SetPoint("TOPLEFT", ui.root, "TOPLEFT",
            col * CHECK_COL_W, -(TOP_ROW_H + row * CHECK_ROW_H))
        cb.label:SetText(string.format("|c%s%s|r", hexColor(s.color), s.label or s.key))
        cb:SetChecked(seriesVisible(prefs, s.key))
        cb:SetScript("OnClick", function(self)
            prefs.series = prefs.series or {}
            prefs.series[s.key] = self:GetChecked() and true or false
            refresh()
        end)
        cb:Show()
    end
    for i = #series + 1, #ui.checks do ui.checks[i]:Hide() end

    -- ---- Layout: graph fills below the checkbox grid --------------------
    local rows = math.ceil(#series / cols)
    local checksH = rows * CHECK_ROW_H
    local graphTop = TOP_ROW_H + checksH + 6
    ui.graphHolder:ClearAllPoints()
    ui.graphHolder:SetPoint("TOPLEFT",     ui.root, "TOPLEFT",     0, -graphTop)
    ui.graphHolder:SetPoint("BOTTOMRIGHT", ui.root, "BOTTOMRIGHT", 0,  BOTTOM_H)

    -- ---- Data ------------------------------------------------------------
    local entries = spec.getEntries() or {}
    local perSeries, maxY, points, startTs, bucketSec = bucketize(entries, series, period)

    -- Stash everything the hover overlay needs to map a cursor X to a bucket
    -- and render that bucket's per-series values. xMax matches SetXAxis below.
    _state.hover = {
        series = series, perSeries = perSeries, prefs = prefs,
        points = points, startTs = startTs, bucketSec = bucketSec, period = period,
        xMin = 0, xMax = math.max(2, points + 1),
    }
    _ui._hoverBucket = nil

    ui.graph:ResetData()
    local anyData = false
    for i, s in ipairs(series) do
        if seriesVisible(prefs, s.key) and perSeries[i].total > 0 then
            local data = {}
            for b = 1, points do data[b] = { b, perSeries[i].buckets[b] } end
            ui.graph:AddDataSeries(data, s.color, false)
            anyData = true
        end
    end

    ui.graph:SetXAxis(0, math.max(2, points + 1))
    ui.graph:SetYAxis(0, maxY + 1)
    ui.graph:SetGridSpacing(1, math.ceil(maxY / 10) + 1)
    ui.graph:SetYLabels(true, false)
    ui.graph.XLabelsEnabled = false   -- we render our own time anchors below

    ui.empty:SetText(anyData and "" or "|cff888888No data in this period.|r")
    local now = (GetServerTime and GetServerTime()) or time()
    ui.xLeft:SetText(axisText(period, startTs, now))
    ui.xMid:SetText(axisText(period, (startTs + now) / 2, now))
    ui.xRight:SetText(axisText(period, now, now))

    -- ---- Totals line -----------------------------------------------------
    local parts = {}
    for i, s in ipairs(series) do
        if seriesVisible(prefs, s.key) then
            tinsert(parts, string.format("|c%s%s|r: %d",
                hexColor(s.color), s.label or s.key, perSeries[i].total))
        end
    end
    ui.totals:SetText(table.concat(parts, "   "))
end

-- Human label for a hovered bucket: an hour range for the 24h period, a day
-- for the daily periods, the bucket's start date for "all time".
local function bucketLabel(h, b)
    local startTs = h.startTs + (b - 1) * h.bucketSec
    if h.period.days == 1 then
        return date("%m/%d %H:00", startTs) .. " – " .. date("%H:00", startTs + h.bucketSec)
    end
    return date("%a %m/%d/%y", startTs)
end

-- ---------------------------------------------------------------------------
-- Public: Render / Detach
-- ---------------------------------------------------------------------------
-- Reparent the singleton frame set onto `host`, bind it to `spec`+`prefs`, and
-- draw. Returns a handle with :Detach() to park the frames back on UIParent
-- (call from the host container's OnRelease).
-- `logPicker` (optional) = { options = {{value,text},...}, current = key,
-- onSelect = function(key) } — renders the leftmost dropdown in the control
-- row so the Log / Period / Expansion pickers share one line.
function LogGraph:Render(host, spec, prefs, logPicker)
    local ui = ensureUI()
    _state.spec  = spec
    _state.prefs = prefs

    ui.root:SetParent(host)
    ui.root:ClearAllPoints()
    ui.root:SetAllPoints(host)
    ui.root:Show()

    -- Log picker (leftmost). When absent, hide it and pull the period dropdown
    -- to the left edge so there's no gap.
    if logPicker then
        setDropdown(ui.logDD, logPicker.options, logPicker.current, function(v)
            if logPicker.onSelect then logPicker.onSelect(v) end
        end)
        ui.logDD:Show()
        ui.periodDD:ClearAllPoints()
        ui.periodDD:SetPoint("LEFT", ui.logDD, "RIGHT", -10, 0)
    else
        ui.logDD:Hide()
        ui.periodDD:ClearAllPoints()
        ui.periodDD:SetPoint("TOPLEFT", ui.root, "TOPLEFT", -12, 4)
    end

    -- AceGUI sizes the host frame AFTER this Draw, and the user can resize the
    -- window later. Re-flow on size change so the checkbox column count and the
    -- graph area track the real width/height (SetScript replaces, so repeated
    -- Renders don't stack handlers).
    ui.root:SetScript("OnSizeChanged", function()
        if _state.spec then refresh() end
    end)

    -- Hover tooltip: map the cursor's X across the plot to a time bucket (the
    -- graph plots data X in [xMin,xMax] across its full width — see LibGraph's
    -- pixel = Width*(x-XMin)/(XMax-XMin)) and show that bucket's per-series
    -- values. Rebuilds only when the bucket under the cursor changes.
    ui.hover:SetScript("OnLeave", function()
        GameTooltip:Hide()
        ui._hoverBucket = nil
    end)
    ui.hover:SetScript("OnUpdate", function(frame)
        local h = _state.hover
        local g = ui.graph
        local w = g:GetWidth()
        if not (h and w and w > 0 and frame:IsMouseOver()) then
            if ui._hoverBucket ~= nil then GameTooltip:Hide(); ui._hoverBucket = nil end
            return
        end
        local scale = g:GetEffectiveScale()
        local cx = (GetCursorPosition()) / scale
        local frac = (cx - g:GetLeft()) / w
        if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
        local dataX = h.xMin + frac * (h.xMax - h.xMin)
        local b = math.floor(dataX + 0.5)
        if b < 1 then b = 1 elseif b > h.points then b = h.points end
        if b == ui._hoverBucket then return end   -- still in the same bucket
        ui._hoverBucket = b

        GameTooltip:SetOwner(frame, "ANCHOR_CURSOR")
        GameTooltip:ClearLines()
        GameTooltip:AddLine(bucketLabel(h, b))
        local any = false
        for i, s in ipairs(h.series) do
            if seriesVisible(h.prefs, s.key) then
                local v = h.perSeries[i].buckets[b] or 0
                if v > 0 then
                    local c = s.color
                    GameTooltip:AddDoubleLine(s.label or s.key, tostring(v),
                        c[1], c[2], c[3], 1, 1, 1)
                    any = true
                end
            end
        end
        if not any then GameTooltip:AddLine("No data", 0.6, 0.6, 0.6) end
        GameTooltip:Show()
    end)

    -- Period dropdown.
    local periodItems = {}
    for i, p in ipairs(PERIODS) do periodItems[i] = { value = i, text = p.label } end
    setDropdown(ui.periodDD, periodItems, prefs.period or 1, function(v)
        prefs.period = v
        refresh()
    end)

    -- Sub-filter dropdown (e.g. expansion) — shown only when the spec has one.
    if spec.subFilter then
        local opts = spec.subFilter.getOptions() or {}
        if prefs.sub == nil or not (function()
            for _, o in ipairs(opts) do if o.value == prefs.sub then return true end end
        end)() then
            prefs.sub = spec.subFilter.default and spec.subFilter.default() or (opts[1] and opts[1].value)
        end
        setDropdown(ui.subDD, opts, prefs.sub, function(v)
            prefs.sub = v
            refresh()
        end)
        ui.subDD:Show()
    else
        ui.subDD:Hide()
    end

    refresh()

    return {
        Detach = function()
            ui.root:Hide()
            ui.root:SetParent(UIParent)
            ui.root:ClearAllPoints()
            _state.spec, _state.prefs = nil, nil
        end,
    }
end
