<?php
/**
 * Sync queue — wraps Action Scheduler (bundled with WooCommerce).
 *
 * @package StoreSheet
 */

declare( strict_types=1 );

namespace StoreSheet\Queue;

defined( 'ABSPATH' ) || exit;

use StoreSheet\Sync\RowMapper;
use StoreSheet\Sync\WcToSheets\CouponPusher;
use StoreSheet\Sync\WcToSheets\OrderItemsPusher;
use StoreSheet\Sync\WcToSheets\OrderPusher;
use StoreSheet\Sync\WcToSheets\ProductPusher;

/**
 * Enqueues and processes async sync jobs via Action Scheduler.
 */
class SyncQueue {

	private const GROUP = 'storesheet';

	public function init(): void {
		add_action( 'storesheet_push_product', array( $this, 'process_product' ) );
		add_action( 'storesheet_push_order', array( $this, 'process_order' ) );
		add_action( 'storesheet_push_coupon', array( $this, 'process_coupon' ) );
		add_action( 'storesheet_scheduled_sync', array( $this, 'process_scheduled_sync' ) );
		add_action( 'storesheet_resync_orders_chunk', array( $this, 'process_resync_orders_chunk' ) );
		add_action( 'storesheet_daily_digest', array( $this, 'process_daily_digest' ) );
		add_action( 'storesheet_sync_order_items', array( $this, 'process_order_items' ) );
	}

	// -------------------------------------------------------------------------
	// Enqueue
	// -------------------------------------------------------------------------

	public function enqueue_product( int $product_id ): void {
		if ( ! $this->action_scheduler_available() ) {
			return;
		}

		if ( as_has_scheduled_action( 'storesheet_push_product', array( $product_id ), self::GROUP ) ) {
			return;
		}

		as_enqueue_async_action( 'storesheet_push_product', array( $product_id ), self::GROUP );
	}

	public function enqueue_order( int $order_id ): void {
		if ( ! $this->action_scheduler_available() ) {
			return;
		}

		if ( as_has_scheduled_action( 'storesheet_push_order', array( $order_id ), self::GROUP ) ) {
			return;
		}

		as_enqueue_async_action( 'storesheet_push_order', array( $order_id ), self::GROUP );
	}

	/**
	 * Enqueue an Order Items push for a single order (dedup).
	 *
	 * @since 1.2.0
	 * @param int $order_id
	 */
	public function enqueue_order_items( int $order_id ): void {
		if ( ! $this->action_scheduler_available() ) {
			return;
		}

		if ( as_has_scheduled_action( 'storesheet_sync_order_items', array( $order_id ), self::GROUP ) ) {
			return;
		}

		as_enqueue_async_action( 'storesheet_sync_order_items', array( $order_id ), self::GROUP );
	}

	public function enqueue_coupon( int $coupon_id ): void {
		if ( ! $this->action_scheduler_available() ) {
			return;
		}

		if ( as_has_scheduled_action( 'storesheet_push_coupon', array( $coupon_id ), self::GROUP ) ) {
			return;
		}

		as_enqueue_async_action( 'storesheet_push_coupon', array( $coupon_id ), self::GROUP );
	}

	// -------------------------------------------------------------------------
	// Process (called by Action Scheduler)
	// -------------------------------------------------------------------------

	public function process_product( int $product_id ): void {
		/**
		 * Filter whether a resource should sync. Add-ons (ConditionalSync) return
		 * false to skip items that do not match their rules.
		 *
		 * @since 1.1.0
		 * @param bool $should_sync Default true.
		 * @param int  $product_id  The object ID.
		 */
		if ( ! apply_filters( 'storesheet_should_sync_product', true, $product_id ) ) {
			return;
		}

		$pusher = new ProductPusher( storesheet()->sheets_client(), new RowMapper() );

		/**
		 * Fires immediately before a resource is pushed to Sheets.
		 *
		 * @since 1.1.0
		 * @param string $resource Resource type ('product').
		 * @param int    $id       Resource ID.
		 */
		do_action( 'storesheet_before_push', 'product', $product_id );

		$result = $pusher->push( $product_id );

		/**
		 * Fires immediately after a resource push to Sheets completes.
		 *
		 * @since 1.1.0
		 * @param string        $resource Resource type ('product').
		 * @param int           $id       Resource ID.
		 * @param true|\WP_Error $result  Push result.
		 */
		do_action( 'storesheet_after_push', 'product', $product_id, $result );

		if ( is_wp_error( $result ) ) {
			storesheet()->log( "Product push failed [{$product_id}]: " . $result->get_error_message(), 'error' );
			throw new \RuntimeException( $result->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}
	}

	public function process_order( int $order_id ): void {
		/**
		 * Filter whether a resource should sync. Add-ons (ConditionalSync) return
		 * false to skip items that do not match their rules.
		 *
		 * @since 1.1.0
		 * @param bool $should_sync Default true.
		 * @param int  $order_id    The object ID.
		 */
		if ( ! apply_filters( 'storesheet_should_sync_order', true, $order_id ) ) {
			return;
		}

		$pusher = new OrderPusher( storesheet()->sheets_client(), new RowMapper() );

		/**
		 * Fires immediately before a resource is pushed to Sheets.
		 *
		 * @since 1.1.0
		 * @param string $resource Resource type ('order').
		 * @param int    $id       Resource ID.
		 */
		do_action( 'storesheet_before_push', 'order', $order_id );

		$result = $pusher->push( $order_id );

		/**
		 * Fires immediately after a resource push to Sheets completes.
		 *
		 * @since 1.1.0
		 * @param string        $resource Resource type ('order').
		 * @param int           $id       Resource ID.
		 * @param true|\WP_Error $result  Push result.
		 */
		do_action( 'storesheet_after_push', 'order', $order_id, $result );

		if ( is_wp_error( $result ) ) {
			storesheet()->log( "Order push failed [{$order_id}]: " . $result->get_error_message(), 'error' );
			throw new \RuntimeException( $result->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}
	}

	/**
	 * Action Scheduler worker — push one order's line items.
	 *
	 * @since 1.2.0
	 * @param int $order_id
	 */
	public function process_order_items( int $order_id ): void {
		$pusher = new OrderItemsPusher( storesheet()->sheets_client(), new RowMapper() );
		$pusher->push( (int) $order_id );
	}

	public function process_coupon( int $coupon_id ): void {
		/**
		 * Filter whether a resource should sync. Add-ons (ConditionalSync) return
		 * false to skip items that do not match their rules.
		 *
		 * @since 1.1.0
		 * @param bool $should_sync Default true.
		 * @param int  $coupon_id   The object ID.
		 */
		if ( ! apply_filters( 'storesheet_should_sync_coupon', true, $coupon_id ) ) {
			return;
		}

		$pusher = new CouponPusher( storesheet()->sheets_client(), new RowMapper() );

		/**
		 * Fires immediately before a resource is pushed to Sheets.
		 *
		 * @since 1.1.0
		 * @param string $resource Resource type ('coupon').
		 * @param int    $id       Resource ID.
		 */
		do_action( 'storesheet_before_push', 'coupon', $coupon_id );

		$result = $pusher->push( $coupon_id );

		/**
		 * Fires immediately after a resource push to Sheets completes.
		 *
		 * @since 1.1.0
		 * @param string        $resource Resource type ('coupon').
		 * @param int           $id       Resource ID.
		 * @param true|\WP_Error $result  Push result.
		 */
		do_action( 'storesheet_after_push', 'coupon', $coupon_id, $result );

		if ( is_wp_error( $result ) ) {
			storesheet()->log( "Coupon push failed [{$coupon_id}]: " . $result->get_error_message(), 'error' );
			throw new \RuntimeException( $result->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}
	}

	public function process_scheduled_sync(): void {
		/**
		 * Filter which resources the scheduled sync processes.
		 *
		 * @since 1.1.0
		 * @param string[] $resources Default free resources.
		 */
		$resources = apply_filters( 'storesheet_sync_resources', array( 'product', 'order', 'coupon' ) );

		$sheets = storesheet()->sheets_client();
		$mapper = new RowMapper();

		( new ProductPusher( $sheets, $mapper ) )->push_all();

		$last_sync = get_option( 'storesheet_last_sync', '1970-01-01 00:00:00' );
		( new OrderPusher( $sheets, $mapper ) )->push_recent( $last_sync );

		( new CouponPusher( $sheets, $mapper ) )->push_all();

		/*
		 * The free build only ever pushes product/order/coupon above. Any
		 * additional resource an add-on contributes via the
		 * `storesheet_sync_resources` filter (e.g. Pro's 'variation' or
		 * 'customer') is dispatched here instead, so Pro can hook its own
		 * pusher without this method needing to know about it.
		 */
		foreach ( array_diff( $resources, array( 'product', 'order', 'coupon' ) ) as $extra_resource ) {
			do_action( "storesheet_scheduled_sync_{$extra_resource}" );
		}

		update_option( 'storesheet_last_sync', current_time( 'mysql' ), false );

		storesheet()->log( 'Scheduled sync complete.' );
	}

	/**
	 * Re-push one page of orders into the (per-status) tabs, then reschedule the
	 * next page while a full batch keeps coming. Fetches IDs to decide whether to
	 * continue (independent of how many pushes succeeded), so a transient push
	 * failure never truncates the migration.
	 *
	 * @param int $offset
	 */
	public function process_resync_orders_chunk( int $offset = 0 ): void {
		/**
		 * Filter the order re-sync page size.
		 *
		 * @since 1.2.0
		 * @param int $batch Default 50.
		 */
		$batch = (int) apply_filters( 'storesheet_resync_batch_size', 50 );

		$ids = wc_get_orders(
			array(
				'limit'   => $batch,
				'offset'  => $offset,
				'orderby' => 'ID',
				'order'   => 'ASC',
				'return'  => 'ids',
			)
		);

		if ( empty( $ids ) || ! is_array( $ids ) ) {
			return;
		}

		$pusher       = new OrderPusher( storesheet()->sheets_client(), new RowMapper() );
		$settings     = get_option( 'storesheet_settings', array() );
		$items_pusher = ! empty( $settings['sync_order_items'] )
			? new OrderItemsPusher( storesheet()->sheets_client(), new RowMapper() )
			: null;

		foreach ( $ids as $id ) {
			$pusher->push( (int) $id );

			if ( $items_pusher ) {
				$items_pusher->push( (int) $id );
			}
		}

		if ( count( $ids ) >= $batch && $this->action_scheduler_available() ) {
			as_enqueue_async_action(
				'storesheet_resync_orders_chunk',
				array( $offset + $batch ),
				self::GROUP
			);
		}
	}

	/**
	 * Aggregate the last 24h of sync activity and dispatch the daily digest.
	 * Skips entirely when nothing happened. Never throws — a bad aggregate
	 * degrades to an empty (skipped) summary so Action Scheduler does not retry.
	 *
	 * @since 1.2.0
	 */
	public function process_daily_digest(): void {
		global $wpdb;

		$since = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );

		$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$wpdb->prepare(
				"SELECT resource, status, COUNT(*) AS cnt FROM {$wpdb->prefix}storesheet_sync_log WHERE created_at >= %s GROUP BY resource, status",
				$since
			),
			ARRAY_A
		);

		$summary = $this->build_digest_summary( is_array( $rows ) ? $rows : array() );

		if ( 0 === $summary['total'] ) {
			return;
		}

		( new \StoreSheet\Notifications\DigestNotifier() )->send( $summary );
	}

	/**
	 * Fold GROUP BY resource/status rows into the canonical digest summary.
	 * 'pending' (in-flight) rows are ignored; only success/failed/skipped count.
	 *
	 * @since 1.2.0
	 *
	 * @param array<int,array<string,mixed>> $rows
	 * @return array<string,mixed>
	 */
	private function build_digest_summary( array $rows ): array {
		$resources    = array(
			'product' => array(
				'success' => 0,
				'failed'  => 0,
				'skipped' => 0,
			),
			'order'   => array(
				'success' => 0,
				'failed'  => 0,
				'skipped' => 0,
			),
			'coupon'  => array(
				'success' => 0,
				'failed'  => 0,
				'skipped' => 0,
			),
		);
		$total        = 0;
		$total_failed = 0;

		foreach ( $rows as $row ) {
			$resource = (string) ( $row['resource'] ?? '' );
			$status   = (string) ( $row['status'] ?? '' );
			$cnt      = (int) ( $row['cnt'] ?? 0 );

			if ( ! in_array( $status, array( 'success', 'failed', 'skipped' ), true ) ) {
				continue;
			}

			$total += $cnt;
			if ( 'failed' === $status ) {
				$total_failed += $cnt;
			}
			if ( isset( $resources[ $resource ][ $status ] ) ) {
				$resources[ $resource ][ $status ] += $cnt;
			}
		}

		return array(
			'window_hours' => 24,
			'resources'    => $resources,
			'total'        => $total,
			'total_failed' => $total_failed,
		);
	}

	// -------------------------------------------------------------------------
	// Cron schedule
	// -------------------------------------------------------------------------

	public static function schedule(): void {
		if ( ! as_has_scheduled_action( 'storesheet_scheduled_sync', array(), self::GROUP ) ) {
			$settings = get_option( 'storesheet_settings', array() );
			$interval = (int) ( $settings['sync_interval'] ?? 15 );

			as_schedule_recurring_action(
				time() + ( $interval * MINUTE_IN_SECONDS ),
				$interval * MINUTE_IN_SECONDS,
				'storesheet_scheduled_sync',
				array(),
				self::GROUP
			);
		}
	}

	public static function unschedule(): void {
		as_unschedule_all_actions( 'storesheet_scheduled_sync', array(), self::GROUP );
	}

	/**
	 * @since 1.2.0
	 */
	public static function schedule_digest(): void {
		if ( ! function_exists( 'as_has_scheduled_action' ) ) {
			return;
		}
		if ( ! as_has_scheduled_action( 'storesheet_daily_digest', array(), self::GROUP ) ) {
			as_schedule_recurring_action(
				time() + DAY_IN_SECONDS,
				DAY_IN_SECONDS,
				'storesheet_daily_digest',
				array(),
				self::GROUP
			);
		}
	}

	/**
	 * @since 1.2.0
	 */
	public static function unschedule_digest(): void {
		if ( function_exists( 'as_unschedule_all_actions' ) ) {
			as_unschedule_all_actions( 'storesheet_daily_digest', array(), self::GROUP );
		}
	}

	private function action_scheduler_available(): bool {
		return function_exists( 'as_enqueue_async_action' );
	}
}
