<?php
/**
 * Warranty renewals.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\Core;

use WC_Product;
use WP_Error;

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

/**
 * Whether a warranty may be renewed, and starting the renewal.
 *
 * The plugin already warned a customer that their cover was about to lapse and
 * then gave them nothing to do about it — the renew endpoint existed but was
 * admin-only, so the one moment in the lifecycle with the highest intent was
 * also the one with no action attached to it.
 *
 * The rules live here rather than in the controller for the same reason they do
 * in `Claims`: "you may only renew your own warranty" has to hold on
 * every path that reaches a renewal, not only the one that arrives over REST.
 */
class Renewals {

	/**
	 * Whether customers may renew at all.
	 */
	public const ENABLED_OPTION = 'warranty_cart_renewals_enabled';

	/**
	 * How many days before expiry the Renew action appears.
	 */
	public const WINDOW_OPTION = 'warranty_cart_renewal_window';

	/**
	 * How many days after expiry a warranty can still be renewed.
	 */
	public const GRACE_OPTION = 'warranty_cart_renewal_grace';

	/**
	 * The cart item key marking a line as a renewal of an existing warranty.
	 *
	 * Carried from the cart onto the order line item, which is what lets order
	 * completion extend the existing record instead of writing a second one.
	 */
	public const CART_ITEM_KEY = 'warranty_cart_renewal_of';

	/**
	 * The order item meta key holding the renewed warranty's ID.
	 *
	 * Underscore-prefixed so WooCommerce keeps it out of the customer-facing
	 * order item display — it is plumbing, not something a customer needs to
	 * read on their receipt.
	 */
	public const ORDER_ITEM_META = '_warranty_cart_renewal_of';

	/**
	 * Whether the renewal feature is switched on.
	 *
	 * @return bool
	 */
	public static function is_enabled(): bool {
		return (bool) get_option( self::ENABLED_OPTION, true );
	}

	/**
	 * How many days before expiry the Renew action appears.
	 *
	 * Defaults to 30, matching the reminder email — offering renewal at the
	 * moment the reminder lands is the point of the feature, and a window
	 * narrower than the reminder would mean the email arrives before the button
	 * a customer would use to act on it.
	 *
	 * @return int
	 */
	public static function window_days(): int {
		$days = (int) get_option( self::WINDOW_OPTION, 30 );

		return $days > 0 ? $days : 30;
	}

	/**
	 * How many days after expiry a warranty can still be renewed.
	 *
	 * @return int
	 */
	public static function grace_days(): int {
		$days = (int) get_option( self::GRACE_OPTION, 30 );

		return $days >= 0 ? $days : 30;
	}

	/**
	 * One warranty record.
	 *
	 * @param int $warranty_id Warranty ID.
	 *
	 * @return object|null
	 */
	public static function warranty( int $warranty_id ): ?object {
		global $wpdb;

		if ( $warranty_id <= 0 ) {
			return null;
		}

		$table = $wpdb->prefix . 'warranty_cart_warranties';

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names cannot be placeholders.
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $warranty_id ) );

		return $row ? $row : null;
	}

	/**
	 * The date a renewal's new term should run from.
	 *
	 * The later of today and the current expiry, and the choice matters in both
	 * directions:
	 *
	 * - Renewing early, from the current expiry. `renew_warranty()` used to
	 *   count from `now` unconditionally, so a customer with two months left who
	 *   renewed for a year ended up with a year from today — paying for twelve
	 *   months and losing the two they had already bought. Renewing early was
	 *   punished, which is exactly the behaviour the reminder email asks for.
	 * - Renewing after a lapse, from today. Counting from the old expiry would
	 *   spend part of the new term on days that have already passed, selling
	 *   cover for a period nothing can be claimed against.
	 *
	 * The gap left by a lapse is not papered over: the customer was uncovered
	 * while it lasted, and the new term starting today says so.
	 *
	 * @param object $warranty The warranty record.
	 *
	 * @return string A `Y-m-d H:i:s` date to count the new term from.
	 */
	public static function renewal_start( object $warranty ): string {
		$now    = current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Compared against another local timestamp.
		$expiry = strtotime( (string) ( $warranty->expiration_date ?? '' ) );

		if ( false === $expiry || $expiry < $now ) {
			return wp_date( 'Y-m-d H:i:s', $now );
		}

		return wp_date( 'Y-m-d H:i:s', $expiry );
	}

	/**
	 * Whether a warranty is inside its renewal window.
	 *
	 * @param object $warranty The warranty record.
	 *
	 * @return bool
	 */
	public static function is_in_window( object $warranty ): bool {
		$expiry = strtotime( (string) ( $warranty->expiration_date ?? '' ) );

		if ( false === $expiry ) {
			return false;
		}

		$now = current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Compared against another local timestamp.

		$opens  = $expiry - ( self::window_days() * DAY_IN_SECONDS );
		$closes = $expiry + ( self::grace_days() * DAY_IN_SECONDS );

		return $now >= $opens && $now <= $closes;
	}

	/**
	 * Whether this customer may renew this warranty, and why not.
	 *
	 * @param object $warranty    The warranty record.
	 * @param int    $customer_id Who is asking.
	 *
	 * @return true|WP_Error
	 */
	public static function can_renew( object $warranty, int $customer_id ) {
		if ( ! self::is_enabled() ) {
			return new WP_Error(
				'warranty_cart_renewals_disabled',
				__( 'Renewals are not being accepted.', 'warranty-cart' ),
				array( 'status' => 403 )
			);
		}

		/*
		 * Ownership, checked here rather than only in the REST layer. This is
		 * the rule that stops one customer renewing — and being charged for —
		 * another account's cover.
		 */
		if ( $customer_id <= 0 || (int) $warranty->customer_id !== $customer_id ) {
			return new WP_Error(
				'warranty_cart_not_yours',
				__( 'That warranty belongs to a different account.', 'warranty-cart' ),
				array( 'status' => 403 )
			);
		}

		$status = (string) $warranty->status;

		if ( ! in_array( $status, array( 'active', 'expired' ), true ) ) {
			return new WP_Error(
				'warranty_cart_not_renewable',
				__( 'That warranty cannot be renewed.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

		if ( ! self::is_in_window( $warranty ) ) {
			return new WP_Error(
				'warranty_cart_outside_window',
				self::window_message( $warranty ),
				array( 'status' => 400 )
			);
		}

		if ( ! self::product_for( $warranty ) instanceof WC_Product ) {
			return new WP_Error(
				'warranty_cart_product_gone',
				__( 'The warranty you bought is no longer sold, so it cannot be renewed.', 'warranty-cart' ),
				array( 'status' => 410 )
			);
		}

		return true;
	}

	/**
	 * Why a warranty is outside its renewal window, in the customer's terms.
	 *
	 * "Too early" and "too late" are different problems with different answers —
	 * one is worth waiting for and the other is not — so they do not share a
	 * message.
	 *
	 * @param object $warranty The warranty record.
	 *
	 * @return string
	 */
	private static function window_message( object $warranty ): string {
		$expiry = strtotime( (string) ( $warranty->expiration_date ?? '' ) );
		$now    = current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Compared against another local timestamp.

		if ( false !== $expiry && $now < $expiry ) {
			return sprintf(
				/* translators: %s: formatted date. */
				__( 'This warranty can be renewed from %s.', 'warranty-cart' ),
				wp_date( get_option( 'date_format' ), $expiry - ( self::window_days() * DAY_IN_SECONDS ) )
			);
		}

		return __( 'This warranty expired too long ago to be renewed.', 'warranty-cart' );
	}

	/**
	 * The product a warranty was sold as, if it is still purchasable.
	 *
	 * A renewal is a fresh purchase of the same cover, so it has to go through
	 * a product that still exists and can still be bought — the shop is being
	 * paid for it.
	 *
	 * @param object $warranty The warranty record.
	 *
	 * @return WC_Product|null
	 */
	public static function product_for( object $warranty ): ?WC_Product {
		$product = wc_get_product( (int) ( $warranty->product_id ?? 0 ) );

		if ( ! $product instanceof WC_Product || ! $product->exists() || ! $product->is_purchasable() ) {
			return null;
		}

		return $product;
	}

	/**
	 * Put a renewal in the customer's cart.
	 *
	 * Through the cart, not straight into the record: a renewal is cover the
	 * shop is selling again, so it should produce an order, a payment and a
	 * receipt like any other sale. The record is extended when that order
	 * completes — see `Warranties::handle_order_completed()`.
	 *
	 * @param int $warranty_id Warranty to renew.
	 * @param int $customer_id Who is renewing.
	 *
	 * @return array<string, mixed>|WP_Error What was added, or why it was refused.
	 */
	public static function add_to_cart( int $warranty_id, int $customer_id ) {
		$warranty = self::warranty( $warranty_id );

		if ( ! $warranty ) {
			return new WP_Error(
				'warranty_cart_no_warranty',
				__( 'That warranty could not be found.', 'warranty-cart' ),
				array( 'status' => 404 )
			);
		}

		$allowed = self::can_renew( $warranty, $customer_id );

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

		if ( ! function_exists( 'WC' ) ) {
			return new WP_Error(
				'warranty_cart_no_woocommerce',
				__( 'WooCommerce is not active.', 'warranty-cart' ),
				array( 'status' => 503 )
			);
		}

		// REST requests do not boot the cart; see Cart::add_to_cart().
		if ( null === WC()->cart && function_exists( 'wc_load_cart' ) ) {
			wc_load_cart();
		}

		if ( null === WC()->cart ) {
			return new WP_Error(
				'warranty_cart_no_cart',
				__( 'The cart is unavailable.', 'warranty-cart' ),
				array( 'status' => 503 )
			);
		}

		$product = self::product_for( $warranty );

		/*
		 * One renewal of a given warranty at a time.
		 *
		 * Without this, pressing Renew twice puts two lines in the cart and the
		 * customer is charged for two terms while the record is extended once —
		 * the second payment buys nothing.
		 */
		if ( self::cart_contains_renewal_of( $warranty_id ) ) {
			return new WP_Error(
				'warranty_cart_already_in_cart',
				__( 'This renewal is already in your cart.', 'warranty-cart' ),
				array( 'status' => 409 )
			);
		}

		/*
		 * Priced against the same product the original cover was bought for.
		 *
		 * A percentage warranty renewed without this would fall back to the
		 * product's fixed price, so a customer renewing 10% cover on a £2,000
		 * television would be charged the £29 the warranty product happens to
		 * carry. `covered_product_id` is NULL for anything bought before that
		 * column existed, and for anything bought from the selector page —
		 * Pricing::price() falls back to the fixed price for those, which is
		 * what they were sold at.
		 */
		$covered = wc_get_product( (int) ( $warranty->covered_product_id ?? 0 ) );
		$covered = $covered instanceof WC_Product ? $covered : null;

		$key = WC()->cart->add_to_cart(
			$product->get_id(),
			1,
			0,
			array(),
			array(
				self::CART_ITEM_KEY    => $warranty_id,
				Pricing::CART_ITEM_KEY => Pricing::price( $product, $covered ),
			)
		);

		if ( ! $key ) {
			return new WP_Error(
				'warranty_cart_add_failed',
				__( 'The renewal could not be added to your cart.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

		/**
		 * Fires after a renewal is added to the cart.
		 *
		 * @param int    $warranty_id The warranty being renewed.
		 * @param int    $customer_id Who is renewing.
		 * @param string $key         The cart item key.
		 */
		do_action( 'warranty_cart_renewal_added_to_cart', $warranty_id, $customer_id, $key );

		return array(
			'warranty_id'  => $warranty_id,
			'product_id'   => $product->get_id(),
			'cart_item'    => $key,
			'cart_count'   => WC()->cart->get_cart_contents_count(),
			'redirect_url' => wc_get_checkout_url(),
			'expires_on'   => wp_date( get_option( 'date_format' ), strtotime( self::renewal_start( $warranty ) ) ),
		);
	}

	/**
	 * Whether the cart already holds a renewal of this warranty.
	 *
	 * @param int $warranty_id Warranty ID.
	 *
	 * @return bool
	 */
	public static function cart_contains_renewal_of( int $warranty_id ): bool {
		if ( ! function_exists( 'WC' ) || null === WC()->cart ) {
			return false;
		}

		foreach ( WC()->cart->get_cart() as $item ) {
			if ( isset( $item[ self::CART_ITEM_KEY ] ) && (int) $item[ self::CART_ITEM_KEY ] === $warranty_id ) {
				return true;
			}
		}

		return false;
	}
}
