<?php
/**
 * Sync Jobs admin-page renderer.
 *
 * Phase 11 slice 11f server-side renderer for the §6 Sync Jobs admin
 * page documented in `specifications/admin-ui-spec.md`. The renderer
 * drives {@see ListSyncJobsUseCase} (paginated row slice — items already
 * carry wire-vocabulary status per §6.0) and echoes the page HTML —
 * filter form, paginated results table, empty-state message, and
 * pagination nav — into the admin output stream.
 *
 * Capability gating is NOT performed by this renderer — the
 * page-render closure in
 * {@see \CommerceLinkBuilder\Presentation\Admin\AdminServiceProvider}
 * re-checks the §3 admin-tier capability and `wp_die`'s on denial.
 * The renderer just emits HTML.
 *
 * Wire ⇔ DB status: rows reaching this renderer have already been
 * projected through {@see ListSyncJobsUseCase}'s DB → wire `pending`
 * → `queued` substitution, so the table's `status` column carries
 * wire vocabulary verbatim. The filter form's status `<select>` also
 * speaks wire vocabulary; {@see SyncJobsListQueryParser} performs
 * the inverse `queued` → `pending` translation when constructing the
 * query VO.
 *
 * 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\SyncJobs\UseCase\ListSyncJobsUseCase;
use CommerceLinkBuilder\Infrastructure\Persistence\SyncJobListQuery;
use CommerceLinkBuilder\Presentation\Admin\AdminTimeFormatter;

/**
 * Renders the §6 Sync Jobs admin page to stdout.
 *
 * See the file-level docblock for the page layout (filter form,
 * results table, pagination), the wire ⇔ DB status posture, and the
 * escaping invariants.
 */
final class SyncJobsPageRenderer {

	/**
	 * Admin menu slug the filter form / pagination links point at.
	 */
	private const MENU_SLUG = 'clb-sync-jobs';

	/**
	 * Wire-vocabulary statuses surfaced in the filter `<select>` per
	 * `specifications/rest-api-spec.md` §6.0. The DB-vocabulary
	 * `pending` is rewritten to wire `queued` here so the dropdown
	 * matches the table's status column.
	 *
	 * @var list<string>
	 */
	private const WIRE_STATUSES = array(
		'queued',
		'running',
		'completed',
		'failed',
		'cancelled',
	);

	/**
	 * §11.3 list use case.
	 */
	private ListSyncJobsUseCase $listUseCase;

	/**
	 * Construct the renderer with explicit collaborator injection.
	 *
	 * @param ListSyncJobsUseCase $listUseCase §11.3 list use case.
	 */
	public function __construct( ListSyncJobsUseCase $listUseCase ) {
		$this->listUseCase = $listUseCase;
	}

	/**
	 * Echo the §6 Sync Jobs page HTML to stdout.
	 *
	 * @param SyncJobListQuery $query Already-validated list query.
	 */
	public function render( SyncJobListQuery $query ): void {
		echo '<div class="wrap">';
		echo '<h1>' . esc_html( '同期ジョブ' ) . '</h1>';

		$this->renderFilterForm( $query );

		$result = $this->listUseCase->execute( $query );

		if ( $result->items() === array() ) {
			echo '<p>' . esc_html( '条件に一致する同期ジョブはありません。' ) . '</p>';
			echo '</div>';
			return;
		}

		echo '<table class="wp-list-table widefat fixed striped">';
		$this->renderTableHead();
		echo '<tbody>';
		foreach ( $result->items() as $row ) {
			$this->renderRow( $row );
		}
		echo '</tbody>';
		echo '</table>';

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

		echo '</div>';
	}

	/**
	 * Render the §6 filter form (status / job_type selects).
	 *
	 * The form submits via GET back to the same admin page so the
	 * parsed query state survives a page reload; the implicit
	 * `page=clb-sync-jobs` hidden field keeps the WordPress admin
	 * router pointing at this page across the round-trip.
	 *
	 * @param SyncJobListQuery $query Current query (drives selected option state).
	 */
	private function renderFilterForm( SyncJobListQuery $query ): void {
		$selectedStatus = $query->status();
		// Round-trip the DB `pending` back to wire `queued` for the
		// dropdown so the visible selected option matches the
		// dropdown's wire-vocabulary labels.
		if ( $selectedStatus === 'pending' ) {
			$selectedStatus = 'queued';
		}

		echo '<form method="get" action="">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '">';

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

		// Status.
		echo '<label for="clb-filter-status">' . esc_html( 'ステータス' ) . '</label> ';
		echo '<select name="' . esc_attr( SyncJobsListQueryParser::KEY_STATUS ) . '" id="clb-filter-status">';
		echo '<option value="">' . esc_html( 'すべて' ) . '</option>';
		foreach ( self::WIRE_STATUSES as $status ) {
			$selected = $selectedStatus === $status ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $status ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals.
				. $selected
				. '>' . esc_html( $status ) . '</option>';
		}
		echo '</select> ';

		// Job type.
		echo '<label for="clb-filter-job-type">' . esc_html( 'ジョブ種別' ) . '</label> ';
		echo '<select name="' . esc_attr( SyncJobsListQueryParser::KEY_JOB_TYPE ) . '" id="clb-filter-job-type">';
		echo '<option value="">' . esc_html( 'すべて' ) . '</option>';
		foreach ( SyncJobListQuery::validJobTypes() as $jobType ) {
			$selected = $query->jobType() === $jobType ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $jobType ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals.
				. $selected
				. '>' . esc_html( $jobType ) . '</option>';
		}
		echo '</select> ';

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

		echo '</form>';
	}

	/**
	 * Render the §6 results table `<thead>`.
	 */
	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 row.
	 *
	 * @param array<string, mixed> $row Wire-shape row from the use case.
	 */
	private function renderRow( array $row ): void {
		$id          = $row['id'] ?? null;
		$jobType     = $row['job_type'] ?? '';
		$status      = $row['status'] ?? '';
		$priority    = $row['priority'] ?? null;
		$attempts    = $row['attempts'] ?? 0;
		$createdAt   = $row['created_at'] ?? null;
		$availableAt = $row['available_at'] ?? null;
		$finishedAt  = $row['finished_at'] ?? null;
		$lastError   = $row['last_error'] ?? null;

		echo '<tr>';
		echo '<td>' . esc_html( $id === null ? '' : (string) $id ) . '</td>';
		echo '<td>' . esc_html( (string) $jobType ) . '</td>';
		echo '<td>' . esc_html( (string) $status ) . '</td>';
		echo '<td>' . esc_html( $priority === null ? '' : (string) (int) $priority ) . '</td>';
		echo '<td>' . esc_html( (string) (int) $attempts ) . '</td>';
		echo '<td>' . esc_html( AdminTimeFormatter::format( $createdAt === null ? null : (string) $createdAt ) ) . '</td>';
		echo '<td>' . esc_html( AdminTimeFormatter::format( $availableAt === null ? null : (string) $availableAt ) ) . '</td>';
		echo '<td>' . esc_html( AdminTimeFormatter::format( $finishedAt === null ? null : (string) $finishedAt ) ) . '</td>';
		echo '<td>' . esc_html( $lastError === null ? '' : (string) $lastError ) . '</td>';
		echo '</tr>';
	}

	/**
	 * Render the textual `Page X of Y` pagination nav with prev /
	 * next anchors that preserve the current filter state.
	 *
	 * @param SyncJobListQuery $query      Originating query.
	 * @param int              $totalPages Total page count.
	 */
	private function renderPagination( SyncJobListQuery $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.
	 *
	 * The `status` axis is rewritten back to wire vocabulary
	 * (`pending` → `queued`) before being emitted into the URL so
	 * the round-trip reaches {@see SyncJobsListQueryParser} in the
	 * same wire shape the original filter form submitted.
	 *
	 * 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 SyncJobListQuery $query Current query state.
	 */
	private static function buildPageUrl( int $page, SyncJobListQuery $query ): string {
		$args = array(
			'page'                            => self::MENU_SLUG,
			SyncJobsListQueryParser::KEY_PAGE => $page,
		);

		$status = $query->status();
		if ( $status !== null ) {
			if ( $status === 'pending' ) {
				$status = 'queued';
			}
			$args[ SyncJobsListQueryParser::KEY_STATUS ] = $status;
		}
		if ( $query->jobType() !== null ) {
			$args[ SyncJobsListQueryParser::KEY_JOB_TYPE ] = $query->jobType();
		}
		if ( $query->perPage() !== SyncJobListQuery::DEFAULT_PER_PAGE ) {
			$args[ SyncJobsListQueryParser::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, '/' );
	}
}
