<?php
/**
 * Logs admin-page renderer.
 *
 * Phase 11 slice 11f server-side renderer for the §6 Logs admin page
 * documented in `specifications/admin-ui-spec.md`. The renderer drives
 * the {@see ListLogsUseCase} (paginated row slice) and the
 * {@see GetLogsSummaryUseCase} (level histogram) and echoes the page
 * HTML — summary card, 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.
 *
 * Row masking is upheld by the use-case → masker pipeline; rows
 * reaching this renderer have already been projected through
 * {@see \CommerceLinkBuilder\Application\Logs\LogMasker::maskRecord()}
 * so credential-shaped tokens have been replaced with `***`.
 *
 * 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\Logs\UseCase\GetLogsSummaryUseCase;
use CommerceLinkBuilder\Application\Logs\UseCase\ListLogsUseCase;
use CommerceLinkBuilder\Infrastructure\Persistence\LogListQuery;
use CommerceLinkBuilder\Presentation\Admin\AdminTimeFormatter;

/**
 * Renders the §6 Logs admin page to stdout.
 *
 * See the file-level docblock for the page layout (summary card,
 * filter form, results table, pagination), the masker pipeline that
 * pre-scrubs every row, and the escaping invariants.
 */
final class LogsPageRenderer {

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

	/**
	 * §12.2 list use case.
	 */
	private ListLogsUseCase $listUseCase;

	/**
	 * §12.3 summary use case.
	 */
	private GetLogsSummaryUseCase $summaryUseCase;

	/**
	 * Construct the renderer with explicit collaborator injection.
	 *
	 * @param ListLogsUseCase       $listUseCase    §12.2 list use case.
	 * @param GetLogsSummaryUseCase $summaryUseCase §12.3 summary use case.
	 */
	public function __construct(
		ListLogsUseCase $listUseCase,
		GetLogsSummaryUseCase $summaryUseCase
	) {
		$this->listUseCase    = $listUseCase;
		$this->summaryUseCase = $summaryUseCase;
	}

	/**
	 * Echo the §6 Logs page HTML to stdout.
	 *
	 * @param LogListQuery $query Already-validated list query.
	 */
	public function render( LogListQuery $query ): void {
		echo '<div class="wrap">';
		echo '<h1>' . esc_html( 'ログ' ) . '</h1>';

		$this->renderSummary();
		$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 summary card: a small `<table>` listing each
	 * PSR-3 level → row count.
	 *
	 * Failures from the summary use case are caught silently so the
	 * filter / table half of the page still renders. The list use
	 * case is the canonical failure surface for the §6 page.
	 */
	private function renderSummary(): void {
		echo '<h2>' . esc_html( '概要' ) . '</h2>';

		try {
			$summary = $this->summaryUseCase->execute();
		} catch ( \Throwable $e ) {
			unset( $e );
			echo '<p>' . esc_html( '概要は一時的に利用できません。' ) . '</p>';
			return;
		}

		$counts = $summary->counts();
		if ( $counts === array() ) {
			echo '<p>' . esc_html( 'ログはまだ記録されていません。' ) . '</p>';
			return;
		}

		echo '<table class="wp-list-table widefat striped">';
		echo '<thead><tr>';
		foreach ( array( 'レベル', '件数' ) as $heading ) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead>';
		echo '<tbody>';
		foreach ( $counts as $level => $count ) {
			echo '<tr>';
			echo '<td>' . esc_html( (string) $level ) . '</td>';
			echo '<td>' . esc_html( (string) (int) $count ) . '</td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
	}

	/**
	 * Render the §6 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-logs` hidden field keeps the WordPress admin router
	 * pointing at this page across the round-trip.
	 *
	 * @param LogListQuery $query Current query (drives selected option state).
	 */
	private function renderFilterForm( LogListQuery $query ): void {
		echo '<form method="get" action="">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '">';

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

		// Level.
		echo '<label for="clb-filter-level">' . esc_html( 'レベル' ) . '</label> ';
		echo '<select name="' . esc_attr( LogsListQueryParser::KEY_LEVEL ) . '" id="clb-filter-level">';
		echo '<option value="">' . esc_html( 'すべてのレベル' ) . '</option>';
		foreach ( LogListQuery::validLevels() as $level ) {
			$selected = $query->level() === $level ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $level ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals.
				. $selected
				. '>' . esc_html( $level ) . '</option>';
		}
		echo '</select> ';

		// Channel.
		echo '<label for="clb-filter-channel">' . esc_html( 'チャネル' ) . '</label> ';
		echo '<input type="text" name="' . esc_attr( LogsListQueryParser::KEY_CHANNEL ) . '"';
		echo ' id="clb-filter-channel" value="' . esc_attr( $query->channel() ?? '' ) . '"> ';

		// Related product id.
		echo '<label for="clb-filter-related-product">' . esc_html( '関連商品 ID' ) . '</label> ';
		echo '<input type="number" min="1" name="' . esc_attr( LogsListQueryParser::KEY_RELATED_PRODUCT_ID ) . '"';
		echo ' id="clb-filter-related-product" value="'
			. esc_attr( $query->relatedProductId() === null ? '' : (string) $query->relatedProductId() )
			. '"> ';

		// Related job id.
		echo '<label for="clb-filter-related-job">' . esc_html( '関連ジョブ ID' ) . '</label> ';
		echo '<input type="number" min="1" name="' . esc_attr( LogsListQueryParser::KEY_RELATED_JOB_ID ) . '"';
		echo ' id="clb-filter-related-job" value="'
			. esc_attr( $query->relatedJobId() === null ? '' : (string) $query->relatedJobId() )
			. '"> ';

		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',
				'作成日時',
				'チャネル',
				'レベル',
				'イベント種別',
				'メッセージ',
				'リクエスト ID',
				'相関 ID',
				'ジョブ ID',
				'関連商品',
				'関連ジョブ',
			) as $heading
		) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead>';
	}

	/**
	 * Render a single masked row.
	 *
	 * @param array<string, mixed> $row Masked row from the use case.
	 */
	private function renderRow( array $row ): void {
		$id               = $row['id'] ?? null;
		$createdAt        = $row['created_at'] ?? '';
		$channel          = $row['channel'] ?? '';
		$level            = $row['level'] ?? '';
		$eventType        = $row['event_type'] ?? null;
		$message          = $row['message'] ?? '';
		$requestId        = $row['request_id'] ?? null;
		$correlationId    = $row['correlation_id'] ?? null;
		$jobId            = $row['job_id'] ?? null;
		$relatedProductId = $row['related_product_id'] ?? null;
		$relatedJobId     = $row['related_job_id'] ?? null;

		echo '<tr>';
		echo '<td>' . esc_html( $id === null ? '' : (string) $id ) . '</td>';
		echo '<td>' . esc_html( AdminTimeFormatter::format( (string) $createdAt ) ) . '</td>';
		echo '<td>' . esc_html( (string) $channel ) . '</td>';
		echo '<td>' . esc_html( (string) $level ) . '</td>';
		echo '<td>' . esc_html( $eventType === null ? '' : (string) $eventType ) . '</td>';
		echo '<td>' . esc_html( (string) $message ) . '</td>';
		echo '<td>' . esc_html( $requestId === null ? '' : (string) $requestId ) . '</td>';
		echo '<td>' . esc_html( $correlationId === null ? '' : (string) $correlationId ) . '</td>';
		echo '<td>' . esc_html( $jobId === null ? '' : (string) $jobId ) . '</td>';
		echo '<td>' . esc_html( $relatedProductId === null ? '' : (string) $relatedProductId ) . '</td>';
		echo '<td>' . esc_html( $relatedJobId === null ? '' : (string) $relatedJobId ) . '</td>';
		echo '</tr>';
	}

	/**
	 * Render the textual `Page X of Y` pagination nav with prev /
	 * next anchors that preserve the current filter state.
	 *
	 * @param LogListQuery $query      Originating query.
	 * @param int          $totalPages Total page count.
	 */
	private function renderPagination( LogListQuery $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 LogListQuery $query Current query state.
	 */
	private static function buildPageUrl( int $page, LogListQuery $query ): string {
		$args = array(
			'page'                        => self::MENU_SLUG,
			LogsListQueryParser::KEY_PAGE => $page,
		);

		if ( $query->level() !== null ) {
			$args[ LogsListQueryParser::KEY_LEVEL ] = $query->level();
		}
		if ( $query->channel() !== null ) {
			$args[ LogsListQueryParser::KEY_CHANNEL ] = $query->channel();
		}
		if ( $query->relatedProductId() !== null ) {
			$args[ LogsListQueryParser::KEY_RELATED_PRODUCT_ID ] = $query->relatedProductId();
		}
		if ( $query->relatedJobId() !== null ) {
			$args[ LogsListQueryParser::KEY_RELATED_JOB_ID ] = $query->relatedJobId();
		}
		if ( $query->perPage() !== LogListQuery::DEFAULT_PER_PAGE ) {
			$args[ LogsListQueryParser::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, '/' );
	}
}
