<?php
/**
 * Database Table Class.
 *
 * @package Codeinwp\HyveLite
 */

namespace ThemeIsle\HyveLite;

use ThemeIsle\HyveLite\OpenAI;
use ThemeIsle\HyveLite\Qdrant_API;
use ThemeIsle\HyveLite\Tokenizer;

/**
 * Class DB_Table
 */
class DB_Table {

	/**
	 * The name of our database table.
	 *
	 * @since 1.2.0
	 * @var string
	 */
	public $table_name;

	/**
	 * The version of our database table.
	 *
	 * @since 1.2.0
	 * @var string
	 */
	public $version = '1.1.0';

	/**
	 * Cache prefix.
	 *
	 * @since 1.2.0
	 * @var string
	 */
	const CACHE_PREFIX = 'hyve-';

	/**
	 * Maximum number of times a chunk is retried after a transient failure
	 * before it is marked failed. See Codeinwp/hyve#199.
	 *
	 * @var int
	 */
	const MAX_PROCESS_ATTEMPTS = 5;

	/**
	 * The single instance of the class.
	 *
	 * @var DB_Table
	 */
	private static $instance = null;

	/**
	 * Ensures only one instance of the class is loaded.
	 *
	 * @return DB_Table An instance of the class.
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * DB_Table constructor.
	 *
	 * @since 1.2.0
	 */
	public function __construct() {
		global $wpdb;
		$this->table_name = $wpdb->prefix . 'hyve';

		add_action(
			'hyve_process_post',
			function ( $id ) {
				// Discard the return value: a cron/action callback must not return anything.
				$this->process_post( $id );
			},
			10,
			1
		);
		add_action( 'hyve_delete_posts', [ $this, 'delete_posts' ], 10, 1 );
		add_action( 'hyve_update_posts', [ $this, 'update_posts' ] );

		Scheduler::ensure_recurring( 'hyve_update_posts', HOUR_IN_SECONDS, 'hourly' );

		if ( ! $this->table_exists() || version_compare( $this->version, get_option( $this->table_name . '_db_version' ), '>' ) ) {
			$this->create_table();
		}
	}

	/**
	 * Create the table.
	 * 
	 * @return void
	 *
	 * @since 1.2.0
	 */
	public function create_table() {
		global $wpdb;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$sql = 'CREATE TABLE ' . $this->table_name . ' (
		id bigint(20) NOT NULL AUTO_INCREMENT,
		date datetime NOT NULL,
		modified datetime NOT NULL,
		post_id mediumtext NOT NULL,
		post_title mediumtext NOT NULL,
		post_content longtext NOT NULL,
		embeddings longtext NOT NULL,
		token_count int(11) NOT NULL DEFAULT 0,
		post_status VARCHAR(255) NOT NULL DEFAULT "scheduled",
        storage VARCHAR(255) NOT NULL DEFAULT "WordPress",
		PRIMARY KEY (id)
		) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;';

		dbDelta( $sql );
		update_option( $this->table_name . '_db_version', $this->version );
	}

	/**
	 * Check if the table exists.
	 *
	 * @since 1.2.0
	 *
	 * @return bool
	 */
	public function table_exists() {
		global $wpdb;
		$table = sanitize_text_field( $this->table_name );
		return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
	}

	/**
	 * Get columns and formats.
	 *
	 * @since 1.2.0
	 *
	 * @return array<string, string>
	 */
	public function get_columns() {
		return [
			'date'         => '%s',
			'modified'     => '%s',
			'post_id'      => '%s',
			'post_title'   => '%s',
			'post_content' => '%s',
			'embeddings'   => '%s',
			'token_count'  => '%d',
			'post_status'  => '%s',
			'storage'      => '%s',
		];
	}

	/**
	 * Get default column values.
	 *
	 * @since 1.2.0
	 *
	 * @return array<string, mixed>
	 */
	public function get_column_defaults() {
		return [
			'date'         => gmdate( 'Y-m-d H:i:s' ),
			'modified'     => gmdate( 'Y-m-d H:i:s' ),
			'post_id'      => '',
			'post_title'   => '',
			'post_content' => '',
			'embeddings'   => '',
			'token_count'  => 0,
			'post_status'  => 'scheduled',
			'storage'      => 'WordPress',
		];
	}

	/**
	 * Get a row by ID.
	 * 
	 * @since 1.3.0
	 * 
	 * @param int $id The row ID.
	 * 
	 * @return object{
	 *     id: string,
	 *     date: string,
	 *     modified: string,
	 *     post_id: string,
	 *     post_title: string,
	 *     post_content: string,
	 *     embeddings: string,
	 *     token_count: string,
	 *     post_status: string,
	 *     storage: string
	 * }
	 */
	public function get( $id ) {
		global $wpdb;

		$cache = $this->get_cache( 'entry_' . $id );

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

		$result = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table_name, $id ) );

		$this->set_cache( 'entry_' . $id, $result );

		return $result;
	}

	/**
	 * Insert a new row.
	 *
	 * @since 1.2.0
	 *
	 * @param array<string, mixed> $data The data to insert.
	 *
	 * @return int
	 */
	public function insert( array $data ): int {
		global $wpdb;

		$column_formats  = $this->get_columns();
		$column_defaults = $this->get_column_defaults();

		$data = wp_parse_args( $data, $column_defaults );
		$data = array_intersect_key( $data, $column_formats );

		$wpdb->insert( $this->table_name, $data, $column_formats );

		$this->delete_cache( 'entries' );
		$this->delete_cache( 'entries_count' );
		$this->delete_cache( 'cached_embeddings' );

		return $wpdb->insert_id;
	}

	/**
	 * Update a row.
	 *
	 * @since 1.2.0
	 *
	 * @param int                  $id The row ID.
	 * @param array<string, mixed> $data The data to update.
	 *
	 * @return int
	 */
	public function update( int $id, array $data ): int {
		global $wpdb;

		$column_formats  = $this->get_columns();
		$column_defaults = $this->get_column_defaults();

		$data = array_intersect_key( $data, $column_formats );

		$rows_affected = $wpdb->update( $this->table_name, $data, [ 'id' => $id ], $column_formats, [ '%d' ] );

		$this->delete_cache( 'entry_' . $id );
		$this->delete_cache( 'entries_processed' );
		$this->delete_cache( 'cached_embeddings' );

		return $rows_affected;
	}

	/**
	 * Delete rows by post ID.
	 * 
	 * @since 1.2.0
	 * 
	 * @param int $post_id The post ID.
	 * 
	 * @return int
	 */
	public function delete_by_post_id( $post_id ) {
		global $wpdb;

		$rows_affected = $wpdb->delete( $this->table_name, [ 'post_id' => $post_id ], [ '%d' ] );

		$this->delete_cache( 'entries' );
		$this->delete_cache( 'entries_processed' );
		$this->delete_cache( 'entries_count' );
		$this->delete_cache( 'cached_embeddings' );

		return $rows_affected;
	}

	/**
	 * Get all rows by status.
	 *
	 * @since 1.2.0
	 *
	 * @param string $status The status.
	 * @param int    $limit The limit.
	 *
	 * @return array<object{
	 *     id: string,
	 *     date: string,
	 *     modified: string,
	 *     post_id: string,
	 *     post_title: string,
	 *     post_content: string,
	 *     embeddings: string,
	 *     token_count: string,
	 *     post_status: string,
	 *     storage: string
	 * }>
	 */
	public function get_by_status( string $status, int $limit = 500 ): array {
		global $wpdb;

		$cache = $this->get_cache( 'entries_' . $status );

		if ( is_array( $cache ) ) {
			return $cache;
		}

		$results = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE post_status = %s LIMIT %d', $this->table_name, $status, $limit ) );

		if ( 'scheduled' !== $status ) {
			$this->set_cache( 'entries_' . $status, $results );
		}

		return $results;
	}

	/**
	 * Get all rows by storage.
	 *
	 * @since 1.2.0
	 *
	 * @param string $storage The storage.
	 * @param int    $limit The limit.
	 *
	 * @return array<object{
	 *     id: string,
	 *     date: string,
	 *     modified: string,
	 *     post_id: string,
	 *     post_title: string,
	 *     post_content: string,
	 *     embeddings: string,
	 *     token_count: string,
	 *     post_status: string,
	 *     storage: string
	 * }>
	 */
	public function get_by_storage( string $storage, int $limit = 100 ): array {
		global $wpdb;
		$results = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE storage = %s LIMIT %d', $this->table_name, $storage, $limit ) );
		return $results;
	}

	/**
	 * Get embeddings with pagination.
	 * 
	 * @param int $offset The offset for pagination.
	 * @param int $limit  The limit of results to return.
	 * 
	 * @return array<object{
	 *     id: string,
	 *     embeddings: string,
	 *     token_count: string
	 * }>
	 */
	public function get_embeddings( $offset, $limit = 50 ) {
		$cache_key = 'hyve_embeddings_' . $offset . '_' . $limit;
		$cache     = $this->get_cache( $cache_key );

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

		global $wpdb;
		$posts = $wpdb->get_results( $wpdb->prepare( 'SELECT id, embeddings, token_count FROM %i WHERE post_status = %s LIMIT %d OFFSET %d', $this->table_name, 'processed', $limit, $offset ) );
		
		if ( empty( $posts ) ) {
			return [];
		}

		$cached_embeddings = $this->get_cache( 'cached_embeddings' );
		if ( false === $cached_embeddings ) {
			$cached_embeddings = [];
		}
		$cached_embeddings[] = $cache_key;

		$this->set_cache( $cache_key, $posts );
		$this->set_cache( 'cached_embeddings', $cached_embeddings );

		return $posts;
	}

	/**
	 * Get post data by ID.
	 * 
	 * @param int $id The row ID.
	 * 
	 * @return array{post_title: string, post_content: string}|null
	 */
	public function get_post_data( $id ) {
		$cache_key = 'hyve_post_data_' . $id;
		$cache     = $this->get_cache( $cache_key );

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

		global $wpdb;
		$post = $wpdb->get_row( $wpdb->prepare( 'SELECT post_title, post_content FROM %i WHERE id = %d', $this->table_name, $id ), ARRAY_A );

		if ( empty( $post ) ) {
			return null;
		}

		$this->set_cache( $cache_key, $post );

		return $post;
	}

	/**
	 * Get the source post ID for a chunk row.
	 *
	 * @since 1.4.2
	 *
	 * @param int $id Row ID in the table.
	 *
	 * @return string|null Source post ID, or null if the row does not exist.
	 */
	public function get_post_id( $id ) {
		global $wpdb;

		return $wpdb->get_var( $wpdb->prepare( 'SELECT post_id FROM %i WHERE id = %d', $this->table_name, $id ) );
	}

	/**
	 * Update storage of all rows.
	 * 
	 * @since 1.3.0
	 * 
	 * @param string $to   The storage.
	 * @param string $from The storage.
	 * 
	 * @return int
	 */
	public function update_storage( $to, $from ) {
		global $wpdb;
		$wpdb->update( $this->table_name, [ 'storage' => $to ], [ 'storage' => $from ], [ '%s' ], [ '%s' ] );
		$this->delete_cache( 'entries' );
		$this->delete_cache( 'entries_processed' );
		$this->delete_cache( 'cached_embeddings' );
		return $wpdb->rows_affected;
	}

	/**
	 * Get Posts over limit.
	 * 
	 * @since 1.3.0
	 * 
	 * @return array<integer>
	 */
	public function get_posts_over_limit() {
		$limit = apply_filters( 'hyve_chunks_limit', 500 );

		global $wpdb;
		$posts = $wpdb->get_results( $wpdb->prepare( 'SELECT post_id FROM %i ORDER BY id DESC LIMIT %d, %d', $this->table_name, $limit, $this->get_count() ) );

		if ( ! $posts ) {
			return [];
		}

		$posts = wp_list_pluck( $posts, 'post_id' );
		$posts = array_unique( $posts );

		return $posts;
	}

	/**
	 * Add Post to queue.
	 * 
	 * @since 1.3.1
	 * 
	 * @param int    $post_id The post ID.
	 * @param string $action The action.
	 * 
	 * @return true|\WP_Error
	 * @throws \Exception If Qdrant API fails.
	 */
	public function add_post( $post_id, $action = 'add' ) {
		update_post_meta( $post_id, '_hyve_post_processing', 1 );

		$result = $this->ingest_document(
			[
				'title'   => get_the_title( $post_id ),
				'content' => apply_filters( 'the_content', get_post_field( 'post_content', $post_id ) ),
			],
			[
				'action'             => 'update' === $action ? 'update' : 'add',
				'override'           => 'override' === $action,
				'post_id'            => $post_id,
				'create'             => false,
				'persist_moderation' => true,
				'retry_async'        => 'update' === $action,
			]
		);

		delete_post_meta( $post_id, '_hyve_post_processing' );

		return $result;
	}

	/**
	 * Ingest a document into the knowledge base.
	 *
	 * Shared pipeline for every data source (posts, custom data, links and
	 * sitemap entries): tokenize -> moderate -> (replace old chunks on update)
	 * -> insert chunk rows -> embed. The only things that vary between sources
	 * are how the owning post is resolved and what extra meta it carries, both
	 * expressed through `$args`.
	 *
	 * @since 1.4.0
	 *
	 * @param array<string, mixed> $doc  The document, with `title` and `content` keys.
	 * @param array<string, mixed> $args {
	 *     Optional. Ingestion options.
	 *
	 *     @type string               $action             'add' or 'update'. Default 'add'.
	 *     @type bool                 $override           Skip the moderation gate. Default false.
	 *     @type int|null             $post_id            Existing post to attach chunks to. Required
	 *                                                    for the posts source and for any update.
	 *     @type bool                 $create             Create (add) or update a managed `hyve_docs`
	 *                                                    post. False for the posts source, where the
	 *                                                    WP post already exists and is left untouched.
	 *                                                    Default false.
	 *     @type string               $post_type          Post type to create when `create` is true.
	 *                                                    Default 'hyve_docs'.
	 *     @type array<string, mixed> $meta               Extra post meta to set on success.
	 *     @type bool                 $persist_moderation Store/clear `_hyve_moderation_*` meta on
	 *                                                    `post_id`. Default false.
	 *     @type bool                 $retry_async        Retry a failed embedding in the background
	 *                                                    via cron. When false, failures are terminal
	 *                                                    and recorded for immediate, synchronous
	 *                                                    surfacing to the caller. Default true.
	 * }
	 *
	 * @return true|\WP_Error
	 * @throws \Exception If Qdrant API fails.
	 */
	public function ingest_document( $doc, $args = [] ) {
		$action             = $args['action'] ?? 'add';
		$override           = ! empty( $args['override'] );
		$create             = ! empty( $args['create'] );
		$post_id            = $args['post_id'] ?? null;
		$post_type          = $args['post_type'] ?? 'hyve_docs';
		$extra_meta         = $args['meta'] ?? [];
		$persist_moderation = ! empty( $args['persist_moderation'] );
		$allow_retry        = $args['retry_async'] ?? true;

		$data   = Tokenizer::tokenize(
			[
				'ID'      => $post_id,
				'title'   => $doc['title'],
				'content' => $doc['content'],
			]
		);
		$chunks = array_column( $data, 'post_content' );
		// Only reuse the per-post moderation cache for an existing WP post
		// (the posts source). Freshly submitted content is always re-moderated.
		$moderation = OpenAI::instance()->moderate_chunks( $chunks, $persist_moderation ? $post_id : null );

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

		if ( true !== $moderation && ! $override ) {
			if ( $persist_moderation && $post_id ) {
				update_post_meta( $post_id, '_hyve_moderation_failed', 1 );
				update_post_meta( $post_id, '_hyve_moderation_review', $moderation );
			}

			return new \WP_Error(
				'content_failed_moderation',
				__( 'The content failed moderation policies.', 'hyve-lite' ),
				[ 'review' => $moderation ]
			);
		}

		// Resolve the owning post. Moderation has passed, so creating/updating a
		// managed post here cannot leave an orphan behind on rejection.
		if ( 'update' === $action ) {
			if ( Qdrant_API::is_active() ) {
				try {
					$delete_result = Qdrant_API::instance()->delete_point( $post_id );

					if ( is_wp_error( $delete_result ) || ! $delete_result ) {
						throw new \Exception( is_wp_error( $delete_result ) ? $delete_result->get_error_message() : __( 'Failed to delete point in Qdrant.', 'hyve-lite' ) );
					}
				} catch ( \Exception $e ) {
					return new \WP_Error( 'qdrant_error', $e->getMessage() );
				}
			}

			$this->delete_by_post_id( $post_id );

			if ( $create ) {
				$updated = wp_update_post(
					[
						'ID'           => $post_id,
						'post_title'   => $doc['title'],
						'post_content' => $doc['content'],
					]
				);

				if ( ! $updated ) {
					return new \WP_Error( 'failed_update_post', __( 'Failed to update post.', 'hyve-lite' ) );
				}
			}
		} elseif ( $create ) {
			$post_id = wp_insert_post(
				[
					'post_title'   => $doc['title'],
					'post_content' => $doc['content'],
					'post_status'  => 'publish',
					'post_type'    => $post_type,
				]
			);

			if ( ! $post_id ) {
				return new \WP_Error( 'failed_insert_post', __( 'Failed to insert post.', 'hyve-lite' ) );
			}
		}

		$processing_error = null;

		foreach ( $data as $datum ) {
			$datum['post_id'] = $post_id;

			$id     = $this->insert( $datum );
			$result = $this->process_post( $id, $allow_retry );

			if ( is_wp_error( $result ) && null === $processing_error ) {
				$processing_error = $result;
			}
		}

		update_post_meta( $post_id, '_hyve_added', 1 );

		foreach ( $extra_meta as $meta_key => $meta_value ) {
			update_post_meta( $post_id, $meta_key, $meta_value );
		}

		// When retries are disabled a chunk failure is terminal, so record the
		// reason against the post. This also restores the error if a later
		// chunk's success cleared it mid-loop, keeping the surfaced reason honest.
		if ( ! $allow_retry && null !== $processing_error ) {
			$this->record_processing_error( $post_id, $processing_error, false );
		}

		if ( $persist_moderation ) {
			delete_post_meta( $post_id, '_hyve_moderation_failed' );
			delete_post_meta( $post_id, '_hyve_moderation_review' );
			delete_post_meta( $post_id, '_hyve_needs_update' );
			$this->delete_cache( 'cached_embeddings' );
		}

		return true;
	}

	/**
	 * Process posts.
	 * 
	 * @since 1.2.0
	 * 
	 * @param int  $id          The post ID.
	 * @param bool $allow_retry Whether a transient failure may be retried
	 *                          asynchronously via the hyve_process_post cron.
	 *                          When false, a failure is terminal and returned to
	 *                          the caller for immediate, synchronous handling.
	 *                          Default true.
	 *
	 * @return true|\WP_Error True on success, or the error encountered.
	 */
	public function process_post( $id, $allow_retry = true ) {
		$post     = $this->get( $id );
		$content  = $post->post_content;
		$openai   = OpenAI::instance();
		$stripped = wp_strip_all_tags( $content );

		// Prepend the title to the text sent for embedding so it contributes to the vector.
		if ( ! empty( $post->post_title ) ) {
			$stripped = $post->post_title . ' ' . $stripped;
		}
		$embeddings = $openai->create_embeddings( $stripped );

		if ( is_wp_error( $embeddings ) || ! $embeddings ) {
			$error = is_wp_error( $embeddings )
				? $embeddings
				: new \WP_Error( 'unknown_error', __( 'An unexpected error occurred while indexing this content.', 'hyve-lite' ) );

			$this->handle_processing_failure( $id, (int) $post->post_id, $error, $allow_retry );
			return $error;
		}

		$embeddings = reset( $embeddings );
		$embeddings = $embeddings->embedding;
		$storage    = 'WordPress';

		if ( Qdrant_API::is_active() ) {
			try {
				$success = Qdrant_API::instance()->add_point(
					$embeddings,
					[
						'post_id'      => $post->post_id,
						'post_title'   => $post->post_title,
						'post_content' => $post->post_content,
						'token_count'  => $post->token_count,
						'website_url'  => get_site_url(),
					]
				);

				$storage = 'Qdrant';
			} catch ( \Exception $e ) {
				$success = new \WP_Error( 'qdrant_error', $e->getMessage() );
			}

			if ( is_wp_error( $success ) ) {
				$this->handle_processing_failure( $id, (int) $post->post_id, $success, $allow_retry );
				return $success;
			}
		}

		$embeddings = wp_json_encode( $embeddings );

		$this->update(
			$id,
			[
				'embeddings'  => $embeddings,
				'post_status' => 'processed',
				'storage'     => $storage,
			]
		);

		// Processing succeeded; clear any error and retry counter from a previous attempt.
		delete_post_meta( (int) $post->post_id, '_hyve_processing_error' );
		delete_transient( self::CACHE_PREFIX . 'process_attempts_' . $id );

		return true;
	}

	/**
	 * Handle a failed processing attempt.
	 *
	 * Fatal errors (bad key, no credits, billing) will not resolve by retrying,
	 * so they stop immediately and the entry is marked failed. Transient errors
	 * (rate limits, network blips) are retried with a linear backoff up to
	 * MAX_PROCESS_ATTEMPTS, then also marked failed. Either way the reason is
	 * recorded for the Knowledge Base UI. See Codeinwp/hyve#199.
	 *
	 * @since 1.4.0
	 *
	 * @param int       $id          The chunk row ID.
	 * @param int       $post_id     The source post ID.
	 * @param \WP_Error $error       The error encountered while processing.
	 * @param bool      $allow_retry Whether an async retry may be scheduled. When
	 *                               false the failure is terminal. Default true.
	 *
	 * @return void
	 */
	private function handle_processing_failure( $id, $post_id, $error, $allow_retry = true ) {
		$transient  = self::CACHE_PREFIX . 'process_attempts_' . $id;
		$attempts   = (int) get_transient( $transient ) + 1;
		$is_fatal   = OpenAI::is_fatal_error_code( $error->get_error_code() );
		$will_retry = $allow_retry && ! $is_fatal && $attempts < self::MAX_PROCESS_ATTEMPTS;

		$this->record_processing_error( $post_id, $error, $will_retry );

		if ( $will_retry ) {
			set_transient( $transient, $attempts, DAY_IN_SECONDS );
			Scheduler::schedule_single( time() + ( MINUTE_IN_SECONDS * $attempts ), 'hyve_process_post', [ $id ] );
			return;
		}

		// Terminal: stop retrying and mark the chunk failed.
		delete_transient( $transient );
		$this->update( $id, [ 'post_status' => 'failed' ] );
	}

	/**
	 * Record a processing error against the source post so it can be surfaced
	 * in the Knowledge Base UI instead of failing silently. The stored message
	 * includes whether the entry will be retried or needs the admin to act.
	 * See Codeinwp/hyve#199.
	 *
	 * @since 1.4.0
	 *
	 * @param int       $post_id    The source post ID.
	 * @param \WP_Error $error      The error encountered while processing.
	 * @param bool      $will_retry Whether another attempt is scheduled.
	 *
	 * @return void
	 */
	private function record_processing_error( $post_id, $error, $will_retry ) {
		if ( empty( $post_id ) ) {
			return;
		}

		$message = OpenAI::get_error_message_for_code( $error->get_error_code() );

		if ( null === $message ) {
			$message = $error->get_error_message();
		}

		$suffix = $will_retry
			? __( 'It will be retried automatically.', 'hyve-lite' )
			: __( 'Please fix the problem and re-add this content.', 'hyve-lite' );

		update_post_meta( $post_id, '_hyve_processing_error', $message . ' ' . $suffix );
	}

	/**
	 * Update posts.
	 * 
	 * @since 1.3.1
	 * 
	 * @return void
	 */
	public function update_posts() {
		$args = [
			'post_type'      => 'any',
			'post_status'    => [ 'publish', 'private' ],
			'posts_per_page' => 5,
			'fields'         => 'ids',
			'meta_query'     => [
				'relation' => 'AND',
				[
					'key'     => '_hyve_needs_update',
					'value'   => '1',
					'compare' => '=',
				],
				[
					'key'     => '_hyve_moderation_failed',
					'compare' => 'NOT EXISTS',
				],
			],
		];

		$query = new \WP_Query( $args );

		if ( ! $query->have_posts() ) {
			return;
		}

		$posts = $query->posts;

		foreach ( $posts as $post_id ) {
			/**
			 * The post id.
			 * 
			 * @var int $post_id
			 */
			$this->add_post( $post_id, 'update' );
		}

		Scheduler::schedule_single( time() + 60, 'hyve_update_posts' );
	}

	/**
	 * Delete posts.
	 * 
	 * @since 1.3.0
	 * 
	 * @param array<int> $posts The posts.
	 * 
	 * @return void
	 */
	public function delete_posts( array $posts ): void {
		$twenty = array_slice( $posts, 0, 20 );

		foreach ( $twenty as $id ) {
			$this->delete_by_post_id( $id );
	
			delete_post_meta( $id, '_hyve_added' );
			delete_post_meta( $id, '_hyve_needs_update' );
			delete_post_meta( $id, '_hyve_moderation_failed' );
			delete_post_meta( $id, '_hyve_moderation_review' );
		}

		if ( ! empty( $twenty ) ) {
			$this->delete_cache( 'cached_embeddings' );
		}

		$has_more = count( $posts ) > 20;

		if ( $has_more ) {
			Scheduler::schedule_single( time() + 10, 'hyve_delete_posts', [ array_slice( $posts, 20 ) ] );
		}
	}

	/**
	 * Get Total Rows Count.
	 * 
	 * @since 1.2.0
	 * 
	 * @return int
	 */
	public function get_count() {
		$cache = $this->get_cache( 'entries_count' );

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

		global $wpdb;

		$count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $this->table_name ) );

		$this->set_cache( 'entries_count', $count );

		return $count;
	}

	/**
	 * Return cache.
	 * 
	 * @since 1.2.0
	 * 
	 * @param string $key The cache key.
	 * 
	 * @return mixed
	 */
	private function get_cache( $key ) {
		$key = $this->get_cache_key( $key );

		if ( $this->get_cache_key( 'entries_processed' ) === $key ) {
			$total = get_transient( $key . '_total' );

			if ( false === $total ) {
				return false;
			}

			$entries = [];

			for ( $i = 0; $i < $total; $i++ ) {
				$chunk_key = $key . '_' . $i;
				$chunk     = get_transient( $chunk_key );

				if ( false === $chunk ) {
					return false;
				}

				$entries = array_merge( $entries, $chunk );
			}

			return $entries;
		}

		return get_transient( $key );
	}

	/**
	 * Set cache.
	 * 
	 * @since 1.2.0
	 * 
	 * @param string $key The cache key.
	 * @param mixed  $value The cache value.
	 * @param int    $expiration The expiration time.
	 * 
	 * @return bool
	 */
	private function set_cache( $key, $value, $expiration = DAY_IN_SECONDS ) {
		$key = $this->get_cache_key( $key );

		if ( $this->get_cache_key( 'entries_processed' ) === $key ) {
			$chunks = array_chunk( $value, 50 );
			$total  = count( $chunks );

			foreach ( $chunks as $index => $chunk ) {
				$chunk_key = $key . '_' . $index;
				set_transient( $chunk_key, $chunk, $expiration );
			}

			set_transient( $key . '_total', $total, $expiration );
			return true;
		}
		return set_transient( $key, $value, $expiration );
	}

	/**
	 * Delete cache.
	 * 
	 * @since 1.2.0
	 * 
	 * @param string $key The cache key.
	 * 
	 * @return bool
	 */
	private function delete_cache( $key ) {
		if ( 'cached_embeddings' === $key ) {
			$cached_embeddings = $this->get_cache( $key );
			if ( is_array( $cached_embeddings ) ) {
				foreach ( $cached_embeddings as $cached_embedding_key ) {
					$this->delete_cache( $cached_embedding_key );
				}
			}
		}

		$key = $this->get_cache_key( $key );

		if ( $this->get_cache_key( 'entries_processed' ) === $key ) {
			$total = get_transient( $key . '_total' );

			if ( false === $total ) {
				return true;
			}

			for ( $i = 0; $i < $total; $i++ ) {
				$chunk_key = $key . '_' . $i;
				delete_transient( $chunk_key );
			}

			delete_transient( $key . '_total' );
			return true;
		}

		return delete_transient( $key );
	}

	/**
	 * Return cache key.
	 * 
	 * @since 1.2.0
	 * 
	 * @param string $key The cache key.
	 * 
	 * @return string
	 */
	private function get_cache_key( $key ) {
		return self::CACHE_PREFIX . $key;
	}
}
