<?php
/**
 * Load snippets from the wpcode.com snippet library.
 *
 * @package WPCode
 */

/**
 * Class WPCode_Library.
 */
class WPCode_Library {

	/**
	 * Key for storing snippets in the cache.
	 *
	 * @var string
	 */
	protected $cache_key = 'snippets';

	/**
	 * Library endpoint for loading all data.
	 *
	 * @var string
	 */
	protected $all_snippets_endpoint = 'get';

	/**
	 * The key for storing individual snippets.
	 *
	 * @var string
	 */
	protected $snippet_key = 'snippets/snippet';

	/**
	 * The base cache folder for this class.
	 *
	 * @var string
	 */
	protected $cache_folder = 'library';

	/**
	 * The data.
	 *
	 * @var array
	 */
	protected $data;

	/**
	 * The default time to live for libary items that are cached.
	 *
	 * @var int
	 */
	protected $ttl = DAY_IN_SECONDS;

	/**
	 * Key for transient used to store already installed snippets.
	 *
	 * @var string
	 */
	protected $used_snippets_transient_key = 'wpcode_used_library_snippets';

	/**
	 * Array of snippet ids that were already loaded from the library.
	 *
	 * @var array
	 */
	protected $library_snippets;

	/**
	 * Meta Key used for storing the library id.
	 *
	 * @var string
	 */
	protected $snippet_library_id_meta_key = '_wpcode_library_id';

	/**
	 * Total number of snippets in the library atm.
	 *
	 * @var int
	 */
	protected $snippets_count;

	/**
	 * Snippets grouped by library username.
	 *
	 * @var array
	 */
	protected $snippets_by_username = array();

	/**
	 * Array of library usernames.
	 *
	 * @var array
	 */
	protected $library_usernames = array();

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->hooks();
	}

	/**
	 * Class-specific hooks.
	 *
	 * @return void
	 */
	protected function hooks() {
		add_action( 'trash_wpcode', array( $this, 'clear_used_snippets' ) );
		add_action( 'transition_post_status', array( $this, 'clear_used_snippets_untrash' ), 10, 3 );
		add_action( 'wpcode_library_api_auth_connected', array( $this, 'delete_cache' ) );
		add_action( 'wpcode_library_api_auth_connected', array( $this, 'get_data_delayed' ), 15 );
		add_action( 'wpcode_library_api_auth_deleted', array( $this, 'delete_cache' ) );
	}

	/**
	 * Wait for the file cache to be cleared before loading the data.
	 *
	 * @return void
	 */
	public function get_data_delayed() {

		// Wait for the cache to be cleared.
		add_action( 'shutdown', array( $this, 'get_data' ) );
	}

	/**
	 * Grab all the available categories from the library.
	 *
	 * @return array
	 */
	public function get_data() {
		if ( ! isset( $this->data ) ) {
			$this->data = $this->load_data();
		}

		return $this->data;
	}

	/**
	 * Get the number of snippets in the library.
	 *
	 * @return int
	 */
	public function get_snippets_count() {
		if ( ! isset( $this->snippets_count ) ) {
			$this->snippets_count = 0;
			$data                 = $this->get_data();
			if ( ! empty( $data['snippets'] ) ) {
				$this->snippets_count = count( $data['snippets'] );
			}
		}

		return $this->snippets_count;
	}

	/**
	 * Grab data from the cache.
	 *
	 * @param string $key The key used to grab from cache.
	 * @param int    $ttl The time to live for cached data, defaults to class ttl.
	 *
	 * @return array|false
	 */
	public function get_from_cache( $key, $ttl = 0 ) {
		if ( empty( $ttl ) ) {
			$ttl = $this->ttl;
		}

		$data = wpcode()->file_cache->get( $this->cache_folder . '/' . $key, $ttl );

		if ( isset( $data['error'] ) && isset( $data['time'] ) ) {
			if ( $data['time'] + 10 * MINUTE_IN_SECONDS < time() ) {
				return false;
			} else {
				return $this->get_empty_array();
			}
		}

		return $data;
	}

	/**
	 * Load the library data either from the server or from cache.
	 *
	 * @return array
	 */
	public function load_data() {
		$this->data = $this->get_from_cache( $this->cache_key );

		if ( false === $this->data ) {
			$this->data = $this->get_from_server();
		}

		$this->maybe_add_usernames_data();

		return $this->data;
	}


	/**
	 * Get data from the server.
	 *
	 * @return array
	 */
	protected function get_from_server() {
		$data = $this->process_response( $this->make_request( $this->all_snippets_endpoint ) );

		if ( empty( $data['snippets'] ) ) {
			return $this->save_temporary_response_fail( $this->cache_key );
		}

		$this->save_to_cache( $this->cache_key, $data );

		return $data;
	}

	/**
	 * Generic request handler with support for authentication.
	 *
	 * @param string $endpoint The API endpoint to load data from.
	 * @param string $method The method used for the request (GET, POST, etc).
	 * @param array  $data The data to pass in the body for POST-like requests.
	 *
	 * @return string
	 */
	public function make_request( $endpoint = '', $method = 'GET', $data = array() ) {
		$args = array(
			'method'  => $method,
			'timeout' => 10,
		);
		if ( wpcode()->library_auth->has_auth() ) {
			$args['headers'] = $this->get_authenticated_headers();
		}
		if ( ! empty( $data ) ) {
			$args['body'] = $data;
		}

		$url = add_query_arg(
			array(
				'site'    => rawurlencode( site_url() ),
				'version' => WPCODE_VERSION,
			),
			wpcode()->library_auth->get_api_url( $endpoint )
		);

		$response = wp_remote_request( $url, $args );

		$response_code = wp_remote_retrieve_response_code( $response );
		if ( $response_code > 299 ) {
			// Temporary error so cache for just 10 minutes and then try again.
			return '';
		}

		return wp_remote_retrieve_body( $response );
	}

	/**
	 * Get the headers for making an authenticated request.
	 *
	 * @return array
	 */
	public function get_authenticated_headers() {
		// Build the headers of the request.
		return array(
			'Content-Type'     => 'application/x-www-form-urlencoded',
			'Cache-Control'    => 'no-store, no-cache, must-revalidate, max-age=0, post-check=0, pre-check=0',
			'Pragma'           => 'no-cache',
			'Expires'          => 0,
			'Origin'           => site_url(),
			'WPCode-Referer'   => site_url(),
			'WPCode-Sender'    => 'WordPress',
			'WPCode-Site'      => esc_attr( get_option( 'blogname' ) ),
			'WPCode-Version'   => esc_attr( WPCODE_VERSION ),
			'WPCode-Client-Id' => wpcode()->library_auth->get_client_id(),
			'X-WPCode-ApiKey'  => wpcode()->library_auth->get_auth_key(),
		);
	}

	/**
	 * When we can't fetch from the server we save a temporary error => true file to avoid
	 * subsequent requests for a while. Returns a properly formatted array for frontend output.
	 *
	 * @param string $key The key used for storing the data in the cache.
	 *
	 * @return array
	 */
	public function save_temporary_response_fail( $key ) {
		$data = array(
			'error' => true,
			'time'  => time(),
		);
		$this->save_to_cache( $key, $data );

		return $this->get_empty_array();
	}

	/**
	 * Get an empty array for a consistent response.
	 *
	 * @return array[]
	 */
	public function get_empty_array() {
		return array(
			'categories' => array(),
			'snippets'   => array(),
		);
	}

	/**
	 * Save to cache.
	 *
	 * @param string      $key The key used to store the data in the cache.
	 * @param array|mixed $data The data that will be stored.
	 *
	 * @return void
	 */
	public function save_to_cache( $key, $data ) {
		wpcode()->file_cache->set( $this->cache_folder . '/' . $key, $data );
	}

	/**
	 * Generic handler for grabbing data by slug. Either all categories or the category slug.
	 *
	 * @param string $data Response body from server.
	 *
	 * @return array
	 */
	public function process_response( $data ) {
		$response = json_decode( $data, true );
		if ( ! isset( $response['status'] ) || 'success' !== $response['status'] ) {
			return $this->get_empty_array();
		}

		return $response['data'];
	}

	/**
	 * Get a cache key for a specific snippet id.
	 *
	 * @param int $id The snippet id.
	 *
	 * @return string
	 */
	public function get_snippet_cache_key( $id ) {
		return $this->snippet_key . '_' . $id;
	}

	/**
	 * Create a new snippet by the library id.
	 * This grabs the snippet by its id from the snippet library site and creates
	 * a new snippet on the current site using the response.
	 *
	 * @param int $library_id The id of the snippet on the library site.
	 *
	 * @return false|WPCode_Snippet
	 */
	public function create_new_snippet( $library_id ) {

		$snippet_data = $this->grab_snippet_from_api( $library_id );

		if ( ! $snippet_data ) {
			return false;
		}

		$snippet_data = apply_filters( 'wpcode_library_import_snippet_data', $snippet_data );

		$snippet = wpcode_get_snippet( $snippet_data );

		$snippet_id = $snippet->save();

		// Save the version information if available in the API response.
		if ( ! empty( $snippet_data['version'] ) ) {
			update_post_meta( $snippet_id, '_wpcode_snippet_version', $snippet_data['version'] );
		}

		delete_transient( $this->used_snippets_transient_key );

		return $snippet;
	}

	/**
	 * Grab a snippet data from the API.
	 *
	 * @param int $library_id The id of the snippet in the Library api.
	 *
	 * @return array|array[]|false
	 */
	public function grab_snippet_from_api( $library_id ) {
		$snippet_request = $this->make_request( 'get/' . $library_id );
		$snippet_data    = $this->process_response( $snippet_request );

		if ( empty( $snippet_data ) ) {
			return false;
		}

		return $snippet_data;
	}

	/**
	 * Get all the snippets that were created from the library, by library ID.
	 * Results are cached in a transient.
	 *
	 * @return array
	 */
	public function get_used_library_snippets() {

		if ( isset( $this->library_snippets ) ) {
			return $this->library_snippets;
		}

		$snippets_from_library = get_transient( $this->used_snippets_transient_key );

		if ( false === $snippets_from_library ) {
			$snippets_from_library = array();

			$args     = array(
				'post_type'   => wpcode_get_post_type(),
				'meta_query'  => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					array(
						'key'     => $this->snippet_library_id_meta_key,
						'compare' => 'EXISTS',
					),
				),
				'fields'      => 'ids',
				'post_status' => 'any',
				'nopaging'    => true,
			);
			$snippets = get_posts( $args );

			foreach ( $snippets as $snippet_id ) {
				$snippets_from_library[ $this->get_snippet_library_id( $snippet_id ) ] = $snippet_id;
			}

			set_transient( $this->used_snippets_transient_key, $snippets_from_library );
		}

		$this->library_snippets = $snippets_from_library;

		return $this->library_snippets;
	}

	/**
	 * Grab the library id from the snippet by snippet id.
	 *
	 * @param int $snippet_id The snippet id.
	 *
	 * @return int
	 */
	public function get_snippet_library_id( $snippet_id ) {
		return absint( get_post_meta( $snippet_id, '_wpcode_library_id', true ) );
	}

	/**
	 * When a snippet is trashed, clear the used snippets transients
	 * for this class instance to avoid confusion in the library.
	 *
	 * @return void
	 */
	public function clear_used_snippets() {
		delete_transient( $this->used_snippets_transient_key );
	}

	/**
	 * Clear used snippets also when a snippet is un-trashed.
	 *
	 * @param string  $new_status The new post status.
	 * @param string  $old_status The old post status.
	 * @param WP_Post $post The post object.
	 *
	 * @return void
	 */
	public function clear_used_snippets_untrash( $new_status, $old_status, $post ) {
		if ( 'wpcode' !== $post->post_type || 'trash' !== $old_status ) {
			return;
		}

		$this->clear_used_snippets();
	}

	/**
	 * Delete the file cache for the snippets library.
	 *
	 * @return void
	 */
	public function delete_cache() {
		wpcode()->file_cache->delete( $this->cache_folder . '/' . $this->cache_key );
		if ( isset( $this->data ) ) {
			unset( $this->data );
		}
	}

	/**
	 * Makes a request to the snippet library API to grab a public snippet by its hash.
	 *
	 * @param string $hash The hash used to identify the snippet on the library server.
	 * @param string $auth The unique user hash used to authenticate the request on the library.
	 *
	 * @return array
	 */
	public function get_public_snippet( $hash, $auth ) {
		// Let's use transients for hashes to avoid unnecessary requests.
		$transient_key = 'wpcode_public_snippet_' . $hash . '_' . $auth;
		$snippet_data  = get_transient( $transient_key );
		if ( false === $snippet_data ) {
			$snippet_request = $this->make_request(
				'public/' . $hash,
				'POST',
				array(
					'auth' => $auth,
				)
			);
			$snippet_data    = json_decode( $snippet_request, true );
			// Transient for 1 minute if error otherwise 30 minutes.
			$timeout = ! isset( $snippet_data['status'] ) || 'error' === $snippet_data['status'] ? 60 : 30 * 60;
			set_transient( $transient_key, $snippet_data, $timeout );
		}

		return $snippet_data;
	}

	/**
	 * Get snippets by username.
	 *
	 * @param string $username The username to grab data for.
	 * @param string $version The version of the library to grab data for.
	 *
	 * @return array
	 */
	public function get_snippets_by_username( $username, $version = '' ) {

		$username = sanitize_key( $username );

		if ( empty( $version ) ) {
			// Let's grab the version from the registered username if no version is explicitly passed.
			$version = $this->get_version_by_username( $username );
		}

		if ( ! isset( $this->snippets_by_username[ $username ] ) ) {
			$this->load_snippets_by_username( $username, $version );
		}

		return $this->snippets_by_username[ $username ];
	}

	/**
	 * Grab the version from the registered username array.
	 *
	 * @param string $username The username to grab version for.
	 *
	 * @return string
	 */
	public function get_version_by_username( $username ) {
		return isset( $this->library_usernames[ $username ] ) ? $this->library_usernames[ $username ]['version'] : '';
	}

	/**
	 * Load snippets in the current instance, either from cache or from the server.
	 *
	 * @param string $username The username to grab data for.
	 * @param string $version The version of the plugin/theme to grab data for.
	 *
	 * @return array
	 */
	private function load_snippets_by_username( $username, $version ) {

		$this->snippets_by_username[ $username ] = $this->get_from_cache( 'profile_' . $username );

		if ( false === $this->snippets_by_username[ $username ] ) {
			$this->snippets_by_username[ $username ] = $this->get_from_server_by_username( $username );
		}

		// Let's filter the loaded data to make sure no snippets aimed at older versions are loaded.
		$this->snippets_by_username[ $username ] = $this->filter_snippets_by_version( $this->snippets_by_username[ $username ], $version );

		return $this->data;
	}

	/**
	 * Go through all the snippets and if they have a maximum version set, remove them if the current version is higher.
	 *
	 * @param array  $profile_data The snippets to filter.
	 * @param string $version The version to filter by.
	 *
	 * @return array
	 */
	public function filter_snippets_by_version( $profile_data, $version ) {
		// If we have no version, we can't filter anything.
		if ( empty( $version ) || empty( $profile_data['snippets'] ) ) {
			return $profile_data;
		}

		$filtered_snippets = array();
		foreach ( $profile_data['snippets'] as $snippet ) {
			if ( empty( $snippet['max_version'] ) || version_compare( $version, $snippet['max_version'], '<=' ) ) {
				$filtered_snippets[] = $snippet;
			}
		}

		$profile_data['snippets'] = $filtered_snippets;

		return $profile_data;
	}

	/**
	 * Grab data from the WPCode library by username.
	 *
	 * @param string $username The username to grab data for.
	 *
	 * @return array|array[]
	 */
	private function get_from_server_by_username( $username ) {
		$data = $this->process_response( $this->make_request( 'profile/' . $username ) );

		if ( empty( $data['snippets'] ) ) {
			return $this->save_temporary_response_fail( 'profile_' . $username );
		}

		$this->save_to_cache( 'profile_' . $username, $data );

		return $data;
	}

	/**
	 * Get a list of usernames that we should attempt to load data from the library for.
	 *
	 * @return array
	 */
	public function get_library_usernames() {
		return $this->library_usernames;
	}

	/**
	 * Add a method to allow other plugins to register usernames to load data for.
	 *
	 * @param string $username The public username on the WPCode Library.
	 * @param string $label The label to display in the WPCode library view.
	 * @param string $max_version The plugin/theme version, used for excluding snippets aimed at older plugin/theme versions.
	 *
	 * @return void
	 */
	public function register_library_username( $username, $label = '', $max_version = '' ) {
		$username = sanitize_key( $username );
		if ( empty( $label ) ) {
			$label = $username;
		}

		$this->library_usernames[ $username ] = array(
			'label'   => $label,
			'version' => $max_version,
		);
	}

	/**
	 * If there are usernames to load data for, add them to the data array.
	 *
	 * @return void
	 */
	public function maybe_add_usernames_data() {
		$usernames = $this->get_library_usernames();
		if ( empty( $usernames ) ) {
			return;
		}

		foreach ( $usernames as $username => $data ) {
			$snippets = $this->get_snippets_by_username( $username, $data['version'] );
			if ( ! empty( $snippets['snippets'] ) ) {
				$this->data['categories'][] = array(
					'slug'  => $username,
					'name'  => $data['label'],
					'count' => count( $snippets['snippets'] ),
				);
				// Append snippets to the $this->data['snippets'] array.
				$this->data['snippets'] = array_merge( $this->data['snippets'], $snippets['snippets'] );
			}
		}
	}

	/**
	 * Get the URL to edit a snippet.
	 *
	 * @param int $snippet_id The snippet id.
	 *
	 * @return string
	 */
	public function get_edit_snippet_url( $snippet_id ) {
		return add_query_arg(
			array(
				'page'       => 'wpcode-snippet-manager',
				'snippet_id' => absint( $snippet_id ),
			),
			admin_url( 'admin.php' )
		);
	}

	/**
	 * Get a direct link to install a snippet by its library URL.
	 *
	 * @param int $snippet_library_id The snippet ID on the WPCode library.
	 *
	 * @return string
	 */
	public function get_install_snippet_url( $snippet_library_id ) {
		return wp_nonce_url(
			add_query_arg(
				array(
					'snippet_library_id' => absint( $snippet_library_id ),
					'page'               => 'wpcode-library',
				),
				admin_url( 'admin.php' )
			),
			'wpcode_add_from_library'
		);
	}

	/**
	 * Get just the snippets from usernames.
	 *
	 * @return array
	 */
	public function get_username_snippets() {
		$usernames = $this->get_library_usernames();

		$snippets   = array();
		$categories = array();

		foreach ( $usernames as $username => $data ) {
			$username_snippets = $this->get_snippets_by_username( $username, $data['version'] );
			if ( ! empty( $username_snippets['snippets'] ) ) {
				$categories[] = array(
					'slug'  => $username,
					'name'  => $data['label'],
					'count' => count( $username_snippets['snippets'] ),
				);
				// Append snippets to the $this->data['snippets'] array.
				$snippets = array_merge( $snippets, $username_snippets['snippets'] );
			}
		}

		return array(
			'categories' => $categories,
			'snippets'   => $snippets,
		);
	}

	/**
	 * Check if a snippet has an update available by comparing with cached data.
	 *
	 * @param int    $snippet_id The snippet ID.
	 * @param string $library_id The library ID.
	 *
	 * @return bool|array False if no update, array with version info if update available.
	 */
	public function check_snippet_update( $snippet_id, $library_id ) {
		// Get current version from post meta.
		$current_version = get_post_meta( $snippet_id, '_wpcode_snippet_version', true );

		// For library snippets, get from library cache.
		$cached_data     = $this->get_data();
		$library_snippet = null;
		if ( ! empty( $cached_data['snippets'] ) ) {
			foreach ( $cached_data['snippets'] as $snippet ) {
				if ( isset( $snippet['library_id'] ) && absint( $snippet['library_id'] ) === absint( $library_id ) ) {
					$library_snippet = $snippet;
					break;
				}
			}
		}

		if ( ! $library_snippet || empty( $library_snippet['version'] ) ) {
			return false;
		}

		$latest_version = $library_snippet['version'];

		// If either version is empty, set it to 1.0.0.
		if ( empty( $current_version ) ) {
			$current_version = '1.0.0';
		}

		if ( empty( $latest_version ) ) {
			$latest_version = '1.0.0';
		}

		// If latest version is greater than current version, update is available.
		if ( version_compare( $latest_version, $current_version, '>' ) ) {
			return array(
				'current_version' => $current_version,
				'latest_version'  => $latest_version,
			);
		}

		return false;
	}


	/**
	 * Update a snippet from the library.
	 *
	 * @param int $snippet_id The ID of the snippet to update.
	 * @param int $library_id The ID of the library snippet to fetch.
	 *
	 * @return array|false Array with success data or false on failure.
	 */
	public function update_snippet_from_library( $snippet_id, $library_id ) {
		// Get snippet data from library.
		$library_snippet = $this->grab_snippet_from_api( $library_id );

		if ( ! $library_snippet ) {
			return false;
		}

		// Update snippet.
		$library_snippet['id'] = $snippet_id;
		$snippet               = wpcode_get_snippet( $library_snippet );
		$result                = $snippet->save();

		if ( ! $result ) {
			return false;
		}

		// Update local version metadata.
		if ( ! empty( $library_snippet['version'] ) ) {
			update_post_meta( $snippet_id, '_wpcode_snippet_version', $library_snippet['version'] );
		}

		return array(
			'success' => true,
			'version' => ! empty( $library_snippet['version'] ) ? $library_snippet['version'] : '',
		);
	}

	/**
	 * Search snippets in the library by keyword.
	 *
	 * @param string $keyword The keyword to search for.
	 *
	 * @return array Array of matching snippets.
	 */
	public function search_snippets( $keyword ) {
		$data         = $this->get_data();
		$all_snippets = isset( $data['snippets'] ) ? $data['snippets'] : array();
		$results      = array();

		if ( empty( $all_snippets ) || ! is_array( $all_snippets ) ) {
			return $results;
		}

		foreach ( $all_snippets as $snippet ) {
			$found = false;

			// Search in title.
			if ( isset( $snippet['title'] ) && stripos( $snippet['title'], $keyword ) !== false ) {
				$found = true;
			}

			// Search in description/note.
			if ( ! $found && isset( $snippet['note'] ) && stripos( $snippet['note'], $keyword ) !== false ) {
				$found = true;
			}

			// Search in tags.
			if ( ! $found && isset( $snippet['tags'] ) && is_array( $snippet['tags'] ) ) {
				foreach ( $snippet['tags'] as $tag ) {
					if ( stripos( $tag, $keyword ) !== false ) {
						$found = true;
						break;
					}
				}
			}

			if ( $found ) {
				$results[] = $snippet;
			}
		}

		return $results;
	}

	/**
	 * Get the list of snippets that have updates available.
	 * Checks on the fly using cached data.
	 *
	 * @return array
	 */
	public function get_snippets_with_updates() {
		// Get all snippets with library IDs.
		$library_snippets = $this->get_used_library_snippets();

		$snippets_with_updates = array();

		// Check library snippets.
		foreach ( $library_snippets as $library_id => $snippet_id ) {
			$update_info = $this->check_snippet_update( $snippet_id, $library_id );
			if ( $update_info ) {
				$snippets_with_updates[] = $snippet_id;
			}
		}

		/**
		 * Filter the list of snippets with updates.
		 * This allows other classes to add their snippets with updates to the results.
		 *
		 * @param array $snippets_with_updates The list of snippets with updates.
		 */
		return apply_filters( 'wpcode_snippets_with_updates', $snippets_with_updates );
	}
}
