<?php
/**
 * The Tickets abstraction object, used to add tickets-related properties to the event object crated by the
 * `tribe_get_event` function.
 *
 * @since 4.10.9
 *
 * @package Tribe\Tickets\Events\Views\V2\Models
 */

namespace Tribe\Tickets\Events\Views\V2\Models;

use ArrayAccess;
use Closure;
use InvalidArgumentException;
use ReturnTypeWillChange;
use Tribe\Utils\Lazy_Events;
use Tribe__Events__Main as TEC;
use Tribe__Tickets__Ticket_Object as Ticket_Object;
use Tribe__Tickets__Tickets as Tickets_Tickets;
use WP_Post;

/**
 * Class Tickets
 *
 * @since 4.10.9
 *
 * @package Tribe\Tickets\Events\Views\V2\Models
 */
class Tickets implements ArrayAccess {
	use Lazy_Events;

	/**
	 * The post ID this tickets model is for.
	 *
	 * @since 4.10.9
	 *
	 * @var int
	 */
	protected $post_id;

	/**
	 * The tickets data.
	 *
	 * @since 4.10.9
	 *
	 * @var array
	 */
	protected $data;

	/**
	 * A flag property indicating whether tickets for the post exists or not.
	 *
	 * @since 4.10.9
	 *
	 * @var bool
	 */
	protected $exists;

	/**
	 * An array of all the tickets for this event.
	 *
	 * @since 4.10.9
	 *
	 * @var array
	 */
	protected $all_tickets;

	/**
	 * A flag indicating whether the data for the model is cached or not.
	 *
	 * @since 5.26.1
	 *
	 * @var true
	 */
	private bool $is_cached = false;

	/**
	 * Tickets constructor.
	 *
	 * @param int $post_id The post ID.
	 */
	public function __construct( $post_id ) {
		$this->post_id = $post_id;

		$this->restore_from_cache();
	}

	/**
	 * Regenerates the caches for the models associated with the post ID.
	 *
	 * @since 5.26.1
	 *
	 * @param int $post_id The post ID. It could be any post type, not just events.
	 *
	 * @return void
	 */
	public static function regenerate_caches( int $post_id ): void {
		$model_cache_key = self::get_cache_key( $post_id );

		// First, clean the kv-cache entries for this post.
		tec_kv_cache()->delete( $model_cache_key );

		$post = get_post( $post_id );

		if ( ! $post instanceof WP_Post ) {
			// The clean of the cache happened in the context of the post deletion, we're done.
			return;
		}

		// Avoid caching the query results while we refresh the cache.
		$do_not_cache_results = static function ( $wp_query ): void {
			if ( ! $wp_query instanceof \WP_Query ) {
				return;
			}

			$wp_query->query_vars['cache_results'] = false;
		};

		add_action( 'parse_query', $do_not_cache_results );

		if ( $post->post_type === TEC::POSTTYPE ) {
			// It's an Event: refresh its cache.
			$model = new self( $post->ID );
			$model->exist();
		} else {
			// These are maps from the service slug to the post type, so we keep only the post type.
			$attendee_post_types = array_values( tribe_attendees()->attendee_types() );
			$ticket_types        = array_values( tribe_tickets()->ticket_types() );

			// This is not an event post, but it might be a Ticket or Attendee linked to an Event.
			$ancillary_post_types = array_merge( $attendee_post_types, $ticket_types );

			if ( ! in_array( $post->post_type, $ancillary_post_types, true ) ) {
				remove_action( 'parse_query', $do_not_cache_results );

				// Not a post type we're interested in.
				return;
			}

			$is_ticket = in_array( $post->post_type, $ticket_types, true );

			// These are maps from the service slug to the meta key, so we keep only the meta key.
			$attendee_to_event_keys  = array_values( tribe_attendees()->attendee_to_event_keys() );
			$ticket_to_event_keys    = array_values( tribe_tickets()->ticket_to_event_keys() );
			$all_connected_meta_keys = array_merge( $attendee_to_event_keys, $ticket_to_event_keys );

			/** @var array<array<int>> $connected_event_ids */
			$connected_event_ids = [];
			foreach ( get_post_meta( $post_id ) as $meta_key => $meta_values ) {
				if ( ! in_array( $meta_key, $all_connected_meta_keys ) ) {
					continue;
				}

				$connected_event_ids[] = array_map( 'absint', $meta_values );

				if ( $is_ticket ) {
					// Invalidate some ticket-specific caches.
					wp_cache_delete( $post_id, 'tec_tickets' );
				}
			}
			/** @var array<int> $connected_event_ids */
			$connected_event_ids = array_merge( ...$connected_event_ids );
			$tribe_cache         = tribe_cache();
			$tickets_class       = Tickets_Tickets::class;

			foreach ( $connected_event_ids as $connected_event_id ) {
				// Reset the `Tribe__Tickets__Tickets::get_tickets` method cache to get the last version of them.
				$provider = Tickets_Tickets::get_event_ticket_provider_object( $connected_event_id );

				if ( $provider ) {
					$orm_provider                      = $provider->orm_provider;
					$tickets_cache_key                 = "{$tickets_class}::get_tickets-{$orm_provider}-{$connected_event_id}";
					$tribe_cache[ $tickets_cache_key ] = null;
				}

				$model = new self( $connected_event_id );
				// The call will trigger a priming of the model cache.
				$model->exist();
				$model->prime_cache();
			}
		}

		remove_action( 'parse_query', $do_not_cache_results );
	}

	/**
	 * {@inheritDoc}
	 */
	public function __get( $property ) {
		if ( 'exist' === $property ) {
			return $this->exist();
		}

		return $this->offsetGet( $property );
	}

	/**
	 * {@inheritDoc}
	 *
	 * @throws InvalidArgumentException When trying to set a property.
	 */
	public function __set( $property, $value ) {
		throw new InvalidArgumentException( "The `Tickets::{$property}` property cannot be set." );
	}

	/**
	 * {@inheritDoc}
	 */
	public function __isset( $property ) {
		return $this->offsetExists( $property );
	}

	/**
	 * Returns the data about the event tickets, if any.
	 *
	 * @since 4.10.9
	 *
	 * @since 5.6.3 Add support for the updated anchor link from new ticket templates.
	 * @since 5.26.7 Fixed issue where empty arrays were being returned when data existed but was empty.
	 * @since 5.27.5 Fixed issue where the stock display was not being refreshed from the current availability.
	 *
	 * @return array Ticket data or empty array.
	 */
	public function fetch_data() {
		if ( ! $this->exist() ) {
			return [];
		}

		if ( null !== $this->data && ! empty( $this->data ) ) {
			// Refresh stock display from current availability so list/archive views stay correct
			// when event or ticket model is served from cache (e.g. tribe_get_event memoization).
			$this->refresh_cached_stock_display();
			return $this->data;
		}

		$num_ticket_types_available = 0;
		foreach ( $this->all_tickets as $ticket ) {
			if ( ! tribe_events_ticket_is_on_sale( $ticket ) ) {
				continue;
			}

			++$num_ticket_types_available;
		}

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

		// Get an array for ticket and rsvp counts.
		$types = Tickets_Tickets::get_ticket_counts( $this->post_id );

		// If no rsvp or tickets return.
		if ( ! $types ) {
			return [];
		}

		$html        = [];
		$parts       = [];
		$stock_html  = '';
		$sold_out    = '';
		$link_label  = '';
		$link_anchor = '';

		// If we have tickets or RSVP, but everything is Sold Out then display the Sold Out message.
		foreach ( $types as $type => $data ) {

			if ( ! $data['count'] ) {
				continue;
			}

			if ( ! $data['available'] ) {
				if ( 'rsvp' === $type ) {
					$parts[ $type . '_stock' ] = esc_html_x( 'Currently full', 'events rsvp full (v2)', 'event-tickets' );
				} else {
					$parts[ $type . '_stock' ] = esc_html_x( 'Sold Out', 'events stock sold out (v2)', 'event-tickets' );
				}

				// Only re-apply if we don't have a stock yet.
				if ( empty( $html['stock'] ) ) {
					$html['stock'] = $parts[ $type . '_stock' ];
					$sold_out      = $parts[ $type . '_stock' ];
				}
			} else {
				// Use get_ticket_counts() so shared capacity shows pool remaining (one number), not sum of per-ticket.
				$stock = $data['stock'];
				if ( $data['unlimited'] || ! $data['stock'] ) {
					// If unlimited tickets, tickets with no stock and rsvp, or no tickets and rsvp unlimited - hide the remaining count.
					$stock = false;
				}

				if ( $stock ) {
					$stock_html = $this->build_stock_html( $stock, $type, $data );
				}

				$html['stock']             = $stock_html;
				$parts[ $type . '_stock' ] = $stock_html;

				if ( 'rsvp' === $type ) {
					/* Translators: RSVP singular label. */
					$link_label  = esc_html( sprintf( _x( '%s Now', 'list view rsvp now ticket button', 'event-tickets' ), tribe_get_rsvp_label_singular( 'list_view_rsvp_now_button' ) ) );
					$link_anchor = '#rsvp-now';
				} else {
					/* Translators: Tickets plural label. */
					$link_label  = esc_html( sprintf( _x( 'Get %s', 'list view buy now ticket button', 'event-tickets' ), tribe_get_ticket_label_plural( 'list_view_buy_now_button' ) ) );
					$link_anchor = tribe_tickets_new_views_is_enabled() ? '#tribe-tickets__tickets-form' : '#tribe-tickets';
				}
			}
		}

		$this->data['link'] = (object) [
			'anchor' => get_permalink( $this->post_id ) . $link_anchor,
			'label'  => $link_label,
		];

		$this->data['stock'] = (object) [
			'available' => $stock_html,
			'sold_out'  => $sold_out,
		];

		return $this->data;
	}

	/**
	 * {@inheritDoc}
	 */
	public function offsetExists( $offset ): bool {
		$this->data = $this->fetch_data();

		return isset( $this->data[ $offset ] );
	}

	/**
	 * {@inheritDoc}
	 */
	#[ReturnTypeWillChange]
	public function offsetGet( $offset ) {
		$this->data = $this->fetch_data();

		return $this->data[ $offset ] ?? null;
	}

	/**
	 * {@inheritDoc}
	 */
	public function offsetSet( $offset, $value ): void {
		$this->data = $this->fetch_data();

		$this->data[ $offset ] = $value;
	}

	/**
	 * {@inheritDoc}
	 */
	public function offsetUnset( $offset ): void {
		$this->data = $this->fetch_data();

		unset( $this->data[ $offset ] );
	}

	/**
	 * Returns an array representation of the event tickets data.
	 *
	 * @since 4.10.9
	 *
	 * @return array An array representation of the event tickets data.
	 */
	public function to_array() {
		$this->data = $this->fetch_data();

		return json_decode( wp_json_encode( $this->data ), true );
	}

	/**
	 * Legacy serialization method kept for backward compatibility.
	 *
	 * @since 5.7.0
	 * @deprecated 5.29.0 Use __serialize() instead.
	 *
	 * @return string
	 */
	public function serialize() {
		_deprecated_function( __METHOD__, '5.29.0', '__serialize()' );

		$data            = $this->fetch_data();
		$data['post_id'] = $this->post_id;

		// Kept for back-compatibility reasons.
		return maybe_serialize( $this->__serialize() );
	}

	/**
	 * PHP 8.0+ compatible implementation of the serialization logic.
	 *
	 * @since 5.7.0
	 *
	 * @return array The data to serialize.
	 */
	public function __serialize(): array { // phpcs:ignore StellarWP.NamingConventions.ValidFunctionName.MethodDoubleUnderscore
		$data            = $this->fetch_data();
		$data['post_id'] = $this->post_id;

		return $data;
	}

	/**
	 * PHP 8.0+ compatible implementation of the unserialization logic.
	 *
	 * @since 5.7.0
	 *
	 * @param array $data The data to unserialize.
	 */
	public function __unserialize( array $data ): void { // phpcs:ignore StellarWP.NamingConventions.ValidFunctionName.MethodDoubleUnderscore
		$this->post_id = $data['post_id'] ?? null;
		$this->data    = $data;
	}

	/**
	 * Legacy unserialization method kept for backward compatibility.
	 *
	 * @since 5.7.0
	 * @deprecated 5.29.0 Use __unserialize() instead.
	 *
	 * @param array|string $serialized The serialized data.
	 *
	 * @return void
	 */
	public function unserialize( $serialized ) {
		_deprecated_function( __METHOD__, '5.29.0', '__unserialize()' );

		// Kept for back-compatibility reasons.
		$data = maybe_unserialize( $serialized );
		$this->__unserialize( $data );

		unset( $data['post_id'] );
	}

	/**
	 * Returns whether an event has tickets at all or not.
	 *
	 * @since 4.10.9
	 *
	 * @return bool Whether an event has tickets at all or not.
	 */
	public function exist() {
		if ( null !== $this->exists ) {
			return $this->exists;
		}

		$this->all_tickets = Tickets_Tickets::get_all_event_tickets( $this->post_id );

		$this->exists = ! empty( $this->all_tickets );

		if ( ! $this->is_cached ) {
			$this->prime_cache();
		}

		return $this->exists;
	}

	/**
	 * Updates cached data's stock display (X tickets/spots left) from current counts.
	 * Uses get_ticket_counts() so shared capacity shows the pool remaining (one number), not the sum of per-ticket.
	 * Called when serving from cache so the number stays correct despite tribe_get_event or model cache.
	 *
	 * @since 5.27.5
	 */
	protected function refresh_cached_stock_display(): void {
		if ( ! isset( $this->data['stock'] ) || ! is_object( $this->data['stock'] ) ) {
			return;
		}

		$types = Tickets_Tickets::get_ticket_counts( $this->post_id );
		if ( ! $types ) {
			return;
		}

		// Use the same type that would "win" in fetch_data (last with count): prefer tickets over rsvp.
		$data = null;
		if ( ! empty( $types['tickets']['count'] ) ) {
			$data = $types['tickets'];
			$type = 'tickets';
		} elseif ( ! empty( $types['rsvp']['count'] ) ) {
			$data = $types['rsvp'];
			$type = 'rsvp';
		}

		if ( ! $data || ! $data['available'] ) {
			return;
		}

		$stock = $data['stock'];
		if ( $data['unlimited'] || ! $stock ) {
			$this->data['stock']->available = '';
			return;
		}

		$this->data['stock']->available = $this->build_stock_html( (int) $stock, $type, $data );
	}

	/**
	 * Returns whether an event has tickets in date range.
	 *
	 * @since 4.12.0
	 *
	 * @return bool Whether an event has tickets in date range
	 */
	public function in_date_range() {
		if ( ! $this->post_id ) {
			return false;
		}

		return tribe_tickets_is_current_time_in_date_window( $this->post_id );
	}

	/**
	 * Returns whether an event has its tickets sold out.
	 *
	 * @since 4.12.0
	 *
	 * @return bool Whether an event has its tickets sold out.
	 */
	public function sold_out() {
		$data = $this->fetch_data();

		return ! empty( $data['stock']->sold_out );
	}

	/**
	 * Builds the "X tickets/spots left" HTML string for a given stock count and type.
	 *
	 * Applies the threshold filter and returns an empty string when the stock is above
	 * the configured threshold so the display is hidden.
	 *
	 * @since 5.27.5
	 *
	 * @param int    $stock The number of tickets/spots remaining.
	 * @param string $type  The ticket type ('rsvp' or 'tickets').
	 * @param array  $data  The ticket-count data array forwarded to the threshold filter.
	 *
	 * @return string The escaped HTML string, or an empty string when hidden by threshold.
	 */
	private function build_stock_html( int $stock, string $type, array $data ): string {
		/** @var Tribe__Settings_Manager $settings_manager */
		$settings_manager = tribe( 'settings.manager' );
		$threshold        = $settings_manager::get_option( 'ticket-display-tickets-left-threshold', 0 );

		/**
		 * Overwrites the threshold to display "# tickets left".
		 *
		 * @since 4.10.1
		 *
		 * @param int   $threshold Stock threshold to trigger display of "# tickets left".
		 * @param array $data      Ticket data.
		 * @param int   $event_id  Event ID.
		 */
		$threshold = absint( apply_filters( 'tribe_display_tickets_left_threshold', $threshold, $data, $this->post_id ) );

		if ( $threshold && $stock > $threshold ) {
			return '';
		}

		$number = number_format_i18n( $stock );

		if ( 'rsvp' === $type ) {
			/* translators: %1$s: Number of stock */
			$text = _n( '%1$s spot left', '%1$s spots left', $stock, 'event-tickets' );
			return esc_html( sprintf( $text, $number ) );
		}

		$ticket_label_singular = tribe_get_ticket_label_singular_lowercase( 'event-tickets' );
		$ticket_label_plural   = tribe_get_ticket_label_plural_lowercase( 'event-tickets' );

		// phpcs:disable -- to suppress WordPress.WP.I18n.MismatchedPlaceholders incorrect warning.
		/* translators: %1$s: Number of stock, %2$s: Ticket label, %3$s: Tickets label */
		$text = _n( '%1$s %2$s left', '%1$s %3$s left', $stock, 'event-tickets' );
		// phpcs:enable

		return esc_html( sprintf( $text, $number, $ticket_label_singular, $ticket_label_plural ) );
	}

	/**
	 * Primes the model cache from the key-value cache, if possible.
	 *
	 * @since 5.26.1
	 *
	 * @return void
	 */
	private function restore_from_cache(): void {
		$cached = tec_kv_cache()->get( self::get_cache_key( $this->post_id ) );

		if ( $cached === '' ) {
			return;
		}

		$unpacked = tec_json_unpack( $cached, true, [ Ticket_Object::class ] );

		if ( ! ( is_array( $unpacked ) && isset( $unpacked['all_tickets'], $unpacked['data'] ) ) ) {
			return;
		}

		$this->all_tickets = $unpacked['all_tickets'];
		$this->data        = $unpacked['data'];
		$this->is_cached   = true;
	}

	/**
	 * Returns the model cache key used to store it in the key-value cache.
	 *
	 * @since 5.26.1
	 *
	 * @param int $post_id The post ID to provide the cache key for.
	 *
	 * @return string The model cache key used to store it in the key-value cache.
	 */
	public static function get_cache_key( int $post_id ): string {
		return 'tec_tickets_views_v2_model_ticket_' . $post_id;
	}

	/**
	 * Primes the key-value cache for the model.
	 *
	 * @since 5.26.1
	 *
	 * @return void
	 */
	private function prime_cache(): void {
		/*
		* Unset the `provider` property of each Ticket to avoid storing a singleton object.
		* The ticket will recover the module instance using the `provider_class` property.
		*/
		foreach ( $this->all_tickets as $ticket ) {
			// phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found -- Faster than Reflection.
			Closure::bind( fn() => $ticket->provider = null, $ticket, Ticket_Object::class )();
		}

		$packed = tec_json_pack(
			[
				'all_tickets' => $this->all_tickets,
				'data'        => $this->fetch_data(),
			],
			[ Ticket_Object::class ]
		);

		tec_kv_cache()->set( self::get_cache_key( $this->post_id ), $packed, DAY_IN_SECONDS );
	}
}
