<?php
/**
 * Handles the removal of auto-generated Series upon Event trashing or deletion.
 *
 * @since   6.0.0
 *
 * @package TEC\Events_Pro\Custom_Tables\V1\Models
 */

namespace TEC\Events_Pro\Custom_Tables\V1\Series;

use Generator;
use TEC\Events\Custom_Tables\V1\Models\Event;
use TEC\Events\Custom_Tables\V1\Models\Occurrence;
use TEC\Events_Pro\Custom_Tables\V1\Models\Provisional_Post;
use TEC\Events_Pro\Custom_Tables\V1\Models\Series_Relationship as Relationship;
use TEC\Events_Pro\Custom_Tables\V1\Series\Post_Type as Series;
use TEC\Events\Custom_Tables\V1\Tables\Events;
use TEC\Events_Pro\Custom_Tables\V1\Tables\Series_Relationships;
use Tribe__Events__Main as TEC;
use WP_Error;
use WP_Post;

/**
 * Class Autogenerated_Series
 *
 * @since   6.0.0
 *
 * @package TEC\Events_Pro\Custom_Tables\V1\Models
 */
class Autogenerated_Series {
	/**
	 * The name of the meta key that will be used to flag a Series as auto-generated
	 * following the creation of a Recurring Event not assigned a pre-existing Series.
	 *
	 * @since 6.0.0
	 */
	const FLAG_META_KEY = '_tec_autogenerated';

	/**
	 * The nane of the meta key that will be used to store the Series post checksum.
	 *
	 * @since 6.0.0
	 */
	const CHECKSUM_META_KEY = '_tec_autogenerated_checksum';

	/**
	 * A reference to the current provisional post ID handler.
	 *
	 * @since 6.0.0
	 *
	 * @var Provisional_Post
	 */
	protected $provisional_post;

	/**
	 * A map from post IDs to the last checksums collected.
	 *
	 * @since 6.0.0
	 *
	 * @var array<int,string>
	 */
	protected $checksums = [];

	/**
	 * Autogenerated_Series constructor.
	 *
	 * @since 6.0.0
	 *
	 * @param \TEC\Events_Pro\Custom_Tables\V1\Models\Provisional_Post $provisional_post A reference to the current provisional post ID handler.
	 */
	public function __construct( Provisional_Post $provisional_post ) {
		$this->provisional_post = $provisional_post;
	}

	/**
	 * Handles the trashing of any auto-generated Series related to an Event
	 *
	 * @since 6.0.0
	 *
	 * @param int|WP_Post $post_id The ID of the post being trashed or a reference to
	 *                             a post object.
	 *
	 * @return int The number of trashed Series.
	 */
	public function trash_following( $post_id ) {
		/**
		 * A filter to provide the possibility to skip trashing the series post,
		 * for example when removing old events during maintenance.
		 *
		 * @since 7.7.12
		 *
		 * @return bool Whether to skip trashing the series post. Default is false.
		 */
		$skip_trashing_series = apply_filters( 'tec_events_skip_updating_series_status', false );

		if ( $skip_trashing_series ) {
			return 0;
		}

		$post = $this->check_event_post( $post_id );
		if ( false === $post ) {
			return false;
		}

		$relationships = $this->get_event_relationships( $post );
		$trashed       = 0;

		/** @var \TEC\Events_Pro\Custom_Tables\V1\Models\Relationship $relationship */
		foreach ( $relationships as $relationship ) {
			$series_id = $relationship->series_post_id;

			if ( ! (
				$this->check_autogenerated( $series_id )
				&& $this->should_follow( $series_id, $post_id ) )
			) {
				continue;
			}

			$trashed_post = wp_trash_post( $series_id );

			if ( ! $trashed_post instanceof WP_Post ) {
				continue;
			}

			$trashed ++;
		}

		return $trashed;
	}

	/**
	 * Get and check the input post ID to make sure trashing or deletion of the
	 * related Series is coherent.
	 *
	 * @since 6.0.0
	 *
	 * @param int $post_id The post ID.
	 *
	 * @return WP_Post|false Either a reference to the Event post object or `false`
	 *                       to indicate the fetching or the checks failed.
	 */
	private function check_event_post( $post_id ) {
		if ( $this->provisional_post->is_provisional_post_id( $post_id ) ) {
			$occurrence_id = $this->provisional_post->normalize_provisional_post_id( $post_id );
			$occurrence    = Occurrence::find( $occurrence_id, 'occurrence_id' );
			if ( ! $occurrence instanceof Occurrence ) {
				return false;
			}
			$event_post_id = $occurrence->post_id;
		} else {
			$event_post_id = $post_id;
		}

		$post = get_post( $event_post_id );

		if ( ! ( $post instanceof WP_Post && TEC::POSTTYPE === $post->post_type ) ) {
			return false;
		}

		$event = Event::find( $post->ID, 'post_id' );

		if ( ! $event instanceof Event || empty( $event->rset ) ) {
			return false;
		}

		return $post;
	}

	/**
	 * Whether a Series post has the pre-conditions to be trashed or not.
	 *
	 * A Series should be trashed following an Event if that Event is the last
	 * one related to the Series and is a Recurring Event.
	 *
	 * @since 6.0.0
	 *
	 * @param int $series_id The Series post ID.
	 * @param int|WP_Post $event_id  The Event post ID or a reference to the Event post object.
	 *
	 * @return bool Whether a Series post has the pre-conditions to be trashed or not.
	 */
	private function should_follow( $series_id, $event_id ) {
		$relationships = Relationship::builder_instance()->find_all( $series_id, 'series_post_id' );
		$event_id = $event_id instanceof WP_Post ? $event_id->ID : $event_id;

		if ( ! $relationships instanceof Generator ) {
			return false;
		}

		$related = array_map( static function ( Relationship $relationship ) {
			return $relationship->event_post_id;
		}, iterator_to_array( $relationships, false ) );

		return $related === [ $event_id ];
	}

	/**
	 * Handles the deletion of any auto-generated Series related to a Recurring Event
	 * that has been deleted.
	 *
	 * @since 6.0.0
	 *
	 * @parma WP_Post $post A reference to the post object being deleted.
	 *
	 * @return int The number of deleted Series.
	 */
	public function delete_following( WP_Post $post ) {
		return $this->trash_following( $post );
	}

	/**
	 * Checks whether a post, or post ID, refers to an auto-generated Series
	 * or not.
	 *
	 * @since 6.0.0
	 *
	 * @param int|WP_Post|null $post_id A reference to the post object, or post ID, to check.
	 *
	 * @return false|WP_Post Either a reference to the auto-generated Series post, `false` otherwise.
	 */
	private function check_autogenerated( $post_id ) {
		$post = get_post( $post_id );

		if ( ! ( $post instanceof WP_Post && Series::POSTTYPE === $post->post_type ) ) {
			return false;
		}

		$flag = get_post_meta( $post->ID, self::FLAG_META_KEY, true );

		if ( empty( $flag ) ) {
			// The flag is not there to begin with, bail.
			return false;
		}

		return $post;
	}

	/**
	 * Removes the auto-generated flag from a Series post if meaningful
	 * edits happened to it.
	 *
	 * @since 6.0.0
	 *
	 * @param WP_Post $series A reference to the Series post object.
	 *
	 * @return bool Either `true` if the autogenerated flag was removed, `false`
	 *              otherwise.
	 */
	public function remove_autogenerated_flag( WP_Post $series ) {
		$post = $this->check_autogenerated( $series );

		if ( ! $post instanceof WP_Post ) {
			return false;
		}

		// If the checksum is the same, do not remove.
		$remove = ! $this->checksum_matches( $series );

		/**
		 * Filters whether a Series post autogenerated flag should be removed from its
		 * meta or not.
		 *
		 * @since 6.0.0
		 *
		 * @param bool    $remove Whether the autogenerated flag should be removed or not.
		 * @param WP_Post $series A reference to the Series post object the filter is
		 *                        being applied for.
		 */
		$remove = apply_filters( 'tec_events_custom_tables_v1_remove_series_autogenerated_flag', $remove, $series );

		if ( ! $remove ) {
			return false;
		}

		return delete_post_meta( $post->ID, self::FLAG_META_KEY )
		       && delete_post_meta( $post->ID, self::CHECKSUM_META_KEY );
	}

	/**
	 * Returns the checksum of the post fields and custom fields for a Series post.
	 *
	 * @since 6.0.0
	 *
	 * @param WP_Post $post A reference to the Series post object.
	 *
	 * @return string A string representing the post current checksum.
	 */
	private function calculate_post_checksum( WP_Post $post ) {
		$post_vars = array_diff_key( get_object_vars( $post ), [
			'post_modified'     => true,
			'post_modified_gmt' => true,
		] );
		$post_meta = array_diff_key( get_post_meta( $post->ID ), [
			self::FLAG_META_KEY     => true,
			self::CHECKSUM_META_KEY => true,
			'_edit_lock'            => true,
			'_edit_last'            => true,
		] );

		$relationships_data = [];
		$relationships      = Relationship::find_all( $post->ID, 'series_post_id' );
		/** @var \TEC\Events_Pro\Custom_Tables\V1\Models\Relationship $relationship */
		foreach ( $relationships as $relationship ) {
			$relationships_data[] = $relationship->to_array();
		}

		return md5(
			wp_json_encode( $post_vars )
			. wp_json_encode( $post_meta )
			. wp_json_encode( $relationships_data )
		);
	}

	/**
	 * Returns whether the current Series post checksum matches the stored version or not.
	 *
	 * If no checksum exists for the Series, then it will be calculated and stored.
	 *
	 * @since 6.0.0
	 *
	 * @param WP_Post $post A reference to the Series post object.
	 *
	 * @return bool Whether the current Series post checksum value matches the stored one
	 *              or not.
	 */
	public function checksum_matches( WP_Post $post ) {
		$expected = get_post_meta( $post->ID, self::CHECKSUM_META_KEY, true );
		$current  = $this->calculate_post_checksum( $post );

		if ( empty( $expected ) ) {
			// First time we check it.
			update_post_meta( $post->ID, self::CHECKSUM_META_KEY, $current );

			return true;
		}

		return $expected === $current;
	}

	/**
	 * Untrashes a Series post if the Event being untrashed is the one that triggered
	 * the Series auto-generation.
	 *
	 * @since 6.0.0
	 *
	 * @param int $post_id The Event post ID.
	 *
	 * @return int The number of untrashed Series.
	 */
	public function untrash_following( $post_id ) {
		$post = $this->check_event_post( $post_id );

		if ( ! $post instanceof WP_Post ) {
			return false;
		}

		$relationships = $this->get_event_relationships( $post );
		$untrashed     = 0;

		/** @var \TEC\Events_Pro\Custom_Tables\V1\Models\Relationship $relationship */
		foreach ( $relationships as $relationship ) {
			$series_id = $relationship->series_post_id;

			if ( ! $this->should_follow( $series_id, $post_id ) ) {
				continue;
			}

			$untrashed_post = wp_untrash_post( $series_id );

			if ( ! $untrashed_post instanceof WP_Post ) {
				continue;
			}

			$untrashed ++;
		}

		return $untrashed;
	}

	/**
	 * Returns a generator that will produce all the Series <> Event relationships
	 * for an Event.
	 *
	 * @since 6.0.0
	 *
	 * @param WP_Post $event A reference to the Event post object.
	 *
	 * @return Generator<\TEC\Events_Pro\Custom_Tables\V1\Models\Relationship>|array Either a Generator that will produce all Relationships
	 *                                       for the Event, or an empty array.
	 */
	private function get_event_relationships( WP_Post $event ) {
		$relationships = Relationship::builder_instance()->find_all( (array) $event->ID, 'event_post_id' );
		return $relationships instanceof Generator ? $relationships : [];
	}

	/**
	 * Updates a Series post status if the Event post status is being updated.
	 *
	 * @since 6.0.11
	 *
	 * @param WP_Post $post       The Event post object.
	 * @param string  $old_status The old Event post status.
	 * @param string  $new_status The new Event post status.
	 *
	 * @return int|WP_Error The updated Series post ID, `0` if no Series was updated, or a WP_Error object.
	 */
	public function update_series_post_status( WP_Post $post, string $old_status, string $new_status ) {
		$event_post_id = Occurrence::normalize_id( $post->ID );

		if ( get_post_type( $event_post_id ) !== TEC::POSTTYPE ) {
			return 0;
		}

		$series = tec_series()
			->where( 'event_post_id', $event_post_id )
			->where( 'post_status', $old_status )
			->first();

		if ( ! $series instanceof WP_Post ) {
			return 0;
		}

		if ( ! tribe_is_truthy( get_post_meta( $series->ID, self::FLAG_META_KEY, true ) ) ) {
			return 0;
		}

		$series_post_status = get_post_status( $series->ID );

		if ( $series_post_status === $new_status ) {
			return 0;
		}

		// Only if all Series' Events will match this new status.
		global $wpdb;
		$events_table              = Events::table_name();
		$series_relationship_table = Series_Relationships::table_name();
		$query = "SELECT COUNT(*)
					FROM
					    {$wpdb->posts} AS event_post
					        INNER JOIN
					    {$events_table} ON event_post.ID = {$events_table}.post_id
					        INNER JOIN
					    {$series_relationship_table} ON {$series_relationship_table}.event_id = {$events_table}.event_id
					WHERE
					    {$series_relationship_table}.series_post_id = %d
					        AND event_post.post_status = %s";
		$query = $wpdb->prepare( $query, $series->ID, $new_status );

		// How many are in that status?
		$total_in_same_status = (int) $wpdb->get_var( $query );
		// How many total?
		$total_events = Relationship::where( 'series_post_id', $series->ID )->count();
		// If they aren't all in the same status, do not transition Autogenerated Series.
		if ( $total_events > 1 && $total_in_same_status !== $total_events ) {
			return 0;
		}

		// This update is happening because the Series is auto-generated: do not remove the flag.
		add_filter( 'tec_events_custom_tables_v1_remove_series_autogenerated_flag', '__return_false' );
		$updated = wp_update_post( [
			'ID'          => $series->ID,
			'post_status' => $new_status,
		] );
		// Update the checksum since the post status changed.
		update_post_meta( $series->ID, self::CHECKSUM_META_KEY, $this->calculate_post_checksum( $series ) );
		remove_filter( 'tec_events_custom_tables_v1_remove_series_autogenerated_flag', '__return_false' );

		if ( $updated instanceof WP_Error ) {
			do_action( 'tribe_log', 'error', __METHOD__, [
				'message'        => 'Error updating series post status following Event post status update.',
				'old_status'     => $old_status,
				'new_status'     => $new_status,
				'event_post_id'  => $event_post_id,
				'series_post_id' => $series->ID,
			] );
		}

		return $updated;
	}
}
