--!strict
--[[
	PermissionService (ModuleScript)
	Location: ServerScriptService.PermissionService
	          (the module with the SAME name as the folder; Roles and AuditLog
	           are its CHILDREN, which is why we require them via script.Roles)

	A server-authoritative Role-Based Access Control system.

	====================================================================
	THE ONE IDEA THIS MODULE EXISTS TO ENFORCE
	====================================================================
	The client is untrusted territory. A player runs the client on their own
	machine and can modify it: a stock client can only fire the RemoteEvents our
	own UI wires up, but a player running an exploit tool can fire ANY remote with
	ANY payload. Therefore the client never decides anything. It REQUESTS; the
	server AUTHORIZES and ACTS. A client-side permission check is UX (graying out
	a button), never security.

	When a RemoteEvent fires, the Roblox engine injects the firing Player as the
	first argument to OnServerEvent. That identity CANNOT be spoofed. Everything
	else in the payload is attacker-controlled. So the rule is exact:
	    trust the engine-provided identity (player.UserId),
	    validate everything else.

	This module is the single place that turns "who is this + what do they want"
	into an allow/deny, resolving a two-axis role lattice with inheritance, and
	logging every decision worth recording.
--]]

local Players           = game:GetService("Players")
local MarketplaceService = game:GetService("MarketplaceService")

local Roles    = require(script.Roles)
local AuditLog = require(script.AuditLog)

local PermissionService = {}

-- Re-export the name tables so callers reference constants
-- (PermissionService.Capability.Kick) instead of raw strings. A typo'd constant
-- is a nil error you catch immediately; a typo'd string is a silent denial.
PermissionService.Capability = Roles.Capability
PermissionService.Perk       = Roles.Perk

--========================================================================
-- INHERITANCE RESOLUTION (memoized, cycle-guarded)
--========================================================================
-- Resolving a role means walking its inheritsFrom chain, unioning capabilities
-- and collapsing perks (most-specific value wins). The lattice never changes at
-- runtime, so we resolve each role ONCE and cache it: every lookup for a given
-- role is then guaranteed identical, and it's cheap.

type ResolvedRole = {
	capabilities: { [string]: boolean },
	perks: { [string]: any },
}

local resolvedCache: { [string]: ResolvedRole } = {}
local resolving: { [string]: boolean } = {} -- roles currently mid-resolution (cycle guard)

local function resolveRole(roleName: string): ResolvedRole?
	local cached = resolvedCache[roleName]
	if cached then
		return cached
	end

	local def = Roles.Definitions[roleName]
	if not def then
		-- Unknown role -> grant nothing. Returning nil (not erroring) makes the
		-- safe default "no permissions" rather than a crash.
		return nil
	end

	-- Cycle guard: if we re-enter a role we're already resolving, the config has
	-- a loop (a inherits b inherits a). Fail loud instead of overflowing the
	-- stack, and degrade that role to its own grants only.
	if resolving[roleName] then
		warn("[PermissionService] cyclic inheritance detected at role '" .. roleName .. "'")
		return nil
	end
	resolving[roleName] = true

	local capabilities: { [string]: boolean } = {}
	local perks: { [string]: any } = {}

	-- Parent first (the base), then this role layers on top.
	if def.inheritsFrom then
		local parent = resolveRole(def.inheritsFrom)
		if parent then
			for cap in parent.capabilities do
				capabilities[cap] = true
			end
			for key, value in parent.perks do
				perks[key] = value
			end
		end
	end

	for cap, granted in def.capabilities do
		if granted then
			capabilities[cap] = true
		end
	end
	for key, value in def.perks do
		perks[key] = value -- a value set here overrides the inherited one
	end

	resolving[roleName] = nil
	local resolved: ResolvedRole = { capabilities = capabilities, perks = perks }
	resolvedCache[roleName] = resolved
	return resolved
end

--========================================================================
-- PLAYER -> ROLE
--========================================================================
-- A player carries a PERK TIER (baseRole) and an optional STAFF ROLE
-- (staffRole), stored as Attributes on the Player. Attributes are server-set
-- and, while replicated to the client for display, are NOT client-writable --
-- so the client can READ "I am vip" to theme its UI but can never grant itself
-- anything by writing one.

local ATTR_BASE  = "PermBaseRole"
local ATTR_STAFF = "PermStaffRole"

-- Assign a player's perk tier. Server-only callers. We deliberately do NOT let
-- this set a staff role -- staff is assigned explicitly, never as a side effect.
function PermissionService.setBaseRole(player: Player, roleName: string)
	if not Roles.Definitions[roleName] then
		warn("[PermissionService] setBaseRole: unknown role '" .. tostring(roleName) .. "'")
		return
	end
	player:SetAttribute(ATTR_BASE, roleName)
end

-- Assign (or, with nil, clear) a player's staff role.
function PermissionService.setStaffRole(player: Player, roleName: string?)
	if roleName ~= nil and not Roles.Definitions[roleName] then
		warn("[PermissionService] setStaffRole: unknown role '" .. tostring(roleName) .. "'")
		return
	end
	player:SetAttribute(ATTR_STAFF, roleName) -- nil removes the attribute
end

-- The two role names a player currently holds. baseRole always resolves (it
-- defaults to "user"); staffRole may be nil.
local function getRoleNames(player: Player): (string, string?)
	local base  = player:GetAttribute(ATTR_BASE) :: string?
	local staff = player:GetAttribute(ATTR_STAFF) :: string?
	return base or "user", staff
end

-- A human-readable label for logging ("mvp+ / mod", or just "user").
function PermissionService.describeRole(player: Player): string
	local base, staff = getRoleNames(player)
	if staff then
		return base .. " / " .. staff
	end
	return base
end

--========================================================================
-- PUBLIC API: can() and getPerk()
--========================================================================

-- can(player, capability) -> boolean
-- The single question the rest of the game asks. Unions the player's perk tier
-- and staff role, because a real player is "their tier PLUS their staff powers".
function PermissionService.can(player: Player, capability: string): boolean
	local baseName, staffName = getRoleNames(player)

	local base = resolveRole(baseName)
	if base and base.capabilities[capability] then
		return true
	end

	if staffName then
		local staff = resolveRole(staffName)
		if staff and staff.capabilities[capability] then
			return true
		end
	end

	return false
end

-- getPerk(player, key) -> value
-- Perks are values, not booleans. If both axes define the key we take the
-- higher numeric value (in practice staff define none, but the union has to
-- resolve to something and "best wins" is the sane rule). Both sides are
-- checked to be numbers before comparing so this can never throw.
function PermissionService.getPerk(player: Player, key: string): any
	local baseName, staffName = getRoleNames(player)
	local value: any = nil

	local base = resolveRole(baseName)
	if base then
		value = base.perks[key]
	end

	if staffName then
		local staff = resolveRole(staffName)
		if staff then
			local staffValue = staff.perks[key]
			if staffValue ~= nil then
				if value == nil then
					value = staffValue
				elseif type(staffValue) == "number" and type(value) == "number" and staffValue > value then
					value = staffValue
				end
			end
		end
	end

	return value
end

--========================================================================
-- GAMEPASS -> ROLE  (server-side verification only)
--========================================================================
-- A purchase is a CLAIM until the server confirms it. We never trust a client
-- saying "I bought MVP". We ask MarketplaceService, server-side, whether this
-- UserId actually owns the pass, and only then upgrade the tier.

-- Rank of a perk tier within TierOrder (0 if not a known tier).
local function tierRank(name: string): number
	for index, tierName in Roles.TierOrder do
		if tierName == name then
			return index
		end
	end
	return 0
end

-- Upgrade a player to tierName ONLY if it's higher than their current tier.
-- This is what guarantees a purchase can never downgrade someone, and it's the
-- single place the "highest wins" rule lives (shared by join-sync and the live
-- purchase event below, so there's no duplicated logic to drift).
local function upgradeTierIfHigher(player: Player, tierName: string)
	local current = (player:GetAttribute(ATTR_BASE) :: string?) or "user"
	if tierRank(tierName) > tierRank(current) then
		PermissionService.setBaseRole(player, tierName)
	end
end

-- On join: check every configured pass and settle on the HIGHEST one actually
-- owned. Each ownership check is pcall'd and fails CLOSED -- a network error
-- leaves the player at their current tier rather than wrongly upgrading them.
function PermissionService.syncPurchasedTier(player: Player)
	for passId, tierName in Roles.GamePassToTier do
		local ok, owns = pcall(function()
			return MarketplaceService:UserOwnsGamePassAsync(player.UserId, passId)
		end)
		if ok and owns then
			upgradeTierIfHigher(player, tierName)
		end
	end
end

--========================================================================
-- THE AUTHORIZATION GATEWAY
--========================================================================
-- A RemoteEvent handler calls authorize() rather than can() directly, because
-- authorize() does three things a bare check doesn't:
--   1. validates the payload SHAPE  -> catches FORGERY (impossible from a real client)
--   2. runs the capability check     -> the actual allow/deny
--   3. logs the outcome at the right severity
--
-- The critical distinction:
--   * A denied-but-well-formed request is logged WARN. It is NOT proof of malice
--     -- your own UI, a lag replay, or a test build can produce it. The action
--     simply doesn't run. No punishment.
--   * A structurally IMPOSSIBLE payload (wrong types, a target that isn't a
--     player when one is required) could not come from your own client. That's
--     high-confidence forgery: logged ALERT and flagged.
--
-- authorize() still only LOGS AND FLAGS -- it never bans. The security goal (the
-- action not running) is ALREADY achieved by the deny; banning is a separate
-- policy decision for a human or a higher-confidence pipeline. That restraint is
-- deliberate: auto-banning on a denied check would false-positive on your own
-- players and your own testing.

export type AuthRequest = {
	actor: Player,            -- engine-provided; trustworthy
	capability: string,       -- what they want to do
	target: Player?,          -- who they're targeting, if applicable
	requiresTarget: boolean?, -- set true for actions like kick/ban
}

-- Per-server, in-memory tally of high-confidence forgery flags per UserId.
-- Exposed for a policy layer to READ; not acted on here. Resets per server --
-- persistent banning is the separate pipeline's job, fed by the ALERT records.
local forgeryFlags: { [number]: number } = {}

function PermissionService.getForgeryFlagCount(userId: number): number
	return forgeryFlags[userId] or 0
end

-- Is the request even SHAPED like something a real client sends? A false here
-- means "no legitimate client produced this".
local function validateShape(req: AuthRequest): (boolean, string?)
	if typeof(req.actor) ~= "Instance" or not req.actor:IsA("Player") then
		return false, "actor is not a Player"
	end
	if type(req.capability) ~= "string" then
		return false, "capability is not a string"
	end
	if req.requiresTarget then
		if typeof(req.target) ~= "Instance" or not req.target:IsA("Player") then
			return false, "action requires a Player target but none/invalid was given"
		end
	end
	return true, nil
end

-- authorize(req) -> boolean. The one function your remote handlers call. Returns
-- true ONLY if the request is shape-valid AND permitted. Logs every path.
function PermissionService.authorize(req: AuthRequest): boolean
	-- Identity for logging comes from the ENGINE-PROVIDED actor only, never from
	-- anything else in the payload. Narrow inside the guard so we never touch a
	-- field on a non-Player.
	local actorId = -1
	local actorRole = "unknown"
	if typeof(req.actor) == "Instance" and req.actor:IsA("Player") then
		actorId = req.actor.UserId
		actorRole = PermissionService.describeRole(req.actor)
	end

	local targetId: number? = nil
	if typeof(req.target) == "Instance" and req.target:IsA("Player") then
		targetId = req.target.UserId
	end

	-- 1) Shape validation -- forgery detection.
	local shapeOk, reason = validateShape(req)
	if not shapeOk then
		forgeryFlags[actorId] = (forgeryFlags[actorId] or 0) + 1
		AuditLog.record({
			severity   = AuditLog.Severity.Alert,
			event      = "forged_payload",
			actorId    = actorId,
			actorRole  = actorRole,
			capability = tostring(req.capability),
			targetId   = targetId,
			detail     = string.format(
				"impossible payload (%s); forgery flag #%d for this user this server",
				tostring(reason), forgeryFlags[actorId]
			),
		})
		return false
	end

	-- 2) The actual permission check.
	local allowed = PermissionService.can(req.actor, req.capability)

	-- 3) Log the decision. Permitted -> INFO (audit trail of privileged actions);
	--    denied-but-well-formed -> WARN (recorded, never punished).
	AuditLog.record({
		severity   = allowed and AuditLog.Severity.Info or AuditLog.Severity.Warn,
		event      = allowed and "authorized" or "denied",
		actorId    = actorId,
		actorRole  = actorRole,
		capability = req.capability,
		targetId   = targetId,
		detail     = allowed and "action permitted" or "well-formed request lacked permission",
	})

	return allowed
end

--========================================================================
-- JOIN WIRING
--========================================================================
-- Everyone starts "user", then we sync any tier they actually own. Staff roles
-- are intentionally NOT set here -- you assign those deliberately elsewhere (a
-- vetted list, a separate admin action), never automatically.
local function onPlayerAdded(player: Player)
	PermissionService.setBaseRole(player, "user")
	PermissionService.syncPurchasedTier(player)
end

Players.PlayerAdded:Connect(onPlayerAdded)

-- Catch players who joined BEFORE this script initialised (common in Studio for
-- the first player). Without this they'd skip the purchase sync entirely.
for _, player in Players:GetPlayers() do
	task.spawn(onPlayerAdded, player)
end

-- Live purchases. UserOwnsGamePassAsync caches per server, so re-polling right
-- after a purchase can return stale "false". The correct signal for a fresh buy
-- is this event, fired server-side when the prompt completes.
MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player: Player, passId: number, wasPurchased: boolean)
	if not wasPurchased then
		return
	end
	local tierName = Roles.GamePassToTier[passId]
	if tierName then
		upgradeTierIfHigher(player, tierName)
	end
end)

Players.PlayerRemoving:Connect(function(player: Player)
	forgeryFlags[player.UserId] = nil -- tidy the per-server tally
end)

return PermissionService
