<?php
/**
 * Provider health status value object.
 *
 * Immutable value object that represents the result of a provider
 * implementation's health-check probe at a specific point in time.
 * The type unifies the cross-provider health surface
 * `specifications/provider-api-contracts.md` §7.10 enumerates so
 * diagnostics dashboards, the admin status surface, and the sync
 * scheduler can branch on a single, validated value without having
 * to re-derive the status from sibling fields.
 *
 * Field semantics match `provider-api-contracts.md` §7.10 exactly:
 *
 *   - `status` is one of the six tokens from §7.10
 *     ({@see self::STATUS_HEALTHY}, {@see self::STATUS_DEGRADED},
 *     {@see self::STATUS_UNCONFIGURED},
 *     {@see self::STATUS_RATE_LIMITED},
 *     {@see self::STATUS_UNREACHABLE},
 *     {@see self::STATUS_INVALID_CREDENTIALS}). The DTO enforces the
 *     set at construction time: any other token raises
 *     {@see \InvalidArgumentException} so a provider implementation
 *     that forgets to map its native vocabulary fails loudly here
 *     rather than silently producing a status the diagnostics layer
 *     does not know how to render.
 *
 *   - `authenticated` reflects whether the provider's credentials
 *     are currently accepted by the upstream API. Required because
 *     the §7.10 status set alone does not answer the question — a
 *     `degraded` provider may still be authenticated, and an
 *     `unconfigured` provider is by definition not authenticated.
 *
 *   - `reachable` reflects whether the provider's HTTP endpoint
 *     responded at all (regardless of whether the response itself
 *     was successful). Required because `unreachable` and
 *     `invalid_credentials` differ on this axis: an unreachable
 *     provider returned no response, while an invalid-credentials
 *     provider returned a 401 / 403 response that says credentials
 *     are wrong.
 *
 *   - `rateLimited` reflects whether the upstream API is currently
 *     refusing requests due to a rate-limit window. Required because
 *     it is independent of `status`: a `degraded` provider may be
 *     rate-limited on one endpoint while still healthy on others,
 *     and the diagnostics surface uses this flag to decide whether
 *     to render a "wait and retry" hint.
 *
 *   - `message` is the provider-supplied human-readable summary
 *     ("PA-API rate limit exceeded; retry after 60s",
 *     "credentials missing in admin settings"). Held as a plain
 *     string at this layer; the rendering layer is responsible for
 *     any sanitization / truncation for display. Nullable because
 *     a `healthy` snapshot does not need a message.
 *
 *   - `checkedAt` is the UTC timestamp at which the probe ran.
 *     Required so the diagnostics surface can display "as of
 *     {date}" captions and so the sync scheduler can decide whether
 *     a cached snapshot is stale enough to re-probe.
 *
 *   - `details` is the structured diagnostic blob preserved for
 *     debugging — HTTP response headers, rate-limit window
 *     reset times, raw error codes, etc. Held as an associative
 *     array; consuming surfaces MUST NOT branch on its contents
 *     beyond rendering them as opaque key-value pairs in the admin
 *     diagnostics view. Validated only to reject non-string keys,
 *     matching the {@see ProductSnapshot::rawMeta()} convention.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because a health snapshot represents a single probe at a single
 * point in time: any change in the underlying state should be
 * modeled as a fresh probe producing a fresh
 * {@see ProviderHealthStatus}, not as an in-place edit of an
 * existing one.
 *
 * @package Affilun\Domain\Provider
 */

declare(strict_types=1);

namespace Affilun\Domain\Provider;

use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable provider health-probe snapshot at a single point in time.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §7.10 and the rationale for keeping
 * the status set closed and the boolean axes independent of the
 * status token.
 */
final class ProviderHealthStatus {

	/**
	 * Provider is fully operational: authenticated, reachable, not
	 * rate-limited, and producing successful responses.
	 *
	 * Exposed as a class constant so call sites can reference the
	 * exact token without duplicating the literal, and so a future
	 * spec change has a single location to update.
	 */
	public const STATUS_HEALTHY = 'healthy';

	/**
	 * Provider is partially operational: some endpoints are
	 * responding while others are failing, or response latencies
	 * are elevated, or successful responses carry a non-fatal
	 * warning. Consuming surfaces SHOULD render the provider but
	 * MAY surface the {@see self::$message} body to the
	 * administrator.
	 */
	public const STATUS_DEGRADED = 'degraded';

	/**
	 * Provider has not been configured: required credentials,
	 * associate tags, or other settings are missing from the admin
	 * settings surface. Consuming surfaces SHOULD hide the provider
	 * from end-user-facing flows and prompt the administrator to
	 * complete configuration.
	 */
	public const STATUS_UNCONFIGURED = 'unconfigured';

	/**
	 * Provider is currently refusing requests due to a rate-limit
	 * window. Consuming surfaces SHOULD render a "wait and retry"
	 * affordance and consult {@see self::$details} for the reset
	 * timestamp when present.
	 */
	public const STATUS_RATE_LIMITED = 'rate_limited';

	/**
	 * Provider's HTTP endpoint did not respond at all (DNS failure,
	 * TCP timeout, TLS handshake failure). Consuming surfaces
	 * SHOULD render a network-level outage indicator and treat
	 * subsequent operations as best-effort with rapid backoff.
	 */
	public const STATUS_UNREACHABLE = 'unreachable';

	/**
	 * Provider's HTTP endpoint responded but rejected the request
	 * for credential reasons (401 / 403, expired tokens, revoked
	 * associate tags). Consuming surfaces SHOULD prompt the
	 * administrator to refresh credentials and SHOULD NOT
	 * automatically retry until the credentials are updated.
	 */
	public const STATUS_INVALID_CREDENTIALS = 'invalid_credentials';

	/**
	 * Closed set of {@see self::STATUS_*} tokens accepted by the
	 * constructor. Maintained as a class-level constant so the
	 * validator and the test suite can iterate the same source of
	 * truth.
	 *
	 * @var list<string>
	 */
	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<string, mixed>
	 */
	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<string, mixed> $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<string>
	 */
	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<string, mixed>
	 */
	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<int|string, mixed> $details Raw details map.
	 *
	 * @return array<string, mixed>
	 *
	 * @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' ) );
	}
}
