<?php
/**
 * Transaction and mutex helpers shared by PufferPDF write workflows.
 *
 * @package PufferPDF
 */

defined( 'ABSPATH' ) || exit;

/**
 * Provides deterministic MySQL locks and core-table transactions.
 */
final class PufferPDF_Storage {

	/**
	 * Names of acquired connection-scoped locks.
	 *
	 * @var string[]
	 */
	private $active_locks = array();

	/**
	 * Whether a core-table transaction is active.
	 *
	 * @var bool
	 */
	private $transaction_active = false;

	/**
	 * Acquire a sorted set of connection-scoped mutexes without waiting.
	 *
	 * Attachment keys deliberately reuse the Phase 2 lock name so single and
	 * multi-object workflows cannot save the same attachment concurrently.
	 *
	 * @param string[] $keys Logical lock keys.
	 * @return true|WP_Error
	 */
	public function acquire_locks( array $keys ) {
		global $wpdb;

		$keys = array_values( array_unique( array_filter( array_map( 'strval', $keys ), 'strlen' ) ) );
		sort( $keys, SORT_STRING );

		foreach ( $keys as $key ) {
			$name = $this->get_lock_name( $key );
			if ( isset( $this->active_locks[ $name ] ) ) {
				continue;
			}

			$acquired = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $name )
			);
			if ( '1' !== (string) $acquired ) {
				$this->release_locks();

				return new WP_Error(
					'pufferpdf_publication_busy',
					__( 'Another PufferPDF publishing request is using the same source or destination. Try again in a moment.', 'pufferpdf' ),
					array( 'status' => 409 )
				);
			}

			$this->active_locks[ $name ] = true;
		}

		return true;
	}

	/**
	 * Release all connection-scoped mutexes held by this instance.
	 *
	 * @return void
	 */
	public function release_locks() {
		global $wpdb;

		$names = array_reverse( array_keys( $this->active_locks ) );
		foreach ( $names as $name ) {
			$wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name )
			);
		}

		$this->active_locks = array();
	}

	/**
	 * Start a transaction covering every core table Phase 3 mutates.
	 *
	 * @param bool $include_terms    Whether taxonomy relationships will be written.
	 * @param bool $include_comments Whether comment rows may be changed by Trash.
	 * @return true|WP_Error
	 */
	public function begin_transaction( $include_terms, $include_comments = false ) {
		global $wpdb;

		if ( $this->transaction_active ) {
			return new WP_Error(
				'pufferpdf_transaction_active',
				__( 'A PufferPDF transaction is already active in this request.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		$tables = array( $wpdb->posts, $wpdb->postmeta );
		if ( $include_terms ) {
			$tables[] = $wpdb->term_relationships;
			$tables[] = $wpdb->term_taxonomy;
		}
		if ( $include_comments ) {
			$tables[] = $wpdb->comments;
			$tables[] = $wpdb->commentmeta;
		}

		if ( ! $this->tables_are_transactional( $tables ) ) {
			return new WP_Error(
				'pufferpdf_transaction_storage_required',
				__( 'PufferPDF publishing requires transactional WordPress post and taxonomy storage.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		$result = $wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		if ( false === $result ) {
			return new WP_Error(
				'pufferpdf_storage_unavailable',
				__( 'PufferPDF could not start a safe database write.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		$this->transaction_active = true;

		return true;
	}

	/**
	 * Commit the active transaction.
	 *
	 * @return bool
	 */
	public function commit_transaction() {
		global $wpdb;

		if ( ! $this->transaction_active ) {
			return false;
		}

		$result = $wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		if ( false !== $result ) {
			$this->transaction_active = false;
		}

		return false !== $result;
	}

	/**
	 * Roll back and invalidate runtime caches for touched objects.
	 *
	 * @param int[] $post_ids Touched post IDs.
	 * @param int[] $term_ids Touched term IDs.
	 * @return void
	 */
	public function rollback_transaction( array $post_ids = array(), array $term_ids = array() ) {
		global $wpdb;

		if ( $this->transaction_active ) {
			$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$this->transaction_active = false;
		}

		$this->clean_runtime_caches( $post_ids, $term_ids );
	}

	/**
	 * Lock existing post rows in deterministic order.
	 *
	 * @param int[] $post_ids Post IDs.
	 * @return int[]
	 */
	public function lock_post_rows( array $post_ids ) {
		global $wpdb;

		$post_ids = array_values( array_unique( array_filter( array_map( 'absint', $post_ids ) ) ) );
		sort( $post_ids, SORT_NUMERIC );
		$locked = array();

		foreach ( $post_ids as $post_id ) {
			$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->prepare( "SELECT ID, post_type FROM {$wpdb->posts} WHERE ID = %d FOR UPDATE", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			);
			if ( $row ) {
				$locked_id = (int) $row->ID;
				$locked[]  = $locked_id;
				wp_cache_delete( $locked_id, 'posts' );
				wp_cache_delete( 'post_parent:' . (string) $locked_id, 'posts' );
				wp_cache_delete( $locked_id, 'post_meta' );
				clean_object_term_cache( $locked_id, (string) $row->post_type );
			}
		}

		return $locked;
	}

	/**
	 * Lock exact existing taxonomy rows in deterministic taxonomy/term order.
	 *
	 * @param array $terms Taxonomy name to term-ID map.
	 * @return string[]|WP_Error Locked taxonomy/term keys.
	 */
	public function lock_term_rows( array $terms ) {
		global $wpdb;

		$requested = array();
		foreach ( $terms as $taxonomy => $term_ids ) {
			foreach ( array_unique( array_filter( array_map( 'absint', (array) $term_ids ) ) ) as $term_id ) {
				$requested[ sanitize_key( $taxonomy ) . ':' . $term_id ] = array( sanitize_key( $taxonomy ), $term_id );
			}
		}
		ksort( $requested, SORT_STRING );

		$locked = array();
		foreach ( $requested as $key => $parts ) {
			$term_taxonomy_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->prepare(
					"SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s AND term_id = %d FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					$parts[0],
					$parts[1]
				)
			);
			if ( ! $term_taxonomy_id ) {
				return new WP_Error(
					'pufferpdf_destination_changed',
					__( 'A selected taxonomy term disappeared before publication.', 'pufferpdf' ),
					array( 'status' => 409 )
				);
			}
			$locked[] = $key;
		}

		return $locked;
	}

	/**
	 * Lock post metadata, taxonomy relationships, and comments for exact posts.
	 *
	 * The indexed range locks also prevent new related rows from appearing while
	 * undo rechecks immutable snapshots.
	 *
	 * @param int[] $post_ids Post IDs.
	 * @return true|WP_Error
	 */
	public function lock_post_relationship_rows( array $post_ids ) {
		global $wpdb;

		$post_ids = array_values( array_unique( array_filter( array_map( 'absint', $post_ids ) ) ) );
		sort( $post_ids, SORT_NUMERIC );
		if ( empty( $post_ids ) ) {
			return true;
		}

		$placeholders = implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) );
		$queries      = array(
			"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id IN ({$placeholders}) FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			"SELECT object_id FROM {$wpdb->term_relationships} WHERE object_id IN ({$placeholders}) FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			"SELECT comment_ID FROM {$wpdb->comments} WHERE comment_post_ID IN ({$placeholders}) FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		);
		foreach ( $queries as $query ) {
			$result = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->prepare( $query, $post_ids ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			);
			if ( false === $result ) {
				return new WP_Error(
					'pufferpdf_storage_unavailable',
					__( 'PufferPDF could not lock the publication snapshot safely.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}
		}

		return true;
	}

	/**
	 * Clear post and term caches after a rollback or verified write.
	 *
	 * @param int[] $post_ids Post IDs.
	 * @param int[] $term_ids Term IDs.
	 * @return void
	 */
	public function clean_runtime_caches( array $post_ids, array $term_ids = array() ) {
		global $wpdb;

		$post_ids = array_values( array_unique( array_filter( array_map( 'absint', $post_ids ) ) ) );
		foreach ( $post_ids as $post_id ) {
			clean_post_cache( $post_id );
			wp_cache_delete( $post_id, 'post_meta' );
		}

		if ( ! empty( $post_ids ) ) {
			$placeholders = implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) );
			$comment_ids  = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_post_ID IN ({$placeholders})", $post_ids ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			);
			foreach ( array_map( 'absint', (array) $comment_ids ) as $comment_id ) {
				clean_comment_cache( $comment_id );
			}
		}

		$term_ids = array_values( array_unique( array_filter( array_map( 'absint', $term_ids ) ) ) );
		if ( ! empty( $term_ids ) ) {
			clean_term_cache( $term_ids );
		}
	}

	/**
	 * Derive a bounded database lock name.
	 *
	 * @param string $key Logical lock key.
	 * @return string
	 */
	private function get_lock_name( $key ) {
		global $wpdb;

		$database = defined( 'DB_NAME' ) ? DB_NAME : $wpdb->dbname;
		$scope    = md5( $database . ':' . $wpdb->prefix );

		if ( 0 === strpos( $key, 'attachment:' ) ) {
			return substr( 'pufferpdf_' . $scope . '_' . absint( substr( $key, 11 ) ), 0, 64 );
		}

		return substr( 'pufferpdf_' . $scope . '_' . hash( 'sha256', $key ), 0, 64 );
	}

	/**
	 * Verify storage engines for an exact table set.
	 *
	 * @param string[] $tables Full table names.
	 * @return bool
	 */
	private function tables_are_transactional( array $tables ) {
		global $wpdb;

		$tables = array_values( array_unique( array_filter( array_map( 'strval', $tables ), 'strlen' ) ) );
		if ( empty( $tables ) ) {
			return false;
		}

		$database     = defined( 'DB_NAME' ) ? DB_NAME : $wpdb->dbname;
		$placeholders = implode( ', ', array_fill( 0, count( $tables ), '%s' ) );
		$sql          = "SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ({$placeholders})"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$parameters   = array_merge( array( $database ), $tables );
		$rows         = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare( $sql, $parameters ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			ARRAY_A
		);

		$engines = array();
		foreach ( (array) $rows as $row ) {
			$engines[ $row['TABLE_NAME'] ] = strtoupper( (string) $row['ENGINE'] );
		}

		foreach ( $tables as $table ) {
			if ( ! isset( $engines[ $table ] ) || 'INNODB' !== $engines[ $table ] ) {
				return false;
			}
		}

		return true;
	}
}
