<?php
/**
 * Click analytics admin-page renderer.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Infrastructure\Persistence\ClickEventRepository;
use Affilun\Infrastructure\Persistence\OptionStore;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;

/**
 * Renders a privacy-light click/CTR dashboard from option-backed events.
 */
final class ClicksDashboardPageRenderer {

	/**
	 * Recent click-events option key.
	 */
	private const OPTION_EVENTS = 'affilun_click_events';

	/**
	 * Export query key.
	 */
	private const KEY_EXPORT = 'affilun_export';

	/**
	 * Export query value.
	 */
	private const EXPORT_CSV = 'clicks_csv';

	/**
	 * Admin menu slug.
	 */
	private const MENU_SLUG = 'affilun-clicks';

	private const PRODUCT_PAGE_PARAM = 'affilun_click_product_paged';

	private const PRODUCT_PER_PAGE_PARAM = 'affilun_click_product_per_page';

	private const POST_PAGE_PARAM = 'affilun_click_post_paged';

	private const POST_PER_PAGE_PARAM = 'affilun_click_post_per_page';

	private const DEFAULT_PER_PAGE = 20;

	/**
	 * Page-size choices exposed on ranking tables.
	 *
	 * @var list<int>
	 */
	private const PER_PAGE_OPTIONS = array( 10, 20, 50 );

	/**
	 * Option storage.
	 */
	private OptionStore $options;

	/**
	 * Optional custom-table click repository.
	 */
	private ?ClickEventRepository $clickEvents;

	/**
	 * Optional product repository for product summary rows.
	 */
	private ?ProductRepository $products;

	/**
	 * Construct the renderer.
	 *
	 * @param OptionStore               $options     Option storage.
	 * @param ClickEventRepository|null $clickEvents Optional click repository.
	 * @param ProductRepository|null    $products    Optional product repository.
	 */
	public function __construct( OptionStore $options, ?ClickEventRepository $clickEvents = null, ?ProductRepository $products = null ) {
		$this->options     = $options;
		$this->clickEvents = $clickEvents;
		$this->products    = $products;
	}

	/**
	 * Echo the dashboard HTML, or CSV when requested.
	 *
	 * @param array<string, mixed> $query Query parameters.
	 */
	public function render( array $query = array() ): void {
		$events  = $this->readEvents();
		$summary = self::summarizeEvents( $events );

		if ( self::isCsvExport( $query ) ) {
			$this->renderCsv( $events );
			return;
		}

		echo '<div class="wrap affilun-admin-page affilun-clicks-page">';
		$this->renderHero();

		echo '<div class="affilun-products-list-card">';
		echo '<h2>' . esc_html( '概要' ) . '</h2>';
		echo '<p class="description">' . esc_html( '商品リンクのクリック数を、個人を特定しない集計で確認します。CTR は表示回数の記録が追加されるまでクリック実績として扱います。' ) . '</p>';
		$this->renderExportLink();
		$this->renderSummaryCards( $summary );
		echo '</div>';

		echo '<div class="affilun-products-list-card">';
		$this->renderDimensionTable( 'プロバイダー別クリック', 'プロバイダー', $summary['providers'] );
		$this->renderProductDimensionTable( $summary['products'], $query );
		$this->renderPostDimensionTable( $summary['posts'], $query );
		echo '</div>';

		echo '</div>';
	}

	/**
	 * Render branded click analytics hero.
	 */
	private function renderHero(): void {
		$logoUrl = defined( 'AFFILUN_PLUGIN_URL' )
			? AFFILUN_PLUGIN_URL . 'assets/img/brand/logo-landscape-transparent.png'
			: '';

		echo '<div class="affilun-admin-hero">';
		echo '<div class="affilun-admin-hero__content">';
		if ( $logoUrl !== '' ) {
			echo '<img class="affilun-admin-logo" src="' . esc_url( $logoUrl ) . '" alt="affilun / あふぃるん">';
		}
		echo '<p class="affilun-admin-kicker">' . esc_html( 'つなぐ・分析する・成長する' ) . '</p>';
		echo '<h1 class="affilun-admin-title">' . esc_html( 'クリック分析' ) . '</h1>';
		echo '</div>';
		echo '<div class="affilun-admin-hero__aside" aria-hidden="true"><div class="affilun-admin-palette">'
			. '<span></span><span></span><span></span><span></span><span></span></div></div>';
		echo '</div>';
	}

	/**
	 * Summarize retained recent events into dashboard dimensions.
	 *
	 * @param list<array<string, mixed>> $events Recent events.
	 *
	 * @return array{total:int,providers:array<string,int>,products:array<string,int>,posts:array<string,int>,buttons:array<string,int>,variants:array<string,int>}
	 */
	public static function summarizeEvents( array $events ): array {
		$summary = array(
			'total'     => count( $events ),
			'providers' => array(),
			'products'  => array(),
			'posts'     => array(),
			'buttons'   => array(),
			'variants'  => array(),
		);

		foreach ( $events as $event ) {
			if ( ! is_array( $event ) ) {
				continue;
			}

			self::incrementDimension( $summary['providers'], self::stringField( $event, 'provider', 'unknown' ) );
			self::incrementDimension( $summary['buttons'], self::stringField( $event, 'button_key', 'unknown' ) );

			$productId = self::idField( $event, 'product_id' );
			if ( $productId !== null ) {
				self::incrementDimension( $summary['products'], (string) $productId );
			}

			$postId = self::idField( $event, 'post_id' );
			if ( $postId !== null ) {
				self::incrementDimension( $summary['posts'], (string) $postId );
			}

			$variant = self::stringField( $event, 'variant', '' );
			if ( $variant !== '' ) {
				self::incrementDimension( $summary['variants'], $variant );
			}
		}

		self::sortDimension( $summary['providers'] );
		self::sortDimension( $summary['products'] );
		self::sortDimension( $summary['posts'] );
		self::sortDimension( $summary['buttons'] );
		self::sortDimension( $summary['variants'] );

		return $summary;
	}

	/**
	 * Read retained recent events from the option store.
	 *
	 * @return list<array<string, mixed>>
	 */
	private function readEvents(): array {
		if ( $this->clickEvents !== null ) {
			$events = $this->clickEvents->recentEvents();
			if ( $events !== array() ) {
				return $events;
			}
		}
		$stored = $this->options->get( self::OPTION_EVENTS, array() );
		if ( ! is_array( $stored ) ) {
			return array();
		}

		$events = array();
		foreach ( $stored as $event ) {
			if ( is_array( $event ) ) {
				$events[] = $event;
			}
		}
		return $events;
	}

	/**
	 * Render the CSV export link.
	 */
	private function renderExportLink(): void {
		$url = self::adminPageUrl(
			array(
				'page'           => self::MENU_SLUG,
				self::KEY_EXPORT => self::EXPORT_CSV,
			)
		);
		echo '<p><a class="button button-secondary" href="' . esc_url( $url ) . '">';
		echo esc_html( 'CSV エクスポート' ) . '</a></p>';
	}

	/**
	 * Render summary cards.
	 *
	 * @param array<string, mixed> $summary Summary.
	 */
	private function renderSummaryCards( array $summary ): void {
		echo '<div class="affilun-click-summary">';
		$this->renderSummaryCard( '保持中クリック', (string) $summary['total'] );
		$this->renderSummaryCard( 'クリック商品数', (string) count( $summary['products'] ) );
		$this->renderSummaryCard( 'クリック投稿数', (string) count( $summary['posts'] ) );
		$this->renderSummaryCard( 'プロバイダー数', (string) count( $summary['providers'] ) );
		echo '</div>';
	}

	/**
	 * Render one summary card.
	 *
	 * @param string $label Label.
	 * @param string $value Value.
	 */
	private function renderSummaryCard( string $label, string $value ): void {
		echo '<div class="affilun-click-summary-card">';
		echo '<strong>' . esc_html( $value ) . '</strong>';
		echo '<span>' . esc_html( $label ) . '</span>';
		echo '</div>';
	}

	/**
	 * Render a dimension table.
	 *
	 * @param string            $title   Heading.
	 * @param string            $keyName First column label.
	 * @param array<string,int> $rows    Count rows.
	 */
	private function renderDimensionTable( string $title, string $keyName, array $rows ): void {
		echo '<div class="affilun-click-dimension">';
		echo '<h2>' . esc_html( $title ) . '</h2>';
		if ( $rows === array() ) {
			echo '<p>' . esc_html( 'まだクリックデータがありません。' ) . '</p>';
			echo '</div>';
			return;
		}

		echo '<table class="wp-list-table widefat striped">';
		echo '<thead><tr><th scope="col">' . esc_html( $keyName ) . '</th>';
		echo '<th scope="col">' . esc_html( 'クリック数' ) . '</th></tr></thead><tbody>';
		$shown = 0;
		foreach ( $rows as $key => $count ) {
			echo '<tr><td>' . esc_html( $key ) . '</td><td>' . esc_html( (string) $count ) . '</td></tr>';
			++$shown;
			if ( $shown >= 20 ) {
				break;
			}
		}
		echo '</tbody></table>';
		echo '</div>';
	}

	/**
	 * Render clicked product ranking with product summary cells.
	 *
	 * @param array<int|string,int> $rows Count rows keyed by product id.
	 * @param array<string, mixed>  $query Query parameters.
	 */
	private function renderProductDimensionTable( array $rows, array $query ): void {
		$perPage     = self::queryPerPage( $query, self::PRODUCT_PER_PAGE_PARAM, self::DEFAULT_PER_PAGE );
		$totalRows   = count( $rows );
		$totalPages  = max( 1, (int) ceil( $totalRows / $perPage ) );
		$currentPage = min( max( 1, self::queryInt( $query, self::PRODUCT_PAGE_PARAM, 1 ) ), $totalPages );
		$pageRows    = array_slice( $rows, ( $currentPage - 1 ) * $perPage, $perPage, true );
		$products    = $this->productsById( array_keys( $pageRows ) );

		echo '<div class="affilun-click-dimension">';
		echo '<h2>' . esc_html( '商品別クリック上位' ) . '</h2>';
		if ( $rows === array() ) {
			echo '<p>' . esc_html( 'まだクリックデータがありません。' ) . '</p>';
			echo '</div>';
			return;
		}

		echo '<table class="wp-list-table widefat striped">';
		echo '<thead><tr><th scope="col">' . esc_html( '商品' ) . '</th>';
		echo '<th scope="col">' . esc_html( 'クリック数' ) . '</th></tr></thead><tbody>';
		foreach ( $pageRows as $key => $count ) {
			$keyString = (string) $key;
			$productId = ctype_digit( $keyString ) ? (int) $keyString : null;
			$product   = $productId !== null ? ( $products[ $productId ] ?? null ) : null;
			echo '<tr><td>';
			$this->renderProductCell( $keyString, $product );
			echo '</td><td>' . esc_html( (string) $count ) . '</td></tr>';
		}
		echo '</tbody></table>';
		$this->renderRankingPagination(
			$query,
			self::PRODUCT_PAGE_PARAM,
			self::PRODUCT_PER_PAGE_PARAM,
			$currentPage,
			$totalPages,
			$totalRows,
			$perPage
		);
		echo '</div>';
	}

	/**
	 * Render clicked post ranking with linked post titles.
	 *
	 * @param array<int|string,int> $rows Count rows keyed by post id.
	 * @param array<string, mixed>  $query Query parameters.
	 */
	private function renderPostDimensionTable( array $rows, array $query ): void {
		$perPage     = self::queryPerPage( $query, self::POST_PER_PAGE_PARAM, self::DEFAULT_PER_PAGE );
		$totalRows   = count( $rows );
		$totalPages  = max( 1, (int) ceil( $totalRows / $perPage ) );
		$currentPage = min( max( 1, self::queryInt( $query, self::POST_PAGE_PARAM, 1 ) ), $totalPages );
		$pageRows    = array_slice( $rows, ( $currentPage - 1 ) * $perPage, $perPage, true );

		echo '<div class="affilun-click-dimension">';
		echo '<h2>' . esc_html( '投稿別クリック上位' ) . '</h2>';
		if ( $rows === array() ) {
			echo '<p>' . esc_html( 'まだクリックデータがありません。' ) . '</p>';
			echo '</div>';
			return;
		}

		echo '<table class="wp-list-table widefat striped">';
		echo '<thead><tr><th scope="col">' . esc_html( '投稿' ) . '</th>';
		echo '<th scope="col">' . esc_html( 'クリック数' ) . '</th></tr></thead><tbody>';
		foreach ( $pageRows as $key => $count ) {
			echo '<tr><td>';
			$this->renderPostCell( (string) $key );
			echo '</td><td>' . esc_html( (string) $count ) . '</td></tr>';
		}
		echo '</tbody></table>';
		$this->renderRankingPagination(
			$query,
			self::POST_PAGE_PARAM,
			self::POST_PER_PAGE_PARAM,
			$currentPage,
			$totalPages,
			$totalRows,
			$perPage
		);
		echo '</div>';
	}

	/**
	 * Render pagination for a ranking table.
	 *
	 * @param array<string,mixed> $query        Current query parameters.
	 * @param string              $pageParam    Page query key.
	 * @param string              $perPageParam Page-size query key.
	 * @param int                 $currentPage  Current page.
	 * @param int                 $totalPages   Total pages.
	 * @param int                 $totalRows    Total rows.
	 * @param int                 $perPage      Current page size.
	 */
	private function renderRankingPagination(
		array $query,
		string $pageParam,
		string $perPageParam,
		int $currentPage,
		int $totalPages,
		int $totalRows,
		int $perPage
	): void {
		echo '<div class="tablenav"><div class="tablenav-pages">';
		echo '<span class="displaying-num">' . esc_html( sprintf( '%d件', $totalRows ) ) . '</span> ';
		$this->renderPerPageLinks( $query, $pageParam, $perPageParam, $perPage );
		if ( $currentPage > 1 ) {
			echo '<a class="prev-page button" href="' . esc_url( self::rankingPageUrl( $query, $pageParam, $perPageParam, $currentPage - 1, $perPage ) ) . '">' . esc_html( '« 前へ' ) . '</a> ';
		}
		echo '<span class="paging-input">' . esc_html( sprintf( '%d / %d', $currentPage, $totalPages ) ) . '</span> ';
		if ( $currentPage < $totalPages ) {
			echo '<a class="next-page button" href="' . esc_url( self::rankingPageUrl( $query, $pageParam, $perPageParam, $currentPage + 1, $perPage ) ) . '">' . esc_html( '次へ »' ) . '</a>';
		}
		echo '</div></div>';
	}

	/**
	 * Render 10/20/50 page-size links for a ranking table.
	 *
	 * @param array<string,mixed> $query        Current query parameters.
	 * @param string              $pageParam    Page query key.
	 * @param string              $perPageParam Page-size query key.
	 * @param int                 $current      Current page size.
	 */
	private function renderPerPageLinks( array $query, string $pageParam, string $perPageParam, int $current ): void {
		echo '<span class="affilun-per-page-links">';
		echo esc_html( '表示件数: ' );
		$first = true;
		foreach ( self::PER_PAGE_OPTIONS as $option ) {
			if ( ! $first ) {
				echo ' / ';
			}
			if ( $option === $current ) {
				echo '<strong>' . esc_html( sprintf( '%d件', $option ) ) . '</strong>';
			} else {
				echo '<a href="' . esc_url( self::rankingPageUrl( $query, $pageParam, $perPageParam, 1, $option ) ) . '">' . esc_html( sprintf( '%d件', $option ) ) . '</a>';
			}
			$first = false;
		}
		echo '</span> ';
	}

	/**
	 * Return product records keyed by id.
	 *
	 * @param list<int|string> $keys Product id keys.
	 *
	 * @return array<int,ProductRecord>
	 */
	private function productsById( array $keys ): array {
		if ( $this->products === null ) {
			return array();
		}

		$ids = array();
		foreach ( $keys as $key ) {
			$keyString = (string) $key;
			if ( ctype_digit( $keyString ) ) {
				$ids[] = (int) $keyString;
			}
		}
		$ids = array_values( array_unique( array_filter( $ids, static fn ( int $id ): bool => $id > 0 ) ) );
		if ( $ids === array() ) {
			return array();
		}

		$records = array();
		foreach ( $this->products->findByIds( $ids ) as $record ) {
			$id = $record->id();
			if ( $id !== null ) {
				$records[ $id ] = $record;
			}
		}
		return $records;
	}

	/**
	 * Render one product summary cell.
	 *
	 * @param string             $key     Product id key.
	 * @param ProductRecord|null $product Product record.
	 */
	private function renderProductCell( string $key, ?ProductRecord $product ): void {
		if ( $product === null ) {
			echo '<span class="affilun-click-entity__meta">#' . esc_html( $key ) . '</span>';
			return;
		}

		$productId  = $product->id();
		$title      = $product->title();
		$imageUrl   = $product->imagePrimaryUrl();
		$productUrl = $product->affiliateUrl() ?? $product->detailUrl();

		echo '<div class="affilun-click-entity affilun-click-entity--product">';
		if ( $imageUrl !== null && $imageUrl !== '' ) {
			echo '<a class="affilun-click-entity__thumb" href="' . esc_url( $productUrl ) . '" target="_blank" rel="noopener noreferrer">';
			echo '<img src="' . esc_url( $imageUrl ) . '" alt="" loading="lazy" width="48" height="48">';
			echo '</a>';
		}
		echo '<span class="affilun-click-entity__body">';
		echo '<span class="affilun-click-entity__meta">#' . esc_html( $productId === null ? $key : (string) $productId ) . '</span>';
		echo '<a href="' . esc_url( $productUrl ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $title ) . '</a>';
		echo '</span>';
		echo '</div>';
	}

	/**
	 * Render one post summary cell.
	 *
	 * @param string $key Post id key.
	 */
	private function renderPostCell( string $key ): void {
		$postId = ctype_digit( $key ) ? (int) $key : null;
		if ( $postId === null || $postId <= 0 ) {
			echo '<span class="affilun-click-entity__meta">#' . esc_html( $key ) . '</span>';
			return;
		}

		$title = function_exists( 'get_the_title' ) ? (string) get_the_title( $postId ) : '';
		$url   = function_exists( 'get_permalink' ) ? get_permalink( $postId ) : '';
		$title = trim( $title ) === '' ? sprintf( '投稿 #%d', $postId ) : trim( $title );
		$url   = is_string( $url ) ? $url : '';

		echo '<span class="affilun-click-entity__body">';
		echo '<span class="affilun-click-entity__meta">#' . esc_html( (string) $postId ) . '</span>';
		if ( $url !== '' ) {
			echo '<a href="' . esc_url( $url ) . '">' . esc_html( $title ) . '</a>';
		} else {
			echo esc_html( $title );
		}
		echo '</span>';
	}

	/**
	 * Render retained click events as CSV.
	 *
	 * @param list<array<string, mixed>> $events Recent events.
	 */
	private function renderCsv( array $events ): void {
		if ( ! headers_sent() ) {
			header( 'Content-Type: text/csv; charset=utf-8' );
			header( 'Content-Disposition: attachment; filename="affilun-clicks.csv"' );
		}

		$rows   = array();
		$rows[] = array(
			'created_at',
			'provider',
			'product_id',
			'post_id',
			'button_key',
			'variant',
			'target_url_hash',
		);
		foreach ( $events as $event ) {
			$rows[] = array(
				self::stringField( $event, 'created_at', '' ),
				self::stringField( $event, 'provider', 'unknown' ),
				self::nullableIdToString( self::idField( $event, 'product_id' ) ),
				self::nullableIdToString( self::idField( $event, 'post_id' ) ),
				self::stringField( $event, 'button_key', 'unknown' ),
				self::stringField( $event, 'variant', '' ),
				self::stringField( $event, 'target_url_hash', '' ),
			);
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming a generated CSV download to the response body.
		$stream = fopen( 'php://output', 'wb' );
		if ( ! is_resource( $stream ) ) {
			return;
		}

		foreach ( $rows as $row ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Streaming a generated CSV download to the response body.
			fwrite( $stream, self::formatCsvRow( $row ) );
		}
	}

	/**
	 * Format a CSV row while preserving the historic all-fields-quoted output.
	 *
	 * @param list<string> $row CSV cells.
	 */
	private static function formatCsvRow( array $row ): string {
		$cells = array_map(
			static fn ( string $cell ): string => '"' . str_replace( '"', '""', $cell ) . '"',
			$row
		);

		return implode( ',', $cells ) . "\n";
	}

	/**
	 * Return whether CSV export was requested.
	 *
	 * @param array<string, mixed> $query Query parameters.
	 */
	private static function isCsvExport( array $query ): bool {
		$value = $query[ self::KEY_EXPORT ] ?? null;
		return is_string( $value ) && $value === self::EXPORT_CSV;
	}

	/**
	 * Read a positive integer query value.
	 *
	 * @param array<string,mixed> $query    Query parameters.
	 * @param string              $key      Query key.
	 * @param int                 $fallback Fallback value.
	 */
	private static function queryInt( array $query, string $key, int $fallback ): int {
		$value = $query[ $key ] ?? null;
		if ( ! is_scalar( $value ) ) {
			return $fallback;
		}
		$string = trim( (string) $value );
		if ( $string === '' || ! ctype_digit( $string ) ) {
			return $fallback;
		}
		$parsed = (int) $string;
		return $parsed > 0 ? $parsed : $fallback;
	}

	/**
	 * Read a page-size query value restricted to the exposed choices.
	 *
	 * @param array<string,mixed> $query    Query parameters.
	 * @param string              $key      Query key.
	 * @param int                 $fallback Fallback page size.
	 */
	private static function queryPerPage( array $query, string $key, int $fallback ): int {
		$value = self::queryInt( $query, $key, $fallback );
		return in_array( $value, self::PER_PAGE_OPTIONS, true ) ? $value : $fallback;
	}

	/**
	 * Increment a dimension count.
	 *
	 * @param array<string,int> $dimension Dimension counts.
	 * @param string            $key       Key.
	 */
	private static function incrementDimension( array &$dimension, string $key ): void {
		$key = trim( $key );
		if ( $key === '' ) {
			$key = 'unknown';
		}
		$dimension[ $key ] = ( $dimension[ $key ] ?? 0 ) + 1;
	}

	/**
	 * Sort a dimension by count descending, key ascending.
	 *
	 * @param array<string,int> $dimension Dimension counts.
	 */
	private static function sortDimension( array &$dimension ): void {
		uksort(
			$dimension,
			static function ( string $a, string $b ) use ( $dimension ): int {
				$countCompare = $dimension[ $b ] <=> $dimension[ $a ];
				return $countCompare !== 0 ? $countCompare : strcmp( $a, $b );
			}
		);
	}

	/**
	 * Read a string field.
	 *
	 * @param array<string, mixed> $event    Event.
	 * @param string               $key      Key.
	 * @param string               $fallback Fallback.
	 */
	private static function stringField( array $event, string $key, string $fallback ): string {
		$value = $event[ $key ] ?? null;
		if ( ! is_scalar( $value ) ) {
			return $fallback;
		}
		$trimmed = trim( (string) $value );
		return $trimmed === '' ? $fallback : $trimmed;
	}

	/**
	 * Read a positive integer field.
	 *
	 * @param array<string, mixed> $event Event.
	 * @param string               $key   Key.
	 */
	private static function idField( array $event, string $key ): ?int {
		$value = $event[ $key ] ?? null;
		if ( is_int( $value ) && $value > 0 ) {
			return $value;
		}
		if ( is_string( $value ) && ctype_digit( $value ) ) {
			$parsed = (int) $value;
			return $parsed > 0 ? $parsed : null;
		}
		return null;
	}

	/**
	 * Convert nullable id to CSV string.
	 *
	 * @param int|null $id Identifier.
	 */
	private static function nullableIdToString( ?int $id ): string {
		return $id === null ? '' : (string) $id;
	}

	/**
	 * Build an admin URL with query args.
	 *
	 * @param array<string, string> $args Query args.
	 */
	private static function adminPageUrl( array $args ): string {
		$path = 'admin.php';
		$url  = function_exists( 'admin_url' ) ? (string) admin_url( $path ) : 'admin.php';
		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( $args, $url );
		}
		return $url . '?' . http_build_query( $args );
	}

	/**
	 * Build a ranking pagination URL while preserving both ranking states.
	 *
	 * @param array<string,mixed> $query        Current query parameters.
	 * @param string              $pageParam    Page query key.
	 * @param string              $perPageParam Page-size query key.
	 * @param int                 $page         Target page.
	 * @param int                 $perPage      Target page size.
	 */
	private static function rankingPageUrl(
		array $query,
		string $pageParam,
		string $perPageParam,
		int $page,
		int $perPage
	): string {
		$args = array( 'page' => self::MENU_SLUG );

		foreach (
			array(
				self::PRODUCT_PAGE_PARAM,
				self::PRODUCT_PER_PAGE_PARAM,
				self::POST_PAGE_PARAM,
				self::POST_PER_PAGE_PARAM,
			) as $key
		) {
			$value = $query[ $key ] ?? null;
			if ( is_scalar( $value ) && trim( (string) $value ) !== '' ) {
				$args[ $key ] = trim( (string) $value );
			}
		}

		$args[ $pageParam ] = (string) max( 1, $page );
		if ( $perPage !== self::DEFAULT_PER_PAGE ) {
			$args[ $perPageParam ] = (string) $perPage;
		} else {
			unset( $args[ $perPageParam ] );
		}

		return self::adminPageUrl( $args );
	}
}
