--[[
	Cerebro - Ledger Model
	
	PURPOSE:
	The Ledger is the authoritative audit trail for all EPGP mutations. It implements
	Log-Based Synchronization, enabling clients to rebuild the entire guild economy
	from transaction history alone. This is the "source of truth" for EPGP standings.
	
	RESPONSIBILITIES:
	1. Transaction Recording - Create immutable records of all EP/GP changes
	2. Transaction Merging - Idempotent ingestion of transactions from other officers
	3. History Replay - Rebuild player state from transaction log (ReplayHistory)
	4. Audit Logging - Track administrative actions (config changes, etc.)
	5. Loot History - Record item awards (AddAward, separate from transactions)
	
	CONTRACTS (per docs/SPEC.md Section 3):
	
	# AddTransaction(player, amount, currency, reason, details, isImport, options) -> uuid
	  - Creates immutable transaction record with unique UUID
	  - MUST append to db.global.transactions (append-only log)
	  - MUST set source="Manual" for officer actions, "Auto-Import" for syncs
	  - MUST set verified=true for manual, verified=false for imports
	  - Returns: Generated UUID for the transaction
	  
	# MergeTransaction(entry) -> boolean
	  - MUST be idempotent: if entry.id exists, return false (no duplicate)
	  - If new entry, append and trigger ReplayHistory()
	  - Returns: true if merged (new), false if duplicate (skipped)
	  
	# ReplayHistory() -> void
	  - MUST reset ALL player EP to 0, GP to BaseGP before processing
	  - MUST sort transactions by timestamp (chronological order)
	  - MUST apply transactions sequentially (EP + amount, GP + amount)
	  - MUST recalculate PR for all players after replay
	  - Note: Decay transactions use negative amounts (already calculated deltas)
	  
	# IngestReceipts(receipts) -> count
	  - Batch version of MergeTransaction with optimized lookup
	  - Returns: Number of new transactions added
	  
	# AddAuditLog(action, details) -> void
	  - Rolling buffer: MUST cap at 1000 entries (FIFO removal)
	  - MUST broadcast to other officers via Communication module
	
	TRANSACTION SCHEMA (per SPEC.md Section 3.2):
	{
	    id = "uuid",              -- Unique identifier (GenerateUUID)
	    timestamp = 1234567890,    -- Unix epoch (for sorting)
	    timestampUTC = "...",      -- Human-readable (for display)
	    player = "Name-Realm",     -- Target player
	    currency = "EP" | "GP",    -- Type of points
	    amount = 50,               -- Signed integer (negative for decay)
	    source = "Manual" | "Auto-Import" | "Boss Kill",
	    reason = "User provided reason",
	    admin = "Officer Name",    -- Who created this transaction
	    verified = true | false,   -- Manual=true, Import=false
	    sessionID = "...",         -- Optional: raid session ID
	    sessionName = "..."        -- Optional: raid name
	}
	
	INVARIANTS:
	1. Transactions are append-only (NEVER delete or modify existing entries)
	2. UUIDs MUST be globally unique (collision prevention via random generation)
	3. ReplayHistory MUST produce identical results given identical transaction logs
	4. Audit log MUST NOT exceed 1000 entries (FIFO eviction)
	5. MergeTransaction MUST NOT create duplicates (idempotency guarantee)
	
	DESIGN DECISIONS:
	- UUID v4 format used (random, not time-based) for collision prevention
	- ReplayHistory resets to BaseGP (not 0) per SPEC.md PR calculation contract
	- Negative amounts in transactions represent removals (decay, corrections)
	- Communication module is optional dependency (checked before calling)
	- UI refresh triggered after merge/ingest to update standings display
	
	SECURITY MODEL:
	This module does NOT enforce authorization. Callers (EPGP.lua, Core.lua)
	MUST verify IsOfficer() before invoking AddTransaction or AddAuditLog.
	MergeTransaction and IngestReceipts are called by Communication module
	after validating incoming data.
	
	EXTERNAL DEPENDENCIES:
	- Models/Player.lua (New, GetData, UpdatePR)
	- Modules/Communication.lua (Broadcast*, optional)
	- Modules/Session.lua (GetCurrentSession, optional)
	- Modules/UI.lua (RefreshStandings, optional)
	- WoW API: UnitName, time, date, GetRealZoneText
	
	TEST COVERAGE:
	- TEST-LEDGER-001: Transaction creation, UUID uniqueness, merge idempotency
	- TEST-REPLAY-001: History replay, state reset, chronological ordering
]]--

local AddonName, PrivateTable = ...
local Cerebro = LibStub("AceAddon-3.0"):GetAddon(AddonName)
local Ledger = {}
Cerebro.Ledger = Ledger

--[[=============================================
	Helper: UUID & Time
==============================================]]

local function GenerateUUID()
	local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
	return string.gsub(template, "[xy]", function(c)
		local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb)
		return string.format("%x", v)
	end)
end

local function GetTimestampUTC()
	return date("!%Y-%m-%d %H:%M:%S")
end

--[[=============================================
	Transaction Management
==============================================]]

-- Create a new transaction record and append to the ledger
-- Contract: Creates immutable record with unique UUID, appends to append-only log
-- Authorization: Caller MUST verify IsOfficer() before calling (delegated to EPGP.lua)
function Ledger:AddTransaction(player, amount, currency, reason, details, isImport, options)
	-- currency: "EP" or "GP"
	local session = Cerebro.modules.Session and Cerebro.modules.Session:GetCurrentSession()
	local myName = UnitName("player")
	
	-- Session-related reasons that SHOULD get session stamps
	-- M+ Readiness EP and other non-raid EP should NOT get session stamps
	local sessionReasons = {
		["BOSS"] = true,           -- Boss kill EP (prefix match)
		["ON_TIME"] = true,        -- On-time attendance bonus
		["FULL_ATTENDANCE"] = true, -- Full attendance bonus
		["PROGRESSION"] = true,    -- 30-min progression EP
		["SESSION_START"] = true,  -- Session lifecycle marker
		["SESSION_END"] = true,    -- Session lifecycle marker
	}
	
	-- Check if this reason is session-related (exact match or prefix match for "BOSS: ")
	local isSessionRelated = false
	if reason then
		for pattern, _ in pairs(sessionReasons) do
			if reason == pattern or string.find(reason, "^" .. pattern) then
				isSessionRelated = true
				break
			end
		end
	end
	
	-- Build transaction entry per SPEC.md Section 3.2 schema
	local entry = {
		id = GenerateUUID(),           -- Unique identifier (collision prevention)
		timestamp = time(),            -- Unix epoch for sorting
		timestampUTC = GetTimestampUTC(), -- Human-readable for UI display
		logicVersion = Cerebro.version, -- Version stamping for schema validation
		player = player,
		amount = amount,               -- Signed: positive=award, negative=removal
		currency = currency,
		reason = reason,               -- e.g. "BOSS_KILL", "LOOT", "DECAY", "MANUAL"
		details = details or {},       -- Optional metadata
		admin = myName,                -- Officer who created this
		source = isImport and "Auto-Import" or "Manual",
		verified = not isImport,       -- Manual=true, Import=false (trust level)
		-- Only attach session info for raid-related transactions (not M+ Readiness, etc.)
		sessionID = (session and isSessionRelated) and session.sessionID or nil,
		sessionName = (session and isSessionRelated) and session.raidName or nil
	}

	-- Receipt Cursor Tracking: Assign monotonic sequence for local receipts
	-- This enables reliable (ts, seq) ordering for cursor comparisons
	if not isImport and not entry.receiptSeq then
		Cerebro.db.global.receiptSeq = (Cerebro.db.global.receiptSeq or 0) + 1
		entry.receiptSeq = Cerebro.db.global.receiptSeq
	end

	-- INVARIANT: Append-only log (never delete or modify)
	table.insert(Cerebro.db.global.transactions, entry)
	
	-- Broadcast to other officers for sync (unless silent mode for replay)
	if not (options and options.silent) then
		if Cerebro.modules.CommunicationV2 and Cerebro.modules.CommunicationV2.BroadcastTransaction then
			Cerebro.modules.CommunicationV2:BroadcastTransaction(entry)
		end
	end
	
	return entry.id -- Return UUID for reference
end
    
--[[=============================================
	Schema Validation (Version Enforcement)
==============================================]]

-- Minimum trusted logic version for transaction validation
-- Transactions with explicit logicVersion below this are REJECTED
local MIN_LOGIC_VERSION = "0.7.15"

-- Validate transaction schema before ingestion
-- Returns: valid (boolean), reason (string or nil)
function Ledger:ValidateTransactionSchema(entry)
	-- Rule 1: Check explicit logicVersion if present
	if entry.logicVersion then
		local cmp = Cerebro:CompareVersions(entry.logicVersion, MIN_LOGIC_VERSION)
		if cmp < 0 then
			return false, "OUTDATED_LOGIC_VERSION"
		end
	end
	-- Note: Missing logicVersion (legacy) is ALLOWED (returns true)
	
	-- Rule 2: M+ transactions MUST have runKey for deduplication
	if entry.reason == "MPLUS_RUN" or (entry.details and entry.details.mapID) then
		if not entry.details or not entry.details.runKey then
			return false, "MISSING_RUNKEY"
		end
	end
	
	return true, nil
end

-- Idempotent merge: Add transaction only if ID doesn't exist
-- Contract: MUST return false for duplicates, true for new entries
-- Called by Communication module when receiving synced transactions
function Ledger:MergeTransaction(entry)
	-- INVARIANT: Idempotency - never create duplicate transactions
	local exists = false
	for _, t in ipairs(Cerebro.db.global.transactions) do
		if t.id == entry.id then
			exists = true
			break
		end
	end
	
	if not exists then
		-- Schema validation: reject outdated or malformed transactions
		local valid, reason = self:ValidateTransactionSchema(entry)
		if not valid then
			Cerebro:Debug("Rejected transaction: " .. (reason or "SCHEMA_INVALID") .. " [" .. entry.id .. "]")
			return false
		end
		
		-- Warn about legacy transactions (missing logicVersion)
		if not entry.logicVersion then
			Cerebro:Debug("Accepted legacy transaction (no logicVersion): " .. entry.id)
		end
		
		table.insert(Cerebro.db.global.transactions, entry)
		Cerebro:Debug("Merged transaction: " .. entry.id)
		
		-- Trigger full replay to recalculate standings with new transaction
		self:ReplayHistory()
		
		-- Refresh UI to show updated standings
		Cerebro:RefreshAllUI()
		return true -- New transaction merged
	end
	
	return false -- Duplicate skipped (idempotency guarantee)
end

function Ledger:AddAward(player, itemLink, gpCost, reason)
	local entry = {
		id = GenerateUUID(), -- For consistency
		timestamp = time(),
		timestampUTC = GetTimestampUTC(),
		player = player,
		itemLink = itemLink,
		gp = gpCost,
		reason = reason,
		zone = GetRealZoneText()
	}
	
	table.insert(Cerebro.db.global.awards, entry)
end

--[[=============================================
	Ingestion & Replay (Sync Logic)
==============================================]]

function Ledger:IngestReceipts(receipts)
	local addedCount = 0
	local rejectedCount = 0
	local existingIDs = {}
	
	-- Phase 2: Build runKey deduplication indexes
	-- verifiedRunKeys[runKey] = true if a verified transaction exists for this runKey
	-- unverifiedPlayerRunKeys[player][runKey] = true for unverified claims
	local verifiedRunKeys = {}
	local unverifiedPlayerRunKeys = {}
	
	-- Optimize lookup by indexing existing transactions
	for _, t in ipairs(Cerebro.db.global.transactions) do
		existingIDs[t.id] = true
		
		-- Index runKey if present in details
		if t.details and t.details.runKey then
			local runKey = t.details.runKey
			if t.verified then
				verifiedRunKeys[runKey] = true
			else
				-- Track unverified claims per player
				if not unverifiedPlayerRunKeys[t.player] then
					unverifiedPlayerRunKeys[t.player] = {}
				end
				unverifiedPlayerRunKeys[t.player][runKey] = true
			end
		end
	end
	
	for _, receipt in ipairs(receipts) do
		if not existingIDs[receipt.id] then
			-- Verify basic schema integrity
			if receipt.id and receipt.amount and receipt.player then
				local shouldReject = false
				local rejectReason = nil
				
				-- Schema validation: reject outdated or malformed transactions
				local valid, reason = self:ValidateTransactionSchema(receipt)
				if not valid then
					shouldReject = true
					rejectReason = reason
				end
				
				-- Warn about legacy transactions (missing logicVersion)
				if not shouldReject and not receipt.logicVersion then
					Cerebro:Debug("Accepting legacy receipt (no logicVersion): " .. receipt.id)
				end
				
				-- Phase 2: RunKey deduplication check
				if receipt.details and receipt.details.runKey then
					local runKey = receipt.details.runKey
					local player = receipt.player
					
					-- Rule 1: Reject if a VERIFIED transaction exists with same runKey
					if verifiedRunKeys[runKey] then
						shouldReject = true
						rejectReason = "Verified award already exists for runKey"
					-- Rule 2: Reject if an UNVERIFIED transaction exists for same player+runKey
					elseif unverifiedPlayerRunKeys[player] and unverifiedPlayerRunKeys[player][runKey] then
						shouldReject = true
						rejectReason = "Duplicate claim from same player for runKey"
					end
				end
				
				if shouldReject then
					Cerebro:Debug("Rejected receipt: " .. (rejectReason or "Unknown") .. " [" .. receipt.id .. "]")
					rejectedCount = rejectedCount + 1
				else
					-- Flag as imported/unverified
					receipt.source = "Auto-Import"
					receipt.verified = false
					
					table.insert(Cerebro.db.global.transactions, receipt)
					addedCount = addedCount + 1
					
					-- Update indexes for subsequent receipts in this batch
					existingIDs[receipt.id] = true
					if receipt.details and receipt.details.runKey then
						if not unverifiedPlayerRunKeys[receipt.player] then
							unverifiedPlayerRunKeys[receipt.player] = {}
						end
						unverifiedPlayerRunKeys[receipt.player][receipt.details.runKey] = true
					end
				end
			end
		end
	end
	
	if addedCount > 0 then
		Cerebro:Debug("Ingested " .. addedCount .. " new receipts.")
		self:ReplayHistory()
        
        -- Refresh UI
        Cerebro:RefreshAllUI()
	end
	
	if rejectedCount > 0 then
		Cerebro:Debug("Rejected " .. rejectedCount .. " duplicate receipts.")
	end
    
    return addedCount
end

-- Rebuild entire EPGP state from transaction log
-- Contract: Reset all players to 0 EP / BaseGP, then apply all transactions chronologically
-- This enables "replay-from-ledger" capability for sync and disaster recovery
function Ledger:ReplayHistory()
	Cerebro:Debug("Replaying EPGP History...")
	
	-- 1. Reset State: All players start at 0 EP, BaseGP (per SPEC.md PR floor)
	local baseGP = Cerebro.db.global.config.epgp.baseGP or 200
	for name, data in pairs(Cerebro.db.global.players) do
		data.ep = 0
		data.gp = baseGP -- Start at floor, not 0 (matches Player model behavior)
		data.pr = 0
	end
	
	-- 2. Sort Transactions: Chronological order is critical for correct state
	-- INVARIANT: Identical logs MUST produce identical results
	table.sort(Cerebro.db.global.transactions, function(a, b)
		return a.timestamp < b.timestamp
	end)
	
	-- 3. Replay: Apply each transaction sequentially
	local count = 0
	for _, t in ipairs(Cerebro.db.global.transactions) do
		local p = Cerebro.Player:New(t.player)
		local data = p:GetData() -- Creates player if missing
		
		-- Apply delta directly to avoid triggering Ledger (would cause infinite loop)
		-- Note: Decay uses negative amounts, so simple addition handles all cases
		if t.currency == "EP" then
			data.ep = (data.ep or 0) + t.amount
		elseif t.currency == "GP" then
			data.gp = (data.gp or 0) + t.amount
		end
		
		count = count + 1
	end
	
	-- 4. Recalculate PR for everyone (uses floored GP per Player model)
	for name, _ in pairs(Cerebro.db.global.players) do
		Cerebro.Player:New(name):UpdatePR()
	end
	
	Cerebro:Debug("Replay complete. Processed " .. count .. " transactions.")
end

--[[=============================================
	Audit Logging
==============================================]]

function Ledger:GetTransactions(filterPlayer)
	if not filterPlayer then
		return Cerebro.db.global.transactions
	end
	
	local filtered = {}
	for _, entry in ipairs(Cerebro.db.global.transactions) do
		if entry.player == filterPlayer then
			table.insert(filtered, entry)
		end
	end
	return filtered
end

-- Returns count of transactions without copying full table
-- Used by Diagnostics module for /cb status
function Ledger:GetTransactionCount()
	local count = 0
	for _ in ipairs(Cerebro.db.global.transactions or {}) do
		count = count + 1
	end
	return count
end

function Ledger:AddAuditLog(action, details)
	local entry = {
		id = GenerateUUID(),  -- UUID for idempotent sync deduplication
		timestamp = time(),
		timestampUTC = GetTimestampUTC(),
		admin = UnitName("player"),
		action = action,
		details = details or "",
		epoch = Cerebro.db.global.resetEpoch or 0,  -- Epoch marker for pre/post reset distinction
	}
	
	if not Cerebro.db.global.audit then Cerebro.db.global.audit = {} end
	table.insert(Cerebro.db.global.audit, entry)
	
	if #Cerebro.db.global.audit > 1000 then
		table.remove(Cerebro.db.global.audit, 1)
	end
    
    if Cerebro.modules.CommunicationV2 and Cerebro.modules.CommunicationV2.BroadcastAuditLog then
        Cerebro.modules.CommunicationV2:BroadcastAuditLog(entry)
    end
end

function Ledger:MergeAuditLog(entry)
    -- Deduplication Strategy:
    -- 1. Primary: UUID match (new entries with id field)
    -- 2. Secondary: Tuple match (legacy entries without id)
    -- 3. Tertiary: Content dedup (same action+details within 60s window, different admins)
    --    This prevents duplicate rejection logs when multiple officers see the same event
    
    local exists = false
    for _, l in ipairs(Cerebro.db.global.audit) do
        -- Primary: UUID match (new entries)
        if entry.id and l.id and entry.id == l.id then
            exists = true
            break
        end
        -- Secondary: Tuple match (legacy entries without id)
        if not entry.id and not l.id then
            if l.timestamp == entry.timestamp and l.admin == entry.admin and l.action == entry.action then
                exists = true
                break
            end
        end
        -- Tertiary: Content dedup for rejection/warning events
        -- Same action + details within 60s = duplicate observation of same event
        if l.action == entry.action and l.details == entry.details then
            local timeDiff = math.abs((entry.timestamp or 0) - (l.timestamp or 0))
            if timeDiff < 60 then
                exists = true
                Cerebro:Debug("Audit dedup: skipped duplicate " .. entry.action .. " (within 60s)")
                break
            end
        end
    end
    
    if not exists then
        table.insert(Cerebro.db.global.audit, entry)
        -- Cap at 1000 entries
        if #Cerebro.db.global.audit > 1000 then
            table.remove(Cerebro.db.global.audit, 1)
        end
        -- Refresh UI (Logs tab redraws on view)
        return true
    end
    return false
end

return Ledger
