<?php
/**
 * Reaching a warranty without an account.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\Core;

use WP_Error;

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

/**
 * What a customer without an account is allowed to see and do.
 *
 * WooCommerce lets a shop take orders from people who never register, and most
 * shops leave that on. Cover bought that way was recorded against
 * `customer_id = 0` and then unreachable: no account to log into, so no
 * warranties tab, so no claim — the plugin sold a promise it gave the customer
 * no way to call in.
 *
 * The key is the order number and the email address together, which is the same
 * pair WooCommerce's own order tracking asks for. Either alone is useless: order
 * numbers are sequential and guessable, and an email address says nothing about
 * which orders it placed.
 */
class GuestAccess {

	/**
	 * Whether the shop accepts claims from people without an account.
	 */
	public const ENABLED_OPTION = 'warranty_cart_guest_claims';

	/**
	 * How many failed lookups an address may make before it is made to wait.
	 */
	public const ATTEMPT_LIMIT = 10;

	/**
	 * Whether guest access is switched on.
	 *
	 * Defaults to on, because a shop that takes guest orders and does not know
	 * this setting exists would otherwise sell cover nobody can claim against —
	 * the failure this class was written to end.
	 *
	 * @return bool
	 */
	public static function is_enabled(): bool {
		$enabled = \get_option( self::ENABLED_OPTION, 'yes' );

		/**
		 * Filter whether guests may reach their warranties.
		 *
		 * @param bool $enabled Whether it is allowed.
		 */
		return (bool) \apply_filters( 'warranty_cart_guest_access_enabled', 'no' !== $enabled );
	}

	/**
	 * The order behind an order number and email address.
	 *
	 * @param int    $order_id The order number the customer was given.
	 * @param string $email    The address the order was placed with.
	 *
	 * @return \WC_Order|WP_Error The order, or why it was refused.
	 */
	public static function verify( int $order_id, string $email ) {
		if ( ! self::is_enabled() ) {
			return new WP_Error(
				'warranty_cart_guest_disabled',
				\__( 'Please sign in to see your warranties.', 'warranty-cart' ),
				array( 'status' => 403 )
			);
		}

		$email = \sanitize_email( $email );

		if ( $order_id <= 0 || ! \is_email( $email ) ) {
			return self::refused();
		}

		if ( self::attempts_exhausted() ) {
			return new WP_Error(
				'warranty_cart_guest_throttled',
				\__( 'Too many attempts. Please wait a few minutes and try again.', 'warranty-cart' ),
				array( 'status' => 429 )
			);
		}

		$order = \wc_get_order( $order_id );

		if ( ! $order instanceof \WC_Order ) {
			self::record_failure();

			return self::refused();
		}

		/*
		 * The order must not belong to an account.
		 *
		 * Otherwise anyone holding a registered customer's order number and
		 * email address — a shop assistant, anyone who has seen a receipt —
		 * could file claims against that account's cover without its password.
		 * A customer with an account has a warranties tab already.
		 */
		if ( $order->get_customer_id() > 0 ) {
			self::record_failure();

			return new WP_Error(
				'warranty_cart_guest_has_account',
				\__( 'That order belongs to an account. Please sign in to see its warranties.', 'warranty-cart' ),
				array( 'status' => 403 )
			);
		}

		if ( ! \hash_equals( \strtolower( (string) $order->get_billing_email() ), \strtolower( $email ) ) ) {
			self::record_failure();

			return self::refused();
		}

		return $order;
	}

	/**
	 * Whether a guest holding this address may claim against a warranty.
	 *
	 * @param object $warranty The warranty record.
	 * @param string $email    The address given.
	 *
	 * @return bool
	 */
	public static function owns( object $warranty, string $email ): bool {
		// Cover issued by hand has no order behind it, so there is nothing for a
		// guest to prove ownership with. It belongs to whoever it was issued to.
		if ( (int) $warranty->order_id <= 0 || (int) $warranty->customer_id > 0 ) {
			return false;
		}

		return ! \is_wp_error( self::verify( (int) $warranty->order_id, $email ) );
	}

	/**
	 * The warranties on an order, for a guest who has proved it is theirs.
	 *
	 * @param int    $order_id The order number.
	 * @param string $email    The address it was placed with.
	 *
	 * @return array<int, object>|WP_Error
	 */
	public static function warranties( int $order_id, string $email ) {
		$order = self::verify( $order_id, $email );

		if ( \is_wp_error( $order ) ) {
			return $order;
		}

		global $wpdb;

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

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE order_id = %d AND ( customer_id IS NULL OR customer_id = 0 ) ORDER BY id ASC",
				$order->get_id()
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery

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

	/**
	 * The one refusal used for every wrong answer.
	 *
	 * Deliberately the same message whether the order does not exist or the
	 * address does not match it. Telling them apart would turn this form into a
	 * way of asking the shop which order numbers are real.
	 *
	 * @return WP_Error
	 */
	private static function refused(): WP_Error {
		return new WP_Error(
			'warranty_cart_guest_not_found',
			\__( 'We could not find an order with that number and email address.', 'warranty-cart' ),
			array( 'status' => 404 )
		);
	}

	/**
	 * The key a caller's failed attempts are counted against.
	 *
	 * @return string
	 */
	private static function attempt_key(): string {
		$address = isset( $_SERVER['REMOTE_ADDR'] )
			? \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
			: 'unknown';

		return 'warranty_cart_guest_' . \md5( $address );
	}

	/**
	 * Whether this caller has used up its attempts.
	 *
	 * A lookup that costs nothing to get wrong is a lookup somebody will get
	 * wrong ten thousand times. The limit is on failures alone, so a customer
	 * checking several of their own orders is never stopped.
	 *
	 * @return bool
	 */
	private static function attempts_exhausted(): bool {
		/**
		 * Filter how many failed lookups are allowed per hour.
		 *
		 * @param int $limit The number of failures.
		 */
		$limit = (int) \apply_filters( 'warranty_cart_guest_attempt_limit', self::ATTEMPT_LIMIT );

		if ( $limit <= 0 ) {
			return false;
		}

		return (int) \get_transient( self::attempt_key() ) >= $limit;
	}

	/**
	 * Count one wrong answer against this caller.
	 *
	 * @return void
	 */
	private static function record_failure(): void {
		$key = self::attempt_key();

		\set_transient( $key, (int) \get_transient( $key ) + 1, \HOUR_IN_SECONDS );
	}
}
