*/ private const VALID_STATUSES = array( self::STATUS_HEALTHY, self::STATUS_DEGRADED, self::STATUS_UNCONFIGURED, self::STATUS_RATE_LIMITED, self::STATUS_UNREACHABLE, self::STATUS_INVALID_CREDENTIALS, ); /** * One of the {@see self::STATUS_*} tokens. Validated at * construction time so an out-of-set value never reaches the * diagnostics layer. */ private string $status; /** * Whether the provider's credentials are currently accepted by * the upstream API. */ private bool $authenticated; /** * Whether the provider's HTTP endpoint responded at all * (regardless of whether the response was successful). */ private bool $reachable; /** * Whether the upstream API is currently refusing requests due * to a rate-limit window. */ private bool $rateLimited; /** * Provider-supplied human-readable summary, or null when the * status alone is the entire signal (typically `healthy`). */ private ?string $message; /** * UTC timestamp at which the probe ran. */ private DateTimeImmutable $checkedAt; /** * Structured diagnostic blob preserved for debugging. * * @var array */ private array $details; /** * Construct a health snapshot with explicit values for every * field. * * The constructor is exhaustive so a programming error * (forgetting to record the `authenticated` axis on a * `rate_limited` snapshot, swapping `reachable` / `rateLimited`) * surfaces at the type system boundary rather than at the * diagnostics layer. * * Validation summary: * * - `status` is trimmed and matched against the closed set in * {@see self::VALID_STATUSES}; any other token raises * {@see InvalidArgumentException} carrying the offending * value verbatim and a comma-separated list of the * accepted tokens for diagnosis. * - `message`, when non-null, is trimmed and rejected when * the trim leaves an empty string — "present but empty" is * treated as a programming error. * - `checkedAt` is normalized to UTC on storage so equality * checks and round-trips do not depend on the caller's * timezone. * - `details` rejects non-string keys; values are NOT * validated because §7.10 expressly preserves provider- * native data here for diagnostics. * * @param string $status One of {@see self::STATUS_*}. * @param bool $authenticated Credentials accepted. * @param bool $reachable Endpoint responded. * @param bool $rateLimited Rate-limit refusal active. * @param string|null $message Human-readable summary, or null. * @param DateTimeImmutable $checkedAt Probe timestamp. * @param array $details Structured diagnostic blob. * * @throws InvalidArgumentException When `$status` is not a * member of the §7.10 closed * set, when `$message` is the * empty string after trimming, * or when `$details` carries * a non-string key. */ public function __construct( string $status, bool $authenticated, bool $reachable, bool $rateLimited, ?string $message, DateTimeImmutable $checkedAt, array $details ) { $this->status = self::normalizeStatus( $status ); $this->authenticated = $authenticated; $this->reachable = $reachable; $this->rateLimited = $rateLimited; $this->message = self::normalizeOptionalString( 'message', $message ); $this->checkedAt = self::normalizeToUtc( $checkedAt ); $this->details = self::normalizeDetails( $details ); } /** * Construct a `healthy` snapshot — the canonical "provider is * fully operational" result. * * Convenience factory for the common happy path so call sites * do not have to spell out the four boolean axes * (`authenticated = true`, `reachable = true`, * `rateLimited = false`), the null `message`, and the empty * `details` map every time. * * @param DateTimeImmutable $checkedAt Probe timestamp. */ public static function healthy( DateTimeImmutable $checkedAt ): self { return new self( self::STATUS_HEALTHY, true, true, false, null, $checkedAt, array() ); } /** * Construct an `unconfigured` snapshot — the canonical "provider * has not been set up" result. * * Convenience factory for the bootstrap path where the admin * settings surface has not yet captured the provider's * credentials. An `unconfigured` provider is by definition not * authenticated and not rate-limited; whether it is reachable * is irrelevant when there is nothing to authenticate with, so * the helper hard-codes `reachable = false` to reflect that no * useful probe has been performed. * * @param DateTimeImmutable $checkedAt Probe timestamp. */ public static function unconfigured( DateTimeImmutable $checkedAt ): self { return new self( self::STATUS_UNCONFIGURED, false, false, false, null, $checkedAt, array() ); } /** * Closed set of accepted status tokens, in declaration order. * * Exposed for tests and for diagnostics surfaces that want to * enumerate the valid tokens (for example, populating an admin * filter dropdown) without duplicating the list. * * @return list */ public static function validStatuses(): array { return self::VALID_STATUSES; } /** * One of the {@see self::STATUS_*} tokens. */ public function status(): string { return $this->status; } /** * Whether the provider's credentials are currently accepted by * the upstream API. */ public function authenticated(): bool { return $this->authenticated; } /** * Whether the provider's HTTP endpoint responded at all. */ public function reachable(): bool { return $this->reachable; } /** * Whether the upstream API is currently refusing requests due * to a rate-limit window. */ public function rateLimited(): bool { return $this->rateLimited; } /** * Provider-supplied human-readable summary, or null. */ public function message(): ?string { return $this->message; } /** * UTC probe timestamp. */ public function checkedAt(): DateTimeImmutable { return $this->checkedAt; } /** * Structured diagnostic blob preserved for debugging. * * Per §7.10 the diagnostics layer MUST treat this map's * contents as opaque beyond rendering them as key-value pairs. * * @return array */ public function details(): array { return $this->details; } /** * Convenience guard: report whether the status equals * {@see self::STATUS_HEALTHY}. * * Exposed because the §7.10 status set has a single dominant * "happy path" token that consuming surfaces routinely branch * on; a dedicated helper keeps those branches readable without * forcing every call site to compare against the raw string * constant. */ public function isHealthy(): bool { return $this->status === self::STATUS_HEALTHY; } /** * Convenience guard: report whether the status indicates the * provider is currently usable in any capacity. * * Returns true for {@see self::STATUS_HEALTHY} and * {@see self::STATUS_DEGRADED} — the two states where the * provider can still answer at least some requests — and false * for the four states where the provider should be treated as * effectively offline. Consuming surfaces use this flag to * decide whether to keep the provider in the rotation or to * route around it. */ public function isOperational(): bool { return $this->status === self::STATUS_HEALTHY || $this->status === self::STATUS_DEGRADED; } /** * Validate and normalize the `status` field at construction. * * The status is trimmed first so a carelessly-decoded payload * (trailing newline, padded space) is normalized to the same * canonical form a freshly-constructed status would carry. The * trimmed value MUST appear in {@see self::VALID_STATUSES}; * anything else raises {@see InvalidArgumentException} carrying * the offending input verbatim and a comma-separated list of * the accepted tokens for diagnosis. * * @param string $status Raw status as supplied by the caller. * * @throws InvalidArgumentException When `$status` is not a * member of the §7.10 closed * set. */ private static function normalizeStatus( string $status ): string { $trimmed = trim( $status ); if ( ! in_array( $trimmed, self::VALID_STATUSES, true ) ) { $message = sprintf( 'ProviderHealthStatus status must be one of [%s]; got "%s".', implode( ', ', self::VALID_STATUSES ), $status ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize a nullable string field that, when * non-null, must carry non-empty content. * * Used for the `message` field where "present but empty" is a * programming error rather than a meaningful state. * * @param string $field Field name for the error message. * @param string|null $value Raw value, or null. * * @return string|null Trimmed value, or null. * * @throws InvalidArgumentException When `$value` is the empty * string after trimming. */ private static function normalizeOptionalString( string $field, ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'ProviderHealthStatus %s must be null or a non-empty string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate the `details` map's keys. * * Values are NOT validated because §7.10 expressly preserves * provider-native data here for downstream diagnostics; the * rendering layer is forbidden from branching on contents * beyond key-value display, so shape constraints would only * obstruct legitimate use. * * @param array $details Raw details map. * * @return array * * @throws InvalidArgumentException When any key is not a string. */ private static function normalizeDetails( array $details ): array { $normalized = array(); foreach ( $details as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'ProviderHealthStatus details keys must be strings; got %s.', gettype( $key ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $key ] = $value; } return $normalized; } /** * Normalize a timestamp to UTC so equality checks and * round-trips do not depend on the caller's timezone. * * @param DateTimeImmutable $value Timestamp to normalize. */ private static function normalizeToUtc( DateTimeImmutable $value ): DateTimeImmutable { return $value->setTimezone( new DateTimeZone( 'UTC' ) ); } }