<?php
/**
 * WordPress administration application shell.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Admin;

// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals -- scheduled_changes_ is the stable plugin prefix.

use ScheduledChanges\Adapter\AdapterStatusService;
use ScheduledChanges\Capabilities\CapabilityManager;
use ScheduledChanges\Domain\Shared\Uuid;
use ScheduledChanges\Extension\BundledModuleIds;
use ScheduledChanges\Extension\ModuleProfileResolver;
use ScheduledChanges\Extension\PublicHooks;
use ScheduledChanges\Provider\ProviderManager;
use ScheduledChanges\Repository\AdminDashboardRepository;
use ScheduledChanges\Repository\RecordingSessionRepository;
use ScheduledChanges\Recording\RecordingService;
use ScheduledChanges\Scheduler\QueueMonitor;

/**
 * Registers accessible admin screens and server-backed fallback actions.
 */
final class AdminAppPage {
	private ?ProviderManager $providers;
	private ?RecordingService $recording_service;
	private AdminDashboardRepository $dashboard;
	private AdminScreenRegistry $screens;
	private AdminActionRegistry $actions;
	private AdminDataRegistry $data_registry;
	private AdminAssetRegistry $assets;
	private ScheduleCenterReadModel $schedule_center;
	private AdminAppDiagnostics $diagnostics;
	private AdapterStatusService $adapter_status;
	private AdminScheduleClock $schedule_clock;
	private const NONCE_ACTION         = 'scheduled_changes_admin_action';
	private const ADMIN_LIST_PAGE_SIZE = 10;

	/**
	 * Creates the administration application shell.
	 *
	 * @param ProviderManager|null         $providers Provider manager.
	 * @param QueueMonitor|null            $queue Queue monitor.
	 * @param RecordingSessionRepository   $recordings Recording session repository.
	 * @param RecordingService|null        $recording_service Recording service.
	 * @param AdminDashboardRepository     $dashboard Dashboard read model.
	 * @param bool                         $recording_enabled Whether recording UI/data/actions should be exposed.
	 * @param AdminScreenRegistry|null     $screens Active screen registry.
	 * @param AdminActionRegistry|null     $actions Active action registry.
	 * @param AdminDataRegistry|null       $data_registry Active data registry.
	 * @param ScheduleCenterReadModel|null $schedule_center Schedule Center read model.
	 * @param AdminAssetRegistry|null      $assets Active asset registry.
	 */
	public function __construct(
		?ProviderManager $providers,
		?QueueMonitor $queue,
		RecordingSessionRepository $recordings,
		?RecordingService $recording_service,
		AdminDashboardRepository $dashboard,
		bool $recording_enabled = true,
		?AdminScreenRegistry $screens = null,
		?AdminActionRegistry $actions = null,
		?AdminDataRegistry $data_registry = null,
		?ScheduleCenterReadModel $schedule_center = null,
		?AdminAssetRegistry $assets = null
	) {
		$this->providers         = $providers;
		$this->recording_service = $recording_service;
		$this->dashboard         = $dashboard;
		$this->diagnostics       = new AdminAppDiagnostics( $providers, $queue, $recordings, $dashboard );
		$this->adapter_status    = new AdapterStatusService();
		$this->schedule_clock    = new AdminScheduleClock();
		$default_modules         = array(
			BundledModuleIds::ADMIN_SHELL,
			BundledModuleIds::ADMIN_SCHEDULE,
			BundledModuleIds::ADMIN_RUN_HISTORY,
		);
		if ( $recording_enabled ) {
			$default_modules[] = BundledModuleIds::ADMIN_RECORDING;
		}
		$this->screens         = $screens ?? AdminScreenRegistry::forModules( $default_modules );
		$this->actions         = $actions ?? AdminActionRegistry::forModules( $default_modules );
		$this->data_registry   = $data_registry ?? AdminDataRegistry::forModules( $default_modules );
		$this->assets          = $assets ?? AdminAssetRegistry::forModules( $default_modules );
		$this->schedule_center = $schedule_center ?? new ScheduleCenterReadModel( $dashboard );
	}

	/**
	 * Registers WordPress hooks for the admin application.
	 */
	public function register(): void {
		add_action( 'admin_menu', array( $this, 'addMenu' ), 60 );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
		add_action( 'wp_ajax_scheduled_changes_admin_screen_data', array( $this, 'ajaxScreenData' ) );
		add_action( 'wp_ajax_scheduled_changes_admin_launch_detail', array( $this, 'ajaxLaunchDetail' ) );
		foreach ( $this->actions->all() as $hook => $method ) {
			add_action( $hook, array( $this, $method ) );
		}
	}

	/**
	 * Adds JJ\'s Post Scheduler as a top-level admin menu.
	 */
	public function addMenu(): void {
		$navigation = $this->navigation();
		$primary_id = $this->screens->primaryId();
		$primary    = $navigation[ $primary_id ];
		add_menu_page(
			(string) $primary['pageTitle'],
			__( 'JJ\'s Scheduler', 'jjs-post-scheduler' ),
			(string) $primary['capability'],
			(string) $primary['slug'],
			array( $this, 'render' ),
			'dashicons-calendar-alt',
			56
		);
		add_submenu_page(
			(string) $primary['slug'],
			(string) $primary['pageTitle'],
			(string) $primary['menuTitle'],
			(string) $primary['capability'],
			(string) $primary['slug'],
			array( $this, 'render' )
		);

		foreach ( $this->navigation() as $screen => $item ) {
			if ( $primary_id === $screen ) {
				continue;
			}

			if ( $this->hiddenScreen( $item ) ) {
				add_submenu_page(
					(string) $primary['slug'],
					(string) $item['pageTitle'],
					(string) $item['menuTitle'],
					(string) $item['capability'],
					(string) $item['slug'],
					array( $this, 'render' )
				);
				continue;
			}

			add_submenu_page(
				(string) $primary['slug'],
				(string) $item['pageTitle'],
				(string) $item['menuTitle'],
				(string) $item['capability'],
				(string) $item['slug'],
				array( $this, 'render' )
			);
		}
	}

	/**
	 * Enqueues the registered admin shell assets and localized bootstrap data.
	 *
	 * @param string $hook_suffix Current admin screen hook suffix.
	 */
	public function enqueue( string $hook_suffix ): void {
		unset( $hook_suffix );

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

		$current_screen = $this->currentScreen();

		foreach ( $this->assets->all() as $handle => $asset ) {
			if ( ! $this->assetVisibleForScreen( $asset, $current_screen ) ) {
				continue;
			}

			$type = (string) ( $asset['type'] ?? '' );
			$src  = (string) ( $asset['src'] ?? '' );

			if ( '' === $handle || '' === $src ) {
				continue;
			}

			if ( 'style' === $type ) {
				$version = $this->adminStyleVersion( $handle );
				wp_enqueue_style( $handle, $src, array(), $version );
				if ( 'scheduled-changes-admin-style' === $handle ) {
					wp_style_add_data( $handle, 'rtl', 'replace' );
				}
				continue;
			}

			if ( 'script' !== $type ) {
				continue;
			}

			$asset_dependents    = is_array( $asset['dependencies'] ?? null ) ? $asset['dependencies'] : array();
			$metadata_dependents = array();
			$version             = SCHEDULED_CHANGES_VERSION;

			if ( $this->isWebpackAdminScript( $handle ) ) {
				$metadata            = $this->adminAssetMetadata( $handle );
				$metadata_dependents = is_array( $metadata['dependencies'] ?? null ) ? $metadata['dependencies'] : array();
				$version             = is_scalar( $metadata['version'] ?? null ) ? (string) $metadata['version'] : SCHEDULED_CHANGES_VERSION;
			}

			$dependencies = array_values( array_unique( array_merge( $asset_dependents, $metadata_dependents ) ) );

			wp_enqueue_script( $handle, $src, $dependencies, $version, true );

			if ( $this->isWebpackAdminScript( $handle ) ) {
				wp_set_script_translations(
					$handle,
					'jjs-post-scheduler',
					SCHEDULED_CHANGES_PLUGIN_DIR . 'languages'
				);
				wp_localize_script(
					$handle,
					'scheduledChangesAdmin',
					$this->dataForScreen( $current_screen )
				);
			}
		}
	}

	/**
	 * Renders the WordPress admin application root.
	 */
	public function render(): void {
		$screen = $this->currentScreen();
		$item   = $this->navigation()[ $screen ] ?? $this->navigation()[ $this->screens->primaryId() ];

		if ( ! current_user_can( (string) $item['capability'] ) ) {
			wp_die( esc_html__( 'You do not have permission to view this page.', 'jjs-post-scheduler' ) );
		}

		echo '<div class="wrap scheduled-changes-admin-wrap">';
		printf(
			'<div id="scheduled-changes-admin-root" data-screen="%1$s"></div>',
			esc_attr( $screen )
		);
		echo '<noscript><div class="notice notice-warning"><p>';
		echo esc_html__( 'JJ\'s Post Scheduler needs JavaScript enabled to show the scheduling dashboard.', 'jjs-post-scheduler' );
		echo '</p></div></noscript>';
		echo '</div>';
	}

	/**
	 * Builds the localized data snapshot for an admin screen.
	 *
	 * @param string $screen Screen identifier.
	 * @return array<string,mixed>
	 */
	public function dataForScreen( string $screen, bool $include_preloaded = true ): array {
		$includes             = function ( string $key ) use ( $screen ): bool {
			return $this->data_registry->includesForScreen( $key, $screen );
		};
		$can_create           = current_user_can( CapabilityManager::CREATE_SCHEDULES );
		$can_edit             = current_user_can( CapabilityManager::EDIT_SCHEDULES );
		$can_schedule         = current_user_can( CapabilityManager::SCHEDULE_CHANGES );
		$can_execute          = current_user_can( CapabilityManager::EXECUTE_SCHEDULES );
		$can_rollback         = current_user_can( CapabilityManager::ROLLBACK_SCHEDULES );
		$can_view_logs        = current_user_can( CapabilityManager::VIEW_LOGS );
		$can_manage_settings  = current_user_can( CapabilityManager::MANAGE_SETTINGS );
		$can_view_diagnostics = $can_manage_settings;

		$data = array(
			'contractVersion'      => 1,
			'applicationName'      => __( 'JJ\'s Post and Product Scheduler', 'jjs-post-scheduler' ),
			'brandLogoUrl'         => $this->brandLogoUrl(),
			'brandLogoFallbackUrl' => $this->brandLogoFallbackUrl(),
			'currentScreen'        => $screen,
			'entitlementProfile'   => ( new ModuleProfileResolver() )->currentProfile(),
			'dateFormat'           => (string) get_option( 'date_format', 'F j, Y' ),
			'timeFormat'           => (string) get_option( 'time_format', 'g:i a' ),
			'navigation'           => $this->navigationForClient( $screen ),
			'adminPostUrl'         => admin_url( 'admin-post.php' ),
			'ajaxUrl'              => admin_url( 'admin-ajax.php' ),
			'restUrl'              => rest_url( 'scheduled-changes/v1/' ),
			'restNonce'            => wp_create_nonce( 'wp_rest' ),
			'nonce'                => wp_create_nonce( self::NONCE_ACTION ),
			'notice'               => $include_preloaded ? $this->consumeNotice() : array(),
			'dashboardSummary'     => $includes( 'dashboardSummary' ) ? $this->schedule_center->summary() : array(),
			'calendar'             => $includes( 'calendar' ) ? $this->schedule_center->calendar( self::ADMIN_LIST_PAGE_SIZE ) : array(),
			'launchDetails'        => array(),
			'launches'             => $includes( 'launches' ) ? $this->schedule_center->launches( self::ADMIN_LIST_PAGE_SIZE ) : array(),
			'runs'                 => $includes( 'runs' ) && $can_view_logs ? $this->dashboard->runs( self::ADMIN_LIST_PAGE_SIZE, $can_view_diagnostics ) : array(),
			'schedulerDiagnostics' => $includes( 'schedulerDiagnostics' ) && $can_view_diagnostics ? $this->diagnostics->schedulerDiagnostics() : array(),
			'scheduleDefaults'     => $includes( 'scheduleDefaults' ) ? $this->schedule_clock->scheduleDefaults() : array(),
			'system'               => $includes( 'system' ) && $can_view_diagnostics ? $this->diagnostics->systemRows() : array(),
			'adapters'             => $includes( 'adapters' ) && $can_view_diagnostics ? $this->adapterRowsForScreen( $screen ) : array(),
			'featureDiagnostics'   => $includes( 'featureDiagnostics' ) && $can_view_diagnostics ? $this->adapter_status->featureDiagnosticRows( $this->providers ) : array(),
			'ownershipCollisions'  => $includes( 'ownershipCollisions' ) && $can_view_diagnostics ? $this->adapter_status->collisionRows( $this->providers ) : array(),
			'supportReportUrl'     => '',
			'supportReport'        => $includes( 'supportReport' ) && $can_view_diagnostics ? $this->supportReportData() : array(),
			'accountUrl'           => '',
			'siteAccess'           => array(),
			'permissions'          => array(
				'canCreate'          => $can_create,
				'canEdit'            => $can_edit,
				'canSchedule'        => $can_schedule,
				'canExecute'         => $can_execute,
				'canRollback'        => $can_rollback,
				'canViewLogs'        => $can_view_logs,
				'canManageSettings'  => $can_manage_settings,
				'canViewDiagnostics' => $can_view_diagnostics,
			),
		);

		if ( $include_preloaded ) {
			$data['preloadedScreens'] = $this->preloadedScreenData( $screen );
		}

		return $data;
	}

	/**
	 * Returns screen-shaped compatibility rows for the React app.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	private function adapterRowsForScreen( string $screen ): array {
		if ( 'modules' === $screen ) {
			return array_merge(
				$this->adapter_status->extensionCatalogRows(),
				$this->adapter_status->moduleCatalogRows()
			);
		}

		return $this->adapter_status->adapterRows( $this->providers );
	}

	/**
	 * Builds a redacted support report including provider/module diagnostics.
	 *
	 * @return array<string,mixed>
	 */
	private function supportReportData(): array {
		$report                         = $this->diagnostics->supportReportData();
		$report['provider_diagnostics'] = $this->adapter_status->platformDiagnostics( $this->providers );

		return $report;
	}

	/**
	 * Returns capped data snapshots for visible product screens.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function preloadedScreenData( string $current_screen ): array {
		$preloaded = array();

		foreach ( $this->navigationForClient( $current_screen ) as $item ) {
			$screen = (string) ( $item['id'] ?? '' );
			if ( '' === $screen || $screen === $current_screen ) {
				continue;
			}

			$preloaded[ $screen ] = $this->dataForScreen( $screen, false );
		}

		return $preloaded;
	}

	/**
	 * Returns one admin screen data snapshot for client-side navigation.
	 */
	public function ajaxScreenData(): void {
		$nonce = $this->ajaxText( 'nonce' );

		if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'This JJ\'s Post Scheduler screen expired. Refresh WordPress and try again.', 'jjs-post-scheduler' ),
				),
				403
			);
		}

		$screen     = $this->ajaxKey( 'screen' );
		$navigation = $this->navigation();

		if ( ! isset( $navigation[ $screen ] ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'JJ\'s Post Scheduler could not find that admin screen.', 'jjs-post-scheduler' ),
				),
				404
			);
		}

		if ( ! current_user_can( (string) $navigation[ $screen ]['capability'] ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'You do not have permission to view that JJ\'s Post Scheduler screen.', 'jjs-post-scheduler' ),
				),
				403
			);
		}

		wp_send_json_success( $this->dataForScreen( $screen, false ) );
	}

	/**
	 * Returns one launch detail for the React admin app.
	 */
	public function ajaxLaunchDetail(): void {
		$nonce = $this->ajaxText( 'nonce' );

		if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'This JJ\'s Post Scheduler detail expired. Refresh WordPress and try again.', 'jjs-post-scheduler' ),
				),
				403
			);
		}

		if ( ! current_user_can( CapabilityManager::VIEW_SCHEDULES ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'You do not have permission to view that scheduled change.', 'jjs-post-scheduler' ),
				),
				403
			);
		}

		$uuid   = $this->ajaxText( 'uuid' );
		$detail = '' === $uuid ? null : $this->dashboard->launchDetailByUuid( $uuid, current_user_can( CapabilityManager::MANAGE_SETTINGS ) );

		if ( null === $detail ) {
			wp_send_json_error(
				array(
					'message' => __( 'JJ\'s Post Scheduler could not find that change.', 'jjs-post-scheduler' ),
				),
				404
			);
		}

		if ( ! current_user_can( CapabilityManager::VIEW_LOGS ) ) {
			$detail['runs']     = array();
			$detail['timeline'] = array();
		}

		wp_send_json_success( $detail );
	}

	/**
	 * Abandons an active recording session and deletes its temporary workspace.
	 */
	public function cancelRecording(): void {
		$this->authorizeAdminAction( CapabilityManager::CREATE_SCHEDULES );
		$uuid = $this->postedString( 'recording_uuid' );

		try {
			if ( null === $this->recording_service ) {
				throw new \RuntimeException( 'Recording service is unavailable.' );
			}

			$this->recording_service->abandon( new Uuid( $uuid ), true );
			$this->redirectWithNotice(
				'all-changes',
				'success',
				__( 'Recording canceled. The temporary workspace was removed.', 'jjs-post-scheduler' )
			);
		} catch ( \Throwable $throwable ) {
			$this->redirectWithNotice(
				'all-changes',
				'error',
				AdminNoticeMessages::withSupportDetail(
					__( 'Recording could not be canceled. The temporary workspace may still be active, and the original target was not changed. Try canceling again from the editor.', 'jjs-post-scheduler' ),
					$throwable,
					__( 'Support detail', 'jjs-post-scheduler' )
				)
			);
		}
	}

	/**
	 * Starts or resumes a recording from the dashboard target picker.
	 */
	public function startDashboardRecording(): void {
		$this->authorizeAdminAction( CapabilityManager::CREATE_SCHEDULES );
		$source_id = $this->postedInt( 'source_id' );

		try {
			if (
				null === $this->recording_service
				|| $source_id <= 0
			) {
				throw new \RuntimeException( 'Recording could not be started for this target.' );
			}

			$actor_id     = get_current_user_id();
			$session      = $this->recording_service->activeForSource( $source_id, $actor_id )
				?? $this->recording_service->start( $source_id, $actor_id );
			$workspace_id = $session->workspaceId();

			if ( null === $workspace_id ) {
				throw new \RuntimeException( 'Recording session has no temporary workspace.' );
			}

			$workspace     = PublicHooks::adminTargetRow(
				array(
					'id'              => $workspace_id,
					'reference'       => (string) ( $session->workspaceReference() ?? '' ),
					'targetReference' => (string) ( $session->workspaceReference() ?? '' ),
					'targetType'      => $session->workspaceType(),
					'name'            => '',
					'editUrl'         => '',
					'viewUrl'         => '',
					'thumbnail'       => '',
					'providerId'      => $session->workspaceProviderId(),
					'parentReference' => '',
				),
				array(
					'target_id'             => $workspace_id,
					'target_reference'      => (string) ( $session->workspaceReference() ?? '' ),
					'target_type'           => $session->workspaceType(),
					'provider_id'           => $session->workspaceProviderId(),
					'workspace_reference'   => (string) ( $session->workspaceReference() ?? '' ),
					'workspace_type'        => $session->workspaceType(),
					'workspace_provider_id' => $session->workspaceProviderId(),
				),
				'recording_workspace_redirect'
			);
			$workspace_url = (string) ( $workspace['editUrl'] ?? '' );

			if ( '' === $workspace_url ) {
				throw new \RuntimeException( 'Unable to open the temporary recording copy.' );
			}

			wp_safe_redirect(
				add_query_arg(
					array(
						'scheduled_changes_recording' => $session->uuid()->value(),
						'scheduled_changes_recording_started' => '1',
					),
					$workspace_url
				)
			);
			exit;
		} catch ( \Throwable $throwable ) {
			$this->redirectWithNotice(
				'all-changes',
				'error',
				AdminNoticeMessages::withSupportDetail(
					__( 'Recording could not be started. No target was changed. Try again from the target picker, or open the target editor and use Record there.', 'jjs-post-scheduler' ),
					$throwable,
					__( 'Support detail', 'jjs-post-scheduler' )
				)
			);
		}
	}

	/**
	 * Builds the support report payload.
	 *
	 * @return array<string,mixed>
	 */
	/**
	 * Returns registered admin screens.
	 *
	 * @return array<string,array<string,string>>
	 */
	private function navigation(): array {
		return $this->screens->all();
	}

	/**
	 * Returns navigation data for the JavaScript application and fallback.
	 *
	 * @return array<int,array<string,string>>
	 */
	private function navigationForClient( string $current_screen = '' ): array {
		$items          = array();
		$current_screen = '' === $current_screen ? $this->currentScreen() : $current_screen;

		foreach ( $this->navigation() as $screen => $item ) {
			if ( $this->hiddenScreen( $item ) ) {
				continue;
			}

			if ( ! current_user_can( (string) $item['capability'] ) ) {
				continue;
			}

			$items[] = array(
				'id'      => $screen,
				'label'   => (string) $item['menuTitle'],
				'href'    => admin_url( 'admin.php?page=' . $item['slug'] ),
				'current' => $screen === $current_screen ? 'true' : 'false',
				'parent'  => (string) ( $item['parent'] ?? '' ),
			);
		}

		return $items;
	}

	/**
	 * Resolves the current JJ\'s Post Scheduler screen from the request.
	 */
	private function currentScreen(): string {
		$page = $this->requestString( 'page' );

		foreach ( $this->navigation() as $screen => $item ) {
			if ( $page === $item['slug'] ) {
				return $screen;
			}
		}

		return $this->screens->primaryId();
	}

	/**
	 * Returns whether the current request targets one registered admin page.
	 */
	private function isCurrentAdminPage(): bool {
		$page = $this->requestString( 'page' );

		foreach ( $this->navigation() as $item ) {
			if ( $page === (string) $item['slug'] ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Returns whether a registered screen should stay out of visible navigation.
	 *
	 * @param array<string,string> $item Screen definition.
	 */
	private function hiddenScreen( array $item ): bool {
		return 'true' === (string) ( $item['hidden'] ?? '' );
	}

	/**
	 * Returns the optional brand logo URL when the raster asset is available.
	 */
	private function brandLogoUrl(): string {
		$relative_path = 'assets/images/logo.avif';

		if ( ! file_exists( SCHEDULED_CHANGES_PLUGIN_DIR . $relative_path ) ) {
			return '';
		}

		return SCHEDULED_CHANGES_PLUGIN_URL . $relative_path;
	}

	/**
	 * Returns the fallback brand logo URL for browsers without AVIF support.
	 */
	private function brandLogoFallbackUrl(): string {
		$relative_path = 'assets/images/logo.jpg';

		if ( ! file_exists( SCHEDULED_CHANGES_PLUGIN_DIR . $relative_path ) ) {
			return '';
		}

		return SCHEDULED_CHANGES_PLUGIN_URL . $relative_path;
	}

	/**
	 * Reads the webpack-generated admin asset metadata.
	 *
	 * @return array<string,mixed>
	 */
	private function adminAssetMetadata( string $handle ): array {
		unset( $handle );
		$asset_file = SCHEDULED_CHANGES_PLUGIN_DIR . 'assets/build/admin.asset.php';

		if ( ! is_readable( $asset_file ) ) {
			return array(
				'dependencies' => array(),
				'version'      => SCHEDULED_CHANGES_VERSION,
			);
		}

		$metadata = require $asset_file;

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

	/**
	 * Returns the compiled admin stylesheet version.
	 */
	private function adminStyleVersion( string $handle ): string {
		unset( $handle );
		$style_file = SCHEDULED_CHANGES_PLUGIN_DIR . 'assets/build/admin.css';

		if ( ! is_readable( $style_file ) ) {
			return SCHEDULED_CHANGES_VERSION;
		}

		$modified = filemtime( $style_file );

		return false === $modified ? SCHEDULED_CHANGES_VERSION : (string) $modified;
	}

	/**
	 * Returns whether an asset belongs on one admin screen.
	 *
	 * @param array<string,mixed> $asset Asset definition.
	 */
	private function assetVisibleForScreen( array $asset, string $screen ): bool {
		$screens = is_array( $asset['screens'] ?? null ) ? $asset['screens'] : array();
		if ( array() !== $screens && ! in_array( $screen, $screens, true ) ) {
			return false;
		}

		$excluded = is_array( $asset['excludeScreens'] ?? null ) ? $asset['excludeScreens'] : array();
		if ( array() !== $excluded && in_array( $screen, $excluded, true ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Returns whether one handle is a localized webpack admin app entry.
	 */
	private function isWebpackAdminScript( string $handle ): bool {
		return 'scheduled-changes-admin' === $handle;
	}

	/**
	 * Authorizes an admin-post action before request-specific input is read.
	 *
	 * @param string $capability Required capability.
	 */
	private function authorizeAdminAction( string $capability ): void {
		check_admin_referer( self::NONCE_ACTION );

		if ( ! current_user_can( $capability ) ) {
			wp_die(
				esc_html__( 'You do not have permission to perform this action.', 'jjs-post-scheduler' ),
				esc_html__( 'Forbidden', 'jjs-post-scheduler' ),
				array( 'response' => 403 )
			);
		}
	}

	/**
	 * Redirects back to an admin screen with a transient notice.
	 *
	 * @param string               $screen Screen identifier.
	 * @param string               $type Notice type.
	 * @param string               $message Notice message.
	 * @param array<string,string> $query Additional query arguments.
	 */
	private function redirectWithNotice(
		string $screen,
		string $type,
		string $message,
		array $query = array()
	): void {
		set_transient(
			'scheduled_changes_admin_notice_' . get_current_user_id(),
			array(
				'type'    => $type,
				'message' => $message,
			),
			60
		);

		$navigation = $this->navigation();
		if ( ! isset( $navigation[ $screen ] ) ) {
			$detail = sprintf( 'Unknown JJ\'s Post Scheduler admin redirect screen: %s', $screen );
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				throw new \RuntimeException( esc_html( $detail ) );
			}

			error_log( 'scheduled_changes_invalid_admin_redirect_screen ' . sanitize_key( $screen ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production fallback records invalid internal redirect targets for support.
			$screen = $this->screens->primaryId();
		}

		$slug = $navigation[ $screen ]['slug'];
		wp_safe_redirect(
			add_query_arg(
				array_merge( array( 'page' => $slug ), $query ),
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

	/**
	 * Consumes the current admin notice, if present.
	 *
	 * @return array<string,string>|null
	 */
	private function consumeNotice(): ?array {
		$key    = 'scheduled_changes_admin_notice_' . get_current_user_id();
		$notice = get_transient( $key );

		if ( false === $notice || ! is_array( $notice ) ) {
			return null;
		}

		delete_transient( $key );

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

	/**
	 * Reads a sanitized string from POST data after nonce verification by the caller.
	 *
	 * @param string $key POST key.
	 */
	private function postedString( string $key ): string {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified before this helper is used.
		return sanitize_text_field( wp_unslash( $_POST[ $key ] ?? '' ) );
	}

	/**
	 * Reads a sanitized integer from POST or GET data after nonce verification by the caller.
	 *
	 * @param string $key Request key.
	 */
	private function postedInt( string $key ): int {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- Nonce is verified before this helper is used.
		return absint( wp_unslash( $_POST[ $key ] ?? $_GET[ $key ] ?? 0 ) );
	}

	/**
	 * Reads a sanitized key from query string routing data.
	 *
	 * @param string $key Query key.
	 */
	private function requestString( string $key ): string {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing data.
		return sanitize_key( wp_unslash( $_GET[ $key ] ?? '' ) );
	}

	/**
	 * Reads a sanitized Ajax key after ajaxScreenData validates its nonce.
	 *
	 * @param string $key Request key.
	 */
	private function ajaxKey( string $key ): string {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- ajaxScreenData verifies the nonce before reading screen-specific data.
		return sanitize_key( wp_unslash( $_GET[ $key ] ?? '' ) );
	}

	/**
	 * Reads a sanitized Ajax text value for nonce validation.
	 *
	 * @param string $key Request key.
	 */
	private function ajaxText( string $key ): string {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This reads the nonce that is verified immediately by the caller.
		return sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ?? '' ) );
	}
}
