<?php
/**
 * Audit Events admin-page renderer.
 *
 * Phase 2 (KI-006 follow-up) server-side renderer for the §10
 * Audit Events admin page documented in
 * `specifications/admin-ui-spec.md` and rooted at the §12.4 /
 * §12.5 REST surfaces. The renderer drives the
 * {@see ListAuditEventsUseCase} (paginated row slice, §12.4) and
 * the {@see GetAuditEventsSummaryUseCase} (per-`audit_kind`
 * histogram + total, §12.5) 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 via
 * {@see \CommerceLinkBuilder\Presentation\Admin\AdminCapability::PAGE_TOKEN_AUDIT}
 * and `wp_die`'s on denial. The renderer just emits HTML.
 *
 * Row safe-summary scrubbing is upheld by the audit-writer
 * contract, NOT by this renderer: the §10.2 `safe_summary` slot
 * is contractually scrubbed by the call site through
 * {@see \CommerceLinkBuilder\Application\Logs\LogMasker} (or
 * equivalent) BEFORE being persisted via the
 * {@see \CommerceLinkBuilder\Application\Audit\AuditEventWriter}
 * port. Re-masking here would risk double-encoding already-
 * redacted tokens. The renderer does still flow every visible
 * value through `esc_html()` so an operator-supplied target id
 * containing markup cannot break out of the table cell.
 *
 * Escaping invariants:
 *
 *   - Every visible text emission flows through `esc_html()`.
 *   - Every attribute value flows through `esc_attr()`.
 *   - Every URL flows through `esc_url()`.
 *
 * Mirrors the {@see \CommerceLinkBuilder\Presentation\Admin\Page\LogsPageRenderer}
 * structure so the §6 Logs admin page and the §10 Audit Events
 * admin page share the same layout idiom (summary card → filter
 * form → results table → pagination nav) and the same
 * "summary failure renders empty state but does not derail the
 * list half" failure posture.
 *
 * @package CommerceLinkBuilder\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Admin\Page;

use CommerceLinkBuilder\Application\Audit\UseCase\GetAuditEventsSummaryUseCase;
use CommerceLinkBuilder\Application\Audit\UseCase\ListAuditEventsUseCase;
use CommerceLinkBuilder\Infrastructure\Persistence\AuditEventListQuery;
use CommerceLinkBuilder\Presentation\Admin\AdminTimeFormatter;

/**
 * Renders the §10 Audit Events admin page to stdout.
 *
 * See the file-level docblock for the page layout (summary card,
 * filter form, results table, pagination), the "audit writer
 * scrubs safe_summary on the persistence boundary" rule that
 * frees this renderer from re-masking, and the escaping
 * invariants every emission honors.
 */
final class AuditPageRenderer {

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

	/**
	 * §12.4 list use case.
	 */
	private ListAuditEventsUseCase $listUseCase;

	/**
	 * §12.5 summary use case.
	 */
	private GetAuditEventsSummaryUseCase $summaryUseCase;

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

	/**
	 * Echo the §10 Audit Events page HTML to stdout.
	 *
	 * Drives the §12.5 summary use case for the headline card,
	 * then the §12.4 list use case for the paginated results
	 * table. A summary failure is swallowed so the list half
	 * still renders; a list failure (typically a driver / schema
	 * problem) bubbles out to the caller because there is no
	 * meaningful page to draw without it.
	 *
	 * @param AuditEventListQuery $query Already-validated list query.
	 */
	public function render( AuditEventListQuery $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 §10 summary card: a small `<table>` listing each
	 * §10.2 audit-kind → row count plus a headline total row.
	 *
	 * 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 §10 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;
		}

		$byKind = $summary->byKind();
		$total  = $summary->total();

		if ( $total === 0 ) {
			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 ( $byKind as $kind => $count ) {
			echo '<tr>';
			echo '<td>' . esc_html( (string) $kind ) . '</td>';
			echo '<td>' . esc_html( (string) (int) $count ) . '</td>';
			echo '</tr>';
		}
		echo '<tr>';
		echo '<th scope="row">' . esc_html( '合計' ) . '</th>';
		echo '<td>' . esc_html( (string) $total ) . '</td>';
		echo '</tr>';
		echo '</tbody></table>';
	}

	/**
	 * Render the §10 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-audit` hidden field keeps the WordPress admin
	 * router pointing at this page across the round-trip.
	 *
	 * Seven filter axes are exposed: the four first-class column
	 * filters (`audit_kind` / `actor_id` / `target_type` /
	 * `target_id`) plus the three Phase 2 KI-006 follow-up
	 * context filters (`request_id` / `correlation_id` /
	 * `job_id`). The persistence layer honors all seven
	 * uniformly through {@see AuditEventListQuery}.
	 *
	 * @param AuditEventListQuery $query Current query (drives selected option state).
	 */
	private function renderFilterForm( AuditEventListQuery $query ): void {
		echo '<form method="get" action="">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '">';

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

		// Audit kind.
		echo '<label for="clb-filter-audit-kind">' . esc_html( '監査種別' ) . '</label> ';
		echo '<select name="' . esc_attr( AuditListQueryParser::KEY_AUDIT_KIND ) . '" id="clb-filter-audit-kind">';
		echo '<option value="">' . esc_html( 'すべて' ) . '</option>';
		foreach ( AuditEventListQuery::validAuditKinds() as $kind ) {
			$selected = $query->auditKind() === $kind ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $kind ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals.
				. $selected
				. '>' . esc_html( $kind ) . '</option>';
		}
		echo '</select> ';

		// Actor id.
		echo '<label for="clb-filter-actor-id">' . esc_html( 'アクター ID' ) . '</label> ';
		echo '<input type="text" name="' . esc_attr( AuditListQueryParser::KEY_ACTOR_ID ) . '"';
		echo ' id="clb-filter-actor-id" value="' . esc_attr( $query->actorId() ?? '' ) . '"> ';

		// Target type.
		echo '<label for="clb-filter-target-type">' . esc_html( '対象種別' ) . '</label> ';
		echo '<input type="text" name="' . esc_attr( AuditListQueryParser::KEY_TARGET_TYPE ) . '"';
		echo ' id="clb-filter-target-type" value="' . esc_attr( $query->targetType() ?? '' ) . '"> ';

		// Target id.
		echo '<label for="clb-filter-target-id">' . esc_html( '対象 ID' ) . '</label> ';
		echo '<input type="text" name="' . esc_attr( AuditListQueryParser::KEY_TARGET_ID ) . '"';
		echo ' id="clb-filter-target-id" value="' . esc_attr( $query->targetId() ?? '' ) . '"> ';

		// Request id (context).
		echo '<label for="clb-filter-request-id">' . esc_html( 'リクエスト ID' ) . '</label> ';
		echo '<input type="text" name="' . esc_attr( AuditListQueryParser::KEY_REQUEST_ID ) . '"';
		echo ' id="clb-filter-request-id" value="' . esc_attr( $query->requestId() ?? '' ) . '"> ';

		// Correlation id (context).
		echo '<label for="clb-filter-correlation-id">' . esc_html( '相関 ID' ) . '</label> ';
		echo '<input type="text" name="' . esc_attr( AuditListQueryParser::KEY_CORRELATION_ID ) . '"';
		echo ' id="clb-filter-correlation-id" value="' . esc_attr( $query->correlationId() ?? '' ) . '"> ';

		// Job id (context).
		echo '<label for="clb-filter-job-id">' . esc_html( 'ジョブ ID' ) . '</label> ';
		echo '<input type="text" name="' . esc_attr( AuditListQueryParser::KEY_JOB_ID ) . '"';
		echo ' id="clb-filter-job-id" value="' . esc_attr( $query->jobId() ?? '' ) . '"> ';

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

		echo '</form>';
	}

	/**
	 * Render the §10 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 projected row.
	 *
	 * The row shape matches the §12.4 envelope row produced by
	 * {@see \CommerceLinkBuilder\Application\Audit\UseCase\ListAuditEventsUseCase::projectRow()}:
	 * `actor` and `target` are `{ type, id }` sub-objects (or
	 * null), and the three context-lifted ids are top-level
	 * string-or-null slots.
	 *
	 * @param array<string, mixed> $row Projected row from the use case.
	 */
	private function renderRow( array $row ): void {
		$id            = $row['id'] ?? null;
		$createdAt     = $row['created_at'] ?? '';
		$auditKind     = $row['audit_kind'] ?? '';
		$eventType     = $row['audit_event_type'] ?? '';
		$action        = $row['action'] ?? '';
		$result        = $row['result'] ?? '';
		$actor         = $row['actor'] ?? null;
		$target        = $row['target'] ?? null;
		$safeSummary   = $row['safe_summary'] ?? '';
		$requestId     = $row['request_id'] ?? null;
		$correlationId = $row['correlation_id'] ?? null;
		$jobId         = $row['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) $auditKind ) . '</td>';
		echo '<td>' . esc_html( (string) $eventType ) . '</td>';
		echo '<td>' . esc_html( (string) $action ) . '</td>';
		echo '<td>' . esc_html( (string) $result ) . '</td>';
		echo '<td>' . esc_html( self::formatActorOrTarget( $actor ) ) . '</td>';
		echo '<td>' . esc_html( self::formatActorOrTarget( $target ) ) . '</td>';
		echo '<td>' . esc_html( (string) $safeSummary ) . '</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 '</tr>';
	}

	/**
	 * Format an `actor` / `target` `{ type, id }` sub-object (or
	 * null) into a single human-readable cell value.
	 *
	 * Layout:
	 *
	 *   - `null` → empty string.
	 *   - `{ type: 'user', id: '42' }` → `"user:42"`.
	 *   - `{ type: 'system', id: null }` → `"system"`.
	 *   - `{ type: null, id: '42' }` → `"42"` (defensive, should
	 *     not happen because the §12.4 projection collapses both-
	 *     null to null in the first place).
	 *
	 * @param mixed $value Raw `actor` / `target` slot from the
	 *                     projected row.
	 */
	private static function formatActorOrTarget( $value ): string {
		if ( ! is_array( $value ) ) {
			return '';
		}

		$type = isset( $value['type'] ) && is_string( $value['type'] ) ? $value['type'] : null;
		$id   = isset( $value['id'] ) && is_string( $value['id'] ) ? $value['id'] : null;

		if ( $type === null && $id === null ) {
			return '';
		}
		if ( $type === null ) {
			return (string) $id;
		}
		if ( $id === null ) {
			return $type;
		}
		return $type . ':' . $id;
	}

	/**
	 * Render the textual `Page X of Y` pagination nav with prev /
	 * next anchors that preserve the current filter state.
	 *
	 * @param AuditEventListQuery $query      Originating query.
	 * @param int                 $totalPages Total page count.
	 */
	private function renderPagination( AuditEventListQuery $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.
	 *
	 * Every non-default filter axis is round-tripped through the
	 * URL so the prev / next links observe the same filter set
	 * the operator just chose; omitting any axis would silently
	 * widen the result set across a page transition.
	 *
	 * @param int                 $page  Target page (>= 1).
	 * @param AuditEventListQuery $query Current query state.
	 */
	private static function buildPageUrl( int $page, AuditEventListQuery $query ): string {
		$args = array(
			'page'                         => self::MENU_SLUG,
			AuditListQueryParser::KEY_PAGE => $page,
		);

		if ( $query->auditKind() !== null ) {
			$args[ AuditListQueryParser::KEY_AUDIT_KIND ] = $query->auditKind();
		}
		if ( $query->actorId() !== null ) {
			$args[ AuditListQueryParser::KEY_ACTOR_ID ] = $query->actorId();
		}
		if ( $query->targetType() !== null ) {
			$args[ AuditListQueryParser::KEY_TARGET_TYPE ] = $query->targetType();
		}
		if ( $query->targetId() !== null ) {
			$args[ AuditListQueryParser::KEY_TARGET_ID ] = $query->targetId();
		}
		if ( $query->requestId() !== null ) {
			$args[ AuditListQueryParser::KEY_REQUEST_ID ] = $query->requestId();
		}
		if ( $query->correlationId() !== null ) {
			$args[ AuditListQueryParser::KEY_CORRELATION_ID ] = $query->correlationId();
		}
		if ( $query->jobId() !== null ) {
			$args[ AuditListQueryParser::KEY_JOB_ID ] = $query->jobId();
		}
		if ( $query->perPage() !== AuditEventListQuery::DEFAULT_PER_PAGE ) {
			$args[ AuditListQueryParser::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, '/' );
	}
}
