--[[
	Cerebro - Validation Test Runner
	In-game test suite with UI frame for results display.

	Usage: /cb validate          — Run all tests, show results in UI frame
	       /cb validate [phase]  — Run specific test phase

	Displays pass/fail per test in a scrollable frame (like /cb support).
	Tests are non-destructive: scratch data is created and cleaned up.

	@module Modules.Validate
	@version 0.12.0
	@invariant Tests must not corrupt production data (scratch player cleanup required)
	@invariant Tests must be idempotent (safe to run multiple times)
]]--

local AddonName, _PrivateTable = ...
local Cerebro = LibStub("AceAddon-3.0"):GetAddon(AddonName)
local AceGUI = LibStub("AceGUI-3.0")

local Validate = Cerebro:NewModule("Validate")
Cerebro.modules.Validate = Validate

-- Test results storage
local results = {}
local testPhases = {}

-- Scratch player name for mutation tests (cleaned up after)
local SCRATCH_PLAYER = "ValidateBot-TestRealm"

--[[=============================================
	Test Registration
===============================================]]

local function addPhase(id, name, tests)
	testPhases[#testPhases + 1] = { id = id, name = name, tests = tests }
end

local function pass(name) return { name = name, status = "PASS", detail = nil } end
local function fail(name, detail) return { name = name, status = "FAIL", detail = detail } end
local function skip(name, reason) return { name = name, status = "SKIP", detail = reason } end

--[[=============================================
	Phase 1: Schema Integrity
===============================================]]

addPhase(1, "Schema Integrity", function()
	local r = {}
	local players = Cerebro.db and Cerebro.db.global and Cerebro.db.global.players
	if not players then
		r[#r + 1] = fail("Database exists", "db.global.players is nil")
		return r
	end
	r[#r + 1] = pass("Database exists")

	-- Check all players have required fields
	local missing = {}
	local nanFound = {}
	for name, data in pairs(players) do
		if data.ep == nil or data.gp == nil or data.status == nil then
			missing[#missing + 1] = name
		end
		if data.ep ~= data.ep then -- NaN check
			nanFound[#nanFound + 1] = name .. " (ep=NaN)"
		end
		if data.gp ~= data.gp then
			nanFound[#nanFound + 1] = name .. " (gp=NaN)"
		end
	end

	if #missing > 0 then
		r[#r + 1] = fail("Required fields (ep, gp, status)", table.concat(missing, ", "))
	else
		r[#r + 1] = pass("Required fields (ep, gp, status)")
	end

	if #nanFound > 0 then
		r[#r + 1] = fail("No NaN values", table.concat(nanFound, ", "))
	else
		r[#r + 1] = pass("No NaN values")
	end

	return r
end)

--[[=============================================
	Phase 2: Hash Determinism
===============================================]]

addPhase(2, "Hash Determinism", function()
	local r = {}
	local cv2 = Cerebro.modules.CommunicationV2

	if not cv2 then
		r[#r + 1] = skip("CommunicationV2 loaded", "Module not available")
		return r
	end
	r[#r + 1] = pass("CommunicationV2 loaded")

	-- ComputeRosterHash
	if not cv2.ComputeRosterHash then
		r[#r + 1] = fail("ComputeRosterHash exists", "Function is nil — HashEngine.lua may not have loaded")
		return r
	end

	cv2:InvalidateHashCache()
	local hash1 = cv2:ComputeRosterHash()
	cv2:InvalidateHashCache()
	local hash2 = cv2:ComputeRosterHash()

	if hash1 == hash2 then
		r[#r + 1] = pass("Roster hash deterministic (" .. tostring(hash1) .. ")")
	else
		r[#r + 1] = fail("Roster hash deterministic", hash1 .. " ~= " .. hash2)
	end

	-- ComputeConfigHash
	if cv2.ComputeConfigHash then
		cv2:InvalidateHashCache()
		local ch1 = cv2:ComputeConfigHash()
		cv2:InvalidateHashCache()
		local ch2 = cv2:ComputeConfigHash()
		if ch1 == ch2 then
			r[#r + 1] = pass("Config hash deterministic (" .. tostring(ch1) .. ")")
		else
			r[#r + 1] = fail("Config hash deterministic", ch1 .. " ~= " .. ch2)
		end
	else
		r[#r + 1] = fail("ComputeConfigHash exists", "Function is nil")
	end

	return r
end)

--[[=============================================
	Phase 3: ApplyPlayerMutation Write Gate
===============================================]]

addPhase(3, "Mutation Write Gate (ADR-001)", function()
	local r = {}

	-- Check gate function exists
	if not Cerebro.ApplyPlayerMutation then
		r[#r + 1] = fail("ApplyPlayerMutation exists", "Function is nil on Cerebro")
		return r
	end
	r[#r + 1] = pass("ApplyPlayerMutation exists")

	-- Create scratch player for testing
	local db = Cerebro.db.global
	local hadScratch = db.players[SCRATCH_PLAYER] ~= nil
	db.players[SCRATCH_PLAYER] = {
		ep = 0, gp = 200, pr = 0, status = "Pending",
		lastUpdate = 0, lastSeen = 0,
		bossKillsPresent = 0, eligibleBossKills = 0,
		attendance = { total = 0, onTime = 0, prepared = 0, full = 0 },
	}
	r[#r + 1] = pass("Scratch player created")

	-- Test 1: Award EP via gate
	local data = Cerebro:ApplyPlayerMutation(SCRATCH_PLAYER, { ep = 500 })
	if data and data.ep == 500 then
		r[#r + 1] = pass("EP mutation applied (ep=500)")
	else
		r[#r + 1] = fail("EP mutation applied", "ep=" .. tostring(data and data.ep))
	end

	-- Test 2: lastUpdate was set (not 0)
	if data and data.lastUpdate and data.lastUpdate > 0 then
		r[#r + 1] = pass("lastUpdate auto-set (" .. tostring(data.lastUpdate) .. ")")
	else
		r[#r + 1] = fail("lastUpdate auto-set", "lastUpdate=" .. tostring(data and data.lastUpdate))
	end

	-- Test 3: PR cache updated
	if data and data.pr and data.pr > 0 then
		r[#r + 1] = pass("PR cache updated (pr=" .. string.format("%.4f", data.pr) .. ")")
	else
		r[#r + 1] = fail("PR cache updated", "pr=" .. tostring(data and data.pr))
	end

	-- Test 4: GP mutation
	Cerebro:ApplyPlayerMutation(SCRATCH_PLAYER, { gp = 400 })
	data = db.players[SCRATCH_PLAYER]
	if data and data.gp == 400 then
		r[#r + 1] = pass("GP mutation applied (gp=400)")
	else
		r[#r + 1] = fail("GP mutation applied", "gp=" .. tostring(data and data.gp))
	end

	-- Test 5: Status mutation
	Cerebro:ApplyPlayerMutation(SCRATCH_PLAYER, { status = "Core" })
	data = db.players[SCRATCH_PLAYER]
	if data and data.status == "Core" then
		r[#r + 1] = pass("Status mutation applied (Core)")
	else
		r[#r + 1] = fail("Status mutation applied", "status=" .. tostring(data and data.status))
	end

	-- Test 6: preserveTimestamp option
	local tsBefore = data.lastUpdate
	Cerebro:ApplyPlayerMutation(SCRATCH_PLAYER, { bossKillsPresent = 5 }, { preserveTimestamp = true })
	data = db.players[SCRATCH_PLAYER]
	if data and data.lastUpdate == tsBefore then
		r[#r + 1] = pass("preserveTimestamp respected")
	else
		r[#r + 1] = fail("preserveTimestamp respected", "lastUpdate changed from " .. tostring(tsBefore))
	end

	-- Test 7: Multi-field mutation
	Cerebro:ApplyPlayerMutation(SCRATCH_PLAYER, { ep = 0, gp = 200, status = "Pending" })
	data = db.players[SCRATCH_PLAYER]
	if data and data.ep == 0 and data.gp == 200 and data.status == "Pending" then
		r[#r + 1] = pass("Multi-field mutation (reset)")
	else
		r[#r + 1] = fail("Multi-field mutation", string.format("ep=%s gp=%s status=%s",
			tostring(data and data.ep), tostring(data and data.gp), tostring(data and data.status)))
	end

	-- Cleanup: remove scratch player
	if not hadScratch then
		db.players[SCRATCH_PLAYER] = nil
	end
	r[#r + 1] = pass("Scratch player cleaned up")

	return r
end)

--[[=============================================
	Phase 4: Config Integrity
===============================================]]

addPhase(4, "Config Integrity", function()
	local r = {}
	local config = Cerebro.db and Cerebro.db.global and Cerebro.db.global.config

	if not config then
		r[#r + 1] = fail("Config exists", "db.global.config is nil")
		return r
	end
	r[#r + 1] = pass("Config exists")

	-- Check critical config sections
	local required = { "epgp", "sync" }
	for _, section in ipairs(required) do
		if config[section] then
			r[#r + 1] = pass("Config section: " .. section)
		else
			r[#r + 1] = fail("Config section: " .. section, "Missing")
		end
	end

	-- Check critical EPGP values
	if config.epgp then
		if config.epgp.baseGP and config.epgp.baseGP > 0 then
			r[#r + 1] = pass("baseGP = " .. tostring(config.epgp.baseGP))
		else
			r[#r + 1] = fail("baseGP", tostring(config.epgp.baseGP))
		end
		if config.epgp.decayRate and config.epgp.decayRate >= 0 and config.epgp.decayRate <= 1 then
			r[#r + 1] = pass("decayRate = " .. tostring(config.epgp.decayRate))
		else
			r[#r + 1] = fail("decayRate", tostring(config.epgp.decayRate))
		end
	end

	return r
end)

--[[=============================================
	Phase 5: RBAC Smoke Test
===============================================]]

addPhase(5, "RBAC Smoke Test", function()
	local r = {}

	-- IsOfficerSafe should return a boolean
	local isOfficer = Cerebro:IsOfficerSafe()
	if type(isOfficer) == "boolean" then
		r[#r + 1] = pass("IsOfficerSafe() returns boolean (" .. tostring(isOfficer) .. ")")
	else
		r[#r + 1] = fail("IsOfficerSafe() returns boolean", type(isOfficer))
	end

	-- IsOfficer with a fake name should return false (fails closed)
	local fakeResult = Cerebro:IsOfficer("FakePlayer-FakeRealm")
	if fakeResult == false then
		r[#r + 1] = pass("IsOfficer(fake) fails closed (false)")
	else
		r[#r + 1] = fail("IsOfficer(fake) fails closed", tostring(fakeResult))
	end

	return r
end)

--[[=============================================
	Phase 6: Purge Ledger Integrity
===============================================]]

addPhase(6, "Purge Ledger Integrity", function()
	local r = {}
	local purged = Cerebro.db and Cerebro.db.global and Cerebro.db.global.purgedPlayers
	local players = Cerebro.db and Cerebro.db.global and Cerebro.db.global.players

	if not purged then
		r[#r + 1] = skip("Purge ledger exists", "No purgedPlayers table (may be empty)")
		return r
	end

	local violations = {}
	for name, _ in pairs(purged) do
		if players and players[name] then
			violations[#violations + 1] = name
		end
	end

	if #violations > 0 then
		r[#r + 1] = fail("Purged players absent from roster", table.concat(violations, ", "))
	else
		local count = 0
		for _ in pairs(purged) do count = count + 1 end
		r[#r + 1] = pass("Purge ledger clean (" .. count .. " entries, none in roster)")
	end

	return r
end)

--[[=============================================
	Phase 7: Command Registry
===============================================]]

addPhase(7, "Command Registry", function()
	local r = {}
	local registry = _G.UI_CommandRegistry

	if not registry then
		r[#r + 1] = fail("CommandRegistry loaded", "_G.UI_CommandRegistry is nil")
		return r
	end
	r[#r + 1] = pass("CommandRegistry loaded")

	local commands = registry:GetAll()
	if not commands or #commands == 0 then
		r[#r + 1] = fail("Commands registered", "GetAll() returned empty")
		return r
	end
	r[#r + 1] = pass("Commands registered (" .. #commands .. " total)")

	-- Verify critical commands exist
	local critical = { "show_status", "self_test", "toggle_ui", "award_points", "start_session", "admit_player", "sync_data" }
	local cmdIndex = {}
	for _, cmd in ipairs(commands) do
		cmdIndex[cmd.id] = true
	end
	for _, cmdId in ipairs(critical) do
		if cmdIndex[cmdId] then
			r[#r + 1] = pass("Command: " .. cmdId)
		else
			r[#r + 1] = fail("Command: " .. cmdId, "Not found in registry")
		end
	end

	return r
end)

--[[=============================================
	Phase 8: Sync Path Smoke Test
===============================================]]

addPhase(8, "Sync Path Smoke Test", function()
	local r = {}
	local cv2 = Cerebro.modules.CommunicationV2

	if not cv2 then
		r[#r + 1] = skip("CommunicationV2 module", "Not loaded")
		return r
	end

	-- Verify V1 Communication module is NOT loaded (Phase 3 V1 kill)
	local v1 = Cerebro.modules.Communication
	if v1 then
		r[#r + 1] = fail("V1 protocol retired", "Communication module still loaded — should be removed from TOC")
	else
		r[#r + 1] = pass("V1 protocol retired (Communication module not loaded)")
	end

	-- Verify utilities migrated to Cerebro (Phase 3)
	if Cerebro.IsQuarantined then
		r[#r + 1] = pass("IsQuarantined on Cerebro (migrated from V1)")
	else
		r[#r + 1] = fail("IsQuarantined on Cerebro", "Function not found — migration incomplete")
	end

	if Cerebro.IsPlayerReachable then
		r[#r + 1] = pass("IsPlayerReachable on Cerebro (migrated from V1)")
	else
		r[#r + 1] = fail("IsPlayerReachable on Cerebro", "Function not found — migration incomplete")
	end

	-- Check critical functions exist (verifies decomposition + migration wiring)
	local criticalFns = {
		"ComputeRosterHash", "ComputeConfigHash", "SimpleHash", "InvalidateHashCache",
		"SendHeartbeat", "HandleHeartbeat", "SendFetch", "HandleFetch",
		"BuildSnapshot", "HandleSnapshot", "MergeRosterData", "MergeConfigData",
		"SendRPC", "HandleRPC", "BroadcastEPGPUpdate", "BroadcastSessionStart",
		"SendV2Message", "GetSyncStatus", "SyncToRaid",
		-- Migrated V1 handlers (Phase 3)
		"HandleEPGPUpdate", "HandleReset", "HandleAnnounceSelf",
		"HandleBossKill", "HandleDecay", "HandleVersionCheck",
	}

	local missingFns = {}
	for _, fn in ipairs(criticalFns) do
		if not cv2[fn] then
			missingFns[#missingFns + 1] = fn
		end
	end

	if #missingFns > 0 then
		r[#r + 1] = fail("V2 functions available (" .. #criticalFns .. " checked)",
			"Missing: " .. table.concat(missingFns, ", "))
	else
		r[#r + 1] = pass("V2 functions available (" .. #criticalFns .. "/" .. #criticalFns .. ")")
	end

	-- BuildSnapshot should return a valid table
	local ok, snapshot = pcall(function() return cv2:BuildSnapshot() end)
	if ok and type(snapshot) == "table" then
		local hasPlayers = snapshot.players ~= nil
		local hasConfig = snapshot.config ~= nil
		r[#r + 1] = pass("BuildSnapshot returns table (players=" .. tostring(hasPlayers) .. ", config=" .. tostring(hasConfig) .. ")")
	else
		r[#r + 1] = fail("BuildSnapshot returns table", tostring(snapshot))
	end

	-- GetSyncStatus should return without error
	local ok2, status = pcall(function() return cv2:GetSyncStatus() end)
	if ok2 then
		r[#r + 1] = pass("GetSyncStatus returns cleanly")
	else
		r[#r + 1] = fail("GetSyncStatus returns cleanly", tostring(status))
	end

	return r
end)

--[[=============================================
	Phase 9: Ledger Consistency
===============================================]]

addPhase(9, "Ledger Consistency", function()
	local r = {}
	local ledger = Cerebro.Ledger

	if not ledger then
		r[#r + 1] = skip("Ledger module", "Not loaded")
		return r
	end

	local transactions = Cerebro.db.global.transactions
	if not transactions then
		r[#r + 1] = skip("Transaction log", "No transactions table")
		return r
	end
	r[#r + 1] = pass("Transaction log exists (" .. #transactions .. " entries)")

	-- Check for duplicate UUIDs
	local seen = {}
	local dupes = 0
	for _, t in ipairs(transactions) do
		if t.id then
			if seen[t.id] then
				dupes = dupes + 1
			end
			seen[t.id] = true
		end
	end

	if dupes > 0 then
		r[#r + 1] = fail("UUID uniqueness", dupes .. " duplicate UUIDs found")
	else
		r[#r + 1] = pass("UUID uniqueness (0 duplicates)")
	end

	return r
end)

--[[=============================================
	Test Runner
===============================================]]

function Validate:RunAll()
	results = {}
	local totalPass, totalFail, totalSkip = 0, 0, 0

	for _, phase in ipairs(testPhases) do
		local phaseResults = phase.tests()
		for _, result in ipairs(phaseResults) do
			result.phase = phase.id
			result.phaseName = phase.name
			results[#results + 1] = result
			if result.status == "PASS" then totalPass = totalPass + 1
			elseif result.status == "FAIL" then totalFail = totalFail + 1
			else totalSkip = totalSkip + 1 end
		end
	end

	return results, totalPass, totalFail, totalSkip
end

function Validate:RunPhase(phaseId)
	results = {}
	local totalPass, totalFail, totalSkip = 0, 0, 0

	for _, phase in ipairs(testPhases) do
		if phase.id == phaseId then
			local phaseResults = phase.tests()
			for _, result in ipairs(phaseResults) do
				result.phase = phase.id
				result.phaseName = phase.name
				results[#results + 1] = result
				if result.status == "PASS" then totalPass = totalPass + 1
				elseif result.status == "FAIL" then totalFail = totalFail + 1
				else totalSkip = totalSkip + 1 end
			end
			break
		end
	end

	return results, totalPass, totalFail, totalSkip
end

--[[=============================================
	Results Formatting
===============================================]]

-- Color codes for future rich-text rendering (used by ShowResultsFrame)
-- luacheck: ignore STATUS_COLORS
local STATUS_COLORS = {
	PASS = "|cff00ff00",  -- green
	FAIL = "|cffff0000",  -- red
	SKIP = "|cff888888",  -- gray
}

function Validate:FormatResults(testResults, totalPass, totalFail, totalSkip)
	local lines = {}
	local total = totalPass + totalFail + totalSkip

	-- Summary bar
	lines[#lines + 1] = "=== Cerebro Validate ==="
	lines[#lines + 1] = string.format("%d/%d passed | %d failed | %d skipped",
		totalPass, total, totalFail, totalSkip)
	lines[#lines + 1] = "Version: " .. (Cerebro.version or "?") .. " | " .. date("%Y-%m-%d %H:%M:%S")
	lines[#lines + 1] = ""

	local currentPhase = nil
	for _, result in ipairs(testResults) do
		if result.phaseName ~= currentPhase then
			currentPhase = result.phaseName
			lines[#lines + 1] = "--- Phase " .. result.phase .. ": " .. currentPhase .. " ---"
		end

		local icon = result.status == "PASS" and "[PASS]" or result.status == "FAIL" and "[FAIL]" or "[SKIP]"
		local line = "  " .. icon .. " " .. result.name
		if result.detail then
			line = line .. "  (" .. result.detail .. ")"
		end
		lines[#lines + 1] = line
	end

	lines[#lines + 1] = ""
	if totalFail == 0 then
		lines[#lines + 1] = "=== ALL PASSED ==="
	else
		lines[#lines + 1] = "=== " .. totalFail .. " FAILURE(S) — INVESTIGATE BEFORE DEPLOYING ==="
	end

	return table.concat(lines, "\n")
end

--[[=============================================
	UI Frame (AceGUI, similar to /cb support)
===============================================]]

function Validate:ShowResultsFrame(testResults, totalPass, totalFail, totalSkip)
	-- Single instance
	if self._frame then
		self._frame:Release()
		self._frame = nil
	end
	if _G["CerebroValidateFrame"] then
		for i = #UISpecialFrames, 1, -1 do
			if UISpecialFrames[i] == "CerebroValidateFrame" then
				table.remove(UISpecialFrames, i)
				break
			end
		end
		_G["CerebroValidateFrame"] = nil
	end

	local total = totalPass + totalFail + totalSkip
	local frame = AceGUI:Create("Frame")
	frame:SetTitle("Cerebro — Validation Results")
	frame:SetWidth(650)
	frame:SetHeight(520)
	frame:SetLayout("Flow")
	frame:SetCallback("OnClose", function(widget)
		widget:Release()
		self._frame = nil
		_G["CerebroValidateFrame"] = nil
	end)

	_G["CerebroValidateFrame"] = frame.frame
	tinsert(UISpecialFrames, "CerebroValidateFrame")

	-- Summary header
	local summaryColor = totalFail == 0 and "|cff00ff00" or "|cffff0000"
	local header = AceGUI:Create("Label")
	header:SetText(summaryColor .. totalPass .. "/" .. total .. " passed|r | " ..
		"|cffff0000" .. totalFail .. " failed|r | " ..
		"|cff888888" .. totalSkip .. " skipped|r")
	header:SetFullWidth(true)
	header:SetFontObject(GameFontNormalLarge)
	frame:AddChild(header)

	-- Timestamp
	local ts = AceGUI:Create("Label")
	ts:SetText("Cerebro v" .. (Cerebro.version or "?") .. " | " .. date("%Y-%m-%d %H:%M:%S"))
	ts:SetFullWidth(true)
	frame:AddChild(ts)

	local spacer = AceGUI:Create("Label")
	spacer:SetText(" ")
	spacer:SetFullWidth(true)
	frame:AddChild(spacer)

	-- Results text (colored)
	local reportText = self:FormatResults(testResults, totalPass, totalFail, totalSkip)

	local editBox = AceGUI:Create("MultiLineEditBox")
	editBox:SetLabel("")
	editBox:SetText(reportText)
	editBox:SetFullWidth(true)
	editBox:SetNumLines(24)
	editBox:DisableButton(true)
	frame:AddChild(editBox)

	-- Buttons
	local selectBtn = AceGUI:Create("Button")
	selectBtn:SetText("Select All")
	selectBtn:SetWidth(140)
	selectBtn:SetCallback("OnClick", function()
		editBox:SetFocus()
		editBox.editBox:HighlightText()
	end)
	frame:AddChild(selectBtn)

	local rerunBtn = AceGUI:Create("Button")
	rerunBtn:SetText("Re-Run All")
	rerunBtn:SetWidth(140)
	rerunBtn:SetCallback("OnClick", function()
		local r, p, f, s = self:RunAll()
		self:ShowResultsFrame(r, p, f, s)
	end)
	frame:AddChild(rerunBtn)

	self._frame = frame
end

--[[=============================================
	Entry Point
===============================================]]

function Validate:Run(phaseArg)
	local testResults, totalPass, totalFail, totalSkip

	if phaseArg and tonumber(phaseArg) then
		testResults, totalPass, totalFail, totalSkip = self:RunPhase(tonumber(phaseArg))
	else
		testResults, totalPass, totalFail, totalSkip = self:RunAll()
	end

	self:ShowResultsFrame(testResults, totalPass, totalFail, totalSkip)
end
