<?php
/**
 * Get
 *
 * @package SureCookie
 * @since 0.0.1
 */

namespace SureCookie\Inc\Functions;

use SureCookie\Inc\Modules\ScriptBlocking\Resource_Categories;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Get - This class will handle all functions to get data from database.
 *
 * @since 0.0.1
 */
class Get {
	/**
	 * Per-request memo for the category usage tally. Never persisted: the tally
	 * must stay dynamic so a newly detected cookie or script reveals its category.
	 *
	 * @var array<string, array{cookies: int, scripts: int, services: int}>|null
	 */
	private static ?array $category_usage_memo = null;

	/**
	 * Check whether current locale direction is RTL.
	 *
	 * @since 0.0.1
	 * @return bool
	 */
	public static function is_rtl(): bool {
		return function_exists( 'is_rtl' ) && is_rtl();
	}

	/**
	 * Get option
	 * This function will get option
	 *
	 * @param string $option_name Option name.
	 * @param mixed  $default     Default value.
	 * @param string $format      Format.
	 *
	 * @since 0.0.1
	 * @return mixed
	 */
	public static function option( $option_name, $default = false, $format = 'string' ) {
		$get = get_option( $option_name, $default );

		if ( $format === 'array' ) {
			$validate = Validate::array( $get );
			return empty( $validate ) || ! is_array( $validate ) ? $default : $validate;
		}

		return $get;
	}

	/**
	 * Get the post types selectable in a SureCookie picker.
	 *
	 * Single source of truth for both the Cookie Policy page picker
	 * ($context = 'policy') and the Site Scanner page picker ($context =
	 * 'scanner'). Both default to the `page` post type; the list is extensible
	 * per surface via the `surecookie_searchable_post_types` filter. The result
	 * is always intersected with public post types and stripped of `attachment`
	 * so a filter can never surface private, non-existent, or media types.
	 *
	 * @param string $context Picker surface: 'policy' or 'scanner'.
	 * @since 1.2.4
	 * @return array<int, string> Indexed array of post type slugs.
	 */
	public static function searchable_post_types( string $context = 'policy' ): array {
		$post_types = [ 'page' => 'page' ];

		/**
		 * Filter the post types selectable in a SureCookie picker.
		 *
		 * Fires for the Cookie Policy picker ($context = 'policy') and the Site
		 * Scanner picker ($context = 'scanner'). Defaults to ['page'] for both.
		 * Return a superset to opt additional PUBLIC post types in; branch on
		 * $context to target a single surface.
		 *
		 * Example - add a `product` CPT to the SCANNER only (functions.php):
		 *
		 *     add_filter( 'surecookie_searchable_post_types', function ( $types, $context ) {
		 *         if ( 'scanner' === $context ) {   // omit the check to target both surfaces
		 *             $types['product'] = 'product'; // must be registered public => true
		 *         }
		 *         return $types;
		 *     }, 10, 2 );
		 *
		 * @param array<string, string> $post_types Post type slugs keyed by slug.
		 * @param string                $context    'policy' | 'scanner'.
		 * @since 1.2.4 Added the $context parameter.
		 * @since 0.0.1-beta.2
		 */
		$post_types = (array) apply_filters( 'surecookie_searchable_post_types', $post_types, $context );

		// Guard against non-public / non-existent slugs a filter may have added,
		// and drop media - attachment titles are noise in a page picker. Strip
		// by value with array_diff (not unset by key): array_intersect preserves
		// the filter's keys, so an indexed return like ['page','attachment']
		// would keep numeric keys and slip past a keyed unset.
		$post_types = array_intersect( $post_types, get_post_types( [ 'public' => true ] ) );
		$post_types = array_diff( $post_types, [ 'attachment' ] );

		return array_values( $post_types );
	}

	/**
	 * Get cookie categories definitions.
	 *
	 * Single source of truth for all cookie category descriptions.
	 * Returns associative array format to match database storage.
	 *
	 * @since 0.0.1
	 * @return array<string, array<string, string|bool>>
	 */
	public static function default_cookie_categories() {
		return [
			'essential'     => [
				'id'          => 'essential',
				'name'        => __( 'Essential Cookies', 'surecookie' ),
				'description' => __( 'Required for basic website functionality.', 'surecookie' ),
				'required'    => true,
			],
			'functional'    => [
				'id'          => 'functional',
				'name'        => __( 'Functional Cookies', 'surecookie' ),
				'description' => __( 'Enable features like saved preferences and personalized content.', 'surecookie' ),
				'required'    => false,
			],
			'analytics'     => [
				'id'          => 'analytics',
				'name'        => __( 'Analytics Cookies', 'surecookie' ),
				'description' => __( 'Help us understand how visitors use our site so we can improve it.', 'surecookie' ),
				'required'    => false,
			],
			'marketing'     => [
				'id'          => 'marketing',
				'name'        => __( 'Marketing Cookies', 'surecookie' ),
				'description' => __( 'Used to show you relevant ads based on your interests.', 'surecookie' ),
				'required'    => false,
			],
			'uncategorized' => [
				'id'          => 'uncategorized',
				'name'        => __( 'Uncategorized Cookies', 'surecookie' ),
				'description' => __( 'Cookies that haven\'t been categorized yet.', 'surecookie' ),
				'required'    => false,
			],
		];
	}

	/**
	 * Get default cookie categories keys only.
	 *
	 * @since 0.0.1
	 * @return array<string>
	 */
	public static function default_cookie_categories_keys() {
		return array_keys( self::default_cookie_categories() );
	}

	/**
	 * Get unique ID - Applicable for cookies, categories.
	 *
	 * @param string $prefix Prefix.
	 *
	 * @since 0.0.1
	 * @return string
	 */
	public static function unique_id( $prefix = '' ) {
		// Must be collision-free even when called many times within the same
		// second (e.g. importing a whole service's cookies in one go); time()
		// alone repeats and callers key arrays by this id, so entries clobbered.
		return $prefix . wp_generate_uuid4();
	}

	/**
	 * Get redirect URL if menu location changed.
	 *
	 * @param array<string, mixed> $sanitized_settings Sanitized settings payload.
	 * @param mixed                $previous_top_level Previous value for top level menu setting.
	 * @return string
	 */
	public static function menu_redirect_url( array $sanitized_settings, $previous_top_level ): string {
		if ( ! array_key_exists( 'top_level_menu_enabled', $sanitized_settings ) ) {
			return '';
		}

		$next_top_level = (bool) $sanitized_settings['top_level_menu_enabled'];
		if ( $next_top_level === (bool) $previous_top_level ) {
			return '';
		}

		$page_fragment = SURECOOKIE_PREFIX . '#/settings/general';

		return $next_top_level
			? admin_url( 'admin.php?page=' . $page_fragment )
			: admin_url( 'options-general.php?page=' . $page_fragment );
	}

	/**
	 * Get formatted custom cookies - depending on cookie categorize separate out all custom cookies and return it to consider under scanned result.
	 *
	 * @since 0.0.1
	 * @return array<list<array<string, mixed>>> Formatted custom cookies.
	 */
	public static function formatted_custom_cookies() {
		$custom_cookies = Settings::get( 'custom_cookies' );

		if ( empty( $custom_cookies ) ) {
			return [];
		}

		$formatted_cookies = [];

		foreach ( $custom_cookies as $_custom_cookie ) {
			$formatted_cookies[ $_custom_cookie['category'] ] [] = [
				'name'        => $_custom_cookie['name'],
				'description' => $_custom_cookie['description'],
				'domain'      => $_custom_cookie['domain'],
				'value'       => $_custom_cookie['value'] ?? '',
				'path'        => $_custom_cookie['path'] ?? '/',
				'expires'     => $_custom_cookie['expires'],
				// Custom cookies are authored with an explicit lifetime in days;
				// carry it through so consumers show the day count instead of re-deriving it from the absolute 'expires' timestamp.
				'duration'    => $_custom_cookie['duration'] ?? '',
				'secure'      => $_custom_cookie['secure'] ?? false,
				'httpOnly'    => $_custom_cookie['httpOnly'] ?? false,
				'provider'    => $_custom_cookie['provider'] ?? '',
				'purpose'     => $_custom_cookie['purpose'] ?? '',
			];
		}

		return $formatted_cookies;
	}

	/**
	 * Get color palette codes.
	 *
	 * @since 0.0.1
	 * @return array<string, array<string, string>>
	 */
	public static function color_palette_codes() {
		return apply_filters(
			'surecookie_color_palette_codes',
			[
				'blue-purple' => [
					'id'                => 'blue-purple',
					'name'              => 'Blue & Purple',
					'acceptButton'      => '#1463FF',
					'acceptButtonHover' => '#3B82F6',
					'declineButton'     => '#1463FF',
					'bgColor'           => '#FFFFFF',
					'textColor'         => '#374151',
				],
				'purple-pink' => [
					'id'                => 'purple-pink',
					'name'              => 'Purple & Pink',
					'acceptButton'      => '#9335B6',
					'acceptButtonHover' => '#8528A7',
					'declineButton'     => '#9335B6',
					'bgColor'           => '#FFFFFF',
					'textColor'         => '#374151',
				],
				'blue-cyan'   => [
					'id'                => 'blue-cyan',
					'name'              => 'Blue & Cyan',
					'acceptButton'      => '#2235DD',
					'acceptButtonHover' => '#1A2BC6',
					'declineButton'     => '#2235DD',
					'bgColor'           => '#FFFFFF',
					'textColor'         => '#374151',
				],
				'green-lime'  => [
					'id'                => 'green-lime',
					'name'              => 'Green & Lime',
					'acceptButton'      => '#377A00',
					'acceptButtonHover' => '#2F6A00',
					'declineButton'     => '#377A00',
					'bgColor'           => '#FFFFFF',
					'textColor'         => '#374151',
				],
				'red-orange'  => [
					'id'                => 'red-orange',
					'name'              => 'Red & Orange',
					'acceptButton'      => '#E11B14',
					'acceptButtonHover' => '#C00902',
					'declineButton'     => '#E11B14',
					'bgColor'           => '#FFFFFF',
					'textColor'         => '#374151',
				],
				'dark-blue'   => [
					'id'                => 'dark-blue',
					'name'              => 'Dark & Blue',
					'acceptButton'      => '#0285FF',
					'acceptButtonHover' => '#0177E3',
					'declineButton'     => '#0285FF',
					'bgColor'           => '#111827',
					'textColor'         => '#E5E7EB',
				],
				'dark-green'  => [
					'id'                => 'dark-green',
					'name'              => 'Dark & Green',
					'acceptButton'      => '#1FB4A6',
					'acceptButtonHover' => '#0DAF9F',
					'declineButton'     => '#1FB4A6',
					'bgColor'           => '#111827',
					'textColor'         => '#E5E7EB',
				],
				'dark-pink'   => [
					'id'                => 'dark-pink',
					'name'              => 'Dark & Pink',
					'acceptButton'      => '#FB5FAB',
					'acceptButtonHover' => '#EA569D',
					'declineButton'     => '#FB5FAB',
					'bgColor'           => '#111827',
					'textColor'         => '#E5E7EB',
				],
				'dark-orange' => [
					'id'                => 'dark-orange',
					'name'              => 'Dark & Orange',
					'acceptButton'      => '#DCA54A',
					'acceptButtonHover' => '#D09A41',
					'declineButton'     => '#DCA54A',
					'bgColor'           => '#111827',
					'textColor'         => '#E5E7EB',
				],
			]
		);
	}

	/**
	 * Get EU/EEA countries list (ISO 3166-1 alpha-2 codes).
	 *
	 * Single source of truth for EU countries used in geo-targeting.
	 * This includes EU members, EEA countries, and other European countries
	 * commonly included for GDPR compliance purposes.
	 *
	 * @since 0.0.1
	 * @return array<string> Array of country codes.
	 */
	public static function eu_countries(): array {
		return apply_filters(
			'surecookie_eu_countries',
			[
				'AL',
				'AD',
				'AT',
				'BY',
				'BE',
				'BA',
				'BG',
				'HR',
				'CY',
				'CZ',
				'DK',
				'EE',
				'FI',
				'FR',
				'DE',
				'GR',
				'HU',
				'IS',
				'IE',
				'IT',
				'LV',
				'LI',
				'LT',
				'LU',
				'MK',
				'MT',
				'MD',
				'MC',
				'ME',
				'NL',
				'NO',
				'PL',
				'PT',
				'RO',
				'RU',
				'SM',
				'RS',
				'SK',
				'SI',
				'ES',
				'SE',
				'CH',
				'UA',
				'GB',
				'VA',
			]
		);
	}

	/**
	 * Get nav menus list for re-consent menu integration.
	 *
	 * @since 0.0.1
	 * @return array<int, array{id: string, name: string}> Array of nav menu items with id and name.
	 */
	public static function nav_menus(): array {
		$nav_menus = wp_get_nav_menus();
		$list      = [];

		if ( ! is_array( $nav_menus ) ) {
			return [];
		}

		foreach ( $nav_menus as $menu ) {
			$list[] = [
				'id'   => (string) $menu->term_id,
				'name' => $menu->name,
			];
		}

		return $list;
	}

	/**
	 * Scan-observed cookies as the display surfaces should see them.
	 *
	 * Applies `surecookie_scanned_cookies`, so a service known only because the
	 * blocker gated it reaches All Cookies AND the public cookie policy. Read
	 * the option directly where you intend to write it back.
	 *
	 * @since x.x.x
	 * @return array<string, mixed> Cookies grouped by category id.
	 */
	public static function scanned_cookies_for_display(): array {
		/**
		 * Filter: the scan-observed cookie set, grouped by category id.
		 *
		 * Applied on the read path only, so a source other than a scan can add
		 * to what is displayed without those rows being written back to the
		 * option by a read-modify-write caller.
		 *
		 * @since x.x.x
		 * @param array<string, mixed> $cookies Cookies grouped by category id.
		 */
		$cookies = apply_filters(
			'surecookie_scanned_cookies',
			self::option( SURECOOKIE_SCANNED_COOKIES_OPTION, [], 'array' )
		);

		return is_array( $cookies ) ? $cookies : [];
	}

	/**
	 * Get scanned cookies as a flat array with category field added to each cookie.
	 *
	 * @since 0.0.1
	 * @return array<int, array<string, mixed>> Flat array of scanned cookies.
	 */
	public static function all_scanned_cookies(): array {
		$scanned_raw = self::scanned_cookies_for_display();

		$flat = [];

		foreach ( $scanned_raw as $category_id => $cookies ) {
			if ( ! is_array( $cookies ) ) {
				continue;
			}
			foreach ( $cookies as $cookie ) {
				if ( ! is_array( $cookie ) ) {
					continue;
				}
				$flat[] = array_merge(
					$cookie,
					[
						'category' => $category_id,
						'type'     => 'scanned',
					]
				);
			}
		}

		return $flat;
	}

	/**
	 * Per-category tally of what actually exists on THIS site, keyed by category id.
	 *
	 * Only per-site evidence, and only evidence that is actually gated: the bundled
	 * catalog spans every core category (so counting it would mark everything in
	 * use), and a "Do not block" domain gates nothing. Catalog precedence and admin
	 * recategorization are both replayed, so a resource counts for the category the
	 * blocker really gates it under, not the one the scanner guessed.
	 *
	 * @since 1.4.0
	 * @return array<string, array{cookies: int, scripts: int, services: int}>
	 */
	public static function category_usage_map(): array {
		if ( self::$category_usage_memo !== null ) {
			return self::$category_usage_memo;
		}

		$categories = Settings::get( 'cookie_categories' );
		$usage      = [];

		foreach ( is_array( $categories ) ? $categories : [] as $key => $category ) {
			$id = is_array( $category ) && ! empty( $category['id'] ) ? (string) $category['id'] : (string) $key;
			if ( $id !== '' ) {
				$usage[ $id ] = [
					'cookies'  => 0,
					'scripts'  => 0,
					'services' => 0,
				];
			}
		}

		// Scanned cookies. Sync pre-creates all five default buckets as empty
		// arrays, so an existing key proves nothing - only a non-empty one does.
		foreach ( self::scanned_cookies_for_display() as $category_id => $cookies ) {
			if ( is_array( $cookies ) && ! empty( $cookies ) && isset( $usage[ $category_id ] ) ) {
				$usage[ $category_id ]['cookies'] += count( $cookies );
			}
		}

		// Custom cookies, which also cover cookies declared by installing a known service.
		foreach ( self::formatted_custom_cookies() as $category_id => $cookies ) {
			if ( is_array( $cookies ) && isset( $usage[ $category_id ] ) ) {
				$usage[ $category_id ]['cookies'] += count( $cookies );
			}
		}

		// Scan-detected resources, after "Do not block" and after recategorization.
		$resources = self::option( SURECOOKIE_SCANNED_RESOURCES_OPTION, [], 'array' );
		foreach ( [
			'scripts' => 'script',
			'iframes' => 'iframe',
		] as $bucket => $kind ) {
			foreach ( is_array( $resources[ $bucket ] ?? null ) ? $resources[ $bucket ] : [] as $resource ) {
				$domain = is_array( $resource ) ? trim( (string) ( $resource['domain'] ?? '' ) ) : '';
				if ( $domain === '' || Resource_Categories::is_excluded_domain( $domain, $kind ) ) {
					continue;
				}
				$category_id = Resource_Categories::gated_category( $domain, (string) ( $resource['category'] ?? 'uncategorized' ), $kind );
				if ( isset( $usage[ $category_id ] ) ) {
					++$usage[ $category_id ]['scripts'];
				}
			}
		}

		// Manually authored block rules. `custom_blocked_scripts` is not in the
		// options registry, so Settings::get() returns null rather than [].
		$rules = Settings::get( 'custom_blocked_scripts' );
		foreach ( is_array( $rules ) ? $rules : [] as $rule ) {
			$category_id = is_array( $rule ) ? sanitize_key( (string) ( $rule['category'] ?? '' ) ) : '';
			$category_id = $category_id !== '' ? $category_id : 'uncategorized';
			if ( isset( $usage[ $category_id ] ) ) {
				++$usage[ $category_id ]['scripts'];
			}
		}

		// Installed known services. A service can declare zero cookies, so the
		// registry is the only place its category is visible. `suppressed` entries
		// are the admin saying "not on this site" and are ignored.
		$registry = self::option( SURECOOKIE_INSTALLED_SERVICES_OPTION, [], 'array' );
		foreach ( is_array( $registry['installed'] ?? null ) ? $registry['installed'] : [] as $entry ) {
			$category_id = is_array( $entry ) ? sanitize_key( (string) ( $entry['category'] ?? '' ) ) : '';
			$category_id = $category_id !== '' ? $category_id : 'uncategorized';
			if ( isset( $usage[ $category_id ] ) ) {
				++$usage[ $category_id ]['services'];
			}
		}

		self::$category_usage_memo = $usage;

		return $usage;
	}

	/**
	 * Ids of the categories the consent UI may show when "hide unused" is on.
	 *
	 * Never used to build the consent model itself: every registered category
	 * stays in the model regardless, hidden ones simply default to denied.
	 *
	 * @since 1.4.0
	 * @return array<int, string>
	 */
	public static function categories_in_use(): array {
		$usage  = self::category_usage_map();
		$in_use = [];

		foreach ( $usage as $id => $counts ) {
			if ( ( $counts['cookies'] + $counts['scripts'] + $counts['services'] ) > 0 ) {
				$in_use[] = $id;
			}
		}

		// Fail visible: no evidence at all means we know nothing about this site, not
		// that every category is unused. Must run BEFORE required ids are added, or
		// this never fires and a fresh install shows only the always-active row.
		if ( empty( $in_use ) ) {
			return array_keys( $usage );
		}

		// A category the visitor must always be able to see. Keyed off `required`
		// rather than the literal 'essential' id, because custom categories may
		// set it too.
		$categories = Settings::get( 'cookie_categories' );
		foreach ( is_array( $categories ) ? $categories : [] as $key => $category ) {
			if ( is_array( $category ) && ! empty( $category['required'] ) ) {
				$in_use[] = ! empty( $category['id'] ) ? (string) $category['id'] : (string) $key;
			}
		}

		return array_values( array_unique( $in_use ) );
	}

	/**
	 * Whether a category is currently hidden from the consent UI.
	 *
	 * A hidden category has no toggle anywhere, so it must not be grantable from
	 * a blocked-content placeholder either: the visitor would have no way to
	 * withdraw what they granted.
	 *
	 * @since 1.4.0
	 * @param string $category_id Category id.
	 * @return bool
	 */
	public static function is_category_hidden( string $category_id ): bool {
		if ( $category_id === '' || empty( Settings::get( 'hide_unused_categories' ) ) ) {
			return false;
		}

		return ! in_array( $category_id, self::categories_in_use(), true );
	}

	/**
	 * Drop the per-request usage memo. Used after a mutation and by tests.
	 *
	 * @since 1.4.0
	 * @return void
	 */
	public static function clear_category_usage_cache(): void {
		self::$category_usage_memo = null;
	}

	/**
	 * Get CSS variables for the active color palette.
	 *
	 * @since 0.0.1
	 * @return string
	 */
	public static function palette_root_css(): string {
		$color_palette = sanitize_key( (string) Settings::get( 'color_palette' ) );
		$palette_codes = self::color_palette_codes();

		// A saved id can vanish (e.g. 'astra' after switching themes); fall
		// back to the default palette instead of emitting no variables, which
		// would leave var()-less CSS rules with no color at all.
		if ( ! isset( $palette_codes[ $color_palette ] ) ) {
			$defaults      = Settings::get_settings_defaults();
			$color_palette = sanitize_key( (string) ( $defaults['color_palette'] ?? '' ) );
		}

		if ( ! isset( $palette_codes[ $color_palette ] ) ) {
			return '';
		}

		$palette_data = $palette_codes[ $color_palette ];

		/**
		 * Filters resolved palette colors before CSS-var emission.
		 *
		 * Array shape mirrors Get::color_palette_codes() entries.
		 *
		 * @since 1.0.0
		 * @param array<string, string> $palette_data  Resolved palette colors.
		 * @param string                $color_palette Active palette id.
		 */
		$filtered     = apply_filters( 'surecookie_resolved_palette_colors', $palette_data, $color_palette );
		$palette_data = is_array( $filtered ) ? $filtered : $palette_data;

		// Decline falls back to text color (not accept) so a missing
		// secondary never visually collapses accept and decline together.
		$secondary_fallback = $palette_data['textColor'] ?? '#374151';

		$css_variables = [
			'--surecookie-banner-primary-color'       => $palette_data['acceptButton'] ?? '#1463FF',
			'--surecookie-banner-primary-hover-color' => $palette_data['acceptButtonHover'] ?? '#3B82F6',
			'--surecookie-banner-secondary-color'     => $palette_data['declineButton'] ?? $secondary_fallback,
			'--surecookie-banner-text-color'          => $palette_data['textColor'] ?? '#374151',
			'--surecookie-banner-background-color'    => $palette_data['bgColor'] ?? '#FFFFFF',
		];

		$css_output = ':root {';
		foreach ( $css_variables as $variable => $value ) {
			// Hex or strict rgba() - Pro's custom palette supports alpha.
			$sanitized_value = Sanitize::css_color( (string) $value );
			if ( empty( $sanitized_value ) ) {
				continue;
			}

			$css_output .= "\t{$variable}: {$sanitized_value};\n";
		}

		if ( $css_output === ':root {' ) {
			return '';
		}

		$css_output .= '}';
		$css_output .= "\n/* Color Palette: {$color_palette} */\n";

		return $css_output;
	}

	/**
	 * Vendor label for every service in the blocking catalog, keyed by service.
	 *
	 * A blocked element only carries its service key, so this is what lets the
	 * frontend name the vendor a placeholder is waiting on. Read from the same
	 * `surecookie_known_scripts` view the blocker matches against, so the label a
	 * visitor sees is the one the admin sees.
	 *
	 * @since 1.4.0
	 * @return array<string, string>
	 */
	public static function blocking_service_labels(): array {
		$catalog = apply_filters( 'surecookie_known_scripts', [] );
		if ( ! is_array( $catalog ) ) {
			return [];
		}

		$labels = [];

		foreach ( $catalog as $services ) {
			if ( ! is_array( $services ) ) {
				continue;
			}

			foreach ( $services as $key => $service ) {
				$label = is_array( $service ) ? (string) ( $service['label'] ?? '' ) : '';
				// Only worth sending when it differs from the key the element
				// already carries; the frontend falls back to that key.
				if ( $label !== '' && $label !== (string) $key ) {
					$labels[ (string) $key ] = $label;
				}
			}
		}

		return $labels;
	}

	/**
	 * Effective banner colors for surfaces painted like the banner (e.g. the
	 * blocked-content placeholder), as literal hex. Background comes from the
	 * active palette (with the same `surecookie_resolved_palette_colors` filter
	 * the banner honors, so Pro custom colors apply); the text color is computed
	 * from that background's brightness so it ALWAYS contrasts - never dark-on-dark
	 * or light-on-light. Emitting literal hex inline avoids any CSS-var mismatch.
	 *
	 * @since 1.4.0
	 * @return array{background: string, text: string, primary: string}
	 */
	public static function banner_display_colors(): array {
		$palette_codes = self::color_palette_codes();
		$color_palette = (string) Settings::get( 'color_palette' );
		$palette       = isset( $palette_codes[ $color_palette ] ) && is_array( $palette_codes[ $color_palette ] )
			? $palette_codes[ $color_palette ]
			: [];

		$filtered = apply_filters( 'surecookie_resolved_palette_colors', $palette, $color_palette );
		$palette  = is_array( $filtered ) ? $filtered : $palette;

		$valid = static function ( $color ): string {
			$color = is_string( $color ) ? trim( $color ) : '';
			return preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ? $color : '';
		};

		$background = $valid( $palette['bgColor'] ?? '' );
		if ( $background === '' ) {
			$background = '#1f2937';
		}

		$primary = $valid( $palette['acceptButton'] ?? '' );
		if ( $primary === '' ) {
			$primary = '#2563eb';
		}

		return [
			'background' => $background,
			'text'       => self::readable_text_color( $background ),
			'primary'    => $primary,
		];
	}

	/**
	 * Get shared style which required for both frontend and in admin preview.
	 *
	 * @since 0.0.1
	 * @return string $style_file CSS file name depending on RTL or LTR.
	 */
	public static function shared_style_css() {
		$style_file = 'style.css';
		if ( self::is_rtl() && file_exists( SURECOOKIE_DIR . 'assets/css/style-rtl.css' ) ) {
			$style_file = 'style-rtl.css';
		}
		return $style_file;
	}

	/**
	 * Get admin style.
	 *
	 * @since 0.0.1
	 * @return string $admin_style_file CSS file name depending on RTL or LTR.
	 */
	public static function admin_style_css() {
		$admin_style_file = 'admin.css';
		if ( Get::is_rtl() && file_exists( SURECOOKIE_DIR . 'build/admin-rtl.css' ) ) {
			$admin_style_file = 'admin-rtl.css';
		}
		return $admin_style_file;
	}

	/**
	 * Get onboarding style.
	 *
	 * @since 0.0.1
	 * @return string $onboarding_style_file CSS file name depending on RTL or LTR.
	 */
	public static function onboarding_style_css() {
		$onboarding_style_file = 'onboarding.css';
		if ( Get::is_rtl() && file_exists( SURECOOKIE_DIR . 'build/onboarding-rtl.css' ) ) {
			$onboarding_style_file = 'onboarding-rtl.css';
		}
		return $onboarding_style_file;
	}

	/**
	 * Get jodit style.
	 *
	 * @since 1.1.0
	 * @return string $jodit_style_file CSS file name depending on RTL or LTR.
	 */
	public static function jodit_style_css() {
		$jodit_style_file = 'jodit.css';
		if ( Get::is_rtl() && file_exists( SURECOOKIE_DIR . 'assets/css/jodit-rtl.css' ) ) {
			$jodit_style_file = 'jodit-rtl.css';
		}
		return $jodit_style_file;
	}

	/**
	 * Get cookie-policy style.
	 *
	 * @since 1.1.0
	 * @return string $cookie_policy_style_file CSS file name depending on RTL or LTR.
	 */
	public static function cookie_policy_style_css() {
		$cookie_policy_style_file = 'cookie-policy.css';
		if ( Get::is_rtl() && file_exists( SURECOOKIE_DIR . 'assets/css/cookie-policy-rtl.css' ) ) {
			$cookie_policy_style_file = 'cookie-policy-rtl.css';
		}
		return $cookie_policy_style_file;
	}

	/**
	 * Get cookie policy page details (URL and title).
	 *
	 * @since 0.0.1
	 * @return array<string, string> Associative array with 'url' and 'title' keys.
	 */
	public static function cookie_policy_page_details(): array {
		$data = [
			'url'   => '',
			'title' => '',
		];

		$policy_page_id = self::resolve_translated_policy_page_id( absint( Settings::get( 'cookie_policy_page_id' ) ) );

		if ( $policy_page_id > 0 ) {
			$page = get_post( $policy_page_id );
			if ( $page instanceof \WP_Post && $page->post_status === 'publish' ) {
				$permalink = get_permalink( $page );
				if ( ! $permalink ) {
					return $data;
				}

				$parsed_url = wp_parse_url( $permalink );
				$home_url   = wp_parse_url( home_url() );

				if ( ! empty( $parsed_url['host'] ) && isset( $home_url['host'] ) && strtolower( $parsed_url['host'] ) === strtolower( $home_url['host'] ) ) {
					$data['url']   = esc_url( $permalink );
					$data['title'] = sanitize_text_field( get_the_title( $page ) );
				}
			}
		}

		return $data;
	}

	/**
	 * Legible text color (near-black or white) for a background hex, using
	 * perceived brightness (ITU-R BT.601). Guarantees the placeholder text
	 * contrasts its background regardless of palette. White for invalid input.
	 *
	 * @since 1.4.0
	 * @param string $hex Background color (hex).
	 * @return string '#111827' (dark) or '#ffffff' (light).
	 */
	private static function readable_text_color( string $hex ): string {
		$hex = ltrim( trim( $hex ), '#' );

		if ( strlen( $hex ) === 3 ) {
			$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
		}

		if ( ! preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) {
			return '#ffffff';
		}

		$r = (int) hexdec( substr( $hex, 0, 2 ) );
		$g = (int) hexdec( substr( $hex, 2, 2 ) );
		$b = (int) hexdec( substr( $hex, 4, 2 ) );

		$brightness = ( $r * 299 + $g * 587 + $b * 114 ) / 1000;

		return $brightness > 140 ? '#111827' : '#ffffff';
	}

	/**
	 * Resolve cookie policy page ID for the current language.
	 *
	 * Supports WPML and Polylang translation APIs when available.
	 *
	 * @param int $page_id Source page ID from settings.
	 * @return int
	 * @since 1.2.1
	 */
	private static function resolve_translated_policy_page_id( int $page_id ): int {
		if ( $page_id <= 0 ) {
			return 0;
		}

		$wpml_page_id = apply_filters( 'wpml_object_id', $page_id, 'page', true );

		if ( is_numeric( $wpml_page_id ) && (int) $wpml_page_id > 0 && (int) $wpml_page_id !== $page_id ) {
			return (int) $wpml_page_id;
		}

		if ( function_exists( 'pll_get_post' ) ) {
			$polylang_page_id = pll_get_post( $page_id );
			if ( is_numeric( $polylang_page_id ) && (int) $polylang_page_id > 0 ) {
				return (int) $polylang_page_id;
			}
		}

		return $page_id;
	}
}
