<?php
/**
 * Gapless sequential invoice numbering.
 *
 * @package Harbor_EU_EInvoicing_For_WooCommerce
 */

defined( 'ABSPATH' ) || exit;

class HEIN_Invoice_Number {

	/**
	 * Pure: no allocation and no write, so a regeneration of a 2024 order is still dated 2024
	 */
	public static function issue_date( WC_Order $order ): string {
		$stored = trim( (string) $order->get_meta( '_hein_invoice_date' ) );

		if ( '' !== $stored ) {
			return $stored;
		}

		$date = $order->get_date_completed() ?? $order->get_date_paid() ?? $order->get_date_created();

		return $date ? $date->date( 'Y-m-d' ) : wp_date( 'Y-m-d' );
	}

	/**
	 * Idempotent, and the LAST mutation of a successful generation: HEIN_Invoice_Data validates
	 * the whole document first, so a failed generation cannot burn a number. The atomic counter
	 * protects the sequence but not the per-order assignment, which is what the lock is for
	 *
	 * @throws HEIN_Invoice_Exception When the assignment row cannot be persisted.
	 */
	public static function for_order( WC_Order $order, string $issue_date ): string {
		global $wpdb;

		$order_id = $order->get_id();
		$existing = self::existing( $order_id );

		if ( null !== $existing ) {
			return (string) apply_filters( 'hein_invoice_number', (string) $existing['number'], $order );
		}

		$lock = 'hein_invnum_' . $order_id;

		// A discarded GET_LOCK result silently proceeds unserialized; 0 (timeout) or NULL (error)
		// means the sequence is unprotected, so refuse rather than let a losing writer burn a number
		$acquired = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock, 10 ) );

		if ( 1 !== $acquired ) {
			throw new HEIN_Invoice_Exception(
				esc_html__( 'The invoice number is being assigned by another request. Please try again.', 'harbor-eu-einvoicing-for-woocommerce' ),
				'number_lock_unavailable'
			);
		}

		try {
			$existing = self::existing( $order_id );

			if ( null !== $existing ) {
				return (string) apply_filters( 'hein_invoice_number', (string) $existing['number'], $order );
			}

			$series = self::series( $issue_date );

			// The counter advance and the assignment row must commit together. Left un-atomic, a
			// failed insert leaves the counter ahead of the last recorded number and the next order
			// skips a value - a permanent hole in a series that must be gapless
			$wpdb->query( 'START TRANSACTION' );

			$sequence = self::next( $series );
			$number   = self::format_number( $sequence, $series );

			$inserted = $wpdb->insert(
				hein_numbers_table(),
				[
					'order_id'    => $order_id,
					'series'      => $series,
					'sequence'    => $sequence,
					'number'      => $number,
					'issue_date'  => $issue_date,
					'created_gmt' => current_time( 'mysql', true ),
				],
				[ '%d', '%s', '%d', '%s', '%s', '%s' ]
			);

			// The assignment row is canonical. If it did not persist, the rollback returns the counter
			// to its prior value so no number is burned, and mirroring is skipped so the order never
			// carries a number the gapless series has no record of
			if ( false === $inserted ) {
				$wpdb->query( 'ROLLBACK' );

				throw new HEIN_Invoice_Exception(
					esc_html__( 'The invoice number could not be recorded. Please try again.', 'harbor-eu-einvoicing-for-woocommerce' ),
					'number_not_recorded'
				);
			}

			$wpdb->query( 'COMMIT' );

			$order->update_meta_data( '_hein_invoice_number', $number );
			$order->update_meta_data( '_hein_invoice_date', $issue_date );
			$order->save();

			return (string) apply_filters( 'hein_invoice_number', $number, $order );
		} finally {
			$wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
		}
	}

	public static function existing( int $order_id ): ?array {
		global $wpdb;

		$table = hein_numbers_table();

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d", $order_id ), ARRAY_A );

		return is_array( $row ) && $row ? $row : null;
	}

	/**
	 * A SELECT-then-UPDATE double-issues under concurrency; this single statement cannot
	 */
	public static function next( string $series ): int {
		global $wpdb;

		$table = hein_counters_table();

		$wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"INSERT INTO {$table} (series, value) VALUES (%s, LAST_INSERT_ID(1))
				ON DUPLICATE KEY UPDATE value = LAST_INSERT_ID(value + 1)",
				$series
			)
		);

		return (int) $wpdb->insert_id;
	}

	public static function series( string $issue_date ): string {
		$reset = (string) ( self::settings()['reset'] ?? 'yearly' );

		if ( 'yearly' === $reset ) {
			return substr( $issue_date, 0, 4 );
		}

		if ( 'monthly' === $reset ) {
			return substr( $issue_date, 0, 7 );
		}

		return 'default';
	}

	public static function format_number( int $sequence, string $series ): string {
		$settings = self::settings();
		$padding  = max( 1, (int) ( $settings['padding'] ?? 6 ) );
		$prefix   = (string) ( $settings['prefix'] ?? '' );
		$token    = 'default' === $series ? '' : $series . '-';

		return $prefix . $token . str_pad( (string) $sequence, $padding, '0', STR_PAD_LEFT );
	}

	private static function settings(): array {
		$number = HEIN_Admin_Settings::get( 'number', [] );

		return is_array( $number ) ? $number : [];
	}
}
