<?php
/**
 * Atomic per-type document number allocator.
 *
 * Invoices and estimates each draw from an independent, ever-incrementing
 * sequence held one row per doc_type in the wrenchpress_document_counters table.
 * A number is assigned by a single atomic UPDATE that both advances the stored
 * counter and captures the value it held via MySQL's LAST_INSERT_ID(expr) idiom,
 * so concurrent requests can never be handed the same number. There is
 * deliberately no PHP-side read-modify-write (a read-then-write option pair
 * would race); the increment and fetch are one SQL statement. The doc_type is
 * whitelisted before any query runs, and the only interpolated identifier is the
 * trusted, prefix-built table name.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Document_Counters
 */
class WrenchPress_Document_Counters {

	/**
	 * The two counter sequences. next() whitelists $doc_type against this list so
	 * an unexpected value never reaches the query.
	 *
	 * @return string[] Allowed doc-type slugs.
	 */
	public static function doc_types() {
		return array( 'invoice', 'estimate' );
	}

	/**
	 * Assign the next number for a document type, atomically.
	 *
	 * Rejects any doc_type outside the whitelist before touching the database.
	 * The single UPDATE advances the stored counter by one while capturing the
	 * pre-bump value through the LAST_INSERT_ID(expr) idiom, so the held value is
	 * captured into the connection's LAST_INSERT_ID; that captured value is the
	 * number assigned to this document. Because the increment is one statement,
	 * two concurrent callers are serialized by the row lock and always receive
	 * distinct numbers, and LAST_INSERT_ID is per-connection so the follow-up read
	 * returns this caller's captured value regardless of concurrent writers. The
	 * invoice and estimate rows are independent, so the two sequences never share a
	 * number.
	 *
	 * @param string $doc_type 'invoice' or 'estimate'.
	 * @return int The assigned number (1 or greater), or 0 on a bad type / failure.
	 */
	public static function next( $doc_type ) {
		global $wpdb;

		$doc_type = (string) $doc_type;

		if ( ! in_array( $doc_type, self::doc_types(), true ) ) {
			return 0;
		}

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

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom-table atomic counter; the increment and fetch are one statement by design, so caching would defeat the concurrency guarantee.
		$updated = $wpdb->query(
			$wpdb->prepare(
				'UPDATE %i SET next_number = LAST_INSERT_ID( next_number ) + 1 WHERE doc_type = %s',
				$table,
				$doc_type
			)
		);

		// The UPDATE must match exactly the one counter row for this type. If it
		// matched zero rows (a missing/unseeded counter row, a partial migration,
		// or a query failure) LAST_INSERT_ID() is NOT reset: it still holds this
		// connection's last value from an unrelated statement, so reading it back
		// would hand out a stale, wrong number. Fail safe with 0 instead.
		if ( 1 !== (int) $updated ) {
			return 0;
		}

		// wpdb::query() only populates $wpdb->insert_id for INSERT/REPLACE, not for
		// an UPDATE, so the LAST_INSERT_ID(expr) value captured above must be read
		// back explicitly. LAST_INSERT_ID() is per-connection, so this returns the
		// pre-bump value this caller captured, not another concurrent writer's.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads the connection-local captured counter value; must not be cached.
		return (int) $wpdb->get_var( 'SELECT LAST_INSERT_ID()' );
	}
}
