<?php
/**
 * The number on the back of the thing.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\Core;

use WP_Error;

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

/**
 * Serial numbers against warranties.
 *
 * Electronics and appliances are identified by the plate on the back, not by an
 * order number. A customer standing in front of a broken washing machine has the
 * serial in front of them and the order number in an inbox somewhere, and a shop
 * asked to honour cover wants to know which unit it is being asked about — the
 * one it sold, or the one somebody bought second-hand.
 *
 * Deliberately not generated here. A serial belongs to the manufacturer; a
 * number this plugin invented would look like one and mean nothing.
 */
class Serials {

	/**
	 * Longest serial the column will hold.
	 */
	public const MAX_LENGTH = 100;

	/**
	 * Tidy a serial into the form it is stored and compared in.
	 *
	 * Upper case with the spaces out, because that is how serials are printed
	 * and not how they are typed. A customer copying "gd 7741-x" off a label
	 * must match the "GD7741-X" the shop entered, or the lookup this exists for
	 * fails in the one case it is needed.
	 *
	 * @param string $serial As given.
	 *
	 * @return string
	 */
	public static function normalise( string $serial ): string {
		$serial = preg_replace( '/\s+/', '', trim( $serial ) );

		return strtoupper( substr( (string) $serial, 0, self::MAX_LENGTH ) );
	}

	/**
	 * Record the serial of the item a warranty covers.
	 *
	 * @param int    $warranty_id The warranty.
	 * @param string $serial      The number, or '' to clear it.
	 *
	 * @return string|WP_Error The stored serial, or why it was refused.
	 */
	public static function set( int $warranty_id, string $serial ) {
		global $wpdb;

		$serial = self::normalise( $serial );

		if ( '' !== $serial && ! preg_match( '/^[A-Z0-9\-\/_.]+$/', $serial ) ) {
			return new WP_Error(
				'warranty_cart_bad_serial',
				__( 'A serial number can contain letters, numbers, hyphens, dots and slashes.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

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

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$updated = $wpdb->update(
			$table,
			array( 'serial_number' => '' === $serial ? null : $serial ),
			array( 'id' => $warranty_id ),
			array( '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			return new WP_Error(
				'warranty_cart_serial_failed',
				__( 'The serial number could not be saved. Please try again.', 'warranty-cart' ),
				array( 'status' => 500 )
			);
		}

		/**
		 * Fires when a warranty's serial number is set or cleared.
		 *
		 * @param int    $warranty_id The warranty.
		 * @param string $serial      The stored serial, or ''.
		 */
		do_action( 'warranty_cart_serial_registered', $warranty_id, $serial );

		return $serial;
	}

	/**
	 * The warranty carrying a serial, if any.
	 *
	 * One warranty, not a list: a serial identifies a physical thing, and two
	 * live warranties on one unit means somebody has made a mistake worth
	 * seeing rather than a set worth paging through. The most recent wins, so a
	 * renewed or reissued policy answers ahead of the one it replaced.
	 *
	 * @param string $serial The number, in any of the forms a customer types it.
	 *
	 * @return object|null
	 */
	public static function find( string $serial ): ?object {
		global $wpdb;

		$serial = self::normalise( $serial );

		if ( '' === $serial ) {
			return null;
		}

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

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
		$row = $wpdb->get_row(
			$wpdb->prepare( "SELECT * FROM {$table} WHERE serial_number = %s ORDER BY id DESC LIMIT 1", $serial )
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery

		return $row ? $row : null;
	}

	/**
	 * Whether a serial is already recorded against a different warranty.
	 *
	 * Not enforced as a constraint. A shop replacing a unit under warranty
	 * legitimately ends up with the old serial on a closed policy and the new
	 * one on its replacement, and a hard uniqueness rule would refuse the
	 * second — so this reports, and the caller decides.
	 *
	 * @param string $serial      The number.
	 * @param int    $warranty_id The warranty it is about to go on.
	 *
	 * @return bool
	 */
	public static function taken( string $serial, int $warranty_id ): bool {
		$existing = self::find( $serial );

		return $existing instanceof \stdClass && (int) $existing->id !== $warranty_id;
	}
}
