<?php
/**
 * Tax Transaction service
 *
 * @package Stripe\StripeTaxForWooCommerce\Stripe
 */

namespace Stripe\StripeTaxForWooCommerce\Stripe;

// Exit if script started not from WordPress.
defined( 'ABSPATH' ) || exit;

use WC_Order;
use WC_Order_Refund;
use Stripe\StripeTaxForWooCommerce\Stripe\CalculateTax;
use Stripe\StripeTaxForWooCommerce\SDK\lib\Exception\ApiErrorException;
use Stripe\StripeTaxForWooCommerce\SDK\lib\Exception\InvalidRequestException;
use Stripe\StripeTaxForWooCommerce\Stripe\Tax_Calculation\Calculator;
use Stripe\StripeTaxForWooCommerce\WordPress\Options;
use Stripe\StripeTaxForWooCommerce\Utils\Amount_Utility;
use Stripe\StripeTaxForWooCommerce\Stripe\Tax_Calculation\Order_Input;
use Stripe\StripeTaxForWooCommerce\SDK\lib\Util\Util;
use Throwable;

/**
 * Tax Transaction service
 */
abstract class TaxTransaction {

	const TABLE_NAME = STRIPE_TAX_FOR_WOOCOMMERCE_DB_PREFIX . 'tax_transactions';

	/**
	 * Cache of Tax Transactions to reduce database calls
	 *
	 * @var array<int, \stdClass>
	 */
	protected static $transactions = array();

	/**
	 * Create Stripe Tax Transaction from Tax Calculation
	 *
	 * @param int $order_id WooCommerce Order ID.
	 *
	 * @return \stdClass
	 * @throws ApiErrorException In case of API error.
	 * @see https://stripe.com/docs/api/tax/transactions/create_from_calculation
	 */
	public static function create_order_transaction( int $order_id ) {
		global $wpdb;

		$order = wc_get_order( $order_id );
		$order->calculate_totals( true );

		$tax_calculation = Calculator::$calculations[ $order_id ];

		if ( ! $tax_calculation ) {
			return;
		}

		$tax_calculation_input  = $tax_calculation['input'];
		$tax_calculation_result = $tax_calculation['result'];

		static::reverse_order_last_transaction( $order_id );

		$last_tax_transaction = static::create_api_tax_transaction(
			$order_id,
			'Order ' . $order_id . ' order timestamp ' . time(),
			$tax_calculation_result
		);

		$order_refunds = static::get_order_refunds( $order_id );

		if ( count( $order_refunds ) > 0 ) {
			foreach ( $order_refunds as $order_refund ) {
				static::create_order_refund_reversal( $order_refund );
			}
		}
		$test = Calculator::$calculations;
		unset( Calculator::$calculations[ $order_id ] );
		$test2 = Calculator::$calculations;

		$tax_calculation_payload = $tax_calculation_input->get_payload();
		$calculate_tax           = new CalculateTax( Options::get_current_mode_key(), $tax_calculation_payload );
		$calculate_tax->delete();

		return $last_tax_transaction;
	}

	/**
	 * Reverse order last tax transaction
	 *
	 * @param int $order_id WooCommerce Order ID.
	 *
	 * @throws ApiErrorException In case of API error.
	 */
	public static function reverse_order_last_transaction( int $order_id ): void {
		$last_tax_transaction = static::get_order_last_transaction( $order_id );

		if ( ! $last_tax_transaction ) {
			return;
		}

		static::create_full_reversal( $order_id, $last_tax_transaction );
	}

	/**
	 * Creates a reversal partial tax transaction from an order refund object.
	 *
	 * @param object $order_refund WooCommerce Order Refund.
	 *
	 * @throws ApiErrorException In case of API error.
	 */
	public static function create_order_refund_reversal( $order_refund ) {
		if ( ! is_object( $order_refund ) ) {
			$order_refund = new WC_Order_Refund( $order_refund );
		}
		$order_id = $order_refund->get_parent_id();

		$order_last_transaction = static::get_order_last_transaction( $order_id );

		if ( ! $order_last_transaction ) {
			return;
		}
		$tax_transaction  = $order_last_transaction->tax_transaction;
		$already_refunded = static::get_already_refunded_amounts( $order_last_transaction->reversals );
		$line_items       = static::get_tax_transaction_refund_line_items( $order_refund, $tax_transaction, $already_refunded['line_items'] );

		$currency = $order_refund->get_currency();

		$shipping_total = (float) $order_refund->get_shipping_total( 'edit' );
		$shipping_tax   = (float) $order_refund->get_shipping_tax( 'edit' );

		$shipping_cost = array();

		if ( $shipping_total < 0.0 || $shipping_tax < 0.0 ) {
			$shipping_amount     = Amount_Utility::to_cents( $shipping_total, $currency );
			$shipping_tax_amount = Amount_Utility::to_cents( $shipping_tax, $currency );

			if ( isset( $tax_transaction->shipping_cost->tax_behavior ) && 'inclusive' === $tax_transaction->shipping_cost->tax_behavior ) {
				$shipping_amount += $shipping_tax_amount;
			}

			if ( isset( $tax_transaction->shipping_cost ) ) {
				$remaining_shipping_tax = ( $tax_transaction->shipping_cost->amount_tax ?? 0 ) - $already_refunded['shipping']['amount_tax'];
				if ( abs( $shipping_tax_amount ) > $remaining_shipping_tax ) {
					$shipping_tax_amount = $shipping_tax_amount < 0 ? -$remaining_shipping_tax : $remaining_shipping_tax;
				}

				$remaining_shipping_amount = ( $tax_transaction->shipping_cost->amount ?? 0 ) - $already_refunded['shipping']['amount'];
				if ( abs( $shipping_amount ) > $remaining_shipping_amount ) {
					$shipping_amount = $shipping_amount < 0 ? -$remaining_shipping_amount : $remaining_shipping_amount;
				}
			}

			$shipping_cost = array(
				'amount'     => $shipping_amount,
				'amount_tax' => $shipping_tax_amount,
			);
		}

		static::create_api_tax_reversal(
			$order_id,
			'Refund order ' . $order_id . ', refund timestamp ' . microtime(),
			$order_last_transaction->tax_transaction->id,
			$line_items,
			$shipping_cost
		);
	}

	/**
	 * Given an order and a refund order item return the original refunded order item.
	 *
	 * @param object $order_refund Order refund.
	 * @param object $order_refund_item Order refund item.
	 */
	protected static function get_original_item_reference( $order_refund, $order_refund_item ) {
		$order                  = wc_get_order( $order_refund->get_parent_id() );
		$order_refunded_item_id = $order_refund_item->get_meta( '_refunded_item_id' );
		$order_refunded_item    = $order->get_item( $order_refunded_item_id );

		$reference = Order_Input::build_item_reference_by_type( $order_refunded_item, true );

		return $reference;
	}

	/**
	 * Given an order refund and a previously tax calculation, return tax calculation lines associated with the refund
	 *
	 * @param object $wc_order_refund Order refund.
	 * @param object $tax_transaction_data Tax calculation data.
	 * @param array  $already_refunded_per_line_item Already refunded per line item.
	 */
	public static function get_tax_transaction_refund_line_items( $wc_order_refund, object $tax_transaction_data, array $already_refunded_per_line_item = array() ): array {
		$tax_transaction_data = Util::convertToStripeObject( $tax_transaction_data, array() );
		$items                = $wc_order_refund->get_items( array( 'line_item', 'fee' ) );
		$currency             = $wc_order_refund->get_currency();
		$line_items           = array();
		$line_items_counter   = 0;

		$items_reference_already_added = array();

		foreach ( $items as $item ) {
			$reference = static::get_original_item_reference( $wc_order_refund, $item );

			$original_line_item_id = '';
			// @phpstan-ignore-next-line
			foreach ( $tax_transaction_data->line_items as $transaction_line_item ) {
				if ( $reference === $transaction_line_item->reference ) {
					$original_line_item_id = $transaction_line_item->id;
					break;
				}
			}

			if ( ! $original_line_item_id ) {
				continue;
			}

			$quantity   = 'fee' === $item->get_type() ? 1 : - $item->get_quantity();
			$amount     = Amount_Utility::to_cents( $item->get_total( 'edit' ), $currency );
			$tax_amount = Amount_Utility::to_cents( $item->get_total_tax( 'edit' ), $currency );

			if ( isset( $transaction_line_item->tax_behavior ) && 'inclusive' === $transaction_line_item->tax_behavior ) {
				$amount += $tax_amount;
			}

			$refunded_for_item = isset( $already_refunded_per_line_item[ $original_line_item_id ] ) ? $already_refunded_per_line_item[ $original_line_item_id ] : array(
				'amount'     => 0,
				'amount_tax' => 0,
			);

			$remaining_tax = $transaction_line_item->amount_tax - $refunded_for_item['amount_tax'];
			if ( abs( $tax_amount ) > $remaining_tax ) {
				$tax_amount = $tax_amount < 0 ? -$remaining_tax : $remaining_tax;
			}

			$remaining_amount = $transaction_line_item->amount - $refunded_for_item['amount'];
			if ( abs( $amount ) > $remaining_amount ) {
				$amount = $amount < 0 ? -$remaining_amount : $remaining_amount;
			}

			$line_items[ $line_items_counter ] = array(
				'amount'             => $amount,
				'reference'          => $reference,
				'quantity'           => $quantity,
				'original_line_item' => $original_line_item_id,
				'amount_tax'         => $tax_amount,
			);

			++$line_items_counter;
		}

		return $line_items;
	}
	/**
	 * Get already-refunded amounts from existing reversals.
	 *
	 * @param array $reversals Existing reversal transaction DTOs.
	 *
	 * @return array Already-refunded amounts per line item and for shipping.
	 */
	protected static function get_already_refunded_amounts( array $reversals ): array {
		$result = array(
			'line_items' => array(),
			'shipping'   => array(
				'amount'     => 0,
				'amount_tax' => 0,
			),
		);

		foreach ( $reversals as $reversal ) {
			$tx = $reversal->tax_transaction;

			if ( isset( $tx->line_items ) ) {
				foreach ( $tx->line_items as $li ) {
					$original_id = isset( $li->reversal->original_line_item ) ? $li->reversal->original_line_item : null;

					if ( ! $original_id ) {
						continue;
					}

					if ( ! isset( $result['line_items'][ $original_id ] ) ) {
						$result['line_items'][ $original_id ] = array(
							'amount'     => 0,
							'amount_tax' => 0,
						);
					}

					$result['line_items'][ $original_id ]['amount']     += abs( $li->amount );
					$result['line_items'][ $original_id ]['amount_tax'] += abs( $li->amount_tax );
				}
			}

			if ( isset( $tx->shipping_cost ) && $tx->shipping_cost ) {
				$result['shipping']['amount']     += abs( $tx->shipping_cost->amount ?? 0 );
				$result['shipping']['amount_tax'] += abs( $tx->shipping_cost->amount_tax ?? 0 );
			}
		}

		return $result;
	}

	/**
	 * Returns an order's refunds sorted by date desc
	 *
	 * @param int $order_id WooCommerce Order ID.
	 */
	public static function get_order_refunds( $order_id ) {
		$order         = new WC_Order( $order_id );
		$order_refunds = $order->get_refunds();

		usort( $order_refunds, array( static::class, 'compare_order_refunds_by_id_asc' ) );

		return $order_refunds;
	}

	/**
	 * Compares two order refunds by their ID.
	 *
	 * @param object $refund1 WooCommerce Order Refund.
	 * @param object $refund2 WooCommerce Order Refund.
	 */
	public static function compare_order_refunds_by_id_asc( $refund1, $refund2 ) {
		return $refund1->get_id() > $refund2->get_id();
	}

	/**
	 * Returns order all tax transactions from the database.
	 *
	 * @param int $order_id WooCommerce Order ID.
	 */
	public static function get_order_all_transactions( int $order_id ) {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$result = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT %i, %i, %i FROM %i WHERE %i = %d',
				array(
					'order_id',
					'tax_calculation',
					'tax_transaction',
					static::TABLE_NAME,
					'order_id',
					$order_id,
				)
			)
		);

		if ( ! $result ) {
			return null;
		} else {
			$transactions = array();

			foreach ( $result as $record ) {
				$transaction                                       = self::create_transaction_dto( $record->order_id, $record->tax_transaction, $record->tax_calculation );
				$transactions[ $transaction->tax_transaction->id ] = $transaction;
			}
		}

		uasort( $transactions, array( static::class, 'compare_tax_transactions_by_created_asc' ) );

		static::$transactions[ $order_id ] = array();

		foreach ( $transactions as $transaction ) {
			static::prepend_transaction( $order_id, $transaction );
		}

		return static::$transactions[ $order_id ];
	}

	/**
	 * Prepends an order tax transaction to the static cache.
	 *
	 * @param int|object $order_id WooCommerce Order ID.
	 * @param object     $transaction Tax Transaction.
	 */
	protected static function prepend_transaction( $order_id, $transaction ) {
		$api_tax_transaction = $transaction->tax_transaction;

		if ( ! isset( static::$transactions[ $order_id ] ) ) {
			static::$transactions[ $order_id ] = array();
		}

		static::$transactions[ $order_id ] = array_merge(
			array( $api_tax_transaction->id => $transaction ),
			static::$transactions[ $order_id ]
		);

		if ( $api_tax_transaction->reversal ) {
			$original_api_tax_transaction_id = $api_tax_transaction->reversal->original_transaction;

			if ( isset( static::$transactions[ $order_id ][ $original_api_tax_transaction_id ] ) ) {
				$original_transaction = static::$transactions[ $order_id ][ $original_api_tax_transaction_id ];

				$original_transaction->reversals = array_merge(
					array( $api_tax_transaction->id => $transaction ),
					$original_transaction->reversals
				);
			}
		}
	}

	/**
	 * Compares two order tax transactions by their creation date.
	 *
	 * @param object $transaction1 Tax Transaction.
	 * @param object $transaction2 Tax Transaction.
	 */
	public static function compare_tax_transactions_by_created_asc( $transaction1, $transaction2 ) {
		$created1 = $transaction1->tax_transaction->created;
		$created2 = $transaction2->tax_transaction->created;

		if ( $created1 < $created2 ) {
			return -1;
		}

		if ( $created1 > $created2 ) {
			return 1;
		}

		return 0;
	}

	/**
	 * Get order last tax transaction from cache or database.
	 *
	 * @param int $order_id Order ID.
	 *
	 * @return object|null
	 */
	public static function get_order_last_transaction( int $order_id ) {
		$all_tax_transactions = static::get_order_all_transactions( $order_id );

		if ( ! is_array( $all_tax_transactions ) ) {
			return null;
		}

		foreach ( $all_tax_transactions as $transaction ) {
			if ( ! $transaction->tax_transaction->reversal ) {
				return $transaction;
			}
		}

		return null;
	}

	/**
	 * Creates a full reversal for a given order and transaction
	 *
	 * @param int    $order_id Order ID.
	 * @param object $transaction Transaction.
	 * @return object|null
	 */
	public static function create_full_reversal( $order_id, $transaction ) {
		foreach ( $transaction->reversals as $reversal ) {
			static::create_full_reversal( $order_id, $reversal );
		}

		return static::create_full_api_tax_reversal(
			$order_id,
			'Refund ' . $transaction->tax_transaction->reference,
			$transaction->tax_transaction->id
		);
	}

	/**
	 * Creates an API tax transaction for a given order with a given reference
	 * and saves the result to the database.
	 *
	 * @param int    $order_id Order ID.
	 * @param string $reference Line items.
	 * @param object $api_tax_calculation The result of a tax calculation API call.
	 *
	 * @return object|null
	 * @throws ApiErrorException In case of API error.
	 * @see https://stripe.com/docs/api/tax/transactions/create_from_calculation
	 */
	protected static function create_api_tax_transaction( $order_id, $reference, $api_tax_calculation ) {
		$stripe_client = StripeClientFactory::get_stripe_client();

		$api_tax_transaction = $stripe_client->tax->transactions->createFromCalculation(
			array(
				'calculation' => $api_tax_calculation->id,
				'reference'   => $reference,
				'expand'      => array( 'line_items' ),
			)
		);

		$transaction = static::save_transaction( $order_id, $api_tax_transaction, $api_tax_calculation );

		return $transaction;
	}

	/**
	 * Creates an API tax transaction partial reversal for given order and tax transaction with a given reference
	 * and saves the result to the database.
	 *
	 * @param int    $order_id                    Order ID.
	 * @param string $reference                   Line items.
	 * @param string $original_api_transaction_id Tax transaction to reverse id.
	 * @param array  $line_items                  Items to reverse for partial reversals.
	 * @param array  $shipping_cost               Shipping cost for partial reversals.
	 * @return object|null
	 * @throws ApiErrorException In case of API error.
	 * @see https://stripe.com/docs/api/tax/transactions/create_api_tax_reversal
	 */
	protected static function create_api_tax_reversal( int $order_id, $reference, $original_api_transaction_id, array $line_items = array(), array $shipping_cost = array() ) {
		$stripe_client = StripeClientFactory::get_stripe_client();

		$reversal_transaction_request_data = array(
			'mode' => ( $line_items || $shipping_cost ) ? 'partial' : 'full',
		);

		$reversal_transaction_request_data['original_transaction'] = $original_api_transaction_id;
		$reversal_transaction_request_data['reference']            = $reference;
		$reversal_transaction_request_data['expand']               = array( 'line_items' );

		if ( $line_items ) {
			$reversal_transaction_request_data['line_items'] = $line_items;
		}

		if ( $shipping_cost ) {
			$reversal_transaction_request_data['shipping_cost'] = $shipping_cost;
		}

		$api_tax_transaction = $stripe_client->tax->transactions->createReversal( $reversal_transaction_request_data );

		$transaction = static::save_transaction( $order_id, $api_tax_transaction );

		return $transaction;
	}

	/**
	 * Creates an API tax transaction full reversal for given order and tax transaction with a given reference
	 * and saves the result to the database.
	 *
	 * @param int    $order_id                        Order ID.
	 * @param string $reference                       Line items.
	 * @param object $original_api_tax_transaction_id Transaction to reverse id.
	 *
	 * @return object|null
	 * @throws ApiErrorException In case of API error.
	 * @see https://stripe.com/docs/api/tax/transactions/create_api_tax_reversal
	 */
	public static function create_full_api_tax_reversal( $order_id, $reference, $original_api_tax_transaction_id ) {
		$stripe_client = StripeClientFactory::get_stripe_client();

		$full_api_tax_reversal_request_data = array(
			'mode'                 => 'full',
			'original_transaction' => $original_api_tax_transaction_id,
			'reference'            => $reference,
			'expand'               => array( 'line_items' ),
		);

		try {
			$full_api_tax_reversal = $stripe_client->tax->transactions->createReversal( $full_api_tax_reversal_request_data );
			$transaction           = static::save_transaction( $order_id, $full_api_tax_reversal );

			return $transaction;
		} catch ( Throwable $err ) {
			static::on_error(
				$err,
				array(
					'order_id'             => $order_id,
					'reference'            => $reference,
					'original_transaction' => $original_api_tax_transaction_id,
				)
			);
			return null;
		}
	}

	/**
	 * Error handler.
	 *
	 * @param \Throwable $err     The error object.
	 * @param array|null $context Error context.
	 *
	 * @throws \Throwable When the error cannot be handled.
	 */
	protected static function on_error( $err, $context = null ) {
		if ( ! ( $err instanceof InvalidRequestException ) ) {
			throw $err;
		}

		if ( ! is_array( $context ) || ! array_key_exists( 'original_transaction', $context ) ) {
			throw $err;
		}

		$err_message = $err->getMessage();

		if ( strpos( $err_message, 'Fully reverse the partial reversals' ) === false ) {
			throw $err;
		}

		preg_match_all( '/(tax_[a-z0-9]+)/i', $err_message, $reversal_tax_ids );
		$reversal_tax_ids = $reversal_tax_ids[1];

		$order_id                       = $context['order_id'];
		$original_transaction_reference = $context['reference'];
		$original_transaction           = $context['original_transaction'];

		$original_transaction_key = array_search( $original_transaction, $reversal_tax_ids, true );

		if ( false === $original_transaction_key ) {
			throw $err;
		}

		unset( $reversal_tax_ids[ $original_transaction_key ] );

		static::create_full_api_tax_reversal_with_partial_reversals( $order_id, 'Refund ' . $original_transaction_reference, $original_transaction, $reversal_tax_ids );
	}

	/**
	 * Creates an API tax transaction full reversal for given order and tax transaction that have partial reversals
	 * with a given reference and saves the result to the database.
	 *
	 * @param int    $order_id             Order ID.
	 * @param string $reference            Line items.
	 * @param string $original_transaction Transaction id.
	 * @param array  $reversal_tax_ids     Reversal transactions of the origina transaction.
	 *
	 * @return object|null
	 * @throws ApiErrorException In case of API error.
	 */
	protected static function create_full_api_tax_reversal_with_partial_reversals( $order_id, $reference, $original_transaction, $reversal_tax_ids ) {
		$stripe_client = StripeClientFactory::get_stripe_client();

		$reversal_references = array();

		foreach ( $reversal_tax_ids as $key => $reversal_tax_id ) {
			$api_tax_reversal                        = $stripe_client->tax->transactions->retrieve( $reversal_tax_id );
			$reversal_references[ $reversal_tax_id ] = $api_tax_reversal->reference;

			if ( $api_tax_reversal->reversal->original_transaction !== $original_transaction ) {
				return null;
			}
		}

		foreach ( $reversal_references as $reversal_tax_id => $reversal_reference ) {
			static::create_full_api_tax_reversal( $order_id, 'Refund' . $reversal_reference, $reversal_tax_id );
		}

		return static::create_full_api_tax_reversal( $order_id, $reference, $original_transaction );
	}

	/**
	 * Saves a transaction to the database after its creation via API calls.
	 *
	 * @param int    $order_id            Order ID.
	 * @param object $api_tax_transaction Tax creation API call response.
	 * @param object $api_tax_calculation Tax calculation API call response.
	 *
	 * @return object
	 */
	protected static function save_transaction( $order_id, $api_tax_transaction, $api_tax_calculation = null ) {
		global $wpdb;

		$json_api_tax_calculation = wp_json_encode( $api_tax_calculation );
		$json_api_tax_transaction = ! is_null( $api_tax_transaction ) ? wp_json_encode( $api_tax_transaction ) : null;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$result = $wpdb->query(
			$wpdb->prepare(
				'INSERT INTO %i (%i, %i, %i) VALUES (%d, %s, %s) ON DUPLICATE KEY UPDATE %i = %s, %i = %s',
				array(
					static::TABLE_NAME,
					'order_id',
					'tax_calculation',
					'tax_transaction',
					$order_id,
					$json_api_tax_calculation,
					$json_api_tax_transaction,
					'tax_calculation',
					$json_api_tax_calculation,
					'tax_transaction',
					$json_api_tax_transaction,
				)
			)
		);

		$last_transaction = self::create_transaction_dto( $order_id, $json_api_tax_transaction, $json_api_tax_calculation );

		static::prepend_transaction( $order_id, $last_transaction );

		return $last_transaction;
	}

	/**
	 * Creates transaction objects to store in the static cache.
	 *
	 * @param int    $order_id                 Order ID.
	 * @param object $json_api_tax_transaction Tax creation API call response.
	 * @param object $json_api_tax_calculation Tax calculation API call response.
	 *
	 * @return object
	 */
	private static function create_transaction_dto( $order_id, $json_api_tax_transaction, $json_api_tax_calculation = null ) {
		$transaction = new \stdClass();

		$transaction->order_id = $order_id;

		$transaction->tax_transaction = Util::convertToStripeObject( json_decode( $json_api_tax_transaction, true ), array() );
		$transaction->tax_calculation = Util::convertToStripeObject( json_decode( $json_api_tax_calculation, true ), array() );
		$transaction->reversals       = array();

		return $transaction;
	}
}
