<?php
/**
 * What a warranty costs.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\Core;

use WC_Product;

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

/**
 * Resolves a warranty's price, which may depend on what it covers.
 *
 * A warranty product carries one price, which works for a catalogue where
 * everything costs roughly the same and breaks immediately where it does not:
 * £29 of cover is most of the value of a £40 kettle and a rounding error on a
 * £2,000 television. The shop's only options were one price that is wrong at
 * both ends, or a separate warranty product per price band.
 *
 * A percentage fixes that, but only where there is something to take a
 * percentage *of* — so the fixed price stays the default and remains the
 * fallback wherever no covered product is in hand.
 */
class Pricing {

	/**
	 * `fixed` or `percentage`.
	 */
	public const MODE_META = '_warranty_cart_pricing_mode';

	/**
	 * The percentage of the covered product's price to charge.
	 */
	public const PERCENTAGE_META = '_warranty_cart_percentage';

	/**
	 * Floor for a percentage price, or '' for none.
	 */
	public const MIN_META = '_warranty_cart_price_min';

	/**
	 * Ceiling for a percentage price, or '' for none.
	 */
	public const MAX_META = '_warranty_cart_price_max';

	/**
	 * The cart item key holding the resolved price.
	 *
	 * Resolved once, when the item is added, and read back on every totals
	 * calculation. Recomputing it each time would let a warranty already in a
	 * customer's cart change price because the shop repriced the product it
	 * covers — the quote they were given has to hold.
	 */
	public const CART_ITEM_KEY = 'warranty_cart_price';

	/**
	 * The pricing modes a warranty can use.
	 *
	 * @return array<string, string> Key to human label.
	 */
	public static function modes(): array {
		return array(
			'fixed'      => __( 'Fixed price', 'warranty-cart' ),
			'percentage' => __( 'Percentage of the covered product', 'warranty-cart' ),
		);
	}

	/**
	 * A warranty's pricing mode.
	 *
	 * Defaults to `fixed`, which is what every warranty sold so far has used.
	 *
	 * @param WC_Product $warranty The warranty product.
	 *
	 * @return string
	 */
	public static function mode( WC_Product $warranty ): string {
		$mode = (string) $warranty->get_meta( self::MODE_META, true );

		return array_key_exists( $mode, self::modes() ) ? $mode : 'fixed';
	}

	/**
	 * Whether a warranty is priced as a percentage.
	 *
	 * @param WC_Product $warranty The warranty product.
	 *
	 * @return bool
	 */
	public static function is_percentage( WC_Product $warranty ): bool {
		return 'percentage' === self::mode( $warranty ) && self::percentage( $warranty ) > 0;
	}

	/**
	 * The percentage a warranty charges.
	 *
	 * @param WC_Product $warranty The warranty product.
	 *
	 * @return float
	 */
	public static function percentage( WC_Product $warranty ): float {
		return max( 0.0, (float) $warranty->get_meta( self::PERCENTAGE_META, true ) );
	}

	/**
	 * A bound on the percentage price, or null when unset.
	 *
	 * Null rather than 0: a minimum of zero and no minimum at all are different
	 * instructions, and reading '' as 0 would make "no floor" behave as "floor
	 * of nothing" — harmless for the minimum, and wrong for the maximum, where
	 * it would price every warranty at zero.
	 *
	 * @param WC_Product $warranty The warranty product.
	 * @param string     $which    `min` or `max`.
	 *
	 * @return float|null
	 */
	public static function bound( WC_Product $warranty, string $which ): ?float {
		$raw = $warranty->get_meta( 'min' === $which ? self::MIN_META : self::MAX_META, true );

		if ( '' === $raw || null === $raw ) {
			return null;
		}

		return max( 0.0, (float) $raw );
	}

	/**
	 * What a warranty costs, given what it covers.
	 *
	 * @param WC_Product      $warranty The warranty product.
	 * @param WC_Product|null $covered  The product being covered, if any.
	 *
	 * @return float
	 */
	public static function price( WC_Product $warranty, ?WC_Product $covered = null ): float {
		$price = (float) $warranty->get_price();

		/*
		 * The product's own price wherever there is nothing to take a
		 * percentage of.
		 *
		 * The `[warranty_cart]` selector sells cover with no product in hand, so
		 * a percentage warranty listed there has nothing to compute against.
		 * Falling back to the price the shop set on the product is truthful —
		 * it is a real price somebody chose — and it keeps the warranty
		 * purchasable, which a zero would not.
		 */
		if ( self::is_percentage( $warranty ) && $covered instanceof WC_Product ) {
			$price = self::apply_bounds(
				$warranty,
				( (float) $covered->get_price() ) * ( self::percentage( $warranty ) / 100 )
			);
		}

		/**
		 * Filter the resolved price of a warranty.
		 *
		 * @param float           $price    The price.
		 * @param WC_Product      $warranty The warranty product.
		 * @param WC_Product|null $covered  The product being covered, if any.
		 */
		$price = (float) apply_filters( 'warranty_cart_warranty_price', $price, $warranty, $covered );

		/*
		 * Rounded to the store's own decimal places, not to two.
		 *
		 * A store selling in yen has zero decimals and one selling in dinars has
		 * three; hardcoding two would leave a price WooCommerce then displays
		 * rounded to something else, so the cart and the offer would disagree.
		 */
		return (float) wc_format_decimal( $price, wc_get_price_decimals() );
	}

	/**
	 * What a warranty costs against each variation of a variable product.
	 *
	 * A variable product's own `get_price()` is the cheapest variation's. Take a
	 * percentage of that and a £2,000 laptop is quoted cover priced on the £200
	 * one beside it in the same dropdown — a figure the shop then honours,
	 * because it is the figure the customer was shown.
	 *
	 * Returned as a map so the offer can quote the variation the customer picked
	 * rather than a number that was only ever true of one of them.
	 *
	 * @param WC_Product $warranty The warranty product.
	 * @param WC_Product $covered  The product being covered.
	 *
	 * @return array<int, float> Variation ID to price. Empty where the price
	 *                           does not vary — a fixed-price warranty, or a
	 *                           product with no variations.
	 */
	public static function variation_prices( WC_Product $warranty, WC_Product $covered ): array {
		if ( ! self::is_percentage( $warranty ) || ! $covered->is_type( 'variable' ) ) {
			return array();
		}

		$prices = array();

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

			if ( ! $variation instanceof WC_Product || ! $variation->is_purchasable() ) {
				continue;
			}

			$prices[ (int) $child_id ] = self::price( $warranty, $variation );
		}

		return $prices;
	}

	/**
	 * Hold a percentage price between its floor and ceiling.
	 *
	 * A percentage of a very cheap item is not worth administering, and a
	 * percentage of a very expensive one asks for more than anybody will pay.
	 *
	 * @param WC_Product $warranty The warranty product.
	 * @param float      $price    The computed price.
	 *
	 * @return float
	 */
	private static function apply_bounds( WC_Product $warranty, float $price ): float {
		$min = self::bound( $warranty, 'min' );
		$max = self::bound( $warranty, 'max' );

		if ( null !== $min ) {
			$price = max( $price, $min );
		}

		/*
		 * The ceiling last, so it wins.
		 *
		 * A shop that sets a minimum above its maximum has made a mistake, and
		 * of the two ways to resolve it, charging the smaller number is the one
		 * that does not overcharge somebody for it.
		 */
		if ( null !== $max ) {
			$price = min( $price, $max );
		}

		return max( 0.0, $price );
	}

	/**
	 * How a warranty's price should be described where no product is named.
	 *
	 * A percentage warranty on the selector page has a price the shop set, but
	 * saying only that hides the rule — a customer seeing "£29" has no idea it
	 * will be £45 against the item they actually buy.
	 *
	 * @param WC_Product $warranty The warranty product.
	 *
	 * @return string A short suffix, or ''.
	 */
	public static function describe( WC_Product $warranty ): string {
		if ( ! self::is_percentage( $warranty ) ) {
			return '';
		}

		return sprintf(
			/* translators: %s: a percentage, e.g. "10%". */
			__( '%s of the item covered', 'warranty-cart' ),
			self::format_percentage( self::percentage( $warranty ) )
		);
	}

	/**
	 * A percentage without trailing zeros: 10% rather than 10.00%.
	 *
	 * @param float $percentage The percentage.
	 *
	 * @return string
	 */
	public static function format_percentage( float $percentage ): string {
		return sprintf( '%s%%', wc_format_decimal( $percentage, false, true ) );
	}
}
