<?php
declare(strict_types=1);

namespace Wohno\Plugin\Api;

use Wohno\Plugin\Security\Secret;
use Wohno\Plugin\Support\Config;
use WP_Error;

use const Wohno\Plugin\EMBED_CONTRACT_VERSION;
use const Wohno\Plugin\VERSION;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Server-side REST client for the Wohno Public Listings API.
 *
 * - Authenticates with the org's secret key (`X-API-Key: sk_...`), HTTPS only
 *   (see {@see Config::api_base()}), never in the query string, never logged.
 * - **Non-blocking render:** a page render never waits on the network when a
 *   cached copy exists. Each query has a *fresh* window (~5 min) and a longer
 *   *retain* window (~1 day). A fresh hit returns immediately; a stale hit
 *   returns the stale copy AND schedules a one-off background refresh; only a
 *   cold cache (first-ever view of a query) fetches synchronously.
 * - **Prewarm:** every query that has been rendered is recorded in a small
 *   registry; a 5-minute WP-Cron job ({@see \Wohno\Plugin\Cron\Scheduler})
 *   refreshes them in the background, honouring the ETag so a `304` does not
 *   consume the org's monthly API quota.
 * - Surfaces the `X-Wohno-Contract-Version` mismatch flag for an admin notice.
 *
 * @see https://wohno.de — Wohno Public Listings API.
 */
final class Client {

	/** Cron hook: refresh a single query in the background (args: [params]). */
	public const REFRESH_HOOK = 'wohno_refresh_query';

	/** Cron hook: refresh a single listing in the background (args: [id]). */
	public const REFRESH_ONE_HOOK = 'wohno_refresh_listing';

	/** Cron hook: refresh every known query. */
	public const PREWARM_HOOK = 'wohno_prewarm';

	/** Custom cron schedule name (registered by the Scheduler). */
	public const CRON_SCHEDULE = 'wohno_5min';

	private const CACHE_TTL_FRESH  = 5 * MINUTE_IN_SECONDS;
	private const CACHE_TTL_RETAIN = DAY_IN_SECONDS;
	/** Short negative-cache window for deterministic 4xx (e.g. 404 / forged cursor). */
	private const CACHE_TTL_NEGATIVE = MINUTE_IN_SECONDS;
	private const REQUEST_TIMEOUT    = 5;

	private const OPTION_API_KEY   = 'wohno_api_key';
	private const OPTION_CACHE_VER = 'wohno_cache_ver';
	private const OPTION_REGISTRY  = 'wohno_known_queries';
	private const CONTRACT_FLAG    = 'wohno_contract_mismatch';

	private const REGISTRY_MAX        = 50;
	private const REGISTRY_PRUNE_AGE  = 30 * DAY_IN_SECONDS;
	private const REGISTRY_TOUCH_GAP  = HOUR_IN_SECONDS;
	private const PREWARM_MAX_SECONDS = 20;

	/**
	 * Fetch a page of listings for the configured org (non-blocking).
	 *
	 * @param array<string, scalar> $params      Validated query params (city, rooms_min, …).
	 * @param bool                  $prewarmable Whether this query may seed the prewarm
	 *                                           registry. Pass false for visitor-driven
	 *                                           ($_GET) queries to prevent registry poisoning.
	 * @return array{listings: array<int, array<string, mixed>>, pagination: array<string, mixed>}|WP_Error
	 */
	public function listings( array $params = array(), bool $prewarmable = true ) {
		if ( '' === $this->api_key() ) {
			return new WP_Error( 'wohno_no_key', __( 'Kein Wohno-API-Key konfiguriert.', 'wohno' ) );
		}

		// Only the first page of an AUTHOR-defined query is worth prewarming.
		// Cursor (load-more) pages are transient, and visitor-driven search
		// queries ($_GET) must NOT seed the registry (poisoning): those callers
		// pass $prewarmable = false and seed the static view via prewarm_register().
		if ( $prewarmable && ! isset( $params['cursor'] ) ) {
			$this->remember_query( $params );
		}

		$cache_key = $this->cache_key( $params );
		$entry     = $this->read_cache( $cache_key );
		$now       = time();

		if ( null !== $entry && $now < (int) ( $entry['fresh_until'] ?? 0 ) ) {
			return $this->shape( $entry ); // Fresh — return immediately.
		}
		if ( null !== $entry ) {
			$this->schedule_refresh( $params ); // Stale — refresh in the background.
			return $this->shape( $entry );
		}

		// Cold: a recent deterministic 4xx (e.g. a forged cursor) is negatively
		// cached so repeated misses don't each hit the upstream API synchronously.
		if ( $this->is_negative( $cache_key ) ) {
			return new WP_Error( 'wohno_not_found', __( 'Keine Inserate gefunden.', 'wohno' ) );
		}

		return $this->refresh( $params, false ); // First view fetches synchronously (no retry → bounded latency).
	}

	/**
	 * Seed the prewarm registry with a TRUSTED, author-defined query (first page
	 * only). Used by the shortcode/block when the public filter bar is enabled,
	 * so the cron warms the embed's static default view WITHOUT recording
	 * visitor-driven ($_GET) search queries (which would let anonymous visitors
	 * poison the registry and evict the makler's legitimate warm queries).
	 *
	 * @param array<string, scalar> $params Author-defined query params.
	 * @return void
	 */
	public function prewarm_register( array $params ): void {
		if ( '' === $this->api_key() || isset( $params['cursor'] ) ) {
			return;
		}
		$this->remember_query( $params );
	}

	/**
	 * Fetch a single listing by id (non-blocking, same fresh/stale/cold model
	 * as {@see Client::listings()}). Returns the raw ListingPublicDto.
	 *
	 * @param string $id Listing id (UUID).
	 * @return array<string, mixed>|WP_Error
	 */
	public function listing( string $id ) {
		if ( '' === $this->api_key() ) {
			return new WP_Error( 'wohno_no_key', __( 'Kein Wohno-API-Key konfiguriert.', 'wohno' ) );
		}
		if ( '' === $id ) {
			return new WP_Error( 'wohno_bad_id', __( 'Keine Inserat-ID angegeben.', 'wohno' ) );
		}

		$cache_key = $this->listing_cache_key( $id );
		$entry     = $this->read_cache( $cache_key );
		$now       = time();

		if ( null !== $entry && $now < (int) ( $entry['fresh_until'] ?? 0 ) ) {
			return $this->dto( $entry );
		}
		if ( null !== $entry ) {
			$this->schedule_listing_refresh( $id );
			return $this->dto( $entry );
		}

		// Cold: a recent 404 for this id is negatively cached so probing
		// non-existent (but validly-shaped) ids doesn't hit upstream every time.
		if ( $this->is_negative( $cache_key ) ) {
			return new WP_Error( 'wohno_not_found', __( 'Inserat nicht gefunden.', 'wohno' ) );
		}

		return $this->refresh_listing( $id, false );
	}

	/**
	 * Lightweight connectivity probe for the settings page. Always hits the
	 * network (bypasses the cache) so the admin gets a real answer.
	 *
	 * @return true|WP_Error True on success, otherwise the underlying error.
	 */
	public function test_connection() {
		$result = $this->refresh( array( 'limit' => 1 ) );

		return is_wp_error( $result ) ? $result : true;
	}

	/**
	 * Cron handler: refresh one query in the background.
	 *
	 * @param array<string, scalar> $params Query params.
	 * @return void
	 */
	public function refresh_event( array $params ): void {
		$this->refresh( $params );
	}

	/**
	 * Cron handler: refresh one listing in the background.
	 *
	 * @param string $id Listing id.
	 * @return void
	 */
	public function refresh_one_event( string $id ): void {
		$this->refresh_listing( $id );
	}

	/**
	 * Cron handler: refresh every query the site has rendered. Individual
	 * failures are swallowed — a down API must not abort the whole run.
	 *
	 * @return void
	 */
	public function prewarm_all(): void {
		if ( '' === $this->api_key() ) {
			return;
		}
		$registry = get_option( self::OPTION_REGISTRY, array() );
		if ( ! is_array( $registry ) ) {
			return;
		}
		// Wall-clock budget: a slow/erroring API must not let one cron run
		// overrun PHP's max_execution_time. Unwarmed queries are picked up next run.
		$start = time();
		foreach ( $registry as $item ) {
			if ( ( time() - $start ) >= self::PREWARM_MAX_SECONDS ) {
				break;
			}
			$params = is_array( $item ) && isset( $item['params'] ) && is_array( $item['params'] ) ? $item['params'] : null;
			if ( null !== $params ) {
				$this->refresh( $params );
			}
		}
	}

	/**
	 * Perform the actual HTTP fetch for a query and update the cache. Serves
	 * the retained (stale) copy on a transient failure so callers degrade
	 * gracefully.
	 *
	 * @param array<string, scalar> $params      Query params.
	 * @param bool                  $allow_retry Retry once on a network error
	 *                                           (background paths only; the cold
	 *                                           synchronous render passes false).
	 * @return array{listings: array<int, array<string, mixed>>, pagination: array<string, mixed>}|WP_Error
	 */
	private function refresh( array $params, bool $allow_retry = true ) {
		$key = $this->api_key();
		if ( '' === $key ) {
			return new WP_Error( 'wohno_no_key', __( 'Kein Wohno-API-Key konfiguriert.', 'wohno' ) );
		}

		$cache_key = $this->cache_key( $params );
		$entry     = $this->read_cache( $cache_key );
		$etag      = is_array( $entry ) ? (string) ( $entry['etag'] ?? '' ) : '';

		$response = $this->do_request( $this->build_url( $params ), $etag, $key, $allow_retry );

		if ( is_wp_error( $response ) ) {
			return null !== $entry ? $this->shape( $entry ) : $response;
		}

		$this->check_contract_version( $response );
		$status = (int) wp_remote_retrieve_response_code( $response );

		if ( 304 === $status && null !== $entry ) {
			// Unchanged — just extend the fresh window.
			$this->write_cache( $cache_key, $entry['data'], $entry['pagination'] ?? array(), $etag );
			return $this->shape( $entry );
		}
		if ( 401 === $status || 403 === $status ) {
			return new WP_Error( 'wohno_auth', __( 'Wohno-API-Key ungültig oder ohne Berechtigung (listings:read).', 'wohno' ) );
		}
		if ( 429 === $status ) {
			return null !== $entry
				? $this->shape( $entry )
				: new WP_Error( 'wohno_quota', __( 'Wohno-API-Limit erreicht. Bitte später erneut.', 'wohno' ) );
		}
		if ( $status < 200 || $status >= 300 ) {
			// Negatively cache a deterministic client error (bad/forged params)
			// so repeated cold misses don't each hit upstream. Transient codes
			// (429/5xx) are NOT cached so a recovering API is retried promptly.
			if ( null === $entry && ( 400 === $status || 404 === $status ) ) {
				$this->write_negative( $cache_key );
			}
			return null !== $entry
				? $this->shape( $entry )
				: new WP_Error( 'wohno_http', sprintf( /* translators: %d: HTTP status code */ __( 'Unerwartete API-Antwort (%d).', 'wohno' ), $status ) );
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		// The Wohno API returns a cursor envelope: `{ data: [...], pagination: {...}, meta: {...} }`.
		if ( ! is_array( $body ) || ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
			return new WP_Error( 'wohno_parse', __( 'API-Antwort konnte nicht gelesen werden.', 'wohno' ) );
		}

		$pagination = isset( $body['pagination'] ) && is_array( $body['pagination'] ) ? $body['pagination'] : array();
		$new_etag   = wp_remote_retrieve_header( $response, 'etag' );
		$this->write_cache( $cache_key, $body['data'], $pagination, is_string( $new_etag ) ? $new_etag : '' );

		return array(
			'listings'   => $body['data'],
			'pagination' => $pagination,
		);
	}

	/**
	 * Fetch + cache a single listing. Mirrors {@see Client::refresh()} but for
	 * the `/{id}` endpoint, whose envelope is `{ data: {…}, meta: {…} }` (a single
	 * DTO object). Serves the retained copy on a transient failure.
	 *
	 * @param string $id          Listing id.
	 * @param bool   $allow_retry Retry once on a network error (background only).
	 * @return array<string, mixed>|WP_Error The DTO, or an error.
	 */
	private function refresh_listing( string $id, bool $allow_retry = true ) {
		$key = $this->api_key();
		if ( '' === $key ) {
			return new WP_Error( 'wohno_no_key', __( 'Kein Wohno-API-Key konfiguriert.', 'wohno' ) );
		}

		$cache_key = $this->listing_cache_key( $id );
		$entry     = $this->read_cache( $cache_key );
		$etag      = is_array( $entry ) ? (string) ( $entry['etag'] ?? '' ) : '';

		$url      = Config::api_base() . '/api/v1/listings/' . rawurlencode( $id );
		$response = $this->do_request( $url, $etag, $key, $allow_retry );

		if ( is_wp_error( $response ) ) {
			return null !== $entry ? $this->dto( $entry ) : $response;
		}

		$this->check_contract_version( $response );
		$status = (int) wp_remote_retrieve_response_code( $response );

		if ( 304 === $status && null !== $entry ) {
			$this->write_cache( $cache_key, $entry['data'], array(), $etag );
			return $this->dto( $entry );
		}
		if ( 401 === $status || 403 === $status ) {
			return new WP_Error( 'wohno_auth', __( 'Wohno-API-Key ungültig oder ohne Berechtigung (listings:read).', 'wohno' ) );
		}
		if ( 404 === $status ) {
			$this->write_negative( $cache_key ); // Cheap repeat-probe defence.
			return new WP_Error( 'wohno_not_found', __( 'Inserat nicht gefunden.', 'wohno' ) );
		}
		if ( 429 === $status ) {
			return null !== $entry
				? $this->dto( $entry )
				: new WP_Error( 'wohno_quota', __( 'Wohno-API-Limit erreicht. Bitte später erneut.', 'wohno' ) );
		}
		if ( $status < 200 || $status >= 300 ) {
			return null !== $entry
				? $this->dto( $entry )
				: new WP_Error( 'wohno_http', sprintf( /* translators: %d: HTTP status code */ __( 'Unerwartete API-Antwort (%d).', 'wohno' ), $status ) );
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		// Single-listing envelope: `{ data: {…}, meta: {…} }`.
		if ( ! is_array( $body ) || ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
			return new WP_Error( 'wohno_parse', __( 'API-Antwort konnte nicht gelesen werden.', 'wohno' ) );
		}

		$new_etag = wp_remote_retrieve_header( $response, 'etag' );
		$this->write_cache( $cache_key, $body['data'], array(), is_string( $new_etag ) ? $new_etag : '' );

		return $body['data'];
	}

	/**
	 * Issue the GET request.
	 *
	 * `redirection => 0`: a key-bearing request must NEVER auto-follow a redirect
	 * (it would re-send `X-API-Key` to the `Location`, potentially another host or
	 * a downgraded scheme). A 30x is surfaced as an unexpected status instead.
	 *
	 * @param string $url         Fully-built request URL.
	 * @param string $etag        Stored ETag (sent as `If-None-Match`, if any).
	 * @param string $key         The decrypted API key.
	 * @param bool   $allow_retry Retry once on a network error. Only the
	 *                            background paths set this — the synchronous
	 *                            front-end (cold) render must not double its
	 *                            worst-case latency.
	 * @return array<string, mixed>|WP_Error Raw `wp_remote_get()` result.
	 */
	private function do_request( string $url, string $etag, string $key, bool $allow_retry ) {
		$headers = array(
			'X-API-Key'      => $key,
			'Accept'         => 'application/json',
			'X-Wohno-Plugin' => 'wordpress/' . VERSION,
		);
		if ( '' !== $etag ) {
			$headers['If-None-Match'] = $etag;
		}

		$args = array(
			'timeout'     => self::REQUEST_TIMEOUT,
			'redirection' => 0,
			'headers'     => $headers,
			'sslverify'   => true,
		);

		$response = wp_remote_get( $url, $args );
		if ( $allow_retry && is_wp_error( $response ) ) {
			$response = wp_remote_get( $url, $args ); // One retry on a network blip (background only).
		}

		return $response;
	}

	/**
	 * Whether the last API response signalled a contract-version mismatch.
	 *
	 * @return bool
	 */
	public function has_contract_mismatch(): bool {
		return '1' === get_option( self::CONTRACT_FLAG );
	}

	/**
	 * The configured (trimmed, decrypted) API key, or an empty string if unset.
	 *
	 * Private on purpose: the cleartext secret must never be reachable from a
	 * caller outside this class (no REST/AJAX/filter/template surface).
	 *
	 * @return string
	 */
	private function api_key(): string {
		$raw = (string) get_option( self::OPTION_API_KEY, '' );

		return trim( Secret::decrypt( $raw ) );
	}

	/**
	 * Invalidate all cached listing pages by bumping the cache namespace.
	 * Call after the API key changes.
	 *
	 * @return void
	 */
	public static function flush_cache(): void {
		update_option( self::OPTION_CACHE_VER, self::cache_version() + 1, false );
	}

	// ------------------------------------------------------------------
	// Internals
	// ------------------------------------------------------------------

	/**
	 * Compare the server contract version against what this plugin expects
	 * and persist a flag the admin notice reads.
	 *
	 * @param array<string, mixed>|WP_Error $response Raw `wp_remote_get()` response.
	 * @return void
	 */
	private function check_contract_version( $response ): void {
		$header = wp_remote_retrieve_header( $response, 'x-wohno-contract-version' );
		if ( '' === $header ) {
			return; // Older deployment without the header — stay quiet.
		}
		$mismatch = EMBED_CONTRACT_VERSION !== (int) $header;
		update_option( self::CONTRACT_FLAG, $mismatch ? '1' : '0', false );
	}

	/**
	 * Build the listings request URL from validated params.
	 *
	 * @param array<string, scalar> $params Query params.
	 * @return string
	 */
	private function build_url( array $params ): string {
		// add_query_arg() url-encodes values itself — do NOT pre-encode here
		// (would double-encode e.g. "Köln").
		return add_query_arg(
			array_filter(
				$params,
				static fn ( $v ) => '' !== $v && null !== $v
			),
			Config::api_base() . '/api/v1/listings'
		);
	}

	/**
	 * Namespaced, version-scoped cache key for a query (object-cache-safe).
	 *
	 * @param array<string, scalar> $params Query params.
	 * @return string
	 */
	private function cache_key( array $params ): string {
		return 'wohno_listings_' . self::cache_version() . '_' . self::query_signature( $params );
	}

	/**
	 * Namespaced, version-scoped cache key for a single listing.
	 *
	 * @param string $id Listing id.
	 * @return string
	 */
	private function listing_cache_key( string $id ): string {
		return 'wohno_listing_' . self::cache_version() . '_' . md5( $id );
	}

	/**
	 * Extract the single-listing DTO from a cache entry.
	 *
	 * @param array<string, mixed> $entry Cache entry.
	 * @return array<string, mixed>
	 */
	private function dto( array $entry ): array {
		return is_array( $entry['data'] ?? null ) ? $entry['data'] : array();
	}

	/**
	 * Stable signature of a query, independent of the cache version/base URL.
	 *
	 * @param array<string, scalar> $params Query params.
	 * @return string
	 */
	private static function query_signature( array $params ): string {
		$filtered = array_filter( $params, static fn ( $v ) => '' !== $v && null !== $v );
		ksort( $filtered );
		$parts = array();
		foreach ( $filtered as $k => $v ) {
			$parts[] = $k . '=' . $v;
		}

		return md5( implode( '&', $parts ) );
	}

	/**
	 * Read a cache entry, or null if absent/malformed.
	 *
	 * @param string $cache_key Cache key.
	 * @return array{data: array<int, array<string, mixed>>, pagination: array<string, mixed>, etag: string, fresh_until: int}|null
	 */
	private function read_cache( string $cache_key ): ?array {
		$entry = get_transient( $cache_key );

		return is_array( $entry ) && isset( $entry['data'] ) && is_array( $entry['data'] ) ? $entry : null;
	}

	/**
	 * Persist a cache entry with a fresh window inside the retain window.
	 *
	 * @param string                           $cache_key  Cache key.
	 * @param array<int, array<string, mixed>> $data       Listing items.
	 * @param array<string, mixed>             $pagination Pagination envelope.
	 * @param string                           $etag       Response ETag (may be '').
	 * @return void
	 */
	private function write_cache( string $cache_key, array $data, array $pagination, string $etag ): void {
		set_transient(
			$cache_key,
			array(
				'data'        => $data,
				'pagination'  => $pagination,
				'etag'        => $etag,
				'fresh_until' => time() + self::CACHE_TTL_FRESH,
			),
			self::CACHE_TTL_RETAIN
		);
	}

	/**
	 * Whether a short-lived negative marker exists for this cache key (a recent
	 * deterministic 4xx). Lets cold lookups fail fast without a network round-trip.
	 *
	 * @param string $cache_key Cache key.
	 * @return bool
	 */
	private function is_negative( string $cache_key ): bool {
		return (bool) get_transient( self::negative_key( $cache_key ) );
	}

	/**
	 * Record a short-lived negative marker for a deterministic 4xx, so repeated
	 * cold misses for the same (e.g. forged) key don't each hit upstream.
	 *
	 * @param string $cache_key Cache key.
	 * @return void
	 */
	private function write_negative( string $cache_key ): void {
		set_transient( self::negative_key( $cache_key ), 1, self::CACHE_TTL_NEGATIVE );
	}

	/**
	 * Prefixed transient name for the short-lived negative marker derived from a
	 * cache key. The literal 'wohno_neg_' prefix keeps the option/transient name
	 * namespaced and collision-safe in the shared transient store.
	 *
	 * @param string $cache_key Cache key.
	 * @return string
	 */
	private static function negative_key( string $cache_key ): string {
		return 'wohno_neg_' . md5( $cache_key );
	}

	/**
	 * Map a cache entry to the public return shape.
	 *
	 * @param array<string, mixed> $entry Cache entry.
	 * @return array{listings: array<int, array<string, mixed>>, pagination: array<string, mixed>}
	 */
	private function shape( array $entry ): array {
		return array(
			'listings'   => is_array( $entry['data'] ?? null ) ? $entry['data'] : array(),
			'pagination' => is_array( $entry['pagination'] ?? null ) ? $entry['pagination'] : array(),
		);
	}

	/**
	 * Record a query in the prewarm registry (write-throttled to ~1/hour per
	 * query), pruning stale entries and capping the registry size.
	 *
	 * @param array<string, scalar> $params Query params.
	 * @return void
	 */
	private function remember_query( array $params ): void {
		$registry = get_option( self::OPTION_REGISTRY, array() );
		if ( ! is_array( $registry ) ) {
			$registry = array();
		}

		$sig  = self::query_signature( $params );
		$now  = time();
		$seen = isset( $registry[ $sig ]['seen'] ) ? (int) $registry[ $sig ]['seen'] : 0;
		if ( 0 !== $seen && ( $now - $seen ) < self::REGISTRY_TOUCH_GAP ) {
			return; // Recently recorded — skip the write.
		}

		$registry[ $sig ] = array(
			'params' => $params,
			'seen'   => $now,
		);

		// Prune entries not seen for a long time.
		foreach ( $registry as $k => $item ) {
			if ( ( $now - (int) ( $item['seen'] ?? 0 ) ) > self::REGISTRY_PRUNE_AGE ) {
				unset( $registry[ $k ] );
			}
		}

		// Cap: keep the most-recently-seen entries.
		if ( count( $registry ) > self::REGISTRY_MAX ) {
			uasort( $registry, static fn ( $a, $b ) => (int) ( $b['seen'] ?? 0 ) <=> (int) ( $a['seen'] ?? 0 ) );
			$registry = array_slice( $registry, 0, self::REGISTRY_MAX, true );
		}

		update_option( self::OPTION_REGISTRY, $registry, false );
	}

	/**
	 * Schedule a one-off background refresh for a query (deduped by args).
	 *
	 * @param array<string, scalar> $params Query params.
	 * @return void
	 */
	private function schedule_refresh( array $params ): void {
		if ( false === wp_next_scheduled( self::REFRESH_HOOK, array( $params ) ) ) {
			wp_schedule_single_event( time() + 1, self::REFRESH_HOOK, array( $params ) );
		}
	}

	/**
	 * Schedule a one-off background refresh for a single listing (deduped by id).
	 *
	 * @param string $id Listing id.
	 * @return void
	 */
	private function schedule_listing_refresh( string $id ): void {
		if ( false === wp_next_scheduled( self::REFRESH_ONE_HOOK, array( $id ) ) ) {
			wp_schedule_single_event( time() + 1, self::REFRESH_ONE_HOOK, array( $id ) );
		}
	}

	/**
	 * Current cache-namespace version.
	 *
	 * @return int
	 */
	private static function cache_version(): int {
		return max( 1, (int) get_option( self::OPTION_CACHE_VER, 1 ) );
	}
}
