<?php
/**
 * Provider service provider.
 *
 * Phase 12 slice 12c Bootstrap-layer service provider that
 * centralizes construction of the three §4 affiliate providers
 * (`amazon`, `rakuten`, `yahoo_shopping`) into a single
 * {@see ProviderRegistry} bound under
 * {@see self::BINDING_PROVIDER_REGISTRY}. The registry is the
 * single seam every consumer of the §10.3 / §12.1 enumerations
 * resolves through, replacing the Phase-6 / Phase-11 minimum that
 * wired each consumer with its own empty `array()` default.
 *
 * Provider ordering — register() resolves bindings published by
 * earlier providers, so the bootstrap pipeline in
 * `affilun.php` MUST register
 * {@see PersistenceServiceProvider} (publishes
 * {@see PersistenceServiceProvider::BINDING_OPTION_STORE}) before
 * this provider, and this provider before every consumer that
 * resolves {@see self::BINDING_PROVIDER_REGISTRY} (currently
 * {@see EditorServiceProvider}, {@see DiagnosticsServiceProvider},
 * and {@see \Affilun\Presentation\Admin\AdminServiceProvider}).
 *
 * Resolution policy:
 *
 *   1. Resolve the {@see OptionStore} from
 *      {@see PersistenceServiceProvider::BINDING_OPTION_STORE} and
 *      construct an inline {@see OptionStoreSettingsRepository}.
 *      No new persistence binding is added — the inline
 *      construction keeps slice 12c small.
 *
 *   2. Read the §13 settings via
 *      {@see SettingsRepository::load()}. The repository is
 *      documented to recover from malformed / unset payloads by
 *      returning {@see PluginSettings::defaults()}, so a fresh
 *      install yields an empty providers map and this provider
 *      binds an empty {@see ProviderRegistry}.
 *
 *   3. Construct each provider only when its
 *      {@see ProviderCredentials::isConfigured()} returns true.
 *      The §13.4 secrets map shape per
 *      {@see ProviderCredentials} is:
 *
 *        - `amazon`: `credential_id`, `credential_secret`,
 *          `version`, `partner_tag` (mapped onto `partnerTag`).
 *          The marketplace is fixed to `www.amazon.co.jp` for
 *          affilun's Japan-focused setup and is not exposed in
 *          the §13.4 form.
 *
 *        - `rakuten`: `application_id`, `affiliate_id`. The
 *          `developer_id` field is optional and currently not
 *          surfaced through the §13.4 form, so the provider is
 *          constructed without it.
 *
 *        - `yahoo`: `app_id` (Client ID, required) plus optional
 *          ValueCommerce `affiliate_sid` / `affiliate_pid`. The
 *          legacy `affiliate_parameters` slot is no longer
 *          surfaced by the §13.4 form; persisted values from
 *          older installations are accepted by
 *          {@see ProviderCredentials::yahoo} but ignored.
 *
 *   4. Wrap the constructed providers into a
 *      {@see ProviderRegistry} keyed in canonical display order
 *      (`amazon`, `rakuten`, `yahoo_shopping`), and bind under
 *      {@see self::BINDING_PROVIDER_REGISTRY}.
 *
 * Failure recovery:
 *
 *   - When {@see SettingsRepository::load()} throws (the
 *     production {@see OptionStoreSettingsRepository} does not,
 *     but a future implementation might), this provider catches
 *     {@see \Throwable} and binds an empty registry rather than
 *     blowing up the bootstrap pipeline. A misbehaving settings
 *     layer must not turn the editor / diagnostics surfaces dark.
 *
 *   - When a provider's credentials are present but the
 *     constructor rejects them (e.g. a region token outside the
 *     {@see AmazonCredentials::SUPPORTED_REGIONS} whitelist), the
 *     {@see InvalidArgumentException} is caught and the offending
 *     provider is skipped. The other providers still register so
 *     a single misconfigured credential pair cannot dark every
 *     surface.
 *
 * Test seam:
 *
 *   - The optional `$registryOverride` constructor parameter
 *     bypasses the settings-driven construction entirely and
 *     binds the supplied registry verbatim. Tests that need to
 *     drive the §10.3 / §12.1 enumeration with synthetic
 *     {@see ProviderInterface} stubs use this seam to avoid
 *     standing up the full credentials → provider graph.
 *
 *   - Optional `$httpClientOverride` and `$clockOverride`
 *     constructor parameters substitute the production
 *     {@see WpRemoteHttpClient} / {@see SystemClock} defaults so
 *     contract tests that exercise the registry-driven graph
 *     against fixture HTTP responses can do so without touching
 *     `wp_remote_*` or the wall clock.
 *
 * @package Affilun\Presentation\Rest
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest;

use Affilun\Application\Provider\ProviderRegistry;
use Affilun\Application\Settings\OptionStoreSettingsRepository;
use Affilun\Application\Settings\PluginSettings;
use Affilun\Application\Settings\ProviderCredentials;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Bootstrap\HookRegistrar;
use Affilun\Bootstrap\ServiceProvider;
use Affilun\Bootstrap\ServiceProviderRegistry;
use Affilun\Domain\Clock;
use Affilun\Domain\Http\HttpClient;
use Affilun\Domain\Provider\ProviderInterface;
use Affilun\Infrastructure\Clock\SystemClock;
use Affilun\Infrastructure\Http\WpRemoteHttpClient;
use Affilun\Infrastructure\Persistence\OptionStore;
use Affilun\Infrastructure\Persistence\PersistenceServiceProvider;
use Affilun\Infrastructure\Provider\Amazon\CreatorsApi\AmazonCreatorsApiAffiliateLinkBuilder;
use Affilun\Infrastructure\Provider\Amazon\CreatorsApi\AmazonCreatorsApiCredentials;
use Affilun\Infrastructure\Provider\Amazon\CreatorsApi\AmazonCreatorsApiErrorMapper;
use Affilun\Infrastructure\Provider\Amazon\CreatorsApi\AmazonCreatorsApiProvider;
use Affilun\Infrastructure\Provider\Amazon\CreatorsApi\AmazonCreatorsApiRequestFactory;
use Affilun\Infrastructure\Provider\Amazon\CreatorsApi\AmazonCreatorsApiResponseNormalizer;
use Affilun\Infrastructure\Provider\Amazon\CreatorsApi\AmazonCreatorsApiTokenProvider;
use Affilun\Infrastructure\Provider\Mercari\MercariProvider;
use Affilun\Infrastructure\Provider\Rakuten\RakutenAffiliateLinkBuilder;
use Affilun\Infrastructure\Provider\Rakuten\RakutenCredentials;
use Affilun\Infrastructure\Provider\Rakuten\RakutenErrorMapper;
use Affilun\Infrastructure\Provider\Rakuten\RakutenProvider;
use Affilun\Infrastructure\Provider\Rakuten\RakutenRequestFactory;
use Affilun\Infrastructure\Provider\Rakuten\RakutenResponseNormalizer;
use Affilun\Infrastructure\Provider\YahooShopping\YahooShoppingAffiliateLinkBuilder;
use Affilun\Infrastructure\Provider\YahooShopping\YahooShoppingCredentials;
use Affilun\Infrastructure\Provider\YahooShopping\YahooShoppingErrorMapper;
use Affilun\Infrastructure\Provider\YahooShopping\YahooShoppingProvider;
use Affilun\Infrastructure\Provider\YahooShopping\YahooShoppingRequestFactory;
use Affilun\Infrastructure\Provider\YahooShopping\YahooShoppingResponseNormalizer;
use InvalidArgumentException;
use LogicException;
use Throwable;

/**
 * Binds the {@see ProviderRegistry} resolved from the §13 Settings
 * aggregate.
 *
 * See the file-level docblock for the resolution policy, the
 * per-provider credentials → constructor mapping, the
 * recovery posture when settings load or per-provider construction
 * throws, and the constructor override seams used by tests.
 */
final class ProviderServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the {@see ProviderRegistry} is bound.
	 */
	public const BINDING_PROVIDER_REGISTRY = 'affilun.providers.registry';

	// KI-019 Phase B (ADR-0022): the old DEFAULT_AMAZON_REGION
	// constant was removed. Creators API credentials carry both
	// credential version and marketplace, so this provider no
	// longer needs a separate region constant.

	/**
	 * Test override that bypasses the settings-driven construction
	 * entirely and binds the supplied registry verbatim.
	 */
	private ?ProviderRegistry $registryOverride;

	/**
	 * HTTP client override; defaults to {@see WpRemoteHttpClient}.
	 */
	private ?HttpClient $httpClientOverride;

	/**
	 * Clock override; defaults to {@see SystemClock}.
	 */
	private ?Clock $clockOverride;

	/**
	 * Construct a provider service provider with optional overrides.
	 *
	 * Empty / no-arg construction is the production default.
	 *
	 * @param ProviderRegistry|null $registryOverride   Test seam that
	 *                                                  bypasses the
	 *                                                  settings-driven
	 *                                                  construction.
	 * @param HttpClient|null       $httpClientOverride HTTP client override.
	 * @param Clock|null            $clockOverride      Clock override.
	 */
	public function __construct(
		?ProviderRegistry $registryOverride = null,
		?HttpClient $httpClientOverride = null,
		?Clock $clockOverride = null
	) {
		$this->registryOverride   = $registryOverride;
		$this->httpClientOverride = $httpClientOverride;
		$this->clockOverride      = $clockOverride;
	}

	/**
	 * Bind the {@see ProviderRegistry} resolved from settings.
	 *
	 * See the file-level docblock for the per-provider construction
	 * rules, the canonical key order
	 * (`amazon`, `rakuten`, `yahoo_shopping`), and the recovery
	 * posture when settings load or per-provider construction
	 * throws.
	 *
	 * @param ServiceProviderRegistry $registry Registry driving
	 *                                          this bootstrap pass.
	 *
	 * @throws LogicException When the persistence provider has not
	 *                        registered before this one.
	 */
	public function register( ServiceProviderRegistry $registry ): void {
		if ( $this->registryOverride !== null ) {
			$registry->bind( self::BINDING_PROVIDER_REGISTRY, $this->registryOverride );
			return;
		}

		$optionStore = $this->resolveOptionStore( $registry );
		$repository  = new OptionStoreSettingsRepository( $optionStore );

		$providers = $this->buildProviders( $repository );

		$registry->bind( self::BINDING_PROVIDER_REGISTRY, new ProviderRegistry( $providers ) );
	}

	/**
	 * No-op — this provider only publishes a registry binding and
	 * has no hook wiring of its own.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar.
	 */
	public function boot( HookRegistrar $hooks ): void {
		unset( $hooks );
	}

	/**
	 * Resolve {@see OptionStore} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not an
	 *                        {@see OptionStore}.
	 */
	private function resolveOptionStore( ServiceProviderRegistry $registry ): OptionStore {
		$key = PersistenceServiceProvider::BINDING_OPTION_STORE;
		if ( ! $registry->has( $key ) ) {
			$message = 'ProviderServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing OptionStore binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$store = $registry->get( $key );
		if ( ! $store instanceof OptionStore ) {
			$message = sprintf(
				'ProviderServiceProvider expected OptionStore under "%s"; got %s.',
				$key,
				is_object( $store ) ? get_class( $store ) : gettype( $store )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $store;
	}

	/**
	 * Build the canonical-order provider map from the §13 settings.
	 *
	 * Settings load failures are caught at the {@see Throwable}
	 * level and yield an empty providers map; per-provider
	 * construction failures are caught at the
	 * {@see InvalidArgumentException} level and yield a skipped
	 * provider while the others still register.
	 *
	 * @param SettingsRepository $repository Settings repository.
	 *
	 * @return array<string, ProviderInterface>
	 */
	private function buildProviders( SettingsRepository $repository ): array {
		try {
			$settings = $repository->load();
		} catch ( Throwable $unused ) {
			unset( $unused );
			return array();
		}

		$providers = array();

		$amazon = $this->buildAmazonProvider( $settings );
		if ( $amazon !== null ) {
			$providers[ AmazonCreatorsApiProvider::PROVIDER_KEY ] = $amazon;
		}

		$rakuten = $this->buildRakutenProvider( $settings );
		if ( $rakuten !== null ) {
			$providers[ RakutenProvider::PROVIDER_KEY ] = $rakuten;
		}

		$yahoo = $this->buildYahooProvider( $settings );
		if ( $yahoo !== null ) {
			$providers[ YahooShoppingProvider::PROVIDER_KEY ] = $yahoo;
		}

		$mercari = $this->buildMercariProvider( $settings );
		if ( $mercari !== null ) {
			$providers[ MercariProvider::PROVIDER_KEY ] = $mercari;
		}

		return $providers;
	}

	/**
	 * Construct the {@see AmazonCreatorsApiProvider} when the
	 * §13.4 Amazon Creators API credentials are configured, else
	 * return null.
	 *
	 * **KI-019 Phase B (ADR-0022)**: 旧 PA-API 5.0 ベースの
	 * `AmazonProvider` を {@see AmazonCreatorsApiProvider} に完全置換。
	 * Creators API は OAuth 2.0 (Credential ID / Credential Secret /
	 * Version / Partner Tag / Marketplace) で認証し、`partnerTag`
	 * + `marketplace` を全リクエストに stamp する。
	 *
	 * 構築手順:
	 *
	 *   1. `$settings->provider('amazon')` から
	 *      {@see ProviderCredentials} を取り出し、enabled かつ
	 *      設定済みの場合のみ続行 (空欄は "unconfigured" として
	 *      provider を bind しない)。
	 *
	 *   2. 4 つの user-editable secret (`credential_id` /
	 *      `credential_secret` / `version` / `partner_tag`) を読み出し、
	 *      marketplace は affilun 固定値 `www.amazon.co.jp` を使って
	 *      {@see AmazonCreatorsApiCredentials} を構築。validation
	 *      失敗 (空 / 不正な version / 不正な marketplace) は
	 *      {@see InvalidArgumentException} を投げるので、その場合は
	 *      provider を bind せず null を返す (degraded path、
	 *      diagnostics 側で `unconfigured` として表示される)。
	 *
	 *   3. {@see AmazonCreatorsApiTokenProvider} を構築。WordPress
	 *      object cache adapter は Phase B MVP では injected しない
	 *      ので、in-memory cache のみで動作する (1 リクエスト内では
	 *      token cache が効くが、リクエスト跨ぎで毎回 token を
	 *      取り直す。これは Amazon の rate limit 設計上問題ない
	 *      範囲。WP object cache adapter の wiring は別 slice で
	 *      対応)。
	 *
	 *   4. {@see AmazonCreatorsApiRequestFactory} /
	 *      {@see AmazonCreatorsApiResponseNormalizer} /
	 *      {@see AmazonCreatorsApiErrorMapper} /
	 *      {@see AmazonCreatorsApiAffiliateLinkBuilder} を順に構築し、
	 *      これらと共に
	 *      {@see AmazonCreatorsApiProvider} を返す。
	 *
	 * @param PluginSettings $settings §13 settings snapshot.
	 */
	private function buildAmazonProvider( PluginSettings $settings ): ?AmazonCreatorsApiProvider {
		$creds = $settings->provider( 'amazon' );
		if ( $creds === null || ! $creds->enabled() || ! $creds->isConfigured() ) {
			return null;
		}

		$credentialId     = (string) $creds->secret( 'credential_id' );
		$credentialSecret = (string) $creds->secret( 'credential_secret' );
		$version          = (string) $creds->secret( 'version' );
		$partnerTag       = (string) $creds->secret( 'partner_tag' );
		$marketplace      = ProviderCredentials::AMAZON_MARKETPLACE;

		try {
			$credentials = new AmazonCreatorsApiCredentials(
				$credentialId,
				$credentialSecret,
				$version,
				$partnerTag,
				$marketplace
			);
		} catch ( InvalidArgumentException $unused ) {
			unset( $unused );
			return null;
		}

		$clock      = $this->resolveClock();
		$httpClient = $this->resolveHttpClient();

		$tokenProvider        = new AmazonCreatorsApiTokenProvider( $httpClient, $clock );
		$requestFactory       = new AmazonCreatorsApiRequestFactory( $credentials, $tokenProvider );
		$responseNormalizer   = new AmazonCreatorsApiResponseNormalizer();
		$errorMapper          = new AmazonCreatorsApiErrorMapper();
		$affiliateLinkBuilder = new AmazonCreatorsApiAffiliateLinkBuilder( $credentials );

		return new AmazonCreatorsApiProvider(
			$credentials,
			$httpClient,
			$tokenProvider,
			$requestFactory,
			$responseNormalizer,
			$errorMapper,
			$affiliateLinkBuilder,
			$clock
		);
	}

	/**
	 * Construct the {@see RakutenProvider} when the §13.4 Rakuten
	 * credentials are configured, else return null. Rakuten's
	 * current API requires an access key in addition to the
	 * application and affiliate identifiers.
	 *
	 * @param PluginSettings $settings §13 settings snapshot.
	 */
	private function buildRakutenProvider( PluginSettings $settings ): ?RakutenProvider {
		$creds = $settings->provider( 'rakuten' );
		if ( $creds === null || ! $creds->enabled() || ! $creds->isConfigured() ) {
			return null;
		}

		$applicationId = (string) $creds->secret( 'application_id' );
		$accessKey     = (string) $creds->secret( 'access_key' );
		$affiliateId   = (string) $creds->secret( 'affiliate_id' );

		try {
			$rakutenCreds = new RakutenCredentials( $applicationId, $accessKey, $affiliateId );
		} catch ( InvalidArgumentException $unused ) {
			unset( $unused );
			return null;
		}

		$clock = $this->resolveClock();

		return new RakutenProvider(
			$this->resolveHttpClient(),
			new RakutenRequestFactory( $rakutenCreds, $this->resolveSiteOrigin() ),
			new RakutenResponseNormalizer(),
			new RakutenErrorMapper(),
			new RakutenAffiliateLinkBuilder(),
			$clock
		);
	}

	/**
	 * Resolve the public WordPress origin used by Rakuten's request
	 * context check.
	 */
	private function resolveSiteOrigin(): string {
		return function_exists( 'home_url' )
			? (string) home_url( '/' )
			: 'https://localhost';
	}

	/**
	 * Construct the {@see YahooShoppingProvider} when the §13.4
	 * Yahoo!ショッピング credentials are configured, else return null.
	 *
	 * The §9.3 商品検索 v3 endpoint authenticates with the
	 * `appid` query parameter (Yahoo Developer Network Client
	 * ID). ValueCommerce sid / pid are optional but, when both are
	 * configured, are forwarded so Yahoo requests and rendered
	 * links can carry affiliate attribution. Any legacy
	 * `affiliate_parameters` value still present in persisted
	 * settings is ignored.
	 *
	 * @param PluginSettings $settings §13 settings snapshot.
	 */
	private function buildYahooProvider( PluginSettings $settings ): ?YahooShoppingProvider {
		$creds = $settings->provider( 'yahoo_shopping' );
		if ( $creds === null || ! $creds->enabled() ) {
			return null;
		}

		$appId        = (string) $creds->secret( 'app_id' );
		$affiliateSid = self::nullableSecret( (string) $creds->secret( 'affiliate_sid' ) );
		$affiliatePid = self::nullableSecret( (string) $creds->secret( 'affiliate_pid' ) );
		if ( trim( $appId ) === '' ) {
			return null;
		}

		try {
			$yahooCreds = new YahooShoppingCredentials( $appId, $affiliateSid, $affiliatePid );
		} catch ( InvalidArgumentException $unused ) {
			unset( $unused );
			return null;
		}

		$clock = $this->resolveClock();

		return new YahooShoppingProvider(
			$this->resolveHttpClient(),
			new YahooShoppingRequestFactory( $yahooCreds ),
			new YahooShoppingResponseNormalizer(),
			new YahooShoppingErrorMapper(),
			new YahooShoppingAffiliateLinkBuilder( $yahooCreds ),
			$clock
		);
	}

	/**
	 * Normalize an optional settings secret to null when blank.
	 *
	 * @param string $value Raw settings value.
	 */
	private static function nullableSecret( string $value ): ?string {
		$value = trim( $value );
		return $value === '' ? null : $value;
	}

	/**
	 * Construct the Mercari provider when enabled.
	 *
	 * Mercari has no API / SDK credentials, but the Ambassador `afid` is stored
	 * so rendered links can be affiliate-stamped.
	 *
	 * @param PluginSettings $settings §13 settings snapshot.
	 */
	private function buildMercariProvider( PluginSettings $settings ): ?MercariProvider {
		$creds = $settings->provider( MercariProvider::PROVIDER_KEY );
		if ( $creds === null || ! $creds->enabled() ) {
			return null;
		}

		return new MercariProvider( (string) $creds->secret( 'afid' ) );
	}

	/**
	 * Resolve the {@see HttpClient} to forward to constructed
	 * providers. Defaults to {@see WpRemoteHttpClient}.
	 */
	private function resolveHttpClient(): HttpClient {
		return $this->httpClientOverride ?? new WpRemoteHttpClient();
	}

	/**
	 * Resolve the {@see Clock} to forward to constructed providers.
	 * Defaults to {@see SystemClock}.
	 */
	private function resolveClock(): Clock {
		return $this->clockOverride ?? new SystemClock();
	}
}
