<?php
/**
 * CPT Builder admin controller.
 *
 * Routes the builder screens and processes every state-changing request
 * (save, delete, duplicate, toggle, import, export) with nonce + capability
 * verification before touching storage.
 *
 * @package CptBuilder
 * @since 1.0.0
 */

namespace CptBuilder;

use WP_PowerSuite\Core\NetworkScope;

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

/**
 * Admin UI and request handlers.
 */
final class Admin {

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

	private Storage $storage;

	private Validator $validator;

	private ImportExport $import_export;

	private PhpExport $php_export;

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

	/**
	 * Capability required to manage this module's screens.
	 */
	private function capability(): string {
		return NetworkScope::adminCapability();
	}

	/**
	 * Whether the current user may manage the builder.
	 */
	private function canManage(): bool {
		return NetworkScope::canManagePlugin();
	}

	/**
	 * URL to a builder 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 builder 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' ) );
		}

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

		// 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::defaultDefinition();
			$errors     = $this->resolveErrors( $form );
			$this->includeView( 'edit', compact( '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->includeView( 'edit', compact( 'definition', 'is_edit', 'notice', 'errors' ) );
			return;
		}

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

	/**
	 * Include a view file with extracted variables.
	 *
	 * @param string               $view View basename (without extension).
	 * @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;
		}

		// Expose the controller so views can reach render helpers and URLs.
		$admin = $this;
		extract( $vars, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract -- controlled, known keys.
		include $file;
	}

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

	/**
	 * Handle create/update submissions (admin_post_wpps_cptb_save).
	 */
	public function handleSave(): void {
		$this->assertRequest( 'wpps_cptb_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 ) {
			// The key is immutable while editing.
			$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->storage->flagFlush();

		$notice_text = $is_edit
			? __( 'Post type updated.', 'wp-powersuite' )
			: __( 'Post type created.', 'wp-powersuite' );

		if (
			! $is_edit
			&& ! empty( $_POST['create_default_field_group'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in assertRequest.
			&& class_exists( '\CustomFields\CptBridge' )
			&& \CustomFields\CptBridge::isCustomFieldsEnabled()
		) {
			$singular = (string) ( $definition['labels']['singular_name'] ?? $definition['key'] );
			$group_key = \CustomFields\CptBridge::createDefaultFieldGroup( (string) $definition['key'], $singular );
			if ( $group_key !== null ) {
				$notice_text .= ' ' . sprintf(
					/* translators: %s: field group key. */
					__( 'A default field group (%s) was created automatically.', 'wp-powersuite' ),
					$group_key
				);
			}
		}

		$this->setNotice( 'success', $notice_text );

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

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

		if ( $key !== '' && $this->storage->delete( $key ) ) {
			$this->detachFromTaxonomies( [ $key ] );
			$this->storage->flagFlush();
			$this->setNotice( 'success', __( 'Post type deleted.', 'wp-powersuite' ) );
		} else {
			$this->setNotice( 'error', __( 'That post type could not be found.', 'wp-powersuite' ) );
		}

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

	/**
	 * Remove deleted post type keys from all stored taxonomy attachments so
	 * taxonomies do not keep referencing post types that no longer exist.
	 *
	 * @param array<int, string> $keys Deleted post type keys.
	 */
	private function detachFromTaxonomies( array $keys ): void {
		$tax_storage = new TaxonomyStorage();

		foreach ( $tax_storage->all() as $tax_key => $definition ) {
			if ( ! is_array( $definition ) || ! isset( $definition['object_types'] ) || ! is_array( $definition['object_types'] ) ) {
				continue;
			}

			$object_types = array_values(
				array_filter(
					array_map( 'strval', $definition['object_types'] ),
					static fn( string $object_type ): bool => ! in_array( $object_type, $keys, true )
				)
			);

			if ( $object_types !== $definition['object_types'] ) {
				$definition['object_types'] = $object_types;
				$definition['updated_at']   = time();
				$tax_storage->save( (string) $tax_key, $definition );
			}
		}
	}

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

		$source = $key !== '' ? $this->storage->get( $key ) : null;
		if ( ! is_array( $source ) ) {
			$this->setNotice( 'error', __( 'That post type 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['created_at'] = time();
		$copy['updated_at'] = time();

		if ( isset( $copy['labels']['name'] ) ) {
			$copy['labels']['name'] = $copy['labels']['name'] . ' ' . __( '(Copy)', 'wp-powersuite' );
		}

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

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

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

		$definition = $key !== '' ? $this->storage->get( $key ) : null;
		if ( ! is_array( $definition ) ) {
			$this->setNotice( 'error', __( 'That post type 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->storage->flagFlush();

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

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

	/**
	 * Handle PHP code export downloads (admin_post_wpps_cptb_export_php).
	 */
	public function handleExportPhp(): void {
		$key = isset( $_REQUEST['key'] ) ? $this->validator->sanitizeKey( wp_unslash( (string) $_REQUEST['key'] ) ) : '';
		$this->assertRequest( 'wpps_cptb_export_php' );

		$export_key = $key !== '' && $this->storage->exists( $key ) ? $key : null;
		$php        = $this->php_export->exportPhp( $export_key );
		$filename   = 'cpt-builder-' . ( $export_key ?? 'all' ) . '-' . gmdate( 'Ymd' ) . '.php';

		nocache_headers();
		header( 'Content-Type: text/plain; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $filename ) . '"' );
		header( 'Content-Length: ' . strlen( $php ) );

		echo $php; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- PHP file download.
		exit;
	}

	/**
	 * Handle JSON export downloads (admin_post_wpps_cptb_export).
	 */
	public function handleExport(): void {
		$key = isset( $_REQUEST['key'] ) ? $this->validator->sanitizeKey( wp_unslash( (string) $_REQUEST['key'] ) ) : '';
		$this->assertRequest( 'wpps_cptb_export' );

		$export_key = $key !== '' && $this->storage->exists( $key ) ? $key : null;
		$payload    = $this->import_export->export( $export_key );

		// The "export everything" payload doubles as a full builder backup,
		// so include taxonomies alongside post types.
		if ( $export_key === null ) {
			$payload['taxonomies'] = array_values( ( new TaxonomyStorage() )->all() );
		}

		$json     = (string) wp_json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
		$filename = 'cpt-builder-' . ( $export_key ?? 'all' ) . '-' . gmdate( 'Ymd' ) . '.json';

		nocache_headers();
		header( 'Content-Type: application/json; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $filename ) . '"' );
		header( 'Content-Length: ' . strlen( $json ) );

		echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON file download.
		exit;
	}

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

		$json               = isset( $_POST['import_json'] ) ? trim( (string) wp_unslash( $_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'];
			}
		}

		$json    = ImportExport::normalizeJson( $json );
		$decoded = json_decode( $json, true, ImportExport::MAX_JSON_DEPTH );
		$has_pt  = is_array( $decoded ) && isset( $decoded['post_types'] ) && is_array( $decoded['post_types'] );
		$has_tax = is_array( $decoded ) && isset( $decoded['taxonomies'] ) && is_array( $decoded['taxonomies'] );

		// taxonomies section; bare lists and single objects go through it too.
		$result = [
			'imported' => 0,
			'skipped'  => [],
			'errors'   => [],
		];
		if ( $has_pt || ! $has_tax ) {
			$result = $this->import_export->import( $json, $overwrite, $import_as_disabled );
		}

		// Combined exports also carry taxonomies; import them in one go.
		$tax_result = [
			'imported' => 0,
			'skipped'  => [],
			'errors'   => [],
		];
		if ( $has_tax ) {
			$tax_storage       = new TaxonomyStorage();
			$tax_import_export = new TaxonomyImportExport( $tax_storage, new TaxonomyValidator( $tax_storage ) );
			$tax_result        = $tax_import_export->import( $json, $overwrite, $import_as_disabled );
		}

		$total_imported = $result['imported'] + $tax_result['imported'];
		if ( $total_imported > 0 ) {
			$this->storage->flagFlush();
		}

		$messages = [];
		if ( $result['imported'] > 0 ) {
			$messages[] = sprintf(
				/* translators: %d: number of imported post types. */
				_n( 'Imported %d post type.', 'Imported %d post types.', $result['imported'], 'wp-powersuite' ),
				$result['imported']
			);
		}
		if ( $tax_result['imported'] > 0 ) {
			$messages[] = sprintf(
				/* translators: %d: number of imported taxonomies. */
				_n( 'Imported %d taxonomy.', 'Imported %d taxonomies.', $tax_result['imported'], 'wp-powersuite' ),
				$tax_result['imported']
			);
		}

		$skipped = array_merge( $result['skipped'], $tax_result['skipped'] );
		if ( $skipped !== [] ) {
			$messages[] = sprintf(
				/* translators: %s: comma-separated list of keys. */
				__( 'Skipped existing: %s.', 'wp-powersuite' ),
				implode( ', ', array_map( 'sanitize_key', $skipped ) )
			);
		}
		foreach ( array_merge( $result['errors'], $tax_result['errors'] ) as $error ) {
			$messages[] = $error;
		}

		$type = $total_imported > 0 ? 'success' : 'error';
		if ( $messages === [] ) {
			$messages[] = __( 'Nothing was imported.', 'wp-powersuite' );
		}

		$this->setNotice( $type, implode( ' ', $messages ) );
		$this->redirect( $this->pageUrl() );
	}

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

	/**
	 * Verify nonce and capability for a state-changing request.
	 *
	 * @param string $nonce_action Nonce action name.
	 */
	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.
		$labels     = isset( $_POST['labels'] ) && is_array( $_POST['labels'] ) ? wp_unslash( $_POST['labels'] ) : [];
		$args       = isset( $_POST['args'] ) && is_array( $_POST['args'] ) ? wp_unslash( $_POST['args'] ) : [];
		$supports   = isset( $_POST['supports'] ) && is_array( $_POST['supports'] ) ? wp_unslash( $_POST['supports'] ) : [];
		$taxonomies = isset( $_POST['taxonomies'] ) && is_array( $_POST['taxonomies'] ) ? wp_unslash( $_POST['taxonomies'] ) : [];
		$key        = isset( $_POST['key'] ) ? wp_unslash( (string) $_POST['key'] ) : '';
		$enabled    = ! empty( $_POST['enabled'] );
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		return [
			'key'        => $key,
			'enabled'    => $enabled,
			'labels'     => is_array( $labels ) ? $labels : [],
			'args'       => is_array( $args ) ? $args : [],
			'supports'   => is_array( $supports ) ? $supports : [],
			'taxonomies' => is_array( $taxonomies ) ? $taxonomies : [],
		];
	}

	/**
	 * 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 ) && ! post_type_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 ) && ! post_type_exists( $candidate ) ) {
				return $candidate;
			}
		}

		return '';
	}

	/**
	 * Safe redirect helper that always terminates.
	 *
	 * @param string $url Destination URL.
	 */
	private function redirect( string $url ): void {
		wp_safe_redirect( $url );
		exit;
	}

	/**
	 * Store a one-time admin notice for the current user.
	 *
	 * @param string $type success|error|warning|info.
	 * @param string $text Notice text.
	 */
	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
		);
	}

	/**
	 * Retrieve and clear the stored notice.
	 *
	 * @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_cptb_notice_' . get_current_user_id();
	}

	/**
	 * Persist a rejected submission so the form can be repopulated.
	 *
	 * @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
		);
	}

	/**
	 * Retrieve and clear any stashed form state.
	 *
	 * @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_cptb_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, or a reserved slug). Please review the values and try again.', 'wp-powersuite' );
		}

		return $errors;
	}

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

	/**
	 * Normalized list rows for the admin 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 bulk actions submitted from the list table form.
	 */
	public function maybeProcessBulkAction(): void {
		if ( ! isset( $_POST['post_type'] ) || ! is_array( $_POST['post_type'] ) ) { // 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-post_types' );

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

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

		$changed = 0;
		$deleted = [];
		foreach ( $keys as $key ) {
			if ( $action === 'delete' ) {
				if ( $this->storage->delete( $key ) ) {
					$deleted[] = $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 ( $deleted !== [] ) {
			$this->detachFromTaxonomies( $deleted );
		}

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

		$status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( (string) $_POST['status'] ) ) : 'all'; // phpcs:ignore WordPress.Security.NonceVerification.Missing
		$args   = in_array( $status, [ 'enabled', 'disabled' ], true ) ? [ 'status' => $status ] : [];
		$this->redirect( $this->pageUrl( $args ) );
	}

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

	/**
	 * Nonced admin-post action URL for views.
	 *
	 * @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 );
	}

	/**
	 * admin-post.php endpoint for form actions.
	 */
	public function postEndpoint(): string {
		return admin_url( 'admin-post.php' );
	}
}
