<?php
/**
 * Durable public submission replay receipts.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Forms;

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Receipt operations require transactional custom-table reads and writes.
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are built from the plugin-owned WordPress prefix.

/**
 * Stores privacy-preserving, transaction-bound submission receipts.
 */
class SubmissionReceiptRepository {
	const STATUS_PROCESSING = 'processing';
	const STATUS_COMPLETED  = 'completed';
	const RECEIPT_TTL       = DAY_IN_SECONDS;

	/**
	 * Read an already completed receipt without creating a claim.
	 *
	 * @param string              $provider Provider key.
	 * @param string              $form_id Form id.
	 * @param string              $submission_id Client submission id.
	 * @param array<string,mixed> $payload Canonical accepted payload inputs.
	 * @return array<string,mixed>|null|\WP_Error
	 */
	public function find_completed( $provider, $form_id, $submission_id, array $payload ) {
		global $wpdb;

		$identity = $this->normalize_identity( $provider, $form_id );
		if ( is_wp_error( $identity ) ) {
			return $identity;
		}

		$submission_id = $this->sanitize_submission_id( $submission_id );
		if ( '' === $submission_id ) {
			return null;
		}

		$request_hash = $this->request_hash( $payload );
		if ( is_wp_error( $request_hash ) ) {
			return $request_hash;
		}

		$submission_key = $this->submission_key(
			$identity['provider'],
			$identity['form_id'],
			$submission_id
		);
		$table = $this->table_name();
		$wpdb->last_error = '';
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE submission_key = %s LIMIT 1",
				$submission_key
			)
		);
		if ( '' !== (string) $wpdb->last_error ) {
			return $this->database_error( 'subin_submission_receipt_read_failed' );
		}
		if ( ! $row ) {
			return null;
		}
		if ( $this->row_is_expired( $row, current_time( 'mysql' ) ) ) {
			return null;
		}

		return $this->replay_from_row( $row, $request_hash );
	}

	/**
	 * Reserve a receipt inside the caller-owned acceptance transaction.
	 *
	 * A processing receipt must be completed before COMMIT. Rolling the outer
	 * transaction back removes a newly inserted reservation automatically.
	 *
	 * @param string              $provider Provider key.
	 * @param string              $form_id Form id.
	 * @param string              $submission_id Client submission id.
	 * @param array<string,mixed> $payload Canonical accepted payload inputs.
	 * @return array<string,mixed>|\WP_Error
	 */
	public function reserve_in_transaction( $provider, $form_id, $submission_id, array $payload ) {
		global $wpdb;

		$identity = $this->normalize_identity( $provider, $form_id );
		if ( is_wp_error( $identity ) ) {
			return $identity;
		}

		$request_hash = $this->request_hash( $payload );
		if ( is_wp_error( $request_hash ) ) {
			return $request_hash;
		}

		$submission_id = $this->sanitize_submission_id( $submission_id );
		$submission_key = '' !== $submission_id
			? $this->submission_key( $identity['provider'], $identity['form_id'], $submission_id )
			: $this->anonymous_submission_key( $identity['provider'], $identity['form_id'] );
		$now        = current_time( 'mysql' );
		$expires_at = $this->mysql_date( time() + self::RECEIPT_TTL );
		$table      = $this->table_name();

		$inserted = $this->insert_reservation(
			$submission_key,
			$request_hash,
			$identity,
			$now,
			$expires_at
		);
		if ( is_wp_error( $inserted ) ) {
			return $inserted;
		}
		if ( $inserted > 0 ) {
			return array(
				'state'         => 'reserved',
				'receipt_id'    => absint( $wpdb->insert_id ),
				'submission_key' => $submission_key,
				'request_hash'  => $request_hash,
				'duplicate'     => false,
				'response'      => array(),
			);
		}

		$wpdb->last_error = '';
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE submission_key = %s FOR UPDATE",
				$submission_key
			)
		);
		if ( '' !== (string) $wpdb->last_error || ! $row ) {
			return $this->database_error( 'subin_submission_receipt_lock_failed' );
		}

		if ( $this->row_is_expired( $row, $now ) ) {
			$deleted = $wpdb->delete(
				$table,
				array( 'id' => absint( $row->id ?? 0 ) ),
				array( '%d' )
			);
			if ( false === $deleted || 1 !== $deleted ) {
				return $this->database_error( 'subin_submission_receipt_expired_delete_failed' );
			}

			$reinserted = $this->insert_reservation(
				$submission_key,
				$request_hash,
				$identity,
				$now,
				$expires_at
			);
			if ( is_wp_error( $reinserted ) ) {
				return $reinserted;
			}
			if ( 1 !== $reinserted ) {
				return $this->database_error( 'subin_submission_receipt_generation_failed' );
			}

			return array(
				'state'         => 'reserved',
				'receipt_id'    => absint( $wpdb->insert_id ),
				'submission_key' => $submission_key,
				'request_hash'  => $request_hash,
				'duplicate'     => false,
				'response'      => array(),
			);
		}

		$replay = $this->replay_from_row( $row, $request_hash );
		if ( is_wp_error( $replay ) ) {
			return $replay;
		}

		return array_merge(
			$replay,
			array(
				'submission_key' => $submission_key,
				'request_hash'  => $request_hash,
				'duplicate'     => true,
			)
		);
	}

	/**
	 * Complete a reserved receipt inside the caller-owned transaction.
	 *
	 * @param int                 $receipt_id Receipt id.
	 * @param string              $expected_request_hash Expected payload hash.
	 * @param array<string,mixed> $response Exact public success response.
	 * @param int                 $lead_id Saved lead id, when applicable.
	 * @param int                 $notification_job_count Number of durable jobs.
	 * @return true|\WP_Error
	 */
	public function complete( $receipt_id, $expected_request_hash, array $response, $lead_id = 0, $notification_job_count = 0 ) {
		global $wpdb;

		$receipt_id = absint( $receipt_id );
		$expected_request_hash = strtolower( trim( (string) $expected_request_hash ) );
		if ( $receipt_id <= 0 || 1 !== preg_match( '/^[a-f0-9]{64}$/', $expected_request_hash ) ) {
			return new \WP_Error(
				'subin_invalid_submission_receipt',
				__( 'The submission receipt is invalid.', 'submission-inbox' )
			);
		}

		$encoded = wp_json_encode( $response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		if ( ! is_string( $encoded ) ) {
			return new \WP_Error(
				'subin_submission_response_encode_failed',
				__( 'The submission result could not be stored.', 'submission-inbox' )
			);
		}

		$now   = current_time( 'mysql' );
		$table = $this->table_name();
		$updated = $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table}
				SET lead_id = NULLIF(%d, 0),
					status = %s,
					response_json = %s,
					notification_job_count = %d,
					updated_at = %s,
					completed_at = %s
				WHERE id = %d AND request_hash = %s AND status = %s",
				absint( $lead_id ),
				self::STATUS_COMPLETED,
				$encoded,
				absint( $notification_job_count ),
				$now,
				$now,
				$receipt_id,
				$expected_request_hash,
				self::STATUS_PROCESSING
			)
		);
		if ( false === $updated ) {
			return $this->database_error( 'subin_submission_receipt_complete_failed' );
		}
		if ( 1 !== $updated ) {
			return new \WP_Error(
				'subin_submission_receipt_state_conflict',
				__( 'The submission receipt changed before it could be completed.', 'submission-inbox' )
			);
		}

		return true;
	}

	/**
	 * Delete expired completed receipts and stale committed reservations.
	 *
	 * A live reservation is transaction-bound and cannot be observed by this
	 * cleanup connection. An expired processing row is therefore abandoned and
	 * must not block the same submission key forever.
	 *
	 * @param int $limit Maximum rows.
	 * @return int|\WP_Error
	 */
	public function cleanup_expired( $limit = 500 ) {
		global $wpdb;

		$limit   = max( 1, min( 1000, absint( $limit ) ) );
		$table   = $this->table_name();
		$deleted = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table}
				WHERE id IN (
					SELECT id FROM (
						SELECT receipts.id
						FROM {$table} receipts
						WHERE receipts.status IN (%s, %s) AND receipts.expires_at < %s
						ORDER BY receipts.id ASC
						LIMIT %d
					) expired_receipts
				)",
				self::STATUS_COMPLETED,
				self::STATUS_PROCESSING,
				current_time( 'mysql' ),
				$limit
			)
		);

		return false === $deleted
			? $this->database_error( 'subin_submission_receipt_cleanup_failed' )
			: absint( $deleted );
	}

	/**
	 * Hash a recursively canonicalized payload without storing its raw values.
	 *
	 * @param array<string,mixed> $payload Payload.
	 * @return string|\WP_Error
	 */
	public function request_hash( array $payload ) {
		$encoded = $this->canonical_json( $payload );
		if ( is_wp_error( $encoded ) ) {
			return new \WP_Error(
				'subin_submission_payload_encode_failed',
				__( 'The submission payload could not be verified.', 'submission-inbox' )
			);
		}

		return $this->key( 'payload:v1|' . $encoded );
	}

	/**
	 * Insert one processing reservation.
	 *
	 * @param string                     $submission_key HMAC submission key.
	 * @param string                     $request_hash HMAC request hash.
	 * @param array{provider:string,form_id:string} $identity Receipt identity.
	 * @param string                     $now Current database time.
	 * @param string                     $expires_at Receipt expiry.
	 * @return int|\WP_Error
	 */
	private function insert_reservation( $submission_key, $request_hash, array $identity, $now, $expires_at ) {
		global $wpdb;

		$table = $this->table_name();
		$inserted = $wpdb->query(
			$wpdb->prepare(
				"INSERT IGNORE INTO {$table}
					(submission_key, request_hash, provider, form_id, lead_id, status, response_json, notification_job_count, created_at, updated_at, completed_at, expires_at)
				VALUES (%s, %s, %s, %s, NULL, %s, NULL, 0, %s, %s, NULL, %s)",
				$submission_key,
				$request_hash,
				$identity['provider'],
				$identity['form_id'],
				self::STATUS_PROCESSING,
				$now,
				$now,
				$expires_at
			)
		);

		return false === $inserted
			? $this->database_error( 'subin_submission_receipt_reserve_failed' )
			: absint( $inserted );
	}

	/**
	 * Check whether a completed receipt generation has expired.
	 *
	 * @param object $row Receipt row.
	 * @param string $now Current database time.
	 * @return bool
	 */
	private function row_is_expired( $row, $now ) {
		if ( self::STATUS_COMPLETED !== sanitize_key( (string) ( $row->status ?? '' ) ) ) {
			return false;
		}

		$expires_at = (string) ( $row->expires_at ?? '' );

		return '' === $expires_at || $expires_at < (string) $now;
	}

	/**
	 * Return a completed replay or a safe state error.
	 *
	 * @param object $row Receipt row.
	 * @param string $request_hash Current request hash.
	 * @return array<string,mixed>|\WP_Error
	 */
	private function replay_from_row( $row, $request_hash ) {
		if ( self::STATUS_COMPLETED !== sanitize_key( (string) ( $row->status ?? '' ) ) ) {
			do_action( 'subin_submission_receipt_invariant_failed', absint( $row->id ?? 0 ) );
			return new \WP_Error(
				'subin_submission_receipt_invariant_failed',
				__( 'The saved submission receipt is incomplete.', 'submission-inbox' )
			);
		}

		$stored_hash = (string) ( $row->request_hash ?? '' );
		if ( 64 !== strlen( $stored_hash ) || ! hash_equals( $stored_hash, (string) $request_hash ) ) {
			return new \WP_Error(
				'subin_submission_payload_mismatch',
				__( 'This submission id was already used for different form data.', 'submission-inbox' ),
				array( 'status' => 409 )
			);
		}

		$response = json_decode( (string) ( $row->response_json ?? '' ), true );
		if ( ! is_array( $response ) ) {
			return new \WP_Error(
				'subin_submission_receipt_corrupt',
				__( 'The saved submission result could not be read.', 'submission-inbox' )
			);
		}

		return array(
			'state'                 => 'completed',
			'receipt_id'            => absint( $row->id ?? 0 ),
			'response'              => $response,
			'lead_id'               => absint( $row->lead_id ?? 0 ),
			'notification_job_count' => absint( $row->notification_job_count ?? 0 ),
		);
	}

	/**
	 * Normalize non-private receipt identity metadata.
	 *
	 * @param string $provider Provider key.
	 * @param string $form_id Form id.
	 * @return array{provider:string,form_id:string}|\WP_Error
	 */
	private function normalize_identity( $provider, $form_id ) {
		$provider = substr( sanitize_key( (string) $provider ), 0, 50 );
		$form_id  = substr( sanitize_text_field( (string) $form_id ), 0, 100 );

		if ( '' === $provider || '' === $form_id ) {
			return new \WP_Error(
				'subin_invalid_submission_receipt_identity',
				__( 'The submission receipt identity is invalid.', 'submission-inbox' )
			);
		}

		return array(
			'provider' => $provider,
			'form_id'  => $form_id,
		);
	}

	/**
	 * Read a valid optional client submission id.
	 *
	 * @param mixed $value Raw id.
	 * @return string
	 */
	private function sanitize_submission_id( $value ) {
		$value = trim( sanitize_text_field( (string) $value ) );
		if ( strlen( $value ) < 16 || strlen( $value ) > 128 ) {
			return '';
		}

		return preg_match( '/^[A-Za-z0-9._:-]+$/', $value ) ? $value : '';
	}

	/**
	 * Build a stable privacy-preserving submission key.
	 *
	 * @param string $provider Provider key.
	 * @param string $form_id Form id.
	 * @param string $submission_id Submission id.
	 * @return string
	 */
	private function submission_key( $provider, $form_id, $submission_id ) {
		return $this->key( 'submission:v1|' . $provider . '|' . $form_id . '|' . $submission_id );
	}

	/**
	 * Build a one-time key for clients without a usable id.
	 *
	 * @param string $provider Provider key.
	 * @param string $form_id Form id.
	 * @return string
	 */
	private function anonymous_submission_key( $provider, $form_id ) {
		return $this->key(
			'anonymous:v1|' . $provider . '|' . $form_id . '|' . wp_generate_uuid4() . '|' . microtime( true )
		);
	}

	/**
	 * Encode a recursively canonical value.
	 *
	 * @param mixed $value Value.
	 * @return string|\WP_Error
	 */
	private function canonical_json( $value ) {
		$valid      = true;
		$normalized = $this->canonicalize( $value, $valid );
		if ( ! $valid ) {
			return new \WP_Error( 'subin_invalid_canonical_json_value' );
		}

		$encoded = wp_json_encode( $normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );

		return is_string( $encoded )
			? $encoded
			: new \WP_Error( 'subin_canonical_json_encode_failed' );
	}

	/**
	 * Recursively sort associative keys while preserving list order.
	 *
	 * @param mixed $value Value.
	 * @param bool  $valid Whether every value can be represented safely.
	 * @return mixed
	 */
	private function canonicalize( $value, &$valid ) {
		if ( is_array( $value ) ) {
			$normalized = array();
			foreach ( $value as $key => $child ) {
				$normalized[ $key ] = $this->canonicalize( $child, $valid );
			}
			if ( ! $this->is_list( $normalized ) ) {
				ksort( $normalized, SORT_STRING );
			}

			return $normalized;
		}

		if ( is_null( $value ) || is_string( $value ) || is_int( $value ) || is_bool( $value ) ) {
			return $value;
		}

		if ( is_float( $value ) && is_finite( $value ) ) {
			return $value;
		}

		$valid = false;

		return null;
	}

	/**
	 * Check whether an array uses consecutive integer keys.
	 *
	 * @param array<mixed> $value Value.
	 * @return bool
	 */
	private function is_list( array $value ) {
		if ( empty( $value ) ) {
			return true;
		}

		return array_keys( $value ) === range( 0, count( $value ) - 1 );
	}

	/**
	 * Generate a keyed fixed-length value.
	 *
	 * @param string $value Source value.
	 * @return string
	 */
	private function key( $value ) {
		return hash_hmac( 'sha256', (string) $value, wp_salt( 'nonce' ) );
	}

	/**
	 * Format an epoch using the WordPress site timezone.
	 *
	 * @param int $timestamp Epoch.
	 * @return string
	 */
	private function mysql_date( $timestamp ) {
		return wp_date( 'Y-m-d H:i:s', absint( $timestamp ), wp_timezone() );
	}

	/**
	 * Get the current receipt table.
	 *
	 * @return string
	 */
	protected function table_name() {
		global $wpdb;

		return $wpdb->prefix . 'subin_submission_receipts';
	}

	/**
	 * Build a safe repository error.
	 *
	 * @param string $code Error code.
	 * @return \WP_Error
	 */
	private function database_error( $code ) {
		global $wpdb;

		return new \WP_Error(
			sanitize_key( $code ),
			__( 'The submission receipt could not be stored.', 'submission-inbox' ),
			array( 'db_error' => sanitize_text_field( (string) $wpdb->last_error ) )
		);
	}
}
