<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.

namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC;

use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Discovery_Interface;
use Yoast\WP\SEO\MyYoast_Client\Domain\Discovery_Document;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Http\HTTP_Client;
use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
use YoastSEO_Vendor\Psr\Log\NullLogger;

/**
 * Fetches and caches the OpenID Connect discovery document.
 *
 * Discovers all endpoint URLs dynamically from `{issuer}/.well-known/openid-configuration`.
 * Caches the document as a WordPress transient for 24 hours.
 */
class Discovery_Client implements Discovery_Interface, LoggerAwareInterface {
	use LoggerAwareTrait;

	private const CACHE_TRANSIENT_PREFIX = 'wpseo_myyoast_oidc_';
	private const CACHE_TTL              = \DAY_IN_SECONDS;

	/**
	 * The issuer configuration.
	 *
	 * @var Issuer_Config
	 */
	private $issuer_config;

	/**
	 * The HTTP client.
	 *
	 * @var HTTP_Client
	 */
	private $http_client;

	/**
	 * In-memory cache of the discovery document.
	 *
	 * @var Discovery_Document|null
	 */
	private $cached_document = null;

	/**
	 * Discovery_Client constructor.
	 *
	 * @param Issuer_Config $issuer_config The issuer configuration.
	 * @param HTTP_Client   $http_client   The HTTP client.
	 */
	public function __construct( Issuer_Config $issuer_config, HTTP_Client $http_client ) {
		$this->issuer_config = $issuer_config;
		$this->http_client   = $http_client;
		$this->logger        = new NullLogger();
	}

	/**
	 * Returns the validated discovery document.
	 *
	 * @return Discovery_Document The validated discovery document.
	 *
	 * @throws Discovery_Failed_Exception  If the document cannot be fetched.
	 * @throws Server_Capability_Exception If the server lacks required capabilities.
	 */
	public function get_document(): Discovery_Document {
		// Check in-memory cache: invalidate if issuer has changed.
		if ( $this->cached_document !== null ) {
			if ( $this->cached_document->get_issuer() === $this->issuer_config->get_issuer_url() ) {
				return $this->cached_document;
			}
			$this->cached_document = null;
		}

		$cached = \get_transient( $this->get_cache_key() );
		if ( \is_array( $cached ) && ! empty( $cached ) ) {
			try {
				$this->cached_document = new Discovery_Document( $cached );
				return $this->cached_document;
			} catch ( Discovery_Failed_Exception | Server_Capability_Exception $e ) {
				// Cached data is corrupted or no longer compatible — fetch fresh.
				$this->logger->info( 'Invalidating cached discovery document: {error}', [ 'error' => $e->getMessage() ] );
				\delete_transient( $this->get_cache_key() );
			}
		}

		return $this->fetch_and_cache();
	}

	/**
	 * Invalidates the cached discovery document.
	 *
	 * @return void
	 */
	public function invalidate_cache(): void {
		$this->cached_document = null;
		\delete_transient( $this->get_cache_key() );
	}

	/**
	 * Returns the issuer-specific transient cache key.
	 *
	 * The issuer URL is hashed into the key so that switching issuers
	 * (e.g. via environment variable or filter) naturally causes a cache miss
	 * instead of returning stale data from a different server.
	 *
	 * @return string The transient key.
	 */
	private function get_cache_key(): string {
		return self::CACHE_TRANSIENT_PREFIX . $this->issuer_config->get_issuer_key();
	}

	/**
	 * Fetches the discovery document from the server, validates it, and caches it.
	 *
	 * @return Discovery_Document The validated discovery document.
	 *
	 * @throws Discovery_Failed_Exception If the document cannot be fetched or parsed.
	 */
	private function fetch_and_cache(): Discovery_Document {
		$url    = $this->issuer_config->get_discovery_url();
		$result = $this->http_client->request(
			'GET',
			$url,
			[
				'timeout' => 10,
				'headers' => [ 'Accept' => 'application/json' ],
			],
		);

		if ( $result->is_transport_failure() ) {
			$error_message = (string) $result->get_body_value( 'error_description', '' );
			throw new Discovery_Failed_Exception(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
				\sprintf( 'Failed to fetch OIDC discovery document from %s: %s', $url, $error_message ),
			);
		}

		if ( $result->get_status() !== 200 ) {
			throw new Discovery_Failed_Exception(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
				\sprintf( 'OIDC discovery returned HTTP %d from %s.', $result->get_status(), $url ),
			);
		}

		$body = $result->get_body();
		if ( ! \is_array( $body ) ) {
			throw new Discovery_Failed_Exception( 'OIDC discovery returned invalid JSON.' );
		}

		$document = new Discovery_Document( $body );

		// OIDC Discovery 1.0 Section 4.1: the issuer in the document must match the expected issuer.
		$expected_issuer = $this->issuer_config->get_issuer_url();
		if ( $document->get_issuer() !== $expected_issuer ) {
			throw new Discovery_Failed_Exception(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
				\sprintf( 'Issuer mismatch: expected %s, got %s.', $expected_issuer, $document->get_issuer() ),
			);
		}

		\set_transient( $this->get_cache_key(), $body, self::CACHE_TTL );
		$this->cached_document = $document;

		return $document;
	}
}
