<?php
/**
 * Versioned plugin capability registry and migration.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Security;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Owns the stable InboxMend capability contract for each WordPress site.
 */
final class Capabilities {
	const VERSION        = '1.0.0';
	const VERSION_OPTION = 'subin_capabilities_version';
	const LOCK_OPTION    = 'subin_capabilities_migration_lock';
	const LOCK_TTL       = 300;

	const ACCESS                = 'subin_access';
	const VIEW_LEADS            = 'subin_view_leads';
	const MANAGE_LEADS          = 'subin_manage_leads';
	const RETRY_NOTIFICATIONS   = 'subin_retry_notifications';
	const EXPORT_LEADS          = 'subin_export_leads';
	const DELETE_LEADS          = 'subin_delete_leads';
	const VIEW_FORMS            = 'subin_view_forms';
	const MANAGE_FORMS          = 'subin_manage_forms';
	const DELETE_FORMS          = 'subin_delete_forms';
	const MANAGE_AUTO_REPLIES   = 'subin_manage_auto_replies';
	const SEND_TEST_EMAILS      = 'subin_send_test_emails';
	const VIEW_EMAIL_HEALTH     = 'subin_view_email_health';
	const VIEW_EMAIL_LOG        = 'subin_view_email_log';
	const EXPORT_EMAIL_LOG      = 'subin_export_email_log';
	const MANAGE_SENDING        = 'subin_manage_sending';
	const MANAGE_SETTINGS       = 'subin_manage_settings';
	const MANAGE_CLOUD          = 'subin_manage_cloud';

	/**
	 * Return the complete stable capability allowlist.
	 *
	 * @return array<int,string>
	 */
	public static function all() {
		return array(
			self::ACCESS,
			self::VIEW_LEADS,
			self::MANAGE_LEADS,
			self::RETRY_NOTIFICATIONS,
			self::EXPORT_LEADS,
			self::DELETE_LEADS,
			self::VIEW_FORMS,
			self::MANAGE_FORMS,
			self::DELETE_FORMS,
			self::MANAGE_AUTO_REPLIES,
			self::SEND_TEST_EMAILS,
			self::VIEW_EMAIL_HEALTH,
			self::VIEW_EMAIL_LOG,
			self::EXPORT_EMAIL_LOG,
			self::MANAGE_SENDING,
			self::MANAGE_SETTINGS,
			self::MANAGE_CLOUD,
		);
	}

	/**
	 * Resolve the capability WordPress should use for the current request.
	 *
	 * The legacy fallback keeps existing manage_options users working while
	 * plugin pages are migrated to the granular capability registry.
	 *
	 * @param string $capability                  Registered plugin capability.
	 * @param bool   $allow_manage_options_fallback Whether manage_options may satisfy the check.
	 * @return string Empty when the current user is not allowed.
	 */
	public static function effective_capability( $capability, $allow_manage_options_fallback = false ) {
		$capability = sanitize_key( (string) $capability );
		if ( ! in_array( $capability, self::all(), true ) ) {
			return '';
		}

		if ( current_user_can( $capability ) ) {
			return $capability;
		}

		if ( $allow_manage_options_fallback && current_user_can( 'manage_options' ) ) {
			return 'manage_options';
		}

		return '';
	}

	/**
	 * Check a registered plugin capability for the current user.
	 *
	 * @param string $capability                  Registered plugin capability.
	 * @param bool   $allow_manage_options_fallback Whether manage_options may satisfy the check.
	 * @return bool
	 */
	public static function can( $capability, $allow_manage_options_fallback = false ) {
		return '' !== self::effective_capability( $capability, $allow_manage_options_fallback );
	}

	/**
	 * Check a plugin capability for the current user with legacy admin access.
	 *
	 * @param string $capability Registered plugin capability.
	 * @return bool
	 */
	public static function can_current_user( $capability ) {
		return self::can( $capability, true );
	}

	/**
	 * Check a plugin capability for a specific user with legacy admin access.
	 *
	 * @param int    $user_id    WordPress user ID.
	 * @param string $capability Registered plugin capability.
	 * @return bool
	 */
	public static function user_can( $user_id, $capability ) {
		$user_id    = absint( $user_id );
		$capability = sanitize_key( (string) $capability );
		if ( $user_id < 1 || ! in_array( $capability, self::all(), true ) ) {
			return false;
		}

		return \user_can( $user_id, $capability ) || \user_can( $user_id, 'manage_options' );
	}

	/**
	 * Upgrade capabilities only when this site's stored version is behind.
	 *
	 * @return true|\WP_Error
	 */
	public static function maybe_upgrade() {
		$stored_version = (string) get_option( self::VERSION_OPTION, '' );
		if ( '' !== $stored_version && ! version_compare( $stored_version, self::VERSION, '<' ) && ! self::roles_need_upgrade() ) {
			return true;
		}

		return self::install_or_upgrade();
	}

	/**
	 * Add the current registry to every role that already manages WordPress.
	 *
	 * Existing manage_options roles already had unrestricted InboxMend access,
	 * so this migration preserves access without expanding a limited role.
	 *
	 * @return true|\WP_Error
	 */
	public static function install_or_upgrade() {
		$lock_token = self::acquire_lock();
		if ( is_wp_error( $lock_token ) ) {
			return $lock_token;
		}

		try {
			$role_names = self::role_names();
			$targets    = array();

			foreach ( $role_names as $role_name ) {
				$role = get_role( $role_name );
				if ( $role && $role->has_cap( 'manage_options' ) ) {
					$targets[] = $role_name;
				}
			}

			if ( empty( $targets ) ) {
				return new \WP_Error(
					'subin_capability_admin_role_missing',
					__( 'InboxMend could not find a site role that manages WordPress options.', 'submission-inbox' )
				);
			}

			foreach ( $targets as $role_name ) {
				$role = get_role( $role_name );
				if ( ! $role ) {
					return new \WP_Error(
						'subin_capability_role_unavailable',
						__( 'InboxMend could not load a role while updating access permissions.', 'submission-inbox' )
					);
				}

				foreach ( self::all() as $capability ) {
					if ( ! $role->has_cap( $capability ) ) {
						$role->add_cap( $capability );
					}
				}
			}

			$verified = self::verify_roles( $targets );
			if ( is_wp_error( $verified ) ) {
				return $verified;
			}

			$stored_version = (string) get_option( self::VERSION_OPTION, '' );
			if ( '' === $stored_version || version_compare( $stored_version, self::VERSION, '<' ) ) {
				update_option( self::VERSION_OPTION, self::VERSION, false );
				$stored_version = (string) get_option( self::VERSION_OPTION, '' );
			}
			if ( version_compare( $stored_version, self::VERSION, '<' ) ) {
				return new \WP_Error(
					'subin_capability_version_not_saved',
					__( 'InboxMend updated role permissions but could not save their version.', 'submission-inbox' )
				);
			}

			return true;
		} catch ( \Throwable $throwable ) {
			return new \WP_Error(
				'subin_capability_upgrade_exception',
				__( 'InboxMend encountered an unexpected role-permission upgrade error.', 'submission-inbox' ),
				array( 'exception' => sanitize_text_field( $throwable->getMessage() ) )
			);
		} finally {
			self::release_lock( $lock_token );
		}
	}

	/**
	 * Remove plugin capabilities from every role in the current site.
	 *
	 * Roles themselves are user-owned and are never removed.
	 *
	 * @return true
	 */
	public static function remove_current_site() {
		foreach ( self::role_names() as $role_name ) {
			$role = get_role( $role_name );
			if ( ! $role ) {
				continue;
			}

			foreach ( self::all() as $capability ) {
				if ( $role->has_cap( $capability ) ) {
					$role->remove_cap( $capability );
				}
			}
		}

		delete_option( self::VERSION_OPTION );
		delete_option( self::LOCK_OPTION );

		return true;
	}

	/**
	 * List role slugs in the current site context.
	 *
	 * @return array<int,string>
	 */
	private static function role_names() {
		$roles = wp_roles();
		if ( ! $roles || ! isset( $roles->roles ) || ! is_array( $roles->roles ) ) {
			return array();
		}

		return array_values(
			array_filter(
				array_keys( $roles->roles ),
				static function ( $role_name ) {
					return is_string( $role_name ) && '' !== $role_name;
				}
			)
		);
	}

	/**
	 * Verify all migrated roles before advancing the stored version.
	 *
	 * @param array<int,string> $role_names Migrated role slugs.
	 * @return true|\WP_Error
	 */
	private static function verify_roles( array $role_names ) {
		$roles    = wp_roles();
		$role_key = $roles && isset( $roles->role_key ) ? (string) $roles->role_key : '';
		if ( '' === $role_key ) {
			return new \WP_Error(
				'subin_capability_role_store_unavailable',
				__( 'InboxMend could not locate the persistent WordPress role store.', 'submission-inbox' )
			);
		}

		if ( function_exists( 'wp_cache_delete' ) ) {
			wp_cache_delete( $role_key, 'options' );
			wp_cache_delete( 'alloptions', 'options' );
		}
		$persisted_roles = get_option( $role_key, array() );
		if ( ! is_array( $persisted_roles ) ) {
			return new \WP_Error(
				'subin_capability_role_store_unavailable',
				__( 'InboxMend could not read the persistent WordPress role store.', 'submission-inbox' )
			);
		}

		foreach ( $role_names as $role_name ) {
			$role = get_role( $role_name );
			if ( ! $role ) {
				return new \WP_Error(
					'subin_capability_role_unavailable',
					__( 'InboxMend could not verify an updated role.', 'submission-inbox' )
				);
			}

			$persisted_capabilities = $persisted_roles[ $role_name ]['capabilities'] ?? null;
			if ( ! is_array( $persisted_capabilities ) ) {
				return new \WP_Error(
					'subin_capability_role_store_unavailable',
					__( 'InboxMend could not verify a role in the persistent WordPress role store.', 'submission-inbox' ),
					array( 'role' => $role_name )
				);
			}

			foreach ( self::all() as $capability ) {
				if ( ! $role->has_cap( $capability ) || empty( $persisted_capabilities[ $capability ] ) ) {
					return new \WP_Error(
						'subin_capability_verification_failed',
						__( 'InboxMend could not verify every required role permission.', 'submission-inbox' ),
						array(
							'role'       => $role_name,
							'capability' => $capability,
						)
					);
				}
			}
		}

		return true;
	}

	/**
	 * Whether an administrator-equivalent role is missing a registry capability.
	 *
	 * This lightweight check also provisions custom administrator roles created
	 * after the capability version itself was installed.
	 *
	 * @return bool
	 */
	private static function roles_need_upgrade() {
		$found_target = false;

		foreach ( self::role_names() as $role_name ) {
			$role = get_role( $role_name );
			if ( ! $role || ! $role->has_cap( 'manage_options' ) ) {
				continue;
			}
			$found_target = true;

			foreach ( self::all() as $capability ) {
				if ( ! $role->has_cap( $capability ) ) {
					return true;
				}
			}
		}

		return ! $found_target;
	}

	/**
	 * Acquire the per-site migration lock through atomic option creation.
	 *
	 * @return string|\WP_Error
	 */
	private static function acquire_lock() {
		$current = get_option( self::LOCK_OPTION, false );
		if ( false !== $current ) {
			$acquired_at = is_array( $current ) ? absint( $current['acquired_at'] ?? 0 ) : 0;
			if ( $acquired_at > 0 && ( time() - $acquired_at ) < self::LOCK_TTL ) {
				return new \WP_Error(
					'subin_capability_upgrade_locked',
					__( 'InboxMend role-permission upgrade is already running.', 'submission-inbox' ),
					array( 'retryable' => true )
				);
			}

			delete_option( self::LOCK_OPTION );
		}

		$token  = wp_generate_uuid4();
		$locked = add_option(
			self::LOCK_OPTION,
			array(
				'token'       => $token,
				'acquired_at' => time(),
			),
			'',
			false
		);

		if ( ! $locked ) {
			return new \WP_Error(
				'subin_capability_upgrade_locked',
				__( 'InboxMend role-permission upgrade is already running.', 'submission-inbox' ),
				array( 'retryable' => true )
			);
		}

		return $token;
	}

	/**
	 * Release a lock only while it is still owned by this process.
	 *
	 * @param string $token Lock token.
	 * @return void
	 */
	private static function release_lock( $token ) {
		$current       = get_option( self::LOCK_OPTION, array() );
		$current_token = is_array( $current ) ? (string) ( $current['token'] ?? '' ) : '';

		if ( '' !== $current_token && hash_equals( $current_token, (string) $token ) ) {
			delete_option( self::LOCK_OPTION );
		}
	}
}
