<?php
/**
 * Vuln_Wordfence_Service Class.
 *
 * @package WP-CLI Vulnerability Scanner
 */

if ( ! defined( 'WP_CLI' ) ) {
	return;
}

// API URL constant defined so we can override it if needed.
if ( ! defined( 'WORDFENCE_API_URL' ) ) {
	define( 'WORDFENCE_API_URL', 'https://www.wordfence.com/api/intelligence/v2/vulnerabilities/production' );
}

/**
 * Class for Wordfence API Service
 */
class Vuln_Wordfence_Service extends Vuln_Service {

	/**
	 * Wordfence API URL.
	 *
	 * @var string
	 */
	private $api_url = WORDFENCE_API_URL;

	/**
	 * Worker for WordPress vulnerability checks.
	 *
	 * @return array
	 */
	public function check_wordpress() {
		global $wp_version;
		$slug = 'wordpress'; // phpcs:ignore WordPress.WP.CapitalPDangit.Misspelled

		// Get vulnerability data from API.
		$args = array(
			'type'  => 'core',
			'items' => array( $slug => $wp_version ),
		);

		$response = $this->get_vulnerabilities( $args );

		// Prepare and return report data.
		return $this->prepare_report_data( $response, $args );
	}

	/**
	 * Worker for Plugin/Theme vulnerability checks.
	 *
	 * @param string       $slug    Installed plugin/theme slug.
	 * @param string       $version Installed plugin/theme version.
	 * @param string|array $type    The "thing" we're checking, "plugin" or "theme".
	 *                              If string, it's pluralized with an "s".
	 *                              If array, should be [ single, plural ].
	 *
	 * @return array Data array
	 */
	public function check_status( $slug, $version, $type ) {
		list( $singular_type ) = $this->get_slugs( $type );

		// Get vulnerability data from API.
		$args = array(
			'type'  => $singular_type,
			'items' => array(
				$slug => $version,
			),
		);

		$response = $this->get_vulnerabilities( $args );

		// Prepare and return report data.
		return $this->prepare_report_data( $response, $args );
	}

	/**
	 * Worker, checks vulnerability in batch for themes/plugins.
	 * If fail, It tun through checking the status of a each plugin/theme.
	 *
	 * @param string|array $type The "thing" we're checking.
	 *                           If string, it's pluralized with an "s"
	 *                           If array, should be [ single, plural ].
	 *
	 * @return array Statuses for all themes/plugins.
	 */
	public function check_thing( $type ) {
		list( $singular_type ) = $this->get_slugs( $type );

		// Prepare list of installed plugins/themes.
		$list = WP_CLI::launch_self(
			$singular_type,
			array( 'list' ),
			array(
				'format' => 'csv',
				'fields' => 'name,version',
			),
			true,
			true
		);

		$list = $this->parse_list( $list );

		// Test list.
		if ( isset( $this->assoc_args['test'] ) && $this->assoc_args['test'] ) {
			$list = $this->get_test_list( $type );
		}

		// If no plugins/themes installed, return.
		if ( empty( $list ) ) {
			return array();
		}

		$items = array();
		foreach ( $list as $item ) {
			$items[ $item['name'] ] = isset( $item['version'] ) ? $item['version'] : '0';
		}

		$args = array(
			'type'  => $singular_type,
			'items' => $items,
		);

		// Get vulnerabilities.
		$response = $this->get_vulnerabilities( $args );

		// Prepare and return report data.
		return $this->prepare_report_data( $response, $args );
	}


	/**
	 * Prepare report data.
	 *
	 * @param array|mixed|WP_Error $response Response from API.
	 * @param array                $args     Arguments.
	 *
	 * @return array.
	 */
	public function prepare_report_data( $response, $args ) {
		if ( ! isset( $args['items'] ) || empty( $args['items'] ) ) {
			return array();
		}

		$data = array();
		foreach ( $args['items'] as $slug => $version ) {
			$is_wp        = false;
			$display_slug = $slug;
			// phpcs:ignore WordPress.WP.CapitalPDangit.Misspelled
			if ( 'wordpress' === $slug ) {
				$is_wp        = true;
				$display_slug = "WordPress $version";
			}

			$report = array();
			if ( is_wp_error( $response ) ) {
				$error_message = "Error generating report for $display_slug";
				if ( strpos( $response->get_error_message(), 'ERROR_API_QUOTA_FULL' ) !== false ) {
					$error_message .= ' (' . $response->get_error_message() . ')';
				}
				$report[] = array(
					'title' => $error_message,
				);
			} else {
				// Let's analyze the report!
				if ( isset( $response[ $slug ] ) && ! empty( $response[ $slug ] ) && is_array( $response[ $slug ] ) ) {
					$report = $this->format_vulnerability_data( $response[ $slug ], $slug, $version );
				}

				if ( count( $report ) <= 0 ) {
					$report[] = array(
						'title' => 'No vulnerabilities reported for this version of ' . ( $is_wp ? 'WordPress' : $slug ),
					);
				}
			}

			$table_format = false;
			if ( ! isset( $this->assoc_args['format'] ) || 'table' === $this->assoc_args['format'] ) {
				$table_format = true;
			}

			$last_item = '';
			foreach ( $report as $index => $stat ) {

				$stat = wp_parse_args(
					$stat,
					array(
						'id'          => '',
						'action'      => '',
						'fixed in'    => 'n/a',
						'affected_in' => 'n/a',
						'severity'    => 'n/a',
						'reference'   => 'n/a',
						'copyrights'  => '',
					)
				);

				if ( $is_wp ) {
					$name = ( $table_format && 0 !== $index ? '' : 'WordPress' );
				} else {
					$name = ( $table_format && $slug === $last_item ? '' : $slug );
				}

				if ( $table_format ) {
					switch ( $stat['action'] ) {
						case 'update':
							$name = \WP_CLI::colorize( "%r$name%n" );
							break;
						case 'watch':
							$name = \WP_CLI::colorize( "%y$name%n" );
							break;
						default:
							break;
					}
				}

				// These keys must match the column headings in the formatter (extras ok).
				$data[] = array(
					'name'              => $name,
					'slug'              => $slug,
					'installed version' => $version,
					'id'                => $stat['id'],
					'status'            => $stat['title'],
					'fixed in'          => $stat['fixed in'],
					'introduced in'     => $stat['affected_in'],
					'action'            => $stat['action'],
					'severity'          => $stat['severity'],
					'reference'         => $stat['reference'],
					'copyrights'        => $stat['copyrights'],
				);

				$last_item = $slug;
			}
		}
		return $data;
	}

	/**
	 * Format Vulnerability data.
	 *
	 * @param array  $vulnerabilities Array of Vulnerability.
	 * @param string $slug            plugin or theme slug.
	 * @param string $version         plugin or theme version.
	 * @return array Formatted array of Vulnerability.
	 */
	private function format_vulnerability_data( $vulnerabilities, $slug, $version ) {
		$report = array();

		foreach ( $vulnerabilities as $vuln ) {
			/**
			 * Filter whether to skip the vulnerability check.
			 *
			 * @since 1.2.1
			 * @hook vuln_skip_vulnerability_check
			 * @param {bool} $skip True to skip.
			 * @param {object} $vuln Vulnerability object.
			 */
			if ( apply_filters( 'vuln_skip_vulnerability_check', false, $vuln ) ) {
				continue;
			}

			$fixed         = false;
			$fixed_version = '';
			$severity      = 'n/a';
			if ( ! empty( $vuln->cvss ) ) {
				$severity = sprintf( '%s %.1f/10', $vuln->cvss->rating, $vuln->cvss->score );
			}

			foreach ( $vuln->software as $software ) {
				if ( $software->slug === $slug ) {
					$fixed = $software->patched;
					if ( $software->patched && ! empty( $software->patched_versions ) ) {
						$fixed_versions = array_filter(
							$software->patched_versions,
							function( $patched_version ) use ( $version ) {
								return version_compare( $version, $patched_version, '<' );
							}
						);
						if ( ! empty( $fixed_versions ) ) {
							$fixed_version = implode( ', ', $fixed_versions );
						}
					}
				}
			}

			// vulnerability that hasn't been fixed :(.
			if ( ! $fixed ) {
				$report[] = array(
					'id'          => $vuln->id,
					'title'       => $vuln->title,
					'fixed in'    => 'Not fixed',
					'affected_in' => 'n/a',
					'action'      => 'watch',
					'severity'    => $severity,
					'reference'   => $vuln->references ? $vuln->references[0] : 'n/a',
					'copyrights'  => $vuln->copyrights ? (array) $vuln->copyrights : array(),
				);

				// Vuln version, fix available.
			} elseif ( ! empty( $fixed_version ) ) {
				$report[] = array(
					'id'          => $vuln->id,
					'title'       => $vuln->title,
					'fixed in'    => $fixed_version,
					'affected_in' => 'n/a',
					'action'      => 'update',
					'severity'    => $severity,
					'reference'   => $vuln->references ? $vuln->references[0] : 'n/a',
					'copyrights'  => $vuln->copyrights ? (array) $vuln->copyrights : array(),
				);
			}
		}

		return $report;
	}

	/**
	 * Get vulnerabilities from Wordfence API.
	 *
	 * @param array $args Arguments array to request data related to specific plugin or theme.
	 *
	 * @return array|mixed|WP_Error
	 */
	protected function get_vulnerabilities( $args ) {
		// 1. Get vulnerability data.
		$vuln_data = $this->get_vulnerability_data();

		// Return error if unable to retrieve vulnerability data.
		if ( is_wp_error( $vuln_data ) ) {
			return $vuln_data;
		}

		if ( empty( $vuln_data ) ) {
			return new WP_Error( 'vuln_db_error', 'Unable to retrieve vulnerability data.' );
		}

		$type       = $args['type'];
		$response   = array();
		$items      = $args['items'];
		$item_slugs = array_keys( $args['items'] );

		// 2. Filter vulnerability data for the requested plugins, themes or core.
		foreach ( $vuln_data as $vuln ) {
			foreach ( $vuln->software as $software ) {
				// Filter out vulnerabilities that don't match the requested type and slug.
				if ( $software->type === $type && in_array( $software->slug, $item_slugs, true ) ) {
					// Filter out vulnerabilities that don't match the requested version.
					if ( $items[ $software->slug ] && $software->affected_versions ) {
						foreach ( (array) $software->affected_versions as $version ) {
							$from_compare = $version->from_inclusive ? '>=' : '>';
							$to_compare   = $version->to_inclusive ? '<=' : '<';
							if (
								( version_compare( $items[ $software->slug ], $version->from_version, $from_compare ) ||
									'*' === $version->from_version
								) &&
								version_compare( $items[ $software->slug ], $version->to_version, $to_compare )
							) {
								$response[ $software->slug ][] = $vuln;
							}
						}
					} else {
						$response[ $software->slug ][] = $vuln;
					}
				}
			}
		}

		return $response;
	}

	/**
	 * Get vulnerability data from Wordfence API.
	 *
	 * If local cache file exists, use it.
	 * Otherwise, get data from Wordfence API and cache it locally for 1 hour.
	 *
	 * @return JsonMachine\Items|WP_Error $vuln_data Array of Vulnerability.
	 */
	private function get_vulnerability_data() {
		$wp_filesystem = $this->init_wp_filesystem();

		$key       = 'vuln_check-' . md5( $this->api_url );
		$file_path = get_transient( $key );

		if ( $file_path && $wp_filesystem->exists( $file_path ) ) {
			WP_CLI::debug( 'Using local cached vuln db json' );
		} else {
			WP_CLI::debug( 'Request Wordfence API for vuln db json' );
			$vuln_db_file = download_url( $this->api_url );
			if ( is_wp_error( $vuln_db_file ) ) {
				return $vuln_db_file;
			}

			$file_path = $vuln_db_file;
			set_transient( $key, $file_path, HOUR_IN_SECONDS );
		}

		return JsonMachine\Items::fromFile( $file_path );
	}

	/**
	 * Initializes WP_Filesystem.
	 */
	private function init_wp_filesystem() {
		global $wp_filesystem;
		WP_Filesystem();

		return $wp_filesystem;
	}
}
