--[=[
	Cerebro - Preparation Module
	
	PURPOSE:
	The Preparation module orchestrates raid-wide readiness checks (gear quality, enchants,
	gems, consumables) and awards "Preparation Bonus" EP to eligible players. It implements
	asynchronous inspection pipelines with timeout handling and RBAC-enforced award logic.
	
	RESPONSIBILITIES:
	1. CheckRaid - Officer-only raid-wide inspection (async pipeline with timeout)
	2. CheckPersonalReadiness - Public personal readiness check (sync, UI-facing)
	3. Inspection Pipeline - Queue management, INSPECT_READY handling, graceful timeouts
	4. Award Orchestration - Roster-bound EP awards via EPGP:AwardReadinessEP
	5. Reporting - Officer chat + UI feedback for readiness status
	
	CONTRACTS (per docs/SPEC.md):
	
	# CheckRaid() -> void
	  - RBAC: MUST enforce IsOfficer() with hard gate at entry
	  - Idempotency: MUST reject if scan already in progress (self.isScanning == true)
	  - Timeout: MUST complete within 60s overall (2s per unit + queue processing)
	  - Side Effects: Broadcasts report to officer chat, awards EP to prepared players
	  - Award Eligibility: Respect config.prep toggles (requireGems/Enchants/Consumables)
	  - Award Bounds: Only award if session.roster[player] exists (roster-bound, conservative)
	  - Session Dedupe: MUST only award once per session (session.prepAwarded flag)
	  
	# CheckPersonalReadiness() -> boolean, table
	  - Public: No officer requirement
	  - Returns: isReady (boolean), report { gems, enchants, score }
	  - Note: Consumables excluded from personal readiness indicator per user request
	  
	# IsPrepared(result, config) -> boolean
	  - Helper: Determines if player passes prep check based on config toggles
	  - Logic: Uses proportional counts (gemCount, enchantCount) and per-category flags
	  - Returns: true if player meets ALL enabled requirements
	
	INSPECTION PIPELINE:
	1. BuildQueue: Enumerate raid members, prioritize self (sync check), queue others
	2. ProcessNextInspect: Pop unit, NotifyInspect(unit), set 2s timeout
	3. OnInspectReady: Cancel timeout, run CheckPlayer, record result, advance queue
	4. Timeout Handler: Record "unknown" result, clear inspect, advance queue
	5. FinishScan: Aggregate results, run report, award EP if session active
	
	INVARIANTS:
	1. self.isScanning MUST be cleared on all exit paths (success, timeout, error, early return)
	2. ClearInspectPlayer() MUST be called before NotifyInspect() on next unit (WoW API requirement)
	3. session.prepAwarded MUST only be set AFTER all awards complete (atomic commit)
	4. Awards MUST validate session.roster[player] exists (roster-bound safety)
	5. Config toggles MUST be respected for award eligibility (no hardcoded assumptions)
	
	DESIGN DECISIONS:
	- 2s per-unit timeout: Balance between network latency and scan speed
	- 60s overall scan bound: Prevent infinite hangs in edge cases
	- Self-inspection is synchronous: No NotifyInspect() needed, no timeout risk
	- Report-only mode: If no session/roster, run report but skip awards (safe degradation)
	- Consumables excluded from personal check: Per user request ("Gems, Enchants Checked. No consumables")
	
	SECURITY MODEL:
	- CheckRaid: RBAC enforced at entry (hard gate), no soft checks
	- CheckPersonalReadiness: Public API, safe for all players
	- Award logic: Double-gated (IsOfficer in CheckRaid + session.roster validation)
	
	EXTERNAL DEPENDENCIES:
	- EPGP:AwardReadinessEP (Money! Audit trail via Ledger)
	- Session:GetCurrentSession (State check, roster validation)
	- Communication:SendOfficerMessage (Reporting)
	- WoW API: NotifyInspect, ClearInspectPlayer, GetInventoryItemLink, C_UnitAuras (11.0+) / UnitBuff (fallback)
	
	TEST COVERAGE:
	- TEST-PREP-001: Inspect logic (mocked gear slots)
	- TEST-PREP-002: Consumable checks (mocked UnitBuff)
	- TEST-PREP-003: Award flow (mocked EPGP:AwardReadinessEP)
	- TEST-PREP-004: Idempotent scan lock
	- TEST-PREP-005: Timeout behavior
	- TEST-PREP-006: Config toggle respect
	- TEST-PREP-007: Report-only mode (no session/roster)
	- PREP-SMOKE-001: Officer runs /cb prep in raid
	- PREP-SMOKE-002: Award idempotency (second check same session)
]=]

local AddonName = "Cerebro"
local Cerebro = LibStub("AceAddon-3.0"):GetAddon(AddonName)
-- Register as AceModule (lifecycle hooks: OnInitialize, OnEnable + AceEvent/AceTimer mixins)
local Preparation = Cerebro:NewModule("Preparation", "AceEvent-3.0", "AceTimer-3.0")
-- Also register on Cerebro.modules table for safe nil-check access by other modules.
-- Callers use Cerebro.modules.Preparation (returns nil if missing) instead of
-- Cerebro:GetModule("Preparation") (throws error if missing). This is the
-- established pattern across all Cerebro modules (CommunicationV2, Session, etc.).
Cerebro.modules.Preparation = Preparation

-- Retry constants (must be declared before first use in GROUP_ROSTER_UPDATE handler)
local PREP_MAX_RETRIES = 4
local PREP_RETRY_INTERVAL = 15  -- seconds per pass

function Preparation:OnInitialize()
	self.inspectQueue = {}
	self.inspectResults = {}
	self.isScanning = false
	self.currentUnit = nil
	self.scanTimeout = nil
	self.scanStartTime = nil
	self.retryTracker = {}   -- Track which units have been retried
	self.totalToScan = 0     -- Total players to scan (for progress)
	self.scannedCount = 0    -- Players scanned so far
	self._lastKnownRaidNames = {}  -- Tracks raid members for new-player detection
end

function Preparation:OnEnable()
	-- Watch for group composition changes to detect new players mid-session
	self:RegisterEvent("GROUP_ROSTER_UPDATE", "OnGroupRosterUpdate")
end

-- Detect new players joining the raid during an active session.
-- Automatically sends a targeted PREP_REQUEST for unchecked players.
function Preparation:OnGroupRosterUpdate()
	if not IsInRaid() then return end
	if not Cerebro:IsOfficerSafe() then return end

	-- Only care during an active session
	local session = Cerebro.modules.Session and Cerebro.modules.Session:GetCurrentSession()
	if not session then return end

	-- Build current raid names
	local currentNames = {}
	for i = 1, GetNumGroupMembers() do
		local unit = "raid" .. i
		if UnitExists(unit) and UnitIsConnected(unit) then
			local name = UnitName(unit)
			if name then
				local fullName = Cerebro:GetFullPlayerName(name)
				currentNames[fullName] = true
			end
		end
	end

	-- Compare against last known roster to find new joiners
	local uncheckedTargets = {}
	local uncheckedNames = {}
	for name in pairs(currentNames) do
		if not self._lastKnownRaidNames[name] then
			-- Check if this player has NOT been prep-checked this session
			local prepResults = session.prepResults or {}
			if not prepResults[name] then
				uncheckedTargets[name] = true
				table.insert(uncheckedNames, Cerebro:GetShortPlayerName(name) or name)
			end
		end
	end

	-- Update last known roster
	self._lastKnownRaidNames = currentNames

	-- Auto-prep-check unchecked new players
	if #uncheckedNames > 0 then
		Cerebro:Print(string.format("|cff00ccff[Prep]|r %d new player(s) joined — auto-checking: %s",
			#uncheckedNames, table.concat(uncheckedNames, ", ")))

		-- Send targeted PREP_REQUEST for just the new players
		local commV2 = Cerebro.modules.CommunicationV2
		if commV2 and not self.isScanning then
			-- Merge new targets into existing checkTargets if a scan is pending,
			-- otherwise just broadcast for the new players
			local payload = {
				requestedBy = Cerebro:GetPlayerFullName(),
				targets = uncheckedTargets,
			}
			if IsInRaid() then
				commV2:SendRPC("PREP_REQUEST", payload, "RAID")
			end
			if IsInGuild() then
				commV2:SendRPC("PREP_REQUEST", payload, "GUILD")
			end

			-- Set up a mini-scan to collect responses
			self.isScanning = true
			self.scanStartTime = time()
			self.retryCount = 0
			self.checkTargets = uncheckedTargets
			self.inspectResults = {}
			self.expectedResponses = #uncheckedNames

			self.scanTimeout = self:ScheduleTimer("FinishScan", PREP_RETRY_INTERVAL)
			Cerebro:Debug(string.format("Prep: Auto-check started for %d new player(s)", #uncheckedNames))
		else
			Cerebro:Debug("Prep: Cannot auto-check — scan already in progress or no comm module")
		end
	end
end

-- Diagnostic: Dump raw item link data for all equipped slots directly to chat
-- Usage: /cb prepdiag (or call Preparation:RunDiagnostic())
function Preparation:RunDiagnostic()
	local patchDB = Cerebro.PrepCheckDB and Cerebro.PrepCheckDB["12.0"]
	local slotNames = {
		[1]="Head",[2]="Neck",[3]="Shoulder",[5]="Chest",[6]="Waist",
		[7]="Legs",[8]="Feet",[9]="Wrist",[10]="Hands",[11]="Ring1",
		[12]="Ring2",[13]="Trinket1",[14]="Trinket2",[15]="Back",
		[16]="MainHand",[17]="OffHand",
	}
	Cerebro:Print("=== PrepCheck Diagnostic ===")
	Cerebro:Print("DB loaded: " .. tostring(patchDB ~= nil))

	local slotsToCheck = {1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17}
	for _, slot in ipairs(slotsToCheck) do
		local itemLink = GetInventoryItemLink("player", slot)
		local sn = slotNames[slot] or ("S" .. slot)
		if itemLink then
			local parts = {strsplit(":", itemLink)}
			-- Confirmed positions: [2]=???, [3]=itemID, [4]=enchantEffectID, [5-8]=gemItemIDs
			local itemID = parts[3] or "?"
			local enchRaw = parts[4] or "0"
			local g1 = parts[5] or "0"
			local g2 = parts[6] or "0"
			local g3 = parts[7] or "0"
			local g4 = parts[8] or "0"

			-- DB lookups for gems
			local g1v = ""
			local g1n = tonumber(g1)
			if g1n and g1n > 0 and patchDB and patchDB.gems then
				g1v = patchDB.gems[g1n] and "OK" or "BAD"
			end

			-- Enchant: presence-based (effect IDs differ from item IDs)
			local en = tonumber(enchRaw)
			local ev = (en and en > 0) and "YES" or "NO"

			Cerebro:Print(string.format("[%d] %s id=%s e=%s(%s) g1=%s(%s) g2=%s g3=%s g4=%s",
				slot, sn, itemID, enchRaw, ev, g1, g1v, g2, g3, g4))
		end
	end
	Cerebro:Print("=== End ===")
end



-- Slots that should have enchants
local ENCHANTABLE_SLOTS = {
	[1] = "Head",			-- Not always enchantable in retail
	[2] = "Neck",
	[3] = "Shldr",			-- Not always enchantable in retail
	[4] = "Shirt",
	[5] = "Chest",
	[6] = "Waist",			-- Not enchantable
	[7] = "Legs",
	[8] = "Feet",
	[9] = "Wrist",
	[10] = "Hands",
	[11] = "Ring1",
	[12] = "Ring2",
	[13] = "Trink1",
	[14] = "Trink2",
	[15] = "Back",
	[16] = "MH",
	[17] = "OH",
}

-- Slots that MUST have an item equipped
local PRESENCE_SLOTS = {
	[1] = true, -- Head
	[2] = true, -- Neck
	[3] = true, -- Shoulder
	[5] = true, -- Chest
	[6] = true, -- Waist
	[7] = true, -- Legs
	[8] = true, -- Feet
	[9] = true, -- Wrist
	[10] = true, -- Hands
	[11] = true, -- Ring 1
	[12] = true, -- Ring 2
	[13] = true, -- Trinket 1
	[14] = true, -- Trinket 2
	[15] = true, -- Back
	[16] = true, -- Main Hand
}

-- Helper: Get the patch-specific item database for validation
-- Returns the PrepCheckDB entry for the current patch
local function GetPatchDB()
	return Cerebro.PrepCheckDB and Cerebro.PrepCheckDB["12.0"]
end

-- Helper: Check if a slot has valid enchants defined in PrepCheckDB
-- Uses enchantEffects (the table actually consumed for validation) instead of
-- the deprecated enchants item-ID table which is dead data.
local function IsEnchantableSlot(slot)
	local patchDB = GetPatchDB()
	return patchDB and patchDB.enchantEffects and patchDB.enchantEffects[slot] ~= nil
end

-- Helper: Determine if player passes All-or-Nothing check
-- Only used when cfg.allOrNothing is enabled
-- Returns true if player passes ALL enabled checks perfectly
local function IsFullyPrepared(result, cfg)
	-- Gems: all sockets must have valid gems
	if cfg.requireGems and result.gemMaxSlots > 0 and result.gemCount < result.gemMaxSlots then
		return false
	end
	-- Enchants: all enchantable slots must have valid enchants
	if cfg.requireEnchants and result.enchantMaxSlots > 0 and result.enchantCount < result.enchantMaxSlots then
		return false
	end
	-- Consumables: each must be present
	if cfg.requireFood and not result.hasFood then
		return false
	end
	if cfg.requireFlask and not result.hasFlask then
		return false
	end
	if cfg.requireAugmentRune and not result.hasAugmentRune then
		return false
	end
	if cfg.requireVantusRune and not result.hasVantusRune then
		return false
	end
	if cfg.requireWeaponEnhancement and not result.hasWeaponEnhancement then
		return false
	end
	-- Average iLvl: must meet minimum threshold
	if cfg.requireMinIlvl and result.avgIlvl then
		local minIlvl = cfg.minIlvl or 237
		if result.avgIlvl < minIlvl then
			return false
		end
	end
	return true
end

-- (PREP_MAX_RETRIES and PREP_RETRY_INTERVAL moved to top of file)

function Preparation:CheckRaid()
	-- RBAC: Hard gate (CRITICAL - officer-only execution)
	if not Cerebro:IsOfficer() then
		Cerebro:Debug("Prep: Non-officer attempted CheckRaid, ignoring")
		return
	end

	-- Idempotency: Scan lock
	if self.isScanning then
		Cerebro:Debug("Prep: Scan already in progress, ignoring duplicate request")
		Cerebro:Print("Preparation check already in progress...")
		return
	end

	self.inspectResults = {}
	self.isScanning = true
	self.scanStartTime = time()
	self.retryCount = 0

	-- Build target list from Standings checklist (selected players)
	-- Only checked players get prep-checked — bench/offline players are excluded
	self.checkTargets = {}
	local selectedPlayers = Cerebro.db.global.customEP
		and Cerebro.db.global.customEP.selectedPlayers or {}
	local myName = Cerebro:GetPlayerFullName()
	local hasTargets = false

	local purged = Cerebro.db.global.purgedPlayers or {}
	local blocked = Cerebro.db.global.blockedPlayers or {}

	for name, selected in pairs(selectedPlayers) do
		if selected and not purged[name] and not blocked[name] then
			self.checkTargets[name] = true
			hasTargets = true
		end
	end

	-- Always include self (officer running the check)
	self.checkTargets[myName] = true

	if not hasTargets then
		self.isScanning = false
		Cerebro:Print("|cffff4444No players selected in Standings checklist.|r Select players using the 'Due' checkboxes first.")
		return
	end

	-- Check self synchronously (always instant for own gear)
	local selfResult = self:CheckPlayer("player")
	table.insert(self.inspectResults, selfResult)
	Cerebro:Debug("Prep: Checked self (synchronous)")

	-- Broadcast PREP_REQUEST to all Cerebro clients via both channels.
	-- RAID channel reaches in-group players; GUILD channel reaches benched players
	-- who are checked off in standings but not in the raid group.
	-- All clients receive it, but only checklist players' responses are tracked.
	local commV2 = Cerebro.modules.CommunicationV2
	if commV2 then
		local payload = {
			requestedBy = Cerebro:GetPlayerFullName(),
			targets = self.checkTargets,  -- { [fullName] = true } — responders self-filter
		}
		if IsInRaid() then
			commV2:SendRPC("PREP_REQUEST", payload, "RAID")
			Cerebro:Debug("Prep: Broadcast PREP_REQUEST via RAID")
		end
		if IsInGuild() then
			commV2:SendRPC("PREP_REQUEST", payload, "GUILD")
			Cerebro:Debug("Prep: Broadcast PREP_REQUEST via GUILD")
		end
	else
		Cerebro:Print("Communication module not available. Running self-check only.")
		self:FinishScan()
		return
	end

	-- Count expected responders (excluding self)
	local expectedCount = 0
	for name in pairs(self.checkTargets) do
		if name ~= myName then
			expectedCount = expectedCount + 1
		end
	end
	self.expectedResponses = expectedCount

	Cerebro:Print(string.format("Prep check sent — checking %d players (%s). Collecting responses (pass 1/%d)...",
		expectedCount + 1, IsInRaid() and "raid" or "guild", PREP_MAX_RETRIES + 1))

	-- Set collection timeout
	self.scanTimeout = self:ScheduleTimer("OnScanPassComplete", PREP_RETRY_INTERVAL)
end

-- Called on ALL Cerebro clients when they receive PREP_REQUEST
function Preparation:HandlePrepRequest(sender, data)
	if not data or not data.requestedBy then return end

	-- Don't respond to our own request (we already checked ourselves)
	local myName = Cerebro:GetPlayerFullName()
	if sender == myName then return end

	-- Only respond if I'm on the target list (checklist filtering)
	if data.targets and not data.targets[myName] then
		Cerebro:Debug("Prep: Ignoring PREP_REQUEST — not on target list")
		return
	end

	-- Dedup: ignore duplicate requests within 30 seconds (dual-channel broadcast)
	local now = time()
	if self._lastPrepRequestTime and (now - self._lastPrepRequestTime) < 30 then
		Cerebro:Debug("Prep: Ignoring duplicate PREP_REQUEST (within 30s window)")
		return
	end
	self._lastPrepRequestTime = now

	Cerebro:Debug("Prep: Received PREP_REQUEST from " .. sender .. ", running self-check")

	-- Run CheckPlayer on ourselves (instant, no inspect needed)
	local result = self:CheckPlayer("player")

	-- Notify the player locally about their prep status
	local cfg = Cerebro.db.global.config.prep
	if IsFullyPrepared(result, cfg) then
		Cerebro:Print("|cff00ff00[Prep Check] \226\156\147 You're good to go! All checks passed.|r")
	else
		-- Build a friendly, readable message
		local issues = {}
		if #result.gemsMissing > 0 or #result.gemsWrong > 0 then
			local count = #result.gemsMissing + #result.gemsWrong
			table.insert(issues, "|cffff8800" .. count .. " gem issue(s)|r")
		end
		if #result.enchantsMissing > 0 or #result.enchantsWrong > 0 then
			local count = #result.enchantsMissing + #result.enchantsWrong
			table.insert(issues, "|cffff8800" .. count .. " enchant issue(s)|r")
		end
		for _, c in ipairs(result.consumablesMissing) do
			table.insert(issues, "|cffff4444Missing " .. c.name .. "|r")
		end
		if cfg.requireMinIlvl and result.avgIlvl then
			local minIlvl = cfg.minIlvl or 237
			if result.avgIlvl < minIlvl then
				table.insert(issues, "|cffff4444iLvl " .. result.avgIlvl .. "/" .. minIlvl .. "|r")
			end
		end
		if #issues > 0 then
			Cerebro:Print("|cffff4444[Prep Check] \226\156\151 Not ready:|r " .. table.concat(issues, ", "))
		else
			Cerebro:Print("|cffff8800[Prep Check]|r Some items could not be verified.")
		end
	end

	-- Build lightweight response payload (only EP-relevant fields)
	local response = {
		player = result.player,
		gemCount = result.gemCount,
		gemMaxSlots = result.gemMaxSlots,
		enchantCount = result.enchantCount,
		enchantMaxSlots = result.enchantMaxSlots,
		hasFood = result.hasFood,
		hasFlask = result.hasFlask,
		hasAugmentRune = result.hasAugmentRune,
		hasVantusRune = result.hasVantusRune,
		hasWeaponEnhancement = result.hasWeaponEnhancement,
		-- Include details for officer report and whisper
		gemsMissing = result.gemsMissing,
		gemsWrong = result.gemsWrong,
		gemSlotDetails = result.gemSlotDetails,
		enchantsMissing = result.enchantsMissing,
		enchantsWrong = result.enchantsWrong,
		consumablesMissing = result.consumablesMissing,
		avgIlvl = result.avgIlvl,
	}

	-- Send response directly to the requesting officer via WHISPER.
	-- Each raider sends exactly 1 whisper per scan, avoiding both:
	-- - Broadcast waste (N² message deliveries where only officer processes them)
	-- - WoW's whisper throttle (only triggers on repeated sends to SAME target)
	local commV2 = Cerebro.modules.CommunicationV2
	if commV2 and data.requestedBy then
		commV2:SendRPC("PREP_RESPONSE", response, "WHISPER", data.requestedBy)
		Cerebro:Debug("Prep: Sent PREP_RESPONSE via WHISPER to " .. data.requestedBy)
	end
end

-- Called on the officer who initiated the scan when a player responds
function Preparation:HandlePrepResponse(sender, data)
	if not self.isScanning then
		Cerebro:Debug("Prep: Received PREP_RESPONSE but no scan active, ignoring")
		return
	end
	if not data or not data.player then return end

	-- Dedupe: don't accept the same player twice
	for _, existing in ipairs(self.inspectResults) do
		if existing.player == data.player then
			Cerebro:Debug("Prep: Duplicate response from " .. data.player .. ", ignoring")
			return
		end
	end

	table.insert(self.inspectResults, data)
	Cerebro:Debug(string.format("Prep: Response from %s (gems=%d/%d, enchants=%d/%d)",
		data.player,
		data.gemCount or 0, data.gemMaxSlots or 0,
		data.enchantCount or 0, data.enchantMaxSlots or 0))

	-- Check if we've received all expected responses (early finish)
	local nonResponders = self:GetNonResponders()
	if #nonResponders == 0 then
		Cerebro:Debug("Prep: All raid members responded, finishing early")
		if self.scanTimeout then
			self:CancelTimer(self.scanTimeout)
			self.scanTimeout = nil
		end
		self:FinishScan()
	end
end

-- Get list of raid members who haven't responded yet
function Preparation:GetNonResponders()
	local responded = {}
	for _, result in ipairs(self.inspectResults or {}) do
		responded[result.player] = true
	end

	local missing = {}
	for name in pairs(self.checkTargets or {}) do
		if not responded[name] then
			table.insert(missing, name)
		end
	end
	return missing
end

-- Called after each 15s pass — decides whether to retry or finalize
function Preparation:OnScanPassComplete()
	-- Cancel any pending timeout
	if self.scanTimeout then
		self:CancelTimer(self.scanTimeout)
		self.scanTimeout = nil
	end

	local nonResponders = self:GetNonResponders()

	-- All responded — finalize
	if #nonResponders == 0 then
		Cerebro:Print(string.format("|cff00ff00All %d raid members responded.|r", #self.inspectResults))
		self:FinishScan()
		return
	end

	-- Still have retries left — send targeted requests
	if self.retryCount < PREP_MAX_RETRIES then
		self.retryCount = self.retryCount + 1

		-- Print who's missing
		local shortNames = {}
		for _, name in ipairs(nonResponders) do
			table.insert(shortNames, Cerebro:GetShortPlayerName(name) or name)
		end
		Cerebro:Print(string.format("|cffffcc00[Pass %d/%d]|r %d/%d responded. Retrying %d non-responder(s): %s",
			self.retryCount + 1, PREP_MAX_RETRIES + 1,
			#self.inspectResults, #self.inspectResults + #nonResponders,
			#nonResponders, table.concat(shortNames, ", ")))

		-- Re-broadcast PREP_REQUEST (non-responders will pick it up; responders will dedupe)
		local channel = IsInRaid() and "RAID" or "GUILD"
		local commV2 = Cerebro.modules.CommunicationV2
		if commV2 then
			commV2:SendRPC("PREP_REQUEST", {
				requestedBy = Cerebro:GetPlayerFullName(),
				targets = self.checkTargets,
			}, channel)
		end

		-- Schedule next pass
		self.scanTimeout = self:ScheduleTimer("OnScanPassComplete", PREP_RETRY_INTERVAL)
	else
		-- Out of retries — finalize with what we have
		local shortNames = {}
		for _, name in ipairs(nonResponders) do
			table.insert(shortNames, Cerebro:GetShortPlayerName(name) or name)
		end
		Cerebro:Print(string.format("|cffff4444[Final]|r %d player(s) never responded: %s",
			#nonResponders, table.concat(shortNames, ", ")))
		self:FinishScan()
	end
end

-- Final processing: report results and award EP
function Preparation:FinishScan()
	Cerebro:Debug("Prep: Finalizing results.")

	self.isScanning = false

	-- Cancel any pending timeout
	if self.scanTimeout then
		self:CancelTimer(self.scanTimeout)
		self.scanTimeout = nil
	end

	local elapsed = self.scanStartTime and (time() - self.scanStartTime) or 0
	local rosterSize = 0
	for _ in pairs(self.checkTargets or {}) do rosterSize = rosterSize + 1 end
	Cerebro:Print(string.format("Scan complete: %d/%d responses collected in %ds.",
		#self.inspectResults, rosterSize, elapsed))
	
	local results = self.inspectResults
	local cfg = Cerebro.db.global.config.prep
	local unprepared = {}

	-- Use IsPrepared helper for unprepared list (reporting only)
	-- In proportional mode, "unprepared" means missing ANY enabled check
	for _, result in ipairs(results) do
		if not IsFullyPrepared(result, cfg) then
			table.insert(unprepared, result)
		end
	end

	-- Report results
	self:ReportPreparationResults(results, unprepared)

	-- === Per-Category EP Awards (Session-Scoped, Per-Player) ===
	local session = Cerebro.modules.Session:GetCurrentSession()

	-- Debug: show what session looks like
	Cerebro:Debug("Prep: currentSession = " .. tostring(session))
	if session then
		Cerebro:Debug("Prep: sessionID = " .. tostring(session.sessionID) .. ", roster = " .. tostring(session.roster ~= nil))
	end

	-- Conservative: Only award if session AND roster exist
	if not session then
		Cerebro:Debug("Prep: No active session, running report-only mode (no awards)")
		return
	end
	
	if not session.roster then
		Cerebro:Debug("Prep: Session exists but no roster, running report-only mode (no awards)")
		return
	end

	-- Initialize per-player prep results tracker on the session
	session.prepResults = session.prepResults or {}
	
	-- Roster-bound awards: only award players in session.roster
	local epgp = Cerebro:GetModule("EPGP")
	local totalEP = 0
	local newAwardCount = 0
	local skippedCount = 0
	local fixedCount = 0

	for _, result in ipairs(results) do
		if session.roster[result.player] then
			local existing = session.prepResults[result.player]

			-- Skip players who already passed and were awarded EP this session
			if existing and existing.awardedEP and existing.awardedEP > 0 then
				skippedCount = skippedCount + 1
				Cerebro:Debug(string.format("Prep EP: %s already awarded %d EP this session, skipping",
					result.player, existing.awardedEP))
			else
				local playerEP = 0

				-- === All-or-Nothing Mode ===
				if cfg.allOrNothing and not IsFullyPrepared(result, cfg) then
					Cerebro:Debug(string.format("Prep EP: %s FAILED All-or-Nothing check → 0 EP",
						result.player))
				else
					-- Gem EP: proportional
					if cfg.requireGems and result.gemCount > 0 then
						local gemEP = result.gemCount * (cfg.gemEPPerSocket or 1)
						playerEP = playerEP + gemEP
					end

					-- Enchant EP: proportional
					if cfg.requireEnchants and result.enchantCount > 0 then
						local enchantEP = result.enchantCount * (cfg.enchantEPPerSlot or 1)
						playerEP = playerEP + enchantEP
					end

					-- Consumable EP: flat
					if cfg.requireFood and result.hasFood then
						playerEP = playerEP + (cfg.foodEP or 1)
					end
					if cfg.requireFlask and result.hasFlask then
						playerEP = playerEP + (cfg.flaskEP or 1)
					end
					if cfg.requireAugmentRune and result.hasAugmentRune then
						playerEP = playerEP + (cfg.augmentRuneEP or 1)
					end
					if cfg.requireVantusRune and result.hasVantusRune then
						playerEP = playerEP + (cfg.vantusRuneEP or 1)
					end
					if cfg.requireWeaponEnhancement and result.hasWeaponEnhancement then
						playerEP = playerEP + (cfg.weaponEnhancementEP or 1)
					end
					-- Average iLvl EP: flat (pass/fail)
					if cfg.requireMinIlvl and result.avgIlvl then
						local minIlvl = cfg.minIlvl or 237
						if result.avgIlvl >= minIlvl then
							playerEP = playerEP + (cfg.minIlvlEP or 0)
						end
					end
				end

				-- Track whether this is a player who previously failed and fixed their gear
				local wasFixed = existing and existing.awardedEP == 0 and playerEP > 0

				-- Award EP (or record 0 for failed players)
				if playerEP > 0 then
					session.roster[result.player].prepared = true
					session.roster[result.player].prepEP = playerEP
					epgp:AwardEP(result.player, playerEP, "PREP_CHECK")
					totalEP = totalEP + playerEP
					newAwardCount = newAwardCount + 1
					if wasFixed then
						fixedCount = fixedCount + 1
					end
				end

				-- Store result for this player (even if 0 EP — allows re-check on next run)
				session.prepResults[result.player] = {
					awardedEP = playerEP,
					awardedAt = playerEP > 0 and time() or nil,
					gemCount = result.gemCount,
					gemMaxSlots = result.gemMaxSlots,
					enchantCount = result.enchantCount,
					enchantMaxSlots = result.enchantMaxSlots,
				}
			end
		end
	end

	-- Summary message
	local parts = {}
	if newAwardCount > 0 then
		table.insert(parts, string.format("|cff00ff00%d newly awarded|r", newAwardCount))
	end
	if fixedCount > 0 then
		table.insert(parts, string.format("|cff00ccff%d fixed gear|r", fixedCount))
	end
	if skippedCount > 0 then
		table.insert(parts, string.format("|cff888888%d already awarded|r", skippedCount))
	end
	local failedThisRun = #results - newAwardCount - skippedCount
	if failedThisRun > 0 then
		table.insert(parts, string.format("|cffff4444%d failed|r", failedThisRun))
	end

	Cerebro:Print(string.format("Prep EP: %s (total new EP: %d)",
		table.concat(parts, ", "), totalEP))
end

--[[=============================================
	Personal Check (Public API)
=============================================]]

-- Returns: boolean isReady, table report
function Preparation:CheckPersonalReadiness()
	-- Run check on player
	-- Note: personal check shows gems + enchants status only (no consumables)
	
	local result = self:CheckPlayer("player")
	
	-- Personal readiness: all gem sockets filled with valid gems AND all enchants valid
	local gemsReady = result.gemMaxSlots == 0 or result.gemCount == result.gemMaxSlots
	local enchantsReady = result.enchantMaxSlots == 0 or result.enchantCount == result.enchantMaxSlots
	local isReady = gemsReady and enchantsReady

	return isReady, {
		gems = gemsReady,
		enchants = enchantsReady,
		gemCount = result.gemCount,
		gemMaxSlots = result.gemMaxSlots,
		enchantCount = result.enchantCount,
		enchantMaxSlots = result.enchantMaxSlots,

	}
end

--[[=============================================
	Main Preparation Check
===============================================]]

function Preparation:CheckPlayer(unit)
	unit = unit or "player"

	local player = Cerebro:GetFullPlayerName(UnitName(unit))

	local result = {
		player = player,
		-- Gem scoring (proportional EP — no binary pass/fail)
		gemCount = 0,       -- Valid gems found (in PrepCheckDB)
		gemMaxSlots = 0,    -- Total gem sockets available
		gemsMissing = {},   -- Empty sockets
		gemsWrong = {},     -- Socketed but not in PrepCheckDB
		gemSlotDetails = {}, -- Per-slot, per-socket status: { {slot, slotName, sockets={{status,gemID},...}} }
		-- Enchant scoring (proportional EP — no binary pass/fail)
		enchantCount = 0,   -- Valid enchants found (in PrepCheckDB.enchantEffects)
		enchantMaxSlots = 0, -- Total enchantable slots checked
		enchantsMissing = {}, -- No enchant present
		enchantsWrong = {},   -- Enchant present but not in PrepCheckDB.enchantEffects
		-- Consumable checks (individual categories, flat EP)
		hasFood = false,
		hasFlask = false,
		hasAugmentRune = false,
		hasVantusRune = false,
		hasWeaponEnhancement = false,
		consumablesMissing = {},
		-- Average iLvl check (flat pass/fail)
		avgIlvl = nil,  -- populated after slot scan via GetAverageItemLevel()

	}

	-- Character sheet visual order:
	-- Head(1), Neck(2), Shldr(3), Back(15), Chest(5), Wrist(9), Hands(10),
	-- Waist(6), Legs(7), Feet(8), Ring1(11), Ring2(12),
	-- Trink1(13), Trink2(14), MH(16), OH(17)
	local slotsToCheck = {1, 2, 3, 15, 5, 9, 10, 6, 7, 8, 11, 12, 13, 14, 16, 17}

	Cerebro:Debug("Checking player: " .. player)

	for _, slot in ipairs(slotsToCheck) do
		local itemLink = GetInventoryItemLink(unit, slot)
		local slotName = ENCHANTABLE_SLOTS[slot] or "Slot " .. slot
		
		if itemLink then
			Cerebro:Debug(string.format("Checking Slot %d: %s", slot, itemLink))

			-- Check Gems (any slot can have sockets)
			if Cerebro.db.global.config.prep.requireGems then
				self:CheckSlotGems(unit, slot, itemLink, result)
			end

			-- Check Enchants (PrepCheckDB defines which slots are enchantable)
			if IsEnchantableSlot(slot) and Cerebro.db.global.config.prep.requireEnchants then
				self:CheckSlotEnchant(unit, slot, itemLink, result)
			end
		else
			-- Item missing
			Cerebro:Debug(string.format("  Slot %d: Empty or Info not loaded", slot))
			
			-- Fail Presence
			if PRESENCE_SLOTS[slot] then
				-- We categorize "Missing Item" under Consumables? No, create a general failure?
				-- The reporting function splits errors into Gems/Enchants/Consumables.
				-- We should probably add it to Enchants or create a new category.
				-- For now, if an item is missing, it definitely misses Enchants (if required).
				-- If not an enchant slot (like Head/Trinket), we need a way to report it.
				
				-- Failing "Consumables" is confusing.
				-- Failing "Enchants" is acceptable for slots 5,8,9,etc.
				-- For Head/Trinket, we might need to piggyback on one of the lists or add to results reporting.
				-- Let's add it to 'gemsMissing' or 'enchantsMissing' arbitrarily or make reporting handle a new type?
				-- Since we can't change reporting structure easily without rewriting ReportPreparationResults,
				-- let's piggyback on 'enchantsMissing' as "Missing Item" usually implies gear failure.
				
				table.insert(result.enchantsMissing, {
					slot = slot,
					slotName = slotName .. " (Missing Item)",
					itemLink = "Missing",
				})
			elseif IsEnchantableSlot(slot) and Cerebro.db.global.config.prep.requireEnchants then
				-- Enchantable slot with no item equipped
				table.insert(result.enchantsMissing, {
					slot = slot,
					slotName = slotName .. " (Missing Item)",
					itemLink = "Missing",
				})
			end
		end
	end

	-- Average iLvl: use Blizzard API (only works for self / "player" unit)
	if unit == "player" then
		local _, equipped = GetAverageItemLevel()
		if equipped then
			result.avgIlvl = math.floor(equipped)
			Cerebro:Debug(string.format("Avg iLvl: %d", result.avgIlvl))
		end
	end

	-- Check consumables (per-category, config-gated)
	self:CheckConsumablesDetailed(unit, result)

	return result
end

--[[=============================================
	Helpers
===============================================]]

function Preparation:GetScanTooltip()
	if not self.scanTooltip then
		self.scanTooltip = CreateFrame("GameTooltip", "CerebroPrepTooltip", nil, "GameTooltipTemplate")
		self.scanTooltip:SetOwner(UIParent, "ANCHOR_NONE")
	end
	return self.scanTooltip
end

function Preparation:GetItemRequirement(itemLink)
	local tooltip = self:GetScanTooltip()
	tooltip:SetHyperlink(itemLink)
	
	for i = 1, tooltip:NumLines() do
		local line = _G["CerebroPrepTooltipTextLeft" .. i]
		if line then
			local text = line:GetText()
			if text then
				-- Standard Track Text (e.g. "Hero 1/6")
				if text:match("Champion") then return 1 end
				
				-- Match "Hero" track but EXCLUDE "Heroic" difficulty
				if text:match("Hero") and not text:match("Heroic") then return 2 end
				
				-- Match "Myth" track but EXCLUDE "Mythic" difficulty
				if text:match("Myth") and not text:match("Mythic") then return 3 end

				-- Crafted Item Upgrade Levels (e.g. "Upgrade Level: Champion 1/8")
				if text:match("Upgrade Level:") then
					if text:match("Champion") then return 1 end
					if text:match("Hero") then return 2 end
					if text:match("Myth") then return 3 end
				end
			end
		end
	end
	-- Default: Require Tier 1 for everything else (Veteran, Explorer, etc.)
	return 1
end

function Preparation:GetQualityFromText(text)
	if not text then return 0 end
	-- Check for Tier icon texture pattern (Broadened match)
	local tier = text:match("Quality%-Tier(%d)")
	return tonumber(tier) or 0
end

--[[=============================================
	Slot Checkers
===============================================]]

function Preparation:CheckSlotGems(unit, slot, itemLink, result)
	local patchDB = GetPatchDB()
	local slotName = ENCHANTABLE_SLOTS[slot] or "Slot " .. slot

	-- Get expected socket count from PrepCheckDB (e.g. Head=1, Neck=2, Ring=2)
	local expectedSockets = patchDB and patchDB.gemSlots and patchDB.gemSlots[slot]
	if not expectedSockets or expectedSockets == 0 then
		return -- Not a gem slot
	end

	-- Parse item link for gem IDs — positions 5-8 are gem slots 1-4
	local linkParts = {strsplit(":", itemLink)}
	local gemIDs = {}
	for i = 5, 5 + expectedSockets - 1 do
		local val = tonumber(linkParts[i])
		if val and val > 0 then
			table.insert(gemIDs, val)
		end
	end
	local gemsFound = #gemIDs
	local totalSlots = expectedSockets

	Cerebro:Debug(string.format("  [Gems] Slot %d (%s): %d/%d gems found",
		slot, slotName, gemsFound, totalSlots))

	-- Track totals for proportional scoring
	result.gemMaxSlots = result.gemMaxSlots + totalSlots

	-- Build per-socket detail for this slot
	local slotDetail = {
		slot = slot,
		slotName = slotName,
		sockets = {},
	}

	-- Validate each socketed gem against PrepCheckDB
	for _, gemNum in ipairs(gemIDs) do
		if patchDB and patchDB.gems and patchDB.gems[gemNum] then
			result.gemCount = result.gemCount + 1
			table.insert(slotDetail.sockets, { status = "P", gemID = gemNum })
			Cerebro:Debug(string.format("    Gem ID %d: P (valid)", gemNum))
		else
			table.insert(result.gemsWrong, {
				slot = slot,
				slotName = slotName,
				itemLink = itemLink,
				gemID = gemNum,
			})
			table.insert(slotDetail.sockets, { status = "F", gemID = gemNum })
			Cerebro:Debug(string.format("    Gem ID %d: F (not in DB)", gemNum))
		end
	end

	-- Report unfilled sockets as empty
	local emptyCount = totalSlots - gemsFound
	for _ = 1, emptyCount do
		table.insert(result.gemsMissing, {
			slot = slot,
			slotName = slotName,
			itemLink = itemLink,
		})
		table.insert(slotDetail.sockets, { status = "M" })
	end

	table.insert(result.gemSlotDetails, slotDetail)
end

function Preparation:CheckSlotEnchant(unit, slot, itemLink, result)
	local slotName = ENCHANTABLE_SLOTS[slot] or "Slot " .. slot

	-- Track total enchantable slots for proportional scoring
	result.enchantMaxSlots = result.enchantMaxSlots + 1

	-- Extract enchant ID from item link
	local enchantID = self:HasEnchant(itemLink, slot)

	if not enchantID then
		-- No enchant present — report missing
		table.insert(result.enchantsMissing, {
			slot = slot,
			slotName = slotName .. " (Missing Enchant)",
			itemLink = itemLink,
		})
		Cerebro:Debug(string.format("  [Enchant] Slot %d (%s): MISSING", slot, slotName))
	else
		-- Strict validation: check effect ID against PrepCheckDB.enchantEffects
		local patchDB = GetPatchDB()
		local effectNum = tonumber(enchantID)
		local isValid
		if patchDB and patchDB.enchantEffects and patchDB.enchantEffects[slot] then
			isValid = effectNum and patchDB.enchantEffects[slot][effectNum]
		else
			-- No enchantEffects entry for this slot — fall back to presence-based
			isValid = true
		end

		if isValid then
			result.enchantCount = result.enchantCount + 1
			Cerebro:Debug(string.format("  [Enchant] Slot %d (%s): VALID (effect ID %s)",
				slot, slotName, tostring(enchantID)))
		else
			-- Enchant present but not the right one for this slot
			table.insert(result.enchantsWrong, {
				slot = slot,
				slotName = slotName .. " (Wrong Enchant: " .. tostring(enchantID) .. ")",
				itemLink = itemLink,
				enchantID = enchantID,
			})
			Cerebro:Debug(string.format("  [Enchant] Slot %d (%s): WRONG (effect ID %s not in PrepCheckDB)",
				slot, slotName, tostring(enchantID)))
		end
	end
end

function Preparation:HasEnchant(itemLink, slot)
	if not itemLink then return nil end

	-- Confirmed positions: [3]=itemID, [4]=enchantEffectID, [5-8]=gemItemIDs
	-- EnchantEffectID is the 4th colon-separated element
	local _, _, _, enchantID = strsplit(":", itemLink)

	-- Return the enchant effect ID if present, or nil
	if enchantID and enchantID ~= "" and enchantID ~= "0" then
		return enchantID
	end
	return nil
end

--[[=============================================
	Consumable Checking
	Checks 4 independent categories:
	1. Food (Well Fed buff)
	2. Flask / Elixir (combat flask or elixir buff)
	3. Augment Rune (Crystallized/Ethereal/Soulgorged)
	4. Weapon Enhancement (Oils, Whetstones, Weightstones via temp weapon enchant)
===============================================]]

-- CheckConsumablesDetailed: Populates result fields per-category
-- Validates buff spell IDs against PrepCheckDB tables when available,
-- falls back to buff name pattern matching if DB table is missing/empty.
-- TODO: Verify all spell IDs in PrepCheckDB match actual in-game buff IDs
function Preparation:CheckConsumablesDetailed(unit, result)
	local cfg = Cerebro.db.global.config.prep
	local patchDB = GetPatchDB()

	-- 1. Food — validate spell ID against PrepCheckDB.food, fallback to name match
	if cfg.requireFood then
		if patchDB and patchDB.food and next(patchDB.food) then
			if self:HasBuffBySpellID(unit, patchDB.food) then
				result.hasFood = true
			else
				table.insert(result.consumablesMissing, {
					type = "food",
					name = "Food (Well Fed)",
				})
			end
		else
			-- Fallback: no DB table, use name matching
			if self:HasBuffMatch(unit, {"Well Fed", "Feast"}) then
				result.hasFood = true
			else
				table.insert(result.consumablesMissing, {
					type = "food",
					name = "Food (Well Fed)",
				})
			end
		end
	end

	-- 2. Flask / Elixir — validate spell ID against PrepCheckDB.flasks
	if cfg.requireFlask then
		if patchDB and patchDB.flasks and next(patchDB.flasks) then
			if self:HasBuffBySpellID(unit, patchDB.flasks) then
				result.hasFlask = true
			else
				table.insert(result.consumablesMissing, {
					type = "flask",
					name = "Flask",
				})
			end
		else
			if self:HasBuffMatch(unit, {"Flask", "Elixir"}) then
				result.hasFlask = true
			else
				table.insert(result.consumablesMissing, {
					type = "flask",
					name = "Flask",
				})
			end
		end
	end

	-- 3. Augment Rune — validate spell ID against PrepCheckDB.augmentRunes
	if cfg.requireAugmentRune then
		if patchDB and patchDB.augmentRunes and next(patchDB.augmentRunes) then
			if self:HasBuffBySpellID(unit, patchDB.augmentRunes) then
				result.hasAugmentRune = true
			else
				table.insert(result.consumablesMissing, {
					type = "augment",
					name = "Augment Rune",
				})
			end
		else
			if self:HasBuffMatch(unit, {"Augment"}) then
				result.hasAugmentRune = true
			else
				table.insert(result.consumablesMissing, {
					type = "augment",
					name = "Augment Rune",
				})
			end
		end
	end

	-- 4. Vantus Rune — validate spell ID against PrepCheckDB.vantusRunes
	if cfg.requireVantusRune then
		if patchDB and patchDB.vantusRunes and next(patchDB.vantusRunes) then
			if self:HasBuffBySpellID(unit, patchDB.vantusRunes) then
				result.hasVantusRune = true
			else
				table.insert(result.consumablesMissing, {
					type = "vantus",
					name = "Vantus Rune",
				})
			end
		else
			if self:HasBuffMatch(unit, {"Vantus"}) then
				result.hasVantusRune = true
			else
				table.insert(result.consumablesMissing, {
					type = "vantus",
					name = "Vantus Rune",
				})
			end
		end
	end

	-- 5. Weapon Enhancement — validate temp enchant ID against PrepCheckDB
	if cfg.requireWeaponEnhancement then
		local hasValid = false
		if GetWeaponEnchantInfo then
			local hasMainHand, _, _, enchantID = GetWeaponEnchantInfo()
			if hasMainHand and enchantID then
				if patchDB and patchDB.weaponEnhancements and patchDB.weaponEnhancements[enchantID] then
					hasValid = true
					Cerebro:Debug(string.format("  [WeaponEnh] Valid temp enchant ID: %d", enchantID))
				else
					Cerebro:Debug(string.format("  [WeaponEnh] Wrong temp enchant ID: %d (not in DB)", enchantID))
					-- Has a temp enchant but it's not valid
					table.insert(result.consumablesMissing, {
						type = "weapon",
						name = "Weapon Enhancement (Bad ID:" .. enchantID .. ")",
					})
				end
			end
		end
		if not hasValid then
			result.hasWeaponEnhancement = false
			-- Only add missing if we didn't already add a "wrong" entry above
			local alreadyFlagged = false
			for _, c in ipairs(result.consumablesMissing) do
				if c.type == "weapon" then alreadyFlagged = true; break end
			end
			if not alreadyFlagged then
				table.insert(result.consumablesMissing, {
					type = "weapon",
					name = "Weapon Enhancement (Oil/Stone)",
				})
			end
		else
			result.hasWeaponEnhancement = true
		end
	end
end

-- Legacy wrapper for backward-compatible callers
function Preparation:CheckConsumables(unit)
	local result = {
		hasFood = false,
		hasFlask = false,
		hasAugmentRune = false,
		hasVantusRune = false,
		hasWeaponEnhancement = false,
		consumablesMissing = {},
	}
	self:CheckConsumablesDetailed(unit, result)
	-- Legacy: return true if all enabled checks pass
	local allPassed = #result.consumablesMissing == 0
	return allPassed, result.consumablesMissing
end

-- HasBuffMatch: Scan unit buffs for any name matching one of the provided patterns
-- patterns: table of strings to match against buff names
function Preparation:HasBuffMatch(unit, patterns)
	for i = 1, 40 do
		local name
		
		-- WoW 11.0+ uses C_UnitAuras
		if C_UnitAuras and C_UnitAuras.GetBuffDataByIndex then
			local buffData = C_UnitAuras.GetBuffDataByIndex(unit, i)
			if buffData then
				name = buffData.name
			end
		elseif UnitBuff then
			-- Fallback for older clients
			name = UnitBuff(unit, i)
		end
		
		if not name then break end
		
		-- Check each pattern
		for _, pattern in ipairs(patterns) do
			if name:match(pattern) then
				return true
			end
		end
	end
	
	return false
end

-- HasBuffBySpellID: Scan unit buffs and check if any spell ID is in the lookup table
-- spellIDTable: { [spellID] = true } from PrepCheckDB
function Preparation:HasBuffBySpellID(unit, spellIDTable)
	for i = 1, 40 do
		local spellId, name

		-- WoW 11.0+ uses C_UnitAuras
		if C_UnitAuras and C_UnitAuras.GetBuffDataByIndex then
			local buffData = C_UnitAuras.GetBuffDataByIndex(unit, i)
			if buffData then
				spellId = buffData.spellId
				name = buffData.name
			end
		elseif UnitBuff then
			-- Fallback for older clients: UnitBuff returns name, ..., spellId
			local buffName, _, _, _, _, _, _, _, _, buffSpellId = UnitBuff(unit, i)
			spellId = buffSpellId
			name = buffName
		end

		if not spellId and not name then break end

		if spellId and spellIDTable[spellId] then
			Cerebro:Debug(string.format("  [ConsumableID] Match: spellID %d (%s)", spellId, name or "?"))
			return true
		end
	end

	return false
end

-- Legacy HasBuff (backward compat) — delegates to HasBuffMatch with mapped patterns
function Preparation:HasBuff(unit, buffType)
	local typePatterns = {
		["Food"] = {"Well Fed", "Feast"},
		["Flask"] = {"Flask"},
		["Elixir"] = {"Elixir"},
	}
	local patterns = typePatterns[buffType]
	if not patterns then return false end
	return self:HasBuffMatch(unit, patterns)
end

--[[=============================================
	Reporting
===============================================]]

function Preparation:ReportPreparationResults(results, unprepared)
	local cfg = Cerebro.db.global.config.prep

	-- Build a lookup for quick unprepared check
	local unpreparedSet = {}
	for _, r in ipairs(unprepared) do
		unpreparedSet[r.player] = true
	end

	-- === Officer Chat: 2 messages only (Pass list, Fail list) ===
	local passNames = {}
	local failNames = {}
	for _, result in ipairs(results) do
		local shortName = Cerebro:GetShortPlayerName(result.player)
		if not unpreparedSet[result.player] then
			table.insert(passNames, shortName)
		else
			table.insert(failNames, shortName)
		end
	end

	if #passNames > 0 then
		Cerebro:SendOfficerMessage("[Cerebro] Pass: " .. table.concat(passNames, ", "))
	end
	if #failNames > 0 then
		Cerebro:SendOfficerMessage("[Cerebro] Fail: " .. table.concat(failNames, ", "))
	end

	-- NOTE: Player notifications are handled locally by each player's own
	-- Cerebro client in HandlePrepRequest. No officer whispers needed.

	-- === Local detailed print (Option B: Grouped Failures) ===
	local passCount = #results - #unprepared
	Cerebro:Print(string.format(
		"|cff888888═══|r |cffccaa00Preparation Check|r |cff888888═══|r %s/%d Ready",
		passCount > 0 and ("|cff00ff00" .. passCount .. "|r") or "|cffff44440|r",
		#results))

	-- Pass line: all passing players in one green line
	if #passNames > 0 then
		Cerebro:Print(string.format("  |cff00ff00✓ PASS:|r %s", table.concat(passNames, ", ")))
	end

	-- Fail blocks: each failing player gets a dedicated section
	for _, result in ipairs(unprepared) do
		local playerDisplay = Cerebro:ColorPlayerName(result.player)
		Cerebro:Print(string.format("  |cffff4444✗|r %s", playerDisplay))

		-- === GEMS: Always show all slots with per-socket icons ===
		-- Green ⬤ = valid gem, Orange ⬤ = wrong gem, Red ◯ = missing/empty
		if cfg.requireGems then
			local gemParts = {}
			if result.gemSlotDetails and #result.gemSlotDetails > 0 then
				for _, slotDetail in ipairs(result.gemSlotDetails) do
					local socketIcons = {}
					for _, socket in ipairs(slotDetail.sockets) do
						if socket.status == "P" then
							table.insert(socketIcons, "|cff00ff00⬤|r")
						elseif socket.status == "W" then
							table.insert(socketIcons, "|cffff8800⬤|r")
						else
							table.insert(socketIcons, "|cffff4444◯|r")
						end
					end
					table.insert(gemParts, slotDetail.slotName .. " " .. table.concat(socketIcons, ""))
				end
			end
			local countStr = string.format("(%d/%d)", result.gemCount or 0, result.gemMaxSlots or 0)
			if #gemParts > 0 then
				Cerebro:Print(string.format("     Gems: %s %s", table.concat(gemParts, ", "), countStr))
			elseif (result.gemMaxSlots or 0) == 0 then
				Cerebro:Print("     Gems: No sockets")
			end
		end

		-- === ENCHANTS: Always show — passing slots get green ⬤ ===
		if cfg.requireEnchants then
			local enchParts = {}
			for _, item in ipairs(result.enchantsMissing) do
				table.insert(enchParts, item.slotName .. " |cffff4444◯|r")
			end
			for _, item in ipairs(result.enchantsWrong) do
				table.insert(enchParts, item.slotName .. " |cffff8800⬤|r")
			end
			local countStr = string.format("(%d/%d)", result.enchantCount or 0, result.enchantMaxSlots or 0)
			local allPass = #result.enchantsMissing == 0 and #result.enchantsWrong == 0
			if allPass and (result.enchantMaxSlots or 0) > 0 then
				Cerebro:Print(string.format("     Enchants: |cff00ff00All Good|r %s |cff00ff00✓|r", countStr))
			elseif #enchParts > 0 then
				Cerebro:Print(string.format("     Enchants: %s %s", table.concat(enchParts, ", "), countStr))
			end
		end

		-- === BUFFS: Always show each enabled consumable with pass/fail icon ===
		local buffParts = {}
		if cfg.requireFood then
			if result.hasFood then
				table.insert(buffParts, "Food |cff00ff00⬤|r")
			else
				table.insert(buffParts, "Food |cffff4444◯|r")
			end
		end
		if cfg.requireFlask then
			if result.hasFlask then
				table.insert(buffParts, "Flask |cff00ff00⬤|r")
			else
				table.insert(buffParts, "Flask |cffff4444◯|r")
			end
		end
		if cfg.requireAugmentRune then
			if result.hasAugmentRune then
				table.insert(buffParts, "Augment Rune |cff00ff00⬤|r")
			else
				table.insert(buffParts, "Augment Rune |cffff4444◯|r")
			end
		end
		if cfg.requireVantusRune then
			if result.hasVantusRune then
				table.insert(buffParts, "Vantus Rune |cff00ff00⬤|r")
			else
				table.insert(buffParts, "Vantus Rune |cffff4444◯|r")
			end
		end
		if cfg.requireWeaponEnhancement then
			if result.hasWeaponEnhancement then
				table.insert(buffParts, "Weapon Enh |cff00ff00⬤|r")
			else
				table.insert(buffParts, "Weapon Enh |cffff4444◯|r")
			end
		end
		if #buffParts > 0 then
			Cerebro:Print("     Buffs: " .. table.concat(buffParts, "  "))
		end

		-- === iLvl: Always show when enabled — green if passing, red if failing ===
		if cfg.requireMinIlvl and result.avgIlvl then
			local minIlvl = cfg.minIlvl or 237
			if result.avgIlvl >= minIlvl then
				Cerebro:Print(string.format("     iLvl: |cff00ff00%d|r / %d |cff00ff00✓|r", result.avgIlvl, minIlvl))
			else
				Cerebro:Print(string.format("     iLvl: |cffff4444%d|r / %d", result.avgIlvl, minIlvl))
			end
		end
	end
end

function Preparation:GetShortNames(players)
	local names = {}

	for _, player in ipairs(players) do
		table.insert(names, Cerebro:GetShortPlayerName(player))
	end

	return names
end

--[[=============================================
	Gem Detail String Builder (shared by officer report + whisper)
	Builds: "Head(PASS), Neck(1-PASS,2-MISSING), Finger 1(1-WRONG,2-MISSING)"
	Returns nil if no gem slots exist or all pass
===============================================]]

function Preparation:BuildGemDetailString(result)
	if not result.gemSlotDetails or #result.gemSlotDetails == 0 then return nil end

	local slotParts = {}
	local hasIssue = false

	for _, slotDetail in ipairs(result.gemSlotDetails) do
		local socketStrs = {}
		local allPass = true

		for _, socket in ipairs(slotDetail.sockets) do
			if socket.status ~= "P" then allPass = false end
			table.insert(socketStrs, socket.status)
		end

		if not allPass then
			hasIssue = true
			table.insert(slotParts, slotDetail.slotName .. "(" .. table.concat(socketStrs, ",") .. ")")
		end
	end

	if not hasIssue then return nil end
	return table.concat(slotParts, " ")
end

function Preparation:GetSlotNames(items)
	local names = {}

	for _, item in ipairs(items) do
		table.insert(names, item.slotName)
	end

	return names
end

return Preparation
