<?php
/**
 * Built-in form repository.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Forms;

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Repository methods read/write Submission Inbox custom tables directly; WordPress has no native API for these records.
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are plugin-owned $wpdb->prefix values, and dynamic clauses are built from sanitized allowlists/placeholders in this repository.

/**
 * Reads and writes built-in Submission Inbox forms.
 */
class FormRepository {
	const DESIGN_STUDIO_SCHEMA_VERSION      = 3;
	const DESIGN_STUDIO_HISTORY_MAX_ITEMS   = 8;
	const DESIGN_STUDIO_HISTORY_MAX_BYTES   = 1835008;
	const DESIGN_STUDIO_LARGE_SETTINGS_JSON = 4194304;

	const STATUS_ACTIVE   = 'active';
	const STATUS_INACTIVE = 'inactive';
	const STATUS_TRASHED  = 'trash';

	const LAYOUT_CLEAN_CARD    = 'clean_card';
	const LAYOUT_MINIMAL       = 'minimal';
	const LAYOUT_SOFT_BUSINESS = 'soft_business';
	const LAYOUT_COMPACT       = 'compact';
	const LAYOUT_FULL_WIDTH    = 'full_width';
	const LAYOUT_TWO_COLUMN    = 'two_column';

	const STYLE_DEFAULT = 'default';
	const STYLE_LIGHT   = 'light';
	const STYLE_SOFT    = 'soft';
	const STYLE_SIMPLE  = 'simple';
	const STYLE_PROFESSIONAL = 'professional';

	/**
	 * Create a form.
	 *
	 * @param array<string,mixed> $data Form data.
	 * @return int
	 */
	public function create( array $data ) {
		global $wpdb;

		$table = $wpdb->prefix . 'subin_forms';
		$now   = current_time( 'mysql' );
		$data  = $this->sanitize_data( $data );

		if ( '' === $data['title'] ) {
			return 0;
		}

		$data['slug']       = $this->generate_unique_slug( $data['slug'] ? $data['slug'] : $data['title'] );
		$data['created_at'] = $now;
		$data['updated_at'] = $now;

		$inserted = $wpdb->insert(
			$table,
			$data,
			array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' )
		);

		return $inserted ? absint( $wpdb->insert_id ) : 0;
	}

	/**
	 * Update a form.
	 *
	 * @param int                 $form_id Form id.
	 * @param array<string,mixed> $data Form data.
	 * @return bool
	 */
	public function update( $form_id, array $data ) {
		global $wpdb;

		$form_id = absint( $form_id );
		if ( $form_id <= 0 ) {
			return false;
		}

		$table = $wpdb->prefix . 'subin_forms';
		$data  = $this->sanitize_data( $data );

		if ( '' === $data['title'] ) {
			return false;
		}

		$data['slug']       = $this->generate_unique_slug( $data['slug'] ? $data['slug'] : $data['title'], $form_id );
		$data['updated_at'] = current_time( 'mysql' );

		$updated = $wpdb->update(
			$table,
			$data,
			array( 'id' => $form_id ),
			array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s' ),
			array( '%d' )
		);

		return false !== $updated;
	}

	/**
	 * Update a form by merging submitted values with the current row.
	 *
	 * @param int                 $form_id Form id.
	 * @param array<string,mixed> $data Form data.
	 * @return bool
	 */
	public function update_partial( $form_id, array $data ) {
		$current = $this->get( $form_id );
		if ( ! $current ) {
			return false;
		}

		return $this->update( $form_id, array_merge( $current, $data ) );
	}

	/**
	 * Update only settings_json with an already validated settings array.
	 *
	 * GrapesJS project data uses camelCase keys that must remain intact, so this
	 * path intentionally avoids the generic nested key sanitizer used by form
	 * field saves.
	 *
	 * @param int                 $form_id Form id.
	 * @param array<string,mixed> $settings Settings.
	 * @return bool
	 */
	public function update_settings_json( $form_id, array $settings ) {
		global $wpdb;

		$form_id = absint( $form_id );
		if ( $form_id <= 0 ) {
			return false;
		}

		$encoded = wp_json_encode( $settings );
		if ( ! is_string( $encoded ) ) {
			return false;
		}

		$updated = $wpdb->update(
			$wpdb->prefix . 'subin_forms',
			array(
				'settings_json' => $encoded,
				'updated_at'    => current_time( 'mysql' ),
			),
			array( 'id' => $form_id ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		return false !== $updated;
	}

	/**
	 * Update an allowlisted subset of scalar columns without rewriting settings.
	 *
	 * @param int                 $form_id Form id.
	 * @param array<string,mixed> $updates Column updates.
	 * @return bool
	 */
	public function update_columns( $form_id, array $updates ) {
		return $this->persist_settings_and_columns( absint( $form_id ), null, $updates );
	}

	/**
	 * Patch top-level settings and scalar columns against a locked current row.
	 *
	 * @param int                 $form_id Form id.
	 * @param array<string,mixed> $settings_patch Top-level settings sections.
	 * @param array<string,mixed> $columns Scalar column updates.
	 * @return bool
	 */
	public function patch_settings_and_columns( $form_id, array $settings_patch, array $columns = array() ) {
		global $wpdb;

		$form_id = absint( $form_id );
		if ( $form_id <= 0 || false === $wpdb->query( 'START TRANSACTION' ) ) {
			return false;
		}

		$form = $this->get_for_update( $form_id );
		if ( ! $form ) {
			$wpdb->query( 'ROLLBACK' );
			return false;
		}

		$settings = $this->decode_settings( $form );
		foreach ( $settings_patch as $section => $patch ) {
			$section = sanitize_key( $section );
			if ( ! in_array( $section, array( 'notification', 'auto_reply' ), true ) || ! is_array( $patch ) ) {
				continue;
			}

			$current_section      = is_array( $settings[ $section ] ?? null ) ? $settings[ $section ] : array();
			$settings[ $section ] = array_replace( $current_section, $patch );
		}

		if ( ! $this->persist_settings_and_columns( $form_id, $settings, $columns ) ) {
			$wpdb->query( 'ROLLBACK' );
			return false;
		}

		if ( false === $wpdb->query( 'COMMIT' ) ) {
			$wpdb->query( 'ROLLBACK' );
			return false;
		}

		return true;
	}

	/**
	 * Atomically reconcile and save a versioned Design Studio project.
	 *
	 * @param int                 $form_id Form id.
	 * @param int                 $expected_revision Client revision.
	 * @param array<string,mixed> $project Sanitized project.
	 * @param callable            $mutator Field reconciliation callback.
	 * @return array{project:array<string,mixed>,revision:int}|\WP_Error
	 */
	public function save_design_studio_project( $form_id, $expected_revision, array $project, callable $mutator ) {
		global $wpdb;

		$form_id           = absint( $form_id );
		$expected_revision = max( 0, (int) $expected_revision );
		if ( $form_id <= 0 || false === $wpdb->query( 'START TRANSACTION' ) ) {
			return new \WP_Error( 'design_studio_transaction_failed', __( 'Design Studio could not be saved.', 'submission-inbox' ), array( 'status' => 500 ) );
		}

		try {
			$form = $this->get_for_update( $form_id );
			if ( ! $form ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'form_not_found', __( 'Form not found.', 'submission-inbox' ), array( 'status' => 404 ) );
			}

			$settings         = $this->decode_settings( $form );
			$design_studio    = is_array( $settings['design_studio'] ?? null ) ? $settings['design_studio'] : array();
			$current_revision = max( 0, (int) ( $design_studio['project_revision'] ?? 0 ) );
			$current_project  = is_array( $design_studio['grapesjs_project'] ?? null ) ? $design_studio['grapesjs_project'] : array();

			if ( $expected_revision !== $current_revision ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error(
					'revision_conflict',
					__( 'This form was changed elsewhere. Reload it before saving again.', 'submission-inbox' ),
					array(
						'status'   => 409,
						'project'  => $current_project,
						'revision' => $current_revision,
					)
				);
			}

			$result = call_user_func( $mutator, $form_id, $project );
			if ( is_wp_error( $result ) ) {
				$wpdb->query( 'ROLLBACK' );
				return $result;
			}
			if ( ! is_array( $result ) || ! is_array( $result['project'] ?? null ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_sync_failed', __( 'Design Studio fields could not be saved.', 'submission-inbox' ), array( 'status' => 500 ) );
			}

			$canonical_project = $result['project'];
			$messages          = is_array( $result['messages'] ?? null ) ? $result['messages'] : array();
			$columns           = is_array( $result['columns'] ?? null ) ? $result['columns'] : array();
			$next_revision     = $current_revision + 1;
			$design_studio['grapesjs_project'] = $canonical_project;
			$design_studio['project_revision'] = $next_revision;
			$settings['design_studio']          = $design_studio;
			if ( ! empty( $messages ) ) {
				$current_messages     = is_array( $settings['messages'] ?? null ) ? $settings['messages'] : array();
				$settings['messages'] = array_merge( $current_messages, $messages );
			}

			$allow_persist = apply_filters( 'subin_design_studio_allow_project_persist', true, $form_id, $settings, $columns );
			if ( is_wp_error( $allow_persist ) ) {
				$wpdb->query( 'ROLLBACK' );
				return $allow_persist;
			}
			if ( true !== $allow_persist || ! $this->persist_settings_and_columns( $form_id, $settings, $columns ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_persist_failed', __( 'Design Studio could not be saved.', 'submission-inbox' ), array( 'status' => 500 ) );
			}

			if ( false === $wpdb->query( 'COMMIT' ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_commit_failed', __( 'Design Studio could not be saved.', 'submission-inbox' ), array( 'status' => 500 ) );
			}

			return array(
				'project'  => $canonical_project,
				'revision' => $next_revision,
			);
		} catch ( \Throwable $throwable ) {
			$wpdb->query( 'ROLLBACK' );
			return new \WP_Error( 'design_studio_save_exception', __( 'Design Studio could not be saved.', 'submission-inbox' ), array( 'status' => 500 ) );
		}
	}

	/**
	 * Read the versioned Design Studio state with a legacy published fallback.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return array<string,mixed>
	 */
	public function get_design_studio_state( array $form ) {
		return $this->get_design_studio_state_from_settings( $this->decode_settings( $form ), $form );
	}

	/**
	 * Read Design Studio state from settings that have already been decoded.
	 *
	 * @param array<string,mixed> $settings Decoded form settings.
	 * @param array<string,mixed> $form Form row used for legacy timestamps.
	 * @return array<string,mixed>
	 */
	public function get_design_studio_state_from_settings( array $settings, array $form = array() ) {
		$design_studio  = is_array( $settings['design_studio'] ?? null ) ? $settings['design_studio'] : array();
		$legacy_project = is_array( $design_studio['grapesjs_project'] ?? null ) ? $design_studio['grapesjs_project'] : array();
		$legacy_revision = max( 0, (int) ( $design_studio['project_revision'] ?? 0 ) );
		$published_raw   = is_array( $design_studio['published'] ?? null ) ? $design_studio['published'] : array();
		$draft_raw       = is_array( $design_studio['draft'] ?? null ) ? $design_studio['draft'] : array();
		$published_project = is_array( $published_raw['project'] ?? null ) ? $published_raw['project'] : $legacy_project;
		$published_project = $this->compact_design_studio_project( $published_project );
		$published_revision = array_key_exists( 'revision', $published_raw ) ? max( 0, (int) $published_raw['revision'] ) : $legacy_revision;
		$has_draft       = is_array( $draft_raw['project'] ?? null ) || array_key_exists( 'revision', $draft_raw );
		$draft_project   = is_array( $draft_raw['project'] ?? null ) ? $draft_raw['project'] : $published_project;
		$draft_project   = $this->compact_design_studio_project( $draft_project );
		$history         = array();

		foreach ( is_array( $design_studio['history'] ?? null ) ? $design_studio['history'] : array() as $snapshot ) {
			if ( ! is_array( $snapshot ) || ! is_array( $snapshot['project'] ?? null ) ) {
				continue;
			}
			$snapshot_project = $this->compact_design_studio_project( $snapshot['project'] );
			$history[] = array(
				'revision'     => max( 0, (int) ( $snapshot['revision'] ?? 0 ) ),
				'project'      => $snapshot_project,
				'hash'         => $this->design_studio_project_hash( $snapshot_project ),
				'published_at' => sanitize_text_field( $snapshot['published_at'] ?? '' ),
				'published_by' => absint( $snapshot['published_by'] ?? 0 ),
			);
		}

		$history         = $this->compact_design_studio_history( $history );
		$published_hash  = $this->design_studio_project_hash( $published_project );
		$draft_hash      = $this->design_studio_project_hash( $draft_project );

		return array(
			'schema_version' => self::DESIGN_STUDIO_SCHEMA_VERSION,
			'published'      => array(
				'revision'     => $published_revision,
				'project'      => $published_project,
				'hash'         => $published_hash,
				'published_at' => sanitize_text_field( $published_raw['published_at'] ?? ( $form['updated_at'] ?? '' ) ),
				'published_by' => absint( $published_raw['published_by'] ?? 0 ),
			),
			'draft'          => array(
				'revision'               => $has_draft ? max( 0, (int) ( $draft_raw['revision'] ?? 0 ) ) : 0,
				'base_published_revision' => $has_draft ? max( 0, (int) ( $draft_raw['base_published_revision'] ?? $published_revision ) ) : $published_revision,
				'project'                => $draft_project,
				'hash'                   => $draft_hash,
				'updated_at'             => sanitize_text_field( $draft_raw['updated_at'] ?? '' ),
				'updated_by'             => absint( $draft_raw['updated_by'] ?? 0 ),
			),
			'history'        => $history,
			'has_draft'      => $has_draft,
			'is_dirty'       => $draft_hash !== $published_hash,
		);
	}

	/**
	 * Atomically autosave a Design Studio draft without changing public fields.
	 *
	 * @param int                 $form_id Form id.
	 * @param int                 $expected_draft_revision Expected draft revision.
	 * @param int                 $expected_published_revision Expected published revision.
	 * @param array<string,mixed> $project Sanitized project.
	 * @return array<string,mixed>|\WP_Error
	 */
	public function save_design_studio_draft( $form_id, $expected_draft_revision, $expected_published_revision, array $project ) {
		global $wpdb;

		$form_id                     = absint( $form_id );
		$expected_draft_revision     = max( 0, (int) $expected_draft_revision );
		$expected_published_revision = max( 0, (int) $expected_published_revision );
		if ( $form_id <= 0 || false === $wpdb->query( 'START TRANSACTION' ) ) {
			return new \WP_Error( 'design_studio_transaction_failed', __( 'Draft could not be saved.', 'submission-inbox' ), array( 'status' => 500 ) );
		}

		try {
			$form = $this->get_for_update( $form_id );
			if ( ! $form ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'form_not_found', __( 'Form not found.', 'submission-inbox' ), array( 'status' => 404 ) );
			}

			$settings           = $this->decode_settings( $form );
			$design_studio      = is_array( $settings['design_studio'] ?? null ) ? $settings['design_studio'] : array();
			$state              = $this->get_design_studio_state_from_settings( $settings, $form );
			$draft_revision     = (int) $state['draft']['revision'];
			$published_revision = (int) $state['published']['revision'];
			if ( $expected_draft_revision !== $draft_revision || $expected_published_revision !== $published_revision ) {
				$wpdb->query( 'ROLLBACK' );
				return $this->design_studio_conflict_error( $state );
			}

			$project        = $this->compact_design_studio_project( $project );
			$hash           = $this->design_studio_project_hash( $project );
			$is_unchanged   = ! empty( $state['has_draft'] ) && hash_equals( (string) $state['draft']['hash'], $hash );
			$next_revision  = $is_unchanged ? $draft_revision : $draft_revision + 1;
			$draft          = $is_unchanged
				? $state['draft']
				: array(
					'revision'                => $next_revision,
					'base_published_revision' => $published_revision,
					'project'                 => $project,
					'hash'                    => $hash,
					'updated_at'              => gmdate( 'c' ),
					'updated_by'              => get_current_user_id(),
				);
			$project         = $draft['project'];
			$design_studio   = $this->design_studio_storage(
				$design_studio,
				$state['published'],
				$draft,
				$state['history']
			);
			$settings['design_studio'] = $design_studio;

			if ( ! $this->persist_settings_and_columns( $form_id, $settings, array() ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_draft_persist_failed', __( 'Draft could not be saved.', 'submission-inbox' ), array( 'status' => 500 ) );
			}
			if ( false === $wpdb->query( 'COMMIT' ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_commit_failed', __( 'Draft could not be saved.', 'submission-inbox' ), array( 'status' => 500 ) );
			}

			return array(
				'project'            => $project,
				'revision'           => $next_revision,
				'published_revision' => $published_revision,
				'is_dirty'           => (string) $draft['hash'] !== (string) $state['published']['hash'],
			);
		} catch ( \Throwable $throwable ) {
			$wpdb->query( 'ROLLBACK' );
			return new \WP_Error( 'design_studio_draft_exception', __( 'Draft could not be saved.', 'submission-inbox' ), array( 'status' => 500 ) );
		}
	}

	/**
	 * Publish the server-stored draft and reconcile its public form fields.
	 *
	 * @param int      $form_id Form id.
	 * @param int      $expected_draft_revision Expected draft revision.
	 * @param int      $expected_published_revision Expected published revision.
	 * @param callable $mutator Field reconciliation callback.
	 * @return array<string,mixed>|\WP_Error
	 */
	public function publish_design_studio_draft( $form_id, $expected_draft_revision, $expected_published_revision, callable $mutator ) {
		global $wpdb;

		$form_id                     = absint( $form_id );
		$expected_draft_revision     = max( 0, (int) $expected_draft_revision );
		$expected_published_revision = max( 0, (int) $expected_published_revision );
		if ( $form_id <= 0 || false === $wpdb->query( 'START TRANSACTION' ) ) {
			return new \WP_Error( 'design_studio_transaction_failed', __( 'Form could not be published.', 'submission-inbox' ), array( 'status' => 500 ) );
		}

		try {
			$form = $this->get_for_update( $form_id );
			if ( ! $form ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'form_not_found', __( 'Form not found.', 'submission-inbox' ), array( 'status' => 404 ) );
			}

			$settings           = $this->decode_settings( $form );
			$design_studio      = is_array( $settings['design_studio'] ?? null ) ? $settings['design_studio'] : array();
			$state              = $this->get_design_studio_state_from_settings( $settings, $form );
			$draft_revision     = (int) $state['draft']['revision'];
			$published_revision = (int) $state['published']['revision'];
			if ( $expected_draft_revision !== $draft_revision || $expected_published_revision !== $published_revision ) {
				$wpdb->query( 'ROLLBACK' );
				return $this->design_studio_conflict_error( $state );
			}
			if ( empty( $state['is_dirty'] ) ) {
				$wpdb->query( 'COMMIT' );
				return array(
					'project'            => $state['published']['project'],
					'revision'           => $draft_revision,
					'published_revision' => $published_revision,
					'published_at'       => $state['published']['published_at'],
					'history'            => $this->design_studio_history_metadata( $state['history'] ),
					'is_dirty'           => false,
				);
			}

			$result = call_user_func( $mutator, $form_id, $state['draft']['project'] );
			if ( is_wp_error( $result ) ) {
				$wpdb->query( 'ROLLBACK' );
				return $result;
			}
			if ( ! is_array( $result ) || ! is_array( $result['project'] ?? null ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_sync_failed', __( 'Form fields could not be published.', 'submission-inbox' ), array( 'status' => 500 ) );
			}

			$canonical_project = $this->compact_design_studio_project( $result['project'] );
			$messages          = is_array( $result['messages'] ?? null ) ? $result['messages'] : array();
			$columns           = is_array( $result['columns'] ?? null ) ? $result['columns'] : array();
			$history           = $state['history'];
			if ( ! empty( $state['published']['project'] ) ) {
				$history[] = $state['published'];
			}
			$next_published_revision     = $published_revision + 1;
			$next_draft_revision         = $draft_revision + 1;
			$published_at                = gmdate( 'c' );
			$hash                        = $this->design_studio_project_hash( $canonical_project );
			$published                   = array(
				'revision'     => $next_published_revision,
				'project'      => $canonical_project,
				'hash'         => $hash,
				'published_at' => $published_at,
				'published_by' => get_current_user_id(),
			);

			$draft = array(
				'revision'                => $next_draft_revision,
				'base_published_revision' => $next_published_revision,
				'project'                 => $canonical_project,
				'hash'                    => $hash,
				'updated_at'              => $published_at,
				'updated_by'              => get_current_user_id(),
			);
			$design_studio = $this->design_studio_storage(
				$design_studio,
				$published,
				$draft,
				$history
			);
			$history                    = $design_studio['history'];
			$settings['design_studio']  = $design_studio;
			if ( ! empty( $messages ) ) {
				$current_messages     = is_array( $settings['messages'] ?? null ) ? $settings['messages'] : array();
				$settings['messages'] = array_merge( $current_messages, $messages );
			}

			$allow_persist = apply_filters( 'subin_design_studio_allow_publish_persist', true, $form_id, $settings, $columns );
			if ( is_wp_error( $allow_persist ) ) {
				$wpdb->query( 'ROLLBACK' );
				return $allow_persist;
			}
			if ( true !== $allow_persist || ! $this->persist_settings_and_columns( $form_id, $settings, $columns ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_publish_persist_failed', __( 'Form could not be published.', 'submission-inbox' ), array( 'status' => 500 ) );
			}
			if ( false === $wpdb->query( 'COMMIT' ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_commit_failed', __( 'Form could not be published.', 'submission-inbox' ), array( 'status' => 500 ) );
			}

			return array(
				'project'            => $canonical_project,
				'revision'           => $next_draft_revision,
				'published_revision' => $next_published_revision,
				'published_at'       => $published_at,
				'history'            => $this->design_studio_history_metadata( $history ),
				'is_dirty'           => false,
			);
		} catch ( \Throwable $throwable ) {
			$wpdb->query( 'ROLLBACK' );
			return new \WP_Error( 'design_studio_publish_exception', __( 'Form could not be published.', 'submission-inbox' ), array( 'status' => 500 ) );
		}
	}

	/**
	 * Restore a published snapshot into a new draft without changing public output.
	 *
	 * @param int $form_id Form id.
	 * @param int $target_revision Published revision to restore.
	 * @param int $expected_draft_revision Expected draft revision.
	 * @param int $expected_published_revision Expected published revision.
	 * @return array<string,mixed>|\WP_Error
	 */
	public function restore_design_studio_revision( $form_id, $target_revision, $expected_draft_revision, $expected_published_revision ) {
		global $wpdb;

		$form_id                     = absint( $form_id );
		$target_revision             = max( 0, (int) $target_revision );
		$expected_draft_revision     = max( 0, (int) $expected_draft_revision );
		$expected_published_revision = max( 0, (int) $expected_published_revision );
		if ( $form_id <= 0 || false === $wpdb->query( 'START TRANSACTION' ) ) {
			return new \WP_Error( 'design_studio_transaction_failed', __( 'Revision could not be restored.', 'submission-inbox' ), array( 'status' => 500 ) );
		}

		try {
			$form = $this->get_for_update( $form_id );
			if ( ! $form ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'form_not_found', __( 'Form not found.', 'submission-inbox' ), array( 'status' => 404 ) );
			}
			$settings      = $this->decode_settings( $form );
			$design_studio = is_array( $settings['design_studio'] ?? null ) ? $settings['design_studio'] : array();
			$state         = $this->get_design_studio_state_from_settings( $settings, $form );
			if (
				$expected_draft_revision !== (int) $state['draft']['revision']
				|| $expected_published_revision !== (int) $state['published']['revision']
			) {
				$wpdb->query( 'ROLLBACK' );
				return $this->design_studio_conflict_error( $state );
			}

			$snapshot = null;
			if ( $target_revision === (int) $state['published']['revision'] ) {
				$snapshot = $state['published'];
			} else {
				foreach ( $state['history'] as $candidate ) {
					if ( $target_revision === (int) $candidate['revision'] ) {
						$snapshot = $candidate;
						break;
					}
				}
			}
			if ( ! is_array( $snapshot ) || ! is_array( $snapshot['project'] ?? null ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_revision_not_found', __( 'Published revision was not found.', 'submission-inbox' ), array( 'status' => 404 ) );
			}

			$next_revision = (int) $state['draft']['revision'] + 1;
			$project       = $this->compact_design_studio_project( $snapshot['project'] );
			$hash          = $this->design_studio_project_hash( $project );
			$draft         = array(
				'revision'                => $next_revision,
				'base_published_revision' => (int) $state['published']['revision'],
				'project'                 => $project,
				'hash'                    => $hash,
				'updated_at'              => gmdate( 'c' ),
				'updated_by'              => get_current_user_id(),
			);
			$design_studio = $this->design_studio_storage(
				$design_studio,
				$state['published'],
				$draft,
				$state['history']
			);
			$settings['design_studio'] = $design_studio;
			if ( ! $this->persist_settings_and_columns( $form_id, $settings, array() ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_restore_persist_failed', __( 'Revision could not be restored.', 'submission-inbox' ), array( 'status' => 500 ) );
			}
			if ( false === $wpdb->query( 'COMMIT' ) ) {
				$wpdb->query( 'ROLLBACK' );
				return new \WP_Error( 'design_studio_commit_failed', __( 'Revision could not be restored.', 'submission-inbox' ), array( 'status' => 500 ) );
			}

			return array(
				'project'            => $project,
				'revision'           => $next_revision,
				'published_revision' => (int) $state['published']['revision'],
				'restored_revision'  => $target_revision,
				'is_dirty'           => $hash !== (string) $state['published']['hash'],
			);
		} catch ( \Throwable $throwable ) {
			$wpdb->query( 'ROLLBACK' );
			return new \WP_Error( 'design_studio_restore_exception', __( 'Revision could not be restored.', 'submission-inbox' ), array( 'status' => 500 ) );
		}
	}

	/**
	 * Remove equivalent GrapesJS CSS rules.
	 *
	 * Preview rules used to be appended on every editor load. Only equivalent
	 * rules are folded so intentionally layered declarations keep their cascade.
	 *
	 * @param array<string,mixed> $project Project.
	 * @return array<string,mixed>
	 */
	public function compact_design_studio_project( array $project ) {
		if ( ! is_array( $project['styles'] ?? null ) ) {
			return $project;
		}

		$rules = array();
		foreach ( $project['styles'] as $index => $rule ) {
			if ( ! is_array( $rule ) ) {
				$rules[ 'invalid:' . $index ] = array(
					'index' => (int) $index,
					'rule'  => $rule,
				);
				continue;
			}

			$identity = $this->normalize_design_studio_identity( $rule );
			$encoded  = wp_json_encode( $identity );
			$key      = is_string( $encoded ) ? hash( 'sha256', $encoded ) : 'invalid:' . $index;

			$rules[ $key ] = array(
				'index' => (int) $index,
				'rule'  => $rule,
			);
		}

		uasort(
			$rules,
			static function ( $left, $right ) {
				return (int) $left['index'] <=> (int) $right['index'];
			}
		);
		$project['styles'] = array_values( array_column( $rules, 'rule' ) );

		return $project;
	}

	/**
	 * Build the bounded schema used for new Design Studio writes.
	 *
	 * @param array<string,mixed> $design_studio Existing Design Studio settings.
	 * @param array<string,mixed> $published Published state.
	 * @param array<string,mixed> $draft Draft state.
	 * @param array<int,array<string,mixed>> $history Published history.
	 * @return array<string,mixed>
	 */
	private function design_studio_storage( array $design_studio, array $published, array $draft, array $history ) {
		$published['project'] = $this->compact_design_studio_project(
			is_array( $published['project'] ?? null ) ? $published['project'] : array()
		);
		$published['hash'] = $this->design_studio_project_hash( $published['project'] );
		$draft['project']  = $this->compact_design_studio_project(
			is_array( $draft['project'] ?? null ) ? $draft['project'] : $published['project']
		);
		$draft['hash']     = $this->design_studio_project_hash( $draft['project'] );

		if ( hash_equals( (string) $published['hash'], (string) $draft['hash'] ) ) {
			unset( $draft['project'] );
			$draft['uses_published_project'] = true;
		} else {
			unset( $draft['uses_published_project'] );
		}

		$design_studio['schema_version']   = self::DESIGN_STUDIO_SCHEMA_VERSION;
		$design_studio['published']        = $published;
		$design_studio['draft']            = $draft;
		$design_studio['history']          = $this->compact_design_studio_history( $history );
		$design_studio['project_revision'] = max( 0, (int) ( $published['revision'] ?? 0 ) );
		unset( $design_studio['grapesjs_project'] );

		return $design_studio;
	}

	/**
	 * Keep a contiguous set of the newest snapshots inside a fixed byte budget.
	 *
	 * @param array<int,array<string,mixed>> $history Published history.
	 * @return array<int,array<string,mixed>>
	 */
	private function compact_design_studio_history( array $history ) {
		$kept  = array();
		$bytes = 0;

		foreach ( array_reverse( $history ) as $snapshot ) {
			if ( count( $kept ) >= self::DESIGN_STUDIO_HISTORY_MAX_ITEMS ) {
				break;
			}
			if ( ! is_array( $snapshot ) || ! is_array( $snapshot['project'] ?? null ) ) {
				continue;
			}

			$snapshot['project'] = $this->compact_design_studio_project( $snapshot['project'] );
			$snapshot['hash']    = $this->design_studio_project_hash( $snapshot['project'] );
			$encoded             = wp_json_encode( $snapshot );
			$size                = is_string( $encoded ) ? strlen( $encoded ) : self::DESIGN_STUDIO_HISTORY_MAX_BYTES + 1;
			if ( $bytes + $size > self::DESIGN_STUDIO_HISTORY_MAX_BYTES ) {
				break;
			}

			$kept[] = $snapshot;
			$bytes += $size;
		}

		return array_reverse( $kept );
	}

	/**
	 * Recursively normalize associative keys for a stable rule identity.
	 *
	 * @param mixed $value Value.
	 * @return mixed
	 */
	private function normalize_design_studio_identity( $value ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}

		foreach ( $value as $key => $item ) {
			$value[ $key ] = $this->normalize_design_studio_identity( $item );
		}
		if ( array_keys( $value ) !== array_keys( array_values( $value ) ) ) {
			ksort( $value, SORT_STRING );
		}

		return $value;
	}

	/**
	 * Hash a normalized project for idempotency and dirty-state checks.
	 *
	 * @param array<string,mixed> $project Project.
	 * @return string
	 */
	private function design_studio_project_hash( array $project ) {
		$json = wp_json_encode( $project );
		return hash( 'sha256', is_string( $json ) ? $json : '' );
	}

	/**
	 * Build a revision conflict containing the current server versions.
	 *
	 * @param array<string,mixed> $state Design Studio state.
	 * @return \WP_Error
	 */
	private function design_studio_conflict_error( array $state ) {
		return new \WP_Error(
			'revision_conflict',
			__( 'This form was changed elsewhere. Reload it before continuing.', 'submission-inbox' ),
			array(
				'status'             => 409,
				'project'            => $state['draft']['project'],
				'revision'           => (int) $state['draft']['revision'],
				'published_revision' => (int) $state['published']['revision'],
			)
		);
	}

	/**
	 * Remove large project payloads from history returned to the editor.
	 *
	 * @param array<int,array<string,mixed>> $history History.
	 * @return array<int,array<string,mixed>>
	 */
	private function design_studio_history_metadata( array $history ) {
		$metadata = array();
		foreach ( $history as $snapshot ) {
			$metadata[] = array(
				'revision'     => max( 0, (int) ( $snapshot['revision'] ?? 0 ) ),
				'published_at' => sanitize_text_field( $snapshot['published_at'] ?? '' ),
				'published_by' => absint( $snapshot['published_by'] ?? 0 ),
			);
		}
		return $metadata;
	}

	/**
	 * Get a form by id.
	 *
	 * @param int $form_id Form id.
	 * @return array<string,mixed>|null
	 */
	public function get( $form_id ) {
		global $wpdb;

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

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

	/**
	 * Get and lock a form row for the active transaction.
	 *
	 * @param int $form_id Form id.
	 * @return array<string,mixed>|null
	 */
	public function get_for_update( $form_id ) {
		global $wpdb;

		$table = $wpdb->prefix . 'subin_forms';
		$row   = $wpdb->get_row(
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The table name is built only from the trusted WordPress prefix; the form ID uses a placeholder.
			$wpdb->prepare( 'SELECT * FROM ' . $table . ' WHERE id = %d FOR UPDATE', absint( $form_id ) ),
			ARRAY_A
		);

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

	/**
	 * Get a form by slug.
	 *
	 * @param string $slug Form slug.
	 * @return array<string,mixed>|null
	 */
	public function get_by_slug( $slug ) {
		global $wpdb;

		$table = $wpdb->prefix . 'subin_forms';
		$slug  = sanitize_title( $slug );
		$row   = $wpdb->get_row(
			$wpdb->prepare( "SELECT * FROM {$table} WHERE slug = %s", $slug ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			ARRAY_A
		);

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

	/**
	 * Get paginated forms.
	 *
	 * @param array<string,mixed> $args Query args.
	 * @return array<int,array<string,mixed>>
	 */
	public function get_paginated( array $args = array() ) {
		global $wpdb;

		$table   = $wpdb->prefix . 'subin_forms';
		$where   = $this->build_where( $args );
		$limit   = max( 1, absint( $args['per_page'] ?? 20 ) );
		$offset  = max( 0, absint( $args['offset'] ?? 0 ) );
		$sort_columns = array(
			'created' => 'created_at',
			'updated' => 'updated_at',
			'title'   => 'title',
			'status'  => 'status',
		);
		$sort_key     = sanitize_key( (string) ( $args['orderby'] ?? 'created' ) );
		$order_by     = $sort_columns[ $sort_key ] ?? $sort_columns['created'];
		$order        = 'asc' === strtolower( (string) ( $args['order'] ?? 'desc' ) ) ? 'ASC' : 'DESC';
		$sql          = "SELECT * FROM {$table} {$where['sql']} ORDER BY {$order_by} {$order}, id {$order} LIMIT %d OFFSET %d";
		$params  = array_merge( $where['params'], array( $limit, $offset ) );
		$results = $wpdb->get_results( $wpdb->prepare( $sql, $params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		return is_array( $results ) ? $results : array();
	}

	/**
	 * Count forms.
	 *
	 * @param array<string,mixed> $filters Filters.
	 * @return int
	 */
	public function count( array $filters = array() ) {
		global $wpdb;

		$table    = $wpdb->prefix . 'subin_forms';
		$where    = $this->build_where( $filters );
		$sql      = "SELECT COUNT(*) FROM {$table} {$where['sql']}";

		if ( ! empty( $where['params'] ) ) {
			return absint( $wpdb->get_var( $wpdb->prepare( $sql, $where['params'] ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		}

		return absint( $wpdb->get_var( $sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Update a form status.
	 *
	 * @param int    $form_id Form id.
	 * @param string $status Status.
	 * @return bool
	 */
	public function update_status( $form_id, $status ) {
		global $wpdb;

		$form_id = absint( $form_id );
		$status  = $this->sanitize_status( $status );

		if ( $form_id <= 0 ) {
			return false;
		}

		$updated = $wpdb->update(
			$wpdb->prefix . 'subin_forms',
			array(
				'status'     => $status,
				'updated_at' => current_time( 'mysql' ),
			),
			array( 'id' => $form_id ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		return false !== $updated;
	}

	/**
	 * Get forms for select controls.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	public function get_for_dropdown() {
		global $wpdb;

		$table   = $wpdb->prefix . 'subin_forms';
		$results = $wpdb->get_results(
			$wpdb->prepare( "SELECT id, title, slug, status FROM {$table} WHERE status != %s ORDER BY title ASC, id ASC", self::STATUS_TRASHED ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			ARRAY_A
		);

		return is_array( $results ) ? $results : array();
	}

	/**
	 * Get summary counts for admin cards.
	 *
	 * @return array<string,int>
	 */
	public function get_summary_counts() {
		global $wpdb;

		$table = $wpdb->prefix . 'subin_forms';
		$row   = $wpdb->get_row(
			"SELECT
				COUNT(*) AS total,
				COALESCE(SUM(status = 'active'), 0) AS active_count,
				COALESCE(SUM(status = 'inactive'), 0) AS inactive_count,
				COALESCE(SUM(status = 'trash'), 0) AS trash_count
			FROM {$table}"
		);

		return array(
			'total'    => absint( $row->total ?? 0 ),
			'active'   => absint( $row->active_count ?? 0 ),
			'inactive' => absint( $row->inactive_count ?? 0 ),
			'trash'    => absint( $row->trash_count ?? 0 ),
		);
	}

	/**
	 * Duplicate a form and its fields.
	 *
	 * @param int $form_id Form id.
	 * @return int
	 */
	public function duplicate( $form_id ) {
		global $wpdb;

		$form_id = absint( $form_id );
		if ( $form_id <= 0 || false === $wpdb->query( 'START TRANSACTION' ) ) {
			return 0;
		}

		try {
			$form = $this->get_for_update( $form_id );
			if ( ! $form ) {
				$wpdb->query( 'ROLLBACK' );
				return 0;
			}

			$settings       = $this->decode_settings( $form );
			$form['title']  = sprintf(
				/* translators: %s: Original form title. */
				__( '%s Copy', 'submission-inbox' ),
				$form['title']
			);
			$form['slug']          = '';
			$form['status']        = self::STATUS_INACTIVE;
			$form['settings_json'] = array();

			$new_form_id = $this->create( $form );
			if ( $new_form_id <= 0 ) {
				$wpdb->query( 'ROLLBACK' );
				return 0;
			}

			$field_repository = new FormFieldRepository();
			$field_id_map     = $field_repository->copy_fields_with_map( $form_id, $new_form_id );
			if ( is_wp_error( $field_id_map ) ) {
				$wpdb->query( 'ROLLBACK' );
				return 0;
			}

			$valid    = true;
			$settings = $this->remap_duplicate_settings( $settings, $field_id_map, $valid );
			if ( ! $valid || ! $this->update_settings_json( $new_form_id, $settings ) ) {
				$wpdb->query( 'ROLLBACK' );
				return 0;
			}

			if ( false === $wpdb->query( 'COMMIT' ) ) {
				$wpdb->query( 'ROLLBACK' );
				return 0;
			}

			return $new_form_id;
		} catch ( \Throwable $throwable ) {
			$wpdb->query( 'ROLLBACK' );
			return 0;
		}
	}

	/**
	 * Count saved leads for a built-in form.
	 *
	 * @param int $form_id Form id.
	 * @return int
	 */
	public function get_lead_count( $form_id ) {
		global $wpdb;

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

		return absint(
			$wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$table} WHERE provider = %s AND form_id = %s",
					'submission_inbox_form',
					(string) absint( $form_id )
				) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			)
		);
	}

	/**
	 * Get last saved lead submission date for a built-in form.
	 *
	 * @param int $form_id Form id.
	 * @return string|null
	 */
	public function get_last_submission( $form_id ) {
		global $wpdb;

		$table = $wpdb->prefix . 'subin_leads';
		$value = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT MAX(submitted_at) FROM {$table} WHERE provider = %s AND form_id = %s",
				'submission_inbox_form',
				(string) absint( $form_id )
			) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		);

		return $value ? (string) $value : null;
	}

	/**
	 * Get email status counts for a built-in form.
	 *
	 * @param int $form_id Form id.
	 * @return array<string,int>
	 */
	public function get_email_status_counts( $form_id ) {
		global $wpdb;

		$table  = $wpdb->prefix . 'subin_leads';
		$counts = array(
			'sent'               => 0,
			'failed'             => 0,
			'no_email_generated' => 0,
			'unknown'            => 0,
		);

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT email_status, COUNT(*) AS total
				FROM {$table}
				WHERE provider = %s AND form_id = %s
				GROUP BY email_status",
				'submission_inbox_form',
				(string) absint( $form_id )
			)
		); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		foreach ( $rows as $row ) {
			$status = sanitize_key( $row->email_status );
			if ( array_key_exists( $status, $counts ) ) {
				$counts[ $status ] = absint( $row->total );
			}
		}

		return $counts;
	}

	/**
	 * Delete a form and its fields.
	 *
	 * @param int $form_id Form id.
	 * @return bool
	 */
	public function delete( $form_id ) {
		global $wpdb;

		$form_id = absint( $form_id );
		if ( $form_id <= 0 ) {
			return false;
		}

		if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
			return false;
		}

		if ( ! $this->get_for_update( $form_id ) ) {
			$wpdb->query( 'ROLLBACK' );
			return false;
		}

		$field_repository = new FormFieldRepository();
		if ( ! $field_repository->delete_by_form_id( $form_id ) ) {
			$wpdb->query( 'ROLLBACK' );
			return false;
		}

		$deleted = $wpdb->delete( $wpdb->prefix . 'subin_forms', array( 'id' => $form_id ), array( '%d' ) );
		if ( false === $deleted || false === $wpdb->query( 'COMMIT' ) ) {
			$wpdb->query( 'ROLLBACK' );
			return false;
		}

		return true;
	}

	/**
	 * Delete the locked form row inside a caller-owned transaction.
	 *
	 * The caller must lock and validate the row before invoking this method.
	 *
	 * @param int $form_id Form id.
	 * @return bool
	 */
	public function delete_locked_row( $form_id ) {
		global $wpdb;

		$form_id = absint( $form_id );
		if ( $form_id <= 0 ) {
			return false;
		}

		$deleted = $wpdb->delete( $wpdb->prefix . 'subin_forms', array( 'id' => $form_id ), array( '%d' ) );

		return 1 === (int) $deleted;
	}

	/**
	 * Sanitize status.
	 *
	 * @param string $status Status.
	 * @return string
	 */
	public function sanitize_status( $status ) {
		$status = sanitize_key( $status );

		return in_array( $status, $this->get_statuses(), true ) ? $status : self::STATUS_ACTIVE;
	}

	/**
	 * Generate a unique form slug.
	 *
	 * @param string $title Title or slug.
	 * @param int    $exclude_id Form id to exclude.
	 * @return string
	 */
	public function generate_unique_slug( $title, $exclude_id = 0 ) {
		global $wpdb;

		$table      = $wpdb->prefix . 'subin_forms';
		$base_slug  = sanitize_title( $title );
		$base_slug  = $base_slug ? $base_slug : 'form';
		$slug       = $base_slug;
		$suffix     = 2;
		$exclude_id = absint( $exclude_id );

		while ( $this->slug_exists( $table, $slug, $exclude_id ) ) {
			$slug = $base_slug . '-' . $suffix;
			++$suffix;
		}

		return $slug;
	}

	/**
	 * Get supported layout templates.
	 *
	 * @return array<int,string>
	 */
	public function get_layout_templates() {
		return array(
			self::LAYOUT_CLEAN_CARD,
			self::LAYOUT_MINIMAL,
			self::LAYOUT_SOFT_BUSINESS,
			self::LAYOUT_COMPACT,
			self::LAYOUT_FULL_WIDTH,
			self::LAYOUT_TWO_COLUMN,
		);
	}

	/**
	 * Get supported style presets.
	 *
	 * @return array<int,string>
	 */
	public function get_style_presets() {
		return array(
			self::STYLE_DEFAULT,
			self::STYLE_LIGHT,
			self::STYLE_SOFT,
			self::STYLE_SIMPLE,
			self::STYLE_PROFESSIONAL,
		);
	}

	/**
	 * Decode form settings JSON.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return array<string,mixed>
	 */
	public function decode_settings( array $form ) {
		if ( empty( $form['settings_json'] ) || ! is_string( $form['settings_json'] ) ) {
			return array();
		}

		if ( strlen( $form['settings_json'] ) > self::DESIGN_STUDIO_LARGE_SETTINGS_JSON && function_exists( 'wp_raise_memory_limit' ) ) {
			wp_raise_memory_limit( 'admin' );
		}

		$settings = json_decode( $form['settings_json'], true );

		return is_array( $settings ) ? $settings : array();
	}

	/**
	 * Remap every persisted field reference in a duplicated form.
	 *
	 * @param array<string,mixed> $settings Source settings.
	 * @param array<int,int>      $field_id_map Old-to-new field ids.
	 * @param bool                $valid Whether every persisted reference is valid.
	 * @return array<string,mixed>
	 */
	private function remap_duplicate_settings( array $settings, array $field_id_map, &$valid ) {
			if ( is_array( $settings['design_studio'] ?? null ) ) {
				if ( is_array( $settings['design_studio']['grapesjs_project'] ?? null ) ) {
				$settings['design_studio']['grapesjs_project'] = $this->remap_duplicate_project_value(
					$settings['design_studio']['grapesjs_project'],
					'',
					$field_id_map,
					$valid
					);
				}

				foreach ( array( 'published', 'draft' ) as $state_key ) {
					if ( ! is_array( $settings['design_studio'][ $state_key ]['project'] ?? null ) ) {
						continue;
					}
					$project = $this->remap_duplicate_project_value(
						$settings['design_studio'][ $state_key ]['project'],
						'',
						$field_id_map,
						$valid
					);
					$settings['design_studio'][ $state_key ]['project']  = $project;
					$settings['design_studio'][ $state_key ]['hash']     = $this->design_studio_project_hash( $project );
					$settings['design_studio'][ $state_key ]['revision'] = 0;
					if ( 'draft' === $state_key ) {
						$settings['design_studio'][ $state_key ]['base_published_revision'] = 0;
					}
				}
				$settings['design_studio']['history'] = array();

				if ( is_array( $settings['design_studio']['layout'] ?? null ) ) {
				$settings['design_studio']['layout'] = $this->remap_duplicate_layout(
					$settings['design_studio']['layout'],
					$field_id_map,
					$valid
				);
			}

			$settings['design_studio']['project_revision'] = 0;
		}

		if ( is_array( $settings['design_studio_grapesjs_project'] ?? null ) ) {
			$settings['design_studio_grapesjs_project'] = $this->remap_duplicate_project_value(
				$settings['design_studio_grapesjs_project'],
				'',
				$field_id_map,
				$valid
			);
		}

		if ( is_array( $settings['design_studio_field_styles'] ?? null ) ) {
			$styles = array();
			foreach ( $settings['design_studio_field_styles'] as $field_id => $style ) {
				$old_field_id = absint( $field_id );
				if ( $old_field_id > 0 ) {
					if ( isset( $field_id_map[ $old_field_id ] ) ) {
						$styles[ $field_id_map[ $old_field_id ] ] = $style;
					} else {
						$valid = false;
					}
					continue;
				}
				$styles[ $field_id ] = $style;
			}
			$settings['design_studio_field_styles'] = $styles;
		}

		if ( is_array( $settings['design_studio_layout'] ?? null ) ) {
			$settings['design_studio_layout'] = $this->remap_duplicate_layout(
				$settings['design_studio_layout'],
				$field_id_map,
				$valid
			);
		}

		return $settings;
	}

	/**
	 * Recursively remap GrapesJS field attributes, classes, and selectors.
	 *
	 * @param mixed          $value Project value.
	 * @param string|int     $key Parent key.
	 * @param array<int,int> $field_id_map Old-to-new field ids.
	 * @param bool           $valid Whether every persisted reference is valid.
	 * @return mixed
	 */
	private function remap_duplicate_project_value( $value, $key, array $field_id_map, &$valid ) {
		if ( is_array( $value ) ) {
			$remapped = array();
			foreach ( $value as $child_key => $child ) {
				$remapped[ $child_key ] = $this->remap_duplicate_project_value(
					$child,
					$child_key,
					$field_id_map,
					$valid
				);
			}
			return $remapped;
		}

		if ( 'data-subin-field-id' === $key ) {
			$old_field_id = absint( $value );
			if ( $old_field_id > 0 && ! isset( $field_id_map[ $old_field_id ] ) ) {
				$valid = false;
				return '';
			}
			return $old_field_id > 0 ? (string) $field_id_map[ $old_field_id ] : '';
		}

		if ( 'data-subin-style-id' === $key && is_scalar( $value ) ) {
			if ( preg_match( '/^field-(\d+)$/', (string) $value, $matches ) ) {
				$old_field_id = absint( $matches[1] );
				if ( $old_field_id > 0 && ! isset( $field_id_map[ $old_field_id ] ) ) {
					$valid = false;
					return '';
				}
				return $old_field_id > 0 ? 'field-' . $field_id_map[ $old_field_id ] : '';
			}
		}

		if ( ! is_string( $value ) ) {
			return $value;
		}

		return preg_replace_callback(
			'/subin-gjs-component-field-(\d+)/',
			static function ( $matches ) use ( $field_id_map, &$valid ) {
				$old_field_id = absint( $matches[1] );
				if ( $old_field_id > 0 && ! isset( $field_id_map[ $old_field_id ] ) ) {
					$valid = false;
					return 'subin-gjs-component-field-0';
				}
				return 'subin-gjs-component-field-' . ( $field_id_map[ $old_field_id ] ?? 0 );
			},
			$value
		);
	}

	/**
	 * Recursively remap legacy layout field references.
	 *
	 * @param array<mixed>   $layout Layout nodes.
	 * @param array<int,int> $field_id_map Old-to-new field ids.
	 * @param bool           $valid Whether every persisted reference is valid.
	 * @return array<mixed>
	 */
	private function remap_duplicate_layout( array $layout, array $field_id_map, &$valid ) {
		if ( 'field' === (string) ( $layout['type'] ?? '' ) && array_key_exists( 'ref_id', $layout ) ) {
			$old_field_id    = absint( $layout['ref_id'] );
			if ( $old_field_id > 0 && ! isset( $field_id_map[ $old_field_id ] ) ) {
				$valid = false;
			}
			$layout['ref_id'] = $field_id_map[ $old_field_id ] ?? 0;
		}

		foreach ( $layout as $key => $value ) {
			if ( is_array( $value ) ) {
				$layout[ $key ] = $this->remap_duplicate_layout( $value, $field_id_map, $valid );
			}
		}

		return $layout;
	}

	/**
	 * Persist validated settings and an allowlisted set of scalar form columns.
	 *
	 * @param int                      $form_id Form id.
	 * @param array<string,mixed>|null $settings Settings, or null to preserve them.
	 * @param array<string,mixed>      $columns Scalar column updates.
	 * @return bool
	 */
	private function persist_settings_and_columns( $form_id, $settings, array $columns ) {
		global $wpdb;

		$form_id = absint( $form_id );
		if ( $form_id <= 0 || ( null !== $settings && ! is_array( $settings ) ) ) {
			return false;
		}

		$data    = array();
		$formats = array();

		foreach ( $columns as $key => $value ) {
			switch ( $key ) {
				case 'slug':
					$slug_source = sanitize_title( $value );
					if ( '' === $slug_source ) {
						$current     = $this->get( $form_id );
						$slug_source = is_array( $current ) ? (string) ( $current['title'] ?? 'form' ) : 'form';
					}
					$data['slug'] = $this->generate_unique_slug( $slug_source, $form_id );
					$formats[]    = '%s';
					break;
				case 'status':
					$data['status'] = $this->sanitize_status( $value );
					$formats[]      = '%s';
					break;
				case 'save_leads':
				case 'notification_enabled':
					$data[ $key ] = empty( $value ) ? 0 : 1;
					$formats[]    = '%d';
					break;
				case 'notification_email':
					$data[ $key ] = sanitize_email( $value );
					$formats[]    = '%s';
					break;
				case 'notification_subject':
				case 'header_title':
					$data[ $key ] = sanitize_text_field( $value );
					$formats[]    = '%s';
					break;
				case 'header_description':
				case 'success_message':
					$data[ $key ] = sanitize_textarea_field( $value );
					$formats[]    = '%s';
					break;
			}
		}

		if ( null !== $settings ) {
			$encoded = wp_json_encode( $settings );
			if ( ! is_string( $encoded ) ) {
				return false;
			}
			$data['settings_json'] = $encoded;
			$formats[]             = '%s';
		}

		$data['updated_at'] = current_time( 'mysql' );
		$formats[]          = '%s';

		return false !== $wpdb->update(
			$wpdb->prefix . 'subin_forms',
			$data,
			array( 'id' => $form_id ),
			$formats,
			array( '%d' )
		);
	}

	/**
	 * Get supported statuses.
	 *
	 * @return array<int,string>
	 */
	private function get_statuses() {
		return array( self::STATUS_ACTIVE, self::STATUS_INACTIVE, self::STATUS_TRASHED );
	}

	/**
	 * Sanitize form data.
	 *
	 * @param array<string,mixed> $data Raw data.
	 * @return array<string,mixed>
	 */
	private function sanitize_data( array $data ) {
		$layout_template = sanitize_key( $data['layout_template'] ?? self::LAYOUT_CLEAN_CARD );
		$style_preset    = sanitize_key( $data['style_preset'] ?? self::STYLE_DEFAULT );

		if ( ! in_array( $layout_template, $this->get_layout_templates(), true ) ) {
			$layout_template = self::LAYOUT_CLEAN_CARD;
		}

		if ( ! in_array( $style_preset, $this->get_style_presets(), true ) ) {
			$style_preset = self::STYLE_DEFAULT;
		}

		return array(
			'title'                => sanitize_text_field( $data['title'] ?? '' ),
			'slug'                 => sanitize_title( $data['slug'] ?? '' ),
			'status'               => $this->sanitize_status( $data['status'] ?? self::STATUS_ACTIVE ),
			'template_key'         => sanitize_key( $data['template_key'] ?? '' ),
			'description'          => sanitize_textarea_field( $data['description'] ?? '' ),
			'layout_template'      => $layout_template,
			'style_preset'         => $style_preset,
			'header_title'         => sanitize_text_field( $data['header_title'] ?? '' ),
			'header_description'   => sanitize_textarea_field( $data['header_description'] ?? '' ),
			'footer_note'          => sanitize_textarea_field( $data['footer_note'] ?? '' ),
			'privacy_text'         => sanitize_textarea_field( $data['privacy_text'] ?? '' ),
			'success_message'      => sanitize_textarea_field( $data['success_message'] ?? '' ),
			'submit_button_text'   => sanitize_text_field( $data['submit_button_text'] ?? __( 'Send', 'submission-inbox' ) ),
			'notification_email'   => sanitize_email( $data['notification_email'] ?? '' ),
			'notification_subject' => sanitize_text_field( $data['notification_subject'] ?? '' ),
			'notification_enabled' => array_key_exists( 'notification_enabled', $data ) && empty( $data['notification_enabled'] ) ? 0 : 1,
			'save_leads'           => array_key_exists( 'save_leads', $data ) && empty( $data['save_leads'] ) ? 0 : 1,
			'settings_json'        => $this->sanitize_settings_json( $data['settings_json'] ?? array() ),
		);
	}

	/**
	 * Sanitize settings JSON input.
	 *
	 * @param mixed $settings Settings.
	 * @return string|null
	 */
	private function sanitize_settings_json( $settings ) {
		if ( is_string( $settings ) ) {
			$decoded = json_decode( $settings, true );
			$settings = is_array( $decoded ) ? $decoded : array();
		}

		if ( ! is_array( $settings ) || empty( $settings ) ) {
			return null;
		}

		$sanitized = $this->sanitize_nested_value( $settings );

		$encoded = wp_json_encode( $sanitized );

		return is_string( $encoded ) ? $encoded : null;
	}

	/**
	 * Sanitize nested values for JSON storage.
	 *
	 * @param mixed $value Value.
	 * @return mixed
	 */
	private function sanitize_nested_value( $value ) {
		if ( is_array( $value ) ) {
			$clean = array();
			foreach ( $value as $key => $item ) {
				$clean_key = sanitize_key( (string) $key );
				if ( 'advanced_html' === $clean_key && is_array( $item ) ) {
					$clean[ $clean_key ] = $this->sanitize_advanced_html_settings( $item );
					continue;
				}
				if ( 'design_blocks' === $clean_key && is_array( $item ) ) {
					$clean[ $clean_key ] = $this->sanitize_design_blocks( $item );
					continue;
				}
				$clean[ $clean_key ] = $this->sanitize_nested_value( $item );
			}

			return $clean;
		}

		if ( is_bool( $value ) ) {
			return $value;
		}

		if ( is_numeric( $value ) ) {
			return 0 + $value;
		}

		return sanitize_text_field( (string) $value );
	}

	/**
	 * Sanitize Advanced HTML settings while preserving allowed markup.
	 *
	 * @param array<string,mixed> $settings Settings.
	 * @return array<string,string>
	 */
	private function sanitize_advanced_html_settings( array $settings ) {
		return array(
			'html_before_form'    => $this->sanitize_custom_html( $settings['html_before_form'] ?? '' ),
			'html_after_form'     => $this->sanitize_custom_html( $settings['html_after_form'] ?? '' ),
			'html_before_submit'  => $this->sanitize_custom_html( $settings['html_before_submit'] ?? '' ),
			'html_after_submit'   => $this->sanitize_custom_html( $settings['html_after_submit'] ?? '' ),
			'custom_wrapper_class' => $this->sanitize_class_list( $settings['custom_wrapper_class'] ?? '' ),
			'custom_form_class'    => $this->sanitize_class_list( $settings['custom_form_class'] ?? '' ),
			'custom_css'           => $this->sanitize_custom_css_storage( $settings['custom_css'] ?? '' ),
		);
	}

	/**
	 * Sanitize Design Studio blocks while preserving safe HTML.
	 *
	 * @param array<int,array<string,mixed>> $blocks Blocks.
	 * @return array<int,array<string,mixed>>
	 */
	private function sanitize_design_blocks( array $blocks ) {
		$clean = array();
		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}
			$type = $this->sanitize_choice( $block['type'] ?? '', array( 'text', 'html', 'notice', 'divider', 'privacy' ), 'text' );
			$style_allowed = 'divider' === $type ? array( 'line', 'space' ) : ( 'notice' === $type ? array( 'neutral', 'info', 'success', 'warning' ) : array( 'neutral' ) );
			$clean[] = array(
				'id'           => sanitize_key( $block['id'] ?? '' ),
				'type'         => $type,
				'title'        => sanitize_text_field( $block['title'] ?? '' ),
				'content'      => 'html' === $type ? $this->sanitize_custom_html( $block['content'] ?? '' ) : sanitize_textarea_field( $block['content'] ?? '' ),
				'position'     => $this->sanitize_choice( $block['position'] ?? '', array( 'before_form', 'after_header', 'before_fields', 'after_fields', 'before_submit', 'after_submit', 'before_footer', 'after_form' ), 'before_form' ),
				'style'        => $this->sanitize_choice( $block['style'] ?? '', $style_allowed, 'divider' === $type ? 'line' : 'neutral' ),
				'custom_class' => 'html' === $type ? $this->sanitize_class_list( $block['custom_class'] ?? '' ) : '',
				'sort_order'   => absint( $block['sort_order'] ?? 0 ),
				'enabled'      => ! array_key_exists( 'enabled', $block ) || (bool) $block['enabled'],
			);
		}

		return $clean;
	}

	/**
	 * Sanitize stored Custom CSS without adding new selectors.
	 *
	 * Full form-scope validation happens before admin save and again before
	 * rendering because repository sanitization does not know the form ID.
	 *
	 * @param mixed $css CSS.
	 * @return string
	 */
	private function sanitize_custom_css_storage( $css ) {
		$css = trim( sanitize_textarea_field( (string) $css ) );
		if ( '' === $css ) {
			return '';
		}

		if ( preg_match( '/(<|>|@|url\s*\(|expression\s*\(|javascript\s*:|data\s*:|behavior\s*:|-moz-binding|<\/?style|<\/?script)/i', $css ) ) {
			return '';
		}

		return $css;
	}

	/**
	 * Sanitize a custom HTML block.
	 *
	 * @param mixed $html HTML.
	 * @return string
	 */
	private function sanitize_custom_html( $html ) {
		return wp_kses( (string) $html, $this->custom_html_allowed_tags(), array( 'http', 'https', 'mailto', 'tel' ) );
	}

	/**
	 * Sanitize a space-separated class list.
	 *
	 * @param mixed $classes Classes.
	 * @return string
	 */
	private function sanitize_class_list( $classes ) {
		$clean = array();
		foreach ( preg_split( '/\s+/', (string) $classes ) as $class ) {
			$class = sanitize_html_class( $class );
			if ( '' !== $class ) {
				$clean[] = $class;
			}
		}

		return implode( ' ', array_unique( $clean ) );
	}

	/**
	 * Sanitize an allowlisted choice.
	 *
	 * @param mixed             $value Value.
	 * @param array<int,string> $allowed Allowed values.
	 * @param string            $fallback Fallback.
	 * @return string
	 */
	private function sanitize_choice( $value, array $allowed, $fallback ) {
		$value = sanitize_key( (string) $value );
		return in_array( $value, $allowed, true ) ? $value : $fallback;
	}

	/**
	 * Get allowed tags for custom HTML blocks.
	 *
	 * @return array<string,array<string,bool>>
	 */
	private function custom_html_allowed_tags() {
		$global = array(
			'class'      => true,
			'aria-label' => true,
		);

		return array(
			'p'      => $global,
			'br'     => array(),
			'strong' => $global,
			'em'     => $global,
			'b'      => $global,
			'i'      => $global,
			'small'  => $global,
			'ul'     => $global,
			'ol'     => $global,
			'li'     => $global,
			'span'   => $global,
			'div'    => $global,
			'a'      => array(
				'href'       => true,
				'title'      => true,
				'target'     => true,
				'rel'        => true,
				'class'      => true,
				'aria-label' => true,
			),
			'hr'     => $global,
		);
	}

	/**
	 * Check whether a slug exists.
	 *
	 * @param string $table Table name.
	 * @param string $slug Slug.
	 * @param int    $exclude_id Form id to exclude.
	 * @return bool
	 */
	private function slug_exists( $table, $slug, $exclude_id ) {
		global $wpdb;

		if ( $exclude_id > 0 ) {
			$count = $wpdb->get_var(
				$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE slug = %s AND id != %d", $slug, $exclude_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			);
		} else {
			$count = $wpdb->get_var(
				$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE slug = %s", $slug ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			);
		}

		return absint( $count ) > 0;
	}

	/**
	 * Build WHERE clause.
	 *
	 * @param array<string,mixed> $args Query args.
	 * @return array{sql:string,params:array<int,mixed>}
	 */
	private function build_where( array $args ) {
		global $wpdb;

		$where  = array();
		$params = array();

		if ( ! empty( $args['status'] ) ) {
			$where[]  = 'status = %s';
			$params[] = $this->sanitize_status( $args['status'] );
		}

		if ( ! empty( $args['exclude_status'] ) ) {
			$where[]  = 'status != %s';
			$params[] = $this->sanitize_status( $args['exclude_status'] );
		}

		if ( ! empty( $args['template_key'] ) ) {
			$where[]  = 'template_key = %s';
			$params[] = sanitize_key( $args['template_key'] );
		}

		if ( ! empty( $args['search'] ) ) {
			$search   = '%' . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . '%';
			$where[]  = '(title LIKE %s OR slug LIKE %s OR description LIKE %s)';
			$params[] = $search;
			$params[] = $search;
			$params[] = $search;
		}

		return array(
			'sql'    => $where ? 'WHERE ' . implode( ' AND ', $where ) : '',
			'params' => $params,
		);
	}
}
