<?php

/**
 * WP Product Feed Controller Class.
 *
 * @package WP Product Feed Manager/Application/Classes
 */

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

if ( ! class_exists( 'WPPFM_Feed_Controller' ) ) :

	/**
	 * Feed Controller Class.
	 *
	 * @since 1.10.0
	 */
	class WPPFM_Feed_Controller {

		/**
		 * Removes a feed id from the feed queue.
		 *
		 * @param string $feed_id the id of the feed to remove.
		 */
		public static function remove_id_from_feed_queue( $feed_id ) {
			$feed_queue = self::get_feed_queue();
			$key        = array_search( $feed_id, $feed_queue, true );

			if ( false !== $key ) {
				unset( $feed_queue[ $key ] );
				$feed_queue = array_values( $feed_queue ); // resort after unset
				update_site_option( 'wppfm_feed_queue', $feed_queue );

				if ( self::feed_queue_is_empty() ) {
					wppfm_clear_feed_process_data();
				}
			}
		}

		/**
		 * Adds a feed id to the feed queue.
		 *
		 * @param string $feed_id the id of the feed to add.
		 */
		public static function add_id_to_feed_queue( $feed_id ) {
			$feed_queue = self::get_feed_queue();

			if ( ! in_array( $feed_id, $feed_queue, true ) ) {
				$feed_queue[] = $feed_id;
				update_site_option( 'wppfm_feed_queue', $feed_queue );
			}
		}

		/**
		 * Gets the next feed id from the feed queue.
		 *
		 * @return string with the next feed id in the feed queue. False if no id is found.
		 */
		public static function get_next_id_from_feed_queue() {
			$feed_queue = self::get_feed_queue();

			return count( $feed_queue ) > 0 ? $feed_queue[0] : false;
		}

		/**
		 * Empties the feed queue.
		 */
		public static function clear_feed_queue() {
			delete_option( 'wppfm_feed_queue' );
			update_site_option( 'wppfm_feed_queue', array() );
		}

		/**
		 * Returns the transient key used to serialize feed startup for a single feed id.
		 *
		 * This short-lived guard closes the gap between "feed start requested" and
		 * "background worker lock acquired", where a second request could otherwise
		 * prepare the same feed again and create a duplicate batch key.
		 *
		 * @param string $feed_id The feed id that is starting.
		 *
		 * @return string
		 */
		private static function get_feed_startup_lock_key( $feed_id ) {
			return 'wppfm_feed_startup_lock_' . sanitize_key( (string) $feed_id );
		}

		/**
		 * Attempts to acquire the per-feed startup lock.
		 *
		 * @param string $feed_id The feed id that is starting.
		 *
		 * @return bool True when the startup lock was acquired.
		 */
		public static function acquire_feed_startup_lock( $feed_id ) {
			if ( '' === (string) $feed_id ) {
				return false;
			}

			$lock_key = self::get_feed_startup_lock_key( $feed_id );
			$existing = get_site_transient( $lock_key );

			if ( ! empty( $existing ) ) {
				return false;
			}

			$ttl = max( MINUTE_IN_SECONDS, intval( apply_filters( 'wppfm_feed_startup_lock_ttl', 5 * MINUTE_IN_SECONDS, $feed_id ) ) );

			return (bool) set_site_transient(
				$lock_key,
				array(
					'feed_id' => (string) $feed_id,
					'ts'      => time(),
				),
				$ttl
			);
		}

		/**
		 * Releases the per-feed startup lock.
		 *
		 * @param string $feed_id The feed id whose startup lock should be cleared.
		 */
		public static function release_feed_startup_lock( $feed_id ) {
			if ( '' === (string) $feed_id ) {
				return;
			}

			delete_site_transient( self::get_feed_startup_lock_key( $feed_id ) );
		}

		/**
		 * Returns the transient key used to track an in-flight next-batch handoff for a feed.
		 *
		 * The marker bridges the small gap where one batch intentionally releases the worker lock
		 * before the next async loopback request has acquired it.
		 *
		 * @param string $feed_id Feed id that is being handed off.
		 *
		 * @return string
		 */
		private static function get_feed_handoff_marker_key( $feed_id ) {
			return 'wppfm_feed_handoff_marker_' . sanitize_key( (string) $feed_id );
		}

		/**
		 * Marks a feed as being in the middle of a batch-to-batch handoff.
		 *
		 * @param string $feed_id Feed id being handed off.
		 *
		 * @return void
		 */
		public static function mark_feed_handoff_pending( $feed_id ) {
			if ( '' === (string) $feed_id ) {
				return;
			}

			$ttl = max( MINUTE_IN_SECONDS, intval( apply_filters( 'wppfm_feed_handoff_marker_ttl', 3 * MINUTE_IN_SECONDS, $feed_id ) ) );

			set_site_transient(
				self::get_feed_handoff_marker_key( $feed_id ),
				array(
					'feed_id' => (string) $feed_id,
					'ts'      => time(),
				),
				$ttl
			);
		}

		/**
		 * Clears the pending handoff marker for a feed.
		 *
		 * @param string $feed_id Feed id whose marker should be removed.
		 *
		 * @return void
		 */
		public static function clear_feed_handoff_marker( $feed_id ) {
			if ( '' === (string) $feed_id ) {
				return;
			}

			delete_site_transient( self::get_feed_handoff_marker_key( $feed_id ) );
		}

		/**
		 * Returns true when a feed has a fresh handoff marker.
		 *
		 * @param string $feed_id Feed id to inspect.
		 *
		 * @return bool
		 */
		public static function feed_handoff_marker_is_active_for_feed( $feed_id ) {
			if ( '' === (string) $feed_id ) {
				return false;
			}

			$payload = get_site_transient( self::get_feed_handoff_marker_key( $feed_id ) );

			if ( ! is_array( $payload ) || empty( $payload['ts'] ) ) {
				return false;
			}

			$ttl = max( MINUTE_IN_SECONDS, intval( apply_filters( 'wppfm_feed_handoff_marker_ttl', 3 * MINUTE_IN_SECONDS, $feed_id ) ) );

			if ( ( time() - intval( $payload['ts'] ) ) > $ttl ) {
				self::clear_feed_handoff_marker( $feed_id );
				return false;
			}

			return true;
		}

		/**
		 * Returns true when any active or queued feed still has a fresh handoff marker.
		 *
		 * @return bool
		 */
		public static function background_process_handoff_is_active() {
			$candidate_feed_ids = array();
			$active_feed_id     = self::get_active_batch_feed_id();
			$queued_feed_id     = self::get_next_id_from_feed_queue();

			if ( $active_feed_id ) {
				$candidate_feed_ids[] = (string) $active_feed_id;
			}

			if ( $queued_feed_id ) {
				$candidate_feed_ids[] = (string) $queued_feed_id;
			}

			$candidate_feed_ids = array_values( array_unique( array_filter( $candidate_feed_ids ) ) );

			foreach ( $candidate_feed_ids as $feed_id ) {
				if ( self::feed_handoff_marker_is_active_for_feed( $feed_id ) ) {
					return true;
				}
			}

			return false;
		}

		/**
		 * Returns true when the currently active batch metadata belongs to the provided feed.
		 *
		 * @param string $feed_id Feed id to compare against the active batch metadata.
		 *
		 * @return bool
		 */
		public static function active_batch_belongs_to_feed( $feed_id ) {
			if ( '' === (string) $feed_id ) {
				return false;
			}

			return (string) $feed_id === (string) self::get_active_batch_feed_id();
		}

		/**
		 * Returns true when startup should not prepare the same feed again during a live handoff.
		 *
		 * This is intentionally narrower than a generic "active batch exists" check so legitimate
		 * watchdog recovery can still re-enter startup after a real crash.
		 *
		 * @param string $feed_id Feed id that is about to start.
		 *
		 * @return bool
		 */
		public static function should_block_same_feed_startup( $feed_id ) {
			if ( ! self::active_batch_belongs_to_feed( $feed_id ) ) {
				return false;
			}

			if ( self::feed_handoff_marker_is_active_for_feed( $feed_id ) ) {
				return true;
			}

			$process_lock = get_site_transient( 'wppfm_feed_generation_process_process_lock' );

			if ( $process_lock ) {
				return true;
			}

			return self::background_process_heartbeat_is_fresh();
		}

		/**
		 * Checks if the feed queue is empty.
		 *
		 * @return bool true if the feed is empty.
		 */
		public static function feed_queue_is_empty() {
			$queue = self::get_feed_queue();

			return count( $queue ) < 1;
		}

		/**
		 * Returns the number of product ids that are still in the product queue.
		 *
		 * @since 2.3.0
		 * @since 3.13.0 - Added a check if the $ids_in_product_queue is set.
		 * @return int number of product ids still in the product queue.
		 */
		public static function nr_ids_remaining_in_product_queue() {
			$key = get_site_option( 'wppfm_background_process_key' );
			if ( empty( $key ) || ! is_string( $key ) ) {
				return 0;
			}

			$batch_metadata = get_site_option( 'wppfm_batch_metadata_' . $key, array() );
			$incremental    = is_array( $batch_metadata ) && isset( $batch_metadata['incremental_state'] ) && is_array( $batch_metadata['incremental_state'] )
				? $batch_metadata['incremental_state']
				: array();
			$is_incremental = isset( $incremental['mode'] ) && 'incremental' === $incremental['mode'];

			$total_products = $is_incremental && isset( $incremental['total_products_to_process'] )
				? max( 0, intval( $incremental['total_products_to_process'] ) )
				: 0;
			$handled_items  = get_transient( 'wppfm_nr_of_handled_items' );
			$handled_items  = false === $handled_items ? 0 : max( 0, intval( $handled_items ) );

			if ( $is_incremental ) {
				return max( 0, $total_products - $handled_items );
			}

			// Fallback for non-incremental queues (for example promotions feeds).
			$queue = get_site_option( $key, array() );
			if ( ! is_array( $queue ) || empty( $queue ) ) {
				return 0;
			}

			$remaining = 0;
			foreach ( $queue as $queue_item ) {
				if ( is_numeric( $queue_item ) ) {
					++$remaining;
					continue;
				}

				if ( is_array( $queue_item ) && array_key_exists( 'product_id', $queue_item ) && is_numeric( $queue_item['product_id'] ) ) {
					++$remaining;
				}
			}

			return $remaining;
		}

		/**
		 * Sets the background_process_is_running option.
		 *
		 * @since 3.11.0 switched from using an option to using a transient to store the process status.
		 * @since 3.18.0 records the process start timestamp to bridge the lock acquisition grace period.
		 * @param bool $set required setting. Default false.
		 */
		public static function set_feed_processing_flag( $set = false ) {
			$status = false !== $set ? 'true' : 'false';
			set_site_transient( 'wppfm_background_process_is_active', $status, DAY_IN_SECONDS );

			if ( 'true' === $status ) {
				// @since 3.18.0 recorded process start time to bridge the lock acquisition grace period.
				set_site_transient( 'wppfm_background_process_started_at', time(), DAY_IN_SECONDS );
			} else {
				// @since 3.18.0 ensure any previously recorded start timestamp is cleared when processing stops.
				delete_site_transient( 'wppfm_background_process_started_at' );
			}
		}

		/**
		 * Get the background_process_is_active status option.
		 *
		 * @since 3.11.0 switched from using an option to using a transient to store the process status.
		 * @since 3.18.0 requires a valid background-process lock or a short grace period after startup.
		 * @return bool true if the process is still running.
		 */
		public static function feed_is_processing() {
			$status   = get_site_transient( 'wppfm_background_process_is_active' );
			$use_lock = apply_filters( 'wppfm_use_lock_for_processing_flag', true );

			if ( 'true' !== $status ) {
				return false;
			}

			if ( ! $use_lock ) {
				return true;
			}

			$process_lock = get_site_transient( 'wppfm_feed_generation_process_process_lock' );

			if ( $process_lock ) {
				return true;
			}

			// A fresh handoff marker means the previous batch deliberately released its lock
			// and is waiting for the next loopback worker to take over.
			if ( self::background_process_handoff_is_active() ) {
				return true;
			}

			$start_timestamp = intval( get_site_transient( 'wppfm_background_process_started_at' ) );
			$grace_period    = apply_filters( 'wppfm_feed_processing_lock_grace_seconds', MINUTE_IN_SECONDS );

			if ( $start_timestamp > 0 && ( time() - $start_timestamp ) <= max( 10, intval( $grace_period ) ) ) {
				// @since 3.18.0 apply a grace period after start to allow the background worker to obtain the lock.
				return true;
			}

			// @since 3.18.0+ Heartbeat fallback for environments where transients may be evicted early.
			// This helps prevent overlapping workers and watchdog restarts when a process is still active but the lock transient vanished.
			if ( self::background_process_heartbeat_is_fresh() ) {
				return true;
			}

			return false;
		}

		/**
		 * Returns the durable background-process heartbeat payload (if present).
		 *
		 * @since 3.18.0
		 *
		 * @return array|null
		 */
		public static function get_background_process_heartbeat() {
			$heartbeat = get_site_option( 'wppfm_feed_generation_process_process_heartbeat' );

			return is_array( $heartbeat ) ? $heartbeat : null;
		}

		/**
		 * Returns true when the durable heartbeat indicates a process is likely still active.
		 *
		 * @since 3.18.0
		 *
		 * @return bool
		 */
		public static function background_process_heartbeat_is_fresh() {
			$heartbeat = self::get_background_process_heartbeat();

			if ( ! $heartbeat || empty( $heartbeat['ts'] ) ) {
				return false;
			}

			$ttl = apply_filters( 'wppfm_feed_generation_process_heartbeat_ttl', 10 * MINUTE_IN_SECONDS );
			$ttl = max( 60, intval( $ttl ) );

			return ( time() - intval( $heartbeat['ts'] ) ) <= $ttl;
		}

		/**
		 * Checks if a running feed size is still growing, in order to identify a failing feed process.
		 *
		 * @since 2.2.0.
		 *
		 * @param   string $feed_file String with the full path and name of the feed file.
		 *
		 * @return  boolean false if the feed still grows, true if it stopped growing for a certain time.
		 */
		/**
		 * Resolves the path the background worker is writing to for a feed, using active batch metadata.
		 *
		 * For regular feeds that use a temporary artifact, this returns the temporary path so stalled-file
		 * detection tracks the real output file instead of the unchanged published file.
		 *
		 * @param string $feed_id Feed id to match against the active batch.
		 *
		 * @since 3.23.0
		 *
		 * @return string Active file path, or empty string when no matching batch metadata exists.
		 */
		public static function resolve_active_feed_generation_file_path_from_batch_metadata( $feed_id ) {
			$feed_id = (string) $feed_id;
			$key     = get_site_option( 'wppfm_background_process_key' );

			if ( ! $key || ! is_string( $key ) ) {
				return '';
			}

			$batch_metadata = get_site_option( 'wppfm_batch_metadata_' . $key, array() );

			if ( ! is_array( $batch_metadata ) ) {
				return '';
			}

			$meta_feed_id = isset( $batch_metadata['feed_id'] ) ? (string) $batch_metadata['feed_id'] : '';

			if ( '' === $meta_feed_id || $meta_feed_id !== $feed_id ) {
				return '';
			}

			if ( ! empty( $batch_metadata['file_path'] ) && is_string( $batch_metadata['file_path'] ) ) {
				return $batch_metadata['file_path'];
			}

			return '';
		}

		/**
		 * Returns the feed id stored in the currently active batch metadata, when available.
		 *
		 * @return string
		 */
		private static function get_active_batch_feed_id() {
			$key = get_site_option( 'wppfm_background_process_key' );

			if ( ! $key || ! is_string( $key ) ) {
				return '';
			}

			$batch_metadata = get_site_option( 'wppfm_batch_metadata_' . $key, array() );

			if ( ! is_array( $batch_metadata ) || empty( $batch_metadata['feed_id'] ) ) {
				return '';
			}

			return (string) $batch_metadata['feed_id'];
		}

		public static function feed_processing_failed( $feed_file ) {

			if ( '' === $feed_file ) {
				return null;
			}
			// Retrieve the last known growth-monitor snapshot for this feed file.
			$monitor_data          = self::get_feed_growth_monitor_data( $feed_file );
			$prev_feed_size        = $monitor_data['size'];
			$prev_feed_time_stamp  = $monitor_data['timestamp'];
			$prev_handled_items    = $monitor_data['processed']; // Now represents \"handled\" items (added or filtered).
			$bonus_delay           = $monitor_data['bonus_delay'];

			$curr_feed_size = file_exists( $feed_file ) ? filesize( $feed_file ) : false;

			// If the file does not exist, treat as failure only when the worker is not
			// actively reporting progress (avoids races while the file is recreated).
			if ( false === $curr_feed_size ) {
				if ( self::background_process_heartbeat_is_fresh() ) {
					return false;
				}
				delete_transient( 'wppfm_feed_file_size' ); // Reset the counter.
				return true;
			}

			// Use the handled-items counter for stall detection. This counter is
			// increased for every queue entry that is processed, regardless of
			// whether the product is added to the feed or filtered out. This makes
			// the watchdog more robust on feeds with heavy filtering.
			$current_handled_items = self::get_handled_items_counter();

			$feed_grew   = $curr_feed_size > $prev_feed_size;
			$items_grew  = $current_handled_items > $prev_handled_items;

			if ( $feed_grew || $items_grew ) {
				// When the process clearly made progress (either the file grew or more
				// items where handled), extend the allowed delay before we consider the
				// feed stalled. This keeps the behaviour compatible with the previous
				// implementation while using the more robust handled-items signal.
				$bonus = $items_grew ? apply_filters( 'wppfm_failed_detection_alpha', 60, $feed_file ) : 0;

				self::persist_feed_growth_monitor_data(
					array(
						'size'        => $curr_feed_size,
						'timestamp'   => time(),
						'file'        => $feed_file,
						'processed'   => $current_handled_items,
						'bonus_delay' => $bonus,
					)
				);

				return false;
			}

			// Safety guard: when there are no ids left in the product queue, the
			// feed process has effectively finished and any lack of recent file
		// growth should not be treated as a hard failure. This prevents false
			// positives on hosts where the final growth check runs after the
			// queue has been drained or when the last part of the run only
			// contains filtered products.
			if ( 0 === self::nr_ids_remaining_in_product_queue() ) {
				return false;
			}

			$base_delay = apply_filters( 'wppfm_failed_detection_base_delay', WPPFM_DELAY_FAILED_LABEL, $feed_file );
			$base_delay = max( 0, intval( $base_delay ) );
			$bonus_delay = max( 0, intval( $bonus_delay ) );
			$delay = $base_delay + $bonus_delay;

			// And the delay time has passed.
			if ( (int) $prev_feed_time_stamp + $delay < time() ) {
				// A fresh heartbeat means the background worker is still alive; do not
				// flag a stall while it may simply be between writes or batches.
				if ( self::background_process_heartbeat_is_fresh() ) {
					return false;
				}
				delete_transient( 'wppfm_feed_file_size' ); // Reset the counter.
				return true;
			}

			return false;
		}

		/**
		 * Updates the timer that is used as reference to monitor if a file is growing during the feed production process.
		 *
		 * @since 2.11.0
		 */
		public static function update_file_grow_monitoring_timer() {
			// Get the current monitor data.
			$grow_monitor_array = get_transient( 'wppfm_feed_file_size' );

			if ( ! $grow_monitor_array ) { // The wppfm_feed_file_size is not set in the non-background mode.
				return;
			}

			$grow_monitor_data = explode( '|', $grow_monitor_array );
			$prev_size         = isset( $grow_monitor_data[0] ) ? intval( $grow_monitor_data[0] ) : 0;
			$tracked_file      = $grow_monitor_data[2] ?? '';
			$prev_processed    = isset( $grow_monitor_data[3] ) ? intval( $grow_monitor_data[3] ) : self::get_processed_products_counter();
			$bonus_delay       = isset( $grow_monitor_data[4] ) ? intval( $grow_monitor_data[4] ) : 0;

			// Reset the timer part of the monitor while keeping the other data intact.
			self::persist_feed_growth_monitor_data(
				array(
					'size'        => $prev_size,
					'timestamp'   => time(),
					'file'        => $tracked_file,
					'processed'   => $prev_processed,
					'bonus_delay' => $bonus_delay,
				)
			);
		}

		/**
		 * Returns the current feed queue.
		 *
		 * @return array with feed ids in the queue or an empty array.
		 */
		protected static function get_feed_queue() {
			return get_site_option( 'wppfm_feed_queue', array() );
		}

		/**
		 * Returns the processed products counter from the transient store.
		 *
		 * @since 3.18.0
		 * @return int
		 */
		private static function get_processed_products_counter() {
			$processed_products = get_transient( 'wppfm_nr_of_processed_products' );

			return false === $processed_products ? 0 : intval( $processed_products );
		}

		/**
		 * Returns the handled-items counter from the transient store.
		 *
		 * This counter tracks every queue entry that has been processed by the
		 * background worker (added to the feed or filtered out). It is used by
		 * the stalled-feed watchdog logic to distinguish \"no progress\" from
		 * \"only filtered products left\" on large feeds.
		 *
		 * @since 3.19.x
		 * @return int
		 */
		private static function get_handled_items_counter() {
			$handled_items = get_transient( 'wppfm_nr_of_handled_items' );

			return false === $handled_items ? 0 : intval( $handled_items );
		}

		/**
		 * Retrieves or initializes the feed growth monitor data.
		 *
		 * @since 3.18.0
		 *
		 * @param string $feed_file The feed file currently being processed.
		 *
		 * @return array
		 */
		private static function get_feed_growth_monitor_data( $feed_file ) {
			$transient_value = get_transient( 'wppfm_feed_file_size' );

			if ( false === $transient_value ) {
				$data = array(
					'size'        => 0,
					'timestamp'   => time(),
					'file'        => $feed_file,
					// For new runs, initialise the processed field using the handled-items
					// counter so the growth monitor reflects overall queue progress.
					'processed'   => self::get_handled_items_counter(),
					'bonus_delay' => 0,
				);
				self::persist_feed_growth_monitor_data( $data );

				return $data;
			}

			$stored = explode( '|', $transient_value );

			// Prepare a normalized dataset (handles both legacy 3-part payloads and the new 5-part payloads).
			$data = array(
				'size'        => isset( $stored[0] ) ? intval( $stored[0] ) : 0,
				'timestamp'   => isset( $stored[1] ) ? intval( $stored[1] ) : time(),
				'file'        => $stored[2] ?? $feed_file,
				// The stored \"processed\" slot now represents handled items. For legacy
				// payloads that were still based on \"products added\", fall back to the
				// current handled-items counter so the monitor has a sane baseline.
				'processed'   => isset( $stored[3] ) ? intval( $stored[3] ) : self::get_handled_items_counter(),
				'bonus_delay' => isset( $stored[4] ) ? intval( $stored[4] ) : 0,
			);

			if ( $feed_file !== $data['file'] && '' !== $feed_file ) {
				$data['size']        = 0;
				$data['timestamp']   = time();
				$data['file']        = $feed_file;
				$data['processed']   = self::get_handled_items_counter();
				$data['bonus_delay'] = 0;
				self::persist_feed_growth_monitor_data( $data );
			}

			return $data;
		}

		/**
		 * Persists the feed growth monitor data structure into the transient store.
		 *
		 * @since 3.18.0
		 *
		 * @param array $data Normalised data set.
		 */
		private static function persist_feed_growth_monitor_data( $data ) {
			$payload = sprintf(
				'%d|%d|%s|%d|%d',
				max( 0, intval( $data['size'] ?? 0 ) ),
				max( 0, intval( $data['timestamp'] ?? time() ) ),
				$data['file'] ?? '',
				max( 0, intval( $data['processed'] ?? 0 ) ),
				max( 0, intval( $data['bonus_delay'] ?? 0 ) )
			);

			set_transient( 'wppfm_feed_file_size', $payload, WPPFM_TRANSIENT_LIVE );
		}
	}

endif;
