<?php
/**
 * Get diagnostics use case.
 *
 * Phase 11 slice 11c Application-layer UseCase that drives the
 * §12.1 `GET /clb/v1/diagnostics` endpoint defined in
 * `specifications/rest-api-spec.md`. The class produces a
 * {@see DiagnosticsResult} VO that wraps the §12.1 envelope's
 * `data` payload — environment, database, providers, jobs,
 * observability, and entry_points — and serves as the input the
 * controller layer projects onto the §4.1 success envelope.
 *
 * Contract sources:
 *
 *   - Environment axes are read through an injected
 *     `?callable $envProvider` so unit tests can drive the
 *     `environment` block deterministically without depending on
 *     the host PHP / WordPress runtime. The default provider
 *     reads from PHP constants and WordPress globals when
 *     available; the override seam keeps tests deterministic.
 *
 *   - Database axes come from the
 *     {@see SchemaVersionRepository}: `schema_version` is the
 *     stringified {@see SchemaVersion} (`v0`, `v1`, ...), and
 *     `tables_ok` is the {@see SchemaVersionRepository::isInitialized()}
 *     boolean. Phase 11 MVP does NOT probe individual table
 *     existence — `tables_ok` is intentionally a coarse "has the
 *     migration runner ever recorded a version on this site?"
 *     signal.
 *
 *   - Provider rows iterate the `$providers` map supplied at
 *     construction time. Phase 11 MVP does NOT centralize the
 *     provider registry; the use case accepts whatever map the
 *     caller has, defaulting to empty so callers without a
 *     registry still produce a valid §12.1 envelope. Each row's
 *     four §12.1 fields (`key`, `configured`, `last_success_at`,
 *     `last_error`) are derived from the provider's
 *     {@see ProviderHealthStatus} probe per the per-flag rules
 *     documented on {@see self::projectProvider()}.
 *
 *   - Jobs counts come from
 *     {@see SyncJobRepository::countByStatus()}. The §12.1 wire
 *     shape uses the §6.0 REST vocabulary (`queued`, `failed`),
 *     while the persistence layer stores the DB vocabulary
 *     (`pending`, ..., `failed`); the projection here maps the
 *     DB-side `pending` count into `queued` and discards every
 *     other DB status, keeping only the two §12.1 fields. Missing
 *     keys default to 0 so a fresh-install repository (which
 *     {@see SyncJobRepository::countByStatus()} returns as the
 *     empty array) yields `{queued: 0, failed: 0}`.
 *
 *   - Observability and entry_points are MVP-static. As of the
 *     Phase 2 KI-006 closeout + follow-up, `logs_available = true`
 *     (Phase 11 slice 11d wires the logs admin page) and
 *     `audit_available = true` (Phase 2 KI-006 wires
 *     `GET /clb/v1/audit/events` and the §12.5 follow-up adds
 *     `GET /clb/v1/audit/events/summary`). Tracing axes stay
 *     `true` because the request-id / correlation-id middleware
 *     is in place from earlier phases. `entry_points.logs` is
 *     the §7.5 path, `entry_points.audit` is the §12.4 list
 *     surface, and `entry_points.audit_summary` is the §12.5
 *     histogram + recent-slice card so the diagnostics-driven UI
 *     can deep-link without re-deriving the path.
 *
 * Defensive contract around `healthCheck()`:
 *
 *   - {@see ProviderInterface::healthCheck()} is documented to
 *     never throw on transient upstream failures. The Phase 6
 *     {@see \CommerceLinkBuilder\Application\Editor\UseCase\ListAvailableProvidersUseCase}
 *     wraps the call in a `try` / `catch ( \Throwable )` block so
 *     a misbehaving provider cannot break the §10.3 enumeration;
 *     this UseCase mirrors that defensive posture for the §12.1
 *     surface. When a provider's `healthCheck()` throws, the row
 *     is rendered as an `unconfigured` snapshot pinned to the
 *     current clock instant — `configured = false`,
 *     `last_success_at = null`, `last_error = null`.
 *
 *   - The `cron_healthy` axis on the default env provider is
 *     deliberately a best-effort signal: it reports whether
 *     `DISABLE_WP_CRON` is set, NOT whether the cron runner has
 *     actually fired recently. A proper liveness probe (reading
 *     the last cron run timestamp from the options table) is
 *     deferred to a P1 follow-up; the current axis is sufficient
 *     to surface the most common "cron is turned off entirely"
 *     misconfiguration to the operator.
 *
 * @package CommerceLinkBuilder\Application\Diagnostics\UseCase
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Diagnostics\UseCase;

use CommerceLinkBuilder\Domain\Clock;
use CommerceLinkBuilder\Domain\Provider\ProviderHealthStatus;
use CommerceLinkBuilder\Domain\Provider\ProviderInterface;
use CommerceLinkBuilder\Infrastructure\Persistence\SchemaVersionRepository;
use CommerceLinkBuilder\Infrastructure\Persistence\SyncJobRepository;
use DateTimeInterface;
use InvalidArgumentException;
use Throwable;

/**
 * Phase 11 UseCase that drives the §12.1
 * `GET /clb/v1/diagnostics` endpoint.
 *
 * See the file-level docblock for the per-branch derivation rules,
 * the §6.0 DB-vocabulary → REST-vocabulary mapping on the `jobs`
 * branch, the defensive `try` / `catch` posture around
 * {@see ProviderInterface::healthCheck()}, and the MVP-static
 * observability / entry_points values.
 */
final class GetDiagnosticsUseCase {

	/**
	 * §6.0 DB-vocabulary status that maps to the §12.1 wire-side
	 * `queued` value.
	 */
	private const DB_STATUS_PENDING = 'pending';

	/**
	 * §6.0 DB-vocabulary status that maps to the §12.1 wire-side
	 * `failed` value verbatim.
	 */
	private const DB_STATUS_FAILED = 'failed';

	/**
	 * Schema-version repository powering the `database` branch.
	 */
	private SchemaVersionRepository $schemaVersions;

	/**
	 * Sync-job repository powering the `jobs` branch.
	 */
	private SyncJobRepository $jobs;

	/**
	 * Registered providers keyed by their §4 stable token.
	 *
	 * Iteration order is the registration order; the UseCase
	 * honors it verbatim so the `providers` list renders in a
	 * stable sequence.
	 *
	 * @var array<string, ProviderInterface>
	 */
	private array $providers;

	/**
	 * Closure that produces the `environment` branch.
	 *
	 * @var callable(): array<string, mixed>
	 */
	private $envProvider;

	/**
	 * Clock used to stamp defensive provider rows when
	 * `healthCheck()` throws.
	 */
	private Clock $clock;

	/**
	 * Construct the UseCase with explicit dependencies.
	 *
	 * @param SchemaVersionRepository                 $schemaVersions Backing schema-version repository.
	 * @param SyncJobRepository                       $jobs           Backing sync-job repository.
	 * @param array<string, ProviderInterface>        $providers      Provider registry; defaults to empty.
	 * @param (callable(): array<string, mixed>)|null $envProvider    Override seam for the `environment` block;
	 *                                                                defaults to a runtime probe.
	 * @param Clock                                   $clock          Clock used to stamp defensive provider rows.
	 *
	 * @throws InvalidArgumentException When any registry entry has
	 *                                  a non-string or empty key,
	 *                                  or a value that does not
	 *                                  implement
	 *                                  {@see ProviderInterface}.
	 */
	public function __construct(
		SchemaVersionRepository $schemaVersions,
		SyncJobRepository $jobs,
		array $providers,
		?callable $envProvider,
		Clock $clock
	) {
		$normalized = array();
		foreach ( $providers as $key => $provider ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'GetDiagnosticsUseCase: provider registry 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 );
			}
			if ( trim( $key ) === '' ) {
				$message = 'GetDiagnosticsUseCase: provider registry keys must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			if ( ! $provider instanceof ProviderInterface ) {
				$message = sprintf(
					'GetDiagnosticsUseCase: provider registry["%s"] must implement ProviderInterface; got %s.',
					$key,
					is_object( $provider ) ? get_class( $provider ) : gettype( $provider )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[ $key ] = $provider;
		}

		$this->schemaVersions = $schemaVersions;
		$this->jobs           = $jobs;
		$this->providers      = $normalized;
		$this->envProvider    = $envProvider ?? self::defaultEnvProvider();
		$this->clock          = $clock;
	}

	/**
	 * Execute the §12.1 enumeration and produce the result VO.
	 *
	 * See the file-level docblock for the per-branch derivation
	 * rules; this method is the orchestration glue that combines
	 * the env provider, the schema-version repository, the
	 * provider registry, the sync-job repository, and the
	 * MVP-static observability / entry_points constants into a
	 * single {@see DiagnosticsResult}.
	 */
	public function execute(): DiagnosticsResult {
		$environment = ( $this->envProvider )();

		$schemaVersion = $this->schemaVersions->currentVersion();
		$database      = array(
			'schema_version' => (string) $schemaVersion,
			'tables_ok'      => $this->schemaVersions->isInitialized(),
		);

		$providerRows = array();
		foreach ( $this->providers as $registryKey => $provider ) {
			$providerRows[] = $this->projectProvider( $registryKey, $provider );
		}

		$jobs = $this->projectJobCounts( $this->jobs->countByStatus() );

		$observability = array(
			'logs_available'         => true,
			'audit_available'        => true,
			'request_id_tracing'     => true,
			'correlation_id_tracing' => true,
		);

		$entryPoints = array(
			'logs'          => '/clb/v1/logs',
			'audit'         => '/clb/v1/audit/events',
			'audit_summary' => '/clb/v1/audit/events/summary',
		);

		return new DiagnosticsResult(
			$environment,
			$database,
			$providerRows,
			$jobs,
			$observability,
			$entryPoints
		);
	}

	/**
	 * Project a single registered provider onto the §12.1 row
	 * shape.
	 *
	 * Per-flag derivation:
	 *
	 *   - `key` reads from the provider's own
	 *     {@see ProviderInterface::key()} accessor on the happy
	 *     path; if any accessor — including `healthCheck()` —
	 *     throws, the row falls back to the registry key for
	 *     `key` and to an `unconfigured` snapshot pinned to the
	 *     current clock instant for the remaining flags.
	 *
	 *   - `configured` is true when
	 *     {@see ProviderHealthStatus::status()} is anything other
	 *     than {@see ProviderHealthStatus::STATUS_UNCONFIGURED}.
	 *     Other unhealthy statuses (degraded, rate_limited,
	 *     unreachable, invalid_credentials) are deliberately
	 *     reported as `configured = true` — the §12.1 surface
	 *     answers the bare "is the admin form filled in?"
	 *     question, and a misconfigured-but-set provider is still
	 *     "configured but broken" rather than "not configured at
	 *     all". This mirrors the §10.3 projection used by
	 *     {@see \CommerceLinkBuilder\Application\Editor\UseCase\ListAvailableProvidersUseCase}.
	 *
	 *   - `last_success_at` is the ISO-8601 (RFC 3339) string of
	 *     {@see ProviderHealthStatus::checkedAt()} when the status
	 *     is {@see ProviderHealthStatus::STATUS_HEALTHY}, else
	 *     null. The §12.1 example shows `last_success_at`
	 *     populated only on healthy providers; the field is the
	 *     "last successful probe" axis, not the "last probe of any
	 *     kind" axis.
	 *
	 *   - `last_error` is {@see ProviderHealthStatus::message()}
	 *     when the status is one of the four non-healthy,
	 *     non-unconfigured tokens (`degraded`, `rate_limited`,
	 *     `unreachable`, `invalid_credentials`) AND a message is
	 *     present, else null. An `unconfigured` provider has no
	 *     "error" to report; a healthy one likewise.
	 *
	 * @param string            $registryKey Registry key for the provider.
	 * @param ProviderInterface $provider    Registered provider instance.
	 *
	 * @return array{key: string, configured: bool, last_success_at: ?string, last_error: ?string}
	 */
	private function projectProvider( string $registryKey, ProviderInterface $provider ): array {
		try {
			$key    = $provider->key();
			$health = $provider->healthCheck();
		} catch ( Throwable $unused ) {
			$key    = $registryKey;
			$health = ProviderHealthStatus::unconfigured( $this->clock->now() );
		}

		$status     = $health->status();
		$configured = $status !== ProviderHealthStatus::STATUS_UNCONFIGURED;

		$lastSuccessAt = null;
		if ( $status === ProviderHealthStatus::STATUS_HEALTHY ) {
			$lastSuccessAt = $health->checkedAt()->format( DateTimeInterface::ATOM );
		}

		$errorStatuses = array(
			ProviderHealthStatus::STATUS_DEGRADED,
			ProviderHealthStatus::STATUS_RATE_LIMITED,
			ProviderHealthStatus::STATUS_UNREACHABLE,
			ProviderHealthStatus::STATUS_INVALID_CREDENTIALS,
		);

		$lastError = null;
		if ( in_array( $status, $errorStatuses, true ) ) {
			$lastError = $health->message();
		}

		return array(
			'key'             => $key,
			'configured'      => $configured,
			'last_success_at' => $lastSuccessAt,
			'last_error'      => $lastError,
		);
	}

	/**
	 * Project a {@see SyncJobRepository::countByStatus()} result
	 * onto the §12.1 `jobs` branch.
	 *
	 * The repository returns DB-vocabulary keys (`pending`,
	 * `running`, `completed`, `failed`, `cancelled`) per §6.0,
	 * with statuses that have zero occurrences omitted entirely.
	 * The §12.1 surface only exposes `queued` and `failed`; the
	 * mapping here projects DB `pending` to wire `queued` and
	 * discards every other DB-vocabulary key. Missing keys default
	 * to 0 so a fresh repository (`array()`) yields
	 * `{queued: 0, failed: 0}`.
	 *
	 * @param array<string, int> $countsByStatus DB-vocabulary histogram.
	 *
	 * @return array{queued: int, failed: int}
	 */
	private function projectJobCounts( array $countsByStatus ): array {
		$queued = 0;
		if ( array_key_exists( self::DB_STATUS_PENDING, $countsByStatus ) ) {
			$queued = (int) $countsByStatus[ self::DB_STATUS_PENDING ];
		}

		$failed = 0;
		if ( array_key_exists( self::DB_STATUS_FAILED, $countsByStatus ) ) {
			$failed = (int) $countsByStatus[ self::DB_STATUS_FAILED ];
		}

		return array(
			'queued' => $queued,
			'failed' => $failed,
		);
	}

	/**
	 * Build the default `environment` provider closure.
	 *
	 * The closure reads the five §12.1 environment axes from PHP
	 * constants and WordPress globals when available. It is only
	 * instantiated when the constructor's `$envProvider` argument
	 * is null; tests override it to drive the branch
	 * deterministically.
	 *
	 * Axis derivation:
	 *
	 *   - `php_version` reads the `PHP_VERSION` constant.
	 *   - `wp_version` reads the global `$wp_version` when
	 *     defined, falling back to the literal `'unknown'` so the
	 *     §12.1 contract still produces a string.
	 *   - `rest_available` is true when `rest_get_server` is a
	 *     defined function (the WordPress REST runtime is loaded).
	 *   - `object_cache_available` is true when
	 *     `wp_using_ext_object_cache` exists AND reports true; an
	 *     unbound function or a built-in non-persistent cache both
	 *     surface as false.
	 *   - `cron_healthy` is true when `DISABLE_WP_CRON` is not
	 *     defined, or defined and falsy. See the file-level
	 *     docblock for the deferred P1 liveness probe note.
	 *
	 * @return callable(): array<string, mixed>
	 */
	private static function defaultEnvProvider(): callable {
		return static function (): array {
			$wpVersion = 'unknown';
			if (
				isset( $GLOBALS['wp_version'] )
				&& is_string( $GLOBALS['wp_version'] )
				&& $GLOBALS['wp_version'] !== ''
			) {
				$wpVersion = $GLOBALS['wp_version'];
			}

			$objectCacheAvailable = false;
			if ( function_exists( 'wp_using_ext_object_cache' ) ) {
				$objectCacheAvailable = (bool) wp_using_ext_object_cache();
			}

			$cronHealthy = true;
			if ( defined( 'DISABLE_WP_CRON' ) ) {
				$cronHealthy = ! constant( 'DISABLE_WP_CRON' );
			}

			return array(
				'php_version'            => PHP_VERSION,
				'wp_version'             => $wpVersion,
				'rest_available'         => function_exists( 'rest_get_server' ),
				'object_cache_available' => $objectCacheAvailable,
				'cron_healthy'           => $cronHealthy,
			);
		};
	}
}
