local addonName, ns = ...

------------------------------------------------------------
-- On-screen shopping list tracker.
--
-- Preferred home: a custom section in Blizzard's objective
-- tracker. That registration surface is the least stable API we
-- touch, so the guaranteed path is a compact standalone themed
-- frame; Blizzard registration is attempted first and any
-- failure silently selects the fallback.
------------------------------------------------------------

local LINE_HEIGHT = 20
-- Height of the title band itself: it hugs the title rather than forming a
-- thick bar.
local HEADER_HEIGHT = 20
-- Where entry lines begin. The gap matters: anchoring lines at HEADER_HEIGHT
-- put the first row on top of the header's bottom border, and line frames draw
-- above the parent's ARTWORK layer, so the border was covered up.
local CONTENT_TOP = HEADER_HEIGHT + 8
local FRAME_WIDTH = 320

-- No backdrop: like Blizzard's objective tracker, the frame sits over the
-- world with only its title row backed (see headerBg below). The GameFont
-- objects carry a shadow, so the entries stay readable without a box.
local standalone = CreateFrame("Frame", "HHTrackerFrame", UIParent)
standalone:SetSize(FRAME_WIDTH, 80)
-- Anchored by its TOP edge so growing, shrinking and collapsing all extend
-- downward, leaving the header where the user put it. Anchoring by RIGHT --
-- the midpoint of the right edge -- resized the frame symmetrically about that
-- point, so collapsing appeared to roll the header inwards from both sides.
standalone:SetPoint("TOPRIGHT", UIParent, "RIGHT", -60, 120)
standalone:SetMovable(true)
standalone:EnableMouse(true)
standalone:RegisterForDrag("LeftButton")
standalone:SetScript("OnDragStart", standalone.StartMoving)
standalone:SetScript("OnDragStop", function(self)
    self:StopMovingOrSizing()
    -- Dragging leaves the frame on whatever anchor the client chose, which may
    -- again be a centre one. Re-pin it by the top-left at its current position
    -- so the next collapse still rolls up rather than in.
    local top, left = self:GetTop(), self:GetLeft()
    if top and left then
        self:ClearAllPoints()
        self:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", left, top)
    end
end)
-- Right-click anywhere on the tracker opens the addon options.
standalone:SetScript("OnMouseUp", function(_, button)
    if button == "RightButton" and ns.OpenSettings then ns:OpenSettings() end
end)
standalone:SetClampedToScreen(true)
standalone:Hide()
ns._trackerFrame = standalone

-- Golds sampled from Blizzard's objective tracker. These are deliberately NOT
-- the website theme's golds (ns.Theme.HEADING/BORDER): those are the paler,
-- desaturated palette the addon's own windows use, and against the tracker's
-- art they read washed out. The tracker mimics Blizzard, so it takes
-- Blizzard's values.
local BLIZZ_BAND_TOP = "#251D0F"     -- band gradient, top
local BLIZZ_BAND_BOTTOM = "#553D0F"  -- band gradient, bottom
local BLIZZ_RULE = "#BA960A"         -- the header's bottom border
local BLIZZ_TITLE = "#FFDA04"        -- title, collapse glyph, group headings
-- The band lets a little of the world through rather than sealing it off.
local BAND_ALPHA = 0.8

-- The header fades out into the world at both ends instead of stopping at a
-- hard edge. The ramp is short, so the middle is at full strength for nearly
-- the whole width.
local FADE_WIDTH = 20
local FADE_STEPS = 8

local function hexRGBA(hex, alpha)
    local r, g, b = ns.HexToRGB(hex)
    return r, g, b, alpha or 1
end

-- Like Blizzard's tracker, ONLY the title row is backed: a translucent band
-- behind the header, with the entries left over open world. Filling the body
-- turns the tracker into a panel, which is not what the objective tracker
-- does -- the shadowed fonts carry the entries on their own.
--
-- A texture carries ONE linear gradient, so the horizontal alpha ramp cannot
-- share a texture with the vertical colour fade. The strip is built from
-- slices instead: every slice carries the full vertical fade and differs only
-- in alpha, which steps up across the leading FADE_WIDTH pixels and back down
-- across the trailing ones. Slicing (rather than flat-colouring the ends)
-- keeps the vertical fade unbroken, so no seam shows where the ends meet the
-- middle.
--
-- `paint(tex, fraction)` fills one slice at `fraction` of full opacity.
local function buildFadingStrip(layer, height, yOffset, paint)
    local pieces = {}
    local sliceWidth = FADE_WIDTH / FADE_STEPS

    for step = 1, FADE_STEPS do
        local fraction = step / FADE_STEPS
        local inset = (step - 1) * sliceWidth
        for _, corner in ipairs({ "TOPLEFT", "TOPRIGHT" }) do
            local slice = standalone:CreateTexture(nil, layer)
            slice:SetSize(sliceWidth, height)
            slice:SetPoint(corner, corner == "TOPLEFT" and inset or -inset, yOffset)
            paint(slice, fraction)
            table.insert(pieces, slice)
        end
    end

    -- Full strength across everything the ramps leave.
    local middle = standalone:CreateTexture(nil, layer)
    middle:SetHeight(height)
    middle:SetPoint("TOPLEFT", FADE_WIDTH, yOffset)
    middle:SetPoint("TOPRIGHT", -FADE_WIDTH, yOffset)
    paint(middle, 1)
    table.insert(pieces, middle)

    return middle, pieces
end

-- SetGradient modulates the texture's vertex colour, so the base is painted
-- white; a coloured base would multiply into the gradient and darken it.
local function paintBandSlice(tex, fraction)
    local alpha = BAND_ALPHA * fraction
    if tex.SetGradient and CreateColor then
        tex:SetColorTexture(1, 1, 1, 1)
        -- VERTICAL: the min colour sits at the bottom, the max at the top.
        local ok = pcall(tex.SetGradient, tex, "VERTICAL",
            CreateColor(hexRGBA(BLIZZ_BAND_BOTTOM, alpha)),
            CreateColor(hexRGBA(BLIZZ_BAND_TOP, alpha)))
        if ok then return end
    end
    -- No gradient available: the dark end alone, so it never falls back to a
    -- white band.
    tex:SetColorTexture(hexRGBA(BLIZZ_BAND_TOP, alpha))
end

local headerBg, headerBandPieces = buildFadingStrip("BACKGROUND", HEADER_HEIGHT, 0, paintBandSlice)
ns._trackerHeaderBg = headerBg
ns._trackerHeaderBandPieces = headerBandPieces

-- Gold title in the style of Blizzard's "Quests" header, with a faint divider.
local header = standalone:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
header:SetPoint("TOPLEFT", 6, -2)
header:SetText("Decor Shopping List")
header:SetTextColor(hexRGBA(BLIZZ_TITLE))
ns._trackerHeader = header

-- Collapse control, mirroring the objective tracker's: bare gold glyph at the
-- end of the title row, no button chrome. Shows the action available, so it
-- reads "-" while expanded and "+" once collapsed.
local collapseButton = CreateFrame("Button", nil, standalone)
collapseButton:SetSize(18, 18)
collapseButton:SetPoint("TOPRIGHT", -6, -1)
collapseButton.text = collapseButton:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
collapseButton.text:SetPoint("CENTER")
collapseButton.text:SetTextColor(hexRGBA(BLIZZ_TITLE))
collapseButton:SetScript("OnClick", function() ns:ToggleTrackerCollapsed() end)
ns._trackerCollapseButton = collapseButton

-- The gold rule is the band's bottom BORDER, drawn at the band's bottom edge
-- rather than inside it. It is a distinctly brighter gold than the band's
-- bottom, so the two read as border and background rather than merging.
--
-- It fades on the same ramp as the band: a hard-edged line would carry on past
-- where the band has faded away and hang in the air at both ends.
local function paintRuleSlice(tex, fraction)
    tex:SetColorTexture(hexRGBA(BLIZZ_RULE, fraction))
end

local divider, dividerPieces = buildFadingStrip("ARTWORK", 2, -HEADER_HEIGHT, paintRuleSlice)
ns._trackerDivider = divider
ns._trackerDividerPieces = dividerPieces

local lines = {}
ns._trackerLines = lines

-- Colours are embedded in the text (|c..|r) rather than set on the
-- fontstring: the Blizzard module's block fontstrings can't be reliably
-- recoloured, and the standalone frame uses the same coded strings for
-- consistency. Group headings take the same Blizzard gold as the title;
-- entries are white,
-- dimmed to muted grey once fulfilled.
local function HexColor(color)
    return string.format("ff%02x%02x%02x",
        math.floor(color.r * 255 + 0.5),
        math.floor(color.g * 255 + 0.5),
        math.floor(color.b * 255 + 0.5))
end
local HEX_HEADING = "ff" .. (BLIZZ_TITLE:gsub("^#", "")):lower()
local HEX_MUTED = HexColor(ns.Theme.TEXT_MUTED)
local HEX_ENTRY = "ffffffff"

local function Colored(hex, text)
    return "|c" .. hex .. text .. "|r"
end

-- Green ready-check tick, sized to the line height (:0). Renders in both
-- the standalone fontstring and the Blizzard block header.
local CHECK_MARKUP = "|TInterface\\RaidFrame\\ReadyCheck-Ready:0|t"

local function IsFulfilled(available, qty)
    return available ~= nil and available >= qty
end

-- Fully styled entry string: a "- " dash bullet in white, or a green
-- check with dimmed text once the requirement is met.
local function EntryLine(entry, available, inUse)
    local line = ns.FormatEntryLine(entry, available, inUse)
    if IsFulfilled(available, entry.qty) then
        return Colored(HEX_MUTED, CHECK_MARKUP .. " " .. line)
    end
    return Colored(HEX_ENTRY, "- " .. line)
end

local function EnsureLine(index)
    local line = lines[index]
    if line then return line end
    line = CreateFrame("Button", nil, standalone)
    line:SetHeight(LINE_HEIGHT)
    line:SetPoint("TOPLEFT", 8, -(CONTENT_TOP + (index - 1) * LINE_HEIGHT))
    line:SetPoint("RIGHT", -8, 0)
    line.text = line:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    line.text:SetAllPoints()
    line.text:SetJustifyH("LEFT")
    line.text:SetWordWrap(false)
    line:SetScript("OnClick", function(self)
        if self.entry then
            ns:OpenCatalogTo(self.entry)
        end
    end)
    lines[index] = line
    return line
end

-- Flatten the grouped entries into ordered display rows: a group-label
-- header row followed by its entry rows. Shared shape for both renderers.
local function BuildRows()
    local rows = {}
    for _, group in ipairs(ns.ShoppingList.GetGroupedEntries()) do
        table.insert(rows, { header = group.label })
        for _, entry in ipairs(group.entries) do
            table.insert(rows, { entry = entry })
        end
    end
    return rows
end

local function isCollapsed()
    return ns.db and ns.db.trackerCollapsed == true
end

-- Resize with the top edge pinned, so the tracker always rolls up and down from
-- its header.
--
-- SetHeight alone is not enough: which way a frame grows depends on the anchor
-- it currently holds, and a centre anchor (the client picks one after a drag,
-- and RIGHT is the midpoint of the right edge) resizes it symmetrically --
-- collapsing pulls the header down towards the middle. Rather than trusting
-- whatever anchor the frame happens to hold, capture the top-left first and
-- re-pin it there afterwards.
local function setHeightKeepingTop(height)
    local top, left = standalone:GetTop(), standalone:GetLeft()
    standalone:SetHeight(height)
    if top and left then
        standalone:ClearAllPoints()
        standalone:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", left, top)
    end
end

local function RenderStandalone()
    local rows = BuildRows()
    if #rows == 0 then
        standalone:Hide()
        return
    end

    collapseButton.text:SetText(isCollapsed() and "+" or "-")

    -- Collapsed: just the title row, so the frame stays a handle you can find
    -- and drag rather than disappearing entirely.
    if isCollapsed() then
        for _, line in ipairs(lines) do
            line:Hide()
            line.entry = nil
        end
        -- Title row plus its border, so the collapsed bar keeps its rule.
        setHeightKeepingTop(HEADER_HEIGHT + 4)
        standalone:Show()
        return
    end

    for i, row in ipairs(rows) do
        local line = EnsureLine(i)
        if row.header then
            line.entry = nil
            line.text:SetText(Colored(HEX_HEADING, row.header))
        else
            local entry = row.entry
            line.entry = entry
            local available, inUse = ns.Availability.Get(entry.type, entry.id)
            line.text:SetText(EntryLine(entry, available, inUse))
        end
        line:Show()
    end
    for i = #rows + 1, #lines do
        lines[i]:Hide()
        lines[i].entry = nil
    end
    setHeightKeepingTop(CONTENT_TOP + #rows * LINE_HEIGHT + 6)
    standalone:Show()
end

-- Collapse/expand the standalone tracker. The state persists so the tracker
-- comes back the way it was left.
function ns:ToggleTrackerCollapsed()
    if ns.db then
        ns.db.trackerCollapsed = not isCollapsed()
    end
    RenderStandalone()
end

------------------------------------------------------------
-- Blizzard objective tracker registration.
--
-- The module is built from Blizzard's ObjectiveTrackerModuleTemplate
-- (confirmed instantiable by addons via /hh trackerprobe) and only
-- handed to AddModule after a completeness check. The first release
-- registered a bare mixin table and crashed Blizzard's module sort
-- ("attempt to compare number with nil" on uiOrder) inside Blizzard's
-- own update — outside any pcall we can hold — taking the whole quest
-- tracker down. Hence the hard rule encoded here: never give the
-- container a module we cannot prove valid, and unregister at the
-- first runtime error (RemoveModule exists on live clients).
------------------------------------------------------------

-- Live modules occupy uiOrder 1-11 (Scenario .. World Quests); sort after.
local BLIZZARD_UI_ORDER = 12

local blizzardModule

local function IsCompleteModule(module)
    return type(module) == "table"
        and module.Header ~= nil
        and module.ContentsFrame ~= nil
        and type(module.MarkDirty) == "function"
        and type(module.GetBlock) == "function"
        and type(module.LayoutBlock) == "function"
        and type(module.BeginLayout) == "function"
        and type(module.EndLayout) == "function"
end

local function SetBlockText(module, block, text)
    if block.SetHeader then
        block:SetHeader(text)
    elseif module.SetBlockHeader then
        module:SetBlockHeader(block, text)
    end
end

local function LayoutContents(self)
    -- Runs inside Blizzard's layout pass; an escaping error would break
    -- the whole tracker, so the body is pcall-sealed. Each group gets a
    -- label block ("Decors"/"Dyes"), then a "- ..." block per entry.
    pcall(function()
        for _, group in ipairs(ns.ShoppingList.GetGroupedEntries()) do
            local headerBlock = self:GetBlock("HHHEADER_" .. group.key)
            if headerBlock then
                headerBlock.hhEntry = nil
                SetBlockText(self, headerBlock, Colored(HEX_HEADING, group.label))
                self:LayoutBlock(headerBlock)
            end
            for _, entry in ipairs(group.entries) do
                local available, inUse = ns.Availability.Get(entry.type, entry.id)
                local block = self:GetBlock(entry.type .. entry.id)
                if block then
                    block.hhEntry = entry
                    SetBlockText(self, block, EntryLine(entry, available, inUse))
                    self:LayoutBlock(block)
                end
            end
        end
    end)
end

local function TryRegisterBlizzardModule()
    local otf = _G.ObjectiveTrackerFrame
    -- RemoveModule is required up front: it is the undo path if the
    -- Blizzard side errors later (see RefreshTracker's demotion).
    if not (otf and type(otf.AddModule) == "function"
        and type(otf.RemoveModule) == "function") then
        return false
    end

    local ok, module = pcall(CreateFrame, "Frame",
        "HousingHeraldObjectiveTracker", otf, "ObjectiveTrackerModuleTemplate")
    if not ok or not IsCompleteModule(module) then
        if type(module) == "table" and module.Hide then
            pcall(module.Hide, module)
        end
        return false
    end

    module.uiOrder = BLIZZARD_UI_ORDER
    module.headerText = "Housing Herald"
    module.LayoutContents = LayoutContents

    -- Blizzard's block frames route header clicks to their parent module.
    module.OnBlockHeaderClick = function(_, block)
        local entry = block and block.hhEntry
        if entry then
            ns:OpenCatalogTo(entry)
        end
    end

    -- The template's OnLoad applies headerText to the header fontstring,
    -- but it ran inside CreateFrame — before headerText was set — so the
    -- header renders blank unless written directly (seen in-game).
    local headerFS = module.Header and module.Header.Text
    if headerFS and headerFS.SetText then
        pcall(headerFS.SetText, headerFS, module.headerText)
    end

    local added = pcall(otf.AddModule, otf, module)
    if not added then
        pcall(module.Hide, module)
        return false
    end

    blizzardModule = module
    return true
end

local function UnregisterBlizzardModule()
    if not blizzardModule then return end
    local otf = _G.ObjectiveTrackerFrame
    if otf and type(otf.RemoveModule) == "function" then
        pcall(otf.RemoveModule, otf, blizzardModule)
    end
    pcall(blizzardModule.Hide, blizzardModule)
    blizzardModule = nil
end

------------------------------------------------------------
-- Taint watcher.
--
-- Adding our module to the objective tracker can taint the secure UI. The
-- error surfaces inside Blizzard's code (outside any pcall we hold), so we
-- can't prevent it — but we can watch the error stream and, once, tell the
-- user how to escape: switch to the standalone tracker and /reload.
------------------------------------------------------------

function ns:NotifyTaint()
    if ns._taintNotified then return end
    ns._taintNotified = true
    ns.Print("A UI error was caused by the Blizzard objective-tracker "
        .. "integration. Switch to the standalone tracker with |cffffffff/hherald "
        .. "tracker standalone|r (or untick it in Options > AddOns > Housing "
        .. "Herald), then |cffffffff/reload|r.")
end

-- Observed: "attempted to iterate a table that cannot be accessed while
-- tainted (execution tainted by 'HousingHerald')"
local function installTaintWatcher()
    if ns._taintWatcherInstalled then return end
    if not (geterrorhandler and seterrorhandler) then return end
    ns._taintWatcherInstalled = true
    local orig = geterrorhandler()
    seterrorhandler(function(err, ...)
        if type(err) == "string" then
            if err:find("HousingHerald", 1, true) and err:find("tainted", 1, true) then
                ns:NotifyTaint()
            elseif ns._trackerMode == "blizzard" and blizzardModule
                and err:find("cannot be accessed while tainted", 1, true) then
                -- Narrower fallback: same taint failure mode, but the error
                -- text doesn't happen to name our addon. Only trust this when
                -- we're actually running the Blizzard integration.
                ns:NotifyTaint()
            end
        end
        if orig then return orig(err, ...) end
    end)
end

------------------------------------------------------------
-- Public API
------------------------------------------------------------

function ns:RefreshTracker()
    if ns._trackerMode == "blizzard" and blizzardModule then
        local ok = pcall(blizzardModule.MarkDirty, blizzardModule)
        if ok then return end
        -- Blizzard path broke at runtime: unhook our module so it cannot
        -- disturb the container again, then demote to standalone.
        UnregisterBlizzardModule()
        ns._trackerMode = "standalone"
    end
    RenderStandalone()
end

-- Pushing the mode back into the settings object keeps an open Options panel's
-- checkbox correct when the mode changes elsewhere (/hherald tracker ...).
--
-- Blizzard's Setting:SetValue applies the value and re-fires its value-changed
-- callback EVERY time -- there is no same-value short circuit -- and that
-- callback calls straight back into SetTrackerMode. This flag is what bounds
-- the cycle; without it every mode switch died with "C stack overflow"
-- (Blizzard_Setting.lua ApplyValue -> TriggerValueChanged -> our callback ->
-- SetTrackerMode -> SetValue -> ...).
local syncingTrackerSetting = false

local function syncTrackerSetting(useBlizzard)
    local setting = ns._trackerSetting
    if syncingTrackerSetting or not (setting and setting.SetValue) then return end
    syncingTrackerSetting = true
    pcall(setting.SetValue, setting, useBlizzard)
    syncingTrackerSetting = false
end

-- Switch tracker homes at the user's request (/hherald tracker ...).
-- Standalone takes effect immediately (and stops our module from disturbing
-- the container); Blizzard registration only happens cleanly at load, so that
-- direction is persisted and applied on the next /reload.
function ns:SetTrackerMode(mode)
    -- Re-entered from our own settings push (see syncTrackerSetting): the
    -- outer call is already applying this exact mode, so doing it again would
    -- duplicate the work and the chat message.
    if syncingTrackerSetting then return end

    if mode ~= "blizzard" and mode ~= "standalone" then
        ns.Print("Tracker mode must be 'blizzard' or 'standalone'.")
        return
    end
    if ns.db then
        ns.db.trackerMode = mode
    end
    syncTrackerSetting(mode == "blizzard")
    if ns.db then ns.db.useBlizzardTracker = mode == "blizzard" end
    if mode == "standalone" then
        UnregisterBlizzardModule()
        ns._trackerMode = "standalone"
        RenderStandalone()
        ns.Print("Tracker: standalone window. Type /reload to fully clear any "
            .. "objective-tracker taint from this session.")
    else
        ns.Print("Tracker: Blizzard objective tracker. Type /reload to apply "
            .. "(this can taint the secure UI on some clients).")
    end
end

function ns:InitTracker()
    local mode = (ns.db and ns.db.trackerMode) or "standalone"
    if mode == "blizzard" then
        ns._trackerMode = TryRegisterBlizzardModule() and "blizzard" or "standalone"
        -- Watch for the taint this integration can cause, so we can point the
        -- user at the standalone fallback.
        if ns._trackerMode == "blizzard" then installTaintWatcher() end
    else
        ns._trackerMode = "standalone"
    end
    ns.ShoppingList.OnChanged(function() ns:RefreshTracker() end)
    ns.Availability.OnChanged(function() ns:RefreshTracker() end)
    ns:RefreshTracker()
end
