<?php
/**
 * Sequential invoice number store.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Invoices;

defined( 'ABSPATH' ) || exit;

/**
 * Manages sequential invoice/credit-note numbers via a dedicated DB table.
 *
 * Uses MySQL AUTO_INCREMENT for atomicity — no flock, no race conditions.
 *
 * @since 1.0.0
 */
final class Number_Store {

	private const TABLE_SUFFIX = 'kramar_invoice_numbers';

	/**
	 * Get the full table name (prefixed).
	 *
	 * @since 1.0.0
	 */
	public static function table_name(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE_SUFFIX;
	}

	/**
	 * Create the table if it doesn't exist.
	 *
	 * @since 1.0.0
	 */
	public static function create_table(): void {
		global $wpdb;

		$table   = self::table_name();
		$charset = $wpdb->get_charset_collate();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) {
			return;
		}

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		dbDelta(
			"CREATE TABLE {$table} (
				id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
				order_id BIGINT UNSIGNED NOT NULL,
				type VARCHAR(20) NOT NULL DEFAULT 'invoice',
				date DATE NOT NULL,
				PRIMARY KEY (id),
				UNIQUE KEY order_id_type (order_id, type)
			) {$charset};"
		);
	}

	/**
	 * Drop the table.
	 *
	 * @since 1.0.0
	 */
	public static function drop_table(): void {
		global $wpdb;

		$table = self::table_name();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
	}

	/**
	 * Get the next sequential number for a document type.
	 *
	 * Inserts a row and returns the AUTO_INCREMENT id.
	 * If the order already has a number of this type, returns the existing one.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $order_id Order ID.
	 * @param string $type     Document type ('invoice' or 'credit-note').
	 * @return int The sequential number.
	 */
	public static function get_next_number( int $order_id, string $type = 'invoice' ): int {
		global $wpdb;

		$table = self::table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- trusted internal table; single-row lookup by primary key, caching not appropriate for sequential number generation
		$existing = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT id FROM {$table} WHERE order_id = %d AND type = %s",
				$order_id,
				$type
			)
		);

		if ( $existing ) {
			return (int) $existing;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- trusted internal table; insert for AUTO_INCREMENT sequential number
		$inserted = $wpdb->insert(
			$table,
			[
				'order_id' => $order_id,
				'type'     => $type,
				'date'     => current_time( 'Y-m-d' ),
			],
			[ '%d', '%s', '%s' ]
		);

		if ( false !== $inserted && $wpdb->insert_id > 0 ) {
			return (int) $wpdb->insert_id;
		}

		// A concurrent request inserted the same (order_id, type) first, hitting the
		// UNIQUE key — insert_id is left at 0, which would format as INV-0000. Re-read
		// the canonical row so both callers return the same real number. See L9.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- trusted internal table; re-select after a UNIQUE-key collision
		$id = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT id FROM {$table} WHERE order_id = %d AND type = %s",
				$order_id,
				$type
			)
		);

		return $id ? (int) $id : 0;
	}

	/**
	 * Format a sequential number with prefix, suffix, and padding.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $number  Raw sequential number.
	 * @param string $prefix  Number prefix (may contain date tokens).
	 * @param string $suffix  Number suffix.
	 * @param int    $padding Zero-padding length.
	 * @return string Formatted number.
	 */
	public static function format_number( int $number, string $prefix = '', string $suffix = '', int $padding = 4 ): string {
		$prefix = self::replace_date_tokens( $prefix );
		$suffix = self::replace_date_tokens( $suffix );

		return $prefix . str_pad( (string) $number, $padding, '0', STR_PAD_LEFT ) . $suffix;
	}

	/**
	 * Replace date tokens in a string.
	 *
	 * @since 1.0.0
	 *
	 * @param string $str String with optional {year}, {month}, {day} tokens.
	 * @return string Resolved string.
	 */
	private static function replace_date_tokens( string $str ): string {
		return str_replace(
			[ '{year}', '{month}', '{day}' ],
			[ current_time( 'Y' ), current_time( 'm' ), current_time( 'd' ) ],
			$str
		);
	}

	/**
	 * Get the raw number for an order + type, or 0 if none exists.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $order_id Order ID.
	 * @param string $type     Document type.
	 * @return int Sequential number or 0.
	 */
	public static function get_number( int $order_id, string $type = 'invoice' ): int {
		global $wpdb;

		$table = self::table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- trusted internal table; single-row lookup, result not cached as it may change between requests
		$number = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT id FROM {$table} WHERE order_id = %d AND type = %s",
				$order_id,
				$type
			)
		);

		return $number ? (int) $number : 0;
	}
}
