-- Portal Pro :: Modules/Ledger.lua
-- Append-only transaction log. Refunds are linked negative entries, never edits to the
-- original, so history stays auditable and a refund can't silently rewrite a sale.

local ADDON_NAME, PP = ...
local U = PP.U

local L = PP:NewModule("Ledger")
PP.Ledger = L

-- entry = {
--   id       string      unique
--   ts       number      unix time
--   customer string|nil
--   dest     string|nil  destination key
--   spellID  number|nil
--   kind     string      "payment" | "refund" | "reagent" | "manual"
--   copper   number      always positive; `kind` decides the sign in totals
--   linkedID string|nil  refund -> the payment it reverses
--   source   string      "trade" | "mail" | "cast" | "manual"
--   note     string|nil
-- }

local SIGN = {
	payment = 1,
	manual  = 1,
	refund  = -1,
	reagent = -1,
}

--------------------------------------------------------------------------------
-- Writing
--------------------------------------------------------------------------------

function L:Add(entry)
	if not PP.global then return nil end
	entry.id     = entry.id or U.NextID()
	entry.ts     = entry.ts or time()
	entry.kind   = entry.kind or "manual"
	entry.copper = math.abs(math.floor(entry.copper or 0))
	entry.source = entry.source or "manual"

	table.insert(PP.global.ledger, entry)

	if entry.customer and (entry.kind == "payment" or entry.kind == "refund") then
		self:TouchCustomer(entry.customer, entry)
	end

	PP:Fire("LEDGER_CHANGED", entry)
	return entry
end

function L:RecordPayment(customer, copper, dest, source, note)
	return self:Add({
		kind = "payment", customer = customer, copper = copper,
		dest = dest, source = source or "trade", note = note,
	})
end

--- Refunds can be attached to a specific payment or stand alone (e.g. gold mailed back
--- long after the fact). Either way the ledger stays append-only.
function L:RecordRefund(customer, copper, linkedID, source, note)
	return self:Add({
		kind = "refund", customer = customer, copper = copper,
		linkedID = linkedID, source = source or "trade", note = note,
	})
end

function L:RecordReagent(copper, dest, spellID)
	if not copper or copper <= 0 then return nil end
	return self:Add({
		kind = "reagent", copper = copper, dest = dest,
		spellID = spellID, source = "cast",
	})
end

--- Refund an existing payment by id. Partial amounts allowed.
function L:RefundEntry(id, copper)
	local entry = self:GetByID(id)
	if not entry or entry.kind ~= "payment" then return nil end
	local amount = copper or entry.copper
	local alreadyRefunded = self:RefundedAmount(id)
	amount = math.min(amount, entry.copper - alreadyRefunded)
	if amount <= 0 then return nil end
	return self:RecordRefund(entry.customer, amount, entry.id, "manual", "Refund of " .. entry.id)
end

function L:Delete(id)
	local ledger = PP.global.ledger
	for i = #ledger, 1, -1 do
		if ledger[i].id == id then
			table.remove(ledger, i)
			PP:Fire("LEDGER_CHANGED")
			return true
		end
	end
	return false
end

--------------------------------------------------------------------------------
-- Reading
--------------------------------------------------------------------------------

function L:All()
	return PP.global and PP.global.ledger or {}
end

function L:GetByID(id)
	for _, e in ipairs(self:All()) do
		if e.id == id then return e end
	end
	return nil
end

function L:RefundedAmount(paymentID)
	local total = 0
	for _, e in ipairs(self:All()) do
		if e.kind == "refund" and e.linkedID == paymentID then
			total = total + e.copper
		end
	end
	return total
end

--- @param filter table|nil { since, until_, customer, dest, kind }
function L:Query(filter)
	filter = filter or {}
	local out = {}
	for _, e in ipairs(self:All()) do
		local ok = true
		if filter.since   and e.ts < filter.since        then ok = false end
		if filter.until_  and e.ts > filter.until_       then ok = false end
		if filter.customer and e.customer ~= filter.customer then ok = false end
		if filter.dest    and e.dest ~= filter.dest      then ok = false end
		if filter.kind    and e.kind ~= filter.kind      then ok = false end
		if ok then out[#out + 1] = e end
	end
	return out
end

--- Totals over a window. `net` already has refunds and reagent cost subtracted.
function L:Totals(since, until_)
	local t = { gross = 0, refunds = 0, reagents = 0, net = 0, count = 0, refundCount = 0 }
	for _, e in ipairs(self:All()) do
		if (not since or e.ts >= since) and (not until_ or e.ts <= until_) then
			if e.kind == "payment" or e.kind == "manual" then
				t.gross = t.gross + e.copper
				t.count = t.count + 1
			elseif e.kind == "refund" then
				t.refunds = t.refunds + e.copper
				t.refundCount = t.refundCount + 1
			elseif e.kind == "reagent" then
				t.reagents = t.reagents + e.copper
			end
		end
	end
	t.net = t.gross - t.refunds - t.reagents
	return t
end

--- Gross-minus-refunds per destination key.
function L:ByDestination(since)
	local out = {}
	for _, e in ipairs(self:All()) do
		if (not since or e.ts >= since) and e.dest then
			local sign = SIGN[e.kind] or 0
			out[e.dest] = (out[e.dest] or 0) + e.copper * sign
		end
	end
	return out
end

--- Net per day for the last `days` days, oldest first. Reagent cost included.
function L:DailySeries(days)
	days = days or 7
	local out, index = {}, {}
	local dayStart = time() - (days - 1) * 86400
	for i = 1, days do
		local key = U.DayKey(dayStart + (i - 1) * 86400)
		out[i] = { key = key, value = 0 }
		index[key] = out[i]
	end
	for _, e in ipairs(self:All()) do
		local bucket = index[U.DayKey(e.ts)]
		if bucket then
			bucket.value = bucket.value + e.copper * (SIGN[e.kind] or 0)
		end
	end
	return out
end

--------------------------------------------------------------------------------
-- Customers
--------------------------------------------------------------------------------

function L:TouchCustomer(name, entry)
	local customers = PP.global.customers
	local c = customers[name]
	if not c then
		c = { first = entry.ts, last = entry.ts, count = 0, totalCopper = 0 }
		customers[name] = c
	end
	c.last = entry.ts
	if entry.kind == "payment" then
		c.count = c.count + 1
		c.totalCopper = c.totalCopper + entry.copper
	elseif entry.kind == "refund" then
		c.totalCopper = c.totalCopper - entry.copper
	end
end

function L:GetCustomer(name)
	return PP.global and PP.global.customers[name]
end

function L:TopCustomers(n)
	local out = {}
	for name, c in pairs(PP.global.customers or {}) do
		if not c.blacklisted then
			out[#out + 1] = { name = name, total = c.totalCopper or 0, count = c.count or 0, vip = c.vip }
		end
	end
	U.SortBy(out, function(x) return x.total end, true)
	while #out > (n or 10) do table.remove(out) end
	return out
end

function L:SetVIP(name, isVIP)
	local customers = PP.global.customers
	customers[name] = customers[name] or { first = time(), last = time(), count = 0, totalCopper = 0 }
	customers[name].vip = isVIP or nil
	PP:Fire("CUSTOMERS_CHANGED")
end

function L:SetBlacklisted(name, isBlacklisted)
	local customers = PP.global.customers
	customers[name] = customers[name] or { first = time(), last = time(), count = 0, totalCopper = 0 }
	customers[name].blacklisted = isBlacklisted or nil
	PP:Fire("CUSTOMERS_CHANGED")
end

function L:IsBlacklisted(name)
	local c = self:GetCustomer(name)
	return c and c.blacklisted or false
end

function L:IsVIP(name)
	local c = self:GetCustomer(name)
	return c and c.vip or false
end

--------------------------------------------------------------------------------
-- Export
--------------------------------------------------------------------------------

function L:ExportCSV(since)
	local lines = { "timestamp,date,customer,destination,kind,source,copper,gold,note" }
	for _, e in ipairs(self:All()) do
		if not since or e.ts >= since then
			lines[#lines + 1] = ("%d,%s,%s,%s,%s,%s,%d,%.4f,%s"):format(
				e.ts,
				date("%Y-%m-%d %H:%M:%S", e.ts),
				(e.customer or ""):gsub(",", " "),
				e.dest or "",
				e.kind,
				e.source or "",
				e.copper * (SIGN[e.kind] or 0),
				e.copper * (SIGN[e.kind] or 0) / 10000,
				(e.note or ""):gsub(",", " ")
			)
		end
	end
	return table.concat(lines, "\n")
end
