<?php
/**
 * CPT Builder taxonomy admin controller.
 *
 * Routes the taxonomy builder screens (section=taxonomies) and processes every
 * state-changing request with nonce + capability verification.
 *
 * @package CptBuilder
 * @since 1.1.0
 */

namespace CptBuilder;

use WP_PowerSuite\Core\NetworkScope;

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

/**
 * Taxonomy admin UI and request handlers.
 */
final class TaxonomyAdmin {

	/**
	 * Section identifier used in the builder page query string.
	 */
	public const SECTION = 'taxonomies';

	private TaxonomyStorage $storage;

	private TaxonomyValidator $validator;

	private TaxonomyImportExport $import_export;

	public function __construct( TaxonomyStorage $storage, TaxonomyValidator $validator, TaxonomyImportExport $import_export ) {
		$this->storage       = $storage;
		$this->validator     = $validator;
		$this->import_export = $import_export;
	}

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

	/**
	 * URL to a taxonomy builder screen (keeps the section query arg).
	 *
	 * @param array<string, mixed> $args Extra query args.
	 */
	private function pageUrl( array $args = [] ): string {
		return NetworkScope::adminUrl(
			'admin.php',
			array_merge(
				[
					'page'    => Admin::PAGE_SLUG,
					'section' => self::SECTION,
				],
				$args
			)
		);
	}

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

	/**
	 * Render the taxonomy 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'] ) ) : '';

		$this->maybeProcessBulkAction();

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

		if ( $action === 'add' ) {
			$is_edit       = false;
			$definition    = is_array( $form['definition'] ?? null ) ? $form['definition'] : Config::defaultTaxonomyDefinition();
			$errors        = $this->resolveErrors( $form );
			$post_types    = $this->availablePostTypes();
			$this->includeView( 'taxonomy-edit', compact( 'definition', 'is_edit', 'notice', 'errors', 'post_types' ) );
			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 );
			$post_types    = $this->availablePostTypes();
			$this->includeView( 'taxonomy-edit', compact( 'definition', 'is_edit', 'notice', 'errors', 'post_types' ) );
			return;
		}

		$definitions = $this->storage->all();
		$this->includeView( 'taxonomy-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;
		}

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

	/**
	 * List of public post types available to attach a taxonomy to.
	 *
	 * @return array<string, string> Map of post type key => label.
	 */
	public function availablePostTypes(): array {
		$types = get_post_types( [ 'show_ui' => true ], 'objects' );

		// Internal/utility types that taxonomies should never attach to.
		// 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 = ( new 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;
				}
			}

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

		return $out;
	}

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

	/**
	 * Handle create/update submissions.
	 */
	public function handleSave(): void {
		$this->assertRequest( 'wpps_cptb_tax_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->flagFlush();

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

		if (
			! $is_edit
			&& ! empty( $_POST['create_default_field_group'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
			&& class_exists( '\CustomFields\CptBridge' )
			&& \CustomFields\CptBridge::isCustomFieldsEnabled()
		) {
			$singular  = (string) ( $definition['labels']['singular_name'] ?? $definition['key'] );
			$group_key = \CustomFields\CptBridge::createDefaultTaxonomyFieldGroup( (string) $definition['key'], $singular );
			if ( $group_key !== null ) {
				$notice_text .= ' ' . sprintf(
					/* translators: %s: field group key. */
					__( 'A default term 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.
	 */
	public function handleDelete(): void {
		$key = isset( $_REQUEST['key'] ) ? $this->validator->sanitizeKey( wp_unslash( (string) $_REQUEST['key'] ) ) : '';
		$this->assertRequest( 'wpps_cptb_tax_delete_' . $key );

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

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

	/**
	 * Remove deleted taxonomy keys from CPT definitions' taxonomies lists.
	 *
	 * @param array<int, string> $keys Deleted taxonomy keys.
	 */
	private function detachFromPostTypes( array $keys ): void {
		$cpt_storage = new Storage();

		foreach ( $cpt_storage->all() as $pt_key => $definition ) {
			if ( ! is_array( $definition ) || ! isset( $definition['taxonomies'] ) || ! is_array( $definition['taxonomies'] ) ) {
				continue;
			}

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

			if ( $taxonomies !== $definition['taxonomies'] ) {
				$definition['taxonomies'] = $taxonomies;
				$definition['updated_at'] = time();
				$cpt_storage->save( (string) $pt_key, $definition );
			}
		}
	}

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

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

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

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

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

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

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

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

		$export_key = $key !== '' && $this->storage->exists( $key ) ? $key : null;
		$json       = $this->import_export->exportJson( $export_key );
		$filename   = 'cpt-builder-taxonomies-' . ( $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.
	 */
	public function handleImport(): void {
		$this->assertRequest( 'wpps_cptb_tax_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'] );

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

		// Combined exports also carry post types; import them in one go.
		$pt_result = [
			'imported' => 0,
			'skipped'  => [],
			'errors'   => [],
		];
		if ( $has_pt ) {
			$pt_storage       = new Storage();
			$pt_import_export = new ImportExport( $pt_storage, new Validator( $pt_storage ) );
			$pt_result        = $pt_import_export->import( $json, $overwrite, $import_as_disabled );
		}

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

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

		$skipped = array_merge( $result['skipped'], $pt_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'], $pt_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                                                               */
	/* --------------------------------------------------------------------- */

	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'] ) : [];
		$object_types = isset( $_POST['object_types'] ) && is_array( $_POST['object_types'] ) ? wp_unslash( $_POST['object_types'] ) : [];
		$key          = isset( $_POST['key'] ) ? wp_unslash( (string) $_POST['key'] ) : '';
		$enabled      = ! empty( $_POST['enabled'] );
		// phpcs:enable WordPress.Security.NonceVerification.Missing

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

	/**
	 * 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_TAXONOMY_KEY_LENGTH - 2 );
		$prefix = substr( $base . '_c', 0, Config::MAX_TAXONOMY_KEY_LENGTH );

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

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

		return '';
	}

	private function flagFlush(): void {
		( new Storage() )->flagFlush();
	}

	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_cptb_tax_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_cptb_tax_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['taxonomy'] ) || ! is_array( $_POST['taxonomy'] ) ) { // 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-taxonomies' );

		$keys = array_map(
			fn( $key ) => $this->validator->sanitizeKey( wp_unslash( (string) $key ) ),
			wp_unslash( $_POST['taxonomy'] )
		);
		$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->detachFromPostTypes( $deleted );
		}

		if ( $changed > 0 ) {
			$this->flagFlush();
			$messages = [
				'enable'  => _n( '%d taxonomy enabled.', '%d taxonomies enabled.', $changed, 'wp-powersuite' ),
				'disable' => _n( '%d taxonomy disabled.', '%d taxonomies disabled.', $changed, 'wp-powersuite' ),
				'delete'  => _n( '%d taxonomy deleted.', '%d taxonomies 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 ),
			NetworkScope::adminUrl( 'admin-post.php' )
		);

		return wp_nonce_url( $url, $nonce_action );
	}

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

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

		$export_key = $key !== '' && $this->storage->exists( $key ) ? $key : null;
		$php        = ( new PhpExport( new Storage(), new Registrar( new Storage() ), $this->storage, new TaxonomyRegistrar( $this->storage ) ) )
			->exportTaxonomyPhp( $export_key );
		$filename   = 'cpt-builder-taxonomies-' . ( $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;
	}
}
