<?php
/**
 * Reacts on SQL changes.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Tracking;

use Dakwamine\Component\ComponentBasedObject;
use Dakwamine\Component\RootDependencyDefinition;
use Ealab\Deployer\Exception\WpDeployException;
use Ealab\Deployer\Tracking\TrackerConfig\TrackerConfigProviderManager;
use Ealab\Deployer\Utility\Database;
use Ealab\Deployer\Utility\Date;
use Exception;
use PhpMyAdmin\SqlParser\Components\Expression;
use PhpMyAdmin\SqlParser\Parser;
use PhpMyAdmin\SqlParser\Statements\AlterStatement;
use PhpMyAdmin\SqlParser\Statements\CreateStatement;
use PhpMyAdmin\SqlParser\Statements\DeleteStatement;
use PhpMyAdmin\SqlParser\Statements\DropStatement;
use PhpMyAdmin\SqlParser\Statements\InsertStatement;
use PhpMyAdmin\SqlParser\Statements\UpdateStatement;
use PhpMyAdmin\SqlParser\Token;
use wpdb;

/**
 * Reacts on SQL changes.
 */
class SqlChangesTracker extends ComponentBasedObject {

	/**
	 * Use this to ignore tracking on a query.
	 */
	public const IGNORE_TRACKING = '/*dsc:ignore_tracking*/';

	/**
	 * Version of the trigger system.
	 */
	public const TRIGGER_SYSTEM_VERSION = '1.0.0-beta9';

	/**
	 * The tracker config provider manager.
	 *
	 * @var TrackerConfigProviderManager|null
	 */
	private ?TrackerConfigProviderManager $tracker_config_provider_manager = null;

	/**
	 * Tracking manager.
	 *
	 * @var TrackingManager|null
	 */
	private ?TrackingManager $tracking_manager = null;

	/**
	 * Disables the tracking triggers for the current SQL session.
	 *
	 * Calling this method will not impact other concurrent SQL sessions.
	 */
	public static function disableCurrentSessionTracking(): void {
		$wpdb = Database::getTrackingDatabaseHandle();

		$wpdb->query( 'SET @no_tracking = IF(@no_tracking IS NULL , 1, @no_tracking + 1);' . static::IGNORE_TRACKING );
	}

	/**
	 * Re-enables the tracking triggers for the current SQL session.
	 *
	 * When multiple calls to disable the tracking occurred, this method must be called
	 * as many times to effectively enable the tracking, unless the $force_enable arg is "true".
	 *
	 * @param bool $force_enable Set to "true" to force enable the tracking. Defaults to "false".
	 */
	public static function enableCurrentSessionTracking( bool $force_enable = false ): void {
		$wpdb = Database::getTrackingDatabaseHandle();

		if ( $force_enable ) {
			$wpdb->query( 'SET @no_tracking = NULL;' . static::IGNORE_TRACKING );
		} else {
			$wpdb->query( 'SET @no_tracking = IF(@no_tracking IS NULL, NULL, IF(@no_tracking <= 1, NULL, @no_tracking - 1));' . static::IGNORE_TRACKING );
		}
	}

	/**
	 * {@inheritDoc}
	 */
	public function getDependencyDefinitions(): array {
		$definitions   = parent::getDependencyDefinitions();
		$definitions[] = new RootDependencyDefinition( TrackingManager::class, $this->tracking_manager );
		$definitions[] = new RootDependencyDefinition( TrackerConfigProviderManager::class, $this->tracker_config_provider_manager );

		return $definitions;
	}

	/**
	 * Tells if the query must be ignored.
	 *
	 * @param Parser $parsed The query to analyze.
	 *
	 * @return bool True if the query must be ignored, false otherwise.
	 */
	public function isTrackingQueryToIgnore( Parser $parsed ): bool {
		$comments = array_filter(
			$parsed->list->tokens,
			function ( $token ) {
				return Token::TYPE_COMMENT === $token->type;
			}
		);

		foreach ( $comments as $comment ) {
			if ( static::IGNORE_TRACKING === $comment->value ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Tells if the query must be analyzed for item changes.
	 *
	 * @param Parser $parsed The query to analyze.
	 *
	 * @return bool True if the query must be analyzed, false otherwise.
	 */
	private function mustAnalyzeQueryForItemChanges( Parser $parsed ): bool {
		$allowed_statements = array(
			'INSERT' => array(
				'class'          => InsertStatement::class,
				'table_property' => 'into',
			),
			'UPDATE' => array(
				'class'          => UpdateStatement::class,
				'table_property' => 'tables',
			),
			'DELETE' => array(
				'class'          => DeleteStatement::class,
				'table_property' => 'from',
			),
		);

		foreach ( $parsed->statements as $statement ) {
			/** @var InsertStatement | UpdateStatement | DeleteStatement $statement */
			$selected_statement = array_filter( $allowed_statements, function ( $allowed_statement ) use ( $statement ) {
				return $statement instanceof $allowed_statement['class'];
			} );

			if ( empty ( $selected_statement ) ) {
				continue;
			}

			$selected_statement = reset( $selected_statement );

			// Extract the table name.
			/** @var Expression[] $expressions */
			$expressions = $statement->{$selected_statement['table_property']} ?? null;

			if ( empty( $expressions ) ) {
				continue;
			}

			foreach ( $expressions as $expression ) {
				$table_name = $expression->table ?? null;

				if ( $table_name ) {
					break;
				}
			}

			if ( empty( $table_name ) ) {
				continue;
			}

			// Remove the database prefix.
			$table_name_unprefixed = Database::removeDatabasePrefix( $table_name );

			if ( strpos( $table_name_unprefixed, Database::PLUGIN_TABLE_NAME_PREFIX ) === 0 ) {
				return false;
			}

			return true;
		}

		// No statement.
		return false;
	}

	/**
	 * Track on deployment item changes.
	 *
	 * @param Parser $parser The parser instance to use.
	 *
	 * @throws Exception If an unexpected error occurs.
	 */
	private function trackOnItemChanges( Parser $parser ): void {
		// Prevent infinite loops by checking if the current query is about tracking.
		if ( ! $this->mustAnalyzeQueryForItemChanges( $parser ) ) {
			return;
		}

		$wpdb = Database::getTrackingDatabaseHandle();

		$changes = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i;', $wpdb->prefix . Database::TRACK_QUEUE_TABLE_NAME ), ARRAY_A );

		if ( empty( $changes ) ) {
			return;
		}

		$current_date = Date::getCurrentDateTime();

		foreach ( $changes as $change ) {
			$tracker_id = $change['tracker_id'] ?? null;

			if ( empty( $tracker_id ) ) {
				continue;
			}

			$local_id = $change['local_id'] ?? null;

			if ( empty( $local_id ) ) {
				continue;
			}

			// Retrieve the item tracker.
			$item_tracker = $this->tracking_manager->getItemTrackerByTrackerId( $tracker_id );

			if ( empty( $item_tracker ) ) {
				// The item tracker has been disabled since the change was tracked.
				// This should not happen a lot, but it can happen.
				continue;
			}

			try {
				switch ( $change['operation'] ?? null ) {
					case SqlOperation::INSERT:
					case SqlOperation::UPDATE:
						$item_tracker->trackItemByLocalId( array( $local_id ), $current_date );
						break;

					case SqlOperation::DELETE:
						$item_tracker->untrackItemByLocalId( $local_id );
						break;
				}
			} catch ( WpDeployException $e ) {
				// We can at least try to track the other changes.
				continue;
			}
		}

		// Empty the queue.
		$wpdb->query( $wpdb->prepare( 'TRUNCATE %i;', $wpdb->prefix . Database::TRACK_QUEUE_TABLE_NAME ) );
	}

	/**
	 * Tracks the changes over the schema.
	 *
	 * @param Parser $parsed The query that has been executed.
	 *
	 * @throws Exception If an unexpected error occurs.
	 */
	public function trackSchemaChanges( Parser $parsed ): void {
		$wpdb                 = Database::getTrackingDatabaseHandle();
		$should_update_config = false;

		foreach ( $parsed->statements as $statement ) {
			$statement_class = get_class( $statement );

			switch ( $statement_class ) {
				// Alter statements must be tracked because they can change the schema (column names for instance).
				case AlterStatement::class:
				case CreateStatement::class:
				case DropStatement::class:
					// Make sure it's for a table.
					if ( ! ( $statement->options->has( 'TABLE' ) ?? false ) ) {
						continue 2;
					}

					// Make sure it's not a temporary table.
					if ( ( $statement->options->has( 'TEMPORARY' ) ?? false ) ) {
						continue 2;
					}

					switch ( $statement_class ) {
						case AlterStatement::class:
						case DropStatement::class:
							$table_name = $statement->table->table ?? null;
							break;

						default:
							$table_name = $statement->name->table ?? null;
							break;
					}

					// Make sure the table name is prefixed with the database prefix.
					if ( 0 !== strpos( $table_name, $wpdb->prefix ) ) {
						continue 2;
					}

					// Remove the database prefix.
					$table_name_unprefixed = Database::removeDatabasePrefix( $table_name );

					// Make sure the table is not a tracking table.
					if ( 0 === strpos( $table_name_unprefixed, Database::PLUGIN_TABLE_NAME_PREFIX ) ) {
						continue 2;
					}

					break;

				default:
					continue 2;
			}

			$should_update_config = true;
		}

		if ( $should_update_config ) {
			// Call the config schema updater.
			$this->tracker_config_provider_manager->updateCurrentConfig();
		}
	}

	/**
	 * Tracks the items that have been queued for tracking by the last queries.
	 *
	 * @param Parser $parsed The query that has been executed.
	 *
	 * @throws Exception If an unexpected error occurs.
	 */
	public function trackOnSqlChanges( Parser $parsed ): void {
		if ( $this->isTrackingQueryToIgnore( $parsed ) ) {
			return;
		}

		$this->trackOnItemChanges( $parsed );
		$this->trackSchemaChanges( $parsed );
	}
}
