<?php
/**
 * The rest of the author's plugins, from the WordPress.org directory.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\REST\Controllers;

use WarrantyCart\REST\Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

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

/**
 * Lists the author's other plugins with their state on this install.
 *
 * Asked of WordPress.org rather than written down here. A hard-coded list is
 * wrong the day a plugin is released, renamed or retired, and it cannot know a
 * version number or how many sites run it — the directory is the source of
 * truth for all of that and every other plugin's "more from us" screen reads it
 * the same way.
 *
 * Read-only. Installing and activating stay on WordPress's own screens: they
 * are privileged, side-effectful operations with their own capability checks,
 * nonces and error handling, and a second implementation of that inside a
 * warranty plugin would be a liability rather than a convenience.
 */
class Plugins extends Controller {

	/**
	 * Who to ask the directory about.
	 */
	private const AUTHOR = 'mralaminahamed';

	/**
	 * Where the directory's answer is kept.
	 */
	private const CACHE_KEY = 'warranty_cart_our_plugins';

	/**
	 * How long to keep it.
	 *
	 * The directory is a third-party HTTP request, and an admin screen that
	 * makes one on every page load is a screen that hangs for as long as
	 * WordPress.org is having a bad day. Twelve hours is far longer than the
	 * data changes and far shorter than a release cycle.
	 */
	private const CACHE_TTL = 12 * HOUR_IN_SECONDS;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->rest_base = 'admin';
	}

	/**
	 * Register routes.
	 *
	 * @return void
	 */
	public function register_routes(): void {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/plugins',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_plugins' ),
					'permission_callback' => array( $this, 'check_admin_permission' ),
					'args'                => array(
						'refresh' => array(
							'required'    => false,
							'type'        => 'boolean',
							'default'     => false,
							'description' => __( 'Bypass the cached directory response.', 'warranty-cart' ),
						),
					),
				),
			)
		);

		/*
		 * Install and activate, each behind the capability WordPress itself uses
		 * for it — not `manage_woocommerce`, which a shop manager holds and which
		 * says nothing about whether they may put new code on the server.
		 */
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/plugins/install',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'install_plugin' ),
					'permission_callback' => static function () {
						return \current_user_can( 'install_plugins' );
					},
					'args'                => $this->slug_arg(),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/plugins/activate',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'activate_listed_plugin' ),
					'permission_callback' => static function () {
						return \current_user_can( 'activate_plugins' );
					},
					'args'                => $this->slug_arg(),
				),
			)
		);
	}

	/**
	 * The slug argument both actions take.
	 *
	 * @return array<string, mixed>
	 */
	private function slug_arg(): array {
		return array(
			'slug' => array(
				'required'          => true,
				'type'              => 'string',
				'description'       => __( 'Plugin directory slug.', 'warranty-cart' ),
				'sanitize_callback' => 'sanitize_key',
			),
		);
	}

	/**
	 * Whether a slug is one this screen actually lists.
	 *
	 * This is the check that keeps the two actions below from being a
	 * general-purpose "install anything from the internet" endpoint bolted onto
	 * a warranty plugin. The permitted set is the directory query's own answer —
	 * the author's plugins and nothing else — so a request naming anything else
	 * is refused whatever capabilities the caller holds.
	 *
	 * @param string $slug The slug asked for.
	 *
	 * @return bool
	 */
	private function is_listed( string $slug ): bool {
		foreach ( $this->from_directory()['plugins'] as $plugin ) {
			if ( $plugin['slug'] === $slug ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * The installed plugin file for a slug, or ''.
	 *
	 * @param string $slug The slug.
	 *
	 * @return string
	 */
	private function plugin_file( string $slug ): string {
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		foreach ( array_keys( \get_plugins() ) as $file ) {
			if ( dirname( $file ) === $slug ) {
				return $file;
			}
		}

		return '';
	}

	/**
	 * Install one of the listed plugins, using WordPress's own installer.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response|\WP_Error
	 */
	public function install_plugin( WP_REST_Request $request ) {
		$slug = (string) $request->get_param( 'slug' );

		if ( ! $this->is_listed( $slug ) ) {
			return $this->error_response( __( 'That plugin is not one of ours.', 'warranty-cart' ), 400 );
		}

		if ( '' !== $this->plugin_file( $slug ) ) {
			return $this->error_response( __( 'That plugin is already installed.', 'warranty-cart' ), 409 );
		}

		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/misc.php';
		require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
		require_once ABSPATH . 'wp-admin/includes/plugin-install.php';

		/*
		 * The package URL is asked of the directory, never taken from the
		 * request. A caller-supplied URL would make this an
		 * arbitrary-code-from-anywhere endpoint wearing a slug's clothes.
		 */
		$information = \plugins_api(
			'plugin_information',
			array(
				'slug'   => $slug,
				'fields' => array( 'sections' => false ),
			)
		);

		if ( \is_wp_error( $information ) || empty( $information->download_link ) ) {
			return $this->error_response( __( 'The plugin directory could not be reached.', 'warranty-cart' ), 502 );
		}

		$upgrader = new \Plugin_Upgrader( new \Automatic_Upgrader_Skin() );
		$result   = $upgrader->install( $information->download_link );

		if ( \is_wp_error( $result ) ) {
			return $this->error_response( $result->get_error_message(), 500 );
		}

		if ( true !== $result ) {
			return $this->error_response( __( 'The plugin could not be installed.', 'warranty-cart' ), 500 );
		}

		return $this->success_response(
			array(
				'slug'    => $slug,
				'state'   => 'inactive',
				'message' => __( 'Installed. Activate it when you are ready.', 'warranty-cart' ),
			)
		);
	}

	/**
	 * Activate one of the listed plugins.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response|\WP_Error
	 */
	public function activate_listed_plugin( WP_REST_Request $request ) {
		$slug = (string) $request->get_param( 'slug' );

		if ( ! $this->is_listed( $slug ) ) {
			return $this->error_response( __( 'That plugin is not one of ours.', 'warranty-cart' ), 400 );
		}

		$file = $this->plugin_file( $slug );

		if ( '' === $file ) {
			return $this->error_response( __( 'That plugin is not installed.', 'warranty-cart' ), 404 );
		}

		$result = \activate_plugin( $file );

		/*
		 * A WP_Error here means the plugin fatalled on load and WordPress has
		 * already rolled the activation back. Worth reporting rather than
		 * swallowing: the screen would otherwise claim success over a plugin
		 * that is still switched off.
		 */
		if ( \is_wp_error( $result ) ) {
			return $this->error_response( $result->get_error_message(), 500 );
		}

		return $this->success_response(
			array(
				'slug'    => $slug,
				'state'   => 'active',
				'message' => __( 'Activated.', 'warranty-cart' ),
			)
		);
	}

	/**
	 * The author's plugins, as the directory reports them.
	 *
	 * @param bool $refresh Skip the cache.
	 *
	 * @return array{plugins: array<int, array<string, mixed>>, error: string}
	 */
	private function from_directory( bool $refresh = false ): array {
		if ( ! $refresh ) {
			$cached = \get_transient( self::CACHE_KEY );

			if ( is_array( $cached ) ) {
				return array(
					'plugins' => $cached,
					'error'   => '',
				);
			}
		}

		if ( ! function_exists( 'plugins_api' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
		}

		/**
		 * Filter the WordPress.org author whose plugins this screen lists.
		 *
		 * @param string $author The directory username.
		 */
		$author = (string) \apply_filters( 'warranty_cart_our_plugins_author', self::AUTHOR );

		$response = \plugins_api(
			'query_plugins',
			array(
				'author'   => $author,
				'per_page' => 24,
				'fields'   => array(
					'short_description' => true,
					'icons'             => true,
					'active_installs'   => true,

					/*
					 * The aggregate rating and how many people gave it, which is
					 * what the author's other plugins show on the same screen.
					 * `ratings` is the full per-star breakdown and is not needed
					 * for a five-star summary — `rating` and `num_ratings` come
					 * back regardless.
					 */
					'ratings'           => false,
					'sections'          => false,
					'contributors'      => false,
					'tags'              => false,
					'compatibility'     => false,
				),
			)
		);

		if ( \is_wp_error( $response ) || empty( $response->plugins ) ) {
			/*
			 * Cached briefly even on failure.
			 *
			 * Without this, a directory that is down means every load of this
			 * screen waits out the same timeout again. Five minutes is short
			 * enough that a recovered directory shows up promptly.
			 */
			\set_transient( self::CACHE_KEY, array(), 5 * MINUTE_IN_SECONDS );

			return array(
				'plugins' => array(),
				'error'   => \is_wp_error( $response )
					? $response->get_error_message()
					: __( 'The plugin directory returned nothing.', 'warranty-cart' ),
			);
		}

		$plugins = array();

		foreach ( $response->plugins as $plugin ) {
			$plugin = (array) $plugin;
			$slug   = (string) ( $plugin['slug'] ?? '' );

			/**
			 * Slugs to leave off the screen.
			 *
			 * Only this plugin by default — listing the screen you are already
			 * looking at. Everything else the author publishes belongs here: the
			 * screen is the author's shelf, and an administrator browsing it is
			 * doing so deliberately rather than having it pushed at them.
			 *
			 * The readmes are a different matter and do carry a shorter list.
			 * That text sits on a WooCommerce listing where the reader came
			 * looking for warranties, so it stays close to what they asked for.
			 *
			 * @param array $skip Plugin slugs.
			 */
			$skip = (array) \apply_filters(
				'warranty_cart_our_plugins_skip',
				array( 'warranty-cart' )
			);

			if ( '' === $slug || in_array( $slug, $skip, true ) ) {
				continue;
			}

			$icons = (array) ( $plugin['icons'] ?? array() );

			$plugins[] = array(
				'slug'            => $slug,
				'name'            => \wp_strip_all_tags( (string) ( $plugin['name'] ?? $slug ) ),
				'description'     => \wp_strip_all_tags( (string) ( $plugin['short_description'] ?? '' ) ),
				'version'         => (string) ( $plugin['version'] ?? '' ),
				'active_installs' => (int) ( $plugin['active_installs'] ?? 0 ),

				/*
				 * The directory scores out of 100, not out of five. Converted on
				 * the way out so no caller has to know that, and `num_ratings`
				 * travels with it — four stars from three people and four stars
				 * from four hundred are not the same claim, and a rating shown
				 * without its count implies the second.
				 */
				'rating'          => (float) ( $plugin['rating'] ?? 0 ) / 20,
				'num_ratings'     => (int) ( $plugin['num_ratings'] ?? 0 ),

				/*
				 * `2x` where the directory has one. These render at 48px on a
				 * screen that is frequently a retina one, and the 1x asset is
				 * visibly soft there. `default` is the generic grey placeholder
				 * WordPress.org substitutes, which is worse than no icon.
				 */
				'icon'            => (string) ( $icons['2x'] ?? $icons['1x'] ?? $icons['svg'] ?? '' ),
				'url'             => \__( 'https://wordpress.org/plugins/', 'warranty-cart' ) . $slug . '/',
			);
		}

		\set_transient( self::CACHE_KEY, $plugins, self::CACHE_TTL );

		return array(
			'plugins' => $plugins,
			'error'   => '',
		);
	}

	/**
	 * The directory's list, marked with what this site already has.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response
	 */
	public function get_plugins( WP_REST_Request $request ): WP_REST_Response {
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$directory = $this->from_directory( (bool) $request->get_param( 'refresh' ) );

		/*
		 * Install state is computed fresh, never cached.
		 *
		 * The directory's answer changes on their schedule; whether this site
		 * has a plugin changes on the administrator's. Caching the two together
		 * would leave a plugin reading "not installed" for twelve hours after
		 * somebody installed it from this very page.
		 */
		$installed = \get_plugins();
		$by_slug   = array();

		foreach ( $installed as $file => $data ) {
			$by_slug[ dirname( $file ) ] = array(
				'file'    => $file,
				'version' => (string) ( $data['Version'] ?? '' ),
			);
		}

		$entries = array();

		foreach ( $directory['plugins'] as $plugin ) {
			$slug  = $plugin['slug'];
			$local = $by_slug[ $slug ] ?? null;

			$is_active = $local && \is_plugin_active( $local['file'] );

			/*
			 * Three states, not two. "Installed but switched off" is the one
			 * worth distinguishing: the shop already has the plugin and needs a
			 * click rather than a download, and telling somebody to install what
			 * they already have is how a page loses their trust.
			 */
			$state = $is_active ? 'active' : ( $local ? 'inactive' : 'missing' );

			$entries[] = array_merge(
				$plugin,
				array(
					'state'            => $state,

					// The installed version where there is one; the directory's otherwise.
					'version'          => $local ? $local['version'] : $plugin['version'],
					'latest_version'   => $plugin['version'],
					'update_available' => (bool) ( $local && version_compare( $local['version'], $plugin['version'], '<' ) ),

					/*
					 * Where the button goes. Both destinations are WordPress's
					 * own screens, which already carry the capability check, the
					 * nonce and the failure handling for what happens next.
					 */
					'action_url'       => $local
						? \admin_url( 'plugins.php?s=' . rawurlencode( $plugin['name'] ) . '&plugin_status=all' )
						: \admin_url( 'plugin-install.php?tab=plugin-information&plugin=' . rawurlencode( $slug ) ),
				)
			);
		}

		return $this->success_response(
			array(
				'plugins'      => $entries,
				'error'        => $directory['error'],
				'can_install'  => \current_user_can( 'install_plugins' ),
				'can_activate' => \current_user_can( 'activate_plugins' ),
			)
		);
	}
}
