<?php
/**
 * JetSmartFilters compatibility package manager.
 *
 * @package JET_APB\Compatibility\Packages
 */

namespace JET_APB\Compatibility\Packages;

use JET_APB\Plugin;

defined( 'ABSPATH' ) || exit;

class Jet_Smart_Filters {

	const SERVICE_AVAILABLE       = 'jet_apb_service_available';
	const PROVIDER_AVAILABLE      = 'jet_apb_provider_available';
	const MAX_AVAILABILITY_DAYS   = 62;

	/**
	 * A reference to an instance of this class.
	 *
	 * @var object
	 */
	private static $instance = null;

	/**
	 * Request-level cache for availability results.
	 *
	 * @var array
	 */
	protected $availability_cache = [];

	/**
	 * Request-level cache for post lists.
	 *
	 * @var array
	 */
	protected $posts_cache = [];

	/**
	 * Request-level cache for service/provider relations.
	 *
	 * @var array
	 */
	protected $relations_cache = [];

	/**
	 * Request-level cache for schedule date checks.
	 *
	 * @var array
	 */
	protected $schedule_date_cache = [];

	/**
	 * Request-level cache for excluded table rows by date.
	 *
	 * @var array
	 */
	protected $excluded_dates_cache = [];

	public function __construct() {
		add_filter( 'jet-smart-filters/post-type/meta-fields-settings', [ $this, 'add_date_sources' ] );
		add_filter( 'jet-smart-filters/query/vars', [ $this, 'add_query_vars' ] );
		add_filter( 'jet-smart-filters/query/final-query', [ $this, 'set_availability_params' ] );
	}

	/**
	 * Package path.
	 *
	 * Return path inside package.
	 *
	 * @access public
	 *
	 * @param string $path Relative package path.
	 *
	 * @return string
	 */
	public function package_path( $path = '' ) {
		return JET_APB_PATH . 'includes/compatibility/packages/jet-smart-filters/' . $path;
	}

	/**
	 * Package URL.
	 *
	 * Return URL inside package.
	 *
	 * @access public
	 *
	 * @param string $url Relative package URL.
	 *
	 * @return string
	 */
	public function package_url( $url = '' ) {
		return JET_APB_URL . 'includes/compatibility/packages/jet-smart-filters/' . $url;
	}

	/**
	 * Add appointment availability options to Date Range/Date Period "Filter by".
	 *
	 * @param array $settings Smart Filters settings schema.
	 *
	 * @return array
	 */
	public function add_date_sources( $settings ) {

		if ( empty( $settings['_date_source']['options'] ) ) {
			return $settings;
		}

		$settings['_date_source']['options'][ self::SERVICE_AVAILABLE ]  = __( 'Service Available', 'jet-appointments-booking' );
		$settings['_date_source']['options'][ self::PROVIDER_AVAILABLE ] = __( 'Provider Available', 'jet-appointments-booking' );

		if ( ! empty( $settings['_query_var']['conditions']['_date_source!'] ) ) {
			$settings['_query_var']['conditions']['_date_source!'] = array_values( array_unique( array_merge(
				(array) $settings['_query_var']['conditions']['_date_source!'],
				[
					self::SERVICE_AVAILABLE,
					self::PROVIDER_AVAILABLE,
				]
			) ) );
		}

		return $settings;

	}

	/**
	 * Register custom query vars emitted by Date Range/Date Period filters.
	 *
	 * @param array $vars Query vars.
	 *
	 * @return array
	 */
	public function add_query_vars( $vars ) {

		$vars[] = self::SERVICE_AVAILABLE;
		$vars[] = self::PROVIDER_AVAILABLE;

		return array_values( array_unique( $vars ) );

	}

	/**
	 * Convert appointment availability query vars into post__in constraints.
	 *
	 * @param array $query Query arguments.
	 *
	 * @return array
	 */
	public function set_availability_params( $query ) {

		foreach ( [ self::SERVICE_AVAILABLE, self::PROVIDER_AVAILABLE ] as $query_var ) {
			if ( empty( $query[ $query_var ] ) ) {
				continue;
			}
			[ $from, $to ] = $this->parse_period( $query[ $query_var ] );
			unset( $query[ $query_var ] );

			if ( ! $from || ! $to ) {
				continue;
			}

			$current_post_in = ! empty( $query['post__in'] ) ? (array) $query['post__in'] : [];
			$available_ids   = self::SERVICE_AVAILABLE === $query_var
				? $this->get_available_services( $from, $to, $current_post_in )
				: $this->get_available_providers( $from, $to, $current_post_in );

			$query['post__in'] = $this->merge_post_in( $current_post_in, $available_ids );
		}

		return $query;

	}

	/**
	 * Return service IDs available in a normalized period.
	 *
	 * @param int   $from          Period start timestamp.
	 * @param int   $to            Period end timestamp.
	 * @param array $candidate_ids Optional candidate service IDs.
	 *
	 * @return array
	 */
	protected function get_available_services( $from, $to, $candidate_ids = [] ) {

		$candidate_ids = $this->normalize_ids( $candidate_ids );
		$cache_key     = $this->get_period_cache_key( 'services', $from, $to, $candidate_ids );

		if ( isset( $this->availability_cache[ $cache_key ] ) ) {
			return $this->availability_cache[ $cache_key ];
		}

		$available   = [];
		$services    = $this->get_posts( 'services' );
		$service_ids = array_map( 'absint', array_keys( $services ) );

		if ( ! empty( $candidate_ids ) ) {
			$service_ids = array_values( array_intersect( $service_ids, $candidate_ids ) );
		}

		foreach ( $service_ids as $service_id ) {
			$providers = $this->get_provider_ids_for_service( $service_id );

			if ( empty( $providers ) && ! Plugin::instance()->settings->get( 'providers_cpt' ) ) {
				$providers = [ 0 ];
			}

			foreach ( $providers as $provider_id ) {
				if ( $this->has_available_date( $from, $to, $service_id, $provider_id ) ) {
					$available[] = absint( $service_id );
					break;
				}
			}
		}

		$this->availability_cache[ $cache_key ] = array_values( array_unique( $available ) );

		return $this->availability_cache[ $cache_key ];

	}

	/**
	 * Return provider IDs available in a normalized period.
	 *
	 * @param int   $from          Period start timestamp.
	 * @param int   $to            Period end timestamp.
	 * @param array $candidate_ids Optional candidate provider IDs.
	 *
	 * @return array
	 */
	protected function get_available_providers( $from, $to, $candidate_ids = [] ) {

		$candidate_ids = $this->normalize_ids( $candidate_ids );
		$cache_key     = $this->get_period_cache_key( 'providers', $from, $to, $candidate_ids );

		if ( isset( $this->availability_cache[ $cache_key ] ) ) {
			return $this->availability_cache[ $cache_key ];
		}

		$available    = [];
		$providers    = $this->get_posts( 'providers' );
		$provider_ids = array_map( 'absint', array_keys( $providers ) );

		if ( ! empty( $candidate_ids ) ) {
			$provider_ids = array_values( array_intersect( $provider_ids, $candidate_ids ) );
		}

		foreach ( $provider_ids as $provider_id ) {
			$services = $this->get_service_ids_for_provider( $provider_id );

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

			foreach ( $services as $service_id ) {
				if ( $this->has_available_date( $from, $to, $service_id, $provider_id ) ) {
					$available[] = absint( $provider_id );
					break;
				}
			}
		}

		$this->availability_cache[ $cache_key ] = array_values( array_unique( $available ) );

		return $this->availability_cache[ $cache_key ];

	}

	/**
	 * Return cached service/provider post list.
	 *
	 * @param string $type Posts type.
	 *
	 * @return array
	 */
	protected function get_posts( $type ) {

		if ( ! isset( $this->posts_cache[ $type ] ) ) {
			$this->posts_cache[ $type ] = Plugin::instance()->tools->get_posts( $type, [
				'post_status'    => 'any',
				'posts_per_page' => -1,
			] );
		}

		return $this->posts_cache[ $type ];

	}

	/**
	 * Return provider IDs related to a service.
	 *
	 * @param int $service_id Service ID.
	 *
	 * @return array
	 */
	protected function get_provider_ids_for_service( $service_id ) {

		$cache_key = 'providers_for_service:' . absint( $service_id );

		if ( ! isset( $this->relations_cache[ $cache_key ] ) ) {
			$providers = Plugin::instance()->tools->get_providers_for_service( $service_id );

			$this->relations_cache[ $cache_key ] = ! empty( $providers ) ? wp_list_pluck( $providers, 'ID' ) : [];
		}

		return $this->relations_cache[ $cache_key ];

	}

	/**
	 * Return service IDs related to a provider.
	 *
	 * @param int $provider_id Provider ID.
	 *
	 * @return array
	 */
	protected function get_service_ids_for_provider( $provider_id ) {

		$cache_key = 'services_for_provider:' . absint( $provider_id );

		if ( ! isset( $this->relations_cache[ $cache_key ] ) ) {
			$services = Plugin::instance()->tools->get_services_for_provider( $provider_id );

			$this->relations_cache[ $cache_key ] = ! empty( $services ) ? array_map( 'absint', $services ) : [];
		}

		return $this->relations_cache[ $cache_key ];

	}

	/**
	 * Check if service/provider pair has any available date in period.
	 *
	 * @param int $from        Normalized period start timestamp.
	 * @param int $to          Normalized period end timestamp.
	 * @param int $service_id  Service ID.
	 * @param int $provider_id Provider ID.
	 *
	 * @return bool
	 */
	protected function has_available_date( $from, $to, $service_id, $provider_id = 0 ) {

		$cache_key = implode( ':', [
			'pair',
			absint( $service_id ),
			absint( $provider_id ),
			absint( $from ),
			absint( $to ),
		] );

		if ( isset( $this->availability_cache[ $cache_key ] ) ) {
			return $this->availability_cache[ $cache_key ];
		}

		$checked_days = 0;
		$max_days     = $this->get_max_availability_days();

		for ( $date = $from; $date <= $to; $date += DAY_IN_SECONDS ) {
			if ( ! $this->is_schedule_date_available( $date, $service_id, $provider_id ) ) {
				continue;
			}

			if ( $this->is_excluded_date( $date, $service_id, $provider_id ) ) {
				continue;
			}

			$checked_days++;

			if ( $max_days && $checked_days > $max_days ) {
				break;
			}

			if ( Plugin::instance()->settings->check_date_availability( absint( $service_id ), absint( $provider_id ), $date ) ) {
				$this->availability_cache[ $cache_key ] = true;

				return true;
			}
		}

		$this->availability_cache[ $cache_key ] = false;

		return false;

	}

	/**
	 * Check calendar-level date availability for schedule settings.
	 *
	 * Mirrors the public calendar date predicate so Days Off, Working Days,
	 * appointment range, and weekday settings affect Smart Filters too.
	 *
	 * @param int $date        Date timestamp.
	 * @param int $service_id  Service ID.
	 * @param int $provider_id Provider ID.
	 *
	 * @return bool
	 */
	protected function is_schedule_date_available( $date, $service_id, $provider_id = 0 ) {

		$cache_key = implode( ':', [
			absint( $date ),
			absint( $service_id ),
			absint( $provider_id ),
		] );

		if ( isset( $this->schedule_date_cache[ $cache_key ] ) ) {
			return $this->schedule_date_cache[ $cache_key ];
		}

		$calendar            = Plugin::instance()->calendar;
		$excluded_dates      = $calendar->get_off_dates( $service_id, $provider_id );
		$works_dates         = $calendar->get_works_dates( $service_id, $provider_id );
		$allowed_dates_range = $calendar->get_dates_range( $service_id, $provider_id );
		$dates_mode          = $calendar->get_working_days_mode( $service_id, $provider_id );
		$available_week_days = $calendar->get_available_week_days( $service_id, $provider_id );

		if ( ! empty( $allowed_dates_range['start'] ) && $date < absint( $allowed_dates_range['start'] ) ) {
			return $this->schedule_date_cache[ $cache_key ] = false;
		}

		if ( ! empty( $allowed_dates_range['end'] ) && $date > absint( $allowed_dates_range['end'] ) ) {
			return $this->schedule_date_cache[ $cache_key ] = false;
		}

		$works_dates = ( ! empty( $works_dates ) && is_array( $works_dates ) ) ? $works_dates : [];

		if ( ! empty( $excluded_dates ) && is_array( $excluded_dates ) ) {
			foreach ( $excluded_dates as $excluded_date ) {
				if ( empty( $excluded_date['start'] ) || empty( $excluded_date['end'] ) ) {
					continue;
				}

				if ( absint( $excluded_date['start'] ) > $date || $date > absint( $excluded_date['end'] ) ) {
					continue;
				}

				if ( ! empty( $excluded_date['service'] ) && absint( $service_id ) !== absint( $excluded_date['service'] ) ) {
					continue;
				}

				if ( ! empty( $excluded_date['provider'] ) && absint( $provider_id ) !== absint( $excluded_date['provider'] ) ) {
					continue;
				}

				if ( ! empty( $excluded_date['is_full'] ) ) {
					return $this->schedule_date_cache[ $cache_key ] = false;
				}

				return $this->schedule_date_cache[ $cache_key ] = $this->is_work_date( $date, $works_dates );
			}
		}

		if ( ! empty( $works_dates ) ) {
			if ( $this->is_work_date( $date, $works_dates ) ) {
				return $this->schedule_date_cache[ $cache_key ] = true;
			}

			if ( 'override_days' !== $dates_mode ) {
				return $this->schedule_date_cache[ $cache_key ] = false;
			}
		}

		if ( ! empty( $available_week_days ) && is_array( $available_week_days ) ) {
			return $this->schedule_date_cache[ $cache_key ] = in_array( strtolower( gmdate( 'l', $date ) ), $available_week_days, true );
		}

		return $this->schedule_date_cache[ $cache_key ] = true;

	}

	/**
	 * Check if date is inside an explicit working day range.
	 *
	 * @param int   $date        Date timestamp.
	 * @param array $works_dates Working day ranges.
	 *
	 * @return bool
	 */
	protected function is_work_date( $date, $works_dates ) {

		foreach ( $works_dates as $works_date ) {
			if ( empty( $works_date['start'] ) || empty( $works_date['end'] ) ) {
				continue;
			}

			if ( absint( $works_date['start'] ) <= $date && $date <= absint( $works_date['end'] ) ) {
				return true;
			}
		}

		return false;

	}

	/**
	 * Check cached full-day exclusions.
	 *
	 * @param int $date        Date timestamp.
	 * @param int $service_id  Service ID.
	 * @param int $provider_id Provider ID.
	 *
	 * @return bool
	 */
	protected function is_excluded_date( $date, $service_id, $provider_id = 0 ) {

		$cache_key = implode( ':', [
			absint( $date ),
			absint( $service_id ),
			absint( $provider_id ),
		] );

		if ( ! isset( $this->excluded_dates_cache[ $cache_key ] ) ) {
			$this->excluded_dates_cache[ $cache_key ] = Plugin::instance()->db->excluded_dates->query( [
				'date'     => $date,
				'service'  => $this->get_excluded_entity_query_values( $service_id ),
				'provider' => $this->get_excluded_entity_query_values( $provider_id ),
			] );
		}

		$excluded_dates = $this->excluded_dates_cache[ $cache_key ];

		if ( empty( $excluded_dates ) ) {
			return false;
		}

		foreach ( $excluded_dates as $excluded_date ) {
			$excluded_service  = ! empty( $excluded_date['service'] ) ? absint( $excluded_date['service'] ) : 0;
			$excluded_provider = ! empty( $excluded_date['provider'] ) ? absint( $excluded_date['provider'] ) : 0;

			if ( $excluded_service && absint( $service_id ) !== $excluded_service ) {
				continue;
			}

			if ( $excluded_provider && absint( $provider_id ) !== $excluded_provider ) {
				continue;
			}

			return true;
		}

		return false;

	}

	/**
	 * Return matching excluded-table values for an entity column.
	 *
	 * Empty and zero values are wildcards in existing excluded-date checks.
	 *
	 * @param int $entity_id Service or provider ID.
	 *
	 * @return array
	 */
	protected function get_excluded_entity_query_values( $entity_id ) {

		$values = [ '', 0 ];

		if ( $entity_id ) {
			$values[] = absint( $entity_id );
		}

		return array_values( array_unique( $values ) );

	}

	/**
	 * Parse Smart Filters date period/range value.
	 *
	 * @param mixed $period Period value.
	 *
	 * @return array
	 */
	protected function parse_period( $period ) {

		if ( is_array( $period ) ) {
			$period = array_values( $period );

			return $this->normalize_period(
				! empty( $period[0] ) ? $period[0] : 0,
				! empty( $period[1] ) ? $period[1] : ( ! empty( $period[0] ) ? $period[0] : 0 )
			);
		}

		if ( ! is_string( $period ) ) {
			return $this->normalize_period( $period, $period );
		}

		$period       = trim( $period );
		$date_pattern = '(?:\d{4}[-.]\d{1,2}[-.]\d{1,2}|\d{1,2}\/\d{1,2}\/\d{2,4}|\d{10,13})';

		if ( preg_match( '/^\s*(' . $date_pattern . ')(?:\s*-\s*(' . $date_pattern . '))?\s*$/', $period, $matches ) ) {
			return $this->normalize_period(
				$matches[1],
				! empty( $matches[2] ) ? $matches[2] : $matches[1]
			);
		}

		return [ 0, 0 ];

	}

	/**
	 * Normalize selected period bounds to UTC day timestamps.
	 *
	 * @param mixed $from Period start.
	 * @param mixed $to   Period end.
	 *
	 * @return array
	 */
	protected function normalize_period( $from, $to ) {

		$from = $this->normalize_day_timestamp( $from );
		$to   = $this->normalize_day_timestamp( $to );

		if ( $from && $to && $from > $to ) {
			[ $from, $to ] = [ $to, $from ];
		}

		return [ $from, $to ];

	}

	/**
	 * Normalize filter date to UTC midnight timestamp used by appointments tables.
	 *
	 * @param mixed $date Date value.
	 *
	 * @return int
	 */
	protected function normalize_day_timestamp( $date ) {

		if ( is_numeric( $date ) ) {
			$date = absint( $date );
			$date = 9999999999 < $date ? absint( $date / 1000 ) : $date;

			return gmmktime( 0, 0, 0, gmdate( 'n', $date ), gmdate( 'j', $date ), gmdate( 'Y', $date ) );
		}

		if ( ! is_string( $date ) || '' === $date ) {
			return 0;
		}

		if ( preg_match( '/^(\d{4})[-.](\d{1,2})[-.](\d{1,2})$/', $date, $matches ) ) {
			return gmmktime( 0, 0, 0, absint( $matches[2] ), absint( $matches[3] ), absint( $matches[1] ) );
		}

		if ( preg_match( '/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/', $date, $matches ) ) {
			$year = absint( $matches[3] );
			$year = 100 > $year ? 2000 + $year : $year;

			return gmmktime( 0, 0, 0, absint( $matches[1] ), absint( $matches[2] ), $year );
		}

		$date = strtotime( str_replace( '.', '-', $date ) );

		return $date ? gmmktime( 0, 0, 0, gmdate( 'n', $date ), gmdate( 'j', $date ), gmdate( 'Y', $date ) ) : 0;

	}

	/**
	 * Build a cache key for period-wide availability.
	 *
	 * @param string $type Availability type.
	 * @param int    $from          Period start timestamp.
	 * @param int    $to            Period end timestamp.
	 * @param array  $candidate_ids Optional candidate IDs.
	 *
	 * @return string
	 */
	protected function get_period_cache_key( $type, $from, $to, $candidate_ids = [] ) {

		$candidate_key = ! empty( $candidate_ids ) ? md5( implode( ',', $candidate_ids ) ) : 'all';

		return implode( ':', [ $type, absint( $from ), absint( $to ), $candidate_key ] );

	}

	/**
	 * Normalize IDs for comparisons and cache keys.
	 *
	 * @param array $ids Raw IDs.
	 *
	 * @return array
	 */
	protected function normalize_ids( $ids ) {

		$ids = array_map( 'absint', (array) $ids );
		$ids = array_values( array_unique( array_filter( $ids ) ) );
		sort( $ids );

		return $ids;

	}

	/**
	 * Return the maximum number of expensive date availability checks per pair.
	 *
	 * @return int
	 */
	protected function get_max_availability_days() {

		return absint( apply_filters(
			'jet-appointments-booking/filters/max-availability-days',
			self::MAX_AVAILABILITY_DAYS
		) );

	}

	/**
	 * Merge availability IDs with existing post__in query constraint.
	 *
	 * @param array $current Existing post__in.
	 * @param array $ids     Available IDs.
	 *
	 * @return array
	 */
	protected function merge_post_in( $current, $ids ) {

		$ids = $this->normalize_ids( $ids );

		if ( empty( $ids ) ) {
			return [ 0 ];
		}

		if ( empty( $current ) ) {
			return array_values( $ids );
		}

		$current = $this->normalize_ids( $current );
		$merged  = array_intersect( $current, $ids );

		return ! empty( $merged ) ? array_values( $merged ) : [ 0 ];

	}

	/**
	 * Returns the instance.
	 *
	 * @access public
	 *
	 * @return object
	 */
	public static function instance() {

		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;

	}

}

Jet_Smart_Filters::instance();
