<?php

namespace YahnisElsts\AdminMenuEditor\ContentPermissions\Policy;

use YahnisElsts\AdminMenuEditor\Actors\Actor;

class ContentItemPolicy implements \JsonSerializable {
	const EFFECT_ALLOW = 'allow';
	const EFFECT_DENY = 'deny';

	private $actorAccess;
	/**
	 * @var string
	 */
	private $replacementContent;
	/**
	 * @var AccessProtectionSettings
	 */
	private $accessProtectionSettings;
	/**
	 * @var bool
	 */
	private $preferAdvancedMode;

	public function __construct(
		$actorAccess,
		$replacementContent = '',
		?AccessProtectionSettings $accessProtectionSettings = null,
		$preferAdvancedMode = false
	) {
		$this->actorAccess = $actorAccess;
		$this->replacementContent = $replacementContent;
		$this->preferAdvancedMode = $preferAdvancedMode;
		if ( $accessProtectionSettings ) {
			$this->accessProtectionSettings = $accessProtectionSettings;
		} else {
			$this->accessProtectionSettings = new AccessProtectionSettings();
		}
	}

	/**
	 * @param Actor $actor
	 * @param Action $action
	 * @param ContentItemPolicy|null $childObjectPolicy
	 * @param mixed $objectId ID of the object that this policy is evaluated for. Usually a post ID.
	 * @return EvaluationResult|null
	 */
	public function evaluate(Actor $actor, Action $action, ?ContentItemPolicy $childObjectPolicy = null, $objectId = null) {
		$bestMatchEffect = null;
		$bestMatchPriority = -1;

		$actionName = $action->getName();
		$flattedActors = $actor->flatten();
		//flatten() returns actors sorted by priority, from highest to lowest.

		foreach ($flattedActors as $componentActor) {
			$actorId = $componentActor->getId();
			$priority = $componentActor->getPriority();

			//If we already have a match with higher priority than this, we can skip the rest.
			if ( $bestMatchEffect && ($priority < $bestMatchPriority) ) {
				break;
			}

			if ( isset($this->actorAccess[$actorId][$actionName]) ) {
				$effect = $this->actorAccess[$actorId][$actionName] ? self::EFFECT_ALLOW : self::EFFECT_DENY;
				//Prefer "allow" over "deny". An actor with a higher priority could override an "allow"
				//with a "deny", but if we already got this far, we don't have a match with a higher priority.
				if ( $effect === self::EFFECT_ALLOW ) {
					$bestMatchEffect = self::EFFECT_ALLOW;
					break;
				}

				$bestMatchEffect = $effect;
				$bestMatchPriority = $priority;
			}
		}

		if ( $bestMatchEffect === null ) {
			return null;
		}

		return new EvaluationResult(
			$bestMatchEffect,
			$actor,
			$action,
			$this,
			$childObjectPolicy,
			$objectId
		);
	}

	public function getReplacementContent() {
		if ( $this->replacementContent !== '' ) {
			return $this->replacementContent;
		}
		return '<p>' . static::getDefaultReplacementText() . '</p>';
	}

	public static function getDefaultReplacementText() {
		return _x(
			"You don't have access to this content.",
			'content permissions: default replacement text',
			'admin-menu-editor'
		);
	}

	/**
	 * @return AccessProtection|null
	 */
	public function getDirectAccessProtection() {
		return $this->accessProtectionSettings->getActiveProtection();
	}

	/**
	 * Iterates over all entries in the policy that match one of the given actions and
	 * calls the callback for each entry.
	 *
	 * The callback receives the following parameters:
	 * - string $actorId
	 * - string $actionName
	 * - bool $isAllowed
	 *
	 * @param Action[] $actions
	 * @param callable $callback
	 * @return void
	 */
	public function forEachActionEntry($actions, $callback) {
		if ( !isset($this->actorAccess) ) {
			return;
		}

		foreach ($actions as $action) {
			$actionName = $action->getName();
			foreach ($this->actorAccess as $actorId => $actionSettings) {
				if ( isset($actionSettings[$actionName]) ) {
					$callback($actorId, $actionName, $actionSettings[$actionName]);
				}
			}
		}
	}

	/**
	 * @param Actor $actor
	 * @param Action $action
	 * @param bool|null $isAllowed
	 * @return void
	 */
	public function setActorPermission(Actor $actor, Action $action, $isAllowed) {
		$actorId = $actor->getId();
		$actionName = $action->getName();

		if ( $isAllowed === null ) {
			unset($this->actorAccess[$actorId][$actionName]);
			if ( empty($this->actorAccess[$actorId]) ) {
				unset($this->actorAccess[$actorId]);
			}
			return;
		}

		if ( !isset($this->actorAccess[$actorId]) ) {
			$this->actorAccess[$actorId] = [];
		}
		$this->actorAccess[$actorId][$actionName] = $isAllowed;
	}

	/**
	 * Check if the policy has a custom permission setting for the given actor and action.
	 *
	 * @param Actor $actor
	 * @param Action $action
	 * @return bool
	 */
	public function hasPermissionSettingFor(Actor $actor, Action $action) {
		return isset($this->actorAccess[$actor->getId()][$action->getName()]);
	}

	/** @noinspection PhpLanguageLevelInspection */
	#[\ReturnTypeWillChange]
	public function jsonSerialize() {
		$data = [];

		//Only serialize non-empty arrays.
		$actorAccess = array_filter($this->actorAccess, function ($actions) {
			return !empty($actions);
		});
		if ( !empty($actorAccess) ) {
			$data['actorAccess'] = $actorAccess;
		}

		if ( $this->replacementContent !== '' ) {
			$data['replacementContent'] = $this->replacementContent;
		}

		if ( $this->accessProtectionSettings ) {
			$serializedProtection = $this->accessProtectionSettings->jsonSerialize();
			if ( !empty($serializedProtection) ) {
				$data['accessProtection'] = $serializedProtection;
			}
		}

		if ( $this->preferAdvancedMode ) {
			$data['preferAdvancedMode'] = true;
		}

		return $data;
	}

	public static function fromArray($properties) {
		$actorAccess = [];
		if ( isset($properties['actorAccess']) && is_array($properties['actorAccess']) ) {
			$actorAccess = $properties['actorAccess'];
		}

		$replacementContent = isset($properties['replacementContent']) ? strval($properties['replacementContent']) : '';

		$accessProtectionSettings = null;
		if ( isset($properties['accessProtection']) ) {
			$accessProtectionSettings = AccessProtectionSettings::fromArray($properties['accessProtection']);
		}

		$preferAdvancedMode = isset($properties['preferAdvancedMode']) && $properties['preferAdvancedMode'];

		return new static($actorAccess, $replacementContent, $accessProtectionSettings, $preferAdvancedMode);
	}
}

class PolicyStore {
	const POST_POLICY_META_KEY = '_ame_cpe_post_policy';
	const TERM_POLICY_META_KEY = '_ame_cpe_term_policy';
	const HIDDEN_ITEM_LOOKUP_OPTION = 'ame_cpe_restricted_items';

	/**
	 * @var RestrictedPostLookup|null
	 */
	private $restrictedPostLookup = null;

	/**
	 * @var ActionRegistry
	 */
	private $actionRegistry;

	/**
	 * @var array Composite key => array of post IDs.
	 */
	private $hiddenListItemCache = [];

	public function __construct(ActionRegistry $actionRegistry) {
		$this->actionRegistry = $actionRegistry;

		//Remove deleted posts from the lookup.
		add_action('deleted_post', [$this, 'forgetDeletedPost']);
	}

	/**
	 * @param string $objectType
	 * @param \WP_Post|\WP_Term $nativeObject
	 * @param string|null $postType For terms, the post type to get the policy for.
	 * @return ContentItemPolicy|null
	 */
	public function getObjectPolicy(string $objectType, $nativeObject, ?string $postType = null): ?ContentItemPolicy {
		switch ($objectType) {
			case Action::OBJECT_TYPE_POST:
				if ( !($nativeObject instanceof \WP_Post) ) {
					return null;
				}
				return $this->getPostPolicy($nativeObject->ID);
			case Action::OBJECT_TYPE_TERM:
				if ( !($nativeObject instanceof \WP_Term) ) {
					return null;
				}
				return $this->getTermPolicy($nativeObject->term_id, $postType);
			default:
				return null;
		}
	}

	public function setObjectPolicy(
		string            $objectType,
		                  $nativeObject,
		ContentItemPolicy $policy,
		?string           $postType = null
	): void {
		switch ($objectType) {
			case Action::OBJECT_TYPE_POST:
				if ( !($nativeObject instanceof \WP_Post) ) {
					return;
				}
				$this->setPostPolicy($nativeObject->ID, $policy);
				break;
			case Action::OBJECT_TYPE_TERM:
				if ( !($nativeObject instanceof \WP_Term) ) {
					return;
				}
				$this->setTermPolicy($nativeObject->term_id, $policy, $postType);
				break;
		}

	}

	/**
	 * @param int $postId
	 * @return ContentItemPolicy|null
	 */
	public function getPostPolicy($postId) {
		$serializedPolicy = get_post_meta($postId, self::POST_POLICY_META_KEY, true);
		if ( !empty($serializedPolicy) ) {
			$properties = json_decode($serializedPolicy, true);
			if ( is_array($properties) ) {
				return ContentItemPolicy::fromArray($properties);
			}
		}
		return null;
	}

	/**
	 * @param int $postId
	 * @param ContentItemPolicy $policy
	 * @return void
	 */
	public function setPostPolicy($postId, ContentItemPolicy $policy) {
		update_post_meta($postId, self::POST_POLICY_META_KEY, wp_slash(wp_json_encode($policy)));

		//Clear policy related caches.
		$this->hiddenListItemCache = [];

		//Update the central lookup that stores hidden posts.
		$lookup = $this->getRestrictedPostLookup();
		$lookup->forgetPost($postId);

		$postType = get_post_type($postId);
		if ( $postType ) {
			$policy->forEachActionEntry(
				[
					$this->actionRegistry->getAction(ActionRegistry::ACTION_READ),
					$this->actionRegistry->getAction(ActionRegistry::ACTION_VIEW_IN_LISTS),
				],
				function ($actorId, $actionName, $isAllowed) use ($lookup, $postId, $postType) {
					$action = $this->actionRegistry->getAction($actionName);
					$lookup->addEntry($actorId, $action, $postType, $postId, $isAllowed);
				}
			);
		}

		if ( $lookup->isModified() ) {
			$this->saveRestrictedPostLookup($lookup);
		}
	}

	public function getTermPolicy($termId, ?string $postType = null, bool $useUniversalOnMiss = false): ?ContentItemPolicy {
		$collection = $this->getTermPolicyCollection($termId);
		if ( $collection === null ) {
			return null;
		}

		if ( $postType === null ) {
			return $collection->getUniversalPolicy();
		} else {
			$policyForPostType = $collection->getPolicyForPostType($postType);
			if ( $policyForPostType ) {
				return $policyForPostType;
			} else if ( $useUniversalOnMiss ) {
				return $collection->getUniversalPolicy();
			}
		}
		return null;
	}

	private function setTermPolicy($termId, ContentItemPolicy $policy, ?string $postType = null) {
		//Unlike a post, a term can have multiple policies - one for each associated post type,
		//and a general one that applies to all post types. So we can't just overwrite the meta
		//value, we need to merge the new policy into the existing one(s).
		$collection = $this->getTermPolicyCollection($termId);
		if ( $collection === null ) {
			$collection = new TermPolicyCollection();
		}

		if ( $postType === null ) {
			$collection->setUniversalPolicy($policy);
		} else {
			$collection->setPolicyForPostType($postType, $policy);
		}

		$this->setTermPolicyCollection($termId, $collection);
	}

	private function getTermPolicyCollection($termId): ?TermPolicyCollection {
		$collectionData = get_term_meta($termId, self::TERM_POLICY_META_KEY, true);
		if ( !empty($collectionData) ) {
			$properties = json_decode($collectionData, true);
			if ( is_array($properties) ) {
				return TermPolicyCollection::deserializeFromArray($properties);
			}
		}
		return null;
	}

	private function setTermPolicyCollection($termId, TermPolicyCollection $collection) {
		update_term_meta($termId, self::TERM_POLICY_META_KEY, wp_slash(wp_json_encode($collection)));
		//todo: Probably need to update caches and lookups here.
	}

	/**
	 * @param Actor $actor
	 * @param string[]|null $postTypes
	 * @return int[]
	 */
	public function getPostsHiddenFromLists($actor, $postTypes = null) {
		if ( is_array($postTypes) && empty($postTypes) ) {
			return [];
		}

		$cacheKey = $this->getHiddenListItemCacheKey(Action::OBJECT_TYPE_POST, $actor, $postTypes);
		if ( isset($this->hiddenListItemCache[$cacheKey]) ) {
			return $this->hiddenListItemCache[$cacheKey];
		}

		$lookup = $this->getRestrictedPostLookup();
		$restrictedPosts = $lookup->getRestrictedPosts(
			$actor,
			$this->actionRegistry->getAction(ActionRegistry::ACTION_VIEW_IN_LISTS),
			$postTypes
		);

		$this->hiddenListItemCache[$cacheKey] = $restrictedPosts;
		return $restrictedPosts;
	}

	/**
	 * Check if there might be *any* posts that the current user isn't allowed to read or view in lists.
	 *
	 * @return bool
	 */
	public function mightHaveHiddenPosts(Actor $actor) {
		$lookup = $this->getRestrictedPostLookup();
		$actions = [
			$this->actionRegistry->getAction(ActionRegistry::ACTION_READ),
			$this->actionRegistry->getAction(ActionRegistry::ACTION_VIEW_IN_LISTS),
		];
		return $lookup->hasPotentialRestrictions($actor, $actions);
	}

	private function getRestrictedPostLookup() {
		if ( $this->restrictedPostLookup === null ) {
			$storedData = get_option(self::HIDDEN_ITEM_LOOKUP_OPTION, '{}');
			$parsed = json_decode((string)$storedData, true);

			if ( !empty($parsed) && is_array($parsed) ) {
				$this->restrictedPostLookup = new RestrictedPostLookup($parsed);
			} else {
				$this->restrictedPostLookup = new RestrictedPostLookup();
			}
		}
		return $this->restrictedPostLookup;
	}

	private function saveRestrictedPostLookup(RestrictedPostLookup $lookup) {
		update_option(self::HIDDEN_ITEM_LOOKUP_OPTION, wp_json_encode($lookup));
	}

	/**
	 * Remove a post from the restricted post lookup when it's deleted.
	 *
	 * This is a hook callback and should not be called directly.
	 *
	 * @param int $postId
	 * @return void
	 * @internal
	 */
	public function forgetDeletedPost($postId) {
		if ( !is_numeric($postId) || ($postId <= 0) ) {
			return;
		}

		$lookup = $this->getRestrictedPostLookup();
		$lookup->forgetPost($postId);
		if ( $lookup->isModified() ) {
			$this->saveRestrictedPostLookup($lookup);
		}
	}

	/**
	 * @param string $objectType
	 * @param Actor $actor
	 * @param array|null $objectWpTypes
	 * @return string
	 */
	private function getHiddenListItemCacheKey($objectType, Actor $actor, $objectWpTypes) {
		$key = $objectType . '|' . $actor->getId() . '|';
		if ( $objectWpTypes ) {
			$key .= implode(',', $objectWpTypes);
		}
		return $key;
	}
}

class Action implements \JsonSerializable {
	protected $name;

	/**
	 * @var string|null
	 */
	protected $label = null;
	/**
	 * @var null|callable
	 */
	protected $labelGenerator = null;

	/**
	 * @var string|null
	 */
	protected $description = null;
	/**
	 * @var null|callable
	 */
	protected $descriptionGenerator = null;

	const OBJECT_TYPE_POST = 'post';
	const OBJECT_TYPE_TERM = 'term';

	protected $supportedObjectTypes = [];
	/**
	 * @var string|null
	 */
	private $postMetaCap;
	/**
	 * @var string|null
	 */
	private $termMetaCap;
	/**
	 * @var string|null
	 */
	private $group;

	/**
	 * @param string $name
	 * @param array $supportedObjectTypes
	 * @param string|callable $label
	 * @param string|null $group
	 * @param string|callable $description
	 * @param string|null $postMetaCap
	 * @param string|null $termMetaCap
	 */
	public function __construct(
		string  $name,
		array   $supportedObjectTypes,
		        $label,
		?string $group = null,
		        $description = '',
		?string $postMetaCap = null,
		?string $termMetaCap = null
	) {
		$this->name = $name;
		$this->supportedObjectTypes = $supportedObjectTypes;
		$this->postMetaCap = $postMetaCap;
		$this->termMetaCap = $termMetaCap;

		if ( is_string($label) ) {
			$this->label = $label;
		} else if ( $label !== null ) {
			$this->labelGenerator = $label;
		}

		if ( is_string($description) ) {
			$this->description = $description;
		} else if ( $description !== null ) {
			$this->descriptionGenerator = $description;
		}
		$this->group = $group;
	}

	/**
	 * @return string
	 */
	public function getName() {
		return $this->name;
	}

	public function getLabel(?\WP_Post_Type $postType = null, ?\WP_Taxonomy $taxonomy = null): string {
		if ( ($this->label === null) && is_callable($this->labelGenerator) ) {
			return call_user_func($this->labelGenerator, $postType, $taxonomy);
		}
		return $this->label ?? '';
	}

	/**
	 * @return string
	 */
	public function getDescription() {
		if ( ($this->description === null) && is_callable($this->descriptionGenerator) ) {
			$this->description = call_user_func($this->descriptionGenerator);
		}
		return $this->description;
	}

	/**
	 * Checks if the given object type is supported by this action.
	 *
	 * @param string $objectType
	 * @param mixed $nativeObject The specific instance of the object type. This can be a post object, term object, etc.
	 * @return bool
	 * @noinspection PhpUnusedParameterInspection ($objectInstance can be used in subclasses)
	 */
	public function appliesToObject($objectType, $nativeObject) {
		return in_array($objectType, $this->supportedObjectTypes, true);
	}

	/**
	 * @return string|null
	 */
	public function getPostMetaCap() {
		return $this->postMetaCap;
	}

	public function getTermMetaCap(): ?string {
		return $this->termMetaCap;
	}

	/** @noinspection PhpLanguageLevelInspection */
	#[\ReturnTypeWillChange]
	public function jsonSerialize() {
		return [
			'name'        => $this->name,
			'description' => $this->getDescription(),
			'group'       => $this->group,
		];
	}
}

class ActionRegistry {
	const ACTION_EDIT = 'edit';
	const ACTION_DELETE = 'delete';
	const ACTION_READ = 'read';
	const ACTION_VIEW_IN_LISTS = 'view_in_lists';
	const ACTION_PUBLISH = 'publish';

	const ACTION_READ_ASSOCIATED_OBJECTS = 'read_associated_objects';
	const ACTION_EDIT_ASSOCIATED_OBJECTS = 'edit_associated_objects';
	const ACTION_DELETE_ASSOCIATED_OBJECTS = 'delete_associated_objects';
	const ACTION_VIEW_ASSOCIATED_OBJECTS_IN_LISTS = 'view_associated_objects_in_lists';
	const ACTION_ASSIGN_TERM_TO_OBJECTS = 'assign_term_to_objects';

	const GROUP_ASSOCIATED_POSTS = 'associated_posts';
	const GROUP_THIS_TERM = 'this_term';

	/**
	 * @var Action[]
	 */
	protected $actions = [];

	public function __construct() {
		$this->addAction(new Action(
			self::ACTION_READ,
			[Action::OBJECT_TYPE_POST],
			function () {
				return _x('Read', 'content permissions: action name', 'admin-menu-editor');
			}, null,
			'',
			'read_post'
		));
		$this->addAction(new Action(
			self::ACTION_VIEW_IN_LISTS,
			[Action::OBJECT_TYPE_POST],
			function () {
				return _x('View in lists', 'content permissions: action name', 'admin-menu-editor');
			}, null,
			function () {
				return __(
					'Applies to places like the post list in the admin dashboard and archive pages on the front end.',
					'admin-menu-editor'
				);
			},
			'read_post'
		));
		$this->addAction(new Action(
			self::ACTION_EDIT,
			[Action::OBJECT_TYPE_POST],
			function () {
				return _x('Edit', 'content permissions: action name', 'admin-menu-editor');
			}, null,
			'',
			'edit_post'
		));
		$this->addAction(new Action(
			self::ACTION_DELETE,
			[Action::OBJECT_TYPE_POST],
			function () {
				return _x('Delete', 'content permissions: action name', 'admin-menu-editor');
			}, null,
			'',
			'delete_post'
		));
		$this->addAction(new Action(
			self::ACTION_PUBLISH,
			[Action::OBJECT_TYPE_POST],
			function () {
				return _x('Publish', 'content permissions: action name', 'admin-menu-editor');
			}, null,
			'',
			'publish_post'
		));

		//Term actions for associated objects
		$this->addAction(new Action(
			self::ACTION_READ_ASSOCIATED_OBJECTS,
			[Action::OBJECT_TYPE_TERM],
			self::labelHelper(function () {
				/* translators: %1$s: post type label, %2$s: taxonomy label */
				return _x('Read %1$s', 'content permissions: action name', 'admin-menu-editor');
			}),
			self::GROUP_ASSOCIATED_POSTS,
			'',
			'read_post'
		));
		//todo: Should disabling this also prevent the term itself from appearing in the term list?
		// We probably don't want too many granular options here.
		$this->addAction(new Action(
			self::ACTION_VIEW_ASSOCIATED_OBJECTS_IN_LISTS,
			[Action::OBJECT_TYPE_TERM],
			self::labelHelper(function () {
				/* translators: %1$s: post type label */
				return _x('View %1$s in lists', 'content permissions: action name', 'admin-menu-editor');
			}),
			self::GROUP_ASSOCIATED_POSTS,
			'',
			'read_post'
		));
		$this->addAction(new Action(
			self::ACTION_EDIT_ASSOCIATED_OBJECTS,
			[Action::OBJECT_TYPE_TERM],
			self::labelHelper(function () {
				/* translators: %1$s: post type label, %2$s: taxonomy label */
				return _x('Edit %1$s', 'content permissions: action name', 'admin-menu-editor');
			}),
			self::GROUP_ASSOCIATED_POSTS,
			'',
			'edit_post'
		));
		$this->addAction(new Action(
			self::ACTION_DELETE_ASSOCIATED_OBJECTS,
			[Action::OBJECT_TYPE_TERM],
			self::labelHelper(function () {
				/* translators: %1$s: post type label, %2$s: taxonomy label */
				return _x('Delete %1$s', 'content permissions: action name', 'admin-menu-editor');
			}),
			self::GROUP_ASSOCIATED_POSTS,
			'',
			'delete_post'
		));

		$this->addAction(new Action(
			self::ACTION_ASSIGN_TERM_TO_OBJECTS,
			[Action::OBJECT_TYPE_TERM],
			self::labelHelper(function () {
				/* translators: %1$s: post type label, %2$s: taxonomy label */
				return _x('Assign this %2$s to %1$s', 'content permissions: action name', 'admin-menu-editor');
			}),
			self::GROUP_THIS_TERM,
			'',
			null,
			'assign_term'
		));
	}

	public function addAction(Action $action) {
		$this->actions[$action->getName()] = $action;
	}

	/**
	 * @param string $name
	 * @return Action|null
	 */
	public function getAction($name) {
		return isset($this->actions[$name]) ? $this->actions[$name] : null;
	}

	/**
	 * @param string $objectType
	 * @param mixed $nativeObject
	 * @return Action[]
	 */
	public function getApplicableActions($objectType, $nativeObject = null) {
		$actions = [];
		foreach ($this->actions as $action) {
			if ( $action->appliesToObject($objectType, $nativeObject) ) {
				$actions[] = $action;
			}
		}
		return $actions;
	}

	private static function labelHelper(callable $templateFn): \Closure {
		return function (?\WP_Post_Type $postType = null, ?\WP_Taxonomy $taxonomy = null) use ($templateFn) {
			return sprintf(
				call_user_func($templateFn),
				$postType ? $postType->labels->name : __('objects', 'admin-menu-editor'),
				$taxonomy ? $taxonomy->labels->singular_name : __('term', 'admin-menu-editor')
			);
		};
	}
}

class EvaluationResult {
	protected $effect;
	protected $actor;
	protected $action;
	protected $objectId;

	/**
	 * @var ContentItemPolicy $usedAccessPolicy The policy that produced this result.
	 * This can be different from $objectPolicy if the policy was inherited from a parent object.
	 */
	protected $usedAccessPolicy;

	/**
	 * @var ContentItemPolicy|null $objectPolicy The policy directly associated with the object.
	 * This can be NULL if the object doesn't have a policy of its own.
	 */
	protected $objectPolicy;


	public function __construct(
		$effect, Actor $actor, Action $action,
		ContentItemPolicy $policy, ?ContentItemPolicy $objectPolicy = null, $objectId = null
	) {
		$this->effect = $effect;
		$this->actor = $actor;
		$this->action = $action;

		$this->usedAccessPolicy = $policy;
		$this->objectPolicy = $objectPolicy;
		$this->objectId = $objectId;
	}

	public function getEffect() {
		return $this->effect;
	}

	/**
	 * @return Actor
	 */
	public function getActor() {
		return $this->actor;
	}

	/**
	 * @return Action
	 */
	public function getAction() {
		return $this->action;
	}

	/**
	 * @return ContentItemPolicy
	 */
	public function getPolicy() {
		if ( $this->objectPolicy ) {
			return $this->objectPolicy;
		}
		return $this->usedAccessPolicy;
	}

	/**
	 * @return ContentItemPolicy
	 */
	public function getUsedAccessPolicy() {
		return $this->usedAccessPolicy;
	}

	/**
	 * @return ContentItemPolicy
	 */
	public function getObjectPolicy() {
		return $this->objectPolicy;
	}

	/**
	 * @return mixed|null
	 */
	public function getObjectId() {
		return $this->objectId;
	}

	public function isDenied() {
		return $this->effect === ContentItemPolicy::EFFECT_DENY;
	}

	public function isAllowed() {
		return $this->effect === ContentItemPolicy::EFFECT_ALLOW;
	}
}

abstract class AccessProtection implements \JsonSerializable {
	/**
	 * @param \WP_Post $post Current post.
	 * @param \WP_Post[] $posts List of posts for the current query.
	 * @param \WP_Query|null $query The current query, if applicable.
	 * @return \WP_Post[]|null Filtered list of posts, or NULL to leave the list unchanged.
	 */
	abstract public function enforce($post, $posts = [], $query = null);

	abstract protected function toArray();

	/**
	 * @param array $properties
	 * @param ContentItemPolicy|null $ownerPolicy The policy that owns the protection that's being deserialized.
	 * @return AccessProtection
	 */
	public static function fromArray($properties, ?ContentItemPolicy $ownerPolicy = null) {
		if ( !isset($properties['tag']) ) {
			throw new \InvalidArgumentException('Invalid properties array for an access protection object');
		}

		$tag = $properties['tag'];
		switch ($tag) {
			case RedirectProtection::$tag:
				return RedirectProtection::deserializeFromArray($properties);
			case ErrorMessageProtection::$tag:
				//If the error message isn't set, it defaults to the replacement content
				//set for the policy that owns this protection.
				$messageCallback = null;
				if ( $ownerPolicy ) {
					$messageCallback = [$ownerPolicy, 'getReplacementContent'];
				}
				return ErrorMessageProtection::deserializeFromArray($properties, $messageCallback);
			case NotFoundProtection::$tag:
				return new NotFoundProtection();
			case ContentReplacementProtection::$tag:
				return new ContentReplacementProtection();
			default:
				throw new \InvalidArgumentException('Unknown access protection tag: ' . esc_html($tag));
		}
	}

	public function getTag() {
		return static::$tag;
	}

	/** @noinspection PhpLanguageLevelInspection */
	#[\ReturnTypeWillChange]
	public function jsonSerialize() {
		return $this->toArray();
	}
}

class RedirectProtection extends AccessProtection {
	protected static $tag = 'redirect';

	const REDIRECT_ACTION = 'template_redirect';
	const DEFAULT_REDIRECT_CODE = 307;

	/**
	 * @var string $targetUrl
	 */
	private $targetUrl;

	private $shortcodesEnabled;
	private $redirectCode;

	public function __construct($targetUrl, $redirectCode = self::DEFAULT_REDIRECT_CODE, $shortcodesEnabled = false) {
		$this->targetUrl = is_string($targetUrl) ? $targetUrl : '';
		$this->shortcodesEnabled = boolval($shortcodesEnabled);

		$code = intval($redirectCode);
		if ( ($code >= 300) && ($code <= 399) ) {
			$this->redirectCode = $code;
		} else {
			$this->redirectCode = self::DEFAULT_REDIRECT_CODE;
		}
	}

	public function enforce($post, $posts = [], $query = null) {
		if ( did_action(self::REDIRECT_ACTION) ) {
			$this->performRedirect();
			return [];
		} else {
			add_action(self::REDIRECT_ACTION, [$this, 'performRedirect']);
		}

		return [];
	}

	public function performRedirect() {
		$url = $this->targetUrl;
		if ( empty($url) ) {
			$url = $this->getFallbackUrl();
		}

		$parsed = wp_parse_url($url);
		if ( empty($parsed) ) {
			$url = $this->getFallbackUrl();
		}

		if ( $this->shortcodesEnabled ) {
			$url = do_shortcode($url);
		}

		//The user provides the redirect URL, and they can choose an external URL if they want.
		//phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
		if ( wp_redirect($url, $this->redirectCode) ) {
			exit();
		} else {
			wp_die('Error: Redirect failed', 500);
		}
	}

	private function getFallbackUrl() {
		return home_url();
	}

	protected function toArray() {
		$result = ['tag' => static::$tag];
		if ( $this->redirectCode !== self::DEFAULT_REDIRECT_CODE ) {
			$result['redirectCode'] = $this->redirectCode;
		}
		if ( $this->shortcodesEnabled ) {
			$result['shortcodesEnabled'] = true;
		}
		if ( !empty($this->targetUrl) ) {
			$result['targetUrl'] = $this->targetUrl;
		}
		return $result;
	}

	protected static function deserializeFromArray($properties) {
		return new static(
			isset($properties['targetUrl']) ? strval($properties['targetUrl']) : '',
			isset($properties['redirectCode']) ? intval($properties['redirectCode']) : self::DEFAULT_REDIRECT_CODE,
			!empty($properties['shortcodesEnabled'])
		);
	}
}

class ErrorMessageProtection extends AccessProtection {
	protected static $tag = 'errorMessage';

	const FALLBACK_MESSAGE = 'Access denied';

	/**
	 * @var string
	 */
	private $errorMessage;
	/**
	 * @var callable|null
	 */
	private $backupMessageCallback;

	public function __construct($errorMessage = '', $backupMessageCallback = null) {
		$this->errorMessage = $errorMessage;
		$this->backupMessageCallback = $backupMessageCallback;
	}

	public function enforce($post, $posts = [], $query = null) {
		$message = '';
		if ( !empty($this->errorMessage) ) {
			$message = $this->errorMessage;
		} else if ( !empty($this->backupMessageCallback) ) {
			$message = call_user_func($this->backupMessageCallback);
		}

		if ( empty($message) ) {
			$message = self::FALLBACK_MESSAGE;
		}

		wp_die(wp_kses_post($message), 403);
	}

	protected function toArray() {
		$result = ['tag' => static::$tag];
		if ( !empty($this->errorMessage) ) {
			$result['errorMessage'] = $this->errorMessage;
		}
		return $result;
	}

	protected static function deserializeFromArray($properties, $messageCallback = null) {
		return new static(
			isset($properties['errorMessage']) ? strval($properties['errorMessage']) : '',
			$messageCallback
		);
	}
}

class NotFoundProtection extends AccessProtection {
	protected static $tag = 'notFound';

	public function enforce($post, $posts = [], $query = null) {
		if ( $query instanceof \WP_Query ) {
			$query->set_404();
		}
		return [];
	}

	protected function toArray() {
		return ['tag' => static::$tag];
	}
}

class ContentReplacementProtection extends AccessProtection {
	protected static $tag = 'replace';

	public function enforce($post, $posts = [], $query = null) {
		//The actual replacement is handled by the ContentPermissionsEnforcer class which
		//covers both direct access and indirect access (e.g. via category archives). This
		//class is a placeholder for the protection type, and helps with serialization.
		return null;
	}

	protected function toArray() {
		return ['tag' => static::$tag];
	}
}

class AccessProtectionSettings implements \JsonSerializable {
	/**
	 * @var AccessProtection|null
	 */
	private $activeProtection;
	/**
	 * @var array<string,AccessProtection>
	 */
	private $configuredProtectionTypes;

	public function __construct(?AccessProtection $activeProtection = null, $configuredProtectionTypes = []) {
		$this->activeProtection = $activeProtection;
		$this->configuredProtectionTypes = $configuredProtectionTypes;

		if ( $this->activeProtection ) {
			$this->configuredProtectionTypes[$activeProtection->getTag()] = $activeProtection;
		}
	}

	/**
	 * @return AccessProtection|null
	 */
	public function getActiveProtection() {
		return $this->activeProtection;
	}

	/**
	 * @return array<string,AccessProtection>
	 */
	public function getConfiguredProtectionTypes() {
		return $this->configuredProtectionTypes;
	}

	public static function fromArray($data) {
		$configuredProtectionTypes = [];
		if ( isset($data['protections']) && is_array($data['protections']) ) {
			foreach ($data['protections'] as $protectionData) {
				$protection = AccessProtection::fromArray($protectionData);
				$configuredProtectionTypes[$protection->getTag()] = $protection;
			}
		}

		$activeProtection = null;
		if ( isset($data['active']) ) {
			$activeTag = $data['active'];
			if ( isset($configuredProtectionTypes[$activeTag]) ) {
				$activeProtection = $configuredProtectionTypes[$activeTag];
			} else {
				$activeProtection = AccessProtection::fromArray(['tag' => $activeTag]);
			}
		}

		return new static($activeProtection, $configuredProtectionTypes);
	}

	/** @noinspection PhpLanguageLevelInspection */
	#[\ReturnTypeWillChange]
	public function jsonSerialize() {
		$data = [];
		if ( $this->activeProtection ) {
			$data['active'] = $this->activeProtection->getTag();
		}

		//We want to preserve protection settings (e.g. redirect URL) even if the corresponding
		//protection type isn't currently active. So we store all protection types that have any
		//serialized data (minus the tag).
		if ( !empty($this->configuredProtectionTypes) ) {
			$protections = [];
			foreach ($this->configuredProtectionTypes as $protection) {
				$serialized = $protection->jsonSerialize();
				if ( count($serialized) > 1 ) {
					$protections[$protection->getTag()] = $serialized;
				}
			}

			if ( !empty($protections) ) {
				$data['protections'] = $protections;
			}
		}
		return $data;
	}
}

class RestrictedPostLookup implements \JsonSerializable {
	/**
	 * @var array<string,array>
	 */
	protected $postAccess;

	private $wasModified = false;

	public function __construct($properties = []) {
		if ( isset($properties['posts']) ) {
			$this->postAccess = $properties['posts'];
		} else {
			$this->postAccess = [];
		}
	}

	/**
	 * @param Actor $actor
	 * @param Action $action
	 * @param array $postTypes
	 * @return int[]
	 */
	public function getRestrictedPosts(Actor $actor, Action $action, $postTypes = null) {
		$actionName = $action->getName();

		$allowed = [];
		$denied = [];

		foreach ($actor->flatten() as $componentActor) {
			$actorId = $componentActor->getId();
			$priority = $componentActor->getPriority();

			$actorSettings = \ameMultiDictionary::get($this->postAccess, [$actorId, $actionName], []);
			if ( $postTypes === null ) {
				$relevantPostTypes = $actorSettings;
			} else {
				$relevantPostTypes = [];
				foreach ($postTypes as $postType) {
					if ( isset($actorSettings[$postType]) ) {
						$relevantPostTypes[$postType] = $actorSettings[$postType];
					}
				}
			}

			foreach ($relevantPostTypes as $postIds) {
				foreach ($postIds as $postId => $isAllowed) {
					if ( isset($allowed[$postId]) ) {
						//Already allowed for another actor of the same or higher priority.
						continue;
					}

					if ( $isAllowed ) {
						//Allow unless it was denied for an actor with a higher priority.
						//For example, a "deny" for a user could override an "allow" for a role,
						//but not vice versa.
						$hasPriorityDeny = isset($denied[$postId]) && ($denied[$postId] > $priority);
						if ( !$hasPriorityDeny ) {
							$allowed[$postId] = $priority;
							unset($denied[$postId]);
						}
					} else {
						//Due to the earlier "isset($allowed[$postId])" check, we only get here
						//if there's no higher-priority actor that would override this setting.
						$denied[$postId] = $priority;
					}
				}
			}
		}

		return array_keys($denied);
	}

	/**
	 * @param Actor $actor
	 * @param Action[] $actions
	 * @return bool
	 */
	public function hasPotentialRestrictions(Actor $actor, $actions) {
		foreach ($actor->flatten() as $componentActor) {
			$actorId = $componentActor->getId();
			foreach ($actions as $action) {
				$actionName = $action->getName();
				if ( !empty($this->postAccess[$actorId][$actionName]) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * @param string $actorId
	 * @param Action $action
	 * @param string $postType
	 * @param int $postId
	 * @param bool $isAllowed
	 * @return void
	 */
	public function addEntry($actorId, Action $action, $postType, $postId, $isAllowed) {
		$actionName = $action->getName();
		$isAllowed = boolval($isAllowed);

		$path = [$actorId, $actionName, $postType, $postId];
		$oldValue = \ameMultiDictionary::get($this->postAccess, $path);
		if ( $oldValue === $isAllowed ) {
			return;
		}

		\ameMultiDictionary::set($this->postAccess, $path, $isAllowed);
		$this->wasModified = true;
	}

	public function removeEntry($actorId, Action $action, $postType, $postId) {
		$actionName = $action->getName();

		if ( isset($this->postAccess[$actorId][$actionName][$postType][$postId]) ) {
			unset($this->postAccess[$actorId][$actionName][$postType][$postId]);
			$this->wasModified = true;
		}
	}

	public function forgetPost($postId) {
		foreach ($this->postAccess as $actorId => $actions) {
			foreach ($actions as $actionName => $postTypes) {
				foreach ($postTypes as $postType => $postIds) {
					if ( isset($postIds[$postId]) ) {
						unset($this->postAccess[$actorId][$actionName][$postType][$postId]);
						$this->wasModified = true;
					}
				}
			}
		}
	}

	public function isModified() {
		return $this->wasModified;
	}

	public function clearModifiedFlag() {
		$this->wasModified = false;
	}

	public function toArray() {
		//Optimization: Keep only non-empty arrays.
		$filtered = [];
		foreach ($this->postAccess as $actorId => $actions) {
			foreach ($actions as $actionName => $postTypes) {
				foreach ($postTypes as $postType => $postIds) {
					if ( !empty($postIds) ) {
						\ameMultiDictionary::set($filtered, [$actorId, $actionName, $postType], $postIds);
					}
				}
			}
		}
		return [
			'posts' => $filtered,
		];
	}

	public static function deserializeFromArray($properties) {
		$restrictedPosts = [];
		if ( isset($properties['posts']) && is_array($properties['posts']) ) {
			$restrictedPosts = $properties['posts'];
		}
		return new static($restrictedPosts);
	}

	/** @noinspection PhpLanguageLevelInspection */
	#[\ReturnTypeWillChange]
	public function jsonSerialize() {
		return $this->toArray();
	}
}

class TermPolicyCollection implements \JsonSerializable {
	/**
	 * @var array<string,ContentItemPolicy> Keyed by post type
	 */
	private array $policies = [];
	/**
	 * @var ContentItemPolicy|null Universal policy that applies to all post types, if any.
	 */
	private ?ContentItemPolicy $universalPolicy = null;

	/**
	 * @param string $postType
	 * @return ContentItemPolicy|null
	 */
	public function getPolicyForPostType(string $postType): ?ContentItemPolicy {
		if ( isset($this->policies[$postType]) ) {
			return $this->policies[$postType];
		}
		return null;
	}

	/**
	 * @return ContentItemPolicy|null
	 */
	public function getUniversalPolicy(): ?ContentItemPolicy {
		return $this->universalPolicy;
	}

	/**
	 * @param string $postType
	 * @param ContentItemPolicy|null $policy
	 * @return void
	 */
	public function setPolicyForPostType(string $postType, ?ContentItemPolicy $policy): void {
		if ( $policy ) {
			$this->policies[$postType] = $policy;
		} else {
			unset($this->policies[$postType]);
		}
	}

	/**
	 * @param ContentItemPolicy|null $policy
	 * @return void
	 */
	public function setUniversalPolicy(?ContentItemPolicy $policy): void {
		$this->universalPolicy = $policy;
	}

	/** @noinspection PhpLanguageLevelInspection
	 * @noinspection PhpMissingReturnTypeInspection
	 */
	#[\ReturnTypeWillChange]
	public function jsonSerialize() {
		$data = [];
		if ( $this->universalPolicy !== null ) {
			$data['universal'] = $this->universalPolicy;
		}
		if ( !empty($this->policies) ) {
			$data['byPostType'] = $this->policies;
		}
		return $data;
	}

	public static function deserializeFromArray(array $data): TermPolicyCollection {
		$collection = new TermPolicyCollection();

		if ( isset($data['universal']) && is_array($data['universal']) ) {
			$collection->setUniversalPolicy(ContentItemPolicy::fromArray($data['universal']));
		}

		if ( isset($data['byPostType']) && is_array($data['byPostType']) ) {
			foreach ($data['byPostType'] as $postType => $policyData) {
				if ( is_array($policyData) ) {
					$policy = ContentItemPolicy::fromArray($policyData);
					$collection->setPolicyForPostType($postType, $policy);
				}
			}
		}

		return $collection;
	}
}