-- MarketWatch Core.lua

local ADDON_NAME = "MarketWatch"
MarketWatch = LibStub("AceAddon-3.0"):NewAddon(
	ADDON_NAME,
	"AceConsole-3.0",
	"AceEvent-3.0",
	"AceTimer-3.0"
)

local MW_Addon = MarketWatch
MW = MW or {}
MW.addon = MW_Addon

--------------------------------------------------------------------
-- Defaults
--------------------------------------------------------------------
local defaults = {
	global = {
		tutorialCompleted = false,
		tutorialInProgress = false,
		tutorialPage = 0,
		quizAttempts = 0,
		quizLockoutUntil = 0,
	},
	profile = {
		enabled = true,
		expiryMinutes = 5,
		scalePercent = 100,
		maxEntriesPerCategory = 200,

		channels = {
			trade = true,
			general = true,
			yell = true,
			say = true,
		},

		categoriesEnabled = {
			Herbs = true,
			MiningBS = true,
			Enchanting = true,
			Tailoring = true,
			Jewelcrafting = true,
			Inscription = true,
			FlasksPotions = true,
			SFS = true,
			BoE = true,
			Mounts = true,
			Misc = true,
			Uncategorized = true,
		},

		-- AceGUI Window status table.
		-- This makes the main window remember its position and size.
		window = {
			width = 800,
			height = 600,
		},

		minimap = {
			angle = 215, -- degrees around the minimap where the button sits
			hide = false,
		},

		-- AH deal detection (BoE/Mounts/Transmog only - see Pricing.lua).
		-- thresholdMode "percent" uses minDiscountPercent (5-50, clamped in
		-- Config); "gold" uses minGoldSaved instead. A listing must always
		-- also clear the fixed 10g/50%-off safety rails in Pricing.lua
		-- regardless of mode.
		dealAlerts = {
			enabled = true,
			thresholdMode = "percent",
			minDiscountPercent = 20,
			minGoldSaved = 300,
			sound = false,
		},

		-- {name=, class=} so removal UI can show the seller's class color
		blacklist = {},

		-- plain strings, matched case-insensitively as substrings of the message
		keywords = {},
	},
}

--------------------------------------------------------------------
-- Storage
--------------------------------------------------------------------
MW.data = {
	items = {},
	transmog = {},
	chartrade = {},
	boost = {},
}

local function EnsureCategoryList(catKey)
	if not MW.data.items[catKey] then
		MW.data.items[catKey] = {}
	end
	return MW.data.items[catKey]
end

-- Matched on normalized text, not (sender, message) - the same copy-pasted
-- ad reposted from a different alt/guildmate is still the same listing to
-- a buyer, not a new one. Whoever posted most recently "owns" the entry;
-- senders tracks the distinct names it's been seen from so that isn't lost
-- (a set, not a raw repost tally - the same person spamming every 30s for
-- an hour still shows as 1 seller, not some ever-climbing count).
local function FindDuplicate(list, matchKey)
	for i, e in ipairs(list) do
		if e.matchKey == matchKey then
			return i
		end
	end
	return nil
end

-- Returns true when this call caused the listing to newly become a deal
-- (wasn't one before, or didn't exist before) - Core.lua uses this to
-- decide whether the deal sound should fire, so a continuously-reposted
-- deal doesn't replay the sound on every single repost.
local function PushCapped(list, entry, cap)
	local dupIdx = FindDuplicate(list, entry.matchKey)
	local wasDealBefore = false
	if dupIdx then
		local old = list[dupIdx]
		wasDealBefore = old.isDeal or false

		local senders = old.senders or { [old.sender] = true }
		senders[entry.sender] = true
		entry.senders = senders

		local n = 0
		for _ in pairs(senders) do n = n + 1 end
		entry.senderCount = n

		table.remove(list, dupIdx)
	end

	table.insert(list, 1, entry)

	while #list > cap do
		table.remove(list)
	end

	return entry.isDeal and not wasDealBefore
end

--------------------------------------------------------------------
-- Channel filtering
--------------------------------------------------------------------
local function ChannelAllowed(chanName)
	if not chanName then return true end

	local db = MW_Addon.db.profile.channels
	local cLo = chanName:lower()

	if db.trade and cLo:find("trade") then
		return true
	end

	if db.general and (
		cLo:find("general") or
		cLo:find("world") or
		cLo:find("global")
	) then
		return true
	end

	return false
end

MW.IsUnlocked = function()
	return MarketWatch.db and MarketWatch.db.global and MarketWatch.db.global.tutorialCompleted
end

--------------------------------------------------------------------
-- Factory reset: full wipe (settings, blacklist, keywords, listings,
-- tutorial/quiz state) - re-locks the addon, tutorial must run again.
--------------------------------------------------------------------
function MW.FactoryReset()
	MW_Addon.db:ResetProfile()

	local gdb = MW_Addon.db.global
	gdb.tutorialCompleted = false
	gdb.tutorialInProgress = false
	gdb.tutorialPage = 0
	gdb.quizAttempts = 0
	gdb.quizLockoutUntil = 0

	MW.data.items = {}
	MW.data.chartrade = {}
	MW.data.boost = {}
	MW.data.transmog = {}

	local mainFrame = _G["MarketWatchFrame"]
	if mainFrame then mainFrame:Hide() end

	DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[MarketWatch]|r Factory reset complete. The addon is locked again.")
end

--------------------------------------------------------------------
-- Blacklist
--------------------------------------------------------------------
local function IsBlacklisted(sender)
	if not sender then return false end
	local db = MW_Addon.db.profile
	if not db.blacklist then return false end

	local senderLo = sender:lower()
	for _, entry in ipairs(db.blacklist) do
		local name = type(entry) == "table" and entry.name or entry
		if name and name:lower() == senderLo then
			return true
		end
	end
	return false
end
MW.IsBlacklisted = IsBlacklisted

--------------------------------------------------------------------
-- Item-link BoE detection (fallback for Uncategorized items only)
--
-- When a message contains a real shift-clicked item link (not just a
-- typed bracketed name), we can check the item's ACTUAL bind type
-- directly via its itemID, using a hidden scanning tooltip. This works
-- identically regardless of faction-specific naming - a Horde and
-- Alliance version of the same drop/token have different names but the
-- same underlying bind flag, so this needs zero per-item dictionary
-- maintenance for anything that gets properly linked.
--
-- Deliberately only runs when a listing would otherwise land in
-- Uncategorized: most chat messages don't contain a real link at all
-- (typed names only), so this keeps the check cheap and avoids
-- overriding an already-correct dictionary match.
--------------------------------------------------------------------
local scanTooltip = CreateFrame("GameTooltip", "MW_ScanTooltip", UIParent, "GameTooltipTemplate")
scanTooltip:SetOwner(UIParent, "ANCHOR_NONE")

local function ExtractItemLinkString(rawMessage)
	if not rawMessage then return nil end
	-- pulls just the "item:12345:..." part out of the full |Hitem:...|h escape sequence
	return rawMessage:match("|H(item:[%-%d:]+)|h")
end

local function IsItemLinkBoE(itemLinkString)
	if not itemLinkString then return false end

	scanTooltip:ClearLines()
	local ok = pcall(scanTooltip.SetHyperlink, scanTooltip, itemLinkString)
	if not ok then return false end

	for i = 1, scanTooltip:NumLines() do
		local line = _G["MW_ScanTooltipTextLeft" .. i]
		if line then
			local text = line:GetText()
			if text and text:find("Binds when equipped", 1, true) then
				return true
			end
		end
	end
	return false
end

MW.ExtractItemLinkString = ExtractItemLinkString
MW.IsItemLinkBoE = IsItemLinkBoE

--------------------------------------------------------------------
-- Core message handler
--------------------------------------------------------------------
function MW.HandleMessage(msg, sender, channel, chanName, guid)
	local db = MW_Addon.db.profile

	if not db.enabled then return end
	if IsBlacklisted(sender) then return end
	if not msg or #msg < 6 then return end

	local normMsg = MW.NormalizeMsg(msg)
	if normMsg == "" then return end

	local tier1 = MW.FindTier1(normMsg)
	if not tier1 then return end
	if not MW.PassesTier2(normMsg, tier1) then return end

	local routeType, catKey, matchedKeyword = MW.Categorize(normMsg, tier1.kind)

	-- Called unconditionally now (not just for the Uncategorized fallback
	-- below) since deal detection also needs it for BoE/Mounts/Transmog.
	local itemLinkString = ExtractItemLinkString(msg)

	if routeType == "ITEM" and catKey == "Uncategorized" then
		if itemLinkString and IsItemLinkBoE(itemLinkString) then
			catKey = "BoE"
		end
	end

	local entry = {
		sender = sender or "Unknown",
		message = msg,
		kind = tier1.kind,
		channel = channel,
		time = time(),
		guid = guid,
		matchKey = normMsg,
	}

	-- Price lookup: BoE/Mounts (item name known from the matched dictionary
	-- keyword) and Transmog (no dictionary item name to go on - Transmog
	-- routes on a trigger phrase, not a specific item - so it only prices
	-- when a real item link is present). Looked up for WTS *and* WTB now -
	-- someone asking "WTB [Merlin's Robe]" with no price mentioned still
	-- benefits from seeing the going rate in the tooltip. WTT is left out
	-- (no single clear price direction). The flashing "DEAL" alert itself
	-- stays WTS-only below - a WTB priced below market means the buyer is
	-- lowballing, not that it's a bargain for the reader, so that verdict
	-- would be actively misleading on a WTB. See Pricing.lua.
	if MW.Pricing then
		local wantsPricing = (tier1.kind == "WTS" or tier1.kind == "WTB")
			and ((routeType == "ITEM" and (catKey == "BoE" or catKey == "Mounts"))
				or routeType == "TRANSMOG")
		if wantsPricing then
			local itemName = (routeType == "ITEM") and matchedKeyword or nil
			-- normMsg, not msg: matchedKeyword was found by matching against
			-- the normalized text (hyperlink codes stripped, apostrophes
			-- removed) - searching for it in the raw message would fail
			-- whenever the item name has punctuation, since the raw text
			-- still has the apostrophe normMsg deleted.
			local deal = MW.Pricing.EvaluateDeal(normMsg, itemName, itemLinkString)
			if deal then
				-- Market price is stored whenever it's known at all, deal or
				-- not, so the tooltip can show it and let the user judge for
				-- themselves even on listings that don't clear the alert
				-- threshold (or have no parseable listing price to compare
				-- against). dealMarketPrice is kept as its own field too,
				-- purely so the existing "DEAL" tooltip block below doesn't
				-- need to change what it reads.
				entry.marketPrice = deal.marketPrice
				entry.priceSource = deal.source
				if deal.isDeal and tier1.kind == "WTS" then
					entry.isDeal = true
					entry.dealPercent = deal.discountPercent
					entry.dealMarketPrice = deal.marketPrice
					entry.dealGoldSaved = deal.goldSaved
					entry.dealSource = deal.source
				end
			end
		end
	end

	local becameDeal = false

	if routeType == "TRANSMOG" then
		entry.category = "Transmog"
		becameDeal = PushCapped(
			MW.data.transmog,
			entry,
			db.maxEntriesPerCategory
		)

	elseif routeType == "CHARTRADE" then
		entry.category = "CharacterTrade"
		PushCapped(
			MW.data.chartrade,
			entry,
			db.maxEntriesPerCategory
		)

	elseif routeType == "BOOST" then
		entry.category = "Boost"
		PushCapped(
			MW.data.boost,
			entry,
			db.maxEntriesPerCategory
		)

	else
		if catKey == "Uncategorized" then
			if not db.categoriesEnabled.Uncategorized then return end
		else
			if not db.categoriesEnabled[catKey] then return end
		end

		entry.category = catKey

		local list = EnsureCategoryList(catKey)
		becameDeal = PushCapped(list, entry, db.maxEntriesPerCategory)
	end

	if becameDeal then
		if MW.Pricing and MW.Pricing.RecomputeAnyDealLive then
			MW.Pricing.RecomputeAnyDealLive()
		end
		if db.dealAlerts and db.dealAlerts.enabled and db.dealAlerts.sound then
			PlaySound("AuctionWindowOpen")
		end
	end

	if MW.UI and MW.UI.OnNewEntry then
		MW.UI.OnNewEntry(routeType, catKey)
	end
end

--------------------------------------------------------------------
-- Events
--------------------------------------------------------------------
function MW_Addon:OnInitialize()
	self.db = LibStub("AceDB-3.0"):New(
		"MarketWatchDB",
		defaults,
		true
	)
end

function MW_Addon:OnEnable()
	self:RegisterEvent("CHAT_MSG_CHANNEL", "OnChatChannel")
	self:RegisterEvent("CHAT_MSG_YELL", "OnChatYell")
	self:RegisterEvent("CHAT_MSG_SAY", "OnChatSay")

	self:ScheduleRepeatingTimer("ExpireOldEntries", 30)

	self:RegisterChatCommand("mw", "SlashCommand")
	self:RegisterChatCommand("marketwatch", "SlashCommand")
end

function MW_Addon:OnChatChannel(
	event, msg, sender, _, _, _, _, _, _, chanBaseName, _, _, senderGUID
)
	if not ChannelAllowed(chanBaseName) then return end
	MW.HandleMessage(msg, sender, "Channel", chanBaseName, senderGUID)
end

function MW_Addon:OnChatYell(event, msg, sender, _, _, _, _, _, _, _, _, _, senderGUID)
	if not MW_Addon.db.profile.channels.yell then return end
	MW.HandleMessage(msg, sender, "Yell", nil, senderGUID)
end

function MW_Addon:OnChatSay(event, msg, sender, _, _, _, _, _, _, _, _, _, senderGUID)
	if not MW_Addon.db.profile.channels.say then return end
	MW.HandleMessage(msg, sender, "Say", nil, senderGUID)
end

--------------------------------------------------------------------
-- Expiry
--------------------------------------------------------------------
function MW_Addon:ExpireOldEntries()
	local db = self.db.profile
	local cutoff = time() - (db.expiryMinutes * 60)

	local function purge(list)
		for i = #list, 1, -1 do
			if list[i].time < cutoff then
				table.remove(list, i)
			end
		end
	end

	for _, list in pairs(MW.data.items) do
		purge(list)
	end

	purge(MW.data.transmog)
	purge(MW.data.chartrade)
	purge(MW.data.boost)

	if MW.Pricing and MW.Pricing.RecomputeAnyDealLive then
		MW.Pricing.RecomputeAnyDealLive()
	end

	if MW.UI and MW.UI.RefreshActive then
		MW.UI.RefreshActive()
	end
end

--------------------------------------------------------------------
-- Slash command
--------------------------------------------------------------------
function MW_Addon:SlashCommand(input)
	input = input and input:lower():trim() or ""

	if input == "config" then
		MW.UI.ToggleConfig()

	elseif input == "transmog" then
		MW.UI.ToggleTransmog()

	elseif input == "trade" or input == "chartrade" then
		MW.UI.ToggleCharTrade()

	else
		MW.UI.ToggleMain()
	end
end
