<?php
/**
 * Which warranties are offered against which products.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\Core;

use WC_Product;

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

/**
 * The rules that decide whether a warranty covers a given product.
 *
 * Until this existed the only way to sell cover was the `[warranty_cart]`
 * selector on a page of its own: a customer had to find that page and choose
 * cover for a product they were not looking at. Nobody browses for a warranty.
 *
 * The rules live on the warranty rather than on the products it covers. One
 * warranty typically covers a whole department, so saying it once on the
 * warranty is a single edit where the alternative is an edit per product — and
 * a catalogue where every new product silently gets no cover offered until
 * somebody remembers to attach it.
 */
class Coverage {

	/**
	 * How a warranty chooses what it covers.
	 */
	public const SCOPE_META = '_warranty_cart_applies_to';

	/**
	 * Product category term IDs, when the scope is `categories`.
	 */
	public const CATEGORIES_META = '_warranty_cart_applies_categories';

	/**
	 * Product IDs, when the scope is `products`.
	 */
	public const PRODUCTS_META = '_warranty_cart_applies_products';

	/**
	 * Whether this cover comes with the product rather than being sold.
	 */
	public const INCLUDED_META = '_warranty_cart_included';

	/**
	 * Where the offer is shown: `product`, `cart`, `both` or `none`.
	 */
	public const PLACEMENT_OPTION = 'warranty_cart_offer_placement';

	/**
	 * The scopes a warranty can use.
	 *
	 * @return array<string, string> Key to human label.
	 */
	public static function scopes(): array {
		return array(
			'none'       => __( 'Not offered alongside products', 'warranty-cart' ),
			'all'        => __( 'Any product', 'warranty-cart' ),
			'categories' => __( 'Products in chosen categories', 'warranty-cart' ),
			'products'   => __( 'Chosen products only', 'warranty-cart' ),
		);
	}

	/**
	 * Where the offer appears.
	 *
	 * Defaults to the product page alone. The offer belongs where the customer
	 * is already deciding whether to buy the thing it covers; adding the cart to
	 * that by default means asking twice in one session, which reads as nagging
	 * rather than helping.
	 *
	 * @return string One of `product`, `cart`, `both`, `none`.
	 */
	public static function placement(): string {
		$placement = (string) get_option( self::PLACEMENT_OPTION, 'product' );

		return in_array( $placement, array( 'product', 'cart', 'both', 'none' ), true ) ? $placement : 'product';
	}

	/**
	 * Whether the offer should be shown on the single product page.
	 *
	 * @return bool
	 */
	public static function shows_on_product(): bool {
		return in_array( self::placement(), array( 'product', 'both' ), true );
	}

	/**
	 * Whether the offer should be shown in the cart.
	 *
	 * @return bool
	 */
	public static function shows_in_cart(): bool {
		return in_array( self::placement(), array( 'cart', 'both' ), true );
	}

	/**
	 * A warranty's coverage scope.
	 *
	 * Defaults to `none`, so a warranty created before this feature existed does
	 * not start appearing under every product in the shop the moment the plugin
	 * updates. Offering cover is a decision the shop owner makes per warranty.
	 *
	 * @param WC_Product $warranty The warranty product.
	 *
	 * @return string
	 */
	public static function scope( WC_Product $warranty ): string {
		$scope = (string) $warranty->get_meta( self::SCOPE_META, true );

		return array_key_exists( $scope, self::scopes() ) ? $scope : 'none';
	}

	/**
	 * Whether a warranty comes with the product rather than being sold.
	 *
	 * A shop that guarantees its goods for a year is not upselling anything: it
	 * wants the promise recorded, the customer able to see it, and a claim
	 * against it handled. Until this existed the plugin could only describe cover
	 * somebody had paid for, which left every such shop with nothing to install.
	 *
	 * @param WC_Product $warranty The warranty product.
	 *
	 * @return bool
	 */
	public static function is_included( WC_Product $warranty ): bool {
		return 'yes' === $warranty->get_meta( self::INCLUDED_META, true );
	}

	/**
	 * The term or product IDs a warranty is restricted to.
	 *
	 * @param WC_Product $warranty The warranty product.
	 * @param string     $which    `categories` or `products`.
	 *
	 * @return array<int, int>
	 */
	public static function targets( WC_Product $warranty, string $which ): array {
		$meta  = 'categories' === $which ? self::CATEGORIES_META : self::PRODUCTS_META;
		$value = $warranty->get_meta( $meta, true );

		if ( ! is_array( $value ) ) {
			return array();
		}

		return array_values( array_filter( array_map( 'absint', $value ) ) );
	}

	/**
	 * Whether a warranty covers a product.
	 *
	 * @param WC_Product $warranty The warranty product.
	 * @param WC_Product $product  The product a customer is looking at.
	 *
	 * @return bool
	 */
	public static function covers( WC_Product $warranty, WC_Product $product ): bool {
		/*
		 * A warranty never covers a warranty.
		 *
		 * Without this the offer appears underneath the warranty products
		 * themselves — cover for your cover — which is both nonsense and, since
		 * every warranty matches an `all` rule, the first thing a shop owner
		 * would see after switching the feature on.
		 */
		if ( '' !== Repository::product_type( $product ) ) {
			return false;
		}

		/*
		 * Included cover is never bought, so whether it could be is beside the
		 * point. A shop stating "two years included" has no reason to give the
		 * plan a price, and WooCommerce calls a product with no price
		 * unpurchasable — which would have made the feature impossible to use in
		 * the obvious way.
		 */
		if ( ! self::is_included( $warranty ) && ! $warranty->is_purchasable() ) {
			return false;
		}

		$covers = self::matches_scope( $warranty, $product );

		/**
		 * Filter whether a warranty is offered against a product.
		 *
		 * @param bool       $covers   Whether it is offered.
		 * @param WC_Product $warranty The warranty product.
		 * @param WC_Product $product  The product being viewed or bought.
		 */
		return (bool) apply_filters( 'warranty_cart_covers_product', $covers, $warranty, $product );
	}

	/**
	 * The product whose catalogue entry a rule should be read from.
	 *
	 * A variation carries a price and a stock level of its own and nothing else:
	 * categories, tags and attributes belong to the variable product above it.
	 * Anything asking what a product is filed under has to ask the parent.
	 *
	 * @param WC_Product $product The product being viewed or bought.
	 *
	 * @return int
	 */
	private static function catalogue_id( WC_Product $product ): int {
		$parent = $product->get_parent_id();

		return $parent > 0 ? $parent : $product->get_id();
	}

	/**
	 * The scope rule, without the filter or the guards.
	 *
	 * @param WC_Product $warranty The warranty product.
	 * @param WC_Product $product  The product being viewed or bought.
	 *
	 * @return bool
	 */
	private static function matches_scope( WC_Product $warranty, WC_Product $product ): bool {
		switch ( self::scope( $warranty ) ) {
			case 'all':
				return true;

			case 'categories':
				$wanted = self::targets( $warranty, 'categories' );

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

				/*
				 * Ancestors included: a warranty set against "Appliances" should
				 * cover something filed under "Appliances > Kitchen". Asking
				 * only for the product's own terms would miss every child
				 * category, which is where most catalogues actually put things.
				 *
				 * Asked of the parent for a variation, because a variation is not
				 * in any category — the terms live on the variable product it
				 * belongs to. Asking the variation returns nothing, so a
				 * category-scoped warranty refused every variation of every
				 * product it was meant to cover.
				 */
				$terms = wc_get_product_term_ids( self::catalogue_id( $product ), 'product_cat' );
				$terms = is_array( $terms ) ? $terms : array();

				foreach ( $terms as $term_id ) {
					if ( in_array( (int) $term_id, $wanted, true ) ) {
						return true;
					}

					$ancestors = get_ancestors( (int) $term_id, 'product_cat', 'taxonomy' );

					if ( array_intersect( array_map( 'absint', $ancestors ), $wanted ) ) {
						return true;
					}
				}

				return false;

			case 'products':
				$wanted = self::targets( $warranty, 'products' );

				if ( in_array( $product->get_id(), $wanted, true ) ) {
					return true;
				}

				// A variation is covered by a rule naming its parent: a shop
				// owner picks "Blue Widget", not each of its six sizes.
				$parent = $product->get_parent_id();

				if ( $parent > 0 && in_array( $parent, $wanted, true ) ) {
					return true;
				}

				/*
				 * And the other way about: a rule naming one variation makes the
				 * product it belongs to worth an offer.
				 *
				 * The picker has always accepted variations, and a shop that
				 * covers only the 512GB model got nothing — the product page asks
				 * about the parent, which no rule named, so the offer never
				 * rendered and the variation could never be reached. What the
				 * customer may actually choose is narrowed per variation on the
				 * page itself.
				 */
				return $product->is_type( 'variable' )
					&& (bool) array_intersect( array_map( 'absint', $product->get_children() ), $wanted );

			case 'none':
			default:
				return false;
		}
	}

	/**
	 * Every warranty offered against a product, cheapest first.
	 *
	 * @param WC_Product $product The product being viewed or bought.
	 *
	 * @return array<int, WC_Product> Warranty products.
	 */
	public static function for_product( WC_Product $product ): array {
		$offers = array();

		foreach ( self::scoped_warranties() as $warranty ) {
			// Included cover is not on sale. Offering a customer the chance to
			// buy what they are already getting is the fastest way to make the
			// promise look like a trick.
			if ( ! self::is_included( $warranty ) && self::covers( $warranty, $product ) ) {
				$offers[] = $warranty;
			}
		}

		/*
		 * Cheapest first, so the offer opens with the easiest yes. A list led by
		 * the three-year cover reads as an upsell; one led by the cheapest reads
		 * as an option.
		 *
		 * Sorted on the resolved price, not the product's own: a percentage
		 * warranty's fixed price is a fallback, and ordering by it would put a
		 * 5%-of-£2,000 plan below a £29 one it actually costs three times as
		 * much as.
		 */
		usort(
			$offers,
			static function ( WC_Product $a, WC_Product $b ) use ( $product ): int {
				return Pricing::price( $a, $product ) <=> Pricing::price( $b, $product );
			}
		);

		/**
		 * Filter the warranties offered against a product.
		 *
		 * @param array<int, WC_Product> $offers  Warranty products.
		 * @param WC_Product             $product The product being viewed or bought.
		 */
		return apply_filters( 'warranty_cart_offers_for_product', $offers, $product );
	}

	/**
	 * Every warranty that comes free with a product.
	 *
	 * Read twice: by the product page, to say what the customer is getting, and
	 * by order completion, to write the records that say they got it.
	 *
	 * @param WC_Product $product The product being viewed or bought.
	 *
	 * @return array<int, WC_Product> Warranty products.
	 */
	public static function included_for_product( WC_Product $product ): array {
		$included = array();

		foreach ( self::scoped_warranties() as $warranty ) {
			if ( self::is_included( $warranty ) && self::covers( $warranty, $product ) ) {
				$included[] = $warranty;
			}
		}

		/**
		 * Filter the warranties included with a product.
		 *
		 * @param array<int, WC_Product> $included Warranty products.
		 * @param WC_Product             $product  The product being viewed or bought.
		 */
		return apply_filters( 'warranty_cart_included_for_product', $included, $product );
	}

	/**
	 * Which variations of a product a warranty actually covers.
	 *
	 * A rule can name one variation out of six. The offer is rendered against the
	 * parent, before the customer has chosen anything, so it has to carry the
	 * answer for each variation with it — otherwise a shop covering only the
	 * 512GB model offers cover for the 128GB one and refuses it at the cart.
	 *
	 * @param WC_Product $warranty The warranty product.
	 * @param WC_Product $product  The variable product being viewed.
	 *
	 * @return array<int, int> Variation IDs. Empty for anything not variable.
	 */
	public static function covered_variations( WC_Product $warranty, WC_Product $product ): array {
		if ( ! $product->is_type( 'variable' ) ) {
			return array();
		}

		$covered = array();

		foreach ( $product->get_children() as $child_id ) {
			$variation = wc_get_product( (int) $child_id );

			if ( $variation instanceof WC_Product && self::covers( $warranty, $variation ) ) {
				$covered[] = (int) $child_id;
			}
		}

		return $covered;
	}

	/**
	 * Whether any warranty is offered against a product.
	 *
	 * @param WC_Product $product The product.
	 *
	 * @return bool
	 */
	public static function has_offer( WC_Product $product ): bool {
		/*
		 * Included cover counts. Nothing can be bought from it, but it is
		 * rendered on the same page and narrowed to the chosen variation by the
		 * same script — so a page showing only included cover still needs the
		 * bundle this gates.
		 */
		return array() !== self::for_product( $product )
			|| array() !== self::included_for_product( $product );
	}

	/**
	 * Every enabled main warranty that names what it applies to.
	 *
	 * Main warranties only: an additional service attaches to a warranty rather
	 * than to a product, so offering one on its own next to a kettle would sell
	 * a customer a support plan for cover they do not have.
	 *
	 * Sold and included plans both, because both are scoped the same way and the
	 * callers above want one kind each.
	 *
	 * @return array<int, WC_Product>
	 */
	private static function scoped_warranties(): array {
		$ids = get_posts(
			array(
				'post_type'   => 'product',
				'post_status' => 'publish',
				'numberposts' => -1,
				'fields'      => 'ids',
				'meta_query'  => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					'relation' => 'AND',
					array(
						'key'   => '_warranty_cart_product_status',
						'value' => 'enabled',
					),
					array(
						'key'     => self::SCOPE_META,
						'value'   => array( 'all', 'categories', 'products' ),
						'compare' => 'IN',
					),
				),
			)
		);

		$warranties = array();

		foreach ( $ids as $id ) {
			$product = wc_get_product( $id );

			if ( $product instanceof WC_Product && 'main_warranty' === Repository::product_type( $product ) ) {
				$warranties[] = $product;
			}
		}

		return $warranties;
	}
}
