<?php
/**
 * Handles article stock changes.
 *
 * @package flo-sync
 */

namespace NMFS\Sync\Items\Articles\Stock;

use NMFS\Admin\Settings_Fields;
use NMFS\Shared;
use NMFS\Sync\Items\Articles\Articles;
use NMFS\Sync\Items\Articles\Stock\Models\Stock_Data_Collection_Model;
use NMFS\Sync\Syncers;
use Northmill_Online_Shared_Sync\Northmill_Online_Shared\Abstracts\Hub_API\Hub_Accounts\Hub_Account_Setup;
use Northmill_Online_Shared_Sync\Northmill_Online_Shared\Abstracts\Hub_API\Hub_Accounts\WP_Repository;
use Northmill_Online_Shared_Sync\Northmill_Online_Shared\Abstracts\Hub_API\Models\Hub_API_Properties;
use Northmill_Online_Shared_Sync\Northmill_Online_Shared\Helpers\Hook_Helpers;
use Northmill_Online_Shared_Sync\Northmill_Online_Shared\Modules\Admin\Notices\Notice_Type_Enum;
use Northmill_Online_Shared_Sync\Northmill_Online_Shared\Modules\Loggers\Log_Level_Enum;
use WC_Order;
use WC_Order_Item;
use WC_Order_Item_Product;
use WC_Order_Refund;
use WC_Product;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Stock class.
 */
class Stock {

	public const NOTICE_HUB_ACCOUNTS_UPDATED = 'stock_hub_accounts_updated';

	/**
	 * Constructor.
	 */
	public static function setup(): void {

		add_filter(
			'woocommerce_payment_complete_reduce_order_stock',
			array( self::class, 'maybe_reduce_order_stock' ),
			999, // Should be run after WooCommerce core.
			2
		);
		add_filter( 'northmill_online_shared_hub_accounts_updated', array( __CLASS__, 'updated_hub_accounts' ), 10, 2 );

		/**
		 * If stock is adjusted on order creation, there is no need for WooCommerce to hold stock.
		 */
		if ( self::get_stock_adjustment_setting() === Settings_Fields::ORDER_STOCK_ADJUSTMENT_ORDER_OPEN ) {
			add_filter( 'woocommerce_hold_stock_for_checkout', '__return_false', 999, 0 );
		}

		$service_name       = Shared::instance()->package_info->get_service_name();
		$settings_menu_slug = Shared::instance()->admin->pages->get_page( $service_name, 'sync' )->menu_slug;
		Shared::instance()->admin->notices->register_notice(
			self::NOTICE_HUB_ACCOUNTS_UPDATED,
			Notice_Type_Enum::WARNING,
			function () use ( $settings_menu_slug ) {
				return sprintf(
					// translators: %1$s: opening anchor tag, %2$s: closing anchor tag.
					__(
						'After updating Hub accounts you need to do a full manual sync of stock at the %1$ssync page%2$s. Check the "Get all data" checkbox, select Stock in the list to the right and then press Manual sync.',
						'flo-sync'
					),
					'<a href="' . admin_url( 'admin.php?page=' . $settings_menu_slug ) . '">',
					'</a>'
				);
			},
			false,
			false
		);

		$stock_syncer = Shared::instance()->sync->get_syncer( Syncers::SYNCER_KEY_ARTICLE_STOCK );
		$option_name  = $stock_syncer->get_ajax_syncer()->syncer->get_last_synced_wp_option_name() . '_ajax_all';
		Hook_Helpers::add_scoped_action(
			'northmill_online_shared_set_last_synced_wp_option_' . $option_name,
			array( __CLASS__, 'full_stock_sync_completed' ),
			10,
			0
		);
	}

	/**
	 * Get stock adjustment setting.
	 *
	 * @return string
	 */
	public static function get_stock_adjustment_setting(): string {
		return Shared::instance()->admin->settings->get_setting(
			Settings_Fields::ORDER_STOCK_ADJUSTMENT,
			Settings_Fields::ORDER_STOCK_ADJUSTMENT_ORDER_PAID
		);
	}

	/**
	 * Hooked to action when full stock sync is completed.
	 */
	public static function full_stock_sync_completed(): void {
		Shared::instance()->admin->notices->get_notice( self::NOTICE_HUB_ACCOUNTS_UPDATED )?->set_closed();
	}

	/**
	 * Replaces core function to reduce stock levels, for multi-account sites.
	 * Based on WC core function wc_reduce_stock_levels().
	 *
	 * @param bool $trigger_reduce Whether to reduce stock.
	 * @param int  $order_id       Order object id.
	 *
	 * @return bool                Whether to use WC core function to reduce stock.
	 */
	public static function maybe_reduce_order_stock( bool $trigger_reduce, int $order_id ): bool {

		Shared::instance()->logger->log(
			'order-sync',
			'Filter maybe_reduce_order_stock',
			array(
				'order_id'       => $order_id,
				'trigger_reduce' => $trigger_reduce,
				'current_action' => current_action(),
				'item_count'     => count( wc_get_order( $order_id )->get_items() ),
				'mas'            => Hub_Account_Setup::is_multiple_stocks_enabled(),
			),
			Log_Level_Enum::DEBUG
		);

		// Only run this if multi-account is enabled. Else, the core function should be used.
		if ( ! Hub_Account_Setup::is_multiple_stocks_enabled() ) {
			return $trigger_reduce;
		}

		// This check is made since the action for this function also is triggered on order status changes.
		// And an order status change is often triggered after 'woocommerce_pre_payment_complete'
		// but before 'woocommerce_payment_complete'.
		if (
			did_action( 'woocommerce_pre_payment_complete' ) &&
			! did_action( 'woocommerce_payment_complete' )
		) {
			// Order hasn't been synced to Hub as paid yet, wait for that, as we use data from Hub to reduce stock.
			Shared::instance()->logger->log(
				'order-sync',
				'Waiting for order to be paid before reducing stock.',
				array(
					'order_id'       => $order_id,
					'current_action' => current_action(),
				),
				Log_Level_Enum::DEBUG
			);
			return false;
		}

		Shared::instance()->logger->log(
			'order-sync',
			'Start multi-account sync stock reduce for order.',
			array(
				'order_id'       => $order_id,
				'current_action' => current_action(),
			),
			Log_Level_Enum::DEBUG
		);

		$order = wc_get_order( $order_id );

		if ( ! $order ) {
			Shared::instance()->logger->log(
				'order-sync',
				'Could not get order when trying to reduce stock',
				array(
					'order_id' => $order_id,
				),
				Log_Level_Enum::ERROR
			);
			return false;
		}

		$stock_already_reduced = $order->get_data_store()->get_stock_reduced( $order_id );

		if ( $stock_already_reduced ) {
			Shared::instance()->logger->log(
				'order-sync',
				'Stock already reduced for order ' . $order_id,
				array(),
				Log_Level_Enum::DEBUG
			);
			return false; // Stock already reduced.
		}

		// We need a store with stock management to continue.
		if (
			'yes' !== get_option( 'woocommerce_manage_stock' ) ||
			! apply_filters( 'woocommerce_can_reduce_order_stock', true, $order )
		) {
			Shared::instance()->logger->log(
				'order-sync',
				'Manage stock disabled when doing Multi-account stock reduction for order: ' . $order_id,
				array(),
				Log_Level_Enum::DEBUG
			);

			return false;
		}

		// Get stock data from API.
		$articles_stock_data = self::get_stock_data_from_order( $order );

		if ( ! empty( $articles_stock_data ) ) {
			foreach ( $articles_stock_data as $article_sku => $stock_data ) {

				/**
				 * Stock data collection.
				 *
				 * @var Stock_Data_Collection_Model $stock_data_collection
				 */
				$stock_data_collection = $stock_data['stock_data_collection'];

				$stock_reduced = self::update_article_stock(
					$article_sku,
					$stock_data_collection
				);

				if ( $stock_reduced && ! is_wp_error( $stock_reduced ) ) {
					/**
					 * Stock data collection.
					 *
					 * @var WC_Order_Item_Product $order_item
					 */
					$order_item = $stock_data['order_item'] ?? null;

					// Set stock reduced for order item.
					if ( $order_item instanceof WC_Order_Item ) {
						// To save anyone else from my confusion, this is only for the order item, not the actual product.
						$order_item->add_meta_data(
							'_reduced_stock',
							$order_item->get_quantity(),
							true
						);
						$order_item->save();
					}
				}
			}

			$changes = self::get_order_stock_changes( $order );
			wc_trigger_stock_change_notifications( $order, $changes );
		}

		// Ensure stock is marked as "reduced" in case payment complete or other stock actions are called.
		$order->get_data_store()->set_stock_reduced( $order_id, true );

		do_action( 'woocommerce_reduce_order_stock', $order );

		return false; // Prevents core WooCommerce stock reduction.
	}

	/**
	 * Get stock changes from order, for stock change notifications.
	 *
	 * @param WC_Order $order Order to get changes for.
	 *
	 * @return array
	 */
	private static function get_order_stock_changes( WC_Order $order ): array {
		$changes = array();

		foreach ( $order->get_items() as $item ) {
			if ( ! $item instanceof WC_Order_Item_Product ) {
				continue;
			}

			$product = $item->get_product();

			if ( ! $product instanceof WC_Product ) {
				continue;
			}

			if ( ! $product->managing_stock() ) {
				continue;
			}

			/**
			 * Filter order item quantity.
			 *
			 * @param int|float             $quantity Quantity.
			 * @param WC_Order              $order    Order data.
			 * @param WC_Order_Item_Product $item Order item data.
			 */
			$qty       = apply_filters( 'woocommerce_order_item_quantity', $item->get_quantity(), $order, $item );
			$new_stock = $product->get_stock_quantity();

			$change = array(
				'product' => $product,
				'from'    => $new_stock + $qty,
				'to'      => $new_stock,
			);

			$changes[] = $change;

			/**
			 * Fires when stock reduced to a specific line item
			 *
			 * @param WC_Order_Item_Product $item Order item data.
			 * @param array $change  Change Details.
			 * @param WC_Order $order  Order data.
			 */
			do_action( 'woocommerce_reduce_order_item_stock', $item, $change, $order );
		}

		return $changes;
	}

	/**
	 * Get stock data for a single article from API, and update in WC.
	 *
	 * @param string $article_sku Article SKU.
	 *
	 * @return int|false|WP_Error New stock level on success, or false on if stock wasn't updated.
	 *                            WP_Error if something went wrong.
	 */
	public static function get_and_update_article_stock_data(
		string $article_sku
	): false|int|WP_Error {
		$stock_data_collection = self::get_stock_data_from_hub_accounts( $article_sku );

		if ( is_wp_error( $stock_data_collection ) ) {
			return $stock_data_collection;
		}

		return self::update_article_stock( $article_sku, $stock_data_collection );
	}

	/**
	 * Update article stock. Both WC core data, and Sync meta data.
	 *
	 * @param string                      $article_sku           Article sku.
	 * @param Stock_Data_Collection_Model $stock_data_collection Stock data. Stock level in all available Hub accounts.
	 *
	 * @return int|false|WP_Error New stock level on success. False if stock wasn't updated. WP_Error if something went wrong.
	 */
	public static function update_article_stock( string $article_sku, Stock_Data_Collection_Model $stock_data_collection ): int|false|WP_Error {
		$article_hook_status = Articles::$save_article_hook_enabled;
		Articles::set_hook_save_article( false );

		$product_id = wc_get_product_id_by_sku( $article_sku );
		$product    = wc_get_product( $product_id );

		if ( $stock_data_collection->is_stock_kept ) {
			$product->set_manage_stock( true );
		} else {
			$product->set_manage_stock( false );
			$product->set_stock_status(); // Defaults to 'instock'.
		}
		$product->save();

		$total_stock = $stock_data_collection->get_total_stock();

		if ( Hub_Account_Setup::is_multiple_stocks_enabled() ) {
			$updated = Stock_Data_Storage::set( $product_id, $stock_data_collection );

			if ( ! $updated ) {

				Articles::set_hook_save_article( $article_hook_status );

				return new WP_Error( 'stock_data_update_failed', 'Failed to update article meta stock data.' );
			}
		}

		if ( $stock_data_collection->is_stock_kept ) {
			$new_stock = self::update_article_wc_stock( $product_id, $total_stock );
		} else {
			$new_stock = 0;
		}

		Articles::set_hook_save_article( $article_hook_status );

		return $new_stock;
	}

	/**
	 * Update WooCommerce stock.
	 *
	 * @param WC_Product|int $product  WC_Product object or product id.
	 * @param float          $quantity Quantity to set.
	 *
	 * @return WP_Error|int|false New stock level on success. False if stock wasn't updated. WP_Error if something went wrong.
	 */
	private static function update_article_wc_stock( WC_Product|int $product, float $quantity ): WP_Error|false|int {
		if ( ! is_a( $product, 'WC_Product' ) ) {
			$product = wc_get_product( $product );
		}

		if ( ! $product ) {
			return new WP_Error( 'product_not_found', 'Failed to get article to update stock for.' );
		}

		$old_stock = $product->get_stock_quantity();

		if ( $old_stock === $quantity ) {
			Shared::instance()->logger->log(
				'sync',
				'Stock for article was not changed',
				array(
					'sku'       => $product->get_sku(),
					'old_stock' => $old_stock,
					'new_stock' => $quantity,
				),
				Log_Level_Enum::DEBUG
			);

			return false;
		}

		$new_stock = wc_update_product_stock( $product, $quantity );

		if ( is_wp_error( $new_stock ) ) {
			return $new_stock;
		} elseif ( false === $new_stock ) {
			return new WP_Error( 'product_stock_update_failed', 'Failed to update stock for article.' );
		} elseif ( is_null( $new_stock ) ) {
			if ( 0.0 !== $quantity ) {
				return new WP_Error( 'product_stock_update_failed', 'Failed to update stock for article.' );
			}

			$new_stock = 0;
		}

		return $new_stock;
	}

	/**
	 * Get stock data from order.
	 *
	 * @param WC_Order|WC_Order_Refund $order Order object.
	 *
	 * @return array{stock_data_collection: Stock_Data_Collection_Model, order_item: WC_Order_Item_Product} Stock data collection and order item.
	 */
	private static function get_stock_data_from_order( WC_Order|WC_Order_Refund $order ): array {

		$order_articles_data = array();

		// Loop over all items.
		foreach ( $order->get_items() as $item ) {
			/**
			 * Order item data.
			 *
			 * @var WC_Order_Item_Product $item
			 */
			if ( ! $item->is_type( 'line_item' ) ) {
				continue;
			}

			$product = $item->get_product();

			if ( ! $product || ! $product->managing_stock() ) {
				continue;
			}

			$product_sku = $product->get_sku();

			if ( isset( $order_articles_data[ $product_sku ] ) ) {
				continue;
			}

			$stock_data_collection = self::get_stock_data_from_hub_accounts( $product_sku );

			if ( is_wp_error( $stock_data_collection ) ) {
				$order->add_order_note( $stock_data_collection->get_error_message() );
				Shared::instance()->logger->log(
					'order-sync',
					'Failed to get order stock data for article',
					array(
						'sku'      => $product_sku,
						'order_id' => $order->get_id(),
					),
					Log_Level_Enum::ERROR
				);
				continue;
			}

			$order_articles_data[ $product_sku ] = array(
				'stock_data_collection' => $stock_data_collection,
				'order_item'            => $item,
			);
		}

		return $order_articles_data;
	}

	/**
	 * Get stock data from all connected Hub accounts.
	 *
	 * @param string                           $product_sku           SKU of article to get stock data for.
	 * @param Stock_Data_Collection_Model|null $stock_data_collection Stock data collection to append to.
	 *
	 * @return Stock_Data_Collection_Model|WP_Error Stock data collection on success, or WP_Error on fail.
	 */
	public static function get_stock_data_from_hub_accounts( string $product_sku, ?Stock_Data_Collection_Model $stock_data_collection = null ): WP_Error|Stock_Data_Collection_Model {
		$hub_accounts     = WP_Repository::get_all_current_accounts();
		$main_data_found  = false;
		$accounts_to_skip = array();

		if ( ! $stock_data_collection ) {
			$stock_data_collection = new Stock_Data_Collection_Model();
		} else {
			$main_data_found  = $stock_data_collection->has_main_account();
			$accounts_to_skip = $stock_data_collection->get_all_hub_account_wp_ids();
		}

		foreach ( $hub_accounts as $hub_account ) {

			if ( in_array( $hub_account->wp_id, $accounts_to_skip, true ) ) {
				continue;
			}

			// Get article from the current Hub Account.
			$article = $hub_account->api->get_article( $product_sku );

			if ( $hub_account->is_main() && ! empty( $article ) ) {
				$main_data_found                      = true;
				$stock_data_collection->is_stock_kept = $article[ Hub_API_Properties::ARTICLE_IS_STOCK_ITEM ] ?? false;
			}

			$stock_data_collection->add_stock_data(
				$hub_account->wp_id,
				$article[ Hub_API_Properties::ARTICLE_QUANTITY ] ?? 0,
				! empty( $article )
			);
		}

		if ( ! $main_data_found ) {
			return new WP_Error(
				'stock_data_not_found',
				sprintf(
					// translators: %s: Product SKU.
					__(
						'Failed to get stock data for article %s from main account.',
						'flo-sync'
					),
					$product_sku
				)
			);
		}
		return $stock_data_collection;
	}

	/**
	 * Get stock data stored in WooCommerce for a product.
	 *
	 * @param WC_Product|int $product_id                WC_Product object or product id.
	 * @param string|null    $sort_by                   Sort by field. Can be property of Stock_Data_Model or Hub_Account_Model.
	 * @param bool           $include_hub_account_model Include Hub account in stock data.
	 *
	 * @return Stock_Data_Collection_Model|null Collection, or null if not found.
	 */
	public static function get_wc_product_stock_data(
		WC_Product|int $product_id,
		?string $sort_by = null,
		bool $include_hub_account_model = true
	): ?Stock_Data_Collection_Model {

		if ( ! is_int( $product_id ) ) {
			$product_id = $product_id->get_id();
		}

		if ( ! $product_id ) {
			return null;
		}

		$stock_data_collection = Stock_Data_Storage::get( $product_id );
		if ( ! $stock_data_collection ) {
			return null;
		}

		if ( $include_hub_account_model ) {
			$stock_data_collection->load_hub_accounts();
		}

		if ( ! empty( $sort_by ) ) {
			$stock_data_collection->sort_stock_data_array( $sort_by );
		}

		return $stock_data_collection;
	}

	/**
	 * Get stock quantity for the main account.
	 *
	 * @param WC_Product $product Product to get stock quantity for.
	 *
	 * @return null|float
	 */
	public static function get_stock_quantity_for_main_account( WC_Product $product ): ?float {
		if (
			! Hub_Account_Setup::is_multiple_stocks_enabled() ||
			! ( WP_Repository::get_current_main_account()->wp_id ?? false )
		) {
			return $product->get_stock_quantity();
		}

		return self::get_stock_quantity_from_backoffice_account( $product, WP_Repository::get_current_main_account()->wp_id );
	}

	/**
	 * Get stock quantity for a Hub account.
	 *
	 * @param WC_Product $product          Product to get stock quantity for.
	 * @param int        $backoffice_account_id Hub account id.
	 * @param float|null $return_on_not_found Return this value if stock data is not found.
	 *
	 * @return float|null
	 */
	public static function get_stock_quantity_from_backoffice_account(
		WC_Product $product,
		int $backoffice_account_id,
		?float $return_on_not_found = 0
	): ?float {
		if ( ! Hub_Account_Setup::is_multiple_stocks_enabled() ) {
			return $product->get_stock_quantity();
		}

		$stock_data_collection = self::get_wc_product_stock_data( $product->get_id(), null, false );

		if ( empty( $stock_data_collection ) ) {
			return $return_on_not_found;
		}

		if ( ! $stock_data_collection->article_exists_in_account( $backoffice_account_id ) ) {
			return $return_on_not_found;
		}

		$quantity = $stock_data_collection->get_stock_quantity_for_account( $backoffice_account_id );

		return $quantity ?? $return_on_not_found;
	}

	/**
	 * Check if an article is in a specific Hub account.
	 *
	 * @param WC_Product $product             Product to check.
	 * @param int        $backoffice_account_id Hub account id.
	 */
	public static function is_article_in_hub_account( WC_Product $product, int $backoffice_account_id ): bool {
		if ( ! Hub_Account_Setup::is_multiple_stocks_enabled() ) {
			Shared::instance()->logger->log(
				'stock',
				'is_article_in_hub_account called when multiple stocks is not enabled.',
				array(),
				Log_Level_Enum::ERROR
			);
			return false;
		}

		$stock_data_collection = self::get_wc_product_stock_data( $product->get_id(), null, false );

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

		return $stock_data_collection->article_exists_in_account( $backoffice_account_id );
	}

	/**
	 * Hook for when Hub accounts are updated.
	 *
	 * @param bool $is_stock_accounts_enabled    If any stock accounts are enabled.
	 * @param bool $was_stock_accounts_enabled   If any stock accounts were enabled before update.
	 */
	public static function updated_hub_accounts( bool $is_stock_accounts_enabled, bool $was_stock_accounts_enabled ): void {
		$show_notice = $is_stock_accounts_enabled || $was_stock_accounts_enabled;
		Shared::instance()->admin->notices->get_notice( self::NOTICE_HUB_ACCOUNTS_UPDATED )->set_closed( ! $show_notice );
	}
}
