<?php
/**
 * Centralized cache invalidator.
 *
 * Phase 9 Application-layer collaborator that implements
 * `specifications/sync-and-cache-strategy.md` §14 (キャッシュ無効化戦略).
 * Each public method maps one-to-one to a §14 sub-section so the
 * call sites (sync handlers, REST controllers, plugin lifecycle
 * hooks, theme switch listeners) have a single, named entry point
 * per invalidation trigger and do not have to re-derive which
 * cache layers participate in each scenario.
 *
 * §14 sub-section coverage:
 *
 *   - {@see self::invalidateProductSync()}           — §14.2 商品更新時
 *   - {@see self::invalidateEmbedConfig()}           — §14.3 埋め込み設定更新時
 *   - {@see self::invalidateTemplate()}              — §14.4 テンプレート変更時
 *   - {@see self::invalidateProviderConfig()}        — §14.5 provider 設定変更時
 *   - {@see self::invalidateThemeChange()}           — §14.6 テーマ切替時
 *   - {@see self::invalidatePluginUpgrade()}         — §14.7 プラグインアップデート時
 *
 * MVP overshoot (Phase 9, intentional):
 *
 *   §14.1 prescribes 「広く消すではなく必要なものだけ消す」 — invalidate
 *   only what is actually affected. Honoring that ideal at every
 *   call site requires reverse indexes the Phase 9 surface does
 *   not yet have:
 *
 *     - product → render-cache-key reverse index. The render keys
 *       produced by {@see CacheKeyFactory::renderEmbedKey()} and
 *       {@see CacheKeyFactory::renderAdhocKey()} hash the product
 *       set into `productsHash` / embed-id, so a single
 *       `productId` cannot be projected back to the render keys
 *       that reference it without an `EmbedRepository`-backed
 *       lookup table that Phase 10 / 11 will introduce.
 *
 *     - template → render-cache-key reverse index. The template
 *       segment lives in the middle of the render key, after the
 *       embed/adhoc-id segment, so a prefix sweep on the template
 *       value alone is not safe.
 *
 *     - provider → search/lookup-cache-key sweep. The
 *       {@see SearchCache} and {@see LookupCache} ports do not
 *       expose a `forgetByPrefix()` hook (only {@see RenderCache}
 *       does, because §14 explicitly relies on prefix sweeps for
 *       render cache and not for the upstream provider layers).
 *       Adding that surface to the ports is deliberately deferred
 *       so this slice does not expand the port contract.
 *
 *   Until those indexes / port hooks land, the methods that would
 *   otherwise need them flush an entire cache layer rather than
 *   leak stale data. Each affected method documents the overshoot
 *   inline and references this docblock. Phase 10 / 11 follow-ups
 *   are tracked in the rendering / sync specs and will replace the
 *   broad flushes with targeted reverse-index lookups without
 *   changing the public surface of this class.
 *
 * @package Affilun\Application\Cache
 */

declare(strict_types=1);

namespace Affilun\Application\Cache;

use Affilun\Infrastructure\Persistence\EmbedItemRepository;
use InvalidArgumentException;

/**
 * §14 cache invalidator. See the file-level docblock for the
 * spec citations and the documented MVP overshoot for
 * methods that flush more aggressively than §14.1's "necessary
 * only" ideal because per-product / per-template reverse indexes
 * are deferred to Phase 10 / 11.
 */
final class CacheInvalidator {

	/**
	 * Search-result cache layer.
	 *
	 * @var SearchCache
	 */
	private SearchCache $searchCache;

	/**
	 * Single-item lookup cache layer.
	 *
	 * @var LookupCache
	 */
	private LookupCache $lookupCache;

	/**
	 * Rendered HTML / view-model cache layer.
	 *
	 * @var RenderCache
	 */
	private RenderCache $renderCache;

	/**
	 * Provider health cache layer.
	 *
	 * @var ProviderHealthCache
	 */
	private ProviderHealthCache $providerHealthCache;

	/**
	 * Cache-key factory used to compose targeted keys for the
	 * scoped `forget()` calls.
	 *
	 * @var CacheKeyFactory
	 */
	private CacheKeyFactory $keys;

	/**
	 * Optional Phase 2 KI-002 reverse-index repository used by
	 * {@see self::invalidateProductSync()} to enumerate the embeds
	 * that reference a given product so the render-cache flush can
	 * be replaced with per-embed `forgetByPrefix()` calls.
	 *
	 * When null, {@see self::invalidateProductSync()} falls back on
	 * the Phase 9 MVP overshoot (`renderCache->flush()`) so the
	 * surrounding cache wiring stays operational on bootstrap paths
	 * that have not yet bound an {@see EmbedItemRepository}.
	 *
	 * @var EmbedItemRepository|null
	 */
	private ?EmbedItemRepository $embedItems;

	/**
	 * Wire the four §4 cache layers, the §6.3 key factory, and the
	 * optional Phase 2 KI-002 embed-item reverse-index repository.
	 *
	 * @param SearchCache              $searchCache         §4 search layer.
	 * @param LookupCache              $lookupCache         §4 lookup layer.
	 * @param RenderCache              $renderCache         §4 render layer.
	 * @param ProviderHealthCache      $providerHealthCache §4 provider-health layer.
	 * @param CacheKeyFactory          $keys                §6.3 key factory.
	 * @param EmbedItemRepository|null $embedItems          KI-002 reverse-index
	 *                                                      repository; when
	 *                                                      null, the
	 *                                                      product-sync
	 *                                                      invalidation falls
	 *                                                      back on the
	 *                                                      Phase 9 broad
	 *                                                      render-cache flush.
	 */
	public function __construct(
		SearchCache $searchCache,
		LookupCache $lookupCache,
		RenderCache $renderCache,
		ProviderHealthCache $providerHealthCache,
		CacheKeyFactory $keys,
		?EmbedItemRepository $embedItems = null
	) {
		$this->searchCache         = $searchCache;
		$this->lookupCache         = $lookupCache;
		$this->renderCache         = $renderCache;
		$this->providerHealthCache = $providerHealthCache;
		$this->keys                = $keys;
		$this->embedItems          = $embedItems;
	}

	/**
	 * §14.2 商品更新時 — invalidate caches affected by a product
	 * snapshot change.
	 *
	 * Targeted:
	 *   - lookup cache entry for the (provider, providerProductId) pair
	 *     produced by {@see CacheKeyFactory::lookupKey()}.
	 *   - render cache (embed surface): when a Phase 2 KI-002
	 *     {@see EmbedItemRepository} is wired, every embed id
	 *     referencing `$productId` is enumerated through
	 *     {@see EmbedItemRepository::findEmbedIdsByProductId()} and
	 *     the matching `affilun:render:single:embed:N:` /
	 *     `affilun:render:grid:embed:N:` prefixes are forgotten one by
	 *     one.
	 *   - render cache (adhoc surface, KI-002 完全解消): the
	 *     post-body Gutenberg block path materializes render keys
	 *     under the `affilun:render:single:adhoc:` /
	 *     `affilun:render:grid:adhoc:` prefix family keyed by
	 *     `productsHash`. Because `productsHash` is a sha256 of
	 *     the canonical product-id list, individual products
	 *     cannot be reversed out of the key without an additional
	 *     post→productId reverse index. The KI-002 完全解消 path
	 *     therefore evicts the entire adhoc family on a product
	 *     sync — still a surgical improvement over the Phase 9
	 *     MVP `renderCache->flush()` overshoot because search /
	 *     lookup / provider-health caches remain untouched, and
	 *     embed-keyed entries for unrelated products survive.
	 *
	 * Broad (legacy fallback — when no {@see EmbedItemRepository}
	 * is injected):
	 *   - render cache: flushed in full, matching the Phase 9 MVP
	 *     overshoot. Bootstrap paths that omit the embed-item
	 *     binding (typically pure-unit tests that exercise the
	 *     invalidator without standing up the persistence stack)
	 *     observe the legacy behavior so existing tests continue
	 *     to pass.
	 *
	 * Untouched:
	 *   - search cache (§14.3 / §14.5 do not require it on a
	 *     per-product change; provider-search results are scoped
	 *     by query, not by individual product).
	 *   - provider health cache (§7.10 status is independent of
	 *     individual product snapshots).
	 *
	 * @param string $provider          Provider key (e.g. `amazon`).
	 * @param string $providerProductId Provider-side product id
	 *                                  (e.g. ASIN, item code).
	 * @param int    $productId         Local `affilun_products.id`. Held
	 *                                  for forward compatibility with
	 *                                  the Phase 10 / 11 reverse-index
	 *                                  lookup; not used directly here.
	 *
	 * @throws InvalidArgumentException When `$provider` /
	 *                                  `$providerProductId` are
	 *                                  rejected by
	 *                                  {@see CacheKeyFactory::lookupKey()}
	 *                                  or `$productId < 1`.
	 */
	public function invalidateProductSync(
		string $provider,
		string $providerProductId,
		int $productId
	): void {
		if ( $productId < 1 ) {
			$message = sprintf(
				'CacheInvalidator: productId must be >= 1; got %d.',
				$productId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->lookupCache->forget( $this->keys->lookupKey( $provider, $providerProductId ) );

		if ( $this->embedItems === null ) {
			// Legacy Phase 9 fallback: no reverse-index repository
			// wired, so flush the whole render cache rather than
			// leak stale data.
			$this->renderCache->flush();
			return;
		}

		// KI-002 embed-side targeted sweep: enumerate every embed
		// that references this product through the §7.3 indexed
		// reverse lookup, then forget the per-embed render keys
		// for both single and grid display modes. Returns an
		// empty list when no embed references the product — that
		// is the normal case for products only embedded directly
		// in post bodies via the Gutenberg block.
		$embedIds = $this->embedItems->findEmbedIdsByProductId( $productId );
		foreach ( $embedIds as $embedId ) {
			$this->renderCache->forgetByPrefix( 'affilun:render:single:embed:' . $embedId . ':' );
			$this->renderCache->forgetByPrefix( 'affilun:render:grid:embed:' . $embedId . ':' );
		}

		// KI-002 完全解消 — adhoc-side sweep: the post-body
		// Gutenberg block path stores render entries under the
		// `affilun:render:*:adhoc:*` family keyed by the productsHash
		// (a sha256 of the canonical id list), so a single
		// product cannot be reversed out of the key. Drop the
		// entire adhoc family per kind so a snapshot change
		// cannot be served from a stale post-body render. Search
		// / lookup / provider-health caches remain untouched and
		// embed-keyed entries for unrelated products survive, so
		// this is still a surgical improvement over the Phase 9
		// renderCache->flush() overshoot.
		$this->renderCache->forgetByPrefix( 'affilun:render:single:adhoc:' );
		$this->renderCache->forgetByPrefix( 'affilun:render:grid:adhoc:' );
	}

	/**
	 * §14.3 埋め込み設定更新時 — invalidate render entries for a
	 * specific embed.
	 *
	 * Targeted: drops every render key under the `single` and
	 * `grid` prefixes for the given `$embedId` produced by
	 * {@see CacheKeyFactory::renderEmbedKey()}. All other embeds,
	 * the search cache, the lookup cache, and the provider health
	 * cache are left intact (per §14.3 「無効化不要: 商品 snapshot /
	 * provider search cache」).
	 *
	 * @param int $embedId Positive embed id.
	 *
	 * @throws InvalidArgumentException When `$embedId < 1`.
	 */
	public function invalidateEmbedConfig( int $embedId ): void {
		if ( $embedId < 1 ) {
			$message = sprintf(
				'CacheInvalidator: embedId must be >= 1; got %d.',
				$embedId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->renderCache->forgetByPrefix( 'affilun:render:single:embed:' . $embedId . ':' );
		$this->renderCache->forgetByPrefix( 'affilun:render:grid:embed:' . $embedId . ':' );
	}

	/**
	 * §14.4 テンプレート変更時 — invalidate render entries that
	 * resolve to a given template.
	 *
	 * Broad (MVP overshoot — see file-level docblock): the
	 * template segment is positioned in the middle of the render
	 * key (after the embed/adhoc-id segment), so a prefix sweep
	 * on the template value alone is not safe. Until a
	 * template → render-key reverse index is introduced in
	 * Phase 10 / 11, this method falls back on
	 * {@see RenderCache::flush()}. The §14.4 「場合によっては
	 * 全 render cache を version bump で無効化」 escape hatch
	 * is the spec-sanctioned justification.
	 *
	 * Untouched: search cache, lookup cache, provider health
	 * cache. A template change does not affect provider data.
	 *
	 * No template-resolver cache port exists in Phase 9; once it
	 * lands (rendering spec §22 follow-up) this method will also
	 * forget the resolver entry for `$templateKey`.
	 *
	 * @param string $templateKey Non-empty trimmed template key.
	 *
	 * @throws InvalidArgumentException When `$templateKey` is
	 *                                  empty after trimming.
	 */
	public function invalidateTemplate( string $templateKey ): void {
		if ( trim( $templateKey ) === '' ) {
			$message = 'CacheInvalidator: templateKey must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		// MVP overshoot: see file-level docblock and §14.4 escape
		// hatch. Phase 10 / 11 will replace this with a targeted
		// sweep keyed by a template→render-key reverse index, and
		// will additionally forget the future template-resolver
		// cache entry for `$templateKey`.
		$this->renderCache->flush();
	}

	/**
	 * §14.5 provider 設定変更時 — invalidate caches affected by a
	 * provider configuration change (affiliate id, link policy,
	 * credentials, etc.).
	 *
	 * Targeted:
	 *   - provider health cache entry for `$provider` produced by
	 *     {@see CacheKeyFactory::providerHealthKey()}.
	 *
	 * Broad (MVP overshoot — see file-level docblock):
	 *   - search cache: flushed in full because {@see SearchCache}
	 *     does not expose `forgetByPrefix()` and the Phase 9 slice
	 *     deliberately does not expand the port contract. The
	 *     practical impact is bounded: the search layer's TTL is
	 *     short (§4 / §6.3), and provider-config changes are rare.
	 *   - lookup cache: same rationale as search cache.
	 *   - render cache: flushed in full because affiliate-id and
	 *     link-policy changes affect every rendered URL produced
	 *     under the given provider, and we lack a provider →
	 *     render-key reverse index in Phase 9.
	 *
	 * §14.5 also lists 「必要に応じて product snapshot の再同期候補化」
	 * — that is a separate concern handled by `SyncPolicy` /
	 * `StaleProductFinder`, not by this invalidator.
	 *
	 * @param string $provider Provider key (e.g. `amazon`).
	 *
	 * @throws InvalidArgumentException When `$provider` is rejected
	 *                                  by
	 *                                  {@see CacheKeyFactory::providerHealthKey()}.
	 */
	public function invalidateProviderConfig( string $provider ): void {
		$this->providerHealthCache->forget( $this->keys->providerHealthKey( $provider ) );

		// MVP overshoot: see file-level docblock. Phase 10 / 11
		// will replace these flushes with provider-scoped sweeps
		// once SearchCache / LookupCache gain a `forgetByPrefix()`
		// hook and a provider→render-key reverse index lands.
		$this->searchCache->flush();
		$this->lookupCache->flush();
		$this->renderCache->flush();
	}

	/**
	 * §14.6 テーマ切替時 — invalidate render entries because
	 * theme overrides change template resolution.
	 *
	 * Targeted: render cache (§14.6 「無効化対象: template
	 * resolver cache, render cache」).
	 *
	 * Search / lookup / provider health caches are untouched —
	 * theme changes do not affect upstream provider data.
	 *
	 * No template-resolver cache port exists in Phase 9; once it
	 * lands (rendering spec §22 follow-up) this method will also
	 * flush the resolver layer.
	 */
	public function invalidateThemeChange(): void {
		$this->renderCache->flush();
	}

	/**
	 * §14.7 プラグインアップデート時 — invalidate every cache
	 * layer to guard against shape drift across an upgrade.
	 *
	 * §14.7 lists two equivalent strategies: (a) flush each
	 * layer, or (b) bump the cache version segment so old keys
	 * become unreachable. Phase 9 implements (a) because the
	 * `VERSION` constant on {@see CacheKeyFactory} is currently
	 * set at compile time and a dynamic bump would require a
	 * persisted version counter that is not in scope for this
	 * slice. The (b) strategy ("cache version bump") is tracked
	 * as a Phase 10 / 11 follow-up.
	 *
	 * Flushed: render, search, lookup, provider health.
	 */
	public function invalidatePluginUpgrade(): void {
		$this->renderCache->flush();
		$this->searchCache->flush();
		$this->lookupCache->flush();
		$this->providerHealthCache->flush();
	}
}
