<?php
/**
 * Shared value formatting for admin dashboard read models.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Repository;

use ScheduledChanges\Capabilities\CapabilityManager;
use ScheduledChanges\Domain\Launch\LaunchHealthProjector;
use ScheduledChanges\Domain\Shared\NamedTimezone;
use ScheduledChanges\Extension\PublicHooks;
use ScheduledChanges\Support\Json;
use ScheduledChanges\Support\ValueRedactor;

/**
 * Converts dashboard storage rows into client-facing labels and summaries.
 */
final class AdminDashboardValueFormatter {
	/**
	 * Decodes stored display text for client-facing admin surfaces.
	 *
	 * @param mixed $value
	 */
	public function displayText( $value ): string {
		$text = (string) $value;

		for ( $i = 0; $i < 3; ++$i ) {
			$decoded = wp_specialchars_decode( $text, ENT_QUOTES );

			if ( $decoded === $text ) {
				break;
			}

			$text = $decoded;
		}

		return $text;
	}

	/**
	 * Returns a readable user label.
	 */
	public function ownerName( int $user_id ): string {
		if ( $user_id <= 0 ) {
			return __( 'Unknown owner', 'jjs-post-scheduler' );
		}

		$user = get_userdata( $user_id );

		if ( ! $user instanceof \WP_User ) {
			return __( 'Unknown owner', 'jjs-post-scheduler' );
		}

		return '' !== $user->display_name ? $user->display_name : $user->user_login;
	}

	/**
	 * Formats one launch database row.
	 *
	 * @param array<string,mixed> $row Database row.
	 * @return array<string,mixed>
	 */
	public function launchRow( array $row ): array {
		$validation        = '' === (string) ( $row['validation_summary_json'] ?? '' )
			? array()
			: $this->decodedObject( $row['validation_summary_json'] );
		$validation_state  = (bool) ( $validation['valid'] ?? false ) ? 'valid' : 'not_validated';
		$projection        = LaunchHealthProjector::project(
			array(
				'launch_status'             => (string) $row['status'],
				'version_status'            => (string) ( $row['version_status'] ?? '' ),
				'validation_state'          => $validation_state,
				'validation_blocking_count' => (int) ( $validation['blocking_error_count'] ?? 0 ),
				'validation_diagnostics'    => $this->validationDiagnostics( $validation ),
				'last_run_status'           => (string) ( $row['last_run_status'] ?? '' ),
				'current_version_id'        => (int) ( $row['current_version_id'] ?? 0 ),
			)
		);
		$available_actions = $projection->availableActions();
		$action_reasons    = array();
		$target_count      = (int) ( $row['target_count'] ?? 0 );
		$has_targets       = (int) ( $row['current_version_id'] ?? 0 ) > 0 && $target_count > 0;
		$can_schedule      = $has_targets && true === (bool) ( $row['can_schedule_targets'] ?? true );
		$can_execute       = $has_targets && true === (bool) ( $row['can_execute_targets'] ?? true );
		$can_rollback      = $has_targets && true === (bool) ( $row['can_rollback_targets'] ?? true );

		if ( ! $has_targets ) {
			if ( array() !== $available_actions ) {
				$action_reasons['all'] = __( 'This scheduled change has no affected items to update.', 'jjs-post-scheduler' );
			}
			$available_actions = array();
		} else {
			if ( ! $can_schedule ) {
				$schedule_actions = array(
					LaunchHealthProjector::ACTION_SCHEDULE,
					LaunchHealthProjector::ACTION_CANCEL,
					LaunchHealthProjector::ACTION_RESCHEDULE,
				);
				if ( array() !== array_intersect( $available_actions, $schedule_actions ) ) {
					$action_reasons['schedule'] = __( 'You do not have permission to edit one or more affected items.', 'jjs-post-scheduler' );
				}
				$available_actions = array_values(
					array_diff(
						$available_actions,
						$schedule_actions
					)
				);
			}

			if ( ! $can_execute ) {
				$execute_actions = array(
					LaunchHealthProjector::ACTION_RUN_NOW,
					LaunchHealthProjector::ACTION_RETRY,
				);
				if ( array() !== array_intersect( $available_actions, $execute_actions ) ) {
					$action_reasons['execute'] = __( 'You do not have permission to run changes for one or more affected items.', 'jjs-post-scheduler' );
				}
				$available_actions = array_values(
					array_diff(
						$available_actions,
						$execute_actions
					)
				);
			}

			if ( ! $can_rollback ) {
				if ( in_array( LaunchHealthProjector::ACTION_ROLLBACK, $available_actions, true ) ) {
					$action_reasons['rollback'] = __( 'You do not have permission to restore one or more affected items.', 'jjs-post-scheduler' );
				}
				$available_actions = array_values(
					array_diff(
						$available_actions,
						array( LaunchHealthProjector::ACTION_ROLLBACK )
					)
				);
			}
		}

		$projection_data                     = $projection->toArray();
		$projection_data['availableActions'] = $available_actions;

		return array_merge(
			array(
				'id'              => (int) $row['id'],
				'uuid'            => (string) $row['uuid'],
				'name'            => $this->displayText( $row['name'] ?? '' ),
				'status'          => (string) $row['status'],
				'versionId'       => (int) ( $row['current_version_id'] ?? 0 ),
				'versionStatus'   => (string) ( $row['version_status'] ?? '' ),
				'targetCount'     => $target_count,
				'startAt'         => (string) ( $row['start_at_utc'] ?? '' ),
				'startDate'       => $this->datePart( $row['start_at_utc'] ?? null, (string) $row['timezone'] ),
				'startTime'       => $this->timePart( $row['start_at_utc'] ?? null, (string) $row['timezone'] ),
				'endAt'           => (string) ( $row['end_at_utc'] ?? '' ),
				'endDate'         => $this->datePart( $row['end_at_utc'] ?? null, (string) $row['timezone'] ),
				'endTime'         => $this->timePart( $row['end_at_utc'] ?? null, (string) $row['timezone'] ),
				'completedAt'     => (string) ( $row['completed_at_utc'] ?? '' ),
				'completedDate'   => $this->datePart( $row['completed_at_utc'] ?? null, (string) $row['timezone'] ),
				'completedTime'   => $this->timePart( $row['completed_at_utc'] ?? null, (string) $row['timezone'] ),
				'timezone'        => (string) $row['timezone'],
				'validationState' => $validation_state,
				'lastRunStatus'   => (string) ( $row['last_run_status'] ?? '' ),
				'owner'           => (string) ( $row['owner_name'] ?? '' ),
				'creationSource'  => (string) $row['creation_source'],
				'updatedAt'       => (string) $row['updated_at_utc'],
			),
			$projection_data,
			array(
				'queued'                => in_array( LaunchHealthProjector::ACTION_CANCEL, $available_actions, true ),
				'canCancel'             => in_array( LaunchHealthProjector::ACTION_CANCEL, $available_actions, true ),
				'canReschedule'         => in_array( LaunchHealthProjector::ACTION_RESCHEDULE, $available_actions, true ),
				'actionDisabledReasons' => $action_reasons,
			)
		);
	}

	/**
	 * Returns public-safe validation diagnostics for launch health.
	 *
	 * @param array<string,mixed> $validation Validation summary.
	 * @return array<int,array<string,string>>
	 */
	private function validationDiagnostics( array $validation ): array {
		$diagnostics = array();

		foreach ( is_array( $validation['issues'] ?? null ) ? $validation['issues'] : array() as $issue ) {
			if ( ! is_array( $issue ) || 'blocking_error' !== (string) ( $issue['level'] ?? '' ) ) {
				continue;
			}

			$diagnostics[] = array(
				'code'    => (string) ( $issue['code'] ?? 'validation.blocked' ),
				'level'   => 'blocking',
				'message' => (string) ( $issue['message'] ?? 'Launch validation is blocked.' ),
			);
		}

		return $diagnostics;
	}

	/**
	 * Formats one run database row.
	 *
	 * @param array<string,mixed> $row Database row.
	 * @return array<string,mixed>
	 */
	public function runRow( array $row ): array {
		$result = '' === (string) ( $row['result_json'] ?? '' )
			? array()
			: $this->decodedObject( $row['result_json'] );

		return array(
			'id'                => (int) $row['id'],
			'uuid'              => (string) $row['uuid'],
			'launchId'          => (int) $row['launch_id'],
			'launchUuid'        => (string) ( $row['launch_uuid'] ?? '' ),
			'launchName'        => $this->displayText( $row['launch_name'] ?? __( 'Deleted launch', 'jjs-post-scheduler' ) ),
			'versionId'         => (int) $row['launch_version_id'],
			'status'            => (string) $row['status'],
			'triggerSource'     => (string) $row['trigger_source'],
			'attemptNumber'     => (int) $row['attempt_number'],
			'scheduledAt'       => (string) ( $row['scheduled_at_utc'] ?? '' ),
			'startedAt'         => (string) ( $row['started_at_utc'] ?? '' ),
			'completedAt'       => (string) ( $row['completed_at_utc'] ?? '' ),
			'warningCount'      => (int) $row['warning_count'],
			'errorCount'        => (int) $row['error_count'],
			'successfulOps'     => (int) ( $result['successful_operations'] ?? 0 ),
			'failedOps'         => (int) ( $result['failed_operations'] ?? 0 ),
			'skippedOps'        => (int) ( $result['skipped_operations'] ?? 0 ),
			'summary'           => $this->runSummary( (string) $row['status'], $result ),
			'rollbackAvailable' => current_user_can( CapabilityManager::ROLLBACK_SCHEDULES )
				&& in_array( (string) $row['status'], array( 'success', 'successful' ), true )
				&& 'manual_rollback' !== (string) $row['trigger_source']
				&& null === ( $row['rollback_run_id'] ?? null )
			&& (int) ( $row['snapshot_count'] ?? 0 ) > 0,
		);
	}

	/**
	 * Returns a plain-language summary of one run.
	 *
	 * @param array<string,mixed> $result Run result payload.
	 */
	private function runSummary( string $status, array $result ): string {
		$successful = (int) ( $result['successful_operations'] ?? 0 );
		$failed     = (int) ( $result['failed_operations'] ?? 0 );
		$skipped    = (int) ( $result['skipped_operations'] ?? 0 );

		if ( 'cancelled' === $status ) {
			return __( 'This change was cancelled before it changed the site.', 'jjs-post-scheduler' );
		}

		if ( 'running' === $status ) {
			return __( 'This change is running.', 'jjs-post-scheduler' );
		}

		if ( $failed > 0 && $successful > 0 ) {
			$completed_text = sprintf(
				/* translators: %d: completed change count. */
				_n( '%d change completed', '%d changes completed', $successful, 'jjs-post-scheduler' ),
				$successful
			);
			$review_text = sprintf(
				/* translators: %d: failed change count. */
				_n( '%d change needs review', '%d changes need review', $failed, 'jjs-post-scheduler' ),
				$failed
			);

			return sprintf(
				/* translators: 1: completed changes summary, 2: failed changes summary. */
				__( '%1$s. %2$s.', 'jjs-post-scheduler' ),
				$completed_text,
				$review_text
			);
		}

		if ( $failed > 0 ) {
			return __( 'No changes completed. Review the issue before retrying.', 'jjs-post-scheduler' );
		}

		if ( $skipped > 0 ) {
			return sprintf(
				/* translators: %d: skipped change count. */
				_n( '%d change was skipped.', '%d changes were skipped.', $skipped, 'jjs-post-scheduler' ),
				$skipped
			);
		}

		if ( $successful > 0 ) {
			return sprintf(
				/* translators: %d: completed change count. */
				_n( '%d change completed.', '%d changes completed.', $successful, 'jjs-post-scheduler' ),
				$successful
			);
		}

		return __( 'No run details are available yet.', 'jjs-post-scheduler' );
	}

	/**
	 * Builds a provider-neutral target row and lets modules supply labels/URLs.
	 *
	 * @param array<string,mixed> $row Stored target row.
	 * @return array<string,mixed>
	 */
	public function targetRow( array $row, string $context, bool $apply_provider_hooks = true ): array {
		$target_reference = (string) ( $row['target_reference'] ?? $row['source_reference'] ?? $row['workspace_reference'] ?? '' );
		$id               = (int) ( $row['target_id'] ?? $this->referenceId( $target_reference ) );
		$target_type      = (string) ( $row['target_type'] ?? $row['source_type'] ?? $row['workspace_type'] ?? '' );
		$provider_id      = (string) ( $row['provider_id'] ?? $row['source_provider_id'] ?? $row['workspace_provider_id'] ?? '' );
		$fallback_name    = (string) ( $row['fallback_name'] ?? '' );
		$name             = '' !== $fallback_name ? $fallback_name : $this->targetDisplayName( $id, $target_type, $target_reference );

		$target = array(
			'id'              => $id,
			'reference'       => $target_reference,
			'targetReference' => $target_reference,
			'targetType'      => $target_type,
			'subtype'         => (string) ( $row['subtype'] ?? '' ),
			'name'            => $this->displayText( $name ),
			'thumbnail'       => '',
			'editUrl'         => '',
			'viewUrl'         => '',
			'providerId'      => $provider_id,
			'providerLabel'   => '' === $provider_id ? __( 'Unregistered provider', 'jjs-post-scheduler' ) : $provider_id,
			'typeLabel'       => $this->targetTypeLabel( $target_type ),
			'icon'            => 'box',
			'canEdit'         => false,
			'parentReference' => (string) ( $row['parent_reference'] ?? '' ),
		);

		return $apply_provider_hooks ? PublicHooks::adminTargetRow( $target, $row, $context ) : $target;
	}

	/**
	 * Resolves a numeric object ID from canonical identity.
	 */
	public function referenceId( string $reference, int $fallback = 0 ): int {
		return 1 === preg_match( '/:([1-9][0-9]*)$/', $reference, $matches )
			? (int) $matches[1]
			: $fallback;
	}

	/**
	 * Returns a friendly item name for dashboard rows.
	 */
	private function targetDisplayName( int $id, string $target_type, string $target_reference ): string {
		if (
			$id > 0
			&& function_exists( 'get_the_title' )
			&& in_array( $target_type, array( 'post', 'page', 'product', 'variation' ), true )
		) {
			$title = \get_the_title( $id );

			if ( is_string( $title ) && '' !== trim( $title ) ) {
				return trim( $title );
			}
		}

		return '' === $target_reference ? __( 'Item', 'jjs-post-scheduler' ) : $target_reference;
	}

	/**
	 * Returns a friendly item type label.
	 */
	private function targetTypeLabel( string $target_type ): string {
		$labels = array(
			'post'      => __( 'Post', 'jjs-post-scheduler' ),
			'page'      => __( 'Page', 'jjs-post-scheduler' ),
			'product'   => __( 'Product', 'jjs-post-scheduler' ),
			'variation' => __( 'Variation', 'jjs-post-scheduler' ),
		);

		if ( isset( $labels[ $target_type ] ) ) {
			return $labels[ $target_type ];
		}

		return '' === $target_type ? __( 'Item', 'jjs-post-scheduler' ) : ucwords( str_replace( array( '-', '_' ), ' ', $target_type ) );
	}

	/**
	 * Returns a local date part for modal defaults.
	 *
	 * @param mixed  $value UTC storage datetime.
	 * @param string $timezone Timezone name.
	 */
	public function datePart( $value, string $timezone ): string {
		return $this->localDateTimePart( $value, $timezone, 'Y-m-d' );
	}

	/**
	 * Returns a local time part for modal defaults.
	 *
	 * @param mixed  $value UTC storage datetime.
	 * @param string $timezone Timezone name.
	 */
	public function timePart( $value, string $timezone ): string {
		return $this->localDateTimePart( $value, $timezone, 'H:i' );
	}

	/**
	 * Converts a UTC storage datetime to a formatted local part.
	 *
	 * @param mixed  $value UTC storage datetime.
	 * @param string $timezone Timezone name.
	 * @param string $format Date format.
	 */
	private function localDateTimePart( $value, string $timezone, string $format ): string {
		if ( null === $value || '' === (string) $value ) {
			return '';
		}

		if ( ! NamedTimezone::isValid( $timezone ) ) {
			$timezone = 'UTC';
		}

		$date = \DateTimeImmutable::createFromFormat(
			'!Y-m-d H:i:s',
			(string) $value,
			new \DateTimeZone( 'UTC' )
		);

		if ( false === $date ) {
			return '';
		}

		return $date->setTimezone( new \DateTimeZone( $timezone ) )->format( $format );
	}

	/**
	 * Returns display-ready operation rows.
	 *
	 * @param array<int,array<string,mixed>> $rows Operation database rows.
	 * @return array<int,array<string,mixed>>
	 */
	public function operationRows( array $rows ): array {
		$pending_terms = $this->pendingTermsForOperations( $rows );

		return array_map(
			function ( array $row ) use ( $pending_terms ): array {
				$field     = (string) ( $row['field_path'] ?? '' );
				$type      = (string) ( $row['operation_type'] ?? '' );
				$schema    = $this->decodedValue( $row['schema_json'] ?? null );
				$sensitive = $this->operationIsSensitive( $row );

				return array(
					'id'              => (string) ( $row['operation_uuid'] ?? '' ),
					'type'            => $type,
					'field'           => $field,
					'targetReference' => (string) ( $row['target_reference'] ?? '' ),
					'label'           => $this->operationLabel( $field, $type, $row['after_json'] ?? null, is_array( $schema ) ? $schema : array() ),
					'group'           => $this->operationGroup( $field, (string) ( $row['provider_id'] ?? '' ), $type, is_array( $schema ) ? $schema : array() ),
					'providerId'      => (string) ( $row['provider_id'] ?? '' ),
					'valueType'       => (string) ( $row['value_type'] ?? 'text' ),
					'before'          => $this->displayOperationValue( $row['before_json'] ?? null, $field, $type, $pending_terms, $sensitive ),
					'after'           => $this->displayOperationValue( $row['after_json'] ?? null, $field, $type, $pending_terms, $sensitive ),
					'summary'         => $this->operationSummary( $row, $pending_terms ),
					'status'          => (string) ( $row['last_operation_status'] ?? 'pending' ),
					'reversibility'   => (string) ( $row['reversibility'] ?? '' ),
					'sensitive'       => $sensitive,
				);
			},
			$rows
		);
	}

	/**
	 * Formats one restore-point value for admin display.
	 *
	 * @param mixed $value Saved restore-point value.
	 */
	public function restorePreviewValue( $value, string $field, string $type, bool $sensitive ): string {
		return $this->displayOperationValue( Json::encode( $value ), $field, $type, array(), $sensitive );
	}

	/**
	 * Formats one recording session database row.
	 *
	 * @param array<string,mixed> $row Database row.
	 * @param callable            $target_row Target row formatter.
	 * @param callable            $reference_id Reference ID resolver.
	 * @param callable            $owner_name Owner name formatter.
	 * @param callable            $display_text Text-decoding callback.
	 * @return array<string,mixed>
	 */
	public function recordingRow(
		array $row,
		callable $target_row,
		callable $reference_id,
		callable $owner_name,
		callable $display_text
	): array {
		$diff             = '' === (string) ( $row['diff_json'] ?? '' )
			? array()
			: $this->decodedObject( $row['diff_json'] );
		$selected         = '' === (string) ( $row['selected_changes_json'] ?? '' )
			? array()
			: $this->decodedObject( $row['selected_changes_json'] );
		$changes          = is_array( $diff['changes'] ?? null ) ? $diff['changes'] : array();
		$diagnostics      = is_array( $selected['diagnostics'] ?? null )
			? $selected['diagnostics']
			: ( is_array( $diff['diagnostics'] ?? null ) ? $diff['diagnostics'] : array() );
		$source_target    = $target_row(
			array(
				'target_id'        => $reference_id( (string) ( $row['source_reference'] ?? '' ), (int) ( $row['source_id'] ?? 0 ) ),
				'target_reference' => (string) ( $row['source_reference'] ?? '' ),
				'target_type'      => (string) ( $row['source_type'] ?? '' ),
				'provider_id'      => (string) ( $row['source_provider_id'] ?? '' ),
				'fallback_name'    => (string) ( $row['source_title'] ?? '' ),
				'parent_reference' => '',
				'subtype'          => '',
			),
			'recording_source'
		);
		$workspace_target = $target_row(
			array(
				'target_id'        => $reference_id( (string) ( $row['workspace_reference'] ?? '' ), null === ( $row['workspace_id'] ?? null ) ? 0 : (int) $row['workspace_id'] ),
				'target_reference' => (string) ( $row['workspace_reference'] ?? '' ),
				'target_type'      => (string) ( $row['workspace_type'] ?? '' ),
				'provider_id'      => (string) ( $row['workspace_provider_id'] ?? '' ),
				'fallback_name'    => (string) ( $row['workspace_title'] ?? '' ),
				'parent_reference' => '',
				'subtype'          => '',
			),
			'recording_workspace'
		);

		return array(
			'id'                     => (int) $row['id'],
			'uuid'                   => (string) $row['uuid'],
			'sourceId'               => (int) $source_target['id'],
			'sourceProviderId'       => (string) ( $row['source_provider_id'] ?? '' ),
			'sourceProviderLabel'    => (string) ( $source_target['providerLabel'] ?? $row['source_provider_id'] ?? '' ),
			'sourceType'             => (string) ( $row['source_type'] ?? '' ),
			'sourceTypeLabel'        => (string) ( $source_target['typeLabel'] ?? $row['source_type'] ?? '' ),
			'sourceReference'        => (string) ( $row['source_reference'] ?? '' ),
			'sourceTitle'            => (string) $display_text( $source_target['name'] ?? __( 'Missing source target', 'jjs-post-scheduler' ) ),
			'sourceEditUrl'          => (string) ( $source_target['editUrl'] ?? '' ),
			'workspaceId'            => (int) $workspace_target['id'] > 0 ? (int) $workspace_target['id'] : null,
			'workspaceProviderId'    => (string) ( $row['workspace_provider_id'] ?? '' ),
			'workspaceProviderLabel' => (string) ( $workspace_target['providerLabel'] ?? $row['workspace_provider_id'] ?? '' ),
			'workspaceType'          => (string) ( $row['workspace_type'] ?? '' ),
			'workspaceTypeLabel'     => (string) ( $workspace_target['typeLabel'] ?? $row['workspace_type'] ?? '' ),
			'workspaceReference'     => (string) ( $row['workspace_reference'] ?? '' ),
			'workspaceTitle'         => (string) $display_text( $workspace_target['name'] ?? '' ),
			'workspaceEditUrl'       => (string) ( $workspace_target['editUrl'] ?? '' ),
			'owner'                  => (string) $owner_name( (int) ( $row['owner_id'] ?? 0 ) ),
			'status'                 => (string) $row['status'],
			'cleanupStatus'          => (string) $row['cleanup_status'],
			'createdAt'              => (string) $row['created_at_utc'],
			'expiresAt'              => (string) $row['expires_at_utc'],
			'updatedAt'              => (string) $row['updated_at_utc'],
			'changeCount'            => count( $changes ),
			'selectedCount'          => count( is_array( $selected['selected_change_ids'] ?? null ) ? $selected['selected_change_ids'] : array() ),
			'diagnostics'            => $diagnostics,
			'changes'                => array_map(
				fn ( array $change ): array => array(
					'id'         => (string) ( $change['change_id'] ?? '' ),
					'field'      => (string) ( $change['field_label'] ?? $change['field_id'] ?? '' ),
					'fieldId'    => (string) ( $change['field_id'] ?? '' ),
					'group'      => (string) ( $change['group'] ?? '' ),
					'before'     => $this->recordingChangeValue( $change, 'before' ),
					'after'      => $this->recordingChangeValue( $change, 'after' ),
					'selectable' => (bool) ( $change['selectable'] ?? false ),
					'reversible' => (bool) ( $change['reversible'] ?? false ),
					'validation' => (string) ( $change['validation_state'] ?? '' ),
					'sensitive'  => (bool) ( $change['sensitive'] ?? false ),
				),
				array_slice( array_values( array_filter( $changes, 'is_array' ) ), 0, 50 )
			),
		);
	}

	/**
	 * Whether an operation row contains a sensitive field value.
	 *
	 * @param array<string,mixed> $row Operation database row.
	 */
	private function operationIsSensitive( array $row ): bool {
		$redaction = $this->decodedValue( $row['redaction_json'] ?? null );

		if ( ! is_array( $redaction ) || ! array_key_exists( 'sensitive', $redaction ) || ! is_bool( $redaction['sensitive'] ) ) {
			return true;
		}

		return true === $redaction['sensitive'];
	}

	/**
	 * Returns a readable field label.
	 *
	 * @param mixed               $after_json Stored JSON value.
	 * @param array<string,mixed> $schema Field schema.
	 */
	private function operationLabel( string $field, string $type, $after_json = null, array $schema = array() ): string {
		if ( isset( $schema['label'] ) && is_scalar( $schema['label'] ) && '' !== trim( (string) $schema['label'] ) ) {
			return trim( (string) $schema['label'] );
		}

		if ( 'create_taxonomy_term' === $type ) {
			$value = $this->decodedValue( $after_json );

			return sprintf(
				/* translators: %s: singular taxonomy label. */
				__( 'New %s', 'jjs-post-scheduler' ),
				$this->taxonomyLabel( (string) ( is_array( $value ) ? ( $value['taxonomy'] ?? '' ) : '' ), true )
			);
		}

		if ( 'provider_defined' === $type ) {
			$value = $this->decodedValue( $after_json );

			if ( is_array( $value ) && isset( $value['taxonomy'] ) ) {
				return $this->taxonomyLabel( (string) $value['taxonomy'] );
			}
		}

		if ( '' === $field ) {
			return ucwords( str_replace( '_', ' ', $type ) );
		}

		$known = array(
			'media.featured_image_id' => __( 'Featured image', 'jjs-post-scheduler' ),
			'media.gallery_image_ids' => __( 'Gallery images', 'jjs-post-scheduler' ),
			'security.api_token'      => __( 'API token', 'jjs-post-scheduler' ),
			'wordpress.title'         => __( 'Title', 'jjs-post-scheduler' ),
			'wordpress.content'       => __( 'Content', 'jjs-post-scheduler' ),
			'wordpress.excerpt'       => __( 'Excerpt', 'jjs-post-scheduler' ),
			'post.post_title'         => __( 'Title', 'jjs-post-scheduler' ),
			'post.post_content'       => __( 'Content', 'jjs-post-scheduler' ),
			'post.post_excerpt'       => __( 'Excerpt', 'jjs-post-scheduler' ),
			'post.post_status'        => __( 'Status', 'jjs-post-scheduler' ),
			'post.post_name'          => __( 'Slug', 'jjs-post-scheduler' ),
			'post.post_date'          => __( 'Publish date', 'jjs-post-scheduler' ),
			'post._thumbnail_id'      => __( 'Featured image', 'jjs-post-scheduler' ),
		);

		foreach ( $known as $suffix => $label ) {
			if ( substr_compare( $field, $suffix, -strlen( $suffix ) ) === 0 ) {
				return $label;
			}
		}

		$segments = explode( '.', $field );
		$label    = (string) end( $segments );

		return ucwords( str_replace( '_', ' ', $label ) );
	}

	/**
	 * Assigns an operation to a UI group.
	 *
	 * @param array<string,mixed> $schema Field schema.
	 */
	private function operationGroup( string $field, string $provider_id, string $type = '', array $schema = array() ): string {
		$display = is_array( $schema['display'] ?? null ) ? $schema['display'] : array();
		$group   = isset( $display['group_label'] ) && is_scalar( $display['group_label'] )
			? trim( (string) $display['group_label'] )
			: '';

		if ( '' !== $group ) {
			return $group;
		}

		if ( 0 === strpos( $field, 'post.' ) || 0 === strpos( $field, 'content.' ) ) {
			return __( 'Content', 'jjs-post-scheduler' );
		}

		if ( false !== strpos( $field, 'security' ) || false !== strpos( $field, 'token' ) || false !== strpos( $field, 'secret' ) ) {
			return __( 'Security', 'jjs-post-scheduler' );
		}

		if ( '' !== $provider_id && 'wordpress-core' !== $provider_id ) {
			return __( 'Supported fields', 'jjs-post-scheduler' );
		}

		if ( strpos( $field, 'category' ) !== false || strpos( $field, 'tag' ) !== false ) {
			return __( 'Categories and tags', 'jjs-post-scheduler' );
		}

		if ( strpos( $field, 'image' ) !== false || strpos( $field, 'gallery' ) !== false ) {
			return __( 'Images', 'jjs-post-scheduler' );
		}

		return __( 'Item details', 'jjs-post-scheduler' );
	}

	/**
	 * Formats JSON operation values for dashboard display.
	 *
	 * @param mixed                                            $json JSON value from storage.
	 * @param array<string,array{name:string,taxonomy:string}> $pending_terms Pending term definitions.
	 */
	private function displayOperationValue(
		$json,
		string $field,
		string $type,
		array $pending_terms,
		bool $sensitive = false
	): string {
		if ( $sensitive ) {
			return ValueRedactor::REDACTED;
		}

		$value = $this->decodedValue( $json );

		if ( null === $value || '' === $value || array() === $value ) {
			return 'create_taxonomy_term' === $type
				? __( 'Not created', 'jjs-post-scheduler' )
				: __( 'Not set', 'jjs-post-scheduler' );
		}

		if ( 'create_taxonomy_term' === $type && is_array( $value ) ) {
			return (string) ( $value['name'] ?? __( 'New term', 'jjs-post-scheduler' ) );
		}

		if ( 'provider_defined' === $type && is_array( $value ) && isset( $value['taxonomy'] ) ) {
			return $this->taxonomySelectionText( $value, $pending_terms );
		}

		if ( substr_compare( $field, 'media.featured_image_id', -strlen( 'media.featured_image_id' ) ) === 0 ) {
			return $this->attachmentNames( array( $value ) );
		}

		if ( substr_compare( $field, 'media.gallery_image_ids', -strlen( 'media.gallery_image_ids' ) ) === 0 && is_array( $value ) ) {
			return $this->attachmentNames( $value );
		}

		return $this->humanValue( $value );
	}

	/**
	 * Returns pending taxonomy term definitions keyed by their recording token.
	 *
	 * @param array<int,array<string,mixed>> $rows Operation rows.
	 * @return array<string,array{name:string,taxonomy:string}>
	 */
	private function pendingTermsForOperations( array $rows ): array {
		$terms = array();

		foreach ( $rows as $row ) {
			if ( 'create_taxonomy_term' !== (string) ( $row['operation_type'] ?? '' ) ) {
				continue;
			}

			$value = $this->decodedValue( $row['after_json'] ?? null );

			if ( ! is_array( $value ) || '' === (string) ( $value['token'] ?? '' ) ) {
				continue;
			}

			$terms[ (string) $value['token'] ] = array(
				'name'     => (string) ( $value['name'] ?? __( 'New term', 'jjs-post-scheduler' ) ),
				'taxonomy' => (string) ( $value['taxonomy'] ?? '' ),
			);
		}

		return $terms;
	}

	/**
	 * Returns a client-facing operation summary.
	 *
	 * @param array<string,mixed>                              $row Operation row.
	 * @param array<string,array{name:string,taxonomy:string}> $pending_terms Pending term definitions.
	 */
	private function operationSummary( array $row, array $pending_terms ): string {
		$type  = (string) ( $row['operation_type'] ?? '' );
		$field = (string) ( $row['field_path'] ?? '' );

		if ( 'create_taxonomy_term' === $type ) {
			$value = $this->decodedValue( $row['after_json'] ?? null );
			$name  = is_array( $value ) ? (string) ( $value['name'] ?? '' ) : '';

			return sprintf(
				/* translators: 1: taxonomy label, 2: term name. */
				__( 'Create the %1$s "%2$s" when this change goes live.', 'jjs-post-scheduler' ),
				$this->taxonomyLabel( (string) ( is_array( $value ) ? ( $value['taxonomy'] ?? '' ) : '' ), true ),
				$name
			);
		}

		if ( 'provider_defined' === $type ) {
			$value = $this->decodedValue( $row['after_json'] ?? null );

			if ( is_array( $value ) && isset( $value['taxonomy'] ) ) {
				return sprintf(
					/* translators: 1: taxonomy label, 2: selected terms. */
					__( 'Set %1$s to %2$s.', 'jjs-post-scheduler' ),
					$this->taxonomyLabel( (string) $value['taxonomy'] ),
					$this->taxonomySelectionText( $value, $pending_terms )
				);
			}
		}

		return sprintf(
			/* translators: %s: field label. */
			__( 'Update %s.', 'jjs-post-scheduler' ),
			$this->operationLabel( $field, $type, $row['after_json'] ?? null )
		);
	}

	/**
	 * Decodes a stored JSON value without exposing malformed JSON to the UI.
	 *
	 * @param mixed $json
	 * @return mixed
	 */
	private function decodedValue( $json ) {
		if ( null === $json || '' === (string) $json ) {
			return null;
		}

		$value = json_decode( (string) $json, true );

		return JSON_ERROR_NONE === json_last_error() ? $value : sanitize_text_field( (string) $json );
	}

	/**
	 * Decodes optional dashboard display JSON without letting legacy rows break reads.
	 *
	 * @param mixed $json Stored JSON object.
	 * @return array<string,mixed>
	 */
	private function decodedObject( $json ): array {
		try {
			return Json::decodeObject( null === $json ? null : (string) $json );
		} catch ( \Throwable $throwable ) {
			unset( $throwable );

			return array();
		}
	}

	/**
	 * Formats a generic value as concise customer-facing text.
	 *
	 * @param mixed $value
	 */
	private function humanValue( $value ): string {
		if ( null === $value || '' === $value || array() === $value ) {
			return __( 'Not set', 'jjs-post-scheduler' );
		}

		if ( is_bool( $value ) ) {
			return $value ? __( 'Yes', 'jjs-post-scheduler' ) : __( 'No', 'jjs-post-scheduler' );
		}

		if ( is_scalar( $value ) ) {
			$text = is_string( $value ) ? wp_strip_all_tags( $value ) : (string) $value;

			return '' === trim( $text ) ? __( 'Not set', 'jjs-post-scheduler' ) : $text;
		}

		if ( ! is_array( $value ) ) {
			return __( 'Updated', 'jjs-post-scheduler' );
		}

		if ( \ScheduledChanges\Support\PhpCompat::isList( $value ) ) {
			$items = array_values(
				array_filter(
					array_map( fn ( $item ): string => $this->humanValue( $item ), $value ),
					static fn ( string $item ): bool => '' !== $item && __( 'Not set', 'jjs-post-scheduler' ) !== $item
				)
			);

			return array() === $items ? __( 'Not set', 'jjs-post-scheduler' ) : implode( ', ', $items );
		}

		$hidden = array( 'token', 'created_at_utc', 'operation_codes', 'term_references', 'duplicate_replay' );
		$parts  = array();

		foreach ( $value as $key => $item ) {
			if ( in_array( (string) $key, $hidden, true ) ) {
				continue;
			}

			$parts[] = sprintf(
				'%1$s: %2$s',
				ucwords( str_replace( '_', ' ', (string) $key ) ),
				$this->humanValue( $item )
			);
		}

		return array() === $parts ? __( 'Updated', 'jjs-post-scheduler' ) : implode( ' · ', $parts );
	}

	/**
	 * Formats a taxonomy assignment, including terms deferred until execution.
	 *
	 * @param array<string,mixed>                              $value Assignment value.
	 * @param array<string,array{name:string,taxonomy:string}> $pending_terms Pending term definitions.
	 */
	private function taxonomySelectionText( array $value, array $pending_terms ): string {
		$taxonomy = (string) ( $value['taxonomy'] ?? '' );
		$names    = array();

		if ( is_array( $value['term_ids'] ?? null ) ) {
			$resolved = $this->termNamesArray( $taxonomy, $value['term_ids'] );
			$names    = array_merge( $names, $resolved );
		}

		foreach ( is_array( $value['term_references'] ?? null ) ? $value['term_references'] : array() as $reference ) {
			$token = str_replace( 'term:', '', (string) $reference );

			if ( isset( $pending_terms[ $token ]['name'] ) ) {
				$names[] = $pending_terms[ $token ]['name'] . ' (' . __( 'new', 'jjs-post-scheduler' ) . ')';
			}
		}

		$names = array_values( array_unique( array_filter( $names ) ) );

		return array() === $names ? __( 'None', 'jjs-post-scheduler' ) : implode( ', ', $names );
	}

	/**
	 * Returns taxonomy term names as an array.
	 *
	 * @param string           $taxonomy Taxonomy name.
	 * @param array<int,mixed> $ids Term IDs.
	 * @return string[]
	 */
	private function termNamesArray( string $taxonomy, array $ids ): array {
		$names = array();

		foreach ( $ids as $id ) {
			$term = get_term( (int) $id, $taxonomy );

			if ( $term instanceof \WP_Term ) {
				$names[] = $term->name;
			}
		}

		return $names;
	}

	/**
	 * Returns attachment titles for media IDs.
	 *
	 * @param array<int,mixed> $ids Attachment IDs.
	 */
	private function attachmentNames( array $ids ): string {
		$names = array();

		foreach ( $ids as $id ) {
			$title = get_the_title( (int) $id );

			if ( '' !== $title ) {
				$names[] = $title;
			}
		}

		return array() === $names ? __( 'No image', 'jjs-post-scheduler' ) : implode( ', ', $names );
	}

	/**
	 * Returns a readable taxonomy label.
	 */
	private function taxonomyLabel( string $taxonomy, bool $singular = false ): string {
		$object = taxonomy_exists( $taxonomy ) ? get_taxonomy( $taxonomy ) : null;

		if ( $object instanceof \WP_Taxonomy ) {
			return $singular ? $object->labels->singular_name : $object->labels->name;
		}

		$label = ucwords( str_replace( array( 'pa_', '_' ), array( '', ' ' ), $taxonomy ) );

		return '' === $label ? __( 'term', 'jjs-post-scheduler' ) : $label;
	}

	/**
	 * Formats a recording value without exposing sensitive field contents.
	 *
	 * @param array<string,mixed> $change Recording change.
	 * @param string              $key Value key.
	 */
	private function recordingChangeValue( array $change, string $key ): string {
		$field_id         = (string) ( $change['field_id'] ?? '' );
		$downloads_suffix = 'downloads.files';
		$sensitive        = true === ( $change['sensitive'] ?? false )
			|| (
				strlen( $field_id ) >= strlen( $downloads_suffix )
				&& 0 === substr_compare( $field_id, $downloads_suffix, -strlen( $downloads_suffix ) )
			);

		return $sensitive
			? __( '[redacted]', 'jjs-post-scheduler' )
			: Json::encode( $change[ $key ] ?? null );
	}
}
