<?php
declare( strict_types=1 );

namespace FiboFilters\Indexer;

use FiboFilters\Util\Cache;
use FiboFilters\WP\WPDBSecond;
use FiboFilters\WPDBException;

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

/**
 * Class AbstractIndexer
 */
abstract class AbstractIndexer {
	/**
	 * @var string Indexer type
	 */
	protected $type = '';

	/** @var Cache */
	protected $cache;

	/** @var BuildInfo */
	protected $build_info;

	/** @var Logger */
	protected $logger;

	/** @var WPDBSecond */
	protected $wpdb_second;

	/** @var string */
	protected $index_role;

	/** @var string */
	protected $call_source = '';

	public function __construct( BuildInfo $build_info, Logger $logger, WPDBSecond $wpdb_second, Cache $cache ) {
		$this->build_info  = $build_info;
		$this->logger      = $logger;
		$this->wpdb_second = $wpdb_second;
		$this->cache       = $cache;
	}

	/**
	 * Start DB transaction
	 */
	public function start_transaction(): void {
		global $wpdb;

		$wpdb->query( 'START TRANSACTION' );
	}

	/**
	 * Commit DB transaction
	 */
	public function commit_transaction(): void {
		global $wpdb;

		$wpdb->query( 'COMMIT' );
	}

	/**
	 * Rollback DB transaction
	 */
	public function rollback_transaction(): void {
		global $wpdb;

		$wpdb->query( 'ROLLBACK' );
	}

	/**
	 * Get indexer type
	 */
	public function get_type(): string {
		return $this->type;
	}

	/**
	 * Get indexer log prefix
	 */
	public function get_log_prefix(): string {
		return sprintf( '[%s index] ', ucfirst( $this->get_type() ) );
	}

	/**
	 * Run indexing for entire set
	 *
	 * @param array    $items_set Set of items to index.
	 * @param callable $update_lock Update lock function. Use "function () {}" to do nothing.
	 */
	public function index_set( array $items_set, callable $update_lock ): bool {
		register_shutdown_function(
			function () {
				$this->logger->maybe_log_error( $this->get_log_prefix() );
			}
		);

		$result = true;

		try {
			$this->index_set_process( $items_set, $update_lock );
		} catch ( \Throwable $e ) {
			$this->wpdb_second->rollback();
			$result = $this->logger->handle_throwable_error( $e, $this->get_log_prefix() );
		}

		return $result;
	}

	/**
	 * Index the entire set in one go
	 *
	 * @param array    $items_set Set of items to index.
	 * @param callable $update_lock Update lock function.
	 *
	 * @throws WPDBException DB exception.
	 */
	public function index_set_process( array $items_set, callable $update_lock ): void {
		$products_processed = $this->build_info->get_info( $this->get_type() . '_processed', $this->get_index_role() );
		$build_id           = $this->build_info->get_info( 'build_id', $this->get_index_role() );

		$time = microtime( true );

		$counter       = ! empty( $products_processed ) && is_numeric( $products_processed ) ? intval( $products_processed ) : 0;
		$counter_start = $counter;

		$default_items_count = constant( '\FiboFilters\Indexer\Builder::' . strtoupper( $this->get_type() ) . '_SET_ITEMS_COUNT' );
		$type                = $this->get_type();
		$products_set_count  = apply_filters( "fibofilters/indexer/{$type}_set_items_count", $default_items_count );
		$long_time_counter   = 0;
		$long_time_limit     = 20;
		$long_time_warning   = false;
		$update_lock_timer   = microtime( true );

		$this->wpdb_second->start_transaction();

		if ( $this->get_call_source() === 'cron' ) {
			$this->cache->flush();
		}

		foreach ( $this->fetch_products_set_data( $items_set, 'indexer' ) as $row ) {
			$counter++;
			$long_time_counter++;
			/**
			 * If the process was started by cron, we first need to delete the document,
			 * because it could have been created by a previous process that did not finish
			 * (cron starts when the index build gets stuck)
			 */
			if ( $this->get_call_source() === 'cron' ) {
				$this->process_item_row( $row, 'delete' );
			}
			$this->process_item_row( $row );

			if ( $long_time_warning === false && microtime( true ) - $time > $long_time_limit ) {
				$long_time_warning = true;
				$this->logger->log(
					"{$this->get_log_prefix()}Timeout warning: " . round( ( $long_time_counter / $products_set_count ) * 100 ) . '% of the objects in the set were indexed in ' . number_format( microtime( true ) - $time, 4, '.', '' ) . ' seconds',
					'debug',
					'file',
					$this->get_type()
				);
			}

			if ( microtime( true ) - $update_lock_timer > 5 && is_callable( $update_lock ) ) {
				$update_lock_timer = microtime( true );
				call_user_func( $update_lock );
			}
		}

		// Has another building process started in the meantime? Kill the process.
		if ( $this->build_info->get_info( 'build_id', $this->get_index_role() ) !== $build_id ) {
			$this->wpdb_second->rollback();
			$this->logger->log( "{$this->get_log_prefix()}Process killed because another one has started" );
			exit();
		}

		// Any errors? Kill the process.
		if ( $this->build_info->get_info( 'status', $this->get_index_role() ) !== 'building' ) {
			$this->wpdb_second->rollback();
			// Error occurred, don't save any changes.
			$this->logger->log( "{$this->get_log_prefix()}Process killed" );
			exit();
		}

		$this->wpdb_second->commit();

		$ntime = number_format( microtime( true ) - $time, 4, '.', '' ) . ' s';
		$this->logger->log( "{$this->get_log_prefix()}Processed " . ( $counter - $counter_start ) . " (in total: $counter) objects in $ntime" );

		$this->build_info->add_info( "{$this->get_type()}_processed", $counter );
	}

	/**
	 * Process single row from set of items to index
	 *
	 * @param array  $row Raw representation of document.
	 * @param string $action Action to process: 'save'|'delete'.
	 * @param string $updated_date Update/delete date.
	 *
	 * @throws WPDBException DB exception.
	 */
	protected function process_item_row( array $row, string $action = 'save', string $updated_date = '' ): void {
		$document = $this->get_document( $row );
		if ( $action === 'save' ) {
			$document->save( $updated_date );
		} elseif ( $action === 'delete' ) {
			$document->delete( $updated_date );
		}
	}

	/**
	 * Fetch data of products that should be indexed
	 *
	 * @param array  $ids Product IDs.
	 * @param string $context From where the update is called.
	 */
	abstract protected function fetch_products_set_data( array $ids, string $context ): array;

	/**
	 * Get single document
	 *
	 * @param array $row Raw representation of document.
	 */
	abstract protected function get_document( array $row ): AbstractDocument;

	abstract public function complete(): void;

	/**
	 * @param string $index_role Index role.
	 */
	public function set_index_role( string $index_role ): void {
		$this->index_role = $index_role;
	}

	/**
	 * @return string
	 */
	public function get_index_role(): string {
		return $this->index_role;
	}

	/**
	 * @param string $call_source Call source.
	 */
	public function set_call_source( string $call_source ): void {
		$this->call_source = $call_source;
	}

	/**
	 * @return string
	 */
	public function get_call_source(): string {
		return $this->call_source;
	}
}
