<?php
/* phpcs:disable WordPress
 * phpcs:disable Generic.Arrays.DisallowShortArraySyntax */

namespace WeeConnectPay\Integrations;

/**
 * Pure decision core for the backend integration-health contract.
 *
 * Deliberately free of WordPress, HTTP, and the plugin's require chain so the
 * contract state machine (contract §3/§6) can be reasoned about and tested in
 * isolation. {@see IntegrationHealth} owns all I/O (HTTP pull, option storage,
 * logging) and delegates every "should the cached flag change?" question here.
 *
 * CANONICAL SOURCE OF TRUTH (cross-plugin standard). This is the reference
 * decision core every WeeConnectPay plugin copy-ports verbatim — PrestaShop and
 * Zoho copy this file (translated only for language idioms, never logic) so the
 * "is this a disconnect?" / "flip or clear?" semantics stay identical across
 * platforms. Changes here ripple to every plugin; see the cross-plugin standard
 * at weeconnect-api/docs/integration-health/ and the porting guide at
 * weeconnect-api/.claude/docs/integration-health-plugin-guide.md before editing.
 *
 * @since 4.2.0
 */
class IntegrationHealthVerdict {

	/** Contract status vocabulary (mirror WSO-49's pill set — do not extend). */
	const STATUS_HEALED                  = 'healed';
	const STATUS_STALE                   = 'stale';
	const STATUS_REFRESH_FAIL            = 'refresh_fail';
	const STATUS_WCP_CREDENTIALS_INVALID = 'wcp_credentials_invalid';
	const STATUS_APP_UNINSTALLED         = 'app_uninstalled';
	const STATUS_DISCONNECTED            = 'disconnected';
	const STATUS_UNKNOWN                 = 'unknown';

	/** Statuses that mean the integration cannot transact and the merchant must act. */
	const DISCONNECTED_STATUSES = array(
		self::STATUS_REFRESH_FAIL,
		self::STATUS_WCP_CREDENTIALS_INVALID,
		self::STATUS_APP_UNINSTALLED,
		self::STATUS_DISCONNECTED,
	);

	/** Log markers returned to the caller so it can record the transition once. */
	const LOG_NONE         = null;
	const LOG_DISCONNECTED = 'disconnected';
	const LOG_HEALED       = 'healed';

	/**
	 * Decide the next cached verdict from the current one and a fresh backend health block.
	 *
	 * Honors the load-bearing rule (contract §3): only an authoritative, non-transient
	 * answer flips the flag; `healed` clears it; everything else (unknown/stale/
	 * non-authoritative) is a no-op so we neither hide a real disconnect nor invent one.
	 *
	 * @param array|null $current The currently stored verdict { status, scenario, reason_code, set_at } or null.
	 * @param array|null $health  Parsed backend health block { status, scenario, reason_code, authoritative, transient } or null.
	 * @param string     $nowIso  Timestamp to stamp a freshly-detected disconnect with (ISO-8601).
	 * @return array{state: array|null, log: string|null} Next state to persist (null = clear) and the transition to log.
	 */
	public static function decide( ?array $current, ?array $health, string $nowIso ): array {
		$noChange = array( 'state' => $current, 'log' => self::LOG_NONE );

		if ( $health === null || ! isset( $health['status'] ) ) {
			return $noChange;
		}

		// Transient failures are explicitly retry-able — never flip (contract §6).
		if ( ! empty( $health['transient'] ) ) {
			return $noChange;
		}

		$status          = (string) $health['status'];
		$wasDisconnected = self::isDisconnectedState( $current );

		if ( in_array( $status, self::DISCONNECTED_STATUSES, true ) ) {
			// Only a decided (authoritative) failure flips the flag (contract §3).
			if ( empty( $health['authoritative'] ) ) {
				return $noChange;
			}

			$reasonCode = isset( $health['reason_code'] ) ? (string) $health['reason_code'] : null;
			// Preserve the original detection time across re-confirmations of the same break.
			$setAt = ( $wasDisconnected && isset( $current['set_at'] ) ) ? $current['set_at'] : $nowIso;

			$next = array(
				'status'      => $status,
				'scenario'    => isset( $health['scenario'] ) ? (string) $health['scenario'] : null,
				'reason_code' => $reasonCode,
				'set_at'      => $setAt,
			);

			$changed = ! $wasDisconnected
				|| ( $current['status'] ?? null ) !== $status
				|| ( $current['reason_code'] ?? null ) !== $reasonCode;

			return array( 'state' => $next, 'log' => $changed ? self::LOG_DISCONNECTED : self::LOG_NONE );
		}

		// A clean healthy verdict clears a stale flag; `unknown`/`stale` stay no-ops.
		if ( $status === self::STATUS_HEALED ) {
			return array(
				'state' => null,
				'log'   => $wasDisconnected ? self::LOG_HEALED : self::LOG_NONE,
			);
		}

		return $noChange;
	}

	/**
	 * Whether a stored verdict array represents an authoritative disconnect.
	 *
	 * @param array|null $state
	 * @return bool
	 */
	public static function isDisconnectedState( ?array $state ): bool {
		return is_array( $state )
			&& isset( $state['status'] )
			&& in_array( $state['status'], self::DISCONNECTED_STATUSES, true );
	}
}
