<?php
/**
 * WooCommerce Square
 *
 * This source file is subject to the GNU General Public License v3.0
 * that is bundled with this package in the file license.txt.
 * It is also available through the world-wide-web at this URL:
 * http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@woocommerce.com so we can send you a copy immediately.
 *
 * DISCLAIMER
 *
 * Do not edit or add to this file if you wish to upgrade WooCommerce Square to newer
 * versions in the future. If you wish to customize WooCommerce Square for your
 * needs please refer to https://docs.woocommerce.com/document/woocommerce-square/
 *
 * @author    WooCommerce
 * @copyright Copyright: (c) 2019, Automattic, Inc.
 * @license   http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
 */

namespace WooCommerce\Square\Sync;

use Square\Models\SearchCatalogObjectsResponse;
use Square\Models\BatchRetrieveInventoryCountsResponse;
use WooCommerce\Square\Handlers\Product;
use WooCommerce\Square\Handlers\Category;

defined( 'ABSPATH' ) || exit;

/**
 * Class to represent a synchronization job to poll latest product updates at intervals.
 *
 * @since 2.0.0
 */
class Interval_Polling extends Stepped_Job {

	/**
	 * Assigns the next steps needed for this sync job.
	 *
	 * Adds the next steps to the 'next_steps' attribute.
	 *
	 * @since 2.0.0
	 */
	protected function assign_next_steps() {

		$next_steps = array();

		if ( $this->is_system_of_record_square() ) {

			$next_steps = array(
				'update_category_data',
				'update_product_data',
			);
		}

		// only pull latest inventory if enabled
		if ( wc_square()->get_settings_handler()->is_inventory_sync_enabled() ) {
			$next_steps[] = 'update_inventory_tracking';
			$next_steps[] = 'update_inventory_counts';
		}

		$this->set_attr( 'next_steps', $next_steps );
	}

	/**
	 * Updates categories from Square.
	 *
	 * @since 2.0.8
	 *
	 * @throws \Exception
	 */
	protected function update_category_data() {
		$date = new \DateTime();
		$date->setTimestamp( $this->get_attr( 'catalog_last_synced_at', (int) wc_square()->get_sync_handler()->get_last_synced_at() ) );
		$date->setTimezone( new \DateTimeZone( 'UTC' ) );

		$count    = 0;
		$response = wc_square()->get_api()->search_catalog_objects(
			array(
				'object_types' => array( 'CATEGORY' ),
				'begin_time'   => $date->format( DATE_ATOM ),
			)
		);

		if ( $response->get_data() instanceof SearchCatalogObjectsResponse ) {
			$categories = $response->get_data()->getObjects();

			if ( $categories && is_array( $categories ) ) {
				foreach ( $categories as $category ) {
					Category::import_or_update( $category );
				}
				$count = count( $categories );

				Records::set_record(
					array(
						'type'    => 'info',
						'message' => sprintf(
							/* translators: Placeholder %d number of categories. */
							_n( 'Updated data for %d category.', 'Updated data for %d categories.', count( $categories ), 'woocommerce-square' ),
							count( $categories )
						),
					)
				);
			}
		} else {
			Records::set_record(
				array(
					'type'    => 'alert',
					'message' => esc_html__( 'Product category data could not be updated from Square. Invalid API response.', 'woocommerce-square' ),
				)
			);
		}

		$this->set_attr( 'update_category_data_count', $count );
		$this->complete_step( 'update_category_data' );
	}

	/**
	 * Updates products from Square.
	 *
	 * @since 2.0.0
	 *
	 * @throws \Exception
	 */
	protected function update_product_data() {
		$date = new \DateTime();
		$date->setTimestamp( $this->get_attr( 'catalog_last_synced_at', (int) wc_square()->get_sync_handler()->get_last_synced_at() ) );
		$date->setTimezone( new \DateTimeZone( 'UTC' ) );

		$products_updated = $this->get_attr( 'processed_product_ids', array() );
		$cursor           = $this->get_attr( 'update_product_data_cursor' );

		$response = wc_square()->get_api()->search_catalog_objects(
			array(
				'object_types'            => array( 'ITEM' ),
				'include_deleted_objects' => true,
				'begin_time'              => $date->format( DATE_ATOM ),
				'cursor'                  => $cursor,
			)
		);

		// store the timestamp after this API request was completed
		// we don't want to set it at the end, as counts may have changed in the time it takes to process the data
		if ( ! $cursor ) {
			wc_square()->get_sync_handler()->set_last_synced_at();
		}

		if ( $response->get_data() instanceof SearchCatalogObjectsResponse && is_array( $response->get_data()->getObjects() ) ) {

			$product_import = new Product_Import();

			foreach ( $response->get_data()->getObjects() as $object ) {

				// filter out objects that aren't at our configured location
				if ( ! $object->getPresentAtAllLocations() && ( ! is_array( $object->getPresentAtLocationIds() ) || ! in_array( wc_square()->get_settings_handler()->get_location_id(), $object->getPresentAtLocationIds(), true ) ) ) {
					continue;
				}

				$product = Product::get_product_by_square_id( $object->getId() );

				if ( $product instanceof \WC_Product ) {
					if ( ! in_array( $product->get_type(), wc_square()->get_sync_handler()->supported_product_types(), true ) ) {
						Records::set_record(
							array(
								'type'    => 'alert',
								'message' => sprintf(
									/* translators: %1$s - product edit page URL, %2$s - Product ID, %3$s - Product type. */
									__( 'Product <a href="%1$s">#%2$s</a> is excluded from sync as the product type "%3$s" is unsupported.', 'woocommerce-square' ),
									get_edit_post_link( $product->get_id() ),
									$product->get_id(),
									$product->get_type()
								),
							)
						);

						continue;
					}

					// deleted items won't have any data to set, so don't try and update the product
					if ( $object->getIsDeleted() ) {

						$record = array(
							'type'       => 'alert',
							'product_id' => $product->get_id(),
						);

						// if enabled, hide the product from the catalog
						if ( wc_square()->get_settings_handler()->hide_missing_square_products() ) {

							try {

								$product->set_catalog_visibility( 'hidden' );
								$product->save();

								$record['product_hidden'] = true;

							} catch ( \Exception $e ) {
								/* translators: Placeholder %1$s Product Name, %2$s Exception message */
								$record['message'] = sprintf( esc_html__( '%1$s was deleted in Square but could not be hidden in WooCommerce. %2$s.', 'woocommerce-square' ), '<a href="' . esc_url( get_edit_post_link( $product->get_id() ) ) . '">' . $product->get_formatted_name() . '</a>', $e->getMessage() );
							}
						}

						Records::set_record( $record );

					} else {

						try {
							$data = $product_import->extract_product_data( $object, $product );

							/**
							 * Filters the data that is used to create update a WooCommerce product during import.
							 *
							 * @since 2.0.0
							 *
							 * @param array $data product data
							 * @param \Square\Models\CatalogObject $object the catalog object from the Square API
							 * @param Interval_Polling $this current class instance
							 */
							$data = apply_filters( 'woocommerce_square_create_product_data', $data, $object, $this );

							// Update the product, this will update/create the variations as well.
							$product_import->update_product( $product, $data );
							Product::update_from_square( $product, $object->getItemData(), false );

							$products_updated[] = $product->get_id();

						} catch ( \Exception $exception ) {

							Records::set_record(
								array(
									'type'       => 'alert',
									'product_id' => $product->get_id(),
									/* translators: Placeholder %1$s Product Name, %2$s Exception message */
									'message'    => sprintf( esc_html__( 'Could not sync %1$s data from Square. %2$s.', 'woocommerce-square' ), '<a href="' . esc_url( get_edit_post_link( $product->get_id() ) ) . '">' . $product->get_formatted_name() . '</a>', $exception->getMessage() ),
								)
							);
						}
					}
				}
			}
		}

		$cursor = $response->get_data() instanceof SearchCatalogObjectsResponse ? $response->get_data()->getCursor() : null;

		$this->set_attr( 'update_product_data_cursor', $cursor );
		$this->set_attr( 'processed_product_ids', array_unique( $products_updated ) );
		$this->set_attr( 'update_product_data_count', count( array_unique( $products_updated ) ) );

		if ( ! $cursor ) {
			$this->complete_step( 'update_product_data' );
		}
	}

	/**
	 * Updates the inventory tracking value from the latest in Square.
	 *
	 * Helper method, do not open to public.
	 *
	 * @since 3.8.2
	 *
	 * @throws \Exception
	 */
	protected function update_inventory_tracking() {
		$products_updated = $this->get_attr( 'processed_product_ids' );
		$cursor           = $this->get_attr( 'update_inventory_tracking_cursor', null );
		$last_synced_at   = $this->get_attr( 'inventory_last_synced_at' );
		$args             = array(
			'object_types' => array( 'ITEM_VARIATION' ),
			'limit'        => 100,
			'cursor'       => $cursor,
		);

		if ( $last_synced_at ) {
			$date = new \DateTime();
			$date->setTimestamp( $last_synced_at );
			$date->setTimezone( new \DateTimeZone( 'UTC' ) );
			$args['begin_time'] = $date->format( DATE_ATOM );
		}

		$search_result = wc_square()->get_api()->search_catalog_objects( $args );

		if ( ! $search_result->get_data() instanceof SearchCatalogObjectsResponse ) {
			throw new \Exception( 'API response data is invalid' );
		}

		$objects = $search_result->get_data()->getObjects() ? $search_result->get_data()->getObjects() : array();
		$cursor  = $search_result->get_data() instanceof SearchCatalogObjectsResponse ? $search_result->get_data()->getCursor() : null;

		$catalog_objects_tracking_stats = Helper::get_catalog_inventory_tracking( $objects );
		$catalog_objects_to_update      = array();

		foreach ( $catalog_objects_tracking_stats as $catalog_object_id => $inventory_data ) {
			$is_tracking_inventory = $inventory_data['track_inventory'] ?? false;
			$sold_out              = $inventory_data['sold_out'] ?? false;
			$product               = Product::get_product_by_square_variation_id( $catalog_object_id );
			if ( $product instanceof \WC_Product ) {
				$manage_stock = $product->get_manage_stock();
				$stock_status = $product->get_stock_status();
				$out_of_stock = 'outofstock' === $stock_status;

				// If Inventory tracking is the same as the product's manage stock setting and sold_old value same, skip.
				if ( (bool) $is_tracking_inventory === (bool) $manage_stock && (bool) $sold_out === (bool) $out_of_stock ) {
					continue;
				}
				$catalog_objects_to_update[] = $catalog_object_id;
			}
		}

		if ( ! empty( $catalog_objects_to_update ) ) {
			// Catalog Inventory data.
			$inventory_hash = Helper::get_catalog_objects_inventory_stats( $catalog_objects_to_update );

			foreach ( $catalog_objects_to_update as $catalog_object_id ) {
				$product = Product::get_product_by_square_variation_id( $catalog_object_id );
				if ( $product instanceof \WC_Product ) {
					$inventory_data        = $catalog_objects_tracking_stats[ $catalog_object_id ] ?? array();
					$is_tracking_inventory = $inventory_data['track_inventory'] ?? false;
					$sold_out              = $inventory_data['sold_out'] ?? false;

					/* If catalog object is tracked and has a quantity > 0 set in Square. */
					if ( $is_tracking_inventory && isset( $inventory_hash[ $catalog_object_id ] ) ) {
						$product->set_stock_quantity( (float) $inventory_hash[ $catalog_object_id ] );
						$product->set_manage_stock( true );

						/* If the catalog object is tracked but the quantity in Square is set to 0. */
					} elseif ( $is_tracking_inventory ) {
						$product->set_stock_quantity( 0 );
						$product->set_manage_stock( true );

						/* If the catalog object is not tracked in Square at all. */
					} else {
						$product->set_stock_status( $sold_out ? 'outofstock' : 'instock' );
						$product->set_manage_stock( false );
					}

					$product->save();
					$products_updated[] = $product->get_id();
				}
			}
		}

		$this->set_attr( 'update_inventory_tracking_cursor', $cursor );
		$this->set_attr( 'processed_product_ids', array_unique( $products_updated ) );

		if ( ! $cursor ) {
			$this->complete_step( 'update_inventory_tracking' );
		}
	}

	/**
	 * Updates the inventory counts from the latest in Square.
	 *
	 * Helper method, do not open to public.
	 *
	 * @since 2.0.0
	 *
	 * @throws \Exception
	 */
	protected function update_inventory_counts() {
		$products_updated = $this->get_attr( 'processed_product_ids' );
		$cursor           = $this->get_attr( 'update_inventory_counts_cursor' );
		$update_count     = $this->get_attr( 'update_inventory_counts_count', 0 );

		$args = array(
			'location_ids' => array( wc_square()->get_settings_handler()->get_location_id() ),
			'cursor'       => $cursor,
		);

		$last_synced_at = $this->get_attr( 'inventory_last_synced_at' );

		if ( $last_synced_at ) {

			$date = new \DateTime();
			$date->setTimestamp( $last_synced_at );
			$date->setTimezone( new \DateTimeZone( 'UTC' ) );

			$args['updated_after'] = $date->format( DATE_ATOM );
		}

		$response = wc_square()->get_api()->batch_retrieve_inventory_counts( $args );
		$cursor   = $response->get_data() instanceof BatchRetrieveInventoryCountsResponse ? $response->get_data()->getCursor() : null;

		// store the start timestamp after the first API request was completed but do not save it now
		// if cursor is present, then it is not the last page. So, use the inventory_last_synced_at time
		// else use the current time
		$last_sync_timestamp = $cursor ? $last_synced_at : current_time( 'timestamp', true ); // phpcs:disable WordPress.DateTime.CurrentTimeTimestamp.RequestedUTC

		$catalog_objects_inventory_stats = array();

		foreach ( $response->get_counts() as $count ) {
			// If catalog stats array already contains the catalog object marked as IN_STOCK, then continue.
			if ( isset( $catalog_objects_inventory_stats[ $count->getCatalogObjectId() ] ) && $catalog_objects_inventory_stats[ $count->getCatalogObjectId() ]['IN_STOCK'] ) {
				continue;
				// Else if the catalog object is IN_STOCK, then mark IN_STOCK as true and set the quantity for later use.
			} elseif ( 'IN_STOCK' === $count->getState() ) {
				$catalog_objects_inventory_stats[ $count->getCatalogObjectId() ] = array(
					'IN_STOCK' => true,
					'quantity' => $count->getQuantity(),
				);
				// Else if the catalog object doesn't have an IN_STOCK status, then mark IN_STOCK as false and set the quantity as 0 for later use.
			} else {
				$catalog_objects_inventory_stats[ $count->getCatalogObjectId() ] = array(
					'IN_STOCK' => false,
					'quantity' => 0,
				);
			}
		}

		// Get the inventory tracking for catalog objects.
		$catalog_objects_tracking_stats = Helper::get_catalog_objects_tracking_stats(
			array_keys( $catalog_objects_inventory_stats )
		);

		foreach ( $catalog_objects_inventory_stats as $catalog_object_id => $stats ) {

			$product = Product::get_product_by_square_variation_id( $catalog_object_id );

			// Square can return multiple "types" of counts, WooCommerce only distinguishes whether a product is in stock or not
			if ( $product instanceof \WC_Product ) {
				$inventory_data        = $catalog_objects_tracking_stats[ $catalog_object_id ] ?? array();
				$is_tracking_inventory = $inventory_data['track_inventory'] ?? false;
				$sold_out              = $inventory_data['sold_out'] ?? false;

				if ( $is_tracking_inventory ) {
					$product->set_manage_stock( true );
					$product->set_stock_quantity( $stats['quantity'] );
				} else {
					$product->set_stock_status( $sold_out ? 'outofstock' : 'instock' );
					$product->set_manage_stock( false );
				}

				$product->save();

				$products_updated[] = $product->get_id();
			}
		}

		$this->set_attr( 'update_inventory_counts_cursor', $cursor );
		$this->set_attr( 'processed_product_ids', array_unique( $products_updated ) );
		$this->set_attr( 'update_inventory_counts_count', $update_count + count( $catalog_objects_inventory_stats ) );

		if ( ! $cursor ) {
			// When all the inventory counts are synced then set the last sync time to the start time that was stored
			wc_square()->get_sync_handler()->set_inventory_last_synced_at( $last_sync_timestamp );
			$this->complete_step( 'update_inventory_counts' );
		}
	}
}
