<?php
/**
 * Diagnostics admin-page renderer.
 *
 * Phase 11 slice 11f server-side renderer for the §13 Diagnostics
 * admin page documented in `specifications/admin-ui-spec.md`. The
 * renderer drives the {@see GetDiagnosticsUseCase}, projects the
 * resulting {@see DiagnosticsResult} into the §13 admin layout
 * (Environment / Database / Providers / Jobs / Observability /
 * Entry points), and echoes the page HTML into the admin output
 * stream.
 *
 * Capability gating is NOT performed by this renderer — the
 * page-render closure in
 * {@see \Affilun\Presentation\Admin\AdminServiceProvider}
 * re-checks the §3 admin-tier capability via
 * {@see \Affilun\Presentation\Admin\AdminCapability::CAP_FALLBACK_MANAGE}
 * and `wp_die`'s on denial. The renderer just emits HTML.
 *
 * Failure posture: the use case may throw on a misconfigured
 * persistence stack or a runtime probe error. The renderer catches
 * every {@see \Throwable} and substitutes a `<div class="notice
 * notice-error">` carrying a generic developer-safe message — no
 * exception text reaches the rendered HTML so secrets / file paths
 * do not leak to admins.
 *
 * Escaping invariants:
 *
 *   - Every visible text emission flows through `esc_html()`.
 *   - Every attribute value flows through `esc_attr()`.
 *   - Every URL flows through `esc_url()`.
 *
 * §12.1 注意 ("secrets MUST NOT appear in any field") is upheld by
 * the use-case → result-VO contract; the renderer does not scan
 * field values for credential shapes itself.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Application\Diagnostics\UseCase\DiagnosticsResult;
use Affilun\Application\Diagnostics\UseCase\GetDiagnosticsUseCase;
use Throwable;

/**
 * Renders the §13 Diagnostics admin page to stdout.
 *
 * See the file-level docblock for the page layout, the failure
 * posture, and the escaping invariants.
 */
final class DiagnosticsPageRenderer {

	/**
	 * Settings page URL carrying the Operations tab hash.
	 */
	private const OPERATIONS_SETTINGS_PATH = 'options-general.php?page=affilun-settings#affilun-settings-tab-operations';

	/**
	 * Japanese label map for diagnostic environment / database /
	 * observability keys. Keys not in this map fall back to the
	 * raw key string so a future field still surfaces (best-effort
	 * UX rather than a hard whitelist).
	 *
	 * @var array<string, string>
	 */
	private const FIELD_LABELS = array(
		// Environment.
		'php_version'            => 'PHP バージョン',
		'wp_version'             => 'WordPress バージョン',
		'rest_available'         => 'REST API 利用可',
		'object_cache_available' => '外部オブジェクトキャッシュ',
		'cron_healthy'           => 'WP-Cron 健全性',
		// Database.
		'schema_version'         => 'スキーマバージョン',
		'tables_ok'              => 'テーブル初期化完了',
		// Observability.
		'logs_available'         => 'ログ取得可',
		'audit_available'        => '監査イベント取得可',
		'request_id_tracing'     => 'request_id トレーシング',
		'correlation_id_tracing' => 'correlation_id トレーシング',
	);

	/**
	 * §12.1 use case driving the page content.
	 */
	private GetDiagnosticsUseCase $useCase;

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

	/**
	 * Echo the §13 Diagnostics admin page HTML to stdout.
	 */
	public function render(): void {
		echo '<div class="wrap">';
		echo '<h1>' . esc_html( '診断情報' ) . '</h1>';
		$this->renderEmbedded();
		echo '</div>';
	}

	/**
	 * Echo diagnostics content without the outer admin-page wrapper.
	 */
	public function renderEmbedded(): void {
		try {
			$result = $this->useCase->execute();
		} catch ( Throwable $e ) {
			unset( $e );
			$this->renderErrorNotice();
			return;
		}

		$this->renderEnvironment( $result );
		$this->renderDatabase( $result );
		$this->renderProviders( $result );
		$this->renderJobs( $result );
		$this->renderObservability( $result );
		$this->renderEntryPoints( $result );
	}

	/**
	 * Render the §13 generic-error notice when the use case throws.
	 *
	 * The message is deliberately generic so a misconfigured
	 * persistence stack does not leak exception text (which can
	 * contain file paths / SQL fragments / credential shapes) into
	 * the admin output.
	 */
	private function renderErrorNotice(): void {
		echo '<div class="notice notice-error"><p>';
		echo esc_html( '診断情報を取得できません。ログをご確認ください。' );
		echo '</p></div>';
	}

	/**
	 * Render the §13 Environment definition list.
	 *
	 * @param DiagnosticsResult $result Backing result VO.
	 */
	private function renderEnvironment( DiagnosticsResult $result ): void {
		echo '<h2>' . esc_html( '動作環境' ) . '</h2>';
		$this->renderInlineList( $result->environment() );
	}

	/**
	 * Render the §13 Database definition list.
	 *
	 * @param DiagnosticsResult $result Backing result VO.
	 */
	private function renderDatabase( DiagnosticsResult $result ): void {
		echo '<h2>' . esc_html( 'データベース' ) . '</h2>';
		$this->renderInlineList( $result->database() );
	}

	/**
	 * Render a key/value map as an inline `<ul>` so each row reads
	 * `<label>: <value>` on a single line (no `<dt>`/`<dd>` line
	 * break). Boolean values render as はい / いいえ for the
	 * Japanese surface; null falls back to an empty string.
	 *
	 * @param iterable<string, mixed> $map Field key → scalar value map.
	 */
	private function renderInlineList( iterable $map ): void {
		echo '<ul style="margin:0 0 1em 1em;">';
		foreach ( $map as $key => $value ) {
			$label = self::FIELD_LABELS[ (string) $key ] ?? (string) $key;
			echo '<li>' . esc_html( $label . ': ' . self::scalarToString( $value ) ) . '</li>';
		}
		echo '</ul>';
	}

	/**
	 * Render the §13 Providers `<table>` (or empty-state message).
	 *
	 * @param DiagnosticsResult $result Backing result VO.
	 */
	private function renderProviders( DiagnosticsResult $result ): void {
		echo '<h2>' . esc_html( 'プロバイダー' ) . '</h2>';

		$providers = $result->providers();
		if ( $providers === 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 ( $providers as $row ) {
			$key         = isset( $row['key'] ) ? (string) $row['key'] : '';
			$configured  = ! empty( $row['configured'] );
			$lastSuccess = $row['last_success_at'] ?? null;
			$lastError   = $row['last_error'] ?? null;

			echo '<tr>';
			echo '<td>' . esc_html( $key ) . '</td>';
			echo '<td>' . esc_html( $configured ? 'はい' : 'いいえ' ) . '</td>';
			echo '<td>' . esc_html( $lastSuccess === null ? '' : (string) $lastSuccess ) . '</td>';
			echo '<td>' . esc_html( $lastError === null ? '' : (string) $lastError ) . '</td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
	}

	/**
	 * Render the §13 Jobs counts line.
	 *
	 * @param DiagnosticsResult $result Backing result VO.
	 */
	private function renderJobs( DiagnosticsResult $result ): void {
		$jobs = $result->jobs();

		echo '<h2>' . esc_html( 'ジョブ' ) . '</h2>';
		echo '<p>';
		echo esc_html( sprintf( 'キュー: %d / 失敗: %d', $jobs['queued'], $jobs['failed'] ) );
		echo '</p>';
	}

	/**
	 * Render the §13 Observability bullet list.
	 *
	 * @param DiagnosticsResult $result Backing result VO.
	 */
	private function renderObservability( DiagnosticsResult $result ): void {
		echo '<h2>' . esc_html( '可観測性' ) . '</h2>';
		$this->renderInlineList( $result->observability() );
	}

	/**
	 * Render the §13 Entry points section.
	 *
	 * The Logs entry point links to the Logs admin page (resolved
	 * via `admin_url()` when available); the Audit entry point
	 * shows "(not available)" when the use case reports it as null
	 * (Phase 11 default).
	 *
	 * @param DiagnosticsResult $result Backing result VO.
	 */
	private function renderEntryPoints( DiagnosticsResult $result ): void {
		$entryPoints = $result->entryPoints();

		echo '<h2>' . esc_html( 'エントリポイント' ) . '</h2>';
		echo '<ul>';

		$logsUrl = self::resolveAdminUrl( self::OPERATIONS_SETTINGS_PATH );
		echo '<li>' . esc_html( 'ログ: ' );
		echo '<a href="' . esc_url( $logsUrl ) . '">' . esc_html( 'ログを開く' ) . '</a>';
		echo '</li>';

		echo '<li>' . esc_html( '監査: ' );
		if ( $entryPoints['audit'] === null ) {
			echo esc_html( '(未利用)' );
		} else {
			echo esc_html( (string) $entryPoints['audit'] );
		}
		echo '</li>';

		echo '</ul>';
	}

	/**
	 * Project a scalar / null value onto a display-safe string.
	 *
	 * Booleans render as `true` / `false`; null renders as the
	 * empty string; everything else is coerced to string and left
	 * to `esc_html()` for output safety.
	 *
	 * @param mixed $value Value to project.
	 */
	private static function scalarToString( $value ): string {
		if ( $value === null ) {
			return '';
		}
		if ( is_bool( $value ) ) {
			return $value ? 'はい' : 'いいえ';
		}
		if ( is_scalar( $value ) ) {
			return (string) $value;
		}
		return '';
	}

	/**
	 * 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?page=affilun-logs'`).
	 */
	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, '/' );
	}
}
