-- ============================================================
-- FILE: Market_UI.lua
-- MODULE: MidnightUI_Market_UI (global)
--
-- PURPOSE:
--   Standalone Market window — visually-polished GUI for reviewing
--   live Trade-chat listings captured by Market.lua.
--   Four tabs in a left sidebar: Overview, Browse, Watchlist,
--   Settings. The header is a gradient hero with kinetic stat
--   pills; rows are rich cards with profession icons, class-colored
--   sender names, price chips, and repost badges.
--
-- HOW IT WORKS:
--   1. Window is lazy-built on first Open(). Combat-deferred.
--   2. Left sidebar hosts the four tab buttons; each tab has its
--      own body frame built once, shown/hidden on tab switch.
--   3. A 1-second ticker refreshes the header + active tab while
--      the window is visible; the engine also pokes the UI via a
--      registered listener so updates are push-driven.
--   4. Browse uses a row pool per column (Offers / Requests) that
--      re-homes onto a slice of the filtered listings array.
--   5. Slash commands /muimarket, /muim { open | close | toggle |
--      clear | pause | resume | status | test [msg] }.
--
-- DEPENDS ON:
--   _G.MidnightUI_Market      — engine (Market.lua)
--   Blizzard API: CreateFrame, C_Timer, ChatFrame_SendTell,
--                 RAID_CLASS_COLORS, CLASS_ICON_TCOORDS,
--                 InCombatLockdown, time, date
--
-- EXPORTS (_G.MidnightUI_Market_UI):
--   Open, Close, Toggle, IsShown
--   SetFilter(prof, category) — programmatic filter
--
-- OWNED SAVEDVARIABLES: none directly.
--
-- EVENTS REGISTERED:
--   ADDON_LOADED          — engine-listener registration
--   PLAYER_REGEN_ENABLED  — deferred-open queue drain (combat)
--
-- FRAMES CREATED:
--   MidnightUI_Market_Frame (FULLSCREEN_DIALOG strata)
--
-- GOTCHAS:
--   • Icon textures use Blizzard's Interface\Icons paths; if a path
--     is missing on a client it renders as a solid color fallback.
--   • Class icon atlas uses CLASS_ICON_TCOORDS global; falls back
--     to plain color circle if the atlas/global isn't present.
--   • Texture:SetGradient on retail uses ColorMixin; wrapped in a
--     pcall to tolerate API variance.
-- ============================================================

local ADDON_NAME = "MidnightUI"
local MODULE = _G.MidnightUI_Market_UI or {}
_G.MidnightUI_Market_UI = MODULE


-- ============================================================
-- SECTION: Palette — market identity
-- Warm deep-navy backgrounds with gold accent. Category colors
-- give LFW (purple) and LFC (green) instant identity. Price chips
-- pick up the gold accent.
-- ============================================================

local C = {
    bg         = {0.050, 0.058, 0.105},  -- deep navy (MidnightUI base)
    panel      = {0.075, 0.090, 0.150},  -- main panels
    card       = {0.100, 0.128, 0.195},  -- cards on panels
    cardHi     = {0.140, 0.175, 0.255},  -- hover state
    inset      = {0.065, 0.080, 0.135},  -- recessed surfaces
    border     = {0.180, 0.220, 0.300},  -- cool blue-gray
    borderHi   = {0.310, 0.380, 0.500},  -- hover/active border
    text       = {0.940, 0.950, 0.965},  -- cool ivory
    muted      = {0.580, 0.605, 0.650},  -- cool muted
    subtle     = {0.400, 0.415, 0.450},  -- darkest legible text
    accent     = {1.000, 0.790, 0.310},  -- market gold
    accentDim  = {0.580, 0.440, 0.150},
    accentGlow = {1.000, 0.860, 0.430},
    lfw        = {0.700, 0.430, 0.970},  -- offers (purple category)
    lfwDim     = {0.390, 0.230, 0.560},
    lfc        = {0.350, 0.860, 0.530},  -- requests (green category)
    lfcDim     = {0.180, 0.480, 0.280},
    gr         = {0.320, 0.780, 0.960},  -- guild recruiting (cyan-teal)
    grDim      = {0.140, 0.420, 0.560},
    price      = {1.000, 0.825, 0.350},  -- gold coin
    priceDim   = {0.570, 0.445, 0.170},
    danger     = {0.920, 0.330, 0.330},
    heatLow    = {0.100, 0.128, 0.195},  -- matches card color
    heatHigh   = {0.290, 0.225, 0.105},  -- warm amber for active profs
}

local ICON_PROF = {
    Alchemy         = "Interface\\Icons\\Trade_Alchemy",
    Blacksmithing   = "Interface\\Icons\\Trade_BlackSmithing",
    Enchanting      = "Interface\\Icons\\Trade_Engraving",
    Engineering     = "Interface\\Icons\\Trade_Engineering",
    Inscription     = "Interface\\Icons\\INV_Inscription_Tradeskill01",
    Jewelcrafting   = "Interface\\Icons\\INV_Misc_Gem_01",
    Leatherworking  = "Interface\\Icons\\Trade_LeatherWorking",
    Tailoring       = "Interface\\Icons\\Trade_Tailoring",
    Herbalism       = "Interface\\Icons\\Trade_Herbalism",
    Mining          = "Interface\\Icons\\Trade_Mining",
    Skinning        = "Interface\\Icons\\INV_Misc_Pelt_Wolf_01",
    Cooking         = "Interface\\Icons\\INV_Misc_Food_15",
    Guild           = "Interface\\Icons\\INV_Shirt_GuildTabard_01",
    Unknown         = "Interface\\Icons\\INV_Misc_QuestionMark",
}

local ICON_TAB = {
    overview  = "Interface\\Icons\\INV_Misc_Coin_01",
    browse    = "Interface\\Icons\\INV_Scroll_03",
    recruit   = "Interface\\Icons\\INV_Shirt_GuildTabard_01",
    watchlist = "Interface\\Icons\\INV_Misc_Note_01",
}

local ICON_STAT = {
    listings = "Interface\\Icons\\INV_Letter_18",
    offers   = "Interface\\Icons\\Trade_BlackSmithing",
    requests = "Interface\\Icons\\INV_Misc_Bag_10",
    price    = "Interface\\Icons\\INV_Misc_Coin_01",
}

local WHITE_TEX = "Interface\\Buttons\\WHITE8X8"
local GOLD_COIN = "Interface\\MoneyFrame\\UI-GoldIcon"
local CLASS_ATLAS = "Interface\\TargetingFrame\\UI-Classes-Circles"

local FONT_PATH_DEFAULT = "Fonts\\FRIZQT__.TTF"
local FONT_PATH_SANS    = "Fonts\\ARIALN.TTF"

-- Safe font path — prefer Arial Narrow for dense UI, fallback FRIZQT
local function PickFont()
    return FONT_PATH_SANS
end


-- ============================================================
-- SECTION: Layout constants
-- ============================================================

local LAYOUT = {
    width        = 1340,
    height       = 840,
    sidebarW     = 220,
    headerH      = 92,
    pad          = 16,
    rowH         = 64,
    rowStride    = 70,
    poolSize     = 10,
}


-- ============================================================
-- SECTION: Internal state
-- ============================================================

local frame
local sidebar
local contentHost
local tabBodies  = {}
local tabButtons = {}
local activeTabId = "overview"
local deferredOpen = false
local uiTicker

local browseState = {
    category    = "All",
    profession  = "All",
    search      = "",
    sort        = "recent",
    selectedKey = nil,
    filteredLFW = {},
    filteredLFC = {},
}

-- Hoisted up so closures built in the Overview section (e.g. the
-- profession-card OnClick that jumps to the Browse tab and clears
-- the search box) can capture it. The Browse section's widgets
-- populate fields on this table later.
local browseUI = {}

local watchlistState = { filtered = {}, selectedKey = nil }


-- ============================================================
-- SECTION: Low-level widget helpers
-- ============================================================

local function Engine() return _G.MidnightUI_Market end
local function GetSettings()
    local e = Engine(); return e and e.GetSettings() or nil
end

local function NewFS(parent, layer, size, color, flags)
    local fs = parent:CreateFontString(nil, layer or "OVERLAY")
    fs:SetFont(PickFont(), size or 13, flags or "")
    if color then fs:SetTextColor(color[1], color[2], color[3], color[4] or 1) end
    return fs
end

local function SolidTex(parent, layer, color, alpha)
    local tex = parent:CreateTexture(nil, layer or "BACKGROUND")
    tex:SetTexture(WHITE_TEX)
    tex:SetVertexColor(color[1], color[2], color[3], alpha or 1)
    return tex
end

local function SetGrad(tex, orient, c1, c2, a1, a2)
    if not tex then return end
    a1 = a1 or 1; a2 = a2 or 1
    tex:SetTexture(WHITE_TEX)
    if _G.CreateColor and tex.SetGradient then
        local ok = pcall(function()
            tex:SetGradient(orient,
                _G.CreateColor(c1[1], c1[2], c1[3], a1),
                _G.CreateColor(c2[1], c2[2], c2[3], a2))
        end)
        if ok then return end
    end
    -- Fallback: solid midpoint
    tex:SetVertexColor((c1[1]+c2[1])/2, (c1[2]+c2[2])/2, (c1[3]+c2[3])/2, (a1+a2)/2)
end

-- Apply card-style layered backdrop to a frame IN PLACE (textures
-- are attached to f's own layers). This matters because a sub-frame
-- BG would render on top of f's own OVERLAY children (labels/icons),
-- making them invisible. Use StyleCard on any frame that will host
-- content directly (buttons, rows). MakeCard wraps it when a
-- dedicated card sub-frame is actually desired.
local function StyleCard(f, bgColor, borderColor, bgAlpha, borderAlpha)
    bgColor = bgColor or C.card
    borderColor = borderColor or C.border
    bgAlpha = bgAlpha or 1
    borderAlpha = borderAlpha or 1

    local bg = SolidTex(f, "BACKGROUND", bgColor, bgAlpha)
    bg:SetAllPoints(f)
    f._bg = bg

    local function addEdge(side)
        local e = SolidTex(f, "BORDER", borderColor, borderAlpha)
        if side == "T" then e:SetPoint("TOPLEFT", f, "TOPLEFT"); e:SetPoint("TOPRIGHT", f, "TOPRIGHT"); e:SetHeight(1) end
        if side == "B" then e:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT"); e:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT"); e:SetHeight(1) end
        if side == "L" then e:SetPoint("TOPLEFT", f, "TOPLEFT"); e:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT"); e:SetWidth(1) end
        if side == "R" then e:SetPoint("TOPRIGHT", f, "TOPRIGHT"); e:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT"); e:SetWidth(1) end
        return e
    end
    f._edges = { T = addEdge("T"), B = addEdge("B"), L = addEdge("L"), R = addEdge("R") }

    f.SetBG = function(self, color, alpha)
        bg:SetVertexColor(color[1], color[2], color[3], alpha or 1)
    end
    f.SetBorderColor = function(self, color, alpha)
        for _, e in pairs(self._edges) do
            e:SetVertexColor(color[1], color[2], color[3], alpha or 1)
        end
    end
    return f
end

-- Creates a new sub-frame and applies card style. Use only when you
-- actually need an independent sub-frame; prefer StyleCard on the
-- host frame when putting widgets on top.
local function MakeCard(parent, bgColor, borderColor, bgAlpha, borderAlpha)
    local f = CreateFrame("Frame", nil, parent)
    return StyleCard(f, bgColor, borderColor, bgAlpha, borderAlpha)
end

-- Icon with subtle inset border.
local function MakeIcon(parent, path, size)
    local f = CreateFrame("Frame", nil, parent)
    f:SetSize(size, size)
    local border = SolidTex(f, "BACKGROUND", C.border, 1)
    border:SetAllPoints(f)
    local tex = f:CreateTexture(nil, "ARTWORK")
    tex:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1)
    tex:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -1, 1)
    tex:SetTexCoord(0.08, 0.92, 0.08, 0.92) -- crop icon bevel
    if path then tex:SetTexture(path) end
    f._tex = tex
    function f:SetIcon(p) if p then tex:SetTexture(p) end end
    function f:SetDesaturated(v) tex:SetDesaturated(v) end
    return f
end

-- Class icon (round). Uses CLASS_ICON_TCOORDS if available.
local function MakeClassIcon(parent, size)
    local f = CreateFrame("Frame", nil, parent)
    f:SetSize(size, size)
    local tex = f:CreateTexture(nil, "ARTWORK")
    tex:SetAllPoints(f)
    tex:SetTexture(CLASS_ATLAS)
    f._tex = tex
    function f:SetClass(className)
        if not className then tex:SetTexCoord(0, 0, 0, 0); tex:SetAlpha(0); return end
        local t = _G.CLASS_ICON_TCOORDS and _G.CLASS_ICON_TCOORDS[className]
        if t then
            tex:SetTexCoord(t[1], t[2], t[3], t[4])
            tex:SetAlpha(1)
        else
            tex:SetAlpha(0)
        end
    end
    return f
end

-- Flat button with label + optional icon.
local function MakeButton(parent, w, h, label, opts)
    opts = opts or {}
    local b = CreateFrame("Button", nil, parent)
    b:SetSize(w, h)
    StyleCard(b, opts.bg or C.inset, opts.border or C.border, 1, 0.9)
    b._card = b

    if opts.icon then
        b._icon = b:CreateTexture(nil, "ARTWORK")
        b._icon:SetSize(h - 10, h - 10)
        b._icon:SetPoint("LEFT", b, "LEFT", 6, 0)
        b._icon:SetTexture(opts.icon)
        b._icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    end

    b.label = NewFS(b, "OVERLAY", opts.fontSize or 13, opts.textColor or C.text, opts.fontFlags)
    if opts.icon then
        b.label:SetPoint("LEFT", b, "LEFT", (h - 10) + 12, 0)
        b.label:SetPoint("RIGHT", b, "RIGHT", -8, 0)
        b.label:SetJustifyH("LEFT")
    else
        b.label:SetPoint("CENTER")
    end
    b.label:SetText(label or "")

    b:SetScript("OnEnter", function(self)
        if self._locked then return end
        self._card:SetBG(opts.hoverBG or C.cardHi, 1)
        self._card:SetBorderColor(opts.hoverBorder or C.borderHi, 1)
    end)
    b:SetScript("OnLeave", function(self)
        if self._locked then return end
        self._card:SetBG(opts.bg or C.inset, 1)
        self._card:SetBorderColor(opts.border or C.border, 0.9)
    end)
    return b
end

local function SetButtonActive(b, active)
    if not b then return end
    if active then
        b._locked = true
        b._card:SetBG(C.accentDim, 1)
        b._card:SetBorderColor(C.accent, 1)
        if b.label then b.label:SetTextColor(1, 1, 1) end
    else
        b._locked = false
        b._card:SetBG(C.inset, 1)
        b._card:SetBorderColor(C.border, 0.9)
        if b.label then b.label:SetTextColor(C.text[1], C.text[2], C.text[3]) end
    end
end


-- ============================================================
-- SECTION: Format helpers
-- ============================================================

local function Truncate(s, n)
    if not s then return "" end
    if #s <= n then return s end
    return string.sub(s, 1, n - 1) .. "…"
end

-- Strip hyperlink anchor markers from a display string while keeping
-- colors and the visible bracketed name. WoW renders messages with
-- |H<linkdata>|h[Name]|h|r as clickable links, but if a Truncate call
-- cuts through the escape sequence the FontString shows raw junk like
-- "|Htrade:Player-76-0BEC943F:45…". This helper is for row previews
-- where links shouldn't be clickable anyway. The inspector uses the
-- raw message and wires real hyperlink handling.
local function StripLinksForPreview(msg)
    if not msg then return "" end
    -- Replace |H<data>|h<visible>|h with just <visible>. The |r reset
    -- and any outer |cXXXXXXXX color code survive so the bracketed
    -- name still renders in its quality color.
    return (msg:gsub("|H[^|]+|h(.-)|h", "%1"))
end

local function SetText(fs, txt) if fs and fs.SetText then fs:SetText(txt or "") end end

local function FormatGold(n)
    if not n then return "" end
    if n >= 1000000 then
        local m = n / 1000000
        if m >= 10 then return string.format("%.0fm", m) else return string.format("%.1fm", m) end
    elseif n >= 1000 then
        local k = n / 1000
        if k >= 100 then return string.format("%.0fk", k) else return string.format("%.1fk", k):gsub("%.0k", "k") end
    else
        return tostring(n) .. "g"
    end
end

local function FormatRel(ts)
    if not ts or ts == 0 then return "" end
    local d = time() - ts
    if d < 5 then return "just now"
    elseif d < 60 then return d .. "s ago"
    elseif d < 3600 then return math.floor(d / 60) .. "m ago"
    elseif d < 86400 then return math.floor(d / 3600) .. "h ago"
    else return math.floor(d / 86400) .. "d ago" end
end

local function ClassColor(className)
    if className and _G.RAID_CLASS_COLORS and _G.RAID_CLASS_COLORS[className] then
        local c = _G.RAID_CLASS_COLORS[className]
        return c.r or 1, c.g or 1, c.b or 1
    end
    return C.text[1], C.text[2], C.text[3]
end

local function CategoryPair(cat)
    if cat == "LFW" then return C.lfw, C.lfwDim end
    if cat == "LFC" then return C.lfc, C.lfcDim end
    if cat == "GR"  then return C.gr,  C.grDim  end
    return C.muted, C.subtle
end


-- ============================================================
-- SECTION: Hero header
-- Gradient background, title, pause/clear controls, kinetic
-- stat pills with icons.
-- ============================================================

local headerUI = {}

local function BuildStatPill(parent, w, iconPath, label, tintColor)
    local pill = MakeCard(parent, C.card, C.border, 1, 1)
    pill:SetSize(w, 60)

    -- Accent edge on left, colored by tint
    local accent = SolidTex(pill, "ARTWORK", tintColor, 1)
    accent:SetPoint("TOPLEFT", pill, "TOPLEFT")
    accent:SetPoint("BOTTOMLEFT", pill, "BOTTOMLEFT")
    accent:SetWidth(3)

    -- Glow gradient fill behind accent
    local glow = pill:CreateTexture(nil, "ARTWORK")
    glow:SetPoint("TOPLEFT", pill, "TOPLEFT", 3, 0)
    glow:SetPoint("BOTTOMLEFT", pill, "BOTTOMLEFT", 3, 0)
    glow:SetWidth(40)
    SetGrad(glow, "HORIZONTAL", tintColor, C.card, 0.35, 0)

    local icon = MakeIcon(pill, iconPath, 32)
    icon:SetPoint("LEFT", pill, "LEFT", 12, 0)

    local lab = NewFS(pill, "OVERLAY", 10, C.muted)
    lab:SetPoint("TOPLEFT", pill, "TOPLEFT", 54, -10)
    lab:SetPoint("TOPRIGHT", pill, "TOPRIGHT", -8, -10)
    lab:SetJustifyH("LEFT")
    lab:SetText(string.upper(label or ""))

    local value = NewFS(pill, "OVERLAY", 20, C.text, "OUTLINE")
    value:SetPoint("BOTTOMLEFT", pill, "BOTTOMLEFT", 54, 10)
    value:SetPoint("BOTTOMRIGHT", pill, "BOTTOMRIGHT", -8, 10)
    value:SetJustifyH("LEFT")
    value:SetText("0")

    pill._value = value
    pill._tint  = tintColor
    pill._accent = accent
    return pill
end

local function BuildHeader(parent)
    local hdr = CreateFrame("Frame", nil, parent)
    hdr:SetHeight(LAYOUT.headerH)
    hdr:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    hdr:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0)

    -- Solid header background that matches sidebar — no gradient cutoff
    local bg = hdr:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(hdr)
    bg:SetTexture(WHITE_TEX)
    bg:SetVertexColor(C.panel[1], C.panel[2], C.panel[3], 1)

    -- Subtle top highlight (lighter strip across the very top, fades down)
    local topLight = hdr:CreateTexture(nil, "BACKGROUND", nil, 1)
    topLight:SetPoint("TOPLEFT", hdr, "TOPLEFT", 0, 0)
    topLight:SetPoint("TOPRIGHT", hdr, "TOPRIGHT", 0, 0)
    topLight:SetHeight(26)
    SetGrad(topLight, "VERTICAL", C.panel, C.card, 0, 0.35)

    -- Accent underline
    local underline = hdr:CreateTexture(nil, "BORDER")
    underline:SetPoint("BOTTOMLEFT", hdr, "BOTTOMLEFT", 0, 0)
    underline:SetPoint("BOTTOMRIGHT", hdr, "BOTTOMRIGHT", 0, 0)
    underline:SetHeight(2)
    SetGrad(underline, "HORIZONTAL", C.accentDim, C.accent, 1, 1)

    -- Title
    local title = NewFS(hdr, "OVERLAY", 26, C.accentGlow, "OUTLINE")
    title:SetPoint("TOPLEFT", hdr, "TOPLEFT", LAYOUT.pad + 8, -16)
    title:SetText("MARKET")

    local subtitle = NewFS(hdr, "OVERLAY", 11, C.muted)
    subtitle:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 2, -2)
    subtitle:SetText("TRADE CHAT MONITOR")

    -- Pause / Clear / Close buttons (right side)
    local closeBtn = MakeButton(hdr, 32, 28, "×", { fontSize = 16 })
    closeBtn:SetPoint("TOPRIGHT", hdr, "TOPRIGHT", -LAYOUT.pad, -12)
    closeBtn:SetScript("OnClick", function() MODULE.Close() end)

    local clearBtn = MakeButton(hdr, 68, 28, "Clear")
    clearBtn:SetPoint("RIGHT", closeBtn, "LEFT", -6, 0)
    clearBtn:SetScript("OnClick", function()
        if Engine() then Engine().Clear() end
    end)

    local pauseBtn = MakeButton(hdr, 84, 28, "Pause")
    pauseBtn:SetPoint("RIGHT", clearBtn, "LEFT", -6, 0)
    pauseBtn:SetScript("OnClick", function()
        local e = Engine(); if not e then return end
        if e.IsPaused() then e.Resume() else e.Pause() end
    end)
    headerUI.pauseBtn = pauseBtn

    -- Stat pills row centered between title block and buttons
    local pillsFrame = CreateFrame("Frame", nil, hdr)
    pillsFrame:SetPoint("LEFT", hdr, "LEFT", 230, -6)
    pillsFrame:SetPoint("RIGHT", pauseBtn, "LEFT", -20, 0)
    pillsFrame:SetHeight(60)

    local pillW = 176
    local pillGap = 10
    local startX = 0

    headerUI.pillListings = BuildStatPill(pillsFrame, pillW, ICON_STAT.listings, "Listings", C.accent)
    headerUI.pillListings:SetPoint("LEFT", pillsFrame, "LEFT", startX, 0)

    headerUI.pillOffers = BuildStatPill(pillsFrame, pillW, ICON_STAT.offers, "Offers (LFW)", C.lfw)
    headerUI.pillOffers:SetPoint("LEFT", headerUI.pillListings, "RIGHT", pillGap, 0)

    headerUI.pillRequests = BuildStatPill(pillsFrame, pillW, ICON_STAT.requests, "Requests (LFC)", C.lfc)
    headerUI.pillRequests:SetPoint("LEFT", headerUI.pillOffers, "RIGHT", pillGap, 0)

    headerUI.pillGR = BuildStatPill(pillsFrame, pillW, ICON_TAB.recruit, "Guild Recruits", C.gr)
    headerUI.pillGR:SetPoint("LEFT", headerUI.pillRequests, "RIGHT", pillGap, 0)

    -- Make frame draggable via header
    hdr:EnableMouse(true)
    hdr:RegisterForDrag("LeftButton")
    hdr:SetScript("OnDragStart", function() parent:StartMoving() end)
    hdr:SetScript("OnDragStop",  function() parent:StopMovingOrSizing() end)

    headerUI.frame = hdr
    return hdr
end

local function RefreshHeader()
    if not headerUI.pillListings then return end
    local e = Engine()
    if not e then
        SetText(headerUI.pillListings._value, "—")
        return
    end
    local agg = e.GetAggregates()
    local t = agg.totals
    SetText(headerUI.pillListings._value, tostring(t.listings))
    SetText(headerUI.pillOffers._value,   tostring(t.lfw))
    SetText(headerUI.pillRequests._value, tostring(t.lfc))
    SetText(headerUI.pillGR._value,       tostring(t.gr or 0))

    if headerUI.pauseBtn and headerUI.pauseBtn.label then
        if e.IsPaused() then
            headerUI.pauseBtn.label:SetText("Resume")
            headerUI.pauseBtn.label:SetTextColor(C.accent[1], C.accent[2], C.accent[3])
        else
            headerUI.pauseBtn.label:SetText("Pause")
            headerUI.pauseBtn.label:SetTextColor(C.text[1], C.text[2], C.text[3])
        end
    end
end


-- ============================================================
-- SECTION: Sidebar (vertical tab rail)
-- ============================================================

local function BuildSidebarButton(parent, id, label, sublabel, iconPath, order)
    local b = CreateFrame("Button", nil, parent)
    b:SetSize(LAYOUT.sidebarW - 20, 56)
    b:SetPoint("TOPLEFT", parent, "TOPLEFT", 10, -(order - 1) * 62 - 38)

    StyleCard(b, C.inset, C.border, 1, 0.6)
    b._card = b

    -- Left accent bar (hidden when inactive)
    local accent = SolidTex(b, "ARTWORK", C.accent, 1)
    accent:SetPoint("TOPLEFT", b, "TOPLEFT")
    accent:SetPoint("BOTTOMLEFT", b, "BOTTOMLEFT")
    accent:SetWidth(3)
    accent:Hide()
    b._accent = accent

    local icon = MakeIcon(b, iconPath, 32)
    icon:SetPoint("LEFT", b, "LEFT", 12, 0)
    b._icon = icon

    local lab = NewFS(b, "OVERLAY", 14, C.text, "OUTLINE")
    lab:SetPoint("TOPLEFT", icon, "TOPRIGHT", 10, -4)
    lab:SetText(label)
    b._label = lab

    local sub = NewFS(b, "OVERLAY", 10, C.muted)
    sub:SetPoint("TOPLEFT", lab, "BOTTOMLEFT", 0, -3)
    sub:SetText(string.upper(sublabel or ""))
    b._sub = sub

    b:SetScript("OnEnter", function(self)
        if activeTabId == id then return end
        self:SetBG(C.cardHi, 1)
        self:SetBorderColor(C.borderHi, 1)
        self._label:SetTextColor(1, 1, 1)
        self._sub:SetTextColor(C.accent[1], C.accent[2], C.accent[3])
    end)
    b:SetScript("OnLeave", function(self)
        if activeTabId == id then return end
        self:SetBG(C.inset, 1)
        self:SetBorderColor(C.border, 0.6)
        self._label:SetTextColor(C.text[1], C.text[2], C.text[3])
        self._sub:SetTextColor(C.muted[1], C.muted[2], C.muted[3])
    end)
    b:SetScript("OnClick", function() MODULE.SetActiveTab(id) end)
    b._id = id
    tabButtons[id] = b
    return b
end

local function BuildSidebar(parent)
    sidebar = MakeCard(parent, C.panel, C.border, 1, 1)
    sidebar:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -LAYOUT.headerH)
    sidebar:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 0, 0)
    sidebar:SetWidth(LAYOUT.sidebarW)

    local brand = NewFS(sidebar, "OVERLAY", 10, C.accent)
    brand:SetPoint("TOPLEFT", sidebar, "TOPLEFT", 14, -10)
    brand:SetText("NAVIGATION")

    BuildSidebarButton(sidebar, "overview",  "Overview",   "Activity snapshot",   ICON_TAB.overview,  1)
    BuildSidebarButton(sidebar, "browse",    "Browse",     "Offers & requests",   ICON_TAB.browse,    2)
    BuildSidebarButton(sidebar, "recruit",   "Recruiting", "Recruiting & LFG",    ICON_TAB.recruit,   3)
    BuildSidebarButton(sidebar, "watchlist", "Watchlist",  "Pinned entries",      ICON_TAB.watchlist, 4)

    -- Footer tip
    local tip = NewFS(sidebar, "OVERLAY", 10, C.subtle)
    tip:SetPoint("BOTTOMLEFT", sidebar, "BOTTOMLEFT", 14, 14)
    tip:SetPoint("BOTTOMRIGHT", sidebar, "BOTTOMRIGHT", -14, 14)
    tip:SetJustifyH("LEFT")
    tip:SetText("Open with /muim\nRight-click rows to pin")
end

local function UpdateSidebarActiveState()
    for id, b in pairs(tabButtons) do
        if id == activeTabId then
            b:SetBG(C.accentDim, 0.55)
            b:SetBorderColor(C.accent, 1)
            b._accent:Show()
            b._label:SetTextColor(1, 1, 1)
            b._sub:SetTextColor(C.accentGlow[1], C.accentGlow[2], C.accentGlow[3])
        else
            b:SetBG(C.inset, 1)
            b:SetBorderColor(C.border, 0.6)
            b._accent:Hide()
            b._label:SetTextColor(C.text[1], C.text[2], C.text[3])
            b._sub:SetTextColor(C.muted[1], C.muted[2], C.muted[3])
        end
    end
end


-- ============================================================
-- SECTION: Tab infrastructure
-- ============================================================

MODULE._refreshers = {}

function MODULE.SetActiveTab(id)
    if not tabBodies[id] then return end
    activeTabId = id
    for tid, body in pairs(tabBodies) do
        if tid == id then body:Show() else body:Hide() end
    end
    UpdateSidebarActiveState()
    local r = MODULE._refreshers[id]
    if r then pcall(r) end
end

local function BuildTabBody(parent, id)
    local body = CreateFrame("Frame", nil, parent)
    body:SetPoint("TOPLEFT", parent, "TOPLEFT", LAYOUT.sidebarW, -LAYOUT.headerH)
    body:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", 0, 0)
    body:Hide()
    tabBodies[id] = body
    return body
end

local function ContentCard(parent, x1, y1, x2, y2, anchorA, anchorB)
    anchorA = anchorA or "TOPLEFT"
    anchorB = anchorB or "BOTTOMRIGHT"
    local c = MakeCard(parent, C.panel, C.border, 1, 1)
    c:SetPoint(anchorA, parent, anchorA, x1, y1)
    c:SetPoint(anchorB, parent, anchorB, x2, y2)
    return c
end


-- ============================================================
-- SECTION: Row factory (shared by Browse + Watchlist)
-- Each row is a richly-composed listing card with:
--   [category glow]  [prof icon]  [sender + class]  [message]
--                                 [price chip]  [×N]  [time]
-- ============================================================

-- Row layout (rowH = 64):
--   Columns:    [ sev ] [ profIcon 40 ] [ content ................... ] [ right chips ]
--   Left edge:  0       10              58                              row.right-130
--   Two stacked content lines:
--     Line 1 (y -10..-28): classIcon + sender + profChip
--     Line 2 (y -36..-58): message preview
--   Right chips (top to bottom):
--     Price card  (top-right, y -8..-34)
--     Count badge (below price, y -36..-56)   shown only when count > 1
--     Time        (bottom-right corner)
local function BuildListingRow(parent)
    local row = CreateFrame("Button", nil, parent)
    row:SetHeight(LAYOUT.rowH)
    StyleCard(row, C.card, C.border, 1, 0.6)
    row._card = row

    -- Category severity bar (solid) + soft gradient glow fading right
    local sev = row:CreateTexture(nil, "ARTWORK")
    sev:SetPoint("TOPLEFT",    row, "TOPLEFT",    0, 0)
    sev:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
    sev:SetWidth(5)
    row._sev = sev

    local sevGlow = row:CreateTexture(nil, "ARTWORK")
    sevGlow:SetPoint("TOPLEFT",    row, "TOPLEFT",    5, 0)
    sevGlow:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 5, 0)
    sevGlow:SetWidth(46)
    row._sevGlow = sevGlow

    -- Profession icon — top-anchored, vertically centered inside row
    local profIcon = MakeIcon(row, nil, 40)
    profIcon:SetPoint("TOPLEFT", row, "TOPLEFT", 10, -12)
    row._profIcon = profIcon

    -- Line 1 — sender block
    local classIcon = MakeClassIcon(row, 16)
    classIcon:SetPoint("TOPLEFT", row, "TOPLEFT", 60, -11)
    row._classIcon = classIcon

    local sender = NewFS(row, "OVERLAY", 13, C.text, "OUTLINE")
    sender:SetPoint("LEFT", classIcon, "RIGHT", 6, 0)
    sender:SetWidth(140)
    sender:SetJustifyH("LEFT")
    sender:SetText("")
    row._sender = sender

    local profChip = NewFS(row, "OVERLAY", 11, C.accent)
    profChip:SetPoint("LEFT", sender, "RIGHT", 6, 0)
    profChip:SetWidth(130)
    profChip:SetJustifyH("LEFT")
    profChip:SetText("")
    row._profChip = profChip

    -- Line 2 — message preview. Word-wrap disabled so long messages
    -- clip at the right edge instead of overflowing the row; this
    -- also keeps embedded hyperlink markers (|H...|h[...]|h|r) intact
    -- for click/hover routing, since we never string-truncate them.
    local msg = NewFS(row, "OVERLAY", 12, C.muted)
    msg:SetPoint("TOPLEFT",     row, "TOPLEFT",  60, -36)
    msg:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -140, 8)
    msg:SetJustifyH("LEFT")
    msg:SetJustifyV("TOP")
    msg:SetWordWrap(false)
    msg:SetText("")
    row._msg = msg

    -- Enable inline hyperlinks on the row so trade-skill, item,
    -- guild, player, achievement, and spell links embedded in the
    -- message render as clickable text. Blizzard's SetItemRef
    -- dispatches every link type correctly.
    -- Click only, no hover tooltips — hovering links doesn't pop
    -- the WoW tooltip; clicking still opens the full destination.
    row:SetHyperlinksEnabled(true)
    row:SetScript("OnHyperlinkClick", function(self, link, text, button)
        if _G.SetItemRef then _G.SetItemRef(link, text, button, self) end
    end)

    -- Right side — price chip (shifted left to leave room for the
    -- close button in the top-right corner).
    local priceCard = MakeCard(row, C.inset, C.priceDim, 1, 1)
    priceCard:SetSize(108, 26)
    priceCard:SetPoint("TOPRIGHT", row, "TOPRIGHT", -24, -8)
    row._priceCard = priceCard

    local coinTex = priceCard:CreateTexture(nil, "ARTWORK")
    coinTex:SetSize(14, 14)
    coinTex:SetPoint("LEFT", priceCard, "LEFT", 6, 0)
    coinTex:SetTexture(GOLD_COIN)
    row._coin = coinTex

    local priceFS = NewFS(priceCard, "OVERLAY", 13, C.price, "OUTLINE")
    priceFS:SetPoint("LEFT",  coinTex,   "RIGHT", 4,  0)
    priceFS:SetPoint("RIGHT", priceCard, "RIGHT", -6, 0)
    priceFS:SetJustifyH("RIGHT")
    priceFS:SetText("")
    row._price = priceFS

    -- Right side — count badge (sits to the left of the price chip
    -- when visible; hidden when count <= 1). Hovering the badge
    -- pops a GameTooltip listing every repost timestamp.
    local countCard = MakeCard(row, C.accentDim, C.accent, 1, 1)
    countCard:SetSize(48, 22)
    countCard:SetPoint("TOPRIGHT", priceCard, "TOPLEFT", -6, 0)
    countCard:EnableMouse(true)
    row._countCard = countCard
    local countFS = NewFS(countCard, "OVERLAY", 11, {1, 1, 1}, "OUTLINE")
    countFS:SetPoint("CENTER")
    countFS:SetText("")
    row._count = countFS

    countCard:SetScript("OnEnter", function(self)
        local L = row._listing
        if not L or not _G.GameTooltip then return end
        local times = L.seenTimes
        if not times or #times == 0 then return end

        _G.GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        _G.GameTooltip:AddLine(string.format("Seen %d time%s",
            #times, (#times == 1) and "" or "s"),
            C.accent[1], C.accent[2], C.accent[3])
        _G.GameTooltip:AddLine(" ")

        local now = time()
        -- List most-recent first so the latest post is at the top.
        for i = #times, 1, -1 do
            local t = times[i]
            local d = now - t
            local rel
            if d < 5       then rel = "just now"
            elseif d < 60  then rel = d .. "s ago"
            elseif d < 3600 then rel = math.floor(d / 60) .. "m ago"
            elseif d < 86400 then rel = math.floor(d / 3600) .. "h ago"
            else rel = math.floor(d / 86400) .. "d ago" end
            local stamp = date and date("%H:%M:%S", t) or tostring(t)
            _G.GameTooltip:AddDoubleLine(stamp, rel, 1, 1, 1, 0.58, 0.6, 0.65)
        end
        _G.GameTooltip:Show()
    end)
    countCard:SetScript("OnLeave", function()
        if _G.GameTooltip then _G.GameTooltip:Hide() end
    end)

    -- Right side — relative timestamp (bottom-right)
    local tm = NewFS(row, "OVERLAY", 10, C.subtle)
    tm:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -12, 10)
    tm:SetText("")
    row._time = tm

    -- Top-right corner — per-listing close button. Removes the whole
    -- listing entry (all collapsed reposts), not just one repost.
    local closeBtn = CreateFrame("Button", nil, row)
    closeBtn:SetSize(16, 16)
    closeBtn:SetPoint("TOPRIGHT", row, "TOPRIGHT", -4, -4)
    local xFS = NewFS(closeBtn, "OVERLAY", 14, C.muted, "OUTLINE")
    xFS:SetPoint("CENTER", closeBtn, "CENTER", 0, 0)
    xFS:SetText("×")
    closeBtn._fs = xFS
    closeBtn:SetScript("OnEnter", function(self)
        self._fs:SetTextColor(C.danger[1], C.danger[2], C.danger[3])
        if _G.GameTooltip then
            _G.GameTooltip:SetOwner(self, "ANCHOR_TOP")
            _G.GameTooltip:SetText("Remove listing", 1, 1, 1)
            _G.GameTooltip:Show()
        end
    end)
    closeBtn:SetScript("OnLeave", function(self)
        self._fs:SetTextColor(C.muted[1], C.muted[2], C.muted[3])
        if _G.GameTooltip then _G.GameTooltip:Hide() end
    end)
    closeBtn:SetScript("OnClick", function(self)
        local L = row._listing
        if L and L.key and Engine() and Engine().RemoveListing then
            Engine().RemoveListing(L.key)
        end
    end)
    row._closeBtn = closeBtn

    -- Row hover: visual highlight only. No tooltip pops on row
    -- mouseover — the message is visible inline, inline hyperlinks
    -- have their own hover tooltips (via OnHyperlinkEnter), the
    -- count badge has its own timestamp tooltip, and full actions
    -- are available via right-click.
    row:SetScript("OnEnter", function(self)
        if not (self._key and self._key == browseState.selectedKey) then
            self:SetBG(C.cardHi, 1)
            self:SetBorderColor(C.borderHi, 1)
        end
    end)
    row:SetScript("OnLeave", function(self)
        if not (self._key and self._key == browseState.selectedKey) then
            self:SetBG(C.card, 1)
            self:SetBorderColor(C.border, 0.6)
        end
    end)
    row:Hide()
    return row
end

local function ApplyRow(row, L)
    if not L then row:Hide(); row._key = nil; row._listing = nil; return end
    row._key = L.key
    row._listing = L

    local catColor, catDim = CategoryPair(L.category)

    row._sev:SetTexture(WHITE_TEX)
    row._sev:SetVertexColor(catColor[1], catColor[2], catColor[3], 1)
    SetGrad(row._sevGlow, "HORIZONTAL", catColor, C.card, 0.42, 0)

    row._profIcon:SetIcon(ICON_PROF[L.profession] or ICON_PROF.Unknown)
    row._classIcon:SetClass(L.senderClass)

    local cr, cg, cb = ClassColor(L.senderClass)
    row._sender:SetTextColor(cr, cg, cb)
    SetText(row._sender, L.senderShort or "?")

    -- GR listings: LFG (player seeking a guild) shows a generic
    -- "seeking guild" chip since there's no guild name to display.
    -- Recruiting posts show the extracted guild tag.
    if L.category == "GR" then
        if L.grSubtype == "LFG" then
            SetText(row._profChip, "seeking guild")
        elseif L.guildName then
            SetText(row._profChip, "<" .. L.guildName .. ">")
        else
            SetText(row._profChip, "<guild>")
        end
        row._profChip:SetTextColor(catColor[1], catColor[2], catColor[3])
    else
        SetText(row._profChip, "· " .. (L.profession or "?"))
        row._profChip:SetTextColor(C.accent[1], C.accent[2], C.accent[3])
    end
    -- Pass the raw message through so link escapes render inline;
    -- the FontString's fixed-size rect + SetWordWrap(false) handles
    -- visual clipping without cutting mid-hyperlink.
    SetText(row._msg, L.message or "")

    if L.category ~= "GR" and L.priceGold then
        row._priceCard:Show()
        SetText(row._price, FormatGold(L.priceGold))
        row._priceCard:SetBorderColor(C.priceDim, 1)
    elseif L.category ~= "GR" and L.priceText and L.priceText ~= "" then
        row._priceCard:Show()
        SetText(row._price, L.priceText)
        row._priceCard:SetBorderColor(C.priceDim, 1)
    else
        row._priceCard:Hide()
    end

    if L.count and L.count > 1 then
        row._countCard:Show()
        SetText(row._count, "×" .. L.count)
        row._countCard:SetBG(catDim, 0.9)
        row._countCard:SetBorderColor(catColor, 1)
    else
        row._countCard:Hide()
    end

    SetText(row._time, FormatRel(L.lastSeen))

    if L.key == browseState.selectedKey then
        row:SetBG(C.cardHi, 1)
        row:SetBorderColor(C.accent, 1)
    else
        row:SetBG(C.card, 1)
        row:SetBorderColor(C.border, 0.6)
    end

    row:Show()
end


-- ============================================================
-- SECTION: Overview tab — dashboard
-- Hero summary stats (already in header). Main content:
--   • Top strip: "Live signal" ribbon showing activity over recent time
--   • Profession grid: rich cards with icon backgrounds + heat
-- ============================================================

local overviewUI = {}

local function BuildProfCard(parent, name)
    local card = MakeCard(parent, C.card, C.border, 1, 1)

    -- Icon filling right side at low alpha (decorative)
    local bgIcon = card:CreateTexture(nil, "BACKGROUND")
    bgIcon:SetSize(88, 88)
    bgIcon:SetPoint("RIGHT", card, "RIGHT", 8, 0)
    bgIcon:SetTexture(ICON_PROF[name] or ICON_PROF.Unknown)
    bgIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    bgIcon:SetAlpha(0.10)
    card._bgIcon = bgIcon

    -- Front icon small
    local icon = MakeIcon(card, ICON_PROF[name] or ICON_PROF.Unknown, 30)
    icon:SetPoint("TOPLEFT", card, "TOPLEFT", 10, -10)
    card._icon = icon

    local title = NewFS(card, "OVERLAY", 13, C.text, "OUTLINE")
    title:SetPoint("TOPLEFT", icon, "TOPRIGHT", 10, -2)
    title:SetText(name)
    card._title = title

    local recent = NewFS(card, "OVERLAY", 9, C.subtle)
    recent:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -2)
    recent:SetText("")
    card._recent = recent

    -- Two-row mini histogram (LFW bar + LFC bar)
    local barsY = -56

    local lfwLabel = NewFS(card, "OVERLAY", 10, C.lfw)
    lfwLabel:SetPoint("TOPLEFT", card, "TOPLEFT", 10, barsY)
    lfwLabel:SetWidth(42)
    lfwLabel:SetJustifyH("LEFT")
    lfwLabel:SetText("LFW 0")

    local lfwBarBG = SolidTex(card, "ARTWORK", C.inset, 1)
    lfwBarBG:SetPoint("LEFT", lfwLabel, "RIGHT", 4, 0)
    lfwBarBG:SetPoint("RIGHT", card, "RIGHT", -10, 0)
    lfwBarBG:SetHeight(7)

    local lfwBar = SolidTex(card, "OVERLAY", C.lfw, 1)
    lfwBar:SetPoint("LEFT", lfwBarBG, "LEFT")
    lfwBar:SetHeight(7)
    lfwBar:SetWidth(1)

    local lfcLabel = NewFS(card, "OVERLAY", 10, C.lfc)
    lfcLabel:SetPoint("TOPLEFT", card, "TOPLEFT", 10, barsY - 14)
    lfcLabel:SetWidth(42)
    lfcLabel:SetJustifyH("LEFT")
    lfcLabel:SetText("LFC 0")

    local lfcBarBG = SolidTex(card, "ARTWORK", C.inset, 1)
    lfcBarBG:SetPoint("LEFT", lfcLabel, "RIGHT", 4, 0)
    lfcBarBG:SetPoint("RIGHT", card, "RIGHT", -10, 0)
    lfcBarBG:SetHeight(7)

    local lfcBar = SolidTex(card, "OVERLAY", C.lfc, 1)
    lfcBar:SetPoint("LEFT", lfcBarBG, "LEFT")
    lfcBar:SetHeight(7)
    lfcBar:SetWidth(1)

    card._lfwLabel = lfwLabel; card._lfwBar = lfwBar; card._lfwBarBG = lfwBarBG
    card._lfcLabel = lfcLabel; card._lfcBar = lfcBar; card._lfcBarBG = lfcBarBG

    -- Price ribbon at bottom
    local priceFS = NewFS(card, "OVERLAY", 10, C.price)
    priceFS:SetPoint("BOTTOMLEFT", card, "BOTTOMLEFT", 10, 8)
    priceFS:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -10, 8)
    priceFS:SetJustifyH("LEFT")
    priceFS:SetText("")
    card._price = priceFS

    -- Make entire card clickable
    local btn = CreateFrame("Button", nil, card)
    btn:SetAllPoints(card)
    btn:SetScript("OnClick", function()
        browseState.profession  = name
        browseState.category    = "All"
        browseState.search      = ""
        browseState.selectedKey = nil
        if browseUI.searchBox then browseUI.searchBox:SetText("") end
        MODULE.SetActiveTab("browse")
    end)
    btn:SetScript("OnEnter", function()
        card:SetBG(C.cardHi, 1); card:SetBorderColor(C.accent, 1)
    end)
    btn:SetScript("OnLeave", function()
        card:SetBG(card._heatBG or C.card, 1); card:SetBorderColor(C.border, 1)
    end)

    return card
end

local function BuildOverview(body)
    local pad = LAYOUT.pad

    -- Wrap the whole tab in a scrollable region so the profession
    -- grid never overflows the body at smaller window / UI scales.
    local scroll = CreateFrame("ScrollFrame", nil, body, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", body, "TOPLEFT", 0, 0)
    scroll:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", -24, 0)

    local content = CreateFrame("Frame", nil, scroll)
    local contentW = LAYOUT.width - LAYOUT.sidebarW - 24
    content:SetSize(contentW, 1)  -- height set after cards are laid out
    scroll:SetScrollChild(content)

    -- The top-level stat tiles live in the window header now; the
    -- Overview tab jumps straight into the profession grid.
    local gridHdrFS = NewFS(content, "OVERLAY", 11, C.accent)
    gridHdrFS:SetPoint("TOPLEFT", content, "TOPLEFT", pad, -pad)
    gridHdrFS:SetText("BY PROFESSION")

    local gridHdrLine = SolidTex(content, "OVERLAY", C.border, 1)
    gridHdrLine:SetPoint("TOPLEFT", gridHdrFS, "BOTTOMLEFT", 0, -4)
    gridHdrLine:SetPoint("RIGHT", content, "RIGHT", -pad, 0)
    gridHdrLine:SetHeight(1)

    -- Profession cards grid
    local grid = CreateFrame("Frame", nil, content)
    grid:SetPoint("TOPLEFT", gridHdrLine, "BOTTOMLEFT", 0, -10)
    grid:SetPoint("RIGHT", gridHdrLine, "RIGHT", 0, 0)

    local PROF_ORDER = {
        "Alchemy","Blacksmithing","Enchanting","Engineering",
        "Inscription","Jewelcrafting","Leatherworking","Tailoring",
        "Herbalism","Mining","Skinning","Cooking","Unknown",
    }
    overviewUI.cards = {}

    local cardsPerRow = 4
    local gap = 10
    local gridContentW = contentW - 2 * pad
    local cardW = (gridContentW - (cardsPerRow - 1) * gap) / cardsPerRow
    local cardH = 120

    for i, name in ipairs(PROF_ORDER) do
        local r = math.floor((i - 1) / cardsPerRow)
        local c = (i - 1) % cardsPerRow
        local card = BuildProfCard(grid, name)
        card:SetSize(cardW, cardH)
        card:SetPoint("TOPLEFT", grid, "TOPLEFT",
            c * (cardW + gap),
            -(r * (cardH + gap)))
        overviewUI.cards[name] = card
    end
    local rows = math.ceil(#PROF_ORDER / cardsPerRow)
    local gridH = rows * cardH + (rows - 1) * gap
    grid:SetHeight(gridH)

    -- Total scroll-child height = pad + section header (11 + 4
    -- underline + 10 gap) + grid + bottom pad
    content:SetHeight(pad + 11 + 4 + 10 + gridH + pad)

    overviewUI.built = true
end

local function RefreshOverview()
    if not overviewUI.built then return end
    local e = Engine(); if not e then return end
    local agg = e.GetAggregates()

    -- Profession cards
    for name, card in pairs(overviewUI.cards) do
        local b = agg.byProfession[name] or { lfwCount=0, lfcCount=0, minLFW=nil, maxLFC=nil, lastActivity=0 }
        local totalN = math.max(1, b.lfwCount + b.lfcCount)
        local maxBar = math.max(1, b.lfwCount, b.lfcCount, 4)

        SetText(card._lfwLabel, "LFW " .. b.lfwCount)
        SetText(card._lfcLabel, "LFC " .. b.lfcCount)

        local bgW = (card._lfwBarBG:GetWidth() or 100)
        card._lfwBar:SetWidth(math.max(1, (b.lfwCount / maxBar) * bgW))
        card._lfcBar:SetWidth(math.max(1, (b.lfcCount / maxBar) * bgW))
        card._lfwBar:SetShown(b.lfwCount > 0)
        card._lfcBar:SetShown(b.lfcCount > 0)

        local priceParts = {}
        if b.minLFW then priceParts[#priceParts+1] = "|cffffd050" .. FormatGold(b.minLFW) .. "|r offer" end
        if b.maxLFC then priceParts[#priceParts+1] = "|cff5cd986" .. FormatGold(b.maxLFC) .. "|r tip" end
        SetText(card._price, table.concat(priceParts, "  ·  "))
        SetText(card._recent, (b.lastActivity > 0) and FormatRel(b.lastActivity) or "quiet")

        -- Heat-map: fade between heatLow and heatHigh based on activity
        local heat = math.min(1, totalN / 6)
        local bgR = C.heatLow[1] + (C.heatHigh[1] - C.heatLow[1]) * heat
        local bgG = C.heatLow[2] + (C.heatHigh[2] - C.heatLow[2]) * heat
        local bgB = C.heatLow[3] + (C.heatHigh[3] - C.heatLow[3]) * heat
        card._heatBG = { bgR, bgG, bgB }
        card:SetBG(card._heatBG, 1)
        if (b.lfwCount + b.lfcCount) > 0 then
            card:SetBorderColor(C.accent, 0.8)
            card._icon:SetDesaturated(false)
        else
            card:SetBorderColor(C.border, 1)
            card._icon:SetDesaturated(true)
        end
    end
end


-- ============================================================
-- SECTION: Browse tab
-- Toolbar + split Offers / Requests columns + inspector card.
-- (browseUI is declared near the top of the file so Overview-section
-- closures can capture it — see the hoisted `local browseUI = {}`.)
-- ============================================================

local function RebuildBrowseFilter()
    local e = Engine()
    if not e then browseState.filteredLFW = {}; browseState.filteredLFC = {}; return end
    local all = e.GetAllListings()
    local s = browseState
    local search = (s.search or ""):lower()

    local function matches(L)
        if s.category ~= "All" and L.category ~= s.category then return false end
        if s.profession ~= "All" and (L.profession or "Unknown") ~= s.profession then return false end
        if search ~= "" then
            local hay = ((L.message or "") .. " " .. (L.senderShort or "") .. " " .. (L.priceText or "")):lower()
            if not string.find(hay, search, 1, true) then return false end
        end
        return true
    end

    local lfw, lfc, other = {}, {}, 0
    for i = 1, #all do
        local L = all[i]
        if matches(L) then
            if L.category == "LFW" then
                lfw[#lfw + 1] = L
            elseif L.category == "LFC" then
                lfc[#lfc + 1] = L
            else
                other = other + 1
            end
        end
    end

    -- Filter trace — reports what the Browse filter saw vs produced,
    -- so a mismatch between Overview counts and Browse columns can be
    -- diagnosed from /muiconsole Stream tab. Gated on Market.verboseDebug
    -- since it fires on every filter change in normal UI use.
    local engineSettings = GetSettings()
    if _G.MidnightUI_Debug and engineSettings and engineSettings.verboseDebug then
        _G.MidnightUI_Debug("Market-UI",
            "FILTER", "total=" .. #all,
            "lfw=" .. #lfw, "lfc=" .. #lfc, "other=" .. other,
            "cat=" .. tostring(s.category), "prof=" .. tostring(s.profession),
            "search='" .. tostring(s.search) .. "'")
    end

    local function sorter(a, b)
        local mode = s.sort
        if mode == "reposted" then return (a.count or 1) > (b.count or 1) end
        if mode == "priceLow" then return (a.priceGold or math.huge) < (b.priceGold or math.huge) end
        if mode == "priceHigh" then return (a.priceGold or -1) > (b.priceGold or -1) end
        if mode == "oldest" then return (a.lastSeen or 0) < (b.lastSeen or 0) end
        return (a.lastSeen or 0) > (b.lastSeen or 0)
    end
    table.sort(lfw, sorter); table.sort(lfc, sorter)
    s.filteredLFW = lfw; s.filteredLFC = lfc
end

local function UpdateColumn(col)
    local list = col._isLFW and browseState.filteredLFW or browseState.filteredLFC
    local offset = col._scroll._offset or 0
    if offset < 0 then offset = 0 end
    local maxOff = math.max(0, #list - LAYOUT.poolSize)
    if offset > maxOff then offset = maxOff end
    col._scroll._offset = offset

    SetText(col._countLabel, #list .. (col._isLFW and " offers" or " requests"))

    if #list == 0 then
        col._empty:Show()
    else
        col._empty:Hide()
    end

    for i = 1, LAYOUT.poolSize do
        local row = col._pool[i]
        local L = list[offset + i]
        if L then
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT",  col._scroll, "TOPLEFT",  2, -((i - 1) * LAYOUT.rowStride))
            row:SetPoint("TOPRIGHT", col._scroll, "TOPRIGHT", -2, -((i - 1) * LAYOUT.rowStride))
            ApplyRow(row, L)
        else
            row:Hide()
            row._key = nil
        end
    end
end

local function BuildBrowseColumn(parent, isLFW)
    local col = MakeCard(parent, C.panel, C.border, 1, 1)
    -- Clip child rows that would extend past the column's bottom
    -- edge. The row pool is fixed at LAYOUT.poolSize but the number
    -- of rows that visually fit depends on the column height, which
    -- varies with the window size. Without clipping, pool rows
    -- beyond the visible area render below the column.
    if col.SetClipsChildren then col:SetClipsChildren(true) end

    local titleColor = isLFW and C.lfw or C.lfc
    local title = NewFS(col, "OVERLAY", 14, titleColor, "OUTLINE")
    title:SetPoint("TOPLEFT", col, "TOPLEFT", 14, -10)
    title:SetText(isLFW and "OFFERS — CRAFTERS LFW" or "REQUESTS — PLAYERS LFC")

    local countLabel = NewFS(col, "OVERLAY", 11, C.muted)
    countLabel:SetPoint("TOPRIGHT", col, "TOPRIGHT", -14, -12)
    countLabel:SetText("0")
    col._countLabel = countLabel

    -- Accent under title
    local underline = SolidTex(col, "ARTWORK", titleColor, 1)
    underline:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4)
    underline:SetWidth(44); underline:SetHeight(2)

    -- Scroll frame
    local scroll = CreateFrame("ScrollFrame", nil, col, "FauxScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", col, "TOPLEFT", 6, -38)
    scroll:SetPoint("BOTTOMRIGHT", col, "BOTTOMRIGHT", -26, 8)
    scroll._offset = 0
    col._scroll = scroll

    -- Empty state
    local empty = CreateFrame("Frame", nil, col)
    empty:SetPoint("CENTER", scroll, "CENTER", 0, 0)
    empty:SetSize(260, 80)
    local eIcon = empty:CreateTexture(nil, "ARTWORK")
    eIcon:SetSize(42, 42)
    eIcon:SetPoint("TOP", empty, "TOP", 0, 0)
    eIcon:SetTexture(isLFW and ICON_PROF.Blacksmithing or "Interface\\Icons\\INV_Misc_QuestionMark")
    eIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    eIcon:SetDesaturated(true)
    eIcon:SetAlpha(0.4)
    local eText = NewFS(empty, "OVERLAY", 11, C.muted)
    eText:SetPoint("TOP", eIcon, "BOTTOM", 0, -8)
    eText:SetPoint("LEFT", empty, "LEFT")
    eText:SetPoint("RIGHT", empty, "RIGHT")
    eText:SetJustifyH("CENTER")
    eText:SetText(isLFW and "No crafters advertising yet." or "No open craft requests yet.")
    col._empty = empty

    local pool = {}
    for i = 1, LAYOUT.poolSize do
        -- Parent rows to the column (card), NOT the scroll frame.
        -- Children of a ScrollFrame get clipped/hidden since the
        -- scroll frame expects a single SetScrollChild. FauxScrollFrame
        -- still exhibits that behavior for direct children.
        local row = BuildListingRow(col)
        row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
        row:SetScript("OnClick", function(self, button)
            if button == "RightButton" then
                MODULE._ShowRowMenu(self._listing)
                return
            end
            browseState.selectedKey = self._key
            UpdateColumn(col)
            if browseUI.lfwCol and browseUI.lfwCol ~= col then UpdateColumn(browseUI.lfwCol) end
            if browseUI.lfcCol and browseUI.lfcCol ~= col then UpdateColumn(browseUI.lfcCol) end
        end)
        pool[i] = row
    end
    col._pool = pool
    col._isLFW = isLFW

    scroll:SetScript("OnVerticalScroll", function(self, off)
        FauxScrollFrame_OnVerticalScroll(self, off, LAYOUT.rowStride, function()
            scroll._offset = FauxScrollFrame_GetOffset(scroll) or 0
            UpdateColumn(col)
        end)
    end)
    scroll:SetScript("OnShow", function()
        local list = col._isLFW and browseState.filteredLFW or browseState.filteredLFC
        FauxScrollFrame_Update(scroll, #list, LAYOUT.poolSize, LAYOUT.rowStride)
        UpdateColumn(col)
    end)

    return col
end

local function UpdateBrowseColumns()
    if browseUI.lfwCol then
        FauxScrollFrame_Update(browseUI.lfwCol._scroll, #browseState.filteredLFW, LAYOUT.poolSize, LAYOUT.rowStride)
        UpdateColumn(browseUI.lfwCol)
    end
    if browseUI.lfcCol then
        FauxScrollFrame_Update(browseUI.lfcCol._scroll, #browseState.filteredLFC, LAYOUT.poolSize, LAYOUT.rowStride)
        UpdateColumn(browseUI.lfcCol)
    end
end

-- NOTE: The bottom inspector panel was removed because it duplicated
-- the information already visible in each row. Its job (showing full
-- detail + actions on the selected listing) is now handled by:
--   • A hover GameTooltip anchored to each row, which shows the
--     untruncated message + first/last seen + channel.
--   • A right-click context menu on each row with Whisper / Copy /
--     Pin actions (see BuildRowContextMenu).
-- These give the user the same affordances without eating a third
-- of the tab's vertical space with duplicate content.

local function BuildBrowseToolbar(parent)
    local bar = CreateFrame("Frame", nil, parent)
    bar:SetHeight(36)

    -- Category pills
    local cats = { "All", "LFW", "LFC" }
    local labels = { All = "All", LFW = "Offers", LFC = "Requests" }
    browseUI.catPills = {}
    local lastPill
    for i, c in ipairs(cats) do
        local b = MakeButton(bar, 90, 30, labels[c], { fontSize = 12, fontFlags = "OUTLINE" })
        if i == 1 then
            b:SetPoint("LEFT", bar, "LEFT", 0, 0)
        else
            b:SetPoint("LEFT", lastPill, "RIGHT", 6, 0)
        end
        b._cat = c
        b:SetScript("OnClick", function(self)
            browseState.category = self._cat
            for _, pb in pairs(browseUI.catPills) do
                SetButtonActive(pb, pb._cat == browseState.category)
            end
            RebuildBrowseFilter(); UpdateBrowseColumns()
        end)
        browseUI.catPills[c] = b
        lastPill = b
    end

    -- Profession dropdown — opens a popup under the button.
    -- "All" uses a neutral scroll icon (not the Unknown question mark
    -- fallback). The dropdown arrow is a texture child rather than a
    -- unicode glyph, since Arial Narrow (our UI font) doesn't ship
    -- the Geometric Shapes block.
    local ICON_ALL = "Interface\\Icons\\INV_Scroll_03"

    local function ProfIconFor(name)
        if name == "All" then return ICON_ALL end
        return ICON_PROF[name] or ICON_PROF.Unknown
    end

    local profBtn = MakeButton(bar, 210, 30, "Profession: All", {
        icon = ICON_ALL, fontSize = 12, fontFlags = "OUTLINE",
    })
    profBtn:SetPoint("LEFT", lastPill, "RIGHT", 20, 0)

    -- Dropdown arrow — try a Blizzard atlas first (modern retail),
    -- fall back to a reliable texture file.
    local arrowTex = profBtn:CreateTexture(nil, "OVERLAY")
    arrowTex:SetSize(12, 12)
    arrowTex:SetPoint("RIGHT", profBtn, "RIGHT", -8, 0)
    local gotAtlas = false
    for _, atlasName in ipairs({
        "common-dropdown-icon-down",
        "minimal-dropdownicon-down",
        "ChatFrame-ButtonFrame-DownArrow",
    }) do
        local ok = pcall(arrowTex.SetAtlas, arrowTex, atlasName)
        if ok and arrowTex.GetAtlas and arrowTex:GetAtlas() then
            gotAtlas = true
            break
        end
    end
    if not gotAtlas then
        arrowTex:SetTexture("Interface\\Buttons\\Arrow-Down-Up")
        arrowTex:SetTexCoord(0, 1, 0, 0.5)      -- down-arrow half
    end
    arrowTex:SetVertexColor(C.accent[1], C.accent[2], C.accent[3], 1)

    -- Shrink the label's right edge so the arrow has room.
    profBtn.label:ClearAllPoints()
    profBtn.label:SetPoint("LEFT",  profBtn, "LEFT",  30, 0)
    profBtn.label:SetPoint("RIGHT", profBtn, "RIGHT", -26, 0)
    profBtn.label:SetJustifyH("LEFT")

    profBtn:SetScript("OnClick", function()
        MODULE._ShowProfessionDropdown(profBtn, browseState.profession or "All",
            function(selected)
                browseState.profession = selected
                profBtn.label:SetText("Profession: " .. selected)
                if profBtn._icon then profBtn._icon:SetTexture(ProfIconFor(selected)) end
                RebuildBrowseFilter(); UpdateBrowseColumns()
            end)
    end)
    browseUI._profIconFor = ProfIconFor
    browseUI.profBtn = profBtn

    -- Search editbox
    local searchWrap = MakeCard(bar, C.inset, C.border, 1, 1)
    searchWrap:SetSize(280, 30)
    searchWrap:SetPoint("LEFT", profBtn, "RIGHT", 12, 0)
    local searchIcon = searchWrap:CreateTexture(nil, "ARTWORK")
    searchIcon:SetSize(14, 14)
    searchIcon:SetPoint("LEFT", searchWrap, "LEFT", 8, 0)
    searchIcon:SetTexture("Interface\\Icons\\INV_Misc_Spyglass_02")
    searchIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    searchIcon:SetVertexColor(C.muted[1], C.muted[2], C.muted[3])

    local eb = CreateFrame("EditBox", nil, searchWrap)
    eb:SetPoint("LEFT", searchIcon, "RIGHT", 6, 0)
    eb:SetPoint("RIGHT", searchWrap, "RIGHT", -8, 0)
    eb:SetHeight(26)
    eb:SetFont(PickFont(), 12, "")
    eb:SetAutoFocus(false)
    eb:SetTextColor(1, 1, 1)
    eb:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    eb:SetScript("OnEnterPressed",  function(self) self:ClearFocus() end)
    eb:SetScript("OnTextChanged", function(self)
        browseState.search = self:GetText() or ""
        RebuildBrowseFilter(); UpdateBrowseColumns()
    end)
    browseUI.searchBox = eb

    local placeholder = NewFS(searchWrap, "OVERLAY", 11, C.muted)
    placeholder:SetPoint("LEFT", eb, "LEFT", 0, 0)
    placeholder:SetText("search sender, message, price…")
    eb:HookScript("OnTextChanged", function(self)
        placeholder:SetShown((self:GetText() or "") == "")
    end)

    -- Sort cycle button
    local sortBtn = MakeButton(bar, 150, 30, "Sort: Recent", { fontSize = 12, fontFlags = "OUTLINE" })
    sortBtn:SetPoint("LEFT", searchWrap, "RIGHT", 12, 0)
    local SORT_CYCLE = {
        {k="recent",    l="Recent"},
        {k="reposted",  l="Reposted"},
        {k="priceHigh", l="Price ↓"},
        {k="priceLow",  l="Price ↑"},
        {k="oldest",    l="Oldest"},
    }
    sortBtn:SetScript("OnClick", function()
        local idx = 1
        for i, s in ipairs(SORT_CYCLE) do if s.k == browseState.sort then idx = i; break end end
        idx = idx + 1; if idx > #SORT_CYCLE then idx = 1 end
        browseState.sort = SORT_CYCLE[idx].k
        sortBtn.label:SetText("Sort: " .. SORT_CYCLE[idx].l)
        RebuildBrowseFilter(); UpdateBrowseColumns()
    end)
    browseUI.sortBtn = sortBtn

    return bar
end

local function BuildBrowse(body)
    local pad = LAYOUT.pad
    local toolbar = BuildBrowseToolbar(body)
    toolbar:SetPoint("TOPLEFT", body, "TOPLEFT", pad, -pad)
    toolbar:SetPoint("RIGHT", body, "RIGHT", -pad, 0)

    -- Columns now fill the full tab body, since the bottom inspector
    -- is gone. LFW on the left, LFC on the right, split vertically.
    local lfwCol = BuildBrowseColumn(body, true)
    lfwCol:SetPoint("TOPLEFT", toolbar, "BOTTOMLEFT", 0, -10)
    lfwCol:SetPoint("BOTTOMLEFT", body, "BOTTOMLEFT", pad, pad)
    lfwCol:SetWidth(((LAYOUT.width - LAYOUT.sidebarW - 2*pad - 12) / 2))

    local lfcCol = BuildBrowseColumn(body, false)
    lfcCol:SetPoint("TOPLEFT", lfwCol, "TOPRIGHT", 12, 0)
    lfcCol:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", -pad, pad)

    browseUI.lfwCol = lfwCol
    browseUI.lfcCol = lfcCol
end

local function RefreshBrowse()
    if browseUI.profBtn and browseUI.profBtn.label then
        local current = browseState.profession or "All"
        browseUI.profBtn.label:SetText("Profession: " .. current)
        if browseUI.profBtn._icon and browseUI._profIconFor then
            browseUI.profBtn._icon:SetTexture(browseUI._profIconFor(current))
        end
    end
    if browseUI.catPills then
        for c, b in pairs(browseUI.catPills) do
            SetButtonActive(b, c == browseState.category)
        end
    end
    RebuildBrowseFilter()
    UpdateBrowseColumns()
end


-- ============================================================
-- SECTION: Recruiting tab
-- Split layout: two columns side by side.
--   Left  — Guilds Recruiting (guild advertising for members)
--   Right — Looking for Guild  (player seeking a guild to join)
-- Cyan-teal severity glow per row. No bottom inspector — full-message
-- detail is available via row hover tooltip and right-click menu.
-- ============================================================

local recruitUI = {}
local recruitState = {
    search = "",
    sort   = "recent",   -- recent | reposted | oldest
    selectedKey = nil,
    filteredRecruit = {},
    filteredLFG     = {},
}

local function RebuildRecruitFilter()
    local e = Engine()
    if not e then
        recruitState.filteredRecruit = {}; recruitState.filteredLFG = {}
        return
    end
    local all = e.GetAllListings()
    local search = (recruitState.search or ""):lower()

    local recruit, lfg = {}, {}
    for i = 1, #all do
        local L = all[i]
        if L.category == "GR" then
            local ok = true
            if search ~= "" then
                local hay = ((L.message or "") .. " " .. (L.senderShort or "")
                          .. " " .. (L.guildName or "")):lower()
                if not string.find(hay, search, 1, true) then ok = false end
            end
            if ok then
                if L.grSubtype == "LFG" then
                    lfg[#lfg + 1] = L
                else
                    recruit[#recruit + 1] = L
                end
            end
        end
    end

    local function sorter(a, b)
        local m = recruitState.sort
        if m == "reposted" then return (a.count or 1) > (b.count or 1) end
        if m == "oldest"   then return (a.lastSeen or 0) < (b.lastSeen or 0) end
        return (a.lastSeen or 0) > (b.lastSeen or 0)
    end
    table.sort(recruit, sorter)
    table.sort(lfg, sorter)
    recruitState.filteredRecruit = recruit
    recruitState.filteredLFG     = lfg
end

local function UpdateRecruitColumn(col)
    local list = col._isLFG and recruitState.filteredLFG or recruitState.filteredRecruit
    local offset = col._scroll._offset or 0
    if offset < 0 then offset = 0 end
    local maxOff = math.max(0, #list - LAYOUT.poolSize)
    if offset > maxOff then offset = maxOff end
    col._scroll._offset = offset

    SetText(col._countLabel, #list .. " posts")
    col._empty:SetShown(#list == 0)

    for i = 1, LAYOUT.poolSize do
        local row = col._pool[i]
        local L = list[offset + i]
        if L then
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT",  col._scroll, "TOPLEFT",  2, -((i - 1) * LAYOUT.rowStride))
            row:SetPoint("TOPRIGHT", col._scroll, "TOPRIGHT", -2, -((i - 1) * LAYOUT.rowStride))
            -- Row selection highlight reuses browseState.selectedKey
            -- (ApplyRow reads it) — save/restore around the call so we
            -- don't leak the Recruit selection into Browse state.
            local prevSel = browseState.selectedKey
            browseState.selectedKey = recruitState.selectedKey
            ApplyRow(row, L)
            browseState.selectedKey = prevSel
        else
            row:Hide()
            row._key = nil
        end
    end
end

local function UpdateRecruitColumns()
    if recruitUI.recruitCol then
        FauxScrollFrame_Update(recruitUI.recruitCol._scroll,
            #recruitState.filteredRecruit, LAYOUT.poolSize, LAYOUT.rowStride)
        UpdateRecruitColumn(recruitUI.recruitCol)
    end
    if recruitUI.lfgCol then
        FauxScrollFrame_Update(recruitUI.lfgCol._scroll,
            #recruitState.filteredLFG, LAYOUT.poolSize, LAYOUT.rowStride)
        UpdateRecruitColumn(recruitUI.lfgCol)
    end
end

local function BuildRecruitToolbar(parent)
    local bar = CreateFrame("Frame", nil, parent)
    bar:SetHeight(36)

    -- Search box
    local searchWrap = MakeCard(bar, C.inset, C.border, 1, 1)
    searchWrap:SetSize(340, 30)
    searchWrap:SetPoint("LEFT", bar, "LEFT", 0, 0)
    local searchIcon = searchWrap:CreateTexture(nil, "ARTWORK")
    searchIcon:SetSize(14, 14)
    searchIcon:SetPoint("LEFT", searchWrap, "LEFT", 8, 0)
    searchIcon:SetTexture("Interface\\Icons\\INV_Misc_Spyglass_02")
    searchIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    searchIcon:SetVertexColor(C.muted[1], C.muted[2], C.muted[3])

    local eb = CreateFrame("EditBox", nil, searchWrap)
    eb:SetPoint("LEFT", searchIcon, "RIGHT", 6, 0)
    eb:SetPoint("RIGHT", searchWrap, "RIGHT", -8, 0)
    eb:SetHeight(26)
    eb:SetFont(PickFont(), 12, "")
    eb:SetAutoFocus(false)
    eb:SetTextColor(1, 1, 1)
    eb:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    eb:SetScript("OnEnterPressed",  function(self) self:ClearFocus() end)
    eb:SetScript("OnTextChanged", function(self)
        recruitState.search = self:GetText() or ""
        RebuildRecruitFilter(); UpdateRecruitColumns()
    end)
    local placeholder = NewFS(searchWrap, "OVERLAY", 11, C.muted)
    placeholder:SetPoint("LEFT", eb, "LEFT", 0, 0)
    placeholder:SetText("search guild, sender, message…")
    eb:HookScript("OnTextChanged", function(self)
        placeholder:SetShown((self:GetText() or "") == "")
    end)

    -- Sort cycle
    local sortBtn = MakeButton(bar, 150, 30, "Sort: Recent", { fontSize = 12, fontFlags = "OUTLINE" })
    sortBtn:SetPoint("LEFT", searchWrap, "RIGHT", 12, 0)
    local SORTS = {
        { k = "recent",   l = "Recent" },
        { k = "reposted", l = "Reposted" },
        { k = "oldest",   l = "Oldest" },
    }
    sortBtn:SetScript("OnClick", function()
        local idx = 1
        for i, s in ipairs(SORTS) do if s.k == recruitState.sort then idx = i; break end end
        idx = idx + 1; if idx > #SORTS then idx = 1 end
        recruitState.sort = SORTS[idx].k
        sortBtn.label:SetText("Sort: " .. SORTS[idx].l)
        RebuildRecruitFilter(); UpdateRecruitColumns()
    end)

    return bar
end

local function BuildRecruitColumn(parent, isLFG)
    local col = MakeCard(parent, C.panel, C.border, 1, 1)
    if col.SetClipsChildren then col:SetClipsChildren(true) end

    local title = NewFS(col, "OVERLAY", 14, C.gr, "OUTLINE")
    title:SetPoint("TOPLEFT", col, "TOPLEFT", 14, -10)
    title:SetText(isLFG and "LOOKING FOR GUILD" or "GUILDS RECRUITING")

    local countLabel = NewFS(col, "OVERLAY", 11, C.muted)
    countLabel:SetPoint("TOPRIGHT", col, "TOPRIGHT", -14, -12)
    countLabel:SetText("0 posts")
    col._countLabel = countLabel

    local underline = SolidTex(col, "ARTWORK", C.gr, 1)
    underline:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4)
    underline:SetWidth(44); underline:SetHeight(2)

    local scroll = CreateFrame("ScrollFrame", nil, col, "FauxScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", col, "TOPLEFT", 6, -38)
    scroll:SetPoint("BOTTOMRIGHT", col, "BOTTOMRIGHT", -26, 8)
    scroll._offset = 0
    col._scroll = scroll

    -- Empty state
    local empty = CreateFrame("Frame", nil, col)
    empty:SetPoint("CENTER", scroll, "CENTER")
    empty:SetSize(300, 90)
    local eIcon = empty:CreateTexture(nil, "ARTWORK")
    eIcon:SetSize(42, 42)
    eIcon:SetPoint("TOP", empty, "TOP")
    eIcon:SetTexture(ICON_TAB.recruit)
    eIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    eIcon:SetAlpha(0.4)
    local eText = NewFS(empty, "OVERLAY", 11, C.muted)
    eText:SetPoint("TOP", eIcon, "BOTTOM", 0, -8)
    eText:SetPoint("LEFT", empty, "LEFT"); eText:SetPoint("RIGHT", empty, "RIGHT")
    eText:SetJustifyH("CENTER")
    eText:SetText(isLFG and "No players currently seeking a guild."
                       or "No guilds currently recruiting.")
    col._empty = empty

    local pool = {}
    for i = 1, LAYOUT.poolSize do
        local row = BuildListingRow(col)
        row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
        row:SetScript("OnClick", function(self, button)
            if button == "RightButton" then
                MODULE._ShowRowMenu(self._listing)
                return
            end
            recruitState.selectedKey = self._key
            UpdateRecruitColumns()
        end)
        pool[i] = row
    end
    col._pool = pool
    col._isLFG = isLFG

    scroll:SetScript("OnVerticalScroll", function(self, off)
        FauxScrollFrame_OnVerticalScroll(self, off, LAYOUT.rowStride, function()
            scroll._offset = FauxScrollFrame_GetOffset(scroll) or 0
            UpdateRecruitColumn(col)
        end)
    end)
    scroll:SetScript("OnShow", function()
        local list = col._isLFG and recruitState.filteredLFG or recruitState.filteredRecruit
        FauxScrollFrame_Update(scroll, #list, LAYOUT.poolSize, LAYOUT.rowStride)
        UpdateRecruitColumn(col)
    end)

    return col
end

local function BuildRecruit(body)
    local pad = LAYOUT.pad
    local toolbar = BuildRecruitToolbar(body)
    toolbar:SetPoint("TOPLEFT", body, "TOPLEFT", pad, -pad)
    toolbar:SetPoint("RIGHT", body, "RIGHT", -pad, 0)

    local recruitCol = BuildRecruitColumn(body, false)  -- left: Recruiting
    recruitCol:SetPoint("TOPLEFT", toolbar, "BOTTOMLEFT", 0, -10)
    recruitCol:SetPoint("BOTTOMLEFT", body, "BOTTOMLEFT", pad, pad)
    recruitCol:SetWidth(((LAYOUT.width - LAYOUT.sidebarW - 2*pad - 12) / 2))

    local lfgCol = BuildRecruitColumn(body, true)       -- right: LFG
    lfgCol:SetPoint("TOPLEFT", recruitCol, "TOPRIGHT", 12, 0)
    lfgCol:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", -pad, pad)

    recruitUI.recruitCol = recruitCol
    recruitUI.lfgCol = lfgCol
end

local function RefreshRecruit()
    RebuildRecruitFilter()
    UpdateRecruitColumns()
end


-- ============================================================
-- SECTION: Watchlist tab
-- ============================================================

local watchUI = {}

local function ListingMatchesWatch(L, s)
    if not s or not s.watchlist then return false end
    if L.profession and s.watchlist.professions[L.profession] then return true end
    if L.sender and s.watchlist.senders[L.sender] then return true end
    if L.itemLinks then
        for i = 1, #L.itemLinks do
            if s.watchlist.items[L.itemLinks[i]] then return true end
        end
    end
    return false
end

local function RebuildWatchFilter()
    local e = Engine()
    if not e then watchlistState.filtered = {}; return end
    local s = GetSettings()
    if not s then watchlistState.filtered = {}; return end
    local all = e.GetAllListings()
    local out = {}
    for i = 1, #all do
        if ListingMatchesWatch(all[i], s) then out[#out + 1] = all[i] end
    end
    table.sort(out, function(a, b) return (a.lastSeen or 0) > (b.lastSeen or 0) end)
    watchlistState.filtered = out
end

local function BuildWatchRail(parent)
    local rail = MakeCard(parent, C.panel, C.border, 1, 1)

    local hdr = NewFS(rail, "OVERLAY", 11, C.accent)
    hdr:SetPoint("TOPLEFT", rail, "TOPLEFT", 14, -12)
    hdr:SetText("PINNED")

    local line = SolidTex(rail, "OVERLAY", C.border, 1)
    line:SetPoint("TOPLEFT", hdr, "BOTTOMLEFT", 0, -4)
    line:SetPoint("RIGHT", rail, "RIGHT", -14, 0)
    line:SetHeight(1)

    local inner = CreateFrame("Frame", nil, rail)
    inner:SetPoint("TOPLEFT", line, "BOTTOMLEFT", 0, -8)
    inner:SetPoint("BOTTOMRIGHT", rail, "BOTTOMRIGHT", -6, 8)

    local emptyIcon = inner:CreateTexture(nil, "ARTWORK")
    emptyIcon:SetSize(36, 36)
    emptyIcon:SetPoint("TOP", inner, "TOP", 0, -20)
    emptyIcon:SetTexture("Interface\\Icons\\INV_Misc_Bell_01")
    emptyIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    emptyIcon:SetAlpha(0.4)

    local empty = NewFS(inner, "OVERLAY", 11, C.muted)
    empty:SetPoint("TOP", emptyIcon, "BOTTOM", 0, -8)
    empty:SetPoint("LEFT", inner, "LEFT", 10, 0)
    empty:SetPoint("RIGHT", inner, "RIGHT", -10, 0)
    empty:SetJustifyH("CENTER")
    empty:SetText("Nothing pinned yet.\n\nClick |cffffd34aPin|r on any\nBrowse listing.")

    watchUI.rail = rail
    watchUI.inner = inner
    watchUI.empty = empty
    watchUI.emptyIcon = emptyIcon
    watchUI.rowPool = {}
    return rail
end

local function AcquireWatchPinRow(parent, idx, kind)
    local pool = watchUI.rowPool
    local row = pool[idx]
    if row then return row end

    row = MakeCard(parent, C.inset, C.border, 1, 0.8)
    row:SetSize(180, 24)

    local icon = row:CreateTexture(nil, "ARTWORK")
    icon:SetSize(16, 16)
    icon:SetPoint("LEFT", row, "LEFT", 6, 0)
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    row._icon = icon

    local fs = NewFS(row, "OVERLAY", 11, C.text)
    fs:SetPoint("LEFT", icon, "RIGHT", 6, 0)
    fs:SetWidth(120)
    fs:SetJustifyH("LEFT")
    row._fs = fs

    local x = CreateFrame("Button", nil, row)
    x:SetSize(16, 16)
    x:SetPoint("RIGHT", row, "RIGHT", -4, 0)
    local xFS = NewFS(x, "OVERLAY", 13, C.muted, "OUTLINE")
    xFS:SetPoint("CENTER")
    xFS:SetText("×")
    x:SetScript("OnEnter", function() xFS:SetTextColor(C.danger[1], C.danger[2], C.danger[3]) end)
    x:SetScript("OnLeave", function() xFS:SetTextColor(C.muted[1], C.muted[2], C.muted[3]) end)
    row._x = x

    pool[idx] = row
    return row
end

local function RefreshWatchRail()
    if not watchUI.inner then return end
    local s = GetSettings()
    for i = 1, #watchUI.rowPool do watchUI.rowPool[i]:Hide() end

    local entries = {}
    if s and s.watchlist then
        for n in pairs(s.watchlist.professions or {}) do entries[#entries + 1] = { k = "Profession", n = n, key = "professions" } end
        for n in pairs(s.watchlist.senders or {})     do entries[#entries + 1] = { k = "Sender",     n = n, key = "senders" } end
        for n in pairs(s.watchlist.items or {})       do entries[#entries + 1] = { k = "Item",       n = n, key = "items" } end
    end

    if #entries == 0 then
        watchUI.empty:Show(); watchUI.emptyIcon:Show(); return
    end
    watchUI.empty:Hide(); watchUI.emptyIcon:Hide()

    local yOff = 0
    for i, e in ipairs(entries) do
        local row = AcquireWatchPinRow(watchUI.inner, i, e.k)
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", watchUI.inner, "TOPLEFT", 8, -yOff)
        row:SetPoint("RIGHT", watchUI.inner, "RIGHT", -8, 0)
        if e.k == "Profession" then
            row._icon:SetTexture(ICON_PROF[e.n] or ICON_PROF.Unknown)
            row._fs:SetTextColor(C.accent[1], C.accent[2], C.accent[3])
        elseif e.k == "Sender" then
            row._icon:SetTexture("Interface\\Icons\\INV_Misc_GroupLooking")
            row._fs:SetTextColor(C.text[1], C.text[2], C.text[3])
        else
            row._icon:SetTexture("Interface\\Icons\\INV_Misc_Bag_10")
            row._fs:SetTextColor(C.price[1], C.price[2], C.price[3])
        end
        row._fs:SetText(Truncate(tostring(e.n), 22))
        row._x:SetScript("OnClick", function()
            s.watchlist[e.key][e.n] = nil
            RefreshWatchRail()
            if watchUI.RefreshList then watchUI.RefreshList() end
        end)
        row:Show()
        yOff = yOff + 28
    end
end

local function BuildWatchList(parent)
    local col = MakeCard(parent, C.panel, C.border, 1, 1)
    if col.SetClipsChildren then col:SetClipsChildren(true) end

    local title = NewFS(col, "OVERLAY", 14, C.text, "OUTLINE")
    title:SetPoint("TOPLEFT", col, "TOPLEFT", 14, -10)
    title:SetText("MATCHING LISTINGS")

    local countLabel = NewFS(col, "OVERLAY", 11, C.muted)
    countLabel:SetPoint("TOPRIGHT", col, "TOPRIGHT", -14, -12)
    countLabel:SetText("0 listings")
    col._countLabel = countLabel

    local underline = SolidTex(col, "ARTWORK", C.accent, 1)
    underline:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4)
    underline:SetWidth(44); underline:SetHeight(2)

    local scroll = CreateFrame("ScrollFrame", nil, col, "FauxScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", col, "TOPLEFT", 6, -38)
    scroll:SetPoint("BOTTOMRIGHT", col, "BOTTOMRIGHT", -26, 8)
    scroll._offset = 0
    col._scroll = scroll

    local empty = CreateFrame("Frame", nil, col)
    empty:SetPoint("CENTER", scroll, "CENTER")
    empty:SetSize(300, 80)
    local eText = NewFS(empty, "OVERLAY", 12, C.muted)
    eText:SetAllPoints(empty)
    eText:SetJustifyH("CENTER")
    eText:SetText("No listings match your pins right now.")
    col._empty = empty

    local pool = {}
    for i = 1, LAYOUT.poolSize do
        pool[i] = BuildListingRow(col)      -- parent to col, not scroll
    end
    col._pool = pool

    local function Update()
        local list = watchlistState.filtered
        local offset = scroll._offset or 0
        if offset < 0 then offset = 0 end
        local maxOff = math.max(0, #list - LAYOUT.poolSize)
        if offset > maxOff then offset = maxOff end
        scroll._offset = offset

        SetText(countLabel, #list .. " listings")
        empty:SetShown(#list == 0)

        for i = 1, LAYOUT.poolSize do
            local row = pool[i]
            local L = list[offset + i]
            if L then
                row:ClearAllPoints()
                row:SetPoint("TOPLEFT",  scroll, "TOPLEFT",  2, -((i - 1) * LAYOUT.rowStride))
                row:SetPoint("TOPRIGHT", scroll, "TOPRIGHT", -2, -((i - 1) * LAYOUT.rowStride))
                ApplyRow(row, L)
            else
                row:Hide()
            end
        end
    end

    scroll:SetScript("OnVerticalScroll", function(self, off)
        FauxScrollFrame_OnVerticalScroll(self, off, LAYOUT.rowStride, function()
            scroll._offset = FauxScrollFrame_GetOffset(scroll) or 0
            Update()
        end)
    end)
    scroll:SetScript("OnShow", function()
        FauxScrollFrame_Update(scroll, #watchlistState.filtered, LAYOUT.poolSize, LAYOUT.rowStride)
        Update()
    end)

    function watchUI.RefreshList()
        RebuildWatchFilter()
        FauxScrollFrame_Update(scroll, #watchlistState.filtered, LAYOUT.poolSize, LAYOUT.rowStride)
        Update()
    end
    return col
end

local function BuildWatchlist(body)
    local pad = LAYOUT.pad
    local rail = BuildWatchRail(body)
    rail:SetPoint("TOPLEFT", body, "TOPLEFT", pad, -pad)
    rail:SetPoint("BOTTOMLEFT", body, "BOTTOMLEFT", pad, pad)
    rail:SetWidth(240)

    local list = BuildWatchList(body)
    list:SetPoint("TOPLEFT", rail, "TOPRIGHT", 12, 0)
    list:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", -pad, pad)
end

local function RefreshWatchlist()
    RefreshWatchRail()
    if watchUI.RefreshList then watchUI.RefreshList() end
end


-- ============================================================
-- SECTION: Profession dropdown — anchored popup under a button,
-- one entry per profession with its icon. Selecting closes the
-- popup and fires onSelect(name).
-- ============================================================

local profMenu

local function BuildProfMenu()
    local m = CreateFrame("Frame", "MidnightUI_Market_ProfMenu", UIParent)
    m:SetFrameStrata("FULLSCREEN_DIALOG")
    m:SetFrameLevel(720)
    m:SetToplevel(true)
    m:EnableMouse(true)
    StyleCard(m, C.card, C.accent, 1, 1)
    m:Hide()
    m._buttons = {}

    local catcher = CreateFrame("Button", nil, UIParent)
    catcher:SetAllPoints(UIParent)
    catcher:SetFrameStrata("FULLSCREEN_DIALOG")
    catcher:SetFrameLevel(710)
    catcher:EnableMouse(true)
    catcher:RegisterForClicks("AnyUp")
    catcher:SetScript("OnClick", function() m:Hide() end)
    catcher:Hide()

    m:SetScript("OnShow", function() catcher:Show() end)
    m:SetScript("OnHide", function() catcher:Hide() end)
    if type(_G.UISpecialFrames) == "table" then
        table.insert(_G.UISpecialFrames, "MidnightUI_Market_ProfMenu")
    end
    return m
end

local PROF_DROPDOWN_ORDER = {
    "All", "Alchemy", "Blacksmithing", "Enchanting", "Engineering",
    "Inscription", "Jewelcrafting", "Leatherworking", "Tailoring",
    "Herbalism", "Mining", "Skinning", "Cooking", "Unknown",
}

function MODULE._ShowProfessionDropdown(anchor, currentSelection, onSelect)
    if not profMenu then profMenu = BuildProfMenu() end

    for _, b in ipairs(profMenu._buttons) do b:Hide(); b:ClearAllPoints() end
    profMenu._buttons = {}

    local padding = 6
    local btnW    = 210
    local btnH    = 24
    local gap     = 2
    local y       = -padding

    for i, name in ipairs(PROF_DROPDOWN_ORDER) do
        local active = (name == currentSelection)
        local btn = CreateFrame("Button", nil, profMenu)
        btn:SetSize(btnW, btnH)
        btn:SetPoint("TOPLEFT", profMenu, "TOPLEFT", padding, y)
        StyleCard(btn, active and C.accentDim or C.inset,
                      active and C.accent    or C.border, 1, 0.9)

        if name ~= "All" then
            local ic = btn:CreateTexture(nil, "ARTWORK")
            ic:SetSize(btnH - 6, btnH - 6)
            ic:SetPoint("LEFT", btn, "LEFT", 4, 0)
            ic:SetTexture(ICON_PROF[name] or ICON_PROF.Unknown)
            ic:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        end

        local lab = NewFS(btn, "OVERLAY", 12,
            active and C.accentGlow or C.text,
            active and "OUTLINE" or "")
        lab:SetPoint("LEFT", btn, "LEFT", (name == "All") and 10 or (btnH + 2), 0)
        lab:SetText(name)

        if not active then
            btn:SetScript("OnEnter", function(self)
                self:SetBG(C.cardHi, 1)
                self:SetBorderColor(C.borderHi, 1)
            end)
            btn:SetScript("OnLeave", function(self)
                self:SetBG(C.inset, 1)
                self:SetBorderColor(C.border, 0.9)
            end)
        end
        btn:SetScript("OnClick", function()
            profMenu:Hide()
            if onSelect then onSelect(name) end
        end)

        profMenu._buttons[i] = btn
        y = y - (btnH + gap)
    end

    profMenu:SetSize(btnW + padding * 2, -y + padding)
    profMenu:ClearAllPoints()
    profMenu:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -4)
    profMenu:Show()
    profMenu:Raise()
end


-- ============================================================
-- SECTION: Row context menu (right-click on any listing)
-- Self-contained popup — avoids Blizzard's EasyMenu /
-- UIDropDownMenu pipeline which varies across retail versions.
-- Clicking a button fires its action and closes the menu; clicking
-- anywhere outside the menu also closes it via a full-screen
-- click catcher frame sandwiched between the menu and the rest
-- of the UI.
-- ============================================================

local rowMenu

local function BuildRowMenu()
    local m = CreateFrame("Frame", "MidnightUI_Market_RowMenu", UIParent)
    m:SetFrameStrata("FULLSCREEN_DIALOG")
    m:SetFrameLevel(720)
    m:SetToplevel(true)
    m:EnableMouse(true)
    StyleCard(m, C.card, C.accent, 1, 1)
    m:Hide()
    m._buttons = {}

    -- Click-outside catcher: sits just below the menu, above Market.
    local catcher = CreateFrame("Button", nil, UIParent)
    catcher:SetAllPoints(UIParent)
    catcher:SetFrameStrata("FULLSCREEN_DIALOG")
    catcher:SetFrameLevel(710)
    catcher:EnableMouse(true)
    catcher:RegisterForClicks("AnyUp")
    catcher:SetScript("OnClick", function() m:Hide() end)
    catcher:Hide()
    m._catcher = catcher

    m:SetScript("OnShow", function() catcher:Show() end)
    m:SetScript("OnHide", function() catcher:Hide() end)

    -- Title label (sender name), populated per invocation.
    local title = NewFS(m, "OVERLAY", 12, C.accent, "OUTLINE")
    title:SetPoint("TOPLEFT", m, "TOPLEFT", 10, -8)
    title:SetPoint("TOPRIGHT", m, "TOPRIGHT", -10, -8)
    title:SetJustifyH("LEFT")
    m._title = title

    if type(_G.UISpecialFrames) == "table" then
        table.insert(_G.UISpecialFrames, "MidnightUI_Market_RowMenu")
    end
    return m
end

function MODULE._ShowRowMenu(L)
    if not L then return end
    if not rowMenu then rowMenu = BuildRowMenu() end

    -- Tear down any previous buttons so reopening shows fresh actions.
    for _, b in ipairs(rowMenu._buttons) do b:Hide(); b:ClearAllPoints() end
    rowMenu._buttons = {}

    local senderShort = L.senderShort or L.sender or "?"
    SetText(rowMenu._title, senderShort)

    -- Action list, built in display order.
    local actions = {}
    if L.sender then
        actions[#actions + 1] = {
            label = "Whisper " .. senderShort,
            color = C.accentGlow,
            func  = function()
                if _G.ChatFrame_SendTell then _G.ChatFrame_SendTell(L.sender) end
            end,
        }
    end
    if L.message then
        actions[#actions + 1] = {
            label = "Copy message",
            func  = function() MODULE._ShowCopyDialog(L.message) end,
        }
    end
    if L.sender then
        actions[#actions + 1] = {
            label = "Pin sender",
            func  = function()
                local s = GetSettings(); if not s then return end
                s.watchlist = s.watchlist or { professions = {}, items = {}, senders = {} }
                s.watchlist.senders[L.sender] = true
                local r = MODULE._refreshers.watchlist; if r then pcall(r) end
            end,
        }
    end
    if L.profession and L.profession ~= "Unknown" and L.profession ~= "Guild" then
        actions[#actions + 1] = {
            label = "Pin profession (" .. L.profession .. ")",
            func  = function()
                local s = GetSettings(); if not s then return end
                s.watchlist = s.watchlist or { professions = {}, items = {}, senders = {} }
                s.watchlist.professions[L.profession] = true
                local r = MODULE._refreshers.watchlist; if r then pcall(r) end
            end,
        }
    end
    if L.itemLinks and L.itemLinks[1] then
        actions[#actions + 1] = {
            label = "Pin item",
            func  = function()
                local s = GetSettings(); if not s then return end
                s.watchlist = s.watchlist or { professions = {}, items = {}, senders = {} }
                s.watchlist.items[L.itemLinks[1]] = true
                local r = MODULE._refreshers.watchlist; if r then pcall(r) end
            end,
        }
    end

    local padding  = 6
    local btnW     = 210
    local btnH     = 24
    local gap      = 2
    local titleH   = 22

    local y = -titleH
    for i, a in ipairs(actions) do
        local btn = MakeButton(rowMenu, btnW, btnH, a.label, {
            fontSize = 12,
            textColor = a.color or C.text,
        })
        btn:SetPoint("TOPLEFT", rowMenu, "TOPLEFT", padding, y)
        btn:SetScript("OnClick", function()
            pcall(a.func)
            rowMenu:Hide()
        end)
        rowMenu._buttons[i] = btn
        y = y - (btnH + gap)
    end

    rowMenu:SetSize(btnW + padding * 2, -y + padding)

    -- Anchor TOPLEFT at cursor, accounting for UI scale.
    local cx, cy = _G.GetCursorPosition()
    local scale  = _G.UIParent and _G.UIParent:GetEffectiveScale() or 1
    rowMenu:ClearAllPoints()
    rowMenu:SetPoint("TOPLEFT", _G.UIParent, "BOTTOMLEFT", cx / scale, cy / scale)
    rowMenu:Show()
    rowMenu:Raise()
end


-- ============================================================
-- SECTION: Copy dialog (self-contained)
-- ============================================================

local copyDialog

function MODULE._ShowCopyDialog(text)
    if not copyDialog then
        copyDialog = CreateFrame("Frame", "MidnightUI_Market_CopyDialog", UIParent)
        copyDialog:SetSize(560, 220)
        copyDialog:SetPoint("CENTER", UIParent, "CENTER", 0, 100)
        copyDialog:SetFrameStrata("FULLSCREEN_DIALOG")
        copyDialog:SetFrameLevel(600)
        copyDialog:SetToplevel(true)
        copyDialog:EnableMouse(true)
        copyDialog:SetMovable(true)
        copyDialog:RegisterForDrag("LeftButton")
        copyDialog:SetScript("OnDragStart", copyDialog.StartMoving)
        copyDialog:SetScript("OnDragStop",  copyDialog.StopMovingOrSizing)

        local card = MakeCard(copyDialog, C.panel, C.accent, 1, 1)
        card:SetAllPoints()

        local title = NewFS(copyDialog, "OVERLAY", 14, C.accent, "OUTLINE")
        title:SetPoint("TOPLEFT", copyDialog, "TOPLEFT", 14, -12)
        title:SetText("COPY MESSAGE")

        local hint = NewFS(copyDialog, "OVERLAY", 10, C.muted)
        hint:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -2)
        hint:SetText("Ctrl+C to copy · ESC to close")

        local box = MakeCard(copyDialog, C.inset, C.border, 1, 1)
        box:SetPoint("TOPLEFT", copyDialog, "TOPLEFT", 14, -48)
        box:SetPoint("BOTTOMRIGHT", copyDialog, "BOTTOMRIGHT", -14, 48)

        local scroll = CreateFrame("ScrollFrame", nil, box, "UIPanelScrollFrameTemplate")
        scroll:SetPoint("TOPLEFT", box, "TOPLEFT", 8, -6)
        scroll:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", -26, 6)

        local eb = CreateFrame("EditBox", nil, scroll)
        eb:SetMultiLine(true)
        eb:SetAutoFocus(true)
        eb:SetFont(PickFont(), 13, "")
        eb:SetWidth(500)
        eb:SetHeight(200)
        eb:SetTextColor(1, 1, 1)
        eb:SetScript("OnEscapePressed", function() copyDialog:Hide() end)
        scroll:SetScrollChild(eb)
        copyDialog._eb = eb

        local closeBtn = MakeButton(copyDialog, 80, 26, "Close", {
            bg = C.accentDim, border = C.accent, fontFlags = "OUTLINE"
        })
        closeBtn:SetPoint("BOTTOMRIGHT", copyDialog, "BOTTOMRIGHT", -14, 12)
        closeBtn:SetScript("OnClick", function() copyDialog:Hide() end)

        if type(_G.UISpecialFrames) == "table" then
            table.insert(_G.UISpecialFrames, "MidnightUI_Market_CopyDialog")
        end
    end
    copyDialog._eb:SetText(tostring(text or ""))
    copyDialog._eb:HighlightText()
    copyDialog:Show()
    copyDialog:Raise()
end


-- ============================================================
-- SECTION: Ticker
-- ============================================================

local function TickerStep()
    if not frame or not frame:IsShown() then return end
    RefreshHeader()
    local r = MODULE._refreshers[activeTabId]
    if r then pcall(r) end
end

local function EnsureTicker()
    if uiTicker then return end
    if _G.C_Timer and _G.C_Timer.NewTicker then
        uiTicker = _G.C_Timer.NewTicker(1.0, TickerStep)
    end
end


-- ============================================================
-- SECTION: Window build
-- ============================================================

local function BuildWindow()
    if frame then return end

    frame = CreateFrame("Frame", "MidnightUI_Market_Frame", UIParent)
    frame:SetSize(LAYOUT.width, LAYOUT.height)
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    frame:SetFrameStrata("FULLSCREEN_DIALOG")
    frame:SetFrameLevel(500)
    frame:SetToplevel(true)
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:SetClampedToScreen(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", frame.StartMoving)
    frame:SetScript("OnDragStop",  frame.StopMovingOrSizing)

    -- Layered background: outer border + inner fill
    local outerBorder = SolidTex(frame, "BACKGROUND", C.accent, 1)
    outerBorder:SetPoint("TOPLEFT", frame, "TOPLEFT", -1, 1)
    outerBorder:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 1, -1)
    outerBorder:SetAlpha(0.25)

    local innerBG = SolidTex(frame, "BACKGROUND", C.bg, 1)
    innerBG:SetAllPoints(frame)

    -- NOTE: Market deliberately is NOT in UISpecialFrames. The
    -- OnKeyDown handler below gives ESC a priority: first close any
    -- link popup (ItemRefTooltip) or other visible UISpecialFrame,
    -- only close Market itself once nothing else is on screen.
    frame:EnableKeyboard(true)
    frame:SetPropagateKeyboardInput(true)
    frame:SetScript("OnKeyDown", function(self, key)
        if key ~= "ESCAPE" then
            self:SetPropagateKeyboardInput(true)
            return
        end

        -- 1. ItemRefTooltip isn't always in UISpecialFrames — close
        -- it manually if it's the thing the player wants dismissed.
        if _G.ItemRefTooltip and _G.ItemRefTooltip:IsShown() then
            _G.ItemRefTooltip:Hide()
            self:SetPropagateKeyboardInput(false)
            return
        end

        -- 2. Any OTHER UISpecialFrame visible? Let WoW's normal ESC
        -- handler close it. Propagate the key so CloseSpecialWindows
        -- runs via the TOGGLEGAMEMENU binding.
        if _G.UISpecialFrames then
            for i = 1, #_G.UISpecialFrames do
                local fname = _G.UISpecialFrames[i]
                if fname and fname ~= "MidnightUI_Market_Frame" then
                    local fr = _G[fname]
                    if fr and fr.IsShown and fr:IsShown() then
                        self:SetPropagateKeyboardInput(true)
                        return
                    end
                end
            end
        end

        -- 3. Nothing else open — close Market and consume the key so
        -- WoW doesn't also open the game menu.
        self:Hide()
        self:SetPropagateKeyboardInput(false)
    end)

    BuildHeader(frame)
    BuildSidebar(frame)

    local overviewBody = BuildTabBody(frame, "overview")
    BuildOverview(overviewBody)

    local browseBody = BuildTabBody(frame, "browse")
    BuildBrowse(browseBody)

    local recruitBody = BuildTabBody(frame, "recruit")
    BuildRecruit(recruitBody)

    local watchBody = BuildTabBody(frame, "watchlist")
    BuildWatchlist(watchBody)

    MODULE._refreshers.overview  = RefreshOverview
    MODULE._refreshers.browse    = RefreshBrowse
    MODULE._refreshers.recruit   = RefreshRecruit
    MODULE._refreshers.watchlist = RefreshWatchlist

    MODULE.SetActiveTab(activeTabId)
    frame:Hide()
end


-- ============================================================
-- SECTION: Public API
-- ============================================================

local function DoOpen()
    BuildWindow()
    if frame then frame:Show(); frame:Raise() end
    EnsureTicker()
    RefreshHeader()
    local r = MODULE._refreshers[activeTabId]
    if r then pcall(r) end
end

function MODULE.Open()
    if _G.InCombatLockdown and _G.InCombatLockdown() then
        deferredOpen = true
        return
    end
    DoOpen()
end

function MODULE.Close()
    if frame then frame:Hide() end
end

function MODULE.Toggle()
    if frame and frame:IsShown() then MODULE.Close() else MODULE.Open() end
end

function MODULE.IsShown()
    return frame and frame:IsShown() and true or false
end

function MODULE.SetFilter(prof, category)
    if prof then browseState.profession = prof end
    if category then browseState.category = category end
    if frame and frame:IsShown() then
        MODULE.SetActiveTab("browse")
        RefreshBrowse()
    end
end


-- ============================================================
-- SECTION: Combat deferral
-- ============================================================

local combatFrame = CreateFrame("Frame", "MidnightUI_Market_UI_Combat")
combatFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
combatFrame:SetScript("OnEvent", function()
    if deferredOpen then deferredOpen = false; DoOpen() end
end)


-- ============================================================
-- SECTION: Slash commands
-- ============================================================

local function Say(msg)
    local cf = DEFAULT_CHAT_FRAME
    if cf and cf.AddMessage then cf:AddMessage("|cffffd34aMarket|r: " .. tostring(msg)) end
end

local function SlashHandler(arg)
    arg = arg or ""
    local cmd, rest = arg:match("^(%S*)%s*(.*)$")
    cmd = (cmd or ""):lower(); rest = rest or ""

    if cmd == "" or cmd == "toggle" then MODULE.Toggle()
    elseif cmd == "open"  then MODULE.Open()
    elseif cmd == "close" then MODULE.Close()
    elseif cmd == "clear" then
        if Engine() then Engine().Clear() end
        Say("listings cleared")
    elseif cmd == "pause" then
        if Engine() then Engine().Pause() end; Say("paused")
    elseif cmd == "resume" then
        if Engine() then Engine().Resume() end; Say("resumed")
    elseif cmd == "status" then
        local e = Engine()
        if not e then Say("engine not loaded"); return end
        local agg = e.GetAggregates()
        local t = agg.totals
        Say(string.format("%d listings · %d offers · %d requests · %d new/min · %s",
            t.listings, t.lfw, t.lfc, t.newLastMinute,
            e.IsPaused() and "paused" or "active"))
    elseif cmd == "test" then
        if Engine() then
            local sample = rest ~= "" and rest or
                "LFW max quality Blacksmithing, JC, LW — accepting orders, flat 50k per craft. Tip optional."
            Engine().InjectTestMessage(sample, "TestCrafter-Test", "Trade - Test")
            Say("test message injected")
        end
    elseif cmd == "diag" then
        -- One-shot comprehensive diagnostic dump to /muiconsole.
        local e = Engine()
        if not e then Say("engine not loaded"); return end
        local D = _G.MidnightUI_Debug or function() end
        local all = e.GetAllListings()
        local agg = e.GetAggregates()
        local t = agg.totals
        local paused = e.IsPaused() and "yes" or "no"

        D("Market-DIAG", "=========================================")
        D("Market-DIAG", string.format(
            "aggregates: total=%d LFW=%d LFC=%d GR=%d paused=%s",
            t.listings, t.lfw, t.lfc, t.gr or 0, paused))
        D("Market-DIAG", string.format(
            "browseState: cat=%s prof=%s search='%s' sort=%s",
            tostring(browseState.category),
            tostring(browseState.profession),
            tostring(browseState.search),
            tostring(browseState.sort)))
        D("Market-DIAG", string.format(
            "filtered: LFW=%d LFC=%d",
            #(browseState.filteredLFW or {}),
            #(browseState.filteredLFC or {})))

        -- Dump each listing
        for i = 1, #all do
            local L = all[i]
            D("Market-DIAG",
              string.format("[%d] cat=%s prof=%s sender=%s count=%d key='%s' msg='%s'",
                i,
                tostring(L.category),
                tostring(L.profession),
                tostring(L.senderShort),
                L.count or 1,
                tostring(L.key):sub(1, 40),
                tostring(L.message):sub(1, 80)))
        end
        D("Market-DIAG", "=========================================")
        Say(string.format("dumped %d listings to /muiconsole (Stream tab)", #all))
    elseif cmd == "help" then
        Say("commands: open | close | toggle | clear | pause | resume | status | test [msg] | diag")
    else
        Say("unknown command: " .. cmd .. " (try /muim help)")
    end
end

SlashCmdList["MIDNIGHTUI_MARKET"] = function(arg)
    local ok, err = pcall(SlashHandler, arg)
    if not ok then Say("ERROR: " .. tostring(err)) end
end
SLASH_MIDNIGHTUI_MARKET1 = "/muimarket"
SLASH_MIDNIGHTUI_MARKET2 = "/muim"


-- ============================================================
-- SECTION: Engine change listener (load-time)
-- Allows auto-open on first listing even before the window
-- has ever been built, and live-refreshes the UI while open.
-- ============================================================

local function OnEngineChange()
    if frame and frame:IsShown() then
        RefreshHeader()
        local r = MODULE._refreshers[activeTabId]
        if r then pcall(r) end
    else
        local s = GetSettings()
        if s and s.autoOpen then MODULE.Open() end
    end
end

local engineRegistered = false
local reg = CreateFrame("Frame")
reg:RegisterEvent("ADDON_LOADED")
reg:SetScript("OnEvent", function(self, _, name)
    if engineRegistered then return end
    if name ~= ADDON_NAME then return end
    if Engine() and Engine().RegisterListener then
        Engine().RegisterListener(OnEngineChange)
        engineRegistered = true
    end
end)
