<?php
/**
 * Products list-page renderer.
 *
 * Phase-8 slice 8d server-side renderer for the §6 Products list
 * admin page documented in `specifications/admin-ui-spec.md`. The
 * renderer echoes the page HTML — filter form, paginated results
 * table, empty-state message, and pagination nav — into the admin
 * output stream and is invoked from the page-render closure wired
 * by {@see \CommerceLinkBuilder\Presentation\Admin\AdminServiceProvider}.
 *
 * Capability gating is NOT performed by this renderer — the
 * page-render closure in the service provider re-checks the §3
 * editor-tier capability via {@see AdminCapability::CAP_VIEW_PRODUCTS}
 * and `wp_die`'s on denial. The renderer just emits HTML.
 *
 * Phase-8 minimum scope (intentional omissions):
 *
 *   - Row actions are intentionally narrow: this page only supports
 *     logical delete, leaving View / Edit Metadata / Sync Now / bulk
 *     actions for later admin slices.
 *   - Product usage is resolved by scanning post content for CLB
 *     Gutenberg block attributes on the current page of products.
 *
 * Result composition:
 *
 *   - The renderer calls {@see ListProductsUseCase::execute()} with
 *     the supplied query and walks the resulting
 *     {@see ProductListResult}'s items, routing each
 *     {@see ProductRecord} through {@see ProductSummaryMapper::map()}
 *     to derive the row's display shape. Every visible field flows
 *     through `esc_html()` / `esc_attr()` so a malicious title /
 *     brand cannot escape the surrounding markup.
 *
 *   - When the result is empty the renderer emits the §6.7
 *     `No products found.` message plus an inline note that the
 *     "Add New Product" CTA lands in a future slice.
 *
 * Filter form:
 *
 *   - GET form submitting back to `admin.php?page=clb-products`
 *     so the parsed query state survives a page reload.
 *   - Provider / sync-state / sort / order are rendered as
 *     `<select>` elements driven by the {@see ProductListQuery}
 *     closed-set whitelists.
 *   - Search is a free-form text input.
 *
 * Pagination:
 *
 *   - Phase-8 minimum: textual `Page X of Y` plus prev / next links
 *     that preserve the current filter state via
 *     {@see self::buildPageUrl()}.
 *   - When the result spans a single page the renderer emits the
 *     `Page X of Y` text without prev / next anchors.
 *
 * Escaping invariants:
 *
 *   - Every visible text emission flows through `esc_html()`.
 *   - Every attribute value flows through `esc_attr()`.
 *   - Every URL flows through `esc_url()`.
 *
 * @package CommerceLinkBuilder\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Admin\Page;

use CommerceLinkBuilder\Application\Product\UseCase\ListProductsUseCase;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductListQuery;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRecord;
use CommerceLinkBuilder\Presentation\Admin\AdminTimeFormatter;
use CommerceLinkBuilder\Presentation\Rest\Mapper\ProductSummaryMapper;

/**
 * Renders the §6 Products list admin page to stdout.
 *
 * See the file-level docblock for the Phase-8 minimum scope, the
 * filter / pagination behaviour, and the escaping invariants.
 */
final class ProductsListPageRenderer {

	/**
	 * Admin menu slug the filter form submits back to.
	 */
	private const MENU_SLUG = 'clb-products';

	/**
	 * Closed list of providers offered in the filter dropdown.
	 *
	 * Keyed by the wire token; values are the human-readable label.
	 *
	 * @var array<string, string>
	 */
	private const PROVIDERS = array(
		'amazon'         => 'Amazon',
		'rakuten'        => 'Rakuten',
		'yahoo_shopping' => 'Yahoo Shopping',
	);

	/**
	 * §8.2 list-products use case.
	 */
	private ListProductsUseCase $listUseCase;

	/**
	 * §6.1 row-shape mapper.
	 */
	private ProductSummaryMapper $summaryMapper;

	/**
	 * Resolves post usage for products on the current page.
	 */
	private PostProductUsageResolver $usageResolver;

	/**
	 * Construct the renderer with explicit collaborators.
	 *
	 * @param ListProductsUseCase           $listUseCase   §8.2 list use case.
	 * @param ProductSummaryMapper          $summaryMapper §6.1 row mapper.
	 * @param PostProductUsageResolver|null $usageResolver Optional product usage resolver.
	 */
	public function __construct(
		ListProductsUseCase $listUseCase,
		ProductSummaryMapper $summaryMapper,
		?PostProductUsageResolver $usageResolver = null
	) {
		$this->listUseCase   = $listUseCase;
		$this->summaryMapper = $summaryMapper;
		$this->usageResolver = $usageResolver ?? new PostProductUsageResolver();
	}

	/**
	 * Echo the §6 Products list page HTML to stdout.
	 *
	 * @param ProductListQuery $query Already-validated list query.
	 */
	public function render( ProductListQuery $query ): void {
		$result = $this->listUseCase->execute( $query );

		echo '<div class="wrap">';
		echo '<h1>' . esc_html( '商品一覧' ) . '</h1>';

		$this->renderFilterForm( $query );

		if ( $result->isEmpty() ) {
			$this->renderEmptyState();
			echo '</div>';
			return;
		}

		$records        = $result->items();
		$productIds     = self::recordIds( $records );
		$usageByProduct = $this->usageResolver->resolveForProductIds( $productIds );

		echo '<table class="wp-list-table widefat fixed striped">';
		$this->renderTableHead();
		echo '<tbody>';
		foreach ( $records as $record ) {
			$id    = $record->id();
			$usage = $id === null ? array() : ( $usageByProduct[ $id ] ?? array() );
			$this->renderRow( $record, $usage );
		}
		echo '</tbody>';
		echo '</table>';

		$this->renderPagination( $query, $result->totalPages() );

		echo '</div>';
	}

	/**
	 * Extract persisted ids from product records.
	 *
	 * @param list<ProductRecord> $records Product records.
	 *
	 * @return list<int>
	 */
	private static function recordIds( array $records ): array {
		$ids = array();
		foreach ( $records as $record ) {
			$id = $record->id();
			if ( $id !== null ) {
				$ids[] = $id;
			}
		}

		return $ids;
	}

	/**
	 * Render the §6.4 filter form.
	 *
	 * The form submits via GET back to the same admin page so the
	 * parsed query state survives a page reload; the implicit
	 * `page=clb-products` hidden field keeps the WordPress admin
	 * router pointing at this page across the round-trip.
	 *
	 * @param ProductListQuery $query Current query (drives the
	 *                                pre-selected option state).
	 */
	private function renderFilterForm( ProductListQuery $query ): void {
		echo '<form method="get" action="">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '">';

		echo '<p class="search-box">';

		// Provider.
		echo '<label for="clb-filter-provider">' . esc_html( 'プロバイダー' ) . '</label> ';
		echo '<select name="' . esc_attr( ProductsListQueryParser::KEY_PROVIDER ) . '" id="clb-filter-provider">';
		echo '<option value="">' . esc_html( 'すべて' ) . '</option>';
		foreach ( self::PROVIDERS as $key => $label ) {
			$selected = $query->provider() === $key ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $key ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals (' selected="selected"' or ''); no user input is interpolated.
				. $selected
				. '>' . esc_html( $label ) . '</option>';
		}
		echo '</select> ';

		// Sync state.
		echo '<label for="clb-filter-sync-state">' . esc_html( '同期状態' ) . '</label> ';
		echo '<select name="' . esc_attr( ProductsListQueryParser::KEY_SYNC_STATE ) . '" id="clb-filter-sync-state">';
		echo '<option value="">' . esc_html( 'すべて' ) . '</option>';
		foreach ( ProductListQuery::validSyncStates() as $state ) {
			$selected = $query->syncState() === $state ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $state ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals.
				. $selected
				. '>' . esc_html( $state ) . '</option>';
		}
		echo '</select> ';

		// Search.
		echo '<label for="clb-filter-search">' . esc_html( '検索' ) . '</label> ';
		echo '<input type="search" name="' . esc_attr( ProductsListQueryParser::KEY_SEARCH ) . '"';
		echo ' id="clb-filter-search" value="' . esc_attr( $query->search() ?? '' ) . '"> ';

		// Sort.
		echo '<label for="clb-filter-sort">' . esc_html( '並び順' ) . '</label> ';
		echo '<select name="' . esc_attr( ProductsListQueryParser::KEY_SORT ) . '" id="clb-filter-sort">';
		$sortLabels = array(
			'updated_at'     => '更新日時',
			'created_at'     => '作成日時',
			'title'          => '商品名',
			'last_synced_at' => '最終同期日時',
		);
		foreach ( ProductListQuery::validSortKeys() as $sortKey ) {
			$selected = $query->sortKey() === $sortKey ? ' selected="selected"' : '';
			$label    = $sortLabels[ $sortKey ] ?? $sortKey;
			echo '<option value="' . esc_attr( $sortKey ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals.
				. $selected
				. '>' . esc_html( $label ) . '</option>';
		}
		echo '</select> ';

		// Order.
		echo '<label for="clb-filter-order">' . esc_html( '順番' ) . '</label> ';
		echo '<select name="' . esc_attr( ProductsListQueryParser::KEY_ORDER ) . '" id="clb-filter-order">';
		$orderLabels = array(
			'desc' => '降順',
			'asc'  => '昇順',
		);
		foreach ( ProductListQuery::validSortOrders() as $order ) {
			$selected = $query->sortOrder() === $order ? ' selected="selected"' : '';
			$label    = $orderLabels[ $order ] ?? $order;
			echo '<option value="' . esc_attr( $order ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals.
				. $selected
				. '>' . esc_html( $label ) . '</option>';
		}
		echo '</select> ';

		echo '<button type="submit" class="button">' . esc_html( '絞り込み' ) . '</button>';
		echo '</p>';

		echo '</form>';
	}

	/**
	 * Render the §6.2 results-table `<thead>`.
	 *
	 * Phase-8 minimum: ID / Provider / Title / Brand / Price /
	 * Used Posts / Sync State / Last Synced / Updated At / Actions.
	 */
	private function renderTableHead(): void {
		echo '<thead><tr>';
		foreach (
			array(
				'ID',
				'プロバイダー',
				'商品名',
				'ブランド',
				'価格',
				'利用ポスト',
				'同期状態',
				'最終同期日時',
				'更新日時',
				'操作',
			) as $heading
		) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead>';
	}

	/**
	 * Render a single product row by routing the record through
	 * {@see ProductSummaryMapper::map()} for shape normalization
	 * and escaping every visible field.
	 *
	 * @param ProductRecord $record     Persisted record.
	 * @param array         $usagePosts Posts using this product.
	 *
	 * @phpstan-param list<array{id:int,title:string,url:string,type:string,status:string}> $usagePosts
	 */
	private function renderRow( ProductRecord $record, array $usagePosts ): void {
		$row = $this->summaryMapper->map( $record, null );

		$id           = $row['id'] ?? null;
		$provider     = (string) ( $row['provider'] ?? '' );
		$title        = (string) ( $row['title'] ?? '' );
		$brand        = $row['brand'] ?? null;
		$syncState    = (string) ( $row['sync_state'] ?? '' );
		$lastSyncedAt = $row['last_synced_at'] ?? null;
		$imageUrl     = $record->imagePrimaryUrl();
		$isDeleted    = $record->lifecycleState() === ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING;
		$productUrl   = $record->affiliateUrl() ?? $record->detailUrl();

		$priceLabel = '';
		if ( is_array( $row['price'] ?? null ) ) {
			$price       = $row['price'];
			$labelString = is_string( $price['label'] ?? null ) ? $price['label'] : null;
			$amount      = is_string( $price['amount'] ?? null ) ? $price['amount'] : null;
			$currency    = is_string( $price['currency'] ?? null ) ? $price['currency'] : null;

			if ( $labelString !== null && $labelString !== '' ) {
				$priceLabel = $labelString;
			} elseif ( $amount !== null ) {
				$priceLabel = trim( ( $currency ?? '' ) . ' ' . $amount );
			}
		}

		$updatedAt           = AdminTimeFormatter::format(
			gmdate( 'Y-m-d\TH:i:s\Z', $record->updatedAt()->getTimestamp() )
		);
		$lastSyncedAtDisplay = AdminTimeFormatter::format(
			$lastSyncedAt === null ? null : (string) $lastSyncedAt
		);

		$idDisplay = $id === null ? '' : (string) $id;
		if ( $idDisplay !== '' && $isDeleted ) {
			$idDisplay .= '(削除済み)';
		}

		echo '<tr>';
		echo '<td>' . esc_html( $idDisplay ) . '</td>';
		echo '<td>' . esc_html( $provider ) . '</td>';
		echo '<td>';
		if ( $imageUrl !== null && $imageUrl !== '' ) {
			echo '<img src="' . esc_url( $imageUrl ) . '"'
				. ' alt="" loading="lazy" width="48" height="48"'
				. ' style="width:48px;height:48px;object-fit:contain;'
				. 'vertical-align:middle;margin-right:8px;border:1px solid #e5e5e5;'
				. 'background:#fff;">';
		}
		echo '<a href="' . esc_url( $productUrl ) . '" target="_blank" rel="noopener noreferrer">'
			. esc_html( $title ) . '</a>';
		echo '</td>';
		echo '<td>' . esc_html( $brand === null ? '' : (string) $brand ) . '</td>';
		echo '<td>' . esc_html( $priceLabel ) . '</td>';
		echo '<td>';
		$this->renderUsagePosts( $usagePosts );
		echo '</td>';
		echo '<td>' . esc_html( $syncState ) . '</td>';
		echo '<td>' . esc_html( $lastSyncedAtDisplay ) . '</td>';
		echo '<td>' . esc_html( $updatedAt ) . '</td>';
		echo '<td>';
		$this->renderDeleteAction( $id, $isDeleted );
		echo '</td>';
		echo '</tr>';
	}

	/**
	 * Render posts that use the product.
	 *
	 * @param array $usagePosts Posts using this product.
	 *
	 * @phpstan-param list<array{id:int,title:string,url:string,type:string,status:string}> $usagePosts
	 */
	private function renderUsagePosts( array $usagePosts ): void {
		if ( $usagePosts === array() ) {
			echo '&mdash;';
			return;
		}

		echo '<ul style="margin:0;padding-left:1.2em;">';
		foreach ( $usagePosts as $post ) {
			$label = sprintf( '%s (#%d)', $post['title'], $post['id'] );
			echo '<li><a href="' . esc_url( $post['url'] ) . '">'
				. esc_html( $label ) . '</a>';
			if ( $post['status'] !== '' ) {
				echo ' <span class="description">' . esc_html( $post['status'] ) . '</span>';
			}
			echo '</li>';
		}
		echo '</ul>';
	}

	/**
	 * Render the logical-delete action for one row.
	 *
	 * @param mixed $id        Product id from the mapped row.
	 * @param bool  $isDeleted Whether the row is already logically deleted.
	 */
	private function renderDeleteAction( mixed $id, bool $isDeleted ): void {
		if ( ! is_int( $id ) || $id <= 0 ) {
			echo '&mdash;';
			return;
		}

		if ( $isDeleted ) {
			echo esc_html( '削除済み' );
			return;
		}

		echo '<form method="post" action="'
			. esc_url( self::resolveAdminUrl( 'admin-post.php' ) )
			. '" style="margin:0;">';
		echo '<input type="hidden" name="action" value="clb_delete_product">';
		echo '<input type="hidden" name="product_id" value="' . esc_attr( (string) $id ) . '">';
		self::renderNonceField( 'clb_delete_product_' . $id, '_clb_delete_product_nonce' );
		echo '<button type="submit" class="button button-small" onclick="return confirm(\''
			. esc_attr( 'この商品を削除済みにしますか？' )
			. '\');">' . esc_html( '削除' ) . '</button>';
		echo '</form>';
	}

	/**
	 * Emit a WordPress nonce field when available.
	 *
	 * @param string $action Nonce action.
	 * @param string $name   Nonce field name.
	 */
	private static function renderNonceField( string $action, string $name ): void {
		if ( function_exists( 'wp_nonce_field' ) ) {
			wp_nonce_field( $action, $name );
			return;
		}

		echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="">';
	}

	/**
	 * Render the §6.7 empty-state message.
	 *
	 * Phase-8 minimum carries no "Add New Product" CTA — the
	 * Add New page lands in slice 8e or Phase 9.
	 */
	private function renderEmptyState(): void {
		echo '<p>';
		echo esc_html( '商品が見つかりませんでした。' );
		echo ' <em>' . esc_html( '「Add New」画面やブロックエディターから商品を保存できます。' ) . '</em>';
		echo '</p>';
	}

	/**
	 * Render the textual `Page X of Y` pagination nav with prev /
	 * next anchors that preserve the current filter state.
	 *
	 * @param ProductListQuery $query      Originating query.
	 * @param int              $totalPages Total page count.
	 */
	private function renderPagination( ProductListQuery $query, int $totalPages ): void {
		$current = $query->page();

		echo '<div class="tablenav"><div class="tablenav-pages">';
		echo '<span class="displaying-num">';
		echo esc_html( sprintf( '%d / %d ページ', $current, max( 1, $totalPages ) ) );
		echo '</span>';

		if ( $totalPages > 1 ) {
			echo ' <span class="pagination-links">';

			if ( $current > 1 ) {
				$prevUrl = self::buildPageUrl( $current - 1, $query );
				echo '<a class="prev-page button" href="' . esc_url( $prevUrl ) . '">'
					. esc_html( '« 前へ' ) . '</a> ';
			}

			if ( $current < $totalPages ) {
				$nextUrl = self::buildPageUrl( $current + 1, $query );
				echo '<a class="next-page button" href="' . esc_url( $nextUrl ) . '">'
					. esc_html( '次へ »' ) . '</a>';
			}

			echo '</span>';
		}

		echo '</div></div>';
	}

	/**
	 * Build the per-page URL preserving the current filter state.
	 *
	 * Prefers `add_query_arg()` (the WordPress helper handles
	 * existing query-string fragments correctly) and falls back to
	 * manual `http_build_query()` concatenation in pure-unit
	 * harnesses where WordPress is not loaded.
	 *
	 * @param int              $page  Target page (>= 1).
	 * @param ProductListQuery $query Current query state.
	 */
	private static function buildPageUrl( int $page, ProductListQuery $query ): string {
		$args = array(
			'page'                            => self::MENU_SLUG,
			ProductsListQueryParser::KEY_PAGE => $page,
		);

		if ( $query->provider() !== null ) {
			$args[ ProductsListQueryParser::KEY_PROVIDER ] = $query->provider();
		}
		if ( $query->syncState() !== null ) {
			$args[ ProductsListQueryParser::KEY_SYNC_STATE ] = $query->syncState();
		}
		if ( $query->search() !== null ) {
			$args[ ProductsListQueryParser::KEY_SEARCH ] = $query->search();
		}
		$args[ ProductsListQueryParser::KEY_SORT ]  = $query->sortKey();
		$args[ ProductsListQueryParser::KEY_ORDER ] = $query->sortOrder();
		if ( $query->perPage() !== 20 ) {
			$args[ ProductsListQueryParser::KEY_PER_PAGE ] = $query->perPage();
		}

		$base = self::resolveAdminUrl( 'admin.php' );

		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( $args, $base );
		}

		$separator = str_contains( $base, '?' ) ? '&' : '?';
		return $base . $separator . http_build_query( $args );
	}

	/**
	 * Resolve an admin URL via `admin_url()` when available,
	 * falling back to a synthetic absolute URL in pure-unit
	 * harnesses where WordPress is not loaded so `esc_url()` does
	 * not strip the value.
	 *
	 * @param string $path Admin path (e.g. `'admin.php'`).
	 */
	private static function resolveAdminUrl( string $path ): string {
		if ( function_exists( 'admin_url' ) ) {
			return (string) admin_url( $path );
		}

		return 'http://example.test/wp-admin/' . ltrim( $path, '/' );
	}
}
