<?php
/**
 * Warranty Handler
 *
 * Handles warranty creation, renewal, and management operations.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\Core;

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

/**
 * Warranty Handler Class
 */
class Warranties {

	/**
	 * The order item meta naming the product a warranty was bought to cover.
	 *
	 * Underscore-prefixed so WooCommerce keeps it off the customer's receipt:
	 * the line already says which warranty was bought, and a raw product id
	 * beneath it is plumbing.
	 */
	public const COVERS_ORDER_ITEM_META = '_warranty_cart_covers';

	/** Cover is live: claimable, renewable, and swept for reminders. */
	public const STATUS_ACTIVE = 'active';

	/** The term has run out. Still renewable inside the grace period. */
	public const STATUS_EXPIRED = 'expired';

	/**
	 * Cover was taken back — today only because the order was refunded.
	 *
	 * Deliberately a third status rather than a deletion. The record is what the
	 * shop sold and what the customer was told they had; a refunded warranty
	 * still needs to be visible on the order, in exports, and in any dispute
	 * about what happened.
	 */
	public const STATUS_CANCELLED = 'cancelled';

	/**
	 * Every status a warranty can hold.
	 *
	 * @return array<string, string> Status => label.
	 */
	public static function statuses(): array {
		return array(
			self::STATUS_ACTIVE    => \__( 'Active', 'warranty-cart' ),
			self::STATUS_EXPIRED   => \__( 'Expired', 'warranty-cart' ),
			self::STATUS_CANCELLED => \__( 'Cancelled', 'warranty-cart' ),
		);
	}

	/**
	 * Constructor
	 */
	public function __construct() {
		$this->init_hooks();
	}

	/**
	 * Initialize hooks
	 */
	protected function init_hooks(): void {
		/*
		 * One hook, for every gateway.
		 *
		 * There were three: this, plus `warranty_cart_paypal_payment_completed`
		 * and `warranty_cart_stripe_payment_completed` fired by a
		 * StripeIntegration and a PayPalIntegration that each existed to detect
		 * one hardcoded plugin file. They were redundant —
		 * `woocommerce_order_status_completed` is WooCommerce core and fires
		 * whatever paid, down to cash on delivery — and they made the plugin
		 * look as though it needed a specific gateway installed, which it never
		 * has.
		 *
		 * Priority 20 so gateways have finished writing their own order data at
		 * the default 10 before the payment method is read off the order.
		 */
		\add_action( 'woocommerce_order_status_completed', array( $this, 'handle_order_completed' ), 20, 1 );

		/*
		 * Carry the renewal marker from the cart onto the order line.
		 *
		 * The cart is gone by the time an order completes, so without this the
		 * order has no way of knowing that one of its lines is a renewal rather
		 * than a new purchase — and the record would be duplicated instead of
		 * extended.
		 */
		\add_action( 'woocommerce_checkout_create_order_line_item', array( $this, 'record_renewal_on_line_item' ), 10, 3 );

		/*
		 * Give the money back, take the cover back.
		 *
		 * Until this existed, a refunded order left its warranties `active`.
		 * The customer had been repaid and still held cover they could claim
		 * against, renew, and be reminded about — the plugin's own guards read
		 * the status, so every one of them said yes.
		 *
		 * Two hooks because WooCommerce refunds two ways. A full refund moves
		 * the order to `refunded` and voids everything on it. A partial refund
		 * fires `woocommerce_order_refunded` without changing the order status,
		 * and voids only the warranties whose own line has been refunded in
		 * full — refunding a shirt should not cancel the cover on a laptop in
		 * the same basket.
		 */
		\add_action( 'woocommerce_order_status_refunded', array( $this, 'cancel_on_full_refund' ), 20, 1 );
		\add_action( 'woocommerce_order_refunded', array( $this, 'cancel_on_partial_refund' ), 20, 2 );
	}

	/**
	 * Void every warranty on an order that has been refunded in full.
	 *
	 * @param int $order_id The refunded order.
	 *
	 * @return void
	 */
	public function cancel_on_full_refund( int $order_id ): void {
		$this->cancel_warranties(
			$this->warranties_for_order( $order_id ),
			'order_refunded'
		);
	}

	/**
	 * Void the warranties covered by a partial refund.
	 *
	 * A warranty is voided only when its own line item has been refunded to the
	 * full quantity that was bought. A partially refunded line — three of five
	 * returned — leaves the cover alone, because there is no way to know which
	 * of the five it belonged to and cancelling all of it would take cover the
	 * customer has still paid for.
	 *
	 * @param int $order_id  The order.
	 * @param int $refund_id The refund that was just created.
	 *
	 * @return void
	 */
	public function cancel_on_partial_refund( int $order_id, int $refund_id ): void {
		$order = \wc_get_order( $order_id );

		if ( ! $order instanceof \WC_Order ) {
			return;
		}

		// A full refund is handled by the status transition; doing it here too
		// would cancel the same rows twice and log it twice.
		if ( 'refunded' === $order->get_status() ) {
			return;
		}

		$fully_refunded = array();

		foreach ( $order->get_items() as $item_id => $item ) {
			if ( ! $item instanceof \WC_Order_Item_Product ) {
				continue;
			}

			$bought = (int) $item->get_quantity();

			if ( $bought <= 0 ) {
				continue;
			}

			// get_qty_refunded_for_item() returns a negative number.
			$refunded = abs( (int) $order->get_qty_refunded_for_item( $item_id ) );

			if ( $refunded >= $bought ) {
				$fully_refunded[] = (int) $item->get_product_id();
			}
		}

		if ( empty( $fully_refunded ) ) {
			return;
		}

		$warranties = array_filter(
			$this->warranties_for_order( $order_id ),
			static function ( $warranty ) use ( $fully_refunded ) {
				return in_array( (int) $warranty->product_id, $fully_refunded, true );
			}
		);

		$this->cancel_warranties( $warranties, 'line_refunded' );

		unset( $refund_id );
	}

	/**
	 * Every warranty belonging to an order that is not already cancelled.
	 *
	 * @param int $order_id The order.
	 *
	 * @return array<int, object>
	 */
	protected function warranties_for_order( int $order_id ): array {
		global $wpdb;

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

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, product_id, status FROM {$table} WHERE order_id = %d AND status != %s",
				$order_id,
				self::STATUS_CANCELLED
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Move warranties to `cancelled`.
	 *
	 * Nothing else has to change to make this stick: `Claims::create()` requires
	 * `active`, `Renewals::can_renew()` accepts only `active` or `expired`, and
	 * both cron sweeps select `status = 'active'`. A cancelled warranty falls
	 * out of all four by itself.
	 *
	 * @param array<int, object> $warranties Rows with at least `id`.
	 * @param string             $reason     Why, for the log and the hook.
	 *
	 * @return int How many were cancelled.
	 */
	protected function cancel_warranties( array $warranties, string $reason ): int {
		if ( empty( $warranties ) ) {
			return 0;
		}

		global $wpdb;

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

		foreach ( $warranties as $warranty ) {
			$warranty_id = (int) $warranty->id;

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$updated = $wpdb->update(
				$table,
				array(
					'status'     => self::STATUS_CANCELLED,
					'updated_at' => \current_time( 'mysql' ),
				),
				array( 'id' => $warranty_id ),
				array( '%s', '%s' ),
				array( '%d' )
			);

			if ( false === $updated ) {
				\wc_get_logger()->error(
					\sprintf( 'Failed to cancel warranty #%d after %s', $warranty_id, $reason ),
					array( 'source' => 'warranty-cart' )
				);

				continue;
			}

			++$cancelled;

			/**
			 * Fires once a warranty has been voided.
			 *
			 * @since 2.1.0
			 *
			 * @param int    $warranty_id The warranty.
			 * @param string $reason      `order_refunded` or `line_refunded`.
			 */
			\do_action( 'warranty_cart_warranty_cancelled', $warranty_id, $reason );
		}

		if ( $cancelled > 0 ) {
			\wc_get_logger()->info(
				\sprintf( '%d warranty record(s) cancelled: %s', $cancelled, $reason ),
				array( 'source' => 'warranty-cart' )
			);
		}

		return $cancelled;
	}

	/**
	 * Issue a warranty by hand.
	 *
	 * Every other warranty in the plugin arrives the same way: an order
	 * completes and this class writes a record. That covers the shop's ordinary
	 * day and nothing else — a phone order, a policy granted as an apology, a
	 * mistake to be corrected, a migration from whatever the shop used before.
	 * All of those meant editing the table by hand or going without.
	 *
	 * The plan is still the source of truth for what cover means: duration,
	 * unit and price come from the warranty product unless they are overridden
	 * here, so a manual policy is the same object as a sold one and everything
	 * downstream — claims, renewals, reminders, the certificate — treats it
	 * identically.
	 *
	 * @param array $args {
	 *     What to issue.
	 *
	 *     @type int    $product_id         Required. The warranty plan.
	 *     @type int    $customer_id        Required. Who holds the cover.
	 *     @type int    $covered_product_id What it covers, if anything specific.
	 *     @type int    $order_id           An order to attribute it to. 0 for none.
	 *     @type int    $duration           Overrides the plan's term.
	 *     @type string $duration_unit      days|months|years.
	 *     @type float  $purchase_price     What was paid, if anything.
	 *     @type string $purchase_date      Y-m-d H:i:s. Defaults to now.
	 * }
	 *
	 * @return int|\WP_Error The new warranty id.
	 */
	public function issue( array $args ) {
		global $wpdb;

		$product_id  = isset( $args['product_id'] ) ? (int) $args['product_id'] : 0;
		$customer_id = isset( $args['customer_id'] ) ? (int) $args['customer_id'] : 0;

		$product = $product_id ? \wc_get_product( $product_id ) : null;

		if ( ! $product ) {
			return new \WP_Error(
				'warranty_cart_no_plan',
				\__( 'Choose a warranty plan to issue.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

		if ( $customer_id <= 0 || ! \get_userdata( $customer_id ) ) {
			return new \WP_Error(
				'warranty_cart_no_customer',
				\__( 'Choose the customer this cover belongs to.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

		/*
		 * The plan's own terms, unless told otherwise.
		 *
		 * Reading them here rather than making the caller supply them means a
		 * warranty issued from the admin form and one sold through the cart
		 * carry the same term for the same plan, which is what a customer
		 * comparing the two would expect.
		 */
		$duration = isset( $args['duration'] ) && (int) $args['duration'] > 0
			? (int) $args['duration']
			: Repository::product_duration( $product );

		$unit = ! empty( $args['duration_unit'] )
			? (string) $args['duration_unit']
			: Repository::product_duration_unit( $product );

		$price = isset( $args['purchase_price'] ) && '' !== $args['purchase_price']
			? round( (float) $args['purchase_price'], 2 )
			: (float) $product->get_price();

		$purchase_date = ! empty( $args['purchase_date'] )
			? (string) $args['purchase_date']
			: \current_time( 'mysql' );

		$expiration = $this->calculate_warranty_expiration( $duration, $unit, $purchase_date );

		$inserted = $wpdb->insert(
			$wpdb->prefix . 'warranty_cart_warranties',
			array(
				'order_id'           => isset( $args['order_id'] ) ? (int) $args['order_id'] : 0,
				'customer_id'        => $customer_id,
				'product_id'         => $product_id,
				'covered_product_id' => ! empty( $args['covered_product_id'] ) ? (int) $args['covered_product_id'] : null,
				'warranty_type'      => Repository::product_type( $product ),
				'duration'           => $duration,
				'duration_unit'      => $unit,
				'purchase_date'      => $purchase_date,
				'expiration_date'    => $expiration,
				'purchase_price'     => $price,
				'currency'           => \function_exists( 'get_woocommerce_currency' ) ? \get_woocommerce_currency() : '',
				'payment_method'     => '',
				'status'             => self::STATUS_ACTIVE,

				/*
				 * `source` is how a record says where it came from, and the
				 * admin list already shows it. A manual policy that claimed to
				 * be 'woocommerce' would be a lie told to whoever audits this
				 * later.
				 */
				'source'             => 'manual',
				'coverage_details'   => (string) $product->get_meta( '_warranty_cart_coverage_details', true ),
				'created_at'         => \current_time( 'mysql' ),
			),
			array( '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%f', '%s', '%s', '%s', '%s', '%s', '%s' )
		);

		if ( ! $inserted ) {
			return new \WP_Error(
				'warranty_cart_issue_failed',
				\__( 'The warranty could not be issued.', 'warranty-cart' ),
				array( 'status' => 500 )
			);
		}

		$warranty_id = (int) $wpdb->insert_id;

		\wc_get_logger()->info(
			\sprintf( 'Warranty #%d issued manually for customer #%d', $warranty_id, $customer_id ),
			array( 'source' => 'warranty-cart' )
		);

		/**
		 * Fires once a warranty has been issued by hand.
		 *
		 * Deliberately not `warranty_cart_warranty_created`: anything listening
		 * for a sale should not be told about a policy nobody bought, and a
		 * shop wanting to treat them alike can hook both.
		 *
		 * @since 2.1.0
		 *
		 * @param int   $warranty_id The new warranty.
		 * @param array $args        What it was issued from.
		 */
		\do_action( 'warranty_cart_warranty_issued', $warranty_id, $args );

		return $warranty_id;
	}

	/**
	 * Copy the renewal marker from a cart item to its order line item.
	 *
	 * @param \WC_Order_Item_Product $item          The order line item.
	 * @param string                 $cart_item_key The cart item key.
	 * @param array                  $values        The cart item.
	 *
	 * @return void
	 */
	public function record_renewal_on_line_item( $item, $cart_item_key, $values ): void {
		if ( ! $item instanceof \WC_Order_Item_Product || ! is_array( $values ) ) {
			return;
		}

		$warranty_id = (int) ( $values[ Renewals::CART_ITEM_KEY ] ?? 0 );

		if ( $warranty_id > 0 ) {
			$item->add_meta_data( Renewals::ORDER_ITEM_META, $warranty_id, true );
		}

		/*
		 * And which product this cover was bought for.
		 *
		 * Same reason as the renewal marker: the cart is gone by the time the
		 * order completes, so without copying it here the record cannot say
		 * what it covers.
		 */
		$covers = (int) ( $values['warranty_cart_covers'] ?? 0 );

		if ( $covers > 0 ) {
			$item->add_meta_data( self::COVERS_ORDER_ITEM_META, $covers, true );
		}
	}

	/**
	 * Create warranty records once an order is paid and complete.
	 *
	 * @param int $order_id Order ID.
	 */
	public function handle_order_completed( int $order_id ): void {
		$order = \wc_get_order( $order_id );

		if ( ! $order instanceof \WC_Order ) {
			return;
		}

		// Check if this order has already been processed
		if ( $order->get_meta( '_warranty_cart_processed', true ) ) {
			return;
		}

		/*
		 * Renewals first, and they are not warranty items.
		 *
		 * A renewed line carries the same product as the original purchase, so
		 * left in the list below it would create a *second* record covering the
		 * same thing — the customer would end up with two warranties for one
		 * product and the shop would carry the liability twice.
		 */
		$renewed = $this->process_renewals( $order );

		// Get warranty items from order
		$warranty_items = $this->get_order_warranty_items( $order );

		/*
		 * Cover the shop gives away, alongside cover it sold.
		 *
		 * Merged rather than handled separately so an order containing both is
		 * written once and marked processed once — two passes would each see the
		 * marker the other had not yet written.
		 */
		$warranty_items = array_merge( $warranty_items, $this->get_included_warranty_items( $order, $warranty_items ) );

		if ( ! empty( $warranty_items ) ) {
			$this->create_warranties_for_order( $order, $warranty_items, $order->get_payment_method() );

			return;
		}

		/*
		 * An order that only renewed things is still processed.
		 *
		 * Without this the marker is never written, and a second run of this
		 * hook — WooCommerce fires it again on any later transition back into
		 * completed — would extend every renewed warranty a second time.
		 */
		if ( ! empty( $renewed ) ) {
			$order->update_meta_data( '_warranty_cart_processed', true );
			$order->update_meta_data( '_warranty_cart_renewed', $renewed );
			$order->save();
		}
	}

	/**
	 * Extend every warranty this order renews.
	 *
	 * @param \WC_Order $order The completed order.
	 *
	 * @return array<int, int> Warranty IDs that were extended.
	 */
	protected function process_renewals( \WC_Order $order ): array {
		$renewed = array();

		foreach ( $order->get_items() as $item ) {
			if ( ! $item instanceof \WC_Order_Item_Product ) {
				continue;
			}

			$warranty_id = (int) $item->get_meta( Renewals::ORDER_ITEM_META, true );

			if ( $warranty_id <= 0 ) {
				continue;
			}

			if ( $this->renew_warranty( $warranty_id, $order->get_id() ) ) {
				$renewed[] = $warranty_id;

				$order->add_order_note(
					\sprintf(
						/* translators: %d: warranty record ID. */
						\__( 'Warranty #%d renewed.', 'warranty-cart' ),
						$warranty_id
					)
				);
			}
		}

		return $renewed;
	}

	/**
	 * Create warranties for an order
	 *
	 * @param \WC_Order $order          Order object.
	 * @param array     $warranty_items Warranty items data.
	 * @param string    $source         Gateway that took the payment, e.g. `stripe`,
	 *                                  `ppcp-gateway`, `cod`. Was one of three
	 *                                  values naming the code path that got here
	 *                                  rather than the gateway, which told a store
	 *                                  owner nothing the row did not already say.
	 */
	protected function create_warranties_for_order( \WC_Order $order, array $warranty_items, string $source ): void {
		// Prevent duplicate processing
		if ( $order->get_meta( '_warranty_cart_processed', true ) ) {
			return;
		}

		$created_warranties = array();

		foreach ( $warranty_items as $item ) {
			/*
			 * Cover that came free says so, rather than naming the gateway that
			 * took the money for the goods it came with. A shop reading its
			 * warranty list needs to be able to tell what it sold from what it
			 * gave away — the loss on the two is not the same question.
			 */
			$warranty_id = $this->create_warranty_record(
				$order,
				$item,
				isset( $item['source'] ) ? (string) $item['source'] : $source
			);

			if ( $warranty_id ) {
				$created_warranties[] = $warranty_id;
			}
		}

		if ( ! empty( $created_warranties ) ) {
			// Mark order as processed
			$order->update_meta_data( '_warranty_cart_processed', true );
			$order->update_meta_data( '_warranty_cart_warranties', $created_warranties );
			$order->update_meta_data( '_warranty_cart_source', $source );
			$order->save();

			// Log successful warranty creation
			$message = \sprintf(
				'Created %d warranty record(s) for order #%d via %s',
				\count( $created_warranties ),
				$order->get_id(),
				$source
			);

			\wc_get_logger()->info( $message, array( 'source' => 'warranty-cart-creation' ) );

			// Trigger action for further processing
			\do_action( 'warranty_cart_warranties_created', $order, $created_warranties, $source );
		}
	}

	/**
	 * Create a warranty record
	 *
	 * @param \WC_Order $order         Order object.
	 * @param array     $warranty_item Warranty item data.
	 * @param string    $source        Payment source.
	 *
	 * @return int|null Warranty record ID or null on failure.
	 */
	protected function create_warranty_record( \WC_Order $order, array $warranty_item, string $source ): ?int {
		try {
			// Validate required warranty item data
			if ( empty( $warranty_item['product_id'] ) || empty( $warranty_item['warranty_type'] ) ) {
				\wc_get_logger()->error(
					'Invalid warranty item data: missing product_id or warranty_type',
					array( 'source' => 'warranty-cart-creation' )
				);

				return null;
			}

			/*
			 * Anchored to the order's own creation date.
			 *
			 * Also the value written to purchase_date below, so the record
			 * cannot say a warranty was bought on one date and runs from
			 * another.
			 */
			$purchase_date = $order->get_date_created();
			$purchase_date = $purchase_date ? $purchase_date->date( 'Y-m-d H:i:s' ) : \current_time( 'mysql' );

			$expiration_date = $this->calculate_warranty_expiration(
				(int) ( $warranty_item['duration'] ?? 1 ),
				(string) ( $warranty_item['duration_unit'] ?? 'years' ),
				$purchase_date
			);

			// Validate expiration date
			if ( empty( $expiration_date ) ) {
				\wc_get_logger()->error(
					'Failed to calculate expiration date for warranty',
					array( 'source' => 'warranty-cart-creation' )
				);

				return null;
			}

			/*
			 * Resolve the product from its id rather than trusting the caller.
			 *
			 * This read $warranty_item['product'] directly, a key only one of
			 * the three callers this method then had ever set. The other two —
			 * the since-removed Stripe and PayPal integrations — built their
			 * item arrays without it, so every completed Stripe or PayPal order
			 * containing a warranty product raised "Call to a member function
			 * get_meta() on null" and took the request down with it.
			 *
			 * product_id is the field the item array is actually keyed on, and
			 * it is already validated above.
			 */
			$product = \wc_get_product( (int) $warranty_item['product_id'] );

			/*
			 * The key the product editor actually writes.
			 *
			 * This asked for `_warranty_coverage_details`, which nothing in the
			 * plugin has ever saved; Admin\Product\Meta writes
			 * `_warranty_cart_coverage_details`. Every warranty record created
			 * so far therefore stored empty coverage details.
			 */
			$coverage = $product ? (string) $product->get_meta( '_warranty_cart_coverage_details', true ) : '';

			// Prepare warranty data with validation
			$warranty_data = array(
				'order_id'           => $order->get_id(),
				'customer_id'        => $order->get_customer_id(),
				'product_id'         => (int) $warranty_item['product_id'],

				/*
				 * Which item this cover was bought for, when it was bought
				 * alongside one. Null for a warranty chosen from the selector
				 * page, which is not the same as zero: nothing was covered
				 * rather than product #0.
				 */
				'covered_product_id' => ( (int) ( $warranty_item['covers'] ?? 0 ) ) > 0
					? (int) $warranty_item['covers']
					: null,
				'warranty_type'      => sanitize_text_field( $warranty_item['warranty_type'] ),
				'duration'           => (int) $warranty_item['duration'],
				'duration_unit'      => sanitize_text_field( $warranty_item['duration_unit'] ),
				// Resolved above, with a fallback: get_date_created() is
				// nullable, and calling ->date() on null fataled.
				'purchase_date'      => $purchase_date,
				'expiration_date'    => $expiration_date,
				'purchase_price'     => (float) $warranty_item['total'],
				'currency'           => sanitize_text_field( $order->get_currency() ),
				'payment_method'     => sanitize_text_field( $order->get_payment_method() ),
				'status'             => 'active',
				'source'             => sanitize_text_field( $source ),
				'coverage_details'   => sanitize_textarea_field( $coverage ),
				'created_at'         => \current_time( 'mysql' ),
			);

			// Insert warranty record
			global $wpdb;
			$table_name = $wpdb->prefix . 'warranty_cart_warranties';

			$result = $wpdb->insert(
				$table_name,
				$warranty_data,
				array(
					'%d', // order_id
					'%d', // customer_id
					'%d', // product_id
					'%d', // covered_product_id
					'%s', // warranty_type
					'%d', // duration
					'%s', // duration_unit
					'%s', // purchase_date
					'%s', // expiration_date
					'%f', // purchase_price
					'%s', // currency
					'%s', // payment_method
					'%s', // status
					'%s', // source
					'%s', // coverage_details
					'%s', // created_at
				)
			);

			if ( false === $result ) {
				\wc_get_logger()->error(
					'Failed to create warranty record for order #' . $order->get_id(),
					array( 'source' => 'warranty-cart-creation' )
				);

				return null;
			}

			$warranty_id = $wpdb->insert_id;

			// Trigger action for individual warranty creation
			\do_action( 'warranty_cart_warranty_created', $warranty_id, $warranty_data, $order );

			return $warranty_id;
		} catch ( \Exception $e ) {
			\wc_get_logger()->error(
				'Exception creating warranty record: ' . $e->getMessage(),
				array( 'source' => 'warranty-cart-creation' )
			);

			return null;
		}
	}

	/**
	 * Calculate warranty expiration date
	 *
	 * @param int    $duration Duration value.
	 * @param string $unit     Duration unit (days, months, years).
	 *
	 * @return string Expiration date in Y-m-d format.
	 */
	protected function calculate_warranty_expiration( int $duration, string $unit, ?string $start = null ): string {
		// Validate inputs
		if ( $duration < 1 ) {
			$duration = 1;
		}

		$valid_units = array( 'days', 'months', 'years' );
		if ( ! in_array( $unit, $valid_units, true ) ) {
			$unit = 'years';
		}

		// Use DateTime for accurate calculations
		try {
			/*
			 * Counted from the purchase, not from this moment.
			 *
			 * This always started at 'now', so the term ran from whenever the
			 * record happened to be written rather than from when the warranty
			 * was sold. For an order completed seconds after checkout the error
			 * is invisible; for one completed a week later, an order completed
			 * by hand, or anything imported or backfilled, the customer
			 * silently receives more coverage than they bought — and the store
			 * carries a liability it never priced.
			 *
			 * $start is the order's creation date where the caller has one.
			 */
			$from = new \DateTime( null !== $start && '' !== $start ? $start : 'now', wp_timezone() );

			switch ( $unit ) {
				case 'days':
					$from->add( new \DateInterval( 'P' . $duration . 'D' ) );
					break;
				case 'months':
					$from->add( new \DateInterval( 'P' . $duration . 'M' ) );
					break;
				case 'years':
				default:
					$from->add( new \DateInterval( 'P' . $duration . 'Y' ) );
					break;
			}

			return $from->format( 'Y-m-d' );
		} catch ( \Exception $e ) {
			// Fallback to strtotime, still anchored to the purchase where known.
			$base      = ( null !== $start && '' !== $start ) ? \strtotime( $start ) : \time();
			$base      = $base ? $base : \time();
			$timestamp = \strtotime( "+{$duration} {$unit}", $base );

			if ( $timestamp ) {
				return \wp_date( 'Y-m-d', $timestamp );
			}

			// Ultimate fallback: 1 year from the same base.
			return \wp_date( 'Y-m-d', (int) \strtotime( '+1 year', $base ) );
		}
	}

	/**
	 * How long a plan runs for, and in what unit.
	 *
	 * @param \WC_Product $plan The warranty product.
	 *
	 * @return array{0: int, 1: string} Duration, then unit.
	 */
	protected function plan_terms( \WC_Product $plan ): array {
		$duration      = (int) $plan->get_meta( '_warranty_cart_duration', true );
		$duration_unit = $plan->get_meta( '_warranty_cart_duration_unit', true );

		/*
		 * Fall back to the configured default, not to a hardcoded 1.
		 *
		 * The Settings screen offers "Default Duration" and "Default Unit" and
		 * saves them to `warranty_cart_default_duration` and
		 * `warranty_cart_default_unit`. Nothing read either option, so a product
		 * missing its own duration got one year regardless of what the store had
		 * configured — a setting that persisted, reloaded correctly, and changed
		 * nothing.
		 */
		if ( $duration < 1 ) {
			$duration = max( 1, (int) \get_option( 'warranty_cart_default_duration', 1 ) );
		}

		$valid_units = array( 'days', 'months', 'years' );

		if ( ! in_array( $duration_unit, $valid_units, true ) ) {
			$duration_unit = (string) \get_option( 'warranty_cart_default_unit', 'years' );
		}

		if ( ! in_array( $duration_unit, $valid_units, true ) ) {
			$duration_unit = 'years';
		}

		return array( $duration, (string) $duration_unit );
	}

	/**
	 * Cover the shop gives away with the goods in an order.
	 *
	 * A shop that guarantees its own products for a year is not selling
	 * anything, so nothing in the order says the warranty exists. The rule does,
	 * and it is read here — against each ordinary line, at the moment the order
	 * completes, which is the same moment paid cover starts.
	 *
	 * @param \WC_Order                        $order    The completed order.
	 * @param array<int, array<string, mixed>> $purchased Warranty items already
	 *                                                    found on the order, so
	 *                                                    cover somebody paid for
	 *                                                    is not also given away.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	protected function get_included_warranty_items( \WC_Order $order, array $purchased = array() ): array {
		$items = array();

		// Plan and covered product together: the same plan can be included with
		// two different things in one order, and each is its own record.
		$already = array();

		foreach ( $purchased as $item ) {
			$already[] = (int) $item['product_id'] . ':' . (int) ( $item['covers'] ?? 0 );
		}

		foreach ( $order->get_items( 'line_item' ) as $item ) {
			if ( ! $item instanceof \WC_Order_Item_Product ) {
				continue;
			}

			$product = $item->get_product();

			// A warranty is not something to include a warranty with, and a
			// renewal line is the same product as the purchase it extends.
			if ( ! $product instanceof \WC_Product
				|| '' !== Repository::product_type( $product )
				|| (int) $item->get_meta( Renewals::ORDER_ITEM_META, true ) > 0 ) {
				continue;
			}

			foreach ( Coverage::included_for_product( $product ) as $plan ) {
				$key = $plan->get_id() . ':' . $product->get_id();

				if ( in_array( $key, $already, true ) ) {
					continue;
				}

				$already[] = $key;

				list( $duration, $duration_unit ) = $this->plan_terms( $plan );

				$items[] = array(
					'product'       => $plan,
					'order_item'    => $item,
					'product_id'    => $plan->get_id(),
					'product_name'  => $plan->get_name(),
					'covers'        => $product->get_id(),
					'quantity'      => 1,
					'total'         => 0.0,
					'warranty_type' => 'main_warranty',
					'duration'      => $duration,
					'duration_unit' => $duration_unit,
					'source'        => 'included',
				);
			}
		}

		return $items;
	}

	/**
	 * Get warranty items from order
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return array Array of warranty items.
	 */
	protected function get_order_warranty_items( \WC_Order $order ): array {
		$warranty_items = array();

		foreach ( $order->get_items( 'line_item' ) as $item ) {
			$product = $item->get_product();

			if ( ! $product ) {
				continue;
			}

			/*
			 * A renewal extends a record; it does not make a new one.
			 *
			 * The line carries the same product as the original purchase, so
			 * without this it reads as an ordinary warranty sale and the
			 * customer ends up holding two warranties for one product.
			 * `process_renewals()` has already dealt with this line.
			 */
			if ( $item instanceof \WC_Order_Item_Product
				&& (int) $item->get_meta( Renewals::ORDER_ITEM_META, true ) > 0 ) {
				continue;
			}

			// Check if this is a warranty product using meta fields
			$product_status = $product->get_meta( '_warranty_cart_product_status', true );
			$warranty_type  = $product->get_meta( '_warranty_cart_product_type', true );

			if ( 'enabled' === $product_status && ! empty( $warranty_type ) ) {
				list( $duration, $duration_unit ) = $this->plan_terms( $product );

				$warranty_items[] = array(
					'product'       => $product,
					'order_item'    => $item,
					'product_id'    => $product->get_id(),
					'product_name'  => $product->get_name(),
					'covers'        => $item instanceof \WC_Order_Item_Product
						? (int) $item->get_meta( self::COVERS_ORDER_ITEM_META, true )
						: 0,
					'quantity'      => $item->get_quantity(),
					'total'         => $item->get_total(),
					'warranty_type' => $warranty_type,
					'duration'      => $duration,
					'duration_unit' => $duration_unit,
				);
			}
		}

		return $warranty_items;
	}

	/**
	 * Record one renewal.
	 *
	 * `renewal_count` and `last_renewal_date` on the warranty are a tally: they
	 * say a policy has been renewed three times and nothing else — not what each
	 * renewal cost, which order paid for it, or how far the term moved each
	 * time. A shop owner looking at cover renewed for five running years wants
	 * the shape of that relationship, and a customer querying a charge wants the
	 * order behind it.
	 *
	 * Written here rather than at either call site, because this method is the
	 * one place both paths meet — a customer's completed order and an
	 * administrator renewing by hand.
	 *
	 * @param object $warranty       The warranty as it was before renewal.
	 * @param string $new_expiration The expiry it now carries.
	 * @param int    $order_id       The order that paid for it, or 0.
	 *
	 * @return void
	 */
	protected function log_renewal( object $warranty, string $new_expiration, int $order_id ): void {
		global $wpdb;

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

		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
			return;
		}

		$amount   = null;
		$currency = null;
		$order    = $order_id > 0 ? \wc_get_order( $order_id ) : null;

		if ( $order instanceof \WC_Order ) {
			/*
			 * What this renewal cost, not what the order totalled.
			 *
			 * An order can renew one warranty and buy three other things; the
			 * line is the part that belongs to this record.
			 */
			foreach ( $order->get_items() as $item ) {
				if ( $item instanceof \WC_Order_Item_Product
					&& (int) $item->get_meta( Renewals::ORDER_ITEM_META, true ) === (int) $warranty->id ) {
					$amount = (float) $item->get_total();
					break;
				}
			}

			$currency = $order->get_currency();
		}

		$wpdb->insert(   // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$table,
			array(
				'warranty_id'         => (int) $warranty->id,
				'order_id'            => $order_id > 0 ? $order_id : null,
				'previous_expiration' => $warranty->expiration_date,
				'new_expiration'      => $new_expiration,
				'amount'              => $amount,
				'currency'            => $currency,
				// Which path this came by, because they behave differently: an
				// administrator's renewal takes no payment.
				'source'              => $order_id > 0 ? 'order' : 'admin',
				'created_at'          => \current_time( 'mysql' ),
			)
		);
	}

	/**
	 * Every renewal a warranty has had, newest first.
	 *
	 * @param int $warranty_id The warranty.
	 *
	 * @return array<int, object>
	 */
	public static function renewal_history( int $warranty_id ): array {
		global $wpdb;

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

		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
			return array();
		}

		$rows = $wpdb->get_results(   // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

			/*
			 * `id` breaks the tie, in the same direction.
			 *
			 * `created_at` has one-second resolution, and two renewals inside one
			 * second — a retry, a script, a test — otherwise come back in
			 * whatever order the storage engine feels like, which for a "newest
			 * first" list means sometimes oldest first.
			 */
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names cannot be placeholders.
				"SELECT * FROM {$table} WHERE warranty_id = %d ORDER BY created_at DESC, id DESC",
				$warranty_id
			)
		);

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Renew a warranty
	 *
	 * @param int $warranty_id Warranty ID to renew.
	 *
	 * @return bool True on success, false on failure.
	 */
	public function renew_warranty( int $warranty_id, int $order_id = 0 ): bool {
		global $wpdb;

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

		// Validate warranty ID
		if ( 0 >= $warranty_id ) {
			\wc_get_logger()->error(
				'Invalid warranty ID provided for renewal',
				array( 'source' => 'warranty-cart-renewal' )
			);

			return false;
		}

		/*
		 * Get current warranty data.
		 *
		 * Interpolated table name. As a %s placeholder it was quoted as a
		 * value, so this lookup always failed and every renewal reported
		 * "Warranty #n not found".
		 */
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$warranty = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table_name} WHERE id = %d",
				$warranty_id
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( ! $warranty ) {
			\wc_get_logger()->error(
				\sprintf( 'Warranty #%d not found for renewal', $warranty_id ),
				array( 'source' => 'warranty-cart-renewal' )
			);

			return false;
		}

		// Check if warranty can be renewed (not already expired beyond renewal period)
		if ( 'expired' === $warranty->status ) {
			$current_date = \current_time( 'Y-m-d' );

			// The store's configured grace period, not a hardcoded month. The
			// same number decides whether the Renew action is offered at all,
			// so the two must not be able to disagree.
			$renewal_deadline = \wp_date(
				'Y-m-d',
				\strtotime( $warranty->expiration_date . ' +' . Renewals::grace_days() . ' days' )
			);

			if ( $current_date > $renewal_deadline ) {
				\wc_get_logger()->error(
					\sprintf( 'Warranty #%d cannot be renewed - expired too long ago', $warranty_id ),
					array( 'source' => 'warranty-cart-renewal' )
				);

				return false;
			}
		}

		/*
		 * The new term runs from the later of today and the current expiry.
		 *
		 * This passed no start date, so `calculate_warranty_expiration()` used
		 * its `now` default and every renewal counted from the moment it was
		 * processed. A customer with two months of cover left who renewed for a
		 * year got a year from today — they paid for twelve months and lost the
		 * two they had already bought, so renewing early was punished, which is
		 * precisely what the reminder email asks them to do.
		 *
		 * Renewing after a lapse still counts from today; see
		 * Renewals::renewal_start() for why the two cases differ.
		 */
		$new_expiration = $this->calculate_warranty_expiration(
			(int) $warranty->duration,
			$warranty->duration_unit,
			Renewals::renewal_start( $warranty )
		);

		if ( empty( $new_expiration ) ) {
			\wc_get_logger()->error(
				\sprintf( 'Failed to calculate new expiration date for warranty #%d', $warranty_id ),
				array( 'source' => 'warranty-cart-renewal' )
			);

			return false;
		}

		// Update warranty record
		$result = $wpdb->update(
			$table_name,
			array(
				'expiration_date'   => $new_expiration,
				'status'            => 'active',
				'renewal_count'     => (int) $warranty->renewal_count + 1,
				'last_renewal_date' => \current_time( 'mysql' ),
				'updated_at'        => \current_time( 'mysql' ),
			),
			array( 'id' => $warranty_id ),
			array( '%s', '%s', '%d', '%s', '%s' ),
			array( '%d' )
		);

		if ( false !== $result ) {
			$this->log_renewal( $warranty, $new_expiration, $order_id );

			// Trigger renewal action
			\do_action( 'warranty_cart_warranty_renewed', $warranty_id, $warranty, $new_expiration );

			\wc_get_logger()->info(
				\sprintf( 'Warranty #%d renewed successfully to %s', $warranty_id, $new_expiration ),
				array( 'source' => 'warranty-cart-renewal' )
			);

			return true;
		}

		\wc_get_logger()->error(
			\sprintf( 'Database update failed for warranty #%d renewal', $warranty_id ),
			array( 'source' => 'warranty-cart-renewal' )
		);

		return false;
	}
}
