<?php
/**
 * REST capability policy.
 *
 * Encodes the §3.2 ("認可ポリシー") and §3.3 ("専用 capability 候補")
 * authorization mappings documented in
 * `specifications/rest-api-spec.md` so every REST controller resolves
 * its required-capability list through one well-known seam rather
 * than scattering capability literals across boot methods.
 *
 * The policy is expressed as a static lookup keyed by a stable
 * "route token" string (for example `'products.search'`,
 * `'sync.run'`, `'settings.write'`). Each route token resolves to an
 * OR-list of capability tokens: a caller is authorized if ANY entry
 * in the list is currently held. The OR-shape directly reflects the
 * §3.3 "初期版では以下でもよい" fallback policy, where the canonical
 * `affilun_*` capability can be granted to a dedicated role while the
 * generic `edit_posts` / `manage_options` capabilities remain a
 * functional fallback for sites that have not customized roles yet.
 *
 * Field semantics match `rest-api-spec.md` §3.2 / §3.3 exactly:
 *
 *   - The §3.3 candidate set is exposed as `CAP_*` constants so call
 *     sites can reference the exact token without duplicating the
 *     literal across the codebase. The two initial-version fallback
 *     capabilities (`edit_posts`, `manage_options`) are exposed as
 *     `CAP_FALLBACK_*` constants for the same reason.
 *
 *   - The §3.2 read-side mapping is enumerated by
 *     {@see self::requiredForRead()}; the §3.2 write-side mapping is
 *     enumerated by {@see self::requiredForWrite()}. Unknown route
 *     tokens deliberately deny by default to `manage_options` rather
 *     than throw, because REST routes registered by future slices
 *     may legitimately introduce new tokens before this class is
 *     updated.
 *
 *   - {@see self::isAuthorized()} returns true when the supplied
 *     `$required` list is empty (no auth needed for that route) OR
 *     the {@see RequestContext} carries any of the required tokens.
 *
 * The policy is stateless and exposed entirely as static methods so
 * controllers, the {@see \Affilun\Presentation\Rest\Routing\RouteRegistrar}
 * permission callbacks, and tests can resolve a route's required-
 * capability list without instantiating anything.
 *
 * @package Affilun\Presentation\Rest\Permission
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Permission;

use Affilun\Presentation\Rest\RequestContext;

/**
 * Static §3.2 / §3.3 capability policy for the REST surface.
 *
 * See the file-level docblock for the mapping to
 * `rest-api-spec.md` §3.2 / §3.3, the OR-list shape rationale, and
 * the deny-by-default policy for unknown route tokens.
 *
 * ### §9.5 supporting verification
 *
 * The route-token mapping enforces the §3.2 capability-tier separation:
 *
 *   - Edit-tier routes (products.*, embeds.*, editor.preview /
 *     editor.templates / editor.providers) require either
 *     `affilun_manage_products` or its `edit_posts` fallback. Editors
 *     reach these.
 *   - Admin-tier routes (diagnostics.read, logs.read, settings.write,
 *     sync.run, cache.purge) require `affilun_view_diagnostics` /
 *     `affilun_view_logs` / `affilun_manage_settings` / `affilun_run_sync` or
 *     their respective `manage_options` fallback. Editors carrying
 *     only `edit_posts` are denied.
 *
 * Unknown route tokens deny by default to `manage_options`. The
 * capability check is symmetric across all routes — anonymous users
 * (no capabilities) fail every required-cap probe and receive 403
 * via the controller's forbidden-envelope path.
 */
final class CapabilityPolicy {

	/**
	 * §3.3 candidate capability for managing plugin settings.
	 */
	public const CAP_MANAGE_SETTINGS = 'affilun_manage_settings';

	/**
	 * §3.3 candidate capability for managing product / embed records.
	 */
	public const CAP_MANAGE_PRODUCTS = 'affilun_manage_products';

	/**
	 * §3.3 candidate capability for triggering sync / cache work.
	 */
	public const CAP_RUN_SYNC = 'affilun_run_sync';

	/**
	 * §3.3 candidate capability for reading the operational log
	 * surface.
	 */
	public const CAP_VIEW_LOGS = 'affilun_view_logs';

	/**
	 * §3.3 candidate capability for reading the diagnostics surface.
	 */
	public const CAP_VIEW_DIAGNOSTICS = 'affilun_view_diagnostics';

	/**
	 * §3.3 initial-version fallback capability for editor-side
	 * surfaces (`edit_posts`).
	 */
	public const CAP_FALLBACK_EDIT = 'edit_posts';

	/**
	 * §3.3 initial-version fallback capability for administrator-side
	 * surfaces (`manage_options`).
	 */
	public const CAP_FALLBACK_MANAGE = 'manage_options';

	/**
	 * §3.2 read-side mapping from route token to OR-list of capability
	 * tokens.
	 *
	 * Held as a private constant so {@see self::requiredForRead()}
	 * and {@see self::validRoutes()} share the same source of truth.
	 *
	 * @var array<string, list<string>>
	 */
	private const READ_MAP = array(
		'products.search'      => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'products.list'        => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'products.read'        => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'embeds.list'          => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'embeds.read'          => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'editor.providers'     => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'editor.templates'     => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'diagnostics.read'     => array( self::CAP_VIEW_DIAGNOSTICS, self::CAP_FALLBACK_MANAGE ),
		'logs.read'            => array( self::CAP_VIEW_LOGS, self::CAP_FALLBACK_MANAGE ),
		'sync.jobs.read'       => array( self::CAP_RUN_SYNC, self::CAP_FALLBACK_MANAGE ),
		'audit.read'           => array( self::CAP_VIEW_DIAGNOSTICS, self::CAP_FALLBACK_MANAGE ),
		'audit.summary'        => array( self::CAP_VIEW_DIAGNOSTICS, self::CAP_FALLBACK_MANAGE ),
		'settings.read.public' => array( self::CAP_FALLBACK_EDIT ),
		'settings.cloud.plans' => array( self::CAP_MANAGE_SETTINGS, self::CAP_FALLBACK_MANAGE ),
	);

	/**
	 * §3.2 write-side mapping from route token to OR-list of
	 * capability tokens.
	 *
	 * Held as a private constant so {@see self::requiredForWrite()}
	 * and {@see self::validRoutes()} share the same source of truth.
	 *
	 * @var array<string, list<string>>
	 */
	private const WRITE_MAP = array(
		'products.write'                   => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'embeds.write'                     => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'editor.preview'                   => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'settings.ai.extract_keywords'     => array( self::CAP_MANAGE_PRODUCTS, self::CAP_FALLBACK_EDIT ),
		'clicks.write'                     => array(),
		'sync.run'                         => array( self::CAP_RUN_SYNC, self::CAP_FALLBACK_MANAGE ),
		'cache.purge'                      => array( self::CAP_RUN_SYNC, self::CAP_FALLBACK_MANAGE ),
		'settings.write'                   => array( self::CAP_MANAGE_SETTINGS, self::CAP_FALLBACK_MANAGE ),
		'settings.cloud.apply_credentials' => array( self::CAP_MANAGE_SETTINGS, self::CAP_FALLBACK_MANAGE ),
		'settings.cloud.checkout_session'  => array( self::CAP_MANAGE_SETTINGS, self::CAP_FALLBACK_MANAGE ),
		'settings.cloud.provision_site'    => array( self::CAP_MANAGE_SETTINGS, self::CAP_FALLBACK_MANAGE ),
		'settings.cloud.connection_email'  => array( self::CAP_MANAGE_SETTINGS, self::CAP_FALLBACK_MANAGE ),
		'settings.cloud.status'            => array( self::CAP_MANAGE_SETTINGS, self::CAP_FALLBACK_MANAGE ),
		'settings.cloud.connection_token'  => array( self::CAP_MANAGE_SETTINGS, self::CAP_FALLBACK_MANAGE ),
	);

	/**
	 * Deny-by-default OR-list returned for unknown route tokens.
	 *
	 * The policy is to require the strongest fallback
	 * (`manage_options`) so a future slice that registers a route
	 * without updating this class fails closed rather than open.
	 *
	 * @var list<string>
	 */
	private const DENY_BY_DEFAULT = array( self::CAP_FALLBACK_MANAGE );

	/**
	 * Return the §3.2 read-side OR-list of capabilities required for
	 * the given route token.
	 *
	 * Unknown route tokens deliberately deny by default to
	 * `manage_options` rather than throw — REST routes registered
	 * by future slices may legitimately introduce new tokens before
	 * this class is updated, and a deny-by-default response keeps
	 * the surface failing closed in the meantime.
	 *
	 * @param string $route Stable route token (case-sensitive).
	 *
	 * @return list<string>
	 */
	public static function requiredForRead( string $route ): array {
		return self::READ_MAP[ $route ] ?? self::DENY_BY_DEFAULT;
	}

	/**
	 * Return the §3.2 write-side OR-list of capabilities required for
	 * the given route token.
	 *
	 * See {@see self::requiredForRead()} for the deny-by-default
	 * policy on unknown tokens.
	 *
	 * @param string $route Stable route token (case-sensitive).
	 *
	 * @return list<string>
	 */
	public static function requiredForWrite( string $route ): array {
		return self::WRITE_MAP[ $route ] ?? self::DENY_BY_DEFAULT;
	}

	/**
	 * Report whether the supplied {@see RequestContext} satisfies the
	 * given OR-list of required capabilities.
	 *
	 * Returns true when `$required` is empty (no auth is needed for
	 * the route) OR the context carries any one of the required
	 * tokens.
	 *
	 * @param RequestContext $ctx      Per-request capability snapshot.
	 * @param list<string>   $required OR-list of capability tokens.
	 */
	public static function isAuthorized( RequestContext $ctx, array $required ): bool {
		if ( $required === array() ) {
			return true;
		}

		foreach ( $required as $cap ) {
			if ( $ctx->hasCapability( $cap ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Return the full list of route tokens this policy currently
	 * recognizes (read-side + write-side, in declaration order).
	 *
	 * Used by tests to assert that every documented route token is
	 * mapped, and by diagnostics surfaces that want to enumerate the
	 * known routes.
	 *
	 * @return list<string>
	 */
	public static function validRoutes(): array {
		$tokens = array_merge(
			array_keys( self::READ_MAP ),
			array_keys( self::WRITE_MAP )
		);

		return array_values( $tokens );
	}
}
