<?php
/**
 * Per-account donation receipt sequence management.
 *
 * @since   1.0.0
 * @package DonationRouter
 */

declare( strict_types=1 );
namespace DonationRouter;

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

/**
 * Atomically generates and formats receipt sequence numbers on a per-account,
 * per-year basis.
 *
 * Sequence numbers are persisted in `wp_options` and allocated with MySQL's
 * `LAST_INSERT_ID(expr)` so the increment and read-back are one atomic,
 * connection-local operation — preventing duplicate receipt numbers even under
 * concurrent requests (see increment_sequence()).
 *
 * Format: {ISO-country}-{YYYY}-{5-digit-zero-padded-sequence}
 * Examples: US-2026-00001 | CA-2026-00142
 *
 * @since 1.0.0
 */
class Receipt_Numbering {

	/**
	 * WordPress options key prefix for per-account receipt sequences.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	private const OPTION_PREFIX = 'ador_receipt_seq_';

	/**
	 * Allocate the next sequence number and return a formatted receipt number.
	 *
	 * The method guarantees that two concurrent calls with the same arguments
	 * will receive different sequence numbers because the underlying SQL UPDATE
	 * is serialised by InnoDB's row-level locking.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $account_id    Account whose sequence to increment.
	 * @param string $country_code  ISO 3166-1 alpha-2 code (e.g. "US", "CA").
	 *
	 * @return string  Formatted receipt number, e.g. "US-2026-00001".
	 */
	public function next( int $account_id, string $country_code ): string {
		$sequence = $this->increment_sequence( $account_id );
		$year     = (int) gmdate( 'Y' );

		return sprintf(
			'%s-%d-%05d',
			strtoupper( $country_code ),
			$year,
			$sequence
		);
	}

	/**
	 * Atomically increment and return the per-account-per-year sequence.
	 *
	 * The increment and the read-back are a single atomic operation on one DB
	 * connection via MySQL's `LAST_INSERT_ID(expr)`: the expression
	 * `option_value + 1` is evaluated under InnoDB's row lock, stored as both the
	 * new column value AND this connection's last-insert-id, then read back with
	 * a connection-local `SELECT LAST_INSERT_ID()`. Because last-insert-id is
	 * per-connection, two concurrent requests each observe their own value.
	 *
	 * The previous implementation did `UPDATE ... +1` and then a SEPARATE
	 * `get_option()` read — under concurrency both requests could read the same
	 * post-increment value, producing duplicate receipt numbers and gaps.
	 *
	 * @since 1.0.0
	 *
	 * @param int $account_id  Account ID.
	 *
	 * @return int  New sequence value (≥ 1).
	 */
	public function increment_sequence( int $account_id ): int {
		global $wpdb;

		$year       = (int) gmdate( 'Y' );
		$option_key = self::OPTION_PREFIX . $account_id . '_' . $year;

		// Ensure the option row exists with an initial value of 0.
		// `add_option` is a no-op if the key already exists.
		add_option( $option_key, '0', '', false );

		// Atomic increment-and-capture — see method docblock. InnoDB serialises
		// concurrent UPDATEs on the same row; LAST_INSERT_ID keeps the read
		// connection-local so no two requests can observe the same new value.
		$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$wpdb->prepare(
				"UPDATE {$wpdb->options} SET option_value = LAST_INSERT_ID( option_value + 1 ) WHERE option_name = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$option_key
			)
		);

		$sequence = (int) $wpdb->get_var( 'SELECT LAST_INSERT_ID()' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		// Keep WP's option cache coherent for any later get_option() on this key.
		wp_cache_delete( $option_key, 'options' );

		return $sequence > 0 ? $sequence : 1;
	}

	/**
	 * Return the option key used for a given account and year.
	 *
	 * Exposed for testing.
	 *
	 * @since 1.0.0
	 *
	 * @param int $account_id  Account ID.
	 * @param int $year        Four-digit year.
	 *
	 * @return string
	 */
	public function option_key( int $account_id, int $year ): string {
		return self::OPTION_PREFIX . $account_id . '_' . $year;
	}
}
