-- ============================================================
-- FILE: Market.lua
-- MODULE: MidnightUI_Market (global)
--
-- PURPOSE:
--   Silent background monitor of the Trade and Services channels.
--   Captures meaningful crafting-related chatter only — crafters
--   advertising work (LFW) and players seeking a craft (LFC) —
--   and stores each as a live "listing" in an in-memory ledger.
--   Reposts from the same sender with the same message collapse
--   into one listing with a repost count and updating lastSeen.
--   Non-crafting chatter (boosts, LFG, guild recruiting, gold
--   trade, etc.) is rejected via three classifier gates before
--   anything is logged. The UI (Market_UI.lua) renders from this
--   module's public API and registers a change listener so the
--   display updates live.
--
-- HOW IT WORKS:
--   1. ADDON_LOADED → InitSettings() ensures MidnightUISettings.Market
--      exists with full defaults, wipes the legacy keyword-watchlist
--      system once, and starts the 30-second expiry ticker.
--   2. CHAT_MSG_CHANNEL fires OnChatMsg → early-exit guards → three
--      classifier gates:
--        Gate 1 — blacklist (raid boosts, LFG, gold trade, etc.)
--        Gate 2 — crafting evidence (profession name, craft verb,
--                 recipe term, or item hyperlink)
--        Gate 3 — LFW / LFC intent scoring with strong/weak tokens.
--   3. On pass: extract profession, price, item links. Build a
--      grouping key from sender + normalized-message prefix. New
--      key → create listing. Existing key → bump count + lastSeen.
--   4. Expiry ticker drops listings whose lastSeen is older than
--      MidnightUISettings.Market.expireMinutes.
--   5. Every mutation pokes registered listener callbacks so the
--      UI can refresh without polling.
--
-- DEPENDS ON:
--   _G.MidnightUI_Debug (Bootstrap.lua) — verbose debug logs
--   Blizzard API: CHAT_MSG_CHANNEL, UnitName, GetPlayerInfoByGUID,
--                 C_Timer.NewTicker, InCombatLockdown, time, date
--
-- EXPORTS (_G.MidnightUI_Market):
--   GetAllListings()      — recency-sorted array of listing records
--   GetAggregates()       — { totals, byProfession, topReposted }
--   Clear()               — wipe active listings
--   Pause() / Resume() / IsPaused()
--   RegisterListener(fn)  — fn() called on any change
--   GetSettings()         — live settings table reference
--   InjectTestMessage(msg, author, channelName) — for /muim test
--   GetDefaults()         — canonical defaults (used by UI reset)
--   CLASS_CACHE           — live {playerName = classToken} cache
--   GATE1_DEFAULT         — default Gate 1 blacklist (for UI editor)
--
-- OWNED SAVEDVARIABLES:
--   MidnightUISettings.Market — full settings subtree (see GetDefaults).
--   MidnightUISettings.__marketWiped_v2 — legacy-wipe guard flag.
--
-- EVENTS REGISTERED:
--   ADDON_LOADED       — init on MidnightUI load
--   CHAT_MSG_CHANNEL   — main ingest
--
-- FRAMES CREATED:
--   Local event listener frame (not exported).
--
-- GOTCHAS:
--   • Listings are memory-only; a /reload clears them. Settings and
--     watchlist persist.
--   • Gate 1 matches plain substrings on the LOWERED message, so
--     entries like "wtb gold" reject without false positives, but
--     an entry "aotc" will reject any message containing "aotc"
--     including quoted ironic use. Accepted — AOTC boost spam is
--     volumetric enough that we'd rather overreject than leak.
--   • Word-boundary patterns for professions use %f[%w]..%f[%W];
--     requires Lua 5.1+ frontier patterns (WoW has these).
--   • Message normalization strips color codes and |K..|k protected
--     links so classification sees the actual text, but raw msg is
--     kept intact for display / item-link extraction.
--   • Sender class is cached from CHAT_MSG_* GUID lookups; cache
--     clears on reload. Listings captured before the sender posted
--     elsewhere will show no class color until next seen.
-- ============================================================

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


-- ============================================================
-- SECTION: Defaults
-- ============================================================

local GATE1_DEFAULT = {
    -- Raid/M+ services
    "boost", "boosting", "boosted", "carry", "carries", "gdkp",
    "aotc", " ksm", " kse", "vault carry", "pilot", "piloted",
    "heroic raid", "mythic raid", "cutting edge", "ce run",
    -- Group finder
    "lfg", "lf m+", "lf mm+", "lf tank", "lf heal", "lf dps",
    "lf 1", "lf 2", "lf 3", "lf 4", "need tank", "need heal",
    "need dps", "forming group", "forming m+", "forming raid",
    "forming mm+", "forming key",
    -- Gold trade
    "wts gold", "wtb gold", "selling gold", "buying gold",
    -- Misc noise
    "wtb tokens", "wts token",
    -- NOTE: guild-recruiting terms are NOT here. They are routed into
    -- the GR (Guild Recruiting) category by a pre-gate classifier.
}

-- Guild Recruiting detection — runs before Gate 1. Split into two
-- subtypes so the UI can show separate columns for each:
--   LFG         — a player looking for a guild to join
--   Recruiting  — a guild advertising for new members
local GR_LFG_SIGNALS = {
    "lf guild", "looking for guild", "lfguild",
    "lfg guild", "lfg a guild", "need a guild",
    "need guild",
}

local GR_RECRUIT_SIGNALS = {
    "guild recruiting", "now recruiting", "recruiting for",
    "we are recruiting", "is recruiting",
    "casually recruiting", "actively recruiting",
    "semi-hardcore recruit",
    "heroic prog guild", "mythic prog guild", "aotc guild",
    "open recruitment", "recruiting casual", "recruiting mythic",
    "recruiting heroic",
    "social guild", "raid team", "raiding team",
}

local GR_CONTEXT = {
    -- Only distinctive multi-char words/phrases. Short abbreviations
    -- like "mon", "wed", "thu" are deliberately omitted — they
    -- substring-match inside crafting words ("diamond", "summon",
    -- "wednesday" reads fine but "thunder" does not), causing LFW/LFC
    -- messages with an unrelated <tag> to be misclassified as GR.
    "recruit", "recruiting", "guild ",
    "raid nights", "raid night",
    "progression", "mythic+", "heroic prog", "mythic prog",
    "aotc", "raiding team", "raid team",
    "weeknights", "weeknight",
}

local DEFAULTS_TEMPLATE = function()
    local professions = {
        Alchemy = true, Blacksmithing = true, Enchanting = true,
        Engineering = true, Inscription = true, Jewelcrafting = true,
        Leatherworking = true, Tailoring = true, Herbalism = true,
        Mining = true, Skinning = true, Cooking = true, Unknown = true,
    }
    local gate1 = {}
    for i = 1, #GATE1_DEFAULT do gate1[i] = GATE1_DEFAULT[i] end
    return {
        enabled        = true,
        autoOpen       = false,
        pauseInCombat  = false,
        strictness     = "Strict",   -- "Strict" | "Balanced" | "Loose"
        categories     = { LFW = true, LFC = true, GR = true },
        professions    = professions,
        expireMinutes  = 30,
        minLength      = 20,
        keyLength      = 60,
        customBlacklist = gate1,     -- copy of GATE1_DEFAULT; user-editable
        watchlist      = { professions = {}, items = {}, senders = {} },
        verboseDebug   = false,
    }
end

MODULE.GATE1_DEFAULT = GATE1_DEFAULT
function MODULE.GetDefaults() return DEFAULTS_TEMPLATE() end


-- ============================================================
-- SECTION: Profession aliases (alias -> canonical)
-- Word-boundary-matched with %f[%w]alias%f[%W] to avoid false
-- positives (e.g. "lw" inside "glowing").
-- ============================================================

local PROFESSIONS = {
    alchemy = "Alchemy", alch = "Alchemy", ["alchemist"] = "Alchemy",
    blacksmithing = "Blacksmithing", ["blacksmith"] = "Blacksmithing",
    bs = "Blacksmithing", smith = "Blacksmithing",
    enchanting = "Enchanting", enchant = "Enchanting", ench = "Enchanting",
    enchanter = "Enchanting",
    engineering = "Engineering", eng = "Engineering", engineer = "Engineering",
    inscription = "Inscription", scribe = "Inscription", inscriber = "Inscription",
    jewelcrafting = "Jewelcrafting", jc = "Jewelcrafting",
    jewel = "Jewelcrafting", jeweler = "Jewelcrafting", jewler = "Jewelcrafting",
    leatherworking = "Leatherworking", lw = "Leatherworking",
    leather = "Leatherworking", leatherworker = "Leatherworking",
    tailoring = "Tailoring", tailor = "Tailoring",
    herbalism = "Herbalism", herb = "Herbalism", herbalist = "Herbalism",
    mining = "Mining", miner = "Mining",
    skinning = "Skinning", skinner = "Skinning",
    cooking = "Cooking", cook = "Cooking", chef = "Cooking",
}


-- ============================================================
-- SECTION: Gate 2 — crafting evidence
-- ============================================================

local CRAFT_VERBS = {
    "craft", "crafts", "crafting", "crafter", "crafters", "crafted",
    "recraft", "recrafts", "recrafted", "recraftable",
    "commission", "commissioned", "commissioning",
}

local CRAFT_MECHANIC = {
    "concentration", "embellishment", "embellish", "embellished",
    "inspiration", "r5", "r3", "max quality", "quality 5", "5/5",
    "tier 3 craft",
}

local RECIPE_TERMS = {
    "recipe", "pattern", "schematic", "design", "formula", "plans",
}


-- ============================================================
-- SECTION: Gate 3 — intent indicators (score 3 strong, 1 weak)
-- ============================================================

local LFW_STRONG = {
    "lfw", "looking for work", "taking orders", "accepting orders",
    "open for commission", "open for commissions", "open for craft",
    "open for crafts", "crafting services", "will craft", "can craft",
    "available to craft", "for hire", "crafter for hire",
    "max quality", "flat rate", "tip optional",
}
local LFW_WEAK = {
    "crafter here", "whisper for", "msg for", "you bring mats",
    "free craft", "free crafts", "no tip needed",
}

local LFC_STRONG = {
    "lfc", "lf crafter", "lf a crafter", "need crafter",
    "need a crafter", "wtc", "want to craft", "want crafted",
    "looking for crafter", "looking for a crafter",
    "paying", "will tip", "tip provided", "tips paid",
    "tips provided", "who can craft", "anyone craft",
    "anyone crafting", "can anyone craft", "need crafted",
}
local LFC_WEAK = {
    "i have mats", "bring mats", "have mats", "mats ready",
}

local LFC_PROF_PHRASES = {
    "lf ", "need ", "need a ", "need an ", "looking for ",
    "looking for a ", "looking for an ", "anyone ", "any ",
    "who can ", "wtc ",
}
local LFW_PROF_PHRASES = {
    "offering ", "my ", "selling ", "doing ",
}


-- ============================================================
-- SECTION: Helpers
-- ============================================================

-- Verbose debug — gated on settings.verboseDebug. Used for noisy
-- per-message decisions (drops, classification scoring).
local function Dbg(...)
    if _G.MidnightUI_Debug then _G.MidnightUI_Debug("Market", ...) end
end

-- Pipeline trace — listing kept/bumped, expiry summary, init.
-- Gated on settings.verboseDebug so normal users don't fill the
-- Diagnostics Console with routine market chatter.
local function Trace(...)
    if not _G.MidnightUI_Debug then return end
    local s = MODULE.GetSettings()
    if not (s and s.verboseDebug) then return end
    _G.MidnightUI_Debug("Market", ...)
end

local function SelfName()
    local n = UnitName and UnitName("player") or nil
    return n
end

local function StripRealm(author)
    if not author then return "" end
    local dash = string.find(author, "-", 1, true)
    if dash then return string.sub(author, 1, dash - 1) end
    return author
end

local function NormalizeMessage(msg)
    if not msg or msg == "" then return "" end
    local s = msg
    s = s:gsub("|c%x%x%x%x%x%x%x%x", "")
    s = s:gsub("|r", "")
    s = s:gsub("|K.-|k", " ")
    s = s:gsub("|H.-|h(.-)|h", "%1")
    s = s:gsub("|T.-|t", " ")
    s = s:lower()
    s = s:gsub("%s+", " ")
    s = s:gsub("^%s+", ""):gsub("%s+$", "")
    return s
end

local function FirstChars(s, n)
    if #s <= n then return s end
    return string.sub(s, 1, n)
end

local function ContainsAny(norm, list)
    for i = 1, #list do
        if string.find(norm, list[i], 1, true) then return list[i] end
    end
    return nil
end

local function ContainsProfession(norm)
    for alias, canon in pairs(PROFESSIONS) do
        if string.find(norm, "%f[%w]" .. alias .. "%f[%W]") then
            return canon, alias
        end
    end
    return nil, nil
end

local function HasItemLink(msg)
    return msg and string.find(msg, "|Hitem:", 1, true) ~= nil
end

-- ExtractGuildName: returns a best-effort guild name from the raw
-- message. Handles two common patterns:
--   1. Angle brackets:  <Chaos Knights>
--   2. Labeled form:    Guild: Chaos Knights
-- Returns nil if neither pattern yields something plausible.
local function ExtractGuildName(raw)
    if not raw then return nil end

    -- 1. Angle brackets (most common).
    for name in string.gmatch(raw, "<([^<>]+)>") do
        local trimmed = name:gsub("^%s+", ""):gsub("%s+$", "")
        if #trimmed >= 2 and #trimmed <= 28 then return trimmed end
    end

    -- 2. "Guild: Name" pattern — case-insensitive. Captures until the
    -- next hard separator (newline, `,`, `.`, `!`, `?`, `|`), then
    -- trims a trailing " - ..." suffix (common for "Guild: X - prog"
    -- style postings).
    local lower = raw:lower()
    local _, endIdx = lower:find("guild:", 1, true)
    if endIdx then
        local rest = raw:sub(endIdx + 1):gsub("^[%s:]+", "")
        local name = rest:match("^([^\r\n,%.|!%?]+)")
        if name then
            name = name:gsub("%s+%-.*$", "")          -- strip " - tail"
            name = name:gsub("^%s+", ""):gsub("%s+$", "")
            if #name >= 2 and #name <= 30 then return name end
        end
    end

    return nil
end

-- HasGuildRecruitSignal: returns (isGR, subtype).
--   subtype is "LFG" when the poster is looking for a guild, or
--   "Recruiting" when a guild is advertising for members.
-- Runs before Gate 1, so GR messages skip the blacklist.
local function HasGuildRecruitSignal(norm, raw)
    -- LFG first — these phrases are unambiguous.
    for i = 1, #GR_LFG_SIGNALS do
        if string.find(norm, GR_LFG_SIGNALS[i], 1, true) then
            return true, "LFG"
        end
    end

    -- Explicit "guild recruiting / now recruiting / ..." phrases.
    for i = 1, #GR_RECRUIT_SIGNALS do
        if string.find(norm, GR_RECRUIT_SIGNALS[i], 1, true) then
            return true, "Recruiting"
        end
    end

    -- Angle-bracket guild tag + any recruitment context word — this
    -- path is always Recruiting since LFG posts don't tag a guild.
    if raw and string.find(raw, "<[^<>]+>") then
        for i = 1, #GR_CONTEXT do
            if string.find(norm, GR_CONTEXT[i], 1, true) then
                return true, "Recruiting"
            end
        end
    end

    return false, nil
end

local function ExtractItemLinks(msg)
    local out = {}
    if not msg then return out end
    for link in string.gmatch(msg, "(|c%x+|Hitem:[^|]+|h%[[^%]]+%]|h|r)") do
        out[#out + 1] = link
    end
    if #out == 0 then
        for link in string.gmatch(msg, "(|Hitem:[^|]+|h%[[^%]]+%]|h)") do
            out[#out + 1] = link
        end
    end
    return out
end


-- ============================================================
-- SECTION: Item → Profession inference
-- Fallback used when a message has no explicit profession keyword
-- but does include an item hyperlink. GetItemInfoInstant is
-- synchronous (no cache required) and returns classID/subclassID
-- which we map to a profession below.
-- ============================================================

-- Enum.ItemArmorSubclass values (retail).
local ARMOR_TO_PROF = {
    [1] = "Tailoring",         -- Cloth
    [2] = "Leatherworking",    -- Leather
    [3] = "Leatherworking",    -- Mail (modern retail LW covers mail)
    [4] = "Blacksmithing",     -- Plate
    [6] = "Blacksmithing",     -- Shield
}

-- Subclass 0 on armor means "Miscellaneous" — it's a catch-all for
-- rings, necklaces, cloaks, shirts, trinkets, etc. The equip location
-- is what actually identifies which profession makes it.
local ARMOR_MISC_EQUIP_TO_PROF = {
    ["INVTYPE_FINGER"] = "Jewelcrafting",   -- Rings
    ["INVTYPE_NECK"]   = "Jewelcrafting",   -- Necklaces
    ["INVTYPE_CLOAK"]  = "Tailoring",       -- Cloaks (retail name)
    ["INVTYPE_BACK"]   = "Tailoring",       -- Cloaks (legacy name)
    ["INVTYPE_BODY"]   = "Tailoring",       -- Shirts
    ["INVTYPE_TABARD"] = "Tailoring",       -- Tabards (rare, historical)
    -- INVTYPE_TRINKET is deliberately omitted — trinkets vary across
    -- professions (Engineering, JC, Alchemy toys, etc.) with no
    -- reliable mapping.
}

-- Enum.ItemWeaponSubclass — most weapons are Blacksmithing-crafted.
-- Ranged and arcane-focus weapons go to other professions.
local WEAPON_TO_PROF = {
    [0]  = "Blacksmithing",    -- Axe 1H
    [1]  = "Blacksmithing",    -- Axe 2H
    [3]  = "Engineering",      -- Gun
    [4]  = "Blacksmithing",    -- Mace 1H
    [5]  = "Blacksmithing",    -- Mace 2H
    [6]  = "Blacksmithing",    -- Polearm
    [7]  = "Blacksmithing",    -- Sword 1H
    [8]  = "Blacksmithing",    -- Sword 2H
    [10] = "Inscription",      -- Staff (Inscription makes modern staves)
    [13] = "Blacksmithing",    -- Fist
    [15] = "Blacksmithing",    -- Dagger
    [16] = "Engineering",      -- Thrown (legacy)
    [18] = "Engineering",      -- Crossbow
    [19] = "Inscription",      -- Wand
    -- Bow (2) and Warglaive (9) are deliberately omitted — not
    -- reliably craftable by a single profession.
}

-- Enum.ItemConsumableSubclass — crafted consumables.
local CONSUMABLE_TO_PROF = {
    [1] = "Alchemy",           -- Potion
    [2] = "Alchemy",           -- Elixir
    [3] = "Alchemy",           -- Flask (some clients fold this in)
    [5] = "Cooking",           -- Food & Drink
}

-- Enum.ItemRecipeSubclass — a recipe's subclass names the profession
-- that learns from it.
local RECIPE_TO_PROF = {
    [1]  = "Leatherworking",
    [2]  = "Tailoring",
    [3]  = "Engineering",
    [4]  = "Blacksmithing",
    [5]  = "Cooking",
    [6]  = "Alchemy",
    [8]  = "Enchanting",
    [10] = "Jewelcrafting",
    [11] = "Inscription",
}

local function IdentifyProfessionFromItemLink(itemLink)
    if not itemLink or not _G.GetItemInfoInstant then return nil end
    local itemID = tonumber(itemLink:match("item:(%d+)"))
    if not itemID then return nil end
    local ok, _, _, _, itemEquipLoc, _, classID, subclassID =
        pcall(_G.GetItemInfoInstant, itemID)
    if not ok or not classID then return nil end

    -- Enum.ItemClass values (retail):
    --   0 = Consumable, 1 = Container, 2 = Weapon, 3 = Gem, 4 = Armor,
    --   7 = Tradegoods, 9 = Recipe, 16 = Glyph
    if classID == 3  then return "Jewelcrafting" end
    if classID == 16 then return "Inscription"   end
    if classID == 9  then return RECIPE_TO_PROF[subclassID] end
    if classID == 1  then return "Tailoring"     end   -- bags default to Tailoring
    if classID == 0  then return CONSUMABLE_TO_PROF[subclassID] end

    if classID == 4 then
        -- Miscellaneous-subclass armor (rings, necks, cloaks, shirts,
        -- trinkets) — dispatch via equip location. Other subclasses
        -- are identified by armor material.
        if subclassID == 0 then
            return ARMOR_MISC_EQUIP_TO_PROF[itemEquipLoc or ""]
        end
        return ARMOR_TO_PROF[subclassID]
    end

    if classID == 2 then return WEAPON_TO_PROF[subclassID] or "Blacksmithing" end
    return nil
end


-- ============================================================
-- SECTION: Classifier gates
-- ============================================================

local function Gate1_Blacklist(norm, customList)
    local list = customList or GATE1_DEFAULT
    return ContainsAny(norm, list)
end

local function Gate2_CraftEvidence(norm, hasLink, strictness)
    -- Profession name always counts as evidence.
    local prof = ContainsProfession(norm)
    if prof then return "profession" end

    -- Strict: also require a craft verb, recipe term, mechanic, or item link.
    if hasLink then return "item_link" end
    local v = ContainsAny(norm, CRAFT_VERBS); if v then return "craft_verb" end
    local m = ContainsAny(norm, CRAFT_MECHANIC); if m then return "mechanic" end
    local r = ContainsAny(norm, RECIPE_TERMS); if r then return "recipe" end

    if strictness == "Balanced" then
        -- Balanced is already covered by profession fallthrough; no change.
        return nil
    end
    return nil
end

local function ScoreWithList(norm, strong, weak)
    local score = 0
    for i = 1, #strong do
        if string.find(norm, strong[i], 1, true) then score = score + 3 end
    end
    for i = 1, #weak do
        if string.find(norm, weak[i], 1, true) then score = score + 1 end
    end
    return score
end

local function ScoreProfPhrases(norm, phrases, professionAlias)
    if not professionAlias then return 0 end
    local combo = 0
    for i = 1, #phrases do
        local p = phrases[i] .. professionAlias
        if string.find(norm, p, 1, true) then combo = combo + 3 end
    end
    return combo
end

local function Gate3_Intent(norm, strictness, professionAlias)
    local lfw = ScoreWithList(norm, LFW_STRONG, LFW_WEAK)
              + ScoreProfPhrases(norm, LFW_PROF_PHRASES, professionAlias)
    local lfc = ScoreWithList(norm, LFC_STRONG, LFC_WEAK)
              + ScoreProfPhrases(norm, LFC_PROF_PHRASES, professionAlias)

    -- Price-word tiebreakers
    if string.find(norm, "tip", 1, true) or string.find(norm, "paying", 1, true) then
        lfc = lfc + 1
    end
    if string.find(norm, "flat ", 1, true) or string.find(norm, "my rate", 1, true)
       or string.find(norm, "charge ", 1, true) or string.find(norm, "price:", 1, true) then
        lfw = lfw + 1
    end

    local marginReq = (strictness == "Loose") and 1 or 2
    local minReq = 3

    if lfw >= minReq and (lfw - lfc) >= marginReq then return "LFW", lfw, lfw - lfc end
    if lfc >= minReq and (lfc - lfw) >= marginReq then return "LFC", lfc, lfc - lfw end
    return nil, math.max(lfw, lfc), 0
end


-- ============================================================
-- SECTION: Price extraction
-- Scan for numbers with a price-context word within ~20 chars.
-- Returns (priceGold:number|nil, priceText:string|nil).
-- Supports k/m/g suffixes, comma/period separators, and ranges.
-- ============================================================

local PRICE_CONTEXT = {
    "price", "cost", "charge", "pay", "paying", "tip", "tipping",
    " gold", " g ", " g.", "flat", "rate", "asking", "offer",
}

local function ParseNumber(n, suffix)
    if not n then return nil end
    local s = n:gsub(",", "")
    local v = tonumber(s)
    if not v then return nil end
    if suffix == "k" then v = v * 1000
    elseif suffix == "m" then v = v * 1000000
    end
    return v
end

local function ExtractPrice(norm)
    if not norm or norm == "" then return nil, nil end

    -- First: ranges like 50-60k or 100-200k.
    for lo, hi, suf in string.gmatch(norm, "(%d[%d%.,]*)%s*[%-–]%s*(%d[%d%.,]*)%s*([kmg]?)") do
        local v1 = ParseNumber(lo, suf)
        local v2 = ParseNumber(hi, suf)
        if v1 and v2 then
            local disp = lo .. "-" .. hi .. (suf ~= "" and suf or "")
            return v1, disp
        end
    end

    -- Collect all (num, suf, position) candidates.
    local best, bestText, bestScore = nil, nil, -1
    local pattern = "(%d[%d%.,]*)%s*([kmg]?)"
    local pos = 1
    while true do
        local s, e, num, suf = string.find(norm, pattern, pos)
        if not s then break end
        local val = ParseNumber(num, suf)
        if val and val >= 50 then
            local windowLo = math.max(1, s - 20)
            local windowHi = math.min(#norm, e + 20)
            local context = string.sub(norm, windowLo, windowHi)
            local score = 0
            for i = 1, #PRICE_CONTEXT do
                if string.find(context, PRICE_CONTEXT[i], 1, true) then
                    score = score + 2
                end
            end
            if suf == "k" or suf == "m" then score = score + 1 end
            if score > bestScore or (score == bestScore and val > (best or 0)) then
                best = val
                bestText = num .. (suf ~= "" and suf or "")
                bestScore = score
            end
        end
        pos = e + 1
    end

    if bestScore <= 0 then return nil, nil end
    return best, bestText
end


-- ============================================================
-- SECTION: Sender class cache
-- ============================================================

local CLASS_CACHE = {}
MODULE.CLASS_CACHE = CLASS_CACHE

local function RememberClass(guid, senderShort)
    if not guid or not senderShort or senderShort == "" then return end
    if CLASS_CACHE[senderShort] then return end
    if not GetPlayerInfoByGUID then return end
    local ok, _, englishClass = pcall(GetPlayerInfoByGUID, guid)
    if ok and englishClass and englishClass ~= "" then
        CLASS_CACHE[senderShort] = englishClass
    end
end


-- ============================================================
-- SECTION: Listings store
-- activeListings is aliased to MidnightUISettings.Market._listings
-- so mutations flow through to SavedVariables (per-character, since
-- Bootstrap aliases MidnightUISettings to MidnightUISettingsPerChar).
-- Never reassign the local — always mutate in place.
-- ============================================================

local activeListings = {}   -- replaced by per-char table in InitSettings
local paused         = false
local listeners      = {}

local function NotifyChange()
    for i = 1, #listeners do
        local fn = listeners[i]
        if type(fn) == "function" then
            pcall(fn)
        end
    end
end

function MODULE.RegisterListener(fn)
    if type(fn) == "function" then
        listeners[#listeners + 1] = fn
    end
end

function MODULE.IsPaused() return paused end
function MODULE.Pause()   paused = true;  NotifyChange() end
function MODULE.Resume()  paused = false; NotifyChange() end

function MODULE.Clear()
    for k in pairs(activeListings) do activeListings[k] = nil end
    NotifyChange()
end

-- Remove a single listing by its grouping key. Removes the entire
-- entry regardless of its repost count. Used by the per-row close
-- button in the UI.
function MODULE.RemoveListing(key)
    if not key or not activeListings[key] then return end
    activeListings[key] = nil
    NotifyChange()
end

function MODULE.GetSettings()
    if not MidnightUISettings or not MidnightUISettings.Market then return nil end
    return MidnightUISettings.Market
end

function MODULE.GetAllListings()
    local out = {}
    local n = 0
    for _, v in pairs(activeListings) do n = n + 1; out[n] = v end
    table.sort(out, function(a, b) return (a.lastSeen or 0) > (b.lastSeen or 0) end)
    return out
end

function MODULE.GetAggregates()
    local totals = { listings = 0, lfw = 0, lfc = 0, gr = 0, newLastMinute = 0, avgLFWPrice = 0 }
    local byProfession = {}
    local now = time and time() or 0
    local priceSum, priceN = 0, 0
    local reposted = {}

    for _, L in pairs(activeListings) do
        totals.listings = totals.listings + 1
        if L.category == "LFW" then totals.lfw = totals.lfw + 1 end
        if L.category == "LFC" then totals.lfc = totals.lfc + 1 end
        if L.category == "GR"  then totals.gr  = totals.gr  + 1 end
        if (now - (L.firstSeen or 0)) <= 60 then
            totals.newLastMinute = totals.newLastMinute + 1
        end

        local p = L.profession or "Unknown"
        local b = byProfession[p]
        if not b then
            b = { lfwCount = 0, lfcCount = 0, minLFW = nil, maxLFC = nil, lastActivity = 0 }
            byProfession[p] = b
        end
        if L.category == "LFW" then
            b.lfwCount = b.lfwCount + 1
            if L.priceGold and (b.minLFW == nil or L.priceGold < b.minLFW) then
                b.minLFW = L.priceGold
            end
            if L.priceGold then
                priceSum = priceSum + L.priceGold; priceN = priceN + 1
            end
        elseif L.category == "LFC" then
            b.lfcCount = b.lfcCount + 1
            if L.priceGold and (b.maxLFC == nil or L.priceGold > b.maxLFC) then
                b.maxLFC = L.priceGold
            end
        end
        if (L.lastSeen or 0) > b.lastActivity then
            b.lastActivity = L.lastSeen or 0
        end

        if (L.count or 1) > 1 then
            reposted[#reposted + 1] = L
        end
    end

    if priceN > 0 then totals.avgLFWPrice = math.floor(priceSum / priceN) end
    table.sort(reposted, function(a, b) return (a.count or 1) > (b.count or 1) end)
    local top = {}
    for i = 1, math.min(5, #reposted) do top[i] = reposted[i] end

    return { totals = totals, byProfession = byProfession, topReposted = top }
end


-- ============================================================
-- SECTION: Core ingest pipeline
-- ============================================================

local function Ingest(msg, author, channelName, guid)
    local settings = MODULE.GetSettings()
    if not settings or settings.enabled == false then return end
    if paused then return end
    if not msg or msg == "" then return end
    if not author or author == "" then return end

    local self = SelfName()
    if self and string.find(author, self, 1, true) then return end

    local chan = channelName and string.lower(channelName) or ""
    local isTrade = string.find(chan, "trade", 1, true) ~= nil
    local isServices = string.find(chan, "services", 1, true) ~= nil
                    or string.find(chan, "service", 1, true) ~= nil
    if not (isTrade or isServices) then return end

    if settings.pauseInCombat and InCombatLockdown and InCombatLockdown() then return end
    if #msg < (settings.minLength or 20) then
        if settings.verboseDebug then Dbg("drop short", msg) end
        return
    end

    local norm = NormalizeMessage(msg)

    -- GR (Guild Recruiting) pre-gate: runs before Gate 1 so GR posts
    -- bypass the raid/LFG/guild-recruit blacklist entries and get
    -- routed into their own category. Subtype splits LFG (player
    -- seeking a guild) from Recruiting (guild advertising).
    local cat, prof, profAlias
    local guildName, grSubtype
    local isGR, gSub = HasGuildRecruitSignal(norm, msg)
    if isGR then
        cat = "GR"
        grSubtype = gSub
        -- Only recruiting posts tag a guild name; LFG posts don't.
        if grSubtype == "Recruiting" then
            guildName = ExtractGuildName(msg)
        end
        prof = "Guild"
        profAlias = nil
    else
        -- Gate 1
        local bl = Gate1_Blacklist(norm, settings.customBlacklist)
        if bl then
            if settings.verboseDebug then Dbg("drop blacklist", bl, "|", msg) end
            return
        end

        -- Gate 2
        local hasLink = HasItemLink(msg)
        local ev = Gate2_CraftEvidence(norm, hasLink, settings.strictness or "Strict")
        if not ev then
            if settings.verboseDebug then Dbg("drop no_craft_evidence", "|", msg) end
            return
        end

        -- Professions — first try to read an explicit profession name
        -- from the message text; fall back to inferring from any item
        -- hyperlink when the text is silent (common for LFC messages
        -- like "LF crafter [item]").
        prof, profAlias = ContainsProfession(norm)
        if not prof and hasLink then
            local links = ExtractItemLinks(msg)
            for i = 1, #links do
                local inferred = IdentifyProfessionFromItemLink(links[i])
                if inferred then
                    prof = inferred
                    break
                end
            end
        end
        prof = prof or "Unknown"

        -- Gate 3
        cat = Gate3_Intent(norm, settings.strictness or "Strict", profAlias)
        if not cat then
            if settings.verboseDebug then Dbg("drop ambiguous_intent", "|", msg) end
            return
        end
    end

    -- Settings filters
    if not settings.categories or settings.categories[cat] == false then
        if settings.verboseDebug then Dbg("drop category_off", cat) end
        return
    end
    -- Profession filter only applies to crafting categories
    if cat ~= "GR" then
        if not settings.professions or settings.professions[prof] == false then
            if settings.verboseDebug then Dbg("drop profession_off", prof) end
            return
        end
    end

    -- Dedup key
    local keyLen = settings.keyLength or 60
    local key = author .. "|" .. FirstChars(norm, keyLen)
    local now = time and time() or 0

    local senderShort = StripRealm(author)
    RememberClass(guid, senderShort)
    RememberClass(guid, author)

    local MAX_SEEN = 30   -- keep up to this many timestamps per listing

    local existing = activeListings[key]
    if existing then
        existing.count = (existing.count or 1) + 1
        existing.lastSeen = now
        -- Append to seenTimes ring (drop oldest if at cap)
        existing.seenTimes = existing.seenTimes or { existing.firstSeen or now }
        existing.seenTimes[#existing.seenTimes + 1] = now
        if #existing.seenTimes > MAX_SEEN then
            table.remove(existing.seenTimes, 1)
        end
        -- Try price again in case new post includes it
        if not existing.priceGold then
            local p, pt = ExtractPrice(norm)
            if p then existing.priceGold = p; existing.priceText = pt end
        end
        if cat == "GR" then
            if not existing.guildName then existing.guildName = guildName end
            if not existing.grSubtype then existing.grSubtype = grSubtype end
        end
        Trace("BUMP", "cat=" .. tostring(cat), "prof=" .. tostring(prof),
              "count=" .. tostring(existing.count), "sender=" .. tostring(senderShort),
              "msg=" .. tostring(msg):sub(1, 60))
    else
        local price, priceText = ExtractPrice(norm)
        local itemLinks = ExtractItemLinks(msg)
        activeListings[key] = {
            key          = key,
            category     = cat,
            grSubtype    = grSubtype,
            profession   = prof,
            guildName    = guildName,
            sender       = author,
            senderShort  = senderShort,
            senderClass  = CLASS_CACHE[senderShort] or CLASS_CACHE[author],
            message      = msg,
            messageNorm  = norm,
            itemLinks    = itemLinks,
            priceGold    = price,
            priceText    = priceText,
            firstSeen    = now,
            lastSeen     = now,
            seenTimes    = { now },
            count        = 1,
            channelName  = channelName or "",
            timestamp    = date and date("%H:%M") or "",
        }
        Trace("KEEP", "cat=" .. tostring(cat), "prof=" .. tostring(prof),
              "price=" .. tostring(price or "nil"),
              "sender=" .. tostring(senderShort),
              "key=" .. tostring(key):sub(1, 40),
              "msg=" .. tostring(msg):sub(1, 60))
    end

    NotifyChange()
end


function MODULE.InjectTestMessage(msg, author, channelName)
    Ingest(msg or "", author or "TestPlayer-Test", channelName or "Trade - Test", nil)
end


-- ============================================================
-- SECTION: Expiry ticker
-- ============================================================

local function ExpireOldListings()
    local settings = MODULE.GetSettings()
    if not settings then return end
    local ttl = (settings.expireMinutes or 30) * 60
    if ttl <= 0 then return end
    local now = time and time() or 0
    local drop = {}
    for k, v in pairs(activeListings) do
        if (now - (v.lastSeen or 0)) > ttl then
            drop[#drop + 1] = k
        end
    end
    if #drop == 0 then return end
    for i = 1, #drop do activeListings[drop[i]] = nil end
    NotifyChange()
end


-- ============================================================
-- SECTION: Settings init + legacy wipe
-- ============================================================

local function DeepMergeDefaults(dst, src)
    for k, v in pairs(src) do
        if type(v) == "table" then
            if type(dst[k]) ~= "table" then dst[k] = {} end
            DeepMergeDefaults(dst[k], v)
        else
            if dst[k] == nil then dst[k] = v end
        end
    end
end

local function InitSettings()
    MidnightUISettings = MidnightUISettings or {}
    if type(MidnightUISettings.Market) ~= "table" then
        MidnightUISettings.Market = DEFAULTS_TEMPLATE()
    else
        DeepMergeDefaults(MidnightUISettings.Market, DEFAULTS_TEMPLATE())
    end

    -- Alias activeListings to the per-character saved table so new
    -- listings persist across /reload and logout. Any in-memory data
    -- that was captured before this alias is preserved by copying it
    -- into the saved table (should normally be empty at this point).
    if type(MidnightUISettings.Market._listings) ~= "table" then
        MidnightUISettings.Market._listings = {}
    end
    for k, v in pairs(activeListings) do
        MidnightUISettings.Market._listings[k] = v
    end
    activeListings = MidnightUISettings.Market._listings

    -- Count what we loaded, grouped by category, BEFORE expiry
    local preLFW, preLFC, preGR, preTotal = 0, 0, 0, 0
    for _, v in pairs(activeListings) do
        preTotal = preTotal + 1
        if v.category == "LFW" then preLFW = preLFW + 1
        elseif v.category == "LFC" then preLFC = preLFC + 1
        elseif v.category == "GR" then preGR = preGR + 1 end
    end
    Trace("INIT", "loaded=" .. preTotal,
          "LFW=" .. preLFW, "LFC=" .. preLFC, "GR=" .. preGR)

    -- Immediate expiry sweep so stale listings from the previous
    -- session don't linger past the user's configured TTL.
    local ttl = (MidnightUISettings.Market.expireMinutes or 30) * 60
    local now = time and time() or 0
    local expired = 0
    if ttl > 0 and now > 0 then
        local drop = {}
        for k, v in pairs(activeListings) do
            if (now - (v.lastSeen or 0)) > ttl then drop[#drop + 1] = k end
        end
        for i = 1, #drop do activeListings[drop[i]] = nil end
        expired = #drop
    end
    Trace("EXPIRY", "ttl=" .. ttl, "expired=" .. expired,
          "remaining=" .. (preTotal - expired))

    -- Backfill: retroactively infer profession for already-captured
    -- listings that have item links but were stored as "Unknown"
    -- (either from before item-class inference existed, or because
    -- their message text didn't name a profession). Runs once per
    -- load and only touches entries that need it.
    local inferred = 0
    for _, v in pairs(activeListings) do
        if (v.profession == "Unknown" or v.profession == nil)
           and v.itemLinks and v.itemLinks[1] then
            for i = 1, #v.itemLinks do
                local p = IdentifyProfessionFromItemLink(v.itemLinks[i])
                if p then
                    v.profession = p
                    inferred = inferred + 1
                    break
                end
            end
        end
    end
    if inferred > 0 then Trace("BACKFILL", "inferred=" .. inferred) end

    -- Backfill: GR listings saved before grSubtype existed get
    -- re-classified from their stored message; also retry guild-name
    -- extraction for recruiting posts that missed it (e.g. because
    -- "Guild: Name" wasn't yet a recognized pattern).
    for _, v in pairs(activeListings) do
        if v.category == "GR" then
            if not v.grSubtype then
                local _, sub = HasGuildRecruitSignal(v.messageNorm or "", v.message or "")
                v.grSubtype = sub or "Recruiting"
            end
            if v.grSubtype == "Recruiting" and not v.guildName then
                v.guildName = ExtractGuildName(v.message or "")
            end
        end
    end

    -- Seed seenTimes for listings saved before that field existed.
    -- Uses whatever we know: firstSeen and lastSeen if distinct,
    -- otherwise just lastSeen. Better to start with something so
    -- the count-badge tooltip has at least the known endpoints.
    for _, v in pairs(activeListings) do
        if not v.seenTimes then
            if v.firstSeen and v.lastSeen and v.firstSeen ~= v.lastSeen then
                v.seenTimes = { v.firstSeen, v.lastSeen }
            else
                v.seenTimes = { v.lastSeen or v.firstSeen or (time and time() or 0) }
            end
        end
    end

    -- One-time legacy wipe (v2 of the Market system)
    if not MidnightUISettings.__marketWiped_v2 then
        if _G.MessengerDB then
            _G.MessengerDB.MarketWatchList = nil
            if _G.MessengerDB.History and _G.MessengerDB.History.Market then
                _G.MessengerDB.History.Market = nil
            end
        end
        if MidnightUISettings.Messenger and MidnightUISettings.Messenger.hideMarket ~= nil then
            MidnightUISettings.Messenger.hideMarket = nil
        end
        MidnightUISettings.__marketWiped_v2 = true
        Dbg("legacy wipe complete (v2)")
    end
end


-- ============================================================
-- SECTION: Event wiring
-- ============================================================

local listener = CreateFrame("Frame")
listener:RegisterEvent("ADDON_LOADED")
listener:RegisterEvent("CHAT_MSG_CHANNEL")

listener:SetScript("OnEvent", function(self, event, ...)
    if event == "ADDON_LOADED" then
        local name = ...
        if name == ADDON_NAME then
            InitSettings()
            if _G.C_Timer and _G.C_Timer.NewTicker then
                _G.C_Timer.NewTicker(30, ExpireOldListings)
            end
        end
    elseif event == "CHAT_MSG_CHANNEL" then
        local msg, author, _langName, _chanString, _target, _flags,
              _zone, _chanIdx, channelBase, _unused, _lineID, guid = ...
        local chanName = channelBase or _chanString or ""
        Ingest(msg, author, chanName, guid)
    end
end)
