<?php

namespace GravityKit\GravityView\Foundation\Helpers;

use wpdb;

class WP {
	/**
	 * Local cache of transients.
	 *
	 * @since 1.2.7
	 *
	 * @var array
	 */
	public static $transients = [];

	/**
	 * Wrapper around {@see get_transient()}. Transient is stored as an option {@see self::set_transient()} in order to avoid object caching issues.
	 * Raw SQL query (taken from WP's core) is used in order to avoid object caching issues, such as with the Redis Object Cache plugin.
	 *
	 * @since 1.2.6
	 *
	 * @param string $transient Transient name.
	 *
	 * @return mixed|false Transient value or false if not set.
	 */
	public static function get_transient( string $transient ) {
		/**
		 * @var wpdb $wpdb
		 */
		global $wpdb;

		if ( ! is_object( $wpdb ) ) {
			return false;
		}

		$pre = apply_filters( "pre_transient_{$transient}", false, $transient );

		if ( false !== $pre ) {
			return $pre;
		}

		if ( isset( self::$transients[ self::get_transient_key_for_cache( $transient ) ] ) ) {
			$data = self::$transients[ self::get_transient_key_for_cache( $transient ) ];
		} else {
			/** @phpstan-ignore-next-line */
			$row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM `$wpdb->options` WHERE option_name = %s LIMIT 1", $transient ) );

			if ( ! is_object( $row ) ) {
				return false;
			}

			$data = maybe_unserialize( $row->option_value );

			self::$transients[ self::get_transient_key_for_cache( $transient ) ] = $data;
		}

		$value = self::retrieve_value_and_maybe_expire_transient( $transient, $data );

		return apply_filters( "transient_{$transient}", $value, $transient );
	}

	/**
	 * Wrapper around {@see set_transient()}. Transient is stored as an option in order to avoid object caching issues.
	 * Raw SQL query (taken from WP's core) is used in order to avoid object caching issues, such as with the Redis Object Cache plugin.
	 *
	 * @since 1.2.6
	 *
	 * @param string $transient  Transient name.
	 * @param mixed  $value      Transient value.
	 * @param int    $expiration (optional) Time until expiration in seconds. Default: 0 (no expiration).
	 *
	 * @return bool True if the value was set, false otherwise.
	 */
	public static function set_transient( string $transient, $value, int $expiration = 0 ): bool {
		/**
		 * @var wpdb $wpdb
		 */
		global $wpdb;

		if ( ! is_object( $wpdb ) ) {
			return false;
		}

		wp_protect_special_option( $transient );

		$expiration = (int) $expiration;

		$value = apply_filters( "pre_set_transient_{$transient}", $value, $expiration, $transient );

		$expiration = apply_filters( "expiration_of_transient_{$transient}", $expiration, $value, $transient );

		$data = self::format_transient_data( $value, $expiration );

		// Insert or update the option.
		/** @phpstan-ignore-next-line */
		$result = $wpdb->query( $wpdb->prepare( "INSERT INTO `$wpdb->options` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), `option_value` = VALUES(`option_value`), `autoload` = VALUES(`autoload`)", $transient, maybe_serialize( $data ), 'no' ) );

		if ( $result ) {
			do_action( "set_transient_{$transient}", $data['value'], $data['expiration'], $transient );
			do_action( 'setted_transient', $transient, $data['value'], $data['expiration'] );

			self::$transients[ self::get_transient_key_for_cache( $transient ) ] = $data;

			return true;
		}

		return false;
	}

	/**
	 * Wrapper around {@see delete_transient()} to delete a transient set with {@see self::set_transient()}.
	 *
	 * @since 1.2.9
	 *
	 * @param string $transient Transient name.
	 *
	 * @return bool True if the transient was deleted, false otherwise.
	 */
	public static function delete_transient( string $transient ): bool {
		do_action( "delete_transient_{$transient}", $transient );

		wp_protect_special_option( $transient );

		$result = delete_option( $transient );

		if ( $result ) {
			do_action( 'deleted_transient', $transient );

			unset( self::$transients[ self::get_transient_key_for_cache( $transient ) ] );
		}

		return $result;
	}

	/**
	 * Inserts an option only when the option does not already exist.
	 *
	 * This is intentionally insert-only. Use it for lock and mutex rows where
	 * updating an existing option would incorrectly grant ownership to another
	 * request. Use WordPress option APIs for normal option writes.
	 *
	 * @since 1.19.0
	 *
	 * @param string $option   Option name.
	 * @param mixed  $value    Option value.
	 * @param bool   $autoload Whether to autoload the option.
	 *
	 * @return bool Whether the option was inserted.
	 */
	public static function insert_option_if_absent( string $option, $value, bool $autoload = false ): bool {
		/**
		 * @var wpdb $wpdb
		 */
		global $wpdb;

		if ( ! is_object( $wpdb ) ) {
			return false;
		}

		$option = trim( $option );

		if ( '' === $option ) {
			return false;
		}

		wp_protect_special_option( $option );

		if ( is_object( $value ) ) {
			$value = clone $value;
		}

		$serialized_value = maybe_serialize( $value );
		$autoload_value   = $autoload ? 'yes' : 'no';

		$inserted = (int) $wpdb->query(
			$wpdb->prepare(
				/** @phpstan-ignore-next-line */
				"INSERT IGNORE INTO `$wpdb->options` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, %s)",
				$option,
				$serialized_value,
				$autoload_value
			)
		);

		if ( 1 !== $inserted ) {
			self::clear_option_cache_after_insert_miss( $option );

			return false;
		}

		self::update_option_cache_after_insert( $option, $serialized_value, $autoload_value );

		return true;
	}

	/**
	 * Clears stale option caches after an insert-only write misses.
	 *
	 * A missed insert usually means the row already exists. If this request had
	 * previously cached the option as missing, follow-up get_option() calls must
	 * be allowed to read the row that another request inserted.
	 *
	 * @since 1.19.0
	 *
	 * @param string $option Option name.
	 *
	 * @return void
	 */
	private static function clear_option_cache_after_insert_miss( string $option ): void {
		if ( wp_installing() ) {
			return;
		}

		wp_cache_delete( $option, 'options' );

		$notoptions = wp_cache_get( 'notoptions', 'options' );

		if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) {
			unset( $notoptions[ $option ] );
			wp_cache_set( 'notoptions', $notoptions, 'options' );
		}
	}

	/**
	 * Wrapper around {@see get_site_transient()}. Transient is stored as an option {@see self::set_site_transient()} in order to avoid object caching issues.
	 * Raw SQL query (taken from WP's core) is used in order to avoid object caching issues, such as with the Redis Object Cache plugin.
	 *
	 * @since 1.2.6
	 *
	 * @param string $transient Transient name.
	 *
	 * @return mixed|false Transient value or false if not set.
	 */
	public static function get_site_transient( string $transient ) {
		/**
		 * @var wpdb $wpdb
		 */
		global $wpdb;

		if ( ! is_object( $wpdb ) ) {
			return false;
		}

		if ( ! is_multisite() ) {
			return self::get_transient( $transient );
		}

		$pre = apply_filters( "pre_site_transient_{$transient}", false, $transient );

		if ( false !== $pre ) {
			return $pre;
		}

		$network_id = get_current_network_id();

		if ( isset( self::$transients[ self::get_transient_key_for_cache( $transient ) ] ) ) {
			$data = self::$transients[ self::get_transient_key_for_cache( $transient ) ];
		} else {
			/** @phpstan-ignore-next-line */
			$row = $wpdb->get_row( $wpdb->prepare( "SELECT meta_value FROM `$wpdb->sitemeta` WHERE meta_key = %s AND site_id = %d", $transient, $network_id ) );

			if ( ! is_object( $row ) ) {
				return false;
			}

			$data = maybe_unserialize( $row->meta_value );

			self::$transients[ self::get_transient_key_for_cache( $transient ) ] = $data;
		}

		$value = self::retrieve_value_and_maybe_expire_transient( $transient, $data );

		return apply_filters( "site_transient_{$transient}", $value, $transient );
	}

	/**
	 * Wrapper around {@see set_site_transient()}. Transient is stored as an option in order to avoid object caching issues.
	 * Raw SQL query (taken from WP's core) is used in order to avoid object caching issues, such as with the Redis Object Cache plugin.
	 *
	 * @since 1.2.6
	 *
	 * @param string $transient  Transient name.
	 * @param mixed  $value      Transient value.
	 * @param int    $expiration (optional) Time until expiration in seconds. Default: 0 (no expiration).
	 *
	 * @return bool True if the value was set, false otherwise.
	 */
	public static function set_site_transient( string $transient, $value, int $expiration = 0 ): bool {
		/**
		 * @var wpdb $wpdb
		 */
		global $wpdb;

		if ( ! is_object( $wpdb ) ) {
			return false;
		}

		if ( ! is_multisite() ) {
			return self::set_transient( $transient, $value, $expiration );
		}

		wp_protect_special_option( $transient );

		$expiration = (int) $expiration;

		$value = apply_filters( "pre_set_site_transient_{$transient}", $value, $transient );

		$expiration = apply_filters( "expiration_of_site_transient_{$transient}", $expiration, $value, $transient );

		$data = self::format_transient_data( $value, $expiration );

		$network_id = get_current_network_id();

		$transient_exists = $wpdb->get_var(
			/** @phpstan-ignore-next-line */
			$wpdb->prepare( "SELECT COUNT(*) FROM `$wpdb->sitemeta` WHERE `site_id` = %s AND `meta_key` = %s", $network_id, $transient )
		);

		// There is no unique constraint on the `sitemeta` table (unlike `options` table), so just in case let's delete existing transients.
		if ( $transient_exists > 0 ) {
			$wpdb->query(
				/** @phpstan-ignore-next-line */
				$wpdb->prepare( "DELETE FROM `$wpdb->sitemeta` WHERE `site_id` = %s AND `meta_key` = %s", $network_id, $transient )
			);
		}

		$result = $wpdb->query(
			/** @phpstan-ignore-next-line */
			$wpdb->prepare( "INSERT INTO `$wpdb->sitemeta` (`site_id`, `meta_key`, `meta_value`) VALUES (%s, %s, %s)", $network_id, $transient, maybe_serialize( $data ) )
		);

		if ( $result ) {
			do_action( "set_site_transient_{$transient}", $value, $expiration, $transient );
			do_action( 'setted_site_transient', $transient, $value, $expiration );

			self::$transients[ self::get_transient_key_for_cache( $transient ) ] = $data;

			return true;
		}

		return false;
	}

	/**
	 * Wrapper around {@see delete_site_transient()} to delete a transient set with {@see self::set_site_transient()}.
	 *
	 * @since 1.2.9
	 *
	 * @param string $transient Transient name.
	 *
	 * @return bool True if the transient was deleted, false otherwise.
	 */
	public static function delete_site_transient( string $transient ): bool {
		/**
		 * @var wpdb $wpdb
		 */
		global $wpdb;

		if ( ! is_multisite() ) {
			return self::delete_transient( $transient );
		}

		do_action( "delete_site_transient_{$transient}", $transient );

		wp_protect_special_option( $transient );

		$network_id = get_current_network_id();

		$result = $wpdb->query(
			/** @phpstan-ignore-next-line */
			$wpdb->prepare( "DELETE FROM `$wpdb->sitemeta` WHERE `site_id` = %s AND `meta_key` = %s", $network_id, $transient )
		);

		if ( $result ) {
			do_action( 'deleted_site_transient', $transient );

			unset( self::$transients[ self::get_transient_key_for_cache( $transient ) ] );

			return true;
		}

		return false;
	}

	/**
	 * Retrieves a value from the transient data and conditionally deletes transient if expired.
	 *
	 * @since 1.2.7
	 *
	 * @param string $transient      Transient name.
	 * @param mixed  $transient_data Transient data as stored in the database (unserialized).
	 *
	 * @return false|mixed
	 */
	private static function retrieve_value_and_maybe_expire_transient( $transient, $transient_data ) {
		if ( ! is_array( $transient_data ) ) {
			return false;
		}

		// If the transient lacks an expiration time or value, it's not a valid transient.
		if ( ! array_key_exists( 'expiration', $transient_data ) || ! array_key_exists( 'value', $transient_data ) ) {
			return false;
		}

		// If the transient has a non-zero expiration and has expired, delete it and return false.
		if ( 0 !== ( $transient_data['expiration'] ?? 0 ) && time() > $transient_data['expiration'] ) {
			delete_option( $transient );

			unset( self::$transients[ self::get_transient_key_for_cache( $transient ) ] );

			return false;
		}

		return $transient_data['value'];
	}

	/**
	 * Formats transient data for storage in the database.
	 *
	 * @since 1.2.7
	 *
	 * @param mixed $value      Transient value.
	 * @param int   $expiration (optional) Time until expiration in seconds. Default: 0 (no expiration).
	 *
	 * @return array
	 */
	private static function format_transient_data( $value, $expiration = 0 ) {
		return [
			'expiration' => 0 === $expiration ? $expiration : time() + $expiration,
			'value'      => $value,
		];
	}

	/**
	 * Updates WordPress option caches after an insert-only option write.
	 *
	 * @since 1.19.0
	 *
	 * @param string $option           Option name.
	 * @param string $serialized_value Serialized option value.
	 * @param string $autoload         Autoload value.
	 *
	 * @return void
	 */
	private static function update_option_cache_after_insert( string $option, string $serialized_value, string $autoload ): void {
		if ( wp_installing() ) {
			return;
		}

		if ( 'yes' === $autoload ) {
			$alloptions            = wp_load_alloptions( true );
			$alloptions[ $option ] = $serialized_value;
			wp_cache_set( 'alloptions', $alloptions, 'options' );
		} else {
			wp_cache_set( $option, $serialized_value, 'options' );
		}

		$notoptions = wp_cache_get( 'notoptions', 'options' );

		if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) {
			unset( $notoptions[ $option ] );
			wp_cache_set( 'notoptions', $notoptions, 'options' );
		}
	}

	/**
	 * Returns a key used to store transient in the class cache.
	 *
	 * @since 1.2.7
	 *
	 * @param string $transient Transient name.
	 *
	 * @return string
	 */
	private static function get_transient_key_for_cache( string $transient ) {
		return ! is_multisite() ? $transient : get_current_blog_id() . '-' . $transient;
	}
}
