--!strict
--[[
	AuditLog (ModuleScript)
	Location: ServerScriptService.PermissionService.AuditLog  (child of PermissionService)

	The logging SINK. Every authorization decision the PermissionService wants to
	record flows through here.

	------------------------------------------------------------------------
	HONEST NOTE ON "send it to another file for DB storage":
	------------------------------------------------------------------------
	A Roblox game server has NO local filesystem it can write a log file to. The
	professional equivalent is this: a sink with a single record() entry point,
	behind which the actual destination can change without any caller knowing.

	Today this sink does two things:
	  1. Emits one line of structured JSON to stdout. That line IS your parseable
	     "log file" -- scrape it from the server log / Developer Console output,
	     and because it's JSON you parse it cleanly (grep for "ALERT", etc.).
	  2. Writes each record to a DataStore under a unique key so records persist
	     across restarts and can be read back in-game (an admin "view logs" view).

	To ship logs to a real external database later, you implement ONE more branch
	(an HTTP POST via HttpService) and change nothing else. That swap-ability is
	the entire point of isolating the sink.

	KNOWN TRADEOFF: one DataStore write per record consumes request budget, so a
	high-traffic game would batch these or push straight to the external sink.
	For an audit trail of privileged/suspicious actions the volume is low and the
	stdout JSON is the primary surface, so per-record writes are fine here.
--]]

local HttpService      = game:GetService("HttpService")
local DataStoreService  = game:GetService("DataStoreService")

local AuditLog = {}

-- Severity lets you separate "someone was denied a normal request" (WARN) from
-- "the payload was structurally impossible, i.e. forged" (ALERT). Parse on this
-- field to find the records that actually matter.
AuditLog.Severity = {
	Info  = "INFO",   -- a privileged action was permitted (audit trail)
	Warn  = "WARN",   -- a well-formed request was denied by policy (not proof of malice)
	Alert = "ALERT",  -- high-confidence tampering (forged / impossible payload)
}

-- The shape of a record. Typed so call sites are checked and so record() can
-- stamp its own fields without the analyzer complaining.
export type LogEntry = {
	severity: string,
	event: string,
	actorId: number?,
	actorRole: string?,
	capability: string?,
	targetId: number?,
	detail: string?,
	timestamp: number?,
	serverJobId: string?,
}

local STORE_NAME = "AuditLog_v1"

-- GetDataStore is wrapped because it can throw in some contexts; a logging
-- subsystem must never break module load. If it fails we degrade to stdout-only.
local ok: boolean, dataStore: any = pcall(function()
	return DataStoreService:GetDataStore(STORE_NAME)
end)
local store: DataStore? = ok and dataStore or nil
if not ok then
	warn("[AuditLog] DataStore unavailable at init: " .. tostring(dataStore))
end

-- A collision-free key. The timestamp prefix gives coarse ordering; the GUID
-- suffix guarantees uniqueness ACROSS servers -- a per-server counter would
-- collide between two servers writing in the same second and silently overwrite.
local function nextKey(): string
	return string.format("%d_%s", os.time(), HttpService:GenerateGUID(false))
end

--[[
	record(entry)

	Fills in server-side fields, prints a structured line, and persists. Never
	yields and never throws: logging is observability, not control flow, so it
	must not block or break the authorization path that called it.
--]]
function AuditLog.record(entry: LogEntry)
	-- Stamp fields the caller shouldn't have to supply.
	entry.timestamp   = os.time()
	entry.serverJobId = game.JobId -- which server instance; empty in Studio, set live

	-- 1) Structured stdout line -- the parseable surface. Encoding is wrapped so
	--    a bad entry can never throw into the caller.
	local encodeOk, encoded = pcall(function()
		return HttpService:JSONEncode(entry)
	end)
	if encodeOk then
		print("[AUDIT] " .. encoded)
	else
		warn("[AuditLog] failed to encode entry: " .. tostring(encoded))
	end

	-- 2) Persist under a unique key, fire-and-forget via task.spawn so the
	--    network latency of the write never blocks the caller.
	local activeStore = store
	if activeStore then
		local key = nextKey()
		task.spawn(function()
			local writeOk, err = pcall(function()
				activeStore:SetAsync(key, entry)
			end)
			if not writeOk then
				warn("[AuditLog] failed to persist record " .. key .. ": " .. tostring(err))
			end
		end)
	end

	-- 3) (Future) external DB: a single pcall'd HttpService:PostAsync(endpoint,
	--    encoded) here is the only change needed. Nothing else would know.
end

return AuditLog
