--[[
	Cerebro - Support API
	Implements the Support API contract defined in docs/SPEC.md §8.

	Exposes a stable, narrow public surface (`Cerebro.SupportAPI`) that allows
	a separate companion addon (Cerebro-Support) to perform end-to-end sync
	health diagnostics WITHOUT touching production EPGP data.

	The contract has three parts:
	  1. Read-only accessors (version, hash, officer status, test-mode state)
	  2. A bounded-lifetime write gate that DROPS production mutations during
	     test runs (it never redirects — Cerebro-Support owns its own storage)
	  3. A passive observer hook for V2 dispatch (read-only, post-dispatch)

	@module Modules.SupportAPI
	@version 0.12.0
	@invariant SA-1: EnterTestMode fails closed when not an officer
	@invariant SA-2: ApplyPlayerMutation drops all writes during test mode
	@invariant SA-3: Test mode auto-exits after deadline without explicit ExitTestMode
	@invariant SA-4: Test mode does not persist to SavedVariables (no /reload survival)
	@invariant SA-5: Observer callbacks run AFTER production dispatch completes
	@invariant SA-6: Observer callback errors do not impair production dispatch
	@invariant SA-7: SupportAPI exposes no mutation paths (no setters in api table)
	@invariant SA-8: Combat gating remains in effect during test mode (no bypass)
	@invariant SA-9: Contract version mismatches cause companion addon to refuse
]]--

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

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

--[[=============================================
	Contract Constants
===============================================]]

-- Bumped only when removing or changing existing API surface.
-- Additive changes (new accessors, new event types) do NOT bump this.
-- See SPEC.md §8.5.
local CONTRACT_VERSION = 1

-- Hard cap on test-mode lifetime, belt-and-braces for SA-3.
-- The companion addon SHOULD pass a smaller value, but we will not
-- accept anything longer than this regardless.
local MAX_DEADLINE_SECONDS = 120

--[[=============================================
	Module-Local State (NOT persisted — see SA-4)

	Lives in this file scope. A /reload destroys this Lua state, which
	guarantees test mode never survives a reload. There is no SavedVariables
	field for any of this state.
===============================================]]

local state = {
	testMode = false,
	activeRunID = nil,
	activeDeadline = nil,
	deadlineTimer = nil,
	observers = {},   -- handle (integer) -> callback (function)
	nextHandle = 1,
}

--[[=============================================
	Public API Surface (Cerebro.SupportAPI)

	IMPORTANT: SA-7 invariant — there are NO setter functions in this table.
	The only state-changing entry points are EnterTestMode and ExitTestMode,
	both of which are gated by officer status (SA-1) and bounded by deadline
	(SA-3). They do not mutate any production state.
===============================================]]

local api = {}

-- Stable contract version. Companion addons must check this at load time.
api.VERSION = CONTRACT_VERSION

--- Get the contract version exposed by this Cerebro build.
-- @return integer
function api.GetContractVersion()
	return CONTRACT_VERSION
end

--- Get the running Cerebro addon version string.
-- @return string
function api.GetAddonVersion()
	return Cerebro.version or "unknown"
end

--- Get the current deterministic roster hash.
-- Reads from CommunicationV2:ComputeRosterHash, which is the same hash
-- the production sync uses for divergence detection.
-- @return string|nil hash
function api.GetCurrentHash()
	local commV2 = Cerebro.modules and Cerebro.modules.CommunicationV2
	if not commV2 or not commV2.ComputeRosterHash then return nil end
	local hash = commV2:ComputeRosterHash()
	return hash
end

--- Get the count of sync-visible players.
-- Uses the SAME filter as the roster hash, so the count matches the
-- hash exactly. Returns 0 if the hash engine is unavailable.
-- @return integer
function api.GetPlayerCount()
	local commV2 = Cerebro.modules and Cerebro.modules.CommunicationV2
	if not commV2 or not commV2.ComputeRosterHash then return 0 end
	local _, count = commV2:ComputeRosterHash()
	return count or 0
end

--- Check whether the current client is an officer.
-- Read-only passthrough to the production officer detector.
-- @return boolean
function api.IsOfficer()
	return Cerebro:IsOfficer() and true or false
end

--- Check whether test mode is currently active on this client.
-- @return boolean
function api.IsInTestMode()
	return state.testMode
end

--- Get the runID of the active test, if any.
-- @return string|nil
function api.GetActiveTestRun()
	return state.activeRunID
end

--- Enter test mode for a bounded period.
-- While test mode is active, ApplyPlayerMutation drops all writes
-- (see SA-2 enforcement in Utilities.lua). Test mode auto-exits after
-- the deadline even if ExitTestMode is never called (SA-3).
--
-- @param runID string Unique identifier for this test run
-- @param deadline number Seconds until auto-exit (1..MAX_DEADLINE_SECONDS)
-- @return boolean ok, string|nil reason
function api.EnterTestMode(runID, deadline)
	-- SA-1: officer gate, fail closed
	if not Cerebro:IsOfficer() then
		return false, "not an officer"
	end

	if state.testMode then
		return false, "already in test mode"
	end

	if type(runID) ~= "string" or #runID == 0 then
		return false, "invalid runID"
	end

	if type(deadline) ~= "number" or deadline <= 0 or deadline > MAX_DEADLINE_SECONDS then
		return false, "invalid deadline (must be 1.." .. MAX_DEADLINE_SECONDS .. ")"
	end

	state.testMode = true
	state.activeRunID = runID
	state.activeDeadline = deadline

	-- SA-3: schedule auto-exit. We capture runID by closure so a stale
	-- timer firing after a manual ExitTestMode + new EnterTestMode does
	-- not accidentally cancel the new session.
	local capturedRunID = runID
	if C_Timer and C_Timer.NewTimer then
		state.deadlineTimer = C_Timer.NewTimer(deadline, function()
			-- Only auto-exit if we are still on the same runID.
			if state.testMode and state.activeRunID == capturedRunID then
				api.ExitTestMode(capturedRunID)
			end
		end)
	end

	return true
end

--- Exit test mode.
-- Returns false if test mode is not active, or if the runID does not
-- match the active run (prevents stale exits from racing new sessions).
-- @param runID string Must match the runID passed to EnterTestMode
-- @return boolean
function api.ExitTestMode(runID)
	if not state.testMode then
		return false
	end
	if state.activeRunID ~= runID then
		return false
	end

	if state.deadlineTimer and state.deadlineTimer.Cancel then
		state.deadlineTimer:Cancel()
	end

	state.testMode = false
	state.activeRunID = nil
	state.activeDeadline = nil
	state.deadlineTimer = nil

	return true
end

--- Register a passive observer for V2 dispatch events.
-- The callback is invoked AFTER production dispatch completes (SA-5)
-- and is isolated via pcall (SA-6). Misbehaving observers are unregistered
-- automatically.
--
-- Callback signature: function(msgType, sender, payload)
--
-- @param callback function
-- @return integer|nil handle (use to unregister), nil if invalid input
function api.RegisterObserver(callback)
	if type(callback) ~= "function" then return nil end
	local handle = state.nextHandle
	state.nextHandle = state.nextHandle + 1
	state.observers[handle] = callback
	return handle
end

--- Unregister a previously-registered observer.
-- Idempotent: unregistering a handle that does not exist is a no-op.
-- @param handle integer
function api.UnregisterObserver(handle)
	if handle ~= nil then
		state.observers[handle] = nil
	end
end

--[[=============================================
	Internal Methods (called from Cerebro core, not Cerebro.SupportAPI)

	These live on the module, not the public api table, because they are
	implementation details that other Cerebro internals call into. They
	are NOT part of the SA-7 public surface.
===============================================]]

--- Called by Utilities.lua ApplyPlayerMutation as the SA-2 write gate.
-- Returns true if the mutation should be DROPPED.
-- @return boolean
function SupportAPI:ShouldDropMutation()
	return state.testMode == true
end

--- Called by RPCDispatch.lua OnCommReceivedV2 after production dispatch
-- completes (SA-5). Notifies all registered observers, isolating each
-- callback via pcall (SA-6). Observers that error are unregistered.
-- @param msgType string
-- @param sender string
-- @param payload table
function SupportAPI:NotifyObservers(msgType, sender, payload)
	if not state.observers then return end
	-- Snapshot the observer set so callbacks that unregister themselves
	-- (or others) during iteration cannot break the loop.
	local snapshot = {}
	for handle, cb in pairs(state.observers) do
		snapshot[#snapshot + 1] = { handle = handle, cb = cb }
	end
	for _, entry in ipairs(snapshot) do
		local ok, err = pcall(entry.cb, msgType, sender, payload)
		if not ok then
			-- SA-6: unregister misbehaving observer, do not propagate
			state.observers[entry.handle] = nil
			if Cerebro.Print then
				Cerebro:Print("|cffff8800SupportAPI|r observer error, unregistered: " .. tostring(err))
			end
		end
	end
end

--- Diagnostic accessor used by /cb supportapi.
-- Returns a snapshot of the module state for human inspection.
-- @return table
function SupportAPI:GetDiagnosticState()
	local observerCount = 0
	for _ in pairs(state.observers) do observerCount = observerCount + 1 end
	return {
		contractVersion = CONTRACT_VERSION,
		addonVersion = api.GetAddonVersion(),
		isOfficer = api.IsOfficer(),
		inTestMode = state.testMode,
		activeRunID = state.activeRunID,
		activeDeadline = state.activeDeadline,
		observerCount = observerCount,
		maxDeadlineSeconds = MAX_DEADLINE_SECONDS,
	}
end

--[[=============================================
	Expose the public API
===============================================]]

Cerebro.SupportAPI = api
