<?php
/**
 * Cart Controller for Warranty Cart
 *
 * Backs the storefront selector's "Add to Cart & Checkout" button.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\REST\Controllers;

use WarrantyCart\Core\Coverage;
use WarrantyCart\Core\Pricing;
use WarrantyCart\Core\Repository;
use WarrantyCart\REST\Controller;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

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

/**
 * Cart class
 *
 * The storefront script has always posted to `warranty-cart/v1/cart/add`. No
 * such route was ever registered — the whole namespace was admin-only — so the
 * request 404'd, and the selector's only button did nothing on every store
 * running this plugin. The failure was invisible too: the script reported
 * errors through `wc_add_notice()` on `window`, which is a PHP function, so
 * the message went to the console instead of the page.
 */
class Cart extends Controller {

	/**
	 * Constructor
	 */
	public function __construct() {
		$this->rest_base = 'cart';
	}

	/**
	 * Register routes
	 *
	 * @return void
	 */
	public function register_routes(): void {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/add',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'add_to_cart' ),
					'permission_callback' => array( $this, 'check_public_permission' ),
					'args'                => array(
						'main'     => array(
							'required'          => true,
							'type'              => 'integer',
							'description'       => __( 'The main warranty product ID.', 'warranty-cart' ),
							'sanitize_callback' => 'absint',
						),
						'services' => array(
							'required'    => false,
							'type'        => 'array',
							'description' => __( 'Additional service product IDs.', 'warranty-cart' ),
							'items'       => array( 'type' => 'integer' ),
							'default'     => array(),
						),
						'covered'  => array(
							'required'          => false,
							'type'              => 'integer',
							'description'       => __( 'The product this warranty is being bought to cover.', 'warranty-cart' ),
							'sanitize_callback' => 'absint',
							'default'           => 0,
						),
					),
				),
			)
		);
	}

	/**
	 * Add the chosen warranty and services to the cart.
	 *
	 * @param WP_REST_Request $request REST request object.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public function add_to_cart( WP_REST_Request $request ) {
		if ( ! function_exists( 'WC' ) ) {
			return $this->error_response(
				__( 'WooCommerce is not active.', 'warranty-cart' ),
				503
			);
		}

		/*
		 * REST requests do not boot the cart.
		 *
		 * WooCommerce only loads the cart and customer session on frontend
		 * requests, so `WC()->cart` is null here. `wc_load_cart()` is the
		 * supported way to bring both up inside a REST or AJAX context;
		 * without it this endpoint fatals on the first `add_to_cart()`.
		 */
		if ( null === WC()->cart && function_exists( 'wc_load_cart' ) ) {
			wc_load_cart();
		}

		if ( null === WC()->cart ) {
			return $this->error_response(
				__( 'The cart is unavailable.', 'warranty-cart' ),
				503
			);
		}

		$main     = (int) $request->get_param( 'main' );
		$services = $request->get_param( 'services' );
		$services = is_array( $services ) ? array_map( 'absint', $services ) : array();

		$main_product = $this->validated_product( $main, 'main_warranty' );

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

		/*
		 * The product this cover is being bought for, when there is one.
		 *
		 * Checked against the warranty's own coverage rules rather than
		 * trusted: the id arrives in a request body, and without this a
		 * customer could attach a £39 warranty to a £4,000 item the shop never
		 * offered it against — and the record would say the shop had agreed to
		 * cover it.
		 */
		$covered = $this->validated_covered_product( (int) $request->get_param( 'covered' ), $main_product );

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

		/*
		 * Guest purchases are refused here as well as at checkout.
		 *
		 * The checkout guard is the one that actually blocks the order, but
		 * finding out only after filling in a checkout form is a poor way to
		 * learn you needed an account — and a warranty sold to a guest has no
		 * `customer_id`, so the account tab and the expiry emails have nobody
		 * to address.
		 */
		if ( ! is_user_logged_in() && ! get_option( 'warranty_guest_checkout', false ) ) {
			return $this->error_response(
				__( 'Please log in or create an account to buy a warranty.', 'warranty-cart' ),
				403
			);
		}

		// Everything is validated before anything is added, so a bad service
		// ID cannot leave a half-filled cart behind.
		$service_products = array();

		foreach ( $services as $service_id ) {
			$service = $this->validated_product( $service_id, 'additional_service' );

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

			$service_products[] = $service;
		}

		$added = array();

		$main_item_data = array( 'warranty_cart_role' => 'main_warranty' );

		if ( $covered instanceof \WC_Product ) {
			$main_item_data['warranty_cart_covers'] = $covered->get_id();
		}

		/*
		 * The price, resolved once and carried on the line.
		 *
		 * A percentage warranty costs whatever the covered product costs today.
		 * Recomputing that on every totals calculation would let a warranty
		 * already sitting in a customer's cart change price because the shop
		 * repriced the item it covers — the quote they were shown has to hold
		 * until they check out.
		 */
		$main_item_data[ Pricing::CART_ITEM_KEY ] = Pricing::price( $main_product, $covered );

		$main_key = WC()->cart->add_to_cart(
			$main_product->get_id(),
			1,
			0,
			array(),
			$main_item_data
		);

		if ( ! $main_key ) {
			return $this->error_response(
				__( 'The warranty could not be added to your cart.', 'warranty-cart' ),
				400
			);
		}

		$added[] = $main_product->get_id();

		foreach ( $service_products as $service ) {
			$key = WC()->cart->add_to_cart(
				$service->get_id(),
				1,
				0,
				array(),
				array(
					'warranty_cart_role'   => 'additional_service',

					/*
					 * Resolved the same way, against the same covered product.
					 *
					 * The product editor offers percentage pricing on main
					 * warranties only, so today this is always the service's own
					 * price. It is routed through Pricing anyway so that a shop
					 * setting the meta through `warranty_cart_warranty_price` or
					 * directly gets a percentage of the *covered product* rather
					 * than of the warranty — a percentage of a percentage
					 * compounds into a number nobody chose.
					 */
					Pricing::CART_ITEM_KEY => Pricing::price( $service, $covered ),
				)
			);

			if ( $key ) {
				$added[] = $service->get_id();
			}
		}

		/**
		 * Fires after warranty products are added to the cart.
		 *
		 * @param array $added Product IDs added.
		 */
		do_action( 'warranty_cart_added_to_cart', $added );

		return $this->success_response(
			array(
				'message'      => __( 'Added to your cart.', 'warranty-cart' ),
				'added'        => $added,
				'cart_count'   => WC()->cart->get_cart_contents_count(),
				'redirect_url' => wc_get_checkout_url(),
			)
		);
	}

	/**
	 * The covered product, if this warranty is actually offered against it.
	 *
	 * @param int         $product_id The product the warranty is meant to cover, or 0.
	 * @param \WC_Product $warranty   The warranty being bought.
	 *
	 * @return \WC_Product|null|WP_Error The product, null when none was named,
	 *                                   or why it was refused.
	 */
	private function validated_covered_product( int $product_id, \WC_Product $warranty ) {
		if ( $product_id <= 0 ) {
			return null;
		}

		$product = wc_get_product( $product_id );

		if ( ! $product instanceof \WC_Product || ! $product->exists() ) {
			return $this->error_response(
				__( 'That product could not be found.', 'warranty-cart' ),
				404
			);
		}

		if ( ! Coverage::covers( $warranty, $product ) ) {
			return $this->error_response(
				__( 'That warranty is not offered for this product.', 'warranty-cart' ),
				400
			);
		}

		return $product;
	}

	/**
	 * A product, if it is a warranty product of the expected kind.
	 *
	 * Type is checked, not just existence: without it a customer could post
	 * any product ID on the store — or a service ID in the `main` slot — and
	 * have it added at whatever price it carries.
	 *
	 * @param int    $product_id The product ID.
	 * @param string $expected   'main_warranty' or 'additional_service'.
	 *
	 * @return \WC_Product|WP_Error
	 */
	private function validated_product( int $product_id, string $expected ) {
		$product = $product_id > 0 ? wc_get_product( $product_id ) : false;

		if ( ! $product instanceof \WC_Product || ! $product->exists() ) {
			return $this->error_response(
				__( 'That warranty is no longer available.', 'warranty-cart' ),
				404
			);
		}

		if ( 'enabled' !== $product->get_meta( '_warranty_cart_product_status', true ) ) {
			return $this->error_response(
				__( 'That warranty is no longer available.', 'warranty-cart' ),
				400
			);
		}

		/*
		 * Cover that comes free is not for sale.
		 *
		 * The page never renders a button for it, so reaching this means a posted
		 * ID. Selling it would take money for something the order was about to
		 * grant anyway, and leave the customer holding two records for one
		 * promise.
		 */
		if ( Coverage::is_included( $product ) ) {
			return $this->error_response(
				__( 'That cover is included with the product and cannot be bought.', 'warranty-cart' ),
				400
			);
		}

		// Either spelling of the type key. See Frontend\Shortcode::product_type().
		$type = Repository::product_type( $product );

		if ( $type !== $expected ) {
			return $this->error_response(
				__( 'That product cannot be selected here.', 'warranty-cart' ),
				400
			);
		}

		if ( ! $product->is_purchasable() ) {
			return $this->error_response(
				__( 'That warranty cannot be purchased right now.', 'warranty-cart' ),
				400
			);
		}

		return $product;
	}
}
