<?php
/**
 * Diagnostics result value object.
 *
 * Phase 11 slice 11c immutable VO that wraps the response shape
 * for `GET /affilun/v1/diagnostics` defined in
 * `specifications/rest-api-spec.md` §12.1. The §12.1 envelope is
 * a fixed nested map with six top-level keys (`environment`,
 * `database`, `providers`, `jobs`, `observability`,
 * `entry_points`) and a closed inner shape per branch. This VO
 * holds those branches verbatim and projects them back through
 * {@see self::toArray()} for the controller layer.
 *
 * §12.1 注意: secrets MUST NOT appear in any field. The
 * diagnostics surface is intended as an entry point into
 * logs / audit / job-trace material — the existence of API keys
 * and secrets MAY be reported as boolean flags by the caller, but
 * raw credential material MUST NOT round-trip through this VO.
 * The constructor enforces the shape of every branch but does NOT
 * scan field values for secrets; callers (the use case that
 * builds the inputs) are responsible for not putting credentials
 * in any field at all.
 *
 * Inner shape:
 *
 *   - `environment` carries the §12.1 "environment" map:
 *     `php_version`, `wp_version`, `rest_available`,
 *     `object_cache_available`, `cron_healthy`. The constructor
 *     enforces presence of every key; values are not type-checked
 *     beyond rejecting non-array inputs because the env-axis
 *     types are heterogeneous (string for versions, bool for the
 *     three availability flags).
 *
 *   - `database` carries `schema_version` (string) and
 *     `tables_ok` (bool). Both keys are required.
 *
 *   - `providers` is a list of per-provider rows, each with the
 *     keys `key`, `configured`, `last_success_at`, `last_error`.
 *     The list MAY be empty (no providers registered).
 *
 *   - `jobs` carries the §6.0 REST-vocabulary status histogram
 *     restricted to `queued` and `failed`, both required ints.
 *
 *   - `observability` carries four boolean axes: `logs_available`,
 *     `audit_available`, `request_id_tracing`,
 *     `correlation_id_tracing`. All four keys are required.
 *
 *   - `entry_points` carries `logs`, `audit`, and `audit_summary`,
 *     all nullable strings. Every key is required (a key MAY be
 *     null to indicate the entry point is not yet exposed; missing
 *     keys are rejected). The `audit_summary` slot points at the
 *     §12.5 `GET /affilun/v1/audit/events/summary` surface so the
 *     diagnostics-driven UI can deep-link into the histogram +
 *     recent-slice card without re-deriving the path.
 *
 * @package Affilun\Application\Diagnostics\UseCase
 */

declare(strict_types=1);

namespace Affilun\Application\Diagnostics\UseCase;

use InvalidArgumentException;

/**
 * Immutable result of {@see GetDiagnosticsUseCase::execute()}.
 *
 * See the file-level docblock for the §12.1 envelope contract,
 * the per-branch shape requirements enforced by the constructor,
 * and the "no secrets" rule that binds every field on this VO.
 */
final class DiagnosticsResult {

	/**
	 * Required keys for the `environment` branch.
	 *
	 * @var list<string>
	 */
	private const ENVIRONMENT_KEYS = array(
		'php_version',
		'wp_version',
		'rest_available',
		'object_cache_available',
		'cron_healthy',
	);

	/**
	 * Required keys for the `database` branch.
	 *
	 * @var list<string>
	 */
	private const DATABASE_KEYS = array(
		'schema_version',
		'tables_ok',
	);

	/**
	 * Required keys for each row of the `providers` branch.
	 *
	 * @var list<string>
	 */
	private const PROVIDER_ROW_KEYS = array(
		'key',
		'configured',
		'last_success_at',
		'last_error',
	);

	/**
	 * Required keys for the `jobs` branch.
	 *
	 * @var list<string>
	 */
	private const JOBS_KEYS = array(
		'queued',
		'failed',
	);

	/**
	 * Required keys for the `observability` branch.
	 *
	 * @var list<string>
	 */
	private const OBSERVABILITY_KEYS = array(
		'logs_available',
		'audit_available',
		'request_id_tracing',
		'correlation_id_tracing',
	);

	/**
	 * Required keys for the `entry_points` branch.
	 *
	 * The `audit_summary` slot was added in the Phase 2 KI-006
	 * follow-up that landed `GET /affilun/v1/audit/events/summary`
	 * (§12.5); it is a sibling of `audit` rather than a child of
	 * the audit slot so the diagnostics surface keeps a flat
	 * key namespace.
	 *
	 * @var list<string>
	 */
	private const ENTRY_POINTS_KEYS = array(
		'logs',
		'audit',
		'audit_summary',
	);

	/**
	 * Environment axes per §12.1.
	 *
	 * @var array<string, mixed>
	 */
	private array $environment;

	/**
	 * Database axes per §12.1.
	 *
	 * @var array<string, mixed>
	 */
	private array $database;

	/**
	 * Per-provider rows per §12.1.
	 *
	 * @var list<array<string, mixed>>
	 */
	private array $providers;

	/**
	 * Job-status histogram restricted to `queued` and `failed`
	 * per §12.1.
	 *
	 * @var array{queued: int, failed: int}
	 */
	private array $jobs;

	/**
	 * Observability axes per §12.1.
	 *
	 * @var array<string, bool>
	 */
	private array $observability;

	/**
	 * Entry-point URL pointers per §12.1.
	 *
	 * @var array{logs: ?string, audit: ?string, audit_summary: ?string}
	 */
	private array $entryPoints;

	/**
	 * Construct the result with explicit values for every branch.
	 *
	 * @param array<string, mixed>                                         $environment   Environment axes.
	 * @param array<string, mixed>                                         $database      Database axes.
	 * @param list<array<string, mixed>>                                   $providers     Per-provider rows.
	 * @param array{queued: int, failed: int}                              $jobs          Job-status histogram.
	 * @param array<string, bool>                                          $observability Observability axes.
	 * @param array{logs: ?string, audit: ?string, audit_summary: ?string} $entryPoints Entry-point URL pointers.
	 *
	 * @throws InvalidArgumentException When any branch is missing
	 *                                  a required key, when a
	 *                                  provider row is malformed,
	 *                                  or when `$jobs` /
	 *                                  `$observability` /
	 *                                  `$entryPoints` carry values
	 *                                  outside the declared types.
	 */
	public function __construct(
		array $environment,
		array $database,
		array $providers,
		array $jobs,
		array $observability,
		array $entryPoints
	) {
		self::assertKeys( 'environment', $environment, self::ENVIRONMENT_KEYS );
		self::assertKeys( 'database', $database, self::DATABASE_KEYS );

		foreach ( $providers as $index => $row ) {
			if ( ! is_array( $row ) ) {
				$message = sprintf(
					'DiagnosticsResult: providers[%d] must be an array; got %s.',
					(int) $index,
					gettype( $row )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			self::assertKeys( sprintf( 'providers[%d]', (int) $index ), $row, self::PROVIDER_ROW_KEYS );
		}

		self::assertKeys( 'jobs', $jobs, self::JOBS_KEYS );
		if ( ! is_int( $jobs['queued'] ) || ! is_int( $jobs['failed'] ) ) {
			$message = 'DiagnosticsResult: jobs.queued and jobs.failed must be ints.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		self::assertKeys( 'observability', $observability, self::OBSERVABILITY_KEYS );
		foreach ( self::OBSERVABILITY_KEYS as $key ) {
			if ( ! is_bool( $observability[ $key ] ) ) {
				$message = sprintf(
					'DiagnosticsResult: observability.%s must be bool.',
					$key
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
		}

		self::assertKeys( 'entry_points', $entryPoints, self::ENTRY_POINTS_KEYS );
		foreach ( self::ENTRY_POINTS_KEYS as $key ) {
			$value = $entryPoints[ $key ];
			if ( $value !== null && ! is_string( $value ) ) {
				$message = sprintf(
					'DiagnosticsResult: entry_points.%s must be string or null.',
					$key
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
		}

		$this->environment   = $environment;
		$this->database      = $database;
		$this->providers     = array_values( $providers );
		$this->jobs          = array(
			'queued' => $jobs['queued'],
			'failed' => $jobs['failed'],
		);
		$this->observability = array();
		foreach ( self::OBSERVABILITY_KEYS as $key ) {
			$this->observability[ $key ] = (bool) $observability[ $key ];
		}
		$this->entryPoints = array(
			'logs'          => $entryPoints['logs'],
			'audit'         => $entryPoints['audit'],
			'audit_summary' => $entryPoints['audit_summary'],
		);
	}

	/**
	 * Environment axes per §12.1.
	 *
	 * @return array<string, mixed>
	 */
	public function environment(): array {
		return $this->environment;
	}

	/**
	 * Database axes per §12.1.
	 *
	 * @return array<string, mixed>
	 */
	public function database(): array {
		return $this->database;
	}

	/**
	 * Per-provider rows per §12.1.
	 *
	 * @return list<array<string, mixed>>
	 */
	public function providers(): array {
		return $this->providers;
	}

	/**
	 * Job-status histogram per §12.1.
	 *
	 * @return array{queued: int, failed: int}
	 */
	public function jobs(): array {
		return $this->jobs;
	}

	/**
	 * Observability axes per §12.1.
	 *
	 * @return array<string, bool>
	 */
	public function observability(): array {
		return $this->observability;
	}

	/**
	 * Entry-point URL pointers per §12.1.
	 *
	 * @return array{logs: ?string, audit: ?string, audit_summary: ?string}
	 */
	public function entryPoints(): array {
		return $this->entryPoints;
	}

	/**
	 * Render the §12.1 success envelope `data` shape:
	 *
	 *   `array(
	 *       'environment'   => array<string, mixed>,
	 *       'database'      => array<string, mixed>,
	 *       'providers'     => list<array<string, mixed>>,
	 *       'jobs'          => array{queued: int, failed: int},
	 *       'observability' => array<string, bool>,
	 *       'entry_points'  => array{logs: ?string, audit: ?string, audit_summary: ?string},
	 *   )`
	 *
	 * Key order matches the §12.1 example so the wire shape is
	 * stable across PHP versions and JSON encoders.
	 *
	 * @return array<string, mixed>
	 */
	public function toArray(): array {
		return array(
			'environment'   => $this->environment,
			'database'      => $this->database,
			'providers'     => $this->providers,
			'jobs'          => $this->jobs,
			'observability' => $this->observability,
			'entry_points'  => $this->entryPoints,
		);
	}

	/**
	 * Assert that `$source` carries every key in `$requiredKeys`.
	 *
	 * @param string               $branch       Branch name for error messages.
	 * @param array<string, mixed> $source       Map to validate.
	 * @param list<string>         $requiredKeys Closed set of required keys.
	 *
	 * @throws InvalidArgumentException When any required key is
	 *                                  missing.
	 */
	private static function assertKeys( string $branch, array $source, array $requiredKeys ): void {
		foreach ( $requiredKeys as $key ) {
			if ( ! array_key_exists( $key, $source ) ) {
				$message = sprintf(
					'DiagnosticsResult: %s is missing required key "%s".',
					$branch,
					$key
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
		}
	}
}
