<?php
if (!class_exists('Ajaw_v1_ActionBuilder', false)):

	class Ajaw_v1_ActionBuilder {
		private $action;
		private $callback = '__return_null';
		private $params = array();
		private $httpMethod = null;

		private $capability = null;
		private $permissionCheckCallback = null;

		private $mustBeLoggedIn = true;
		private $checkNonce = true;

		public function __construct($action) {
			$this->action = $action;
		}

		/**
		 * @param callable $callback
		 * @return $this
		 */
		public function handler($callback) {
			$this->callback = $callback;
			return $this;
		}

		public function requiredParam($name, $type = null, $validateCallback = null) {
			return $this->addParameter($name, $type, true, null, $validateCallback);
		}

		public function optionalParam($name, $defaultValue = null, $type = null, $validateCallback = null) {
			return $this->addParameter($name, $type, false, $defaultValue, $validateCallback);
		}

		private function addParameter($name, $type, $required, $defaultValue, $validateCallback) {
			if (isset($type) && !isset(Ajaw_v1_Action::$defaultValidators[$type])) {
				throw new LogicException(sprintf(
					'Unknown parameter type "%s". Supported types are: %s.',
					$type,
					implode(', ', array_keys(Ajaw_v1_Action::$defaultValidators[$type]))
				));
			}

			$this->params[$name] = array(
				'required' => $required,
				'defaultValue' => $defaultValue,
				'type' => $type,
				'validateCallback' => $validateCallback,
			);
			return $this;
		}

		public function method($httpMethod) {
			$this->httpMethod = strtoupper($httpMethod);
			return $this;
		}

		public function requiredCap($capability) {
			$this->capability = $capability;
			return $this;
		}

		public function permissionCallback($callback) {
			$this->permissionCheckCallback = $callback;
			return $this;
		}

		public function allowUnprivilegedUsers() {
			$this->mustBeLoggedIn = false;
			return $this;
		}

		public function withoutNonce() {
			$this->checkNonce = false;
			return $this;
		}

		public function build() {
			$instance = new Ajaw_v1_Action($this->action, $this->callback, $this->params);

			$instance->mustBeLoggedIn = $this->mustBeLoggedIn;
			$instance->requiredCap = $this->capability;
			$instance->nonceCheckEnabled = $this->checkNonce;
			$instance->method = $this->httpMethod;
			$instance->permissionCallback = $this->permissionCheckCallback;

			return $instance;
		}

		public function register() {
			$instance = $this->build();
			$instance->register();
			return $instance;
		}
	}

endif;

if (!class_exists('Ajaw_v1_Action', false)):

	class Ajaw_v1_Action {
		public $action;
		public $callback;
		public $params = array();
		public $method = null;

		public $requiredCap = null;
		public $mustBeLoggedIn = false;
		public $nonceCheckEnabled = true;
		public $permissionCallback = null;

		private $isScriptRegistered = false;

		public static $defaultValidators = array(
			'int'     => array(__CLASS__, 'validateInt'),
			'float'   => array(__CLASS__, 'validateFloat'),
			'boolean' => array(__CLASS__, 'validateBoolean'),
			'string'  => array(__CLASS__, 'validateString'),
		);

		public function __construct($action, $callback, $params) {
			$this->action = $action;
			$this->callback = $callback;
			$this->params = $params;

			if (empty($this->action)) {
				throw new LogicException(sprintf(
					'AJAX action name is missing. You must either pass it to the %1$s constructor '
					. 'or give the %1$s::$action property a valid default value.',
					get_class($this)
				));
			}
		}

		/**
		 * Set up hooks for AJAX and helper scripts.
		 */
		public function register() {
			//Register the AJAX handler(s).
			$hookNames = array('wp_ajax_' . $this->action);
			if (!$this->mustBeLoggedIn) {
				$hookNames[] = 'wp_ajax_nopriv_' . $this->action;
			}

			foreach($hookNames as $hook) {
				if (has_action($hook)) {
					throw new RuntimeException(sprintf('The action name "%s" is already in use.', $this->action));
				}
				add_action($hook, array($this, 'processAjaxRequest'));
			}

			//Register the utility JS library after WP is fully loaded.
			if (did_action('wp_loaded')) {
				$this->registerScript();
			} else {
				add_action('wp_loaded', array($this, 'registerScript'), 2);
			}
		}

		/**
		 * @access protected
		 */
		public function processAjaxRequest() {
			$result = $this->handleAction();

			if (is_wp_error($result)) {
				$statusCode = $result->get_error_data();
				if (isset($statusCode) && is_int($statusCode) ) {
					status_header($statusCode);
				}

				$errorResponse = array(
					'error' => array(
						'message' => $result->get_error_message(),
						'code' => $result->get_error_code()
					)
				);

				$result = $errorResponse;
			}

			if (isset($result)) {
				$this->outputJSON($result);
			}
			exit;
		}

		protected function handleAction() {
			$method = $this->getRequestMethod();
			if (isset($this->method) && ($method !== $this->method)) {
				return new WP_Error(
					'http_method_not_allowed',
					'The HTTP method is not supported by the request handler.',
					405
				);
			}

			$isAuthorized = $this->checkAuthorization();
			if ($isAuthorized !== true) {
				return $isAuthorized;
			}

			$params = $this->parseParameters();
			if ($params instanceof WP_Error) {
				return $params;
			}

			//Call the user-specified action handler.
			if (is_callable($this->callback)) {
				return call_user_func($this->callback, $params);
			} else {
				return new WP_Error(
					'missing_ajax_handler',
					sprintf(
						'There is no request handler assigned to the "%1$s" action. '
						. 'Either pass a valid callback to $builder->request() or override the %2$s::%3$s method.',
						$this->action,
						__CLASS__,
						__METHOD__
					),
					500
				);
			}
		}

		/**
		 * Check if the current user is authorized to perform this action.
		 *
		 * @return bool|WP_Error
		 */
		protected function checkAuthorization() {
			if ($this->mustBeLoggedIn && !is_user_logged_in()) {
				return new WP_Error('login_required', 'You must be logged in to perform this action.', 403);
			}

			if (isset($this->requiredCap) && !current_user_can($this->requiredCap)) {
				return new WP_Error('capability_missing', 'You don\'t have permission to perform this action.', 403);
			}

			if ($this->nonceCheckEnabled && !check_ajax_referer($this->action, false, false)) {
				return new WP_Error('nonce_check_failed', 'Invalid or missing nonce.', 403);
			}

			if (isset($this->permissionCallback)) {
				$result = call_user_func($this->permissionCallback);
				if ($result === false) {
					return new WP_Error(
						'permission_callback_failed',
						'You don\'t have permission to perform this action.',
						403
					);
				} else if (is_wp_error($result)) {
					return $result;
				}
			}

			return true;
		}

		protected function getRequestMethod() {
			return strtoupper(filter_input(
				INPUT_SERVER,
				'REQUEST_METHOD',
				FILTER_VALIDATE_REGEXP,
				array('options' => array('regexp' => '/^[a-z]{3,20}$/i'))
			));
		}

		protected function parseParameters() {
			$method = $this->getRequestMethod();

			// phpcs:disable WordPress.Security.NonceVerification -- checkAuthorization() is where nonce verification happens.
			//Retrieve request parameters.
			if ($method === 'GET') {
				$rawParams = $_GET;
			} else if ($method === 'POST') {
				$rawParams = $_POST;
			} else {
				$rawParams = $_REQUEST;
			}
			// phpcs:enable

			//Remove magic quotes. WordPress applies them in wp-settings.php.
			//There's no hook for wp_magic_quotes, so we use one that's closest in execution order.
			if (did_action('sanitize_comment_cookies') && function_exists('wp_magic_quotes')) {
				$rawParams = wp_unslash($rawParams);
			}

			//Validate all parameters.
			$inputParams = $rawParams;
			foreach($this->params as $name => $settings) {
				//Verify that all the required parameters are present.
				//Empty strings are treated as missing parameters.
				if (isset($inputParams[$name]) && ($inputParams[$name] !== '')) {
					$value = $this->validateParameter($settings, $inputParams[$name], $name);
					if (is_wp_error($value)) {
						return $value;
					} else {
						$inputParams[$name] = $value;
					}
				} else if (empty($settings['required'])) {
					//It's an optional parameter. Use the default value.
					$inputParams[$name] = $settings['defaultValue'];
				} else {
					return new WP_Error(
						'missing_required_parameter',
						sprintf('Required parameter is missing or empty: "%s".', $name),
						400
					);
				}
			}

			return $inputParams;
		}

		protected function validateParameter($settings, $value, $name) {
			if (isset($settings['type'])) {
				$value = call_user_func(self::$defaultValidators[$settings['type']], $value, $name);
				if (is_wp_error($value)) {
					return $value;
				}
			}
			if (isset($settings['validateCallback'])) {
				$success = call_user_func($settings['validateCallback'], $value);
				if (is_wp_error($success)) {
					return $success;
				} else if ($success === false) {
					return new WP_Error(
						'invalid_parameter_value',
						sprintf('The value of the parameter "%s" is invalid.', $name),
						400
					);
				}
			}
			return $value;
		}

		private static function validateInt($value, $name) {
			$result = filter_var($value, FILTER_VALIDATE_INT);
			if ($result === false) {
				return new WP_Error(
					'invalid_parameter_value',
					sprintf('The value of the parameter "%s" is invalid. It must be an integer.', $name),
					400
				);
			}
			return $result;
		}

		private static function validateFloat($value, $name) {
			$result = filter_var($value, FILTER_VALIDATE_FLOAT);
			if ($result === false) {
				return new WP_Error(
					'invalid_parameter_value',
					sprintf('The value of the parameter "%s" is invalid. It must be a float.', $name),
					400
				);
			}
			return $result;
		}

		private static function validateBoolean($value, $name) {
			$result = filter_var($value, FILTER_VALIDATE_BOOLEAN, array('flags' => FILTER_NULL_ON_FAILURE));
			if ($result === null) {
				return new WP_Error(
					'invalid_parameter_value',
					sprintf('The value of the parameter "%s" is invalid. It must be a boolean.', $name),
					400
				);
			}
			return $result;
		}

		private static function validateString($value, $name) {
			if (!is_string($value)) {
				return new WP_Error(
					'invalid_parameter_value',
					sprintf('The value of the parameter "%s" is invalid. It must be a string.', $name),
					400
				);
			}
			return $value;
		}

		protected function outputJSON($response) {
			@header('Content-Type: application/json; charset=' . get_option('blog_charset'));
			echo wp_json_encode($response);
		}

		public function registerScript() {
			if ($this->isScriptRegistered) {
				return;
			}
			$this->isScriptRegistered = true;

			//There could be multiple instances of this class, but we only need to register the script once.
			$handle = $this->getScriptHandle();
			if (!wp_script_is($handle, 'registered')) {
				wp_register_script(
					$handle,
					plugins_url('ajax-action-wrapper.js', __FILE__),
					array('jquery'),
					'20161105'
				);
			}

			//Pass the action to the script.
			if (function_exists('wp_add_inline_script')) {
				wp_add_inline_script($handle, $this->generateActionJs(), 'after'); //WP 4.5+
			} else {
				add_filter('script_loader_tag', array($this, 'addRegistrationScript'), 10, 2); //WP 4.1+
			}
		}

		/**
		 * Backwards compatibility for older versions of WP that don't have wp_add_inline_script().
		 * @internal
		 *
		 * @param string $tag
		 * @param string $handle
		 * @return string
		 */
		public function addRegistrationScript($tag, $handle) {
			if ($handle === $this->getScriptHandle()) {
				$tag .= '<script type="text/javascript">' . $this->generateActionJs() . '</script>';
			}
			return $tag;
		}

		protected function generateActionJs() {
			$properties = array(
				'ajaxUrl' => admin_url('admin-ajax.php'),
				'method' => $this->method,
				'nonce' => $this->nonceCheckEnabled ? wp_create_nonce($this->action) : null,
			);

			return sprintf(
				'AjawV1.actionRegistry.add("%s", %s);' . "\n",
				esc_js($this->action),
				wp_json_encode($properties)
			);
		}

		public function getScriptHandle() {
			return 'ajaw-v1-ajax-action-wrapper';
		}
	}

endif;

if (!function_exists('ajaw_v1_CreateAction')) {
	function ajaw_v1_CreateAction($action) {
		return new Ajaw_v1_ActionBuilder($action);
	}
}
