-- KiraUI-Plugin_AutoBuy.lua
-- Buy a listed item the moment a vendor window opens.
--
-- Built for dungeon vendors that sell a cheap situational item - Sleyin in
-- Murder Row selling the Concealed Pistol (235644) for 30 copper - where the
-- click-open-find-click-close dance costs more time than the item saves.
--
-- WHY THIS IS SAFE TO AUTOMATE, AND WHERE IT STOPS
--
-- BuyMerchantItem is not a protected function on 12.x: Blizzard's own UI calls
-- it from ordinary Lua. But "not protected" is not the same as "safe to fire
-- blindly", so every guard below exists to make an unwanted purchase
-- impossible rather than merely unlikely:
--
--   * matched by ITEM ID, never by slot - slot 1 is whatever the vendor
--     happens to list first, and that moves with stock and season
--   * a PRICE CAP, so a mis-typed ID cannot cost real money
--   * items costing a CURRENCY or token are refused outright by default;
--     those are the purchases you cannot undo
--   * "only when I have none" by default, so reopening the window does not
--     buy a second one
--   * every purchase prints a line, so a mistake is visible immediately
--     rather than three keys later
--   * off by default, with an empty list

local addonName, ns = ...
local _G = _G

local CreateFrame = CreateFrame
local issecretvalue = issecretvalue
local pairs, ipairs, type, pcall, tonumber = pairs, ipairs, type, pcall, tonumber

ns.hasAutoBuy = true

local function secret(v) return issecretvalue and issecretvalue(v) end

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
-- 235644 = Concealed Pistol (Murder Row). Ships ON, with the pistol already
-- listed, because that is the whole point of the feature existing.
--
-- Note what "on by default" costs, since it is not nothing: a fresh install
-- that opens Sleyin's window spends one Faceted Crystalline Fel without being
-- asked. That is deliberate - it is a trivial dungeon currency and the whole
-- feature is worthless if it needs setting up first - but it is why the
-- limits below are not adjustable, and why nothing here is ever silent.
local DEFAULTS = {
	enable = true,
	items = { 235644 },
	quantity = 1,
	maxCopper = 10000,    -- 1 gold. Anything dearer is refused.
	allowCurrency = true, -- the pistol costs a currency, so this has to be on
	onlyIfNone = true,    -- skip when one is already in your bags
	announce = true,
	autoConfirm = true,   -- click Yes on the "are you sure" box
}

-- repair a saved list that an older build left as a non-table
local function Migrate(cfg)
	if type(cfg.items) ~= 'table' then cfg.items = {} end
end

local function GetCfg() return ns.ModuleCfg('autoBuy', DEFAULTS, Migrate) end
ns.ABGetCfg = GetCfg

local function Msg(text) print('|cffff7d0aKiraAutoBuy|r ' .. text) end

----------------------------------------------------------------------
-- Merchant scan
----------------------------------------------------------------------
-- The merchant API addresses items by SLOT INDEX, not by item ID, so the ID
-- has to be recovered from each slot's item link. There is no itemID field on
-- MerchantItemInfo - only name/price/currency - which is why the link parse is
-- unavoidable rather than lazy.
local function SlotItemID(index)
	local f = _G.GetMerchantItemLink
	if not f then return nil end
	local ok, link = pcall(f, index)
	if not ok or not link or secret(link) then return nil end
	return tonumber(link:match('item:(%d+)'))
end

local function CarriedCount(itemID)
	local f = (_G.C_Item and _G.C_Item.GetItemCount) or _G.GetItemCount
	if not f then return 0 end
	local ok, n = pcall(f, itemID)
	return (ok and type(n) == 'number') and n or 0
end

local function WantedSet(cfg)
	local set = {}
	for _, id in ipairs(cfg.items) do
		local n = tonumber(id)
		if n then set[n] = true end
	end
	return set
end

local lastBuy = {}   -- itemID -> time, so one window cannot double-fire

----------------------------------------------------------------------
-- The confirmation box
--
-- Two different things can put a "are you sure?" box on screen, and only one
-- of them is ours to worry about:
--
--   * The CLIENT raises CONFIRM_PURCHASE_TOKEN_ITEM and friends from the
--     merchant button's own click handler, BEFORE BuyMerchantItem is ever
--     called - their OnAccept is literally "BuyMerchantItem(...)". Calling
--     BuyMerchantItem directly, as this module does, walks straight past
--     them. So this code will usually find nothing to click.
--   * The SERVER raises MERCHANT_CONFIRM_TRADE_TIMER_REMOVAL after the fact,
--     when a purchase would burn the refund window on something you are
--     still carrying. That one appears no matter how the buy was made, and
--     is the case this exists for.
--
-- Clicking is only ever done in the two seconds after OUR OWN purchase call.
-- Outside that window an identical-looking box belongs to something the
-- player did by hand, and clicking it would be answering a question that was
-- never asked of us.
----------------------------------------------------------------------
local CONFIRM_POPUPS = {
	CONFIRM_PURCHASE_TOKEN_ITEM = true,
	CONFIRM_PURCHASE_NONREFUNDABLE_ITEM = true,
	CONFIRM_HIGH_COST_ITEM = true,
	CONFIRM_MERCHANT_TRADE_TIMER_REMOVAL = true,
}
-- Deliberately NOT in that list: CONFIRM_PURCHASE_ITEM_DELAYED, the box shown
-- for capped seasonal currencies. Blizzard disables its Yes button for several
-- seconds precisely so the spend cannot be made without a pause, and an addon
-- that waits out that pause for you has defeated the only protection on a
-- purchase you can never get back.

local CONFIRM_WINDOW = 2
local boughtAt = 0

local function DialogButton(d)
	if d.GetButton1 then
		local ok, b = pcall(d.GetButton1, d)
		if ok and type(b) == 'table' then return b end
	end
	local n = d.GetName and d:GetName()
	if type(n) == 'string' then return _G[n .. 'Button1'] end
	return nil
end

local function FindConfirm()
	local find = _G.StaticPopup_FindVisible
	if not find then return nil end
	for which in pairs(CONFIRM_POPUPS) do
		local ok, d = pcall(find, which)
		if ok and type(d) == 'table' then return d, which end
	end
	return nil
end

-- The box does not necessarily exist on the frame after the purchase, so this
-- looks again a few times rather than giving up on the first miss.
local TryConfirm
TryConfirm = function(attempt)
	local cfg = GetCfg()
	if not cfg.enable or not cfg.autoConfirm then return end
	local now = _G.GetTime and _G.GetTime() or 0
	if now - boughtAt > CONFIRM_WINDOW then return end

	local d, which = FindConfirm()
	if d then
		local b = DialogButton(d)
		if b then
			local okE, on = pcall(b.IsEnabled, b)
			if okE and on then
				pcall(b.Click, b)
				if cfg.announce then Msg('confirmed: ' .. which) end
				return
			end
			-- shown but the button is still disabled: an accept delay is
			-- running, so keep looking rather than clicking a dead button
		end
	end

	if attempt < 10 then
		_G.C_Timer.After(0.15, function() TryConfirm(attempt + 1) end)
	end
end

ns.ABTryConfirm = TryConfirm

local function TryBuy()
	local cfg = GetCfg()
	if not cfg.enable then return end
	if _G.InCombatLockdown and _G.InCombatLockdown() then return end

	local wanted = WantedSet(cfg)
	if not next(wanted) then return end

	local count = _G.GetMerchantNumItems and _G.GetMerchantNumItems()
	if not count or secret(count) or count < 1 then return end

	local now = _G.GetTime and _G.GetTime() or 0
	local CM = _G.C_MerchantFrame

	for i = 1, count do
		local id = SlotItemID(i)
		if id and wanted[id] then
			-- read the slot's terms before committing to anything
			local info
			if CM and CM.GetItemInfo then
				local okI, v = pcall(CM.GetItemInfo, i)
				if okI then info = v end
			end

			local price       = info and info.price or nil
			local extended    = info and info.hasExtendedCost or false
			local purchasable = (info == nil) or info.isPurchasable
			local available   = info and info.numAvailable or -1

			local reason
			if not purchasable then
				reason = 'the vendor will not sell it to you'
			elseif available == 0 then
				reason = 'out of stock'
			elseif extended and not cfg.allowCurrency then
				reason = 'it costs a currency or token (allow that in options if you mean to)'
			elseif type(price) == 'number' and price > (cfg.maxCopper or 0) then
				reason = ('it costs more than your cap (%d > %d copper)'):format(price, cfg.maxCopper or 0)
			elseif cfg.onlyIfNone and CarriedCount(id) > 0 then
				reason = nil   -- already have one: skip quietly, this is normal
			elseif lastBuy[id] and (now - lastBuy[id]) < 2 then
				reason = nil   -- just bought it; the window re-fires its event
			else
				lastBuy[id] = now
				boughtAt = now
				local qty = math.max(1, math.floor(cfg.quantity or 1))
				local okB = pcall(_G.BuyMerchantItem, i, qty)
				if okB and cfg.autoConfirm then
					_G.C_Timer.After(0, function() TryConfirm(1) end)
				end
				if okB and cfg.announce then
					local nm = info and info.name
					if secret(nm) or type(nm) ~= 'string' then nm = 'item ' .. id end
					Msg(('bought %s x%d'):format(nm, qty))
				elseif not okB then
					Msg('purchase call failed for item ' .. id)
				end
				return   -- one purchase per window opening, deliberately
			end

			if reason and cfg.announce then
				Msg(('skipped item %d - %s'):format(id, reason))
			end
		end
	end
end

ns.ABTryBuy = TryBuy

----------------------------------------------------------------------
-- Events
----------------------------------------------------------------------
local evt = CreateFrame('Frame')
local function Reg(e) return pcall(evt.RegisterEvent, evt, e) end
Reg('MERCHANT_SHOW')
Reg('MERCHANT_UPDATE')

evt:SetScript('OnEvent', function()
	-- one frame later: the merchant list is not fully populated at the instant
	-- MERCHANT_SHOW fires, so an immediate scan finds nothing
	_G.C_Timer.After(0, function() pcall(TryBuy) end)
end)

----------------------------------------------------------------------
-- Diagnostic
----------------------------------------------------------------------
_G.SLASH_KIRABUY1 = '/kirabuy'
_G.SlashCmdList.KIRABUY = function()
	local cfg = GetCfg()
	Msg(('enabled: %s | cap %d copper | currency purchases %s | only when I have none: %s')
		:format(cfg.enable and 'yes' or 'NO', cfg.maxCopper or 0,
			cfg.allowCurrency and 'ALLOWED' or 'refused',
			cfg.onlyIfNone and 'yes' or 'no'))
	if #cfg.items == 0 then
		Msg('no items configured.')
	else
		for _, id in ipairs(cfg.items) do
			local nm
			if _G.C_Item and _G.C_Item.GetItemNameByID then
				local ok, v = pcall(_G.C_Item.GetItemNameByID, id)
				if ok then nm = v end
			end
			Msg(('  %d  %s  (you carry %d)'):format(id, tostring(nm or '?'), CarriedCount(id)))
		end
	end

	local count = _G.GetMerchantNumItems and _G.GetMerchantNumItems()
	if not count or count < 1 then
		Msg('no vendor window open - open one to list what it sells.')
		return
	end
	Msg('this vendor sells:')
	for i = 1, count do
		local id = SlotItemID(i)
		local info
		if _G.C_MerchantFrame and _G.C_MerchantFrame.GetItemInfo then
			local okI, v = pcall(_G.C_MerchantFrame.GetItemInfo, i)
			if okI then info = v end
		end
		local nm = info and info.name
		if secret(nm) or type(nm) ~= 'string' then nm = '?' end
		Msg(('  slot %d: itemID |cffffd200%s|r  %s  price %s%s'):format(
			i, tostring(id or '?'), nm,
			tostring(info and info.price or '?'),
			(info and info.hasExtendedCost) and ' |cffff5555(has extra cost)|r' or ''))
		-- Spell out what the "extra cost" actually is. An item can want copper
		-- AND a currency at once, and the copper figure alone reads as cheap.
		if info and info.hasExtendedCost then
			for c = 1, 3 do
				local okC, _, cnt, link, curName = pcall(_G.GetMerchantItemCostItem, i, c)
				if okC and cnt and not secret(cnt) and cnt > 0 then
					local what = curName
					if type(what) ~= 'string' and type(link) == 'string' then
						what = link:match('%[(.-)%]') or link
					end
					if type(what) == 'string' then
						Msg(('      costs %s x%s'):format(what, tostring(cnt)))
					end
				end
			end
		end
	end
end
