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

namespace WeeConnectPay\Integrations;

require_once plugin_dir_path( dirname( __FILE__ ) ) . 'Api/ApiClient.php';
require_once plugin_dir_path( dirname( __FILE__ ) ) . 'Api/ApiEndpoints.php';
require_once plugin_dir_path( dirname( __FILE__ ) ) . 'Api/Requests/IntegrationStatusRequest.php';
require_once plugin_dir_path( __FILE__ ) . 'IntegrationHealthVerdict.php';

use WeeConnectPay\Api\Requests\IntegrationStatusRequest;
use WeeConnectPay\Environment;

/**
 * Plugin-side consumer of the backend integration-health contract.
 *
 * Single source of truth for "is this integration authoritatively disconnected?"
 * on the plugin side. The backend (WSO-43/45/49) already classifies authoritative
 * vs transient failures and which provider broke; this class is the thin read layer
 * that surfaces that verdict to the merchant. It is the ONLY plugin class that knows
 * the wire contract, so PrestaShop/Zoho can copy this pattern against the same doc:
 * weeconnect-api/docs/integration-health/plugin-health-contract.md
 *
 * The contract decision is delegated to {@see IntegrationHealthVerdict} (pure, no I/O);
 * this class owns the side effects — the HTTP pull, the per-environment WordPress
 * option, and the log transitions.
 *
 * Load-bearing rule (contract §3): the cached "disconnected" flag is mutated ONLY on
 * an authoritative backend answer. Never on a raw 401, a forwarded Clover error code,
 * or a health call that itself failed. A failed pull proves nothing — keep prior state.
 *
 * Two channels feed the same decision core:
 *   - PULL — {@see reconcile()} hits GET /v1/integration/status on demand at
 *     settings render (and during the reconnect flow, which is how a healed
 *     verdict clears the flag and the gateway returns).
 *   - PUSH — {@see applyFromResponse()} reads the `health` block the backend
 *     attaches to a charge-failure envelope and the `X-WCP-Health` header it
 *     stamps on a guard 401, so the flag flips in the same request a charge
 *     fails in — no settings visit required.
 *
 * @since 4.2.0
 */
class IntegrationHealth {

	/** Per-environment WordPress option suffix holding the cached verdict. */
	const DB_KEY_SUFFIX_HEALTH = 'health';

	/** Per-environment option suffix for the display-only heal-evidence sidecar (WSO-66). */
	const DB_KEY_SUFFIX_HEAL_EVIDENCE = 'health_heal_evidence';

	/** Evidence tier on a healed verdict (contract §4, WSO-64): heal not yet proven by a real Clover API success. */
	const EVIDENCE_UNVERIFIED = 'unverified';

	/** Per-environment transient suffix throttling the autonomous recovery pull. */
	const DB_KEY_SUFFIX_RECONCILE_LOCK = 'health_reconcile_lock';

	/** Seconds the recovery pull stays throttled — caps GET /v1/integration/status to one call per window. */
	const RECONCILE_LOCK_TTL = 60;

	/** DisconnectScenario value => merchant remediation key (codes, not copy). */
	const SCENARIO_CLOVER_API_KEY      = 'clover_api_key';
	const SCENARIO_WEECONNECTPAY_TOKEN = 'weeconnectpay_token';
	const REMEDIATION_RECONNECT_CLOVER = 'reconnect-clover';
	const REMEDIATION_REISSUE_WCP_KEY  = 'reissue-wcp-key';

	/**
	 * @var IntegrationSettings $settings
	 */
	private $settings;

	/**
	 * Cached verdict array { status, scenario, reason_code, set_at } or null when healthy.
	 *
	 * @var array|null $state
	 */
	private $state;

	public function __construct( IntegrationSettings $settings ) {
		$this->settings = $settings;
		$this->state    = $this->loadState();
	}

	/**
	 * On-demand reconcile against the backend's authoritative verdict (PULL channel).
	 *
	 * Only meaningful for client_credentials auth. Sets the flag on an authoritative
	 * failure, clears it on `healed`, and no-ops on anything ambiguous — including a
	 * call that throws (network / 5xx / the endpoint 401ing on a dead WCP token).
	 *
	 * @return self
	 */
	public function reconcile(): self {
		if ( $this->settings->getAuthMethodOrDefault() !== 'client_credentials' ) {
			return $this;
		}

		// No local token at all => nothing to reconcile; accessTokenExists() gates this.
		if ( ! $this->settings->accessTokenExists() ) {
			return $this;
		}

		try {
			$response = ( new IntegrationStatusRequest() )->GET();
			$health   = $this->extractHealthBlock( (string) $response->getBody() );
		} catch ( \Throwable $e ) {
			// A failed pull proves nothing (contract §8.2) — keep prior state.
			LogService::debug( 'Integration health: status pull did not resolve (' . $e->getMessage() . '). Keeping cached state.' );

			return $this;
		}

		$this->applyVerdict( $health );

		return $this;
	}

	/**
	 * Throttled, single-flight wrapper around {@see reconcile()} for the autonomous
	 * recovery path (WSO-61).
	 *
	 * The hard gate hides the gateway while disconnected, which kills the PUSH heal
	 * (no charge can fire) and leaves the PULL firing only when an admin reopens the
	 * settings page. This lets a still-disconnected surface — every is_available()
	 * gate evaluation at cart/checkout — spend one cheap reconcile to notice the
	 * merchant reconnected, so the gateway re-offers itself with no settings visit.
	 *
	 * A per-environment transient bounds that to at most one GET /v1/integration/status
	 * per {@see RECONCILE_LOCK_TTL}, no matter how many concurrent renders hit it. The
	 * lock is claimed BEFORE the pull (single-flight): a concurrent render never
	 * double-fires, and a pull that throws still holds the lock for the window, so a
	 * failing endpoint is not retried on every page load. Callers gate this behind
	 * {@see isDisconnected()} so a healthy store never touches the network here.
	 *
	 * @return self
	 */
	public function reconcileThrottled(): self {
		if ( $this->settings->getAuthMethodOrDefault() !== 'client_credentials' ) {
			return $this;
		}

		$lockKey = IntegrationSettings::PLUGIN_OPTION_PREFIX . Environment::configKey( self::DB_KEY_SUFFIX_RECONCILE_LOCK );
		if ( get_transient( $lockKey ) ) {
			return $this;
		}

		// Claim the window first so a concurrent render — or a throwing pull — never
		// re-fires the endpoint before the throttle expires.
		set_transient( $lockKey, time(), self::RECONCILE_LOCK_TTL );

		return $this->reconcile();
	}

	/**
	 * Consume the PUSH channel: apply the authoritative health verdict the backend
	 * attaches to a charge response — the top-level `health` block on a failure
	 * envelope, or the `X-WCP-Health` header on a guard 401. Runs the same decision
	 * core as {@see reconcile()} ({@see IntegrationHealthVerdict::decide()}), so it
	 * flips the flag only on an authoritative, non-transient disconnect and clears
	 * it only on `healed`; a plain decline / transient 5xx (no block) is a no-op.
	 *
	 * Safe to call on every charge response, success or failure: a response with no
	 * health block leaves the cached state untouched. The header wins over the body
	 * when both are present — the guard 401 is the most authoritative signal we get.
	 *
	 * Gated to client_credentials like {@see reconcile()}: the health feature does
	 * not exist for legacy-JWT integrations, so their charge responses never mutate
	 * the flag.
	 *
	 * @param array      $body    The decoded response envelope (top-level — the
	 *                            `health` key lives beside `result`/`data`).
	 * @param array      $headers PSR-7 header map (name => string[] | string).
	 * @return self
	 */
	public function applyFromResponse( array $body, array $headers = array() ): self {
		if ( $this->settings->getAuthMethodOrDefault() !== 'client_credentials' ) {
			return $this;
		}

		$this->applyVerdict( $this->resolveHealthBlock( $body, $headers ) );

		return $this;
	}

	/**
	 * Whether the integration is authoritatively disconnected per the cached verdict.
	 *
	 * @return bool
	 */
	public function isDisconnected(): bool {
		return IntegrationHealthVerdict::isDisconnectedState( $this->state );
	}

	/**
	 * Whether a SPECIFIC environment is authoritatively disconnected per its cached
	 * verdict, without instantiating against the current environment's settings.
	 *
	 * Lets surfaces that iterate every environment (e.g. the env-switcher connected
	 * dots) honour the same authoritative verdict {@see isDisconnected} applies to the
	 * active environment, instead of trusting bare token presence. Reads stored truth
	 * only: an environment that has never reconciled has no verdict, so this returns
	 * false and the caller falls back to token presence.
	 *
	 * @param string $envKey
	 * @return bool
	 */
	public static function isDisconnectedForEnv( string $envKey ): bool {
		$stored = get_option( IntegrationSettings::PLUGIN_OPTION_PREFIX . Environment::configKeyFor( self::DB_KEY_SUFFIX_HEALTH, $envKey ) );

		return IntegrationHealthVerdict::isDisconnectedState( is_array( $stored ) ? $stored : null );
	}

	/**
	 * @return string|null
	 */
	public function status(): ?string {
		return $this->state['status'] ?? null;
	}

	/**
	 * Which provider's credentials broke (DisconnectScenario value), or null.
	 *
	 * @return string|null
	 */
	public function scenario(): ?string {
		return $this->state['scenario'] ?? null;
	}

	/**
	 * @return string|null
	 */
	public function reasonCode(): ?string {
		return $this->state['reason_code'] ?? null;
	}

	/**
	 * Remediation key for the merchant message, derived from `scenario` only.
	 *
	 * `scenario` is the stable axis (which provider broke); `reason_code` is detail for
	 * logs and fine-grained copy. The backend already normalizes `reason_code` to a
	 * closed set via TokenRevocationReason::fromRawReason() (no dynamic `_http_NNN`
	 * suffixes), but per the contract an unrecognized code must still degrade
	 * gracefully — so remediation never branches on `reason_code`, and an unknown
	 * `scenario` falls back to the generic disconnect message.
	 *
	 * @return string|null
	 */
	public function remediation(): ?string {
		switch ( $this->scenario() ) {
			case self::SCENARIO_CLOVER_API_KEY:
				return self::REMEDIATION_RECONNECT_CLOVER;
			case self::SCENARIO_WEECONNECTPAY_TOKEN:
				return self::REMEDIATION_REISSUE_WCP_KEY;
			default:
				return null;
		}
	}

	/**
	 * Whether the integration healed on token evidence alone and is still awaiting
	 * its first proven Clover API success (contract §4 `evidence: unverified`).
	 *
	 * Display-only (WSO-66): never feeds flip/clear decisions or the is_available()
	 * gate. False whenever the integration is disconnected — the disconnect notice
	 * supersedes the reassurance copy. A missing/absent sidecar means the heal is
	 * verified-equivalent (back-compat with pre-WSO-64 backends).
	 *
	 * @return bool
	 */
	public function isAwaitingFirstTransaction(): bool {
		if ( $this->isDisconnected() ) {
			return false;
		}

		$sidecar = get_option( $this->healEvidenceOptionKey() );

		return is_array( $sidecar ) && ( $sidecar['evidence'] ?? null ) === self::EVIDENCE_UNVERIFIED;
	}

	/**
	 * The health block handed to the React app via vue_data. Codes, not copy.
	 *
	 * @return array{status: ?string, scenario: ?string, reason_code: ?string, remediation: ?string, disconnected: bool, awaiting_first_transaction: bool}
	 */
	public function toArray(): array {
		return array(
			'status'                     => $this->status(),
			'scenario'                   => $this->scenario(),
			'reason_code'                => $this->reasonCode(),
			'remediation'                => $this->remediation(),
			'disconnected'               => $this->isDisconnected(),
			'awaiting_first_transaction' => $this->isAwaitingFirstTransaction(),
		);
	}

	/**
	 * Apply a parsed backend health block: persist the decided next state and log the
	 * transition once. The decision itself lives in IntegrationHealthVerdict (pure).
	 *
	 * @param array|null $health { status, scenario, reason_code, authoritative, transient }
	 * @return void
	 */
	private function applyVerdict( ?array $health ): void {
		$decision = IntegrationHealthVerdict::decide( $this->state, $health, gmdate( 'c' ) );

		$previous    = $this->state;
		$this->state = $decision['state'];

		if ( $decision['state'] === null ) {
			if ( $previous !== null ) {
				delete_option( $this->optionKey() );
			}
		} else {
			update_option( $this->optionKey(), $decision['state'] );
		}

		$this->syncHealEvidence( $health, $decision['state'] );

		if ( $decision['log'] === IntegrationHealthVerdict::LOG_DISCONNECTED ) {
			LogService::error( sprintf(
				'Integration health: DISCONNECTED — env=%s status=%s scenario=%s reason=%s',
				Environment::getCurrentKey(),
				$decision['state']['status'] ?? 'n/a',
				$decision['state']['scenario'] ?? 'n/a',
				$decision['state']['reason_code'] ?? 'n/a'
			) );
		} elseif ( $decision['log'] === IntegrationHealthVerdict::LOG_HEALED ) {
			LogService::info( 'Integration health: reconnected (healed) for env ' . Environment::getCurrentKey() );
		}
	}

	/**
	 * Keep the display-only heal-evidence sidecar in sync with the backend's
	 * `evidence` key (contract §4, additive since WSO-64). Mirrors decide()'s
	 * healed acceptance (non-transient `healed` status) without ever feeding
	 * back into it — flip/clear semantics do not read this option.
	 *
	 * Rules: a fresh disconnect deletes the sidecar (the error notice supersedes
	 * it); an accepted healed verdict with `evidence: unverified` persists it
	 * (preserving the first-detection timestamp across re-confirmations); any
	 * other evidence value on a heal — `verified`, missing, null, or a future
	 * vocabulary extension — is verified-equivalent and deletes it. Everything
	 * else (no block, transient, stale, unknown) leaves the sidecar untouched,
	 * matching the decision core's no-op stance.
	 *
	 * @param array|null $health    Raw parsed backend health block, or null.
	 * @param array|null $nextState The verdict state decide() chose to persist.
	 * @return void
	 */
	private function syncHealEvidence( ?array $health, ?array $nextState ): void {
		if ( IntegrationHealthVerdict::isDisconnectedState( $nextState ) ) {
			delete_option( $this->healEvidenceOptionKey() );

			return;
		}

		$isAcceptedHeal = $health !== null
			&& ( $health['status'] ?? null ) === IntegrationHealthVerdict::STATUS_HEALED
			&& empty( $health['transient'] );

		if ( ! $isAcceptedHeal ) {
			return;
		}

		if ( ( $health['evidence'] ?? null ) === self::EVIDENCE_UNVERIFIED ) {
			$existing = get_option( $this->healEvidenceOptionKey() );

			update_option( $this->healEvidenceOptionKey(), array(
				'evidence' => self::EVIDENCE_UNVERIFIED,
				'set_at'   => is_array( $existing ) && isset( $existing['set_at'] ) ? $existing['set_at'] : gmdate( 'c' ),
			) );

			return;
		}

		delete_option( $this->healEvidenceOptionKey() );
	}

	/**
	 * Pull the health block out of a backend response body string (the PULL
	 * channel's GET /v1/integration/status payload), optionally letting a
	 * `X-WCP-Health` header override it.
	 *
	 * @param string $body
	 * @param array  $headers
	 * @return array|null
	 */
	private function extractHealthBlock( string $body, array $headers = array() ): ?array {
		$decoded = json_decode( $body, true );

		return $this->resolveHealthBlock( is_array( $decoded ) ? $decoded : null, $headers );
	}

	/**
	 * Resolve the health block from an already-decoded envelope and/or response
	 * headers. Single extraction path shared by the PULL ({@see extractHealthBlock})
	 * and PUSH ({@see applyFromResponse}) channels.
	 *
	 * Precedence: the `X-WCP-Health` header wins when present (it is the guard-401
	 * signal, the most authoritative answer we get), then a top-level `health` key,
	 * then a bare top-level `status` shape (the status endpoint's payload).
	 *
	 * @param array|null $decoded Decoded response envelope, or null.
	 * @param array      $headers PSR-7 header map (name => string[] | string).
	 * @return array|null
	 */
	private function resolveHealthBlock( ?array $decoded, array $headers = array() ): ?array {
		$fromHeader = $this->healthFromHeader( $headers );
		if ( $fromHeader !== null ) {
			return $fromHeader;
		}

		if ( is_array( $decoded ) ) {
			if ( isset( $decoded['health'] ) && is_array( $decoded['health'] ) ) {
				return $decoded['health'];
			}

			if ( isset( $decoded['status'] ) ) {
				return $decoded;
			}
		}

		return null;
	}

	/**
	 * Decode the JSON health block carried in the `X-WCP-Health` response header,
	 * tolerant of PSR-7's name => string[] shape and case-insensitive header names.
	 *
	 * @param array $headers
	 * @return array|null
	 */
	private function healthFromHeader( array $headers ): ?array {
		foreach ( $headers as $name => $value ) {
			if ( strtolower( (string) $name ) !== 'x-wcp-health' ) {
				continue;
			}

			$raw = is_array( $value ) ? ( $value[0] ?? null ) : $value;
			if ( ! is_string( $raw ) || $raw === '' ) {
				return null;
			}

			$decoded = json_decode( $raw, true );

			return is_array( $decoded ) ? $decoded : null;
		}

		return null;
	}

	/**
	 * @return array|null
	 */
	private function loadState(): ?array {
		$stored = get_option( $this->optionKey() );

		return is_array( $stored ) ? $stored : null;
	}

	/**
	 * @return string
	 */
	private function optionKey(): string {
		return IntegrationSettings::PLUGIN_OPTION_PREFIX . Environment::configKey( self::DB_KEY_SUFFIX_HEALTH );
	}

	/**
	 * @return string
	 */
	private function healEvidenceOptionKey(): string {
		return IntegrationSettings::PLUGIN_OPTION_PREFIX . Environment::configKey( self::DB_KEY_SUFFIX_HEAL_EVIDENCE );
	}
}
