<?php
/**
 * Common plugin functions
 *
 * @package WP_CLI_Cron_Control_Offload
 */

namespace Automattic\WP\WP_CLI_Cron_Control_Offload;
use WP_Error;

/**
 * Create cron event for a given WP-CLI command
 *
 * @param string $command WP-CLI command to schedule.
 * @param int    $timestamp Optional. Unix timestamp to schedule command to run at.
 * @return int|WP_Error
 */
function schedule_cli_command( $command, $timestamp = null ) {
	$command = validate_command( $command );

	if ( is_wp_error( $command ) ) {
		return $command;
	}

	if ( ! is_int( $timestamp ) ) {
		$timestamp = strtotime( '+30 seconds' );
	}

	if ( $timestamp <= time() ) {
		return new WP_Error( 'invalid-timestamp', __( 'Timestamp is in the past.', 'wp-cli-cron-control-offload' ) );
	}

	$event_args = array(
		'command' => $command,
	);

	$scheduled = wp_schedule_single_event( $timestamp, ACTION, $event_args );

	if ( false === $scheduled ) {
		return new WP_Error( 'not-scheduled', __( 'Command may already be scheduled, or it was blocked via the `schedule_event` filter.', 'wp-cli-cron-control-offload' ) );
	}

	return $timestamp;
}

/**
 * Validate WP-CLI command to be scheduled
 *
 * @param string $command WP-CLI command to validate.
 * @return string|WP_Error
 */
function validate_command( $command ) {
	$command = trim( $command );
	$command = parse_command( $command );

	// Failed to parse command.
	if ( is_wp_error( $command ) ) {
		return $command;
	}

	if ( ! is_command_allowed( $command['positional_args'][0] ) ) {
		/* translators: 1: Disallowed command */
		return new WP_Error( 'blocked-command', sprintf( __( '`%1$s` not allowed', 'wp-cli-cron-control-offload' ), $command['positional_args'][0] ) );
	}

	$command = implode_parsed_command( $command );

	return $command;
}

/**
 * Check if command is allowed
 *
 * @param string $command Top-level WP-CLI command to check against blacklist and whitelist.
 * @return bool
 */
function is_command_allowed( $command ) {
	// No recursion allowed.
	if ( CLI_NAMESPACE === $command ) {
		return false;
	}

	// Command explicitly disallowed.
	if ( in_array( $command, get_command_blacklist(), true ) ) {
		return false;
	}

	// Default to command whitelist.
	$whitelisted = in_array( $command, get_command_whitelist(), true );

	return apply_filters( 'wp_cli_cron_control_offload_is_command_allowed', $whitelisted, $command );
}

/**
 * Support a whitelist of commands
 *
 * @return array
 */
function get_command_whitelist() {
	if ( defined( 'WP_CLI_CRON_CONTROL_OFFLOAD_COMMAND_WHITELIST' ) && is_array( \WP_CLI_CRON_CONTROL_OFFLOAD_COMMAND_WHITELIST ) ) {
		return _filter_list_allow_only_additions( \WP_CLI_CRON_CONTROL_OFFLOAD_COMMAND_WHITELIST, 'wp_cli_cron_control_offload_command_whitelist' );
	}

	return apply_filters( 'wp_cli_cron_control_offload_command_whitelist', array() );
}

/**
 * Allow commands to be blocked
 *
 * @return array
 */
function get_command_blacklist() {
	if ( defined( 'WP_CLI_CRON_CONTROL_OFFLOAD_COMMAND_BLACKLIST' ) && is_array( \WP_CLI_CRON_CONTROL_OFFLOAD_COMMAND_BLACKLIST ) ) {
		return _filter_list_allow_only_additions( \WP_CLI_CRON_CONTROL_OFFLOAD_COMMAND_BLACKLIST, 'wp_cli_cron_control_offload_command_blacklist' );
	}

	return apply_filters( 'wp_cli_cron_control_offload_command_blacklist', array() );
}

/**
 * Splits positional args from associative args.
 *
 * Adapted from `WP_CLI\Configurator\extract_assoc()`.
 *
 * @param array|string $command String or array of command to parse.
 * @return array|WP_Error
 */
function parse_command( $command ) {
	// Borrowed code expects an array, but a string is easier for our needs.
	if ( is_string( $command ) ) {
		$command = explode( ' ', $command );
	}

	// Bad request.
	if ( ! is_array( $command ) || empty( $command ) ) {
		return new WP_Error( 'command-parse-failed', __( 'Failed to parse requested command', 'wp-cli-cron-control-offload' ) );
	}

	// Don't care about existing keys, so reset to zero-indexed array.
	$command = array_values( $command );

	// `wp` is not part of the parsed command when WP-CLI is invoked.
	if ( 'wp' === $command[0] ) {
		unset( $command[0] );
	}

	// Match naming in what's borrowed from WP-CLI.
	$arguments = $command;

	// Start what's borrowed from WP-CLI. @codingStandardsIgnoreStart
	$positional_args = $assoc_args = $global_assoc = $local_assoc = array();

	foreach ( $arguments as $arg ) {
		$positional_arg = $assoc_arg = null;

		if ( preg_match( '|^--no-([^=]+)$|', $arg, $matches ) ) {
			$assoc_arg = array( $matches[1], false );
		} elseif ( preg_match( '|^--([^=]+)$|', $arg, $matches ) ) {
			$assoc_arg = array( $matches[1], true );
		} elseif ( preg_match( '|^--([^=]+)=(.*)|s', $arg, $matches ) ) {
			$assoc_arg = array( $matches[1], $matches[2] );
		} else {
			$positional = $arg;
		}

		if ( ! is_null( $assoc_arg ) ) {
			// Start addition.
			// Skip, allowing WP-CLI to inherit
			if ( 'allow-root' === $assoc_arg[0] ) {
				continue;
			}
			// End addition.
			$assoc_args[] = $assoc_arg;
			if ( count( $positional_args ) ) {
				$local_assoc[] = $assoc_arg;
			} else {
				$global_assoc[] = $assoc_arg;
			}
		} else if ( ! is_null( $positional ) ) {
			$positional_args[] = $positional;
		}

	}
	// End what's borrowed from WP-CLI. @codingStandardsIgnoreEnd

	// Nothing to do.
	if ( ! isset( $positional_args[0] ) || empty( $positional_args[0] ) ) {
		return new WP_Error( 'no-command-specified', __( 'No command was provided.', 'wp-cli-cron-control-offload' ) );
	}

	// Block unsupported wanderings beyond WP-CLI.
	// See http://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-3.html, http://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-4.html.
	// TODO: provide additive filter?
	$disallowed_positionals = array(
		'&',
		'|',
		'>',
		'2>',
		'1>&2',
		'2>&1',
		'&>',
	);

	$found_disallowed = array_intersect( $positional_args, $disallowed_positionals );
	if ( ! empty( $found_disallowed ) ) {
		/* translators: 1: Disallowed character ampersand, 2: Disallowed character pipe, 3: Disallowed character redirect */
		return new WP_Error( 'invalid-positional-args', sprintf( __( 'Invalid positional arguments, such as "%1$s", "%2$s", or "%3$s", found.', 'wp-cli-cron-control-offload' ), $disallowed_positionals[0], $disallowed_positionals[1], $disallowed_positionals[2] ) );
	}

	// Success!
	return compact( 'positional_args', 'assoc_args', 'global_assoc', 'local_assoc' );
}

/**
 * Restores a parsed command to a string WP-CLI can run
 *
 * @param array $command Parsed command to convert to string.
 * @return string|WP_Error
 */
function implode_parsed_command( $command ) {
	if ( ! is_array( $command ) || empty( $command['positional_args'] ) ) {
		return new WP_Error( 'no-command-specified', __( 'No command was provided.', 'wp-cli-cron-control-offload' ) );
	}

	$to_implode = array();

	if ( ! empty( $command['global_assoc'] ) ) {
		$global     = array_map( __NAMESPACE__ . '\_assoc_arg_array_to_string', $command['global_assoc'] );
		$to_implode = array_merge( $to_implode, $global );
	}

	$to_implode = array_merge( $to_implode, $command['positional_args'] );

	if ( ! empty( $command['local_assoc'] ) ) {
		$local      = array_map( __NAMESPACE__ . '\_assoc_arg_array_to_string', $command['local_assoc'] );
		$to_implode = array_merge( $to_implode, $local );
	}

	$imploded = trim( implode( ' ', $to_implode ) );

	if ( empty( $imploded ) ) {
		return new WP_Error( 'command-implode-failed', __( 'Failed to convert command array to string.', 'wp-cli-cron-control-offload' ) );
	}

	return $imploded;
}

/**
 * Convert an associative arg's array representation to a string for WP-CLI
 *
 * @param array $assoc_arg Associative arg to convert.
 * @return string
 */
function _assoc_arg_array_to_string( $assoc_arg ) {
	if ( true === $assoc_arg[1] ) {
		return '--' . $assoc_arg[0];
	} else {
		return sprintf( '--%1$s=%2$s', $assoc_arg[0], $assoc_arg[1] );
	}
}

/**
 * Allow whitelist or blacklist to be filtered, permitting ONLY additions
 *
 * @param array  $constant List value from constant, to be added to.
 * @param string $filter_tag String for list filter.
 * @return array
 */
function _filter_list_allow_only_additions( $constant, $filter_tag ) {
	$list = $constant;
	$list = array_values( $list ); // Keys are irrelevant, and dropping them reinforces the additive nature of the following filter.

	$additional = apply_filters( $filter_tag, array(), $list );

	if ( ! is_array( $additional ) || empty( $additional ) ) {
		return $constant;
	}

	$additional = array_values( $additional ); // Stop any funny business with string keys.

	$list = array_merge( $list, $additional );
	$list = array_unique( $list, SORT_STRING ); // Force type conversion to retain value from constant if filter tries funny business.

	return empty( $list ) ? $constant : $list;
}