<?php
/**
 * Custom Fields admin controller.
 *
 * Routes the field-group screens and processes every state-changing request
 * (save, delete, duplicate, toggle) with nonce + capability verification.
 *
 * @package CustomFields
 * @since 1.0.0
 */

namespace CustomFields;

use WP_PowerSuite\Core\NetworkScope;

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

/**
 * Admin UI and request handlers for field groups.
 */
final class Admin {

	/**
	 * Admin page slug (under the PowerSuite top-level menu).
	 */
	public const PAGE_SLUG = 'powersuite-custom-fields';

	private Storage $storage;

	private Validator $validator;

	private ?ImportExport $import_export = null;

	/** @var array<string, string>|null Request cache for taxonomy term location choices. */
	private ?array $taxonomy_term_choices_cache = null;

	public function __construct( Storage $storage, Validator $validator ) {
		$this->storage   = $storage;
		$this->validator = $validator;
	}

	private function importExport(): ImportExport {
		if ( $this->import_export === null ) {
			$this->import_export = new ImportExport( $this->storage, $this->validator );
		}

		return $this->import_export;
	}

	private function canManage(): bool {
		return NetworkScope::canManagePlugin();
	}

	private function renderLicenseRequiredNotice( string $message ): void {
		$license_page_url = admin_url( 'admin.php?page=powersuite-license' );
		?>
		<div class="wrap wpps-cf">
			<div class="notice notice-warning">
				<p>
					<?php echo esc_html( $message ); ?>
					<a href="<?php echo esc_url( $license_page_url ); ?>"><?php esc_html_e( 'Activate license', 'wp-powersuite' ); ?></a>
				</p>
			</div>
		</div>
		<?php
	}

	/**
	 * URL to a field-group screen.
	 *
	 * @param array<string, mixed> $args Extra query args.
	 */
	private function pageUrl( array $args = [] ): string {
		return NetworkScope::adminUrl( 'admin.php', array_merge( [ 'page' => self::PAGE_SLUG ], $args ) );
	}

	/* --------------------------------------------------------------------- */
	/* Rendering                                                             */
	/* --------------------------------------------------------------------- */

	/**
	 * Render the admin page, routing on the `action` query arg.
	 */
	public function renderPage(): void {
		if ( ! $this->canManage() ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wp-powersuite' ) );
		}

		if ( ! function_exists( 'wp_powersuite_is_license_valid' ) || ! wp_powersuite_is_license_valid() ) {
			$this->renderLicenseRequiredNotice(
				__( 'Custom Fields requires an active license. Activate your license to create field groups and attach fields to posts and taxonomy terms.', 'wp-powersuite' )
			);
			return;
		}

		$action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( (string) $_GET['action'] ) ) : '';
		$key    = isset( $_GET['key'] ) ? $this->validator->sanitizeKey( wp_unslash( (string) $_GET['key'] ) ) : '';

		// Process bulk actions before pulling the notice so their feedback
		// is visible on this request, not the next one.
		$this->maybeProcessBulkAction();

		$notice = $this->pullNotice();
		$form   = $this->pullFormState();

		if ( $action === 'add' ) {
			$is_edit    = false;
			$definition = is_array( $form['definition'] ?? null ) ? $form['definition'] : Config::defaultGroup();
			if ( ! is_array( $form['definition'] ?? null ) ) {
				$definition = CptBridge::applyPrefill( $definition );
			}
			$errors     = $this->resolveErrors( $form );
			$this->renderEdit( $definition, $is_edit, $notice, $errors );
			return;
		}

		if ( $action === 'edit' && $key !== '' && $this->storage->exists( $key ) ) {
			$is_edit    = true;
			$definition = is_array( $form['definition'] ?? null ) ? $form['definition'] : $this->storage->get( $key );
			$errors     = $this->resolveErrors( $form );
			$this->renderEdit( $definition, $is_edit, $notice, $errors );
			return;
		}

		$definitions = $this->storage->all();
		$this->includeView( 'list', compact( 'definitions', 'notice' ) );
	}

	/**
	 * Normalized list rows for the list table.
	 *
	 * @return array<int, array{key: string, definition: array<string, mixed>}>
	 */
	public function getDefinitionsForList(): array {
		$items = [];
		foreach ( $this->storage->all() as $key => $definition ) {
			if ( ! is_string( $key ) || ! is_array( $definition ) ) {
				continue;
			}
			$items[] = [
				'key'        => $key,
				'definition' => $definition,
			];
		}

		return $items;
	}

	/**
	 * Process list table bulk actions (enable / disable / delete).
	 */
	public function maybeProcessBulkAction(): void {
		if ( ! isset( $_POST['field_group'] ) || ! is_array( $_POST['field_group'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			return;
		}

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

		$action = '';
		if ( isset( $_POST['action'] ) && $_POST['action'] !== '-1' ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			$action = sanitize_key( wp_unslash( (string) $_POST['action'] ) );
		} elseif ( isset( $_POST['action2'] ) && $_POST['action2'] !== '-1' ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			$action = sanitize_key( wp_unslash( (string) $_POST['action2'] ) );
		}

		if ( ! in_array( $action, [ 'enable', 'disable', 'delete' ], true ) ) {
			return;
		}

		check_admin_referer( 'bulk-field_groups' );

		$keys = array_map(
			fn( $key ) => $this->validator->sanitizeKey( wp_unslash( (string) $key ) ),
			wp_unslash( $_POST['field_group'] )
		);
		$keys = array_values( array_filter( $keys ) );

		if ( $keys === [] ) {
			return;
		}

		$changed = 0;
		foreach ( $keys as $key ) {
			if ( $action === 'delete' ) {
				if ( $this->storage->delete( $key ) ) {
					++$changed;
				}
				continue;
			}

			$definition = $this->storage->get( $key );
			if ( ! is_array( $definition ) ) {
				continue;
			}

			$definition['enabled']    = $action === 'enable';
			$definition['updated_at'] = time();
			$this->storage->save( $key, $definition );
			++$changed;
		}

		if ( $changed > 0 ) {
			$messages = [
				'enable'  => _n( '%d field group enabled.', '%d field groups enabled.', $changed, 'wp-powersuite' ),
				'disable' => _n( '%d field group disabled.', '%d field groups disabled.', $changed, 'wp-powersuite' ),
				'delete'  => _n( '%d field group deleted.', '%d field groups deleted.', $changed, 'wp-powersuite' ),
			];
			$this->setNotice( 'success', sprintf( $messages[ $action ], $changed ) );
		}
	}

	/**
	 * @param array<string, mixed>                     $definition Group definition.
	 * @param bool                                     $is_edit    Whether editing.
	 * @param array{type: string, text: string}|null   $notice     One-time notice.
	 * @param array<int, string>                       $errors     Validation errors.
	 */
	private function renderEdit( array $definition, bool $is_edit, ?array $notice, array $errors ): void {
		$post_types         = $this->availablePostTypes();
		$taxonomies         = $this->availableTaxonomies();
		$page_templates     = $this->availablePageTemplates();
		$field_targets      = $this->availableFieldTargets();
		$rule_value_choices = $this->ruleValueChoices();
		$field_key_opts     = $this->fieldKeyOptions( isset( $definition['fields'] ) && is_array( $definition['fields'] ) ? $definition['fields'] : [] );
		$this->includeView( 'edit', compact( 'definition', 'is_edit', 'notice', 'errors', 'post_types', 'taxonomies', 'page_templates', 'field_targets', 'field_key_opts', 'rule_value_choices' ) );
	}

	/**
	 * Include a view file with extracted variables.
	 *
	 * @param string               $view View basename.
	 * @param array<string, mixed> $vars Variables exposed to the view.
	 */
	private function includeView( string $view, array $vars ): void {
		$file = __DIR__ . '/../views/' . $view . '.php';
		if ( ! is_readable( $file ) ) {
			return;
		}

		$admin = $this;
		extract( $vars, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract -- controlled, known keys.
		include $file;
	}

	/**
	 * Public post types available as a location target.
	 *
	 * @return array<string, string>
	 */
	public function availablePostTypes(): array {
		$types = get_post_types( [ 'show_ui' => true ], 'objects' );

		// Internal/utility types that field groups should never target.
		// Builder/utility plugins often register these as public, so they
		// are excluded by name as well as by the heuristics below.
		$excluded = [
			'attachment',
			'wp_block',
			'wp_navigation',
			'wp_template',
			'wp_template_part',
			'wp_font_family',
			'wp_font_face',
			'elementor_library',
			'e-floating-buttons',
			'e-landing-page',
			'elementor_snippet',
			'elementor_font',
			'elementor_icons',
			'elementor-hf',
			'cartflows_step',
			'cartflows_flow',
			'sureforms_form',
		];

		// CPT Builder definitions are always offered, even if not public.
		$builder_types = class_exists( '\\CptBuilder\\Storage' ) ? ( new \CptBuilder\Storage() )->all() : [];

		$out = [];
		foreach ( $types as $type ) {
			if ( in_array( $type->name, $excluded, true ) ) {
				continue;
			}

			if ( ! isset( $builder_types[ $type->name ] ) ) {
				// Only offer real content types. Utility types from page
				// builders and form plugins register as public but opt out
				// of nav menus, which makes a reliable second signal.
				if ( ! $type->public || ! $type->show_in_nav_menus ) {
					continue;
				}
			}

			$out[ $type->name ] = isset( $type->labels->singular_name ) ? (string) $type->labels->singular_name : $type->name;
		}

		return $out;
	}

	/**
	 * Taxonomies available as a location target.
	 *
	 * @return array<string, string>
	 */
	public function availableTaxonomies(): array {
		$taxes = get_taxonomies( [ 'show_ui' => true ], 'objects' );

		// Internal/utility taxonomies that field groups should never target.
		$excluded = [ 'post_format', 'nav_menu', 'link_category', 'wp_theme', 'wp_template_part_area' ];

		// CPT Builder taxonomies are always offered, even if not public.
		$builder_taxes = class_exists( '\\CptBuilder\\TaxonomyStorage' ) ? ( new \CptBuilder\TaxonomyStorage() )->all() : [];

		$out = [];
		foreach ( $taxes as $tax ) {
			if ( in_array( $tax->name, $excluded, true ) ) {
				continue;
			}
			if ( ! $tax->public && ! isset( $builder_taxes[ $tax->name ] ) ) {
				continue;
			}
			$out[ $tax->name ] = isset( $tax->labels->singular_name ) ? (string) $tax->labels->singular_name : $tax->name;
		}

		return $out;
	}

	/**
	 * Known value choices per location-rule parameter.
	 *
	 * Parameters without an entry (post_id, post_parent, user_id) use a free
	 * text input in the rule builder.
	 *
	 * @return array<string, array<string, string>>
	 */
	public function ruleValueChoices(): array {
		$statuses = [];
		foreach ( get_post_stati( [ 'show_in_admin_status_list' => true ], 'objects' ) as $status ) {
			$statuses[ (string) $status->name ] = (string) $status->label;
		}

		$roles = [];
		foreach ( wp_roles()->get_names() as $role_key => $role_label ) {
			$roles[ (string) $role_key ] = translate_user_role( (string) $role_label );
		}

		$formats = [
			'standard' => _x( 'Standard', 'Post format', 'wp-powersuite' ),
			'aside'    => _x( 'Aside', 'Post format', 'wp-powersuite' ),
			'gallery'  => _x( 'Gallery', 'Post format', 'wp-powersuite' ),
			'link'     => _x( 'Link', 'Post format', 'wp-powersuite' ),
			'image'    => _x( 'Image', 'Post format', 'wp-powersuite' ),
			'quote'    => _x( 'Quote', 'Post format', 'wp-powersuite' ),
			'status'   => _x( 'Status', 'Post format', 'wp-powersuite' ),
			'video'    => _x( 'Video', 'Post format', 'wp-powersuite' ),
			'audio'    => _x( 'Audio', 'Post format', 'wp-powersuite' ),
			'chat'     => _x( 'Chat', 'Post format', 'wp-powersuite' ),
		];

		$taxonomy_terms = $this->taxonomyTermChoices();

		return [
			'post_type'     => $this->availablePostTypes(),
			'taxonomy'      => $this->availableTaxonomies(),
			'page_template' => $this->availablePageTemplates(),
			'post_status'   => $statuses,
			'post_format'   => $formats,
			'post_term'     => $taxonomy_terms,
			'post_taxonomy' => $taxonomy_terms,
			'user_role'     => $roles,
		];
	}

	/**
	 * Term choices for post_term / post_taxonomy location rules.
	 *
	 * @return array<string, string>
	 */
	private function taxonomyTermChoices(): array {
		if ( $this->taxonomy_term_choices_cache !== null ) {
			return $this->taxonomy_term_choices_cache;
		}

		$out = [];

		foreach ( array_keys( $this->availableTaxonomies() ) as $taxonomy ) {
			$tax_object = get_taxonomy( $taxonomy );
			$tax_label  = $tax_object instanceof \WP_Taxonomy && isset( $tax_object->labels->singular_name )
				? (string) $tax_object->labels->singular_name
				: (string) $taxonomy;

			$terms = get_terms(
				[
					'taxonomy'   => $taxonomy,
					'hide_empty' => false,
					'number'     => 50,
					'orderby'    => 'name',
					'fields'     => 'all',
				]
			);

			if ( ! is_array( $terms ) ) {
				continue;
			}

			foreach ( $terms as $term ) {
				if ( ! $term instanceof \WP_Term ) {
					continue;
				}
				$out[ (string) $term->term_id ] = $tax_label . ': ' . $term->name;
			}
		}

		$this->taxonomy_term_choices_cache = $out;

		return $out;
	}

	/**
	 * All field targets for clone/bidirectional pickers.
	 *
	 * @return array<string, string> "group|field" => label
	 */
	public function availableFieldTargets(): array {
		$out = [];
		foreach ( $this->storage->all() as $group_key => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			foreach ( (array) ( $group['fields'] ?? [] ) as $field ) {
				if ( ! is_array( $field ) ) {
					continue;
				}
				$type = (string) ( $field['type'] ?? '' );
				if ( ! in_array( $type, Config::relationalTypes(), true ) && $type !== 'clone' ) {
					continue;
				}
				$fkey = (string) ( $field['key'] ?? '' );
				if ( $fkey === '' ) {
					continue;
				}
				$out[ $group_key . '|' . $fkey ] = (string) ( $group['title'] ?? $group_key ) . ' → ' . (string) ( $field['label'] ?? $fkey );
			}
		}

		return $out;
	}

	/**
	 * Field keys in a group (for conditional logic).
	 *
	 * @param array<int, array<string, mixed>> $fields Field list.
	 * @return array<string, string>
	 */
	public function fieldKeyOptions( array $fields ): array {
		$out = [];
		foreach ( $fields as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			$key = (string) ( $field['key'] ?? '' );
			if ( $key === '' || (string) ( $field['type'] ?? '' ) === 'message' ) {
				continue;
			}
			$out[ $key ] = (string) ( $field['label'] ?? $key );
		}

		return $out;
	}

	/**
	 * Page templates available for location rules.
	 *
	 * @return array<string, string>
	 */
	public function availablePageTemplates(): array {
		$templates = wp_get_theme()->get_page_templates( null, 'page' );
		$out       = [
			'default' => __( 'Default template', 'wp-powersuite' ),
		];
		foreach ( $templates as $file => $label ) {
			$out[ (string) $file ] = (string) $label;
		}

		return $out;
	}

	/* --------------------------------------------------------------------- */
	/* Handlers                                                              */
	/* --------------------------------------------------------------------- */

	/**
	 * Handle create/update submissions.
	 */
	public function handleSave(): void {
		$this->assertRequest( 'wpps_cf_save' );

		$original_key = isset( $_POST['original_key'] )
			? $this->validator->sanitizeKey( wp_unslash( (string) $_POST['original_key'] ) )
			: '';
		$is_edit  = $original_key !== '' && $this->storage->exists( $original_key );
		$existing = $is_edit ? $this->storage->get( $original_key ) : null;

		$input      = $this->collectDefinitionInput();
		$definition = $this->validator->buildDefinition( $input, $existing );

		if ( $is_edit ) {
			$definition['key'] = $original_key;
		}

		$errors = $this->validator->validateDefinition( $definition, $is_edit, $is_edit ? $original_key : null );

		if ( $errors !== [] ) {
			$this->stashFormState( $definition, $errors );
			// The invalid flag guarantees visible feedback even when the
			// stashed form state is unavailable (e.g. dropped transients).
			$redirect = $is_edit
				? $this->pageUrl(
					[
						'action'  => 'edit',
						'key'     => $original_key,
						'invalid' => '1',
					]
				)
				: $this->pageUrl(
					[
						'action'  => 'add',
						'invalid' => '1',
					]
				);
			$this->redirect( $redirect );
		}

		$this->storage->save( $definition['key'], $definition );

		$this->setNotice(
			'success',
			$is_edit ? __( 'Field group updated.', 'wp-powersuite' ) : __( 'Field group created.', 'wp-powersuite' )
		);

		// Stay on the edit screen so the user can keep working.
		$this->redirect( $this->pageUrl( [ 'action' => 'edit', 'key' => (string) $definition['key'] ] ) );
	}

	/**
	 * Handle deletions.
	 */
	public function handleDelete(): void {
		$key = isset( $_REQUEST['key'] ) ? $this->validator->sanitizeKey( wp_unslash( (string) $_REQUEST['key'] ) ) : '';
		$this->assertRequest( 'wpps_cf_delete_' . $key );

		if ( $key !== '' && $this->storage->delete( $key ) ) {
			$this->setNotice( 'success', __( 'Field group deleted.', 'wp-powersuite' ) );
		} else {
			$this->setNotice( 'error', __( 'That field group could not be found.', 'wp-powersuite' ) );
		}

		$this->redirect( $this->pageUrl() );
	}

	/**
	 * Handle duplication.
	 */
	public function handleDuplicate(): void {
		$key = isset( $_REQUEST['key'] ) ? $this->validator->sanitizeKey( wp_unslash( (string) $_REQUEST['key'] ) ) : '';
		$this->assertRequest( 'wpps_cf_duplicate_' . $key );

		$source = $key !== '' ? $this->storage->get( $key ) : null;
		if ( ! is_array( $source ) ) {
			$this->setNotice( 'error', __( 'That field group could not be found.', 'wp-powersuite' ) );
			$this->redirect( $this->pageUrl() );
		}

		$new_key = $this->uniqueKey( $key );
		if ( $new_key === '' ) {
			$this->setNotice( 'error', __( 'Could not generate a unique key for the duplicate.', 'wp-powersuite' ) );
			$this->redirect( $this->pageUrl() );
		}

		$copy               = $source;
		$copy['key']        = $new_key;
		$copy['enabled']    = false;
		$copy['title']      = (string) ( $source['title'] ?? '' ) . ' ' . __( '(Copy)', 'wp-powersuite' );
		$copy['created_at'] = time();
		$copy['updated_at'] = time();

		$this->storage->save( $new_key, $copy );

		$this->setNotice( 'success', __( 'Field group duplicated. The copy is disabled until you enable it.', 'wp-powersuite' ) );
		$this->redirect( $this->pageUrl( [ 'action' => 'edit', 'key' => $new_key ] ) );
	}

	/**
	 * Handle JSON export download.
	 */
	public function handleExport(): void {
		$this->assertRequest( 'wpps_cf_export' );

		$key = isset( $_REQUEST['key'] ) ? $this->validator->sanitizeKey( wp_unslash( (string) $_REQUEST['key'] ) ) : '';
		$json = $this->importExport()->exportJson( $key !== '' && $this->storage->exists( $key ) ? $key : null );

		$filename = $key !== '' ? 'field-group-' . $key . '.json' : 'field-groups-export.json';
		$filename = sanitize_file_name( $filename );

		header( 'Content-Type: application/json; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
		header( 'Content-Length: ' . strlen( $json ) );
		header( 'X-Content-Type-Options: nosniff' );
		echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON download.
		exit;
	}

	/**
	 * Handle JSON import.
	 */
	public function handleImport(): void {
		$this->assertRequest( 'wpps_cf_import' );

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in handler.
		$json = isset( $_POST['import_json'] ) ? wp_unslash( (string) $_POST['import_json'] ) : '';
		$overwrite = ! empty( $_POST['overwrite'] );
		$import_as_disabled = ! empty( $_POST['import_disabled'] );

		// An uploaded .json file takes precedence over the pasted text.
		if ( isset( $_FILES['import_file'] ) && is_array( $_FILES['import_file'] ) ) {
			$upload = ImportExport::readUploadedFile( $_FILES['import_file'] );
			if ( $upload['error'] !== '' ) {
				$this->setNotice( 'error', $upload['error'] );
				$this->redirect( $this->pageUrl() );
			}
			if ( $upload['json'] !== '' ) {
				$json = $upload['json'];
			}
		}

		$result = $this->importExport()->import( $json, $overwrite, $import_as_disabled );

		$messages = [];
		if ( $result['imported'] > 0 ) {
			$messages[] = sprintf(
				/* translators: %d: number of field groups. */
				_n( '%d field group imported.', '%d field groups imported.', $result['imported'], 'wp-powersuite' ),
				$result['imported']
			);
		}
		if ( $result['skipped'] !== [] ) {
			$messages[] = sprintf(
				/* translators: %s: comma-separated keys. */
				__( 'Skipped existing groups: %s', 'wp-powersuite' ),
				implode( ', ', $result['skipped'] )
			);
		}
		if ( $result['errors'] !== [] ) {
			foreach ( $result['errors'] as $error ) {
				$messages[] = $error;
			}
		}

		if ( $result['imported'] > 0 && $result['errors'] === [] ) {
			$this->setNotice( 'success', implode( ' ', $messages ) );
		} elseif ( $result['imported'] > 0 ) {
			$this->setNotice( 'warning', implode( ' ', $messages ) );
		} else {
			$this->setNotice( 'error', $messages !== [] ? implode( ' ', $messages ) : __( 'Nothing was imported.', 'wp-powersuite' ) );
		}

		$this->redirect( $this->pageUrl() );
	}

	/**
	 * Handle enable/disable toggling.
	 */
	public function handleToggle(): void {
		$key = isset( $_REQUEST['key'] ) ? $this->validator->sanitizeKey( wp_unslash( (string) $_REQUEST['key'] ) ) : '';
		$this->assertRequest( 'wpps_cf_toggle_' . $key );

		$definition = $key !== '' ? $this->storage->get( $key ) : null;
		if ( ! is_array( $definition ) ) {
			$this->setNotice( 'error', __( 'That field group could not be found.', 'wp-powersuite' ) );
			$this->redirect( $this->pageUrl() );
		}

		$definition['enabled']    = empty( $definition['enabled'] );
		$definition['updated_at'] = time();
		$this->storage->save( $key, $definition );

		$this->setNotice(
			'success',
			$definition['enabled'] ? __( 'Field group enabled.', 'wp-powersuite' ) : __( 'Field group disabled.', 'wp-powersuite' )
		);

		$this->redirect( $this->pageUrl() );
	}

	/* --------------------------------------------------------------------- */
	/* Helpers                                                               */
	/* --------------------------------------------------------------------- */

	private function assertRequest( string $nonce_action ): void {
		if ( ! $this->canManage() ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to perform this action.', 'wp-powersuite' ) );
		}

		check_admin_referer( $nonce_action );
	}

	/**
	 * Collect raw definition input from $_POST (unslashed).
	 *
	 * @return array<string, mixed>
	 */
	private function collectDefinitionInput(): array {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified in handler.
		$key      = isset( $_POST['key'] ) ? wp_unslash( (string) $_POST['key'] ) : '';
		$title    = isset( $_POST['title'] ) ? wp_unslash( (string) $_POST['title'] ) : '';
		$enabled     = ! empty( $_POST['enabled'] );
		$position    = isset( $_POST['position'] ) ? wp_unslash( (string) $_POST['position'] ) : 'normal';
		$priority    = isset( $_POST['priority'] ) ? wp_unslash( (string) $_POST['priority'] ) : 'default';
		$style       = isset( $_POST['style'] ) ? wp_unslash( (string) $_POST['style'] ) : 'default';
		$location       = isset( $_POST['location'] ) && is_array( $_POST['location'] ) ? wp_unslash( $_POST['location'] ) : [];
		$location_rules = isset( $_POST['location_rules'] ) && is_array( $_POST['location_rules'] ) ? wp_unslash( $_POST['location_rules'] ) : [];
		$fields         = isset( $_POST['fields'] ) && is_array( $_POST['fields'] ) ? wp_unslash( $_POST['fields'] ) : [];
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		return [
			'key'            => $key,
			'title'          => $title,
			'enabled'        => $enabled,
			'position'       => $position,
			'priority'       => $priority,
			'style'          => $style,
			'location'       => is_array( $location ) ? $location : [],
			'location_rules' => is_array( $location_rules ) ? $location_rules : [],
			'fields'         => is_array( $fields ) ? array_values( $fields ) : [],
		];
	}

	/**
	 * Generate a unique storage key based on a source key.
	 *
	 * @param string $base Source key.
	 */
	private function uniqueKey( string $base ): string {
		$base   = substr( $base, 0, Config::MAX_KEY_LENGTH - 2 );
		$prefix = substr( $base . '_c', 0, Config::MAX_KEY_LENGTH );

		if ( ! $this->storage->exists( $prefix ) ) {
			return $prefix;
		}

		for ( $i = 2; $i <= 99; $i++ ) {
			$candidate = substr( $base, 0, Config::MAX_KEY_LENGTH - strlen( (string) $i ) - 1 ) . '_' . $i;
			if ( ! $this->storage->exists( $candidate ) ) {
				return $candidate;
			}
		}

		return '';
	}

	private function redirect( string $url ): void {
		wp_safe_redirect( $url );
		exit;
	}

	private function setNotice( string $type, string $text ): void {
		set_transient(
			$this->noticeKey(),
			[
				'type' => in_array( $type, [ 'success', 'error', 'warning', 'info' ], true ) ? $type : 'info',
				'text' => $text,
			],
			60
		);
	}

	/**
	 * @return array{type: string, text: string}|null
	 */
	private function pullNotice(): ?array {
		$notice = get_transient( $this->noticeKey() );
		if ( ! is_array( $notice ) || empty( $notice['text'] ) ) {
			return null;
		}

		delete_transient( $this->noticeKey() );

		return [
			'type' => (string) ( $notice['type'] ?? 'info' ),
			'text' => (string) $notice['text'],
		];
	}

	private function noticeKey(): string {
		return 'wpps_cf_notice_' . get_current_user_id();
	}

	/**
	 * @param array<string, mixed> $definition Attempted definition.
	 * @param array<int, string>   $errors     Validation errors.
	 */
	private function stashFormState( array $definition, array $errors ): void {
		set_transient(
			$this->formKey(),
			[
				'definition' => $definition,
				'errors'     => $errors,
			],
			120
		);
	}

	/**
	 * @return array<string, mixed>
	 */
	private function pullFormState(): array {
		$state = get_transient( $this->formKey() );
		if ( ! is_array( $state ) ) {
			return [];
		}

		delete_transient( $this->formKey() );

		return $state;
	}

	private function formKey(): string {
		return 'wpps_cf_form_' . get_current_user_id();
	}

	/**
	 * Resolve validation errors for the edit screen.
	 *
	 * Prefers the stashed form state; falls back to a generic message when the
	 * redirect carries the invalid flag but the stash is unavailable.
	 *
	 * @param array<string, mixed> $form Stashed form state.
	 * @return array<int, string>
	 */
	private function resolveErrors( array $form ): array {
		$errors = is_array( $form['errors'] ?? null ) ? $form['errors'] : [];

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only flag.
		if ( $errors === [] && isset( $_GET['invalid'] ) ) {
			$errors[] = __( 'The submitted settings were invalid (for example a reserved or duplicate key). Please review the values and try again.', 'wp-powersuite' );
		}

		return $errors;
	}

	/* --------------------------------------------------------------------- */
	/* View helpers (used by templates)                                      */
	/* --------------------------------------------------------------------- */

	/**
	 * @param array<string, mixed> $args Query args.
	 */
	public function url( array $args = [] ): string {
		return $this->pageUrl( $args );
	}

	/**
	 * @param string               $action       admin-post action suffix.
	 * @param string               $nonce_action Nonce action name.
	 * @param array<string, mixed> $args         Extra query args.
	 */
	public function actionUrl( string $action, string $nonce_action, array $args = [] ): string {
		$url = add_query_arg(
			array_merge( [ 'action' => $action ], $args ),
			admin_url( 'admin-post.php' )
		);

		return wp_nonce_url( $url, $nonce_action );
	}

	public function postEndpoint(): string {
		return admin_url( 'admin-post.php' );
	}
}
