--[[
	Cerebro - Sync Hash Engine
	Extracted from CommunicationV2.lua (Phase 2a decomposition)

	Produces deterministic hashes from game state for divergence detection.
	PR and Attendance% are EXCLUDED (derived fields — §1.6).
	Class, lastSeen, lifetimeEarned/Spent are EXCLUDED (display-only).

	@module Sync.HashEngine
	@version 0.12.0
	@invariant ComputeRosterHash excludes lastUpdate recursively (prevents hash ping-pong)
	@invariant Roster hash includes only participants (status=Core OR ep>0 OR gp>baseGP)
	@invariant ComputeConfigHash excludes lastUpdate at ALL nesting depths
	@invariant SimpleHash is deterministic across clients (DJB2 variant, mod 2^32)
]]--

local AddonName = "Cerebro"
local Cerebro = LibStub("AceAddon-3.0"):GetAddon(AddonName)
local CommunicationV2 = Cerebro:GetModule("CommunicationV2")

--[[=============================================
	Canonical Hash Functions
===============================================]]

--- Compute a deterministic hash of all player data.
-- Iterates players sorted by name. Each player contributes a canonical line.
-- Returns a hex string (CRC32-style via simple polynomial hash).
function CommunicationV2:ComputeRosterHash()
	-- Frame-level cache: avoid recomputing within same heartbeat cycle
	local now = GetTime()
	if self._rosterHashCache and self._rosterHashCacheAt == now then
		return self._rosterHashCache, self._rosterHashPlayerCount
	end

	-- CRITICAL: Use same filtered player set as BuildSnapshot/GetFilteredPlayersForSync.
	-- This ensures the hash matches the payload (excludes synthetics).
	local players = Cerebro:GetFilteredPlayersForSync()
	local baseGP = (Cerebro.db.global.config.epgp and Cerebro.db.global.config.epgp.baseGP) or 200

	-- Collect and sort participant names for deterministic order.
	-- PARTICIPANT FILTER: Only players with real EPGP data drive the hash.
	-- Ghost Pending stubs (EP=0, GP=baseGP, status=Pending) are excluded so
	-- different clients holding different ghost records cannot produce a permanent
	-- hash mismatch. Non-participants still travel in snapshot payloads for UI display.
	local names = {}
	for name, pData in pairs(players) do
		if Cerebro:IsParticipant(pData, baseGP) then
			names[#names + 1] = name
		end
	end
	table.sort(names)

	-- Build canonical string
	local parts = {}
	for _, name in ipairs(names) do
		local p = players[name]
		-- Canonical line: deterministic field order, coerced to safe defaults
		-- Fields included: ep, gp, status, lastUpdate
		-- Fields EXCLUDED: pr (derived), class (display), lastSeen (display),
		--                  lifetimeEarned/Spent (display), role (display),
		--                  bossKillsPresent (display), attendance (display),
		--                  eligibleBossKills (derived)
		-- NOTE: Attendance fields excluded because eligibleBossKills diverges across
		--       clients after resets, causing permanent hash mismatches +
		--       infinite sync push loops. They still sync via snapshot payloads.
		parts[#parts + 1] = string.format("%s:%.0f:%.0f:%s:%.0f",
			name,
			p.ep or 0,
			p.gp or 0,
			p.status or "Pending",
			p.lastUpdate or 0
		)
	end

	-- Include active session ID in hash so session start/end triggers divergence detection
	local sessionID = Cerebro.db.global.currentSession and Cerebro.db.global.currentSession.sessionID or "NONE"
	parts[#parts + 1] = "session:" .. sessionID

	-- NOTE: selection state (customEP.lastUpdate) is intentionally excluded from the roster hash.
	-- Selection has its own LWW merge path (MergeSelectionData) and its timestamp legitimately
	-- differs between officers (each officer may have a different active selection).
	-- Including it here caused permanent officer↔officer hash divergence and infinite FETCH loops.

	local canonical = table.concat(parts, "|")
	local hash = self:SimpleHash(canonical)

	-- Cache result for this frame
	self._rosterHashCache = hash
	self._rosterHashPlayerCount = #names
	self._rosterHashCacheAt = now

	return hash, #names
end

--- Compute a deterministic hash of synced config fields.
-- Uses dynamic recursive serialization so ALL config fields are covered
-- automatically. No maintenance needed when adding new settings.
--
-- EXCLUDED from hash (local-only, not synced):
--   debug, debugLevel, ui, configVersion, lastSeenConfigVersion,
--   configLastUpdate (merge-only timestamp, not content)
--
-- NOTE: If features like signupStatuses or epSignUp are re-implemented
-- in a future version, they will be automatically included via the
-- dynamic serializer. No code change needed.
function CommunicationV2:ComputeConfigHash()
	-- Frame-level cache: avoid recomputing within same heartbeat cycle
	local now = GetTime()
	if self._configHashCache and self._configHashCacheAt == now then
		return self._configHashCache
	end

	local config = Cerebro.db.global.config or {}

	-- Fields that are local-only and must NOT be hashed (top-level sections)
	local EXCLUDED_KEYS = {
		debug = true, debugLevel = true, ui = true,
		configVersion = true, lastSeenConfigVersion = true,
		configLastUpdate = true,  -- merge-only LWW timestamp
	}

	-- Fields excluded at ANY nesting depth (merge-metadata, not content).
	-- `lastUpdate` inside config.sync (or any future section) is a LWW timestamp
	-- used only by MergeConfigData — it must NOT contribute to the content hash.
	-- Without this exclusion: two officers with identical settings but different
	-- save timestamps produce different hashes → permanent config ping-pong.
	local EXCLUDED_NESTED_KEYS = {
		lastUpdate = true,
	}

	local parts = {}

	-- Recursively serialize a table with sorted keys for deterministic output.
	-- Coerces all keys to strings and sorts them to ensure identical ordering
	-- regardless of Lua table iteration order.
	local function serialize(tbl, prefix)
		local keys = {}
		for k in pairs(tbl) do
			if not EXCLUDED_NESTED_KEYS[k] then
				keys[#keys + 1] = tostring(k)
			end
		end
		table.sort(keys)
		for _, k in ipairs(keys) do
			-- Look up with both string and number key (AceComm may deliver
			-- table keys as numbers even when stored as strings)
			local v = tbl[k]
			if v == nil then v = tbl[tonumber(k)] end
			local fullKey = prefix .. "." .. k
			if type(v) == "table" then
				serialize(v, fullKey)
			elseif type(v) == "number" then
				-- Use %.6g to handle both integers and decimals deterministically
				parts[#parts + 1] = string.format("%s=%.6g", fullKey, v)
			else
				parts[#parts + 1] = fullKey .. "=" .. tostring(v)
			end
		end
	end

	-- Hash only synced config sections (exclude local-only: debug, ui, etc.)
	-- IMPORTANT: Must sort section names for deterministic output across clients.
	-- pairs() iteration order is non-deterministic in Lua.
	local sections = {}
	for section, value in pairs(config) do
		if type(value) == "table" and not EXCLUDED_KEYS[section] then
			sections[#sections + 1] = section
		end
	end
	table.sort(sections)
	for _, section in ipairs(sections) do
		serialize(config[section], section)
	end

	local canonical = table.concat(parts, "|")
	local hash = self:SimpleHash(canonical)

	-- Cache result for this frame
	self._configHashCache = hash
	self._configHashCacheAt = now

	return hash
end

--- Simple polynomial hash function (DJB2 variant).
-- Returns a hex string. Deterministic across clients.
-- Not cryptographic — purely for state comparison.
function CommunicationV2:SimpleHash(str)
	local hash = 5381
	for i = 1, #str do
		hash = ((hash * 33) + string.byte(str, i)) % 4294967296  -- mod 2^32
	end
	return string.format("%08x", hash)
end

--- Invalidate all hash caches. Call when underlying data changes
-- outside the normal mutation paths (e.g., factory reset, bulk import).
function CommunicationV2:InvalidateHashCache()
	self._rosterHashCache = nil
	self._rosterHashCacheAt = nil
	self._configHashCache = nil
	self._configHashCacheAt = nil
end
