<?php

declare(strict_types=1);

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

final class CRToday_Exchange_Rates_Block {

	use CRToday_Color_Helpers;
	use CRToday_Fetch_Lock;
	use CRToday_Rate_Helpers;

	private const HANDLE_EDITOR = 'crtoday-block-editor';
	private const HANDLE_STYLE  = 'crtoday-block-style';
	private const HANDLE_FRONTEND = 'crtoday-currency-converter-frontend';
	private const API_TIMEOUT   = 8;
	private const AJAX_PREVIEW_ACTION = 'crtoday_shortcode_preview';
	private const CHANGE_WINDOW_SECONDS = 86400;
	private const FETCH_LOCK_TTL_SECONDS = 20;

	public const DEFAULT_ATTRIBUTES = [
		'sourceId'            => 'FEX',
		'deal'                => 'DEFAULT',
		'base'                => 'USD',
		'intervalMin'         => 15,
		'symbols'             => ['EUR', 'GBP', 'JPY'],
		'valueModePreference' => 'auto',
		'theme'               => 'light',
		'layout'              => 'table',
		'showChange'          => false,
		'showRange'           => false,
		'showTimestamp'       => true,
		'sourceLabel'         => '',
		'decimals'            => 2,
		'shadow'              => true,
		'seamless'            => false,
		'radius'              => 16,
		'currencyLabelMode'   => 'code',
		'showCurrencySymbols' => false,
		'invertRates'         => false,
		'amount'              => '1',
		'adjustmentPercent'   => 0,
		'showCtaButton'       => false,
		'ctaButtonText'       => 'Convert',
		'ctaUrlTemplate'      => '',
		'compactMode'         => true,
		'showFlags'           => true,
		'attribution'         => true,
		'accentTheme'         => 'default',
		'accentAngle'         => 135,
		'accentMode'          => 'stripe',
		'fitWidth'            => true,
		'enableStructuredData'=> false,
	];

	public static function register(): void {
		self::register_assets();

		register_block_type(
			CRTODAY_PLUGIN_DIR . 'blocks/exchange-rates',
			[
				'render_callback' => [self::class, 'render'],
			]
		);

		add_shortcode('crtoday_exchange_rates', [self::class, 'render_shortcode']);
		add_action('wp_ajax_' . self::AJAX_PREVIEW_ACTION, [self::class, 'handle_ajax_preview']);
	}

	/**
	 * Shortcode handler: [crtoday_exchange_rates base="USD" symbols="EUR,GBP,JPY" source="FEX:DEFAULT" ...]
	 *
	 * @param array<string,string>|string $atts Shortcode attributes.
	 */
	public static function render_shortcode($atts): string {
		$atts = shortcode_atts(
			array_merge(self::DEFAULT_ATTRIBUTES, ['source' => '']),
			is_array($atts) ? $atts : [],
			'crtoday_exchange_rates'
		);

		/* Parse compound source parameter → sourceId + deal */
		$source_param = sanitize_text_field((string) ($atts['source'] ?? ''));
		if ($source_param !== '') {
			$parsed = CRToday_Source_Selection::parse_compound_source($source_param);
			$atts['sourceId'] = $parsed['sourceId'];
			$atts['deal'] = $parsed['deal'];
		}
		unset($atts['source']);

		/* Convert comma-separated symbols string → array */
		if (is_string($atts['symbols'])) {
			$atts['symbols'] = array_filter(array_map('trim', explode(',', $atts['symbols'])));
		}

		/* snake_case aliases */
		if (isset($atts['adjustment_percent'])) {
			$atts['adjustmentPercent'] = $atts['adjustment_percent'];
		}
		if (isset($atts['adjustment'])) {
			$atts['adjustmentPercent'] = $atts['adjustment'];
		}
		if (isset($atts['show_cta_button'])) {
			$atts['showCtaButton'] = $atts['show_cta_button'];
		}
		if (isset($atts['cta_button_text'])) {
			$atts['ctaButtonText'] = $atts['cta_button_text'];
		}
		if (isset($atts['cta_text'])) {
			$atts['ctaButtonText'] = $atts['cta_text'];
		}
		if (isset($atts['cta_url_template'])) {
			$atts['ctaUrlTemplate'] = $atts['cta_url_template'];
		}
		if (isset($atts['cta_url'])) {
			$atts['ctaUrlTemplate'] = $atts['cta_url'];
		}
		if (isset($atts['enable_structured_data'])) {
			$atts['enableStructuredData'] = $atts['enable_structured_data'];
		}
		if (isset($atts['show_currency_symbols'])) {
			$atts['showCurrencySymbols'] = $atts['show_currency_symbols'];
		}

		/* Cast numeric/boolean strings from shortcode context */
		foreach (['intervalMin', 'decimals', 'radius'] as $num_key) {
			$atts[$num_key] = (int) $atts[$num_key];
		}
		if (isset($atts['adjustmentPercent'])) {
			$atts['adjustmentPercent'] = (float) $atts['adjustmentPercent'];
		}
		foreach (['showChange', 'showRange', 'showTimestamp', 'shadow', 'seamless', 'showCurrencySymbols', 'invertRates', 'showCtaButton', 'compactMode', 'showFlags', 'fitWidth', 'enableStructuredData', 'attribution'] as $bool_key) {
			$atts[$bool_key] = filter_var($atts[$bool_key], FILTER_VALIDATE_BOOLEAN);
		}

		/* Ensure the style is enqueued */
		if (wp_style_is(self::HANDLE_STYLE, 'registered')) {
			wp_enqueue_style(self::HANDLE_STYLE);
		}

		if (wp_script_is(self::HANDLE_FRONTEND, 'registered')) {
			wp_enqueue_script(self::HANDLE_FRONTEND);
		}

		return self::render($atts);
	}

	/**
	 * AJAX handler for shortcode generator live preview.
	 */
	public static function handle_ajax_preview(): void {
		if (! current_user_can('manage_options')) {
			wp_send_json_error('Unauthorized', 403);
		}

		check_ajax_referer(self::AJAX_PREVIEW_ACTION, 'nonce');

		$raw = isset($_POST['attributes']) && is_array($_POST['attributes'])
			? map_deep(wp_unslash($_POST['attributes']), 'sanitize_text_field')
			: [];
		if (! is_array($raw)) {
			wp_send_json_error('Invalid attributes', 400);
		}

		/* Parse compound source parameter (SOURCE:DEAL) used by shortcode generator form */
		if (isset($raw['source']) && is_string($raw['source']) && $raw['source'] !== '') {
			$parsed = CRToday_Source_Selection::parse_compound_source((string) $raw['source']);
			$raw['sourceId'] = $parsed['sourceId'];
			$raw['deal'] = $parsed['deal'];
			unset($raw['source']);
		}

		/* Convert comma-separated symbols */
		if (isset($raw['symbols']) && is_string($raw['symbols'])) {
			$raw['symbols'] = array_filter(array_map('trim', explode(',', $raw['symbols'])));
		}

		/* Cast types */
		foreach (['intervalMin', 'decimals', 'radius'] as $k) {
			if (isset($raw[$k])) {
				$raw[$k] = (int) $raw[$k];
			}
		}
		if (isset($raw['adjustmentPercent'])) {
			$raw['adjustmentPercent'] = (float) $raw['adjustmentPercent'];
		}
		foreach (['showChange', 'showRange', 'showTimestamp', 'shadow', 'seamless', 'showCurrencySymbols', 'invertRates', 'showCtaButton', 'compactMode', 'showFlags', 'fitWidth', 'enableStructuredData', 'attribution'] as $k) {
			if (isset($raw[$k])) {
				$raw[$k] = filter_var($raw[$k], FILTER_VALIDATE_BOOLEAN);
			}
		}

		$merged = wp_parse_args($raw, self::DEFAULT_ATTRIBUTES);
		$html   = self::render($merged);

		wp_send_json_success(['html' => $html]);
	}

	private static function register_assets(): void {
		$asset_path = CRTODAY_PLUGIN_DIR . 'build/index.asset.php';
		$asset      = file_exists($asset_path)
			? require $asset_path
			: [
				'dependencies' => ['wp-blocks', 'wp-element', 'wp-i18n', 'wp-components', 'wp-block-editor', 'wp-server-side-render'],
				'version'      => CRTODAY_PLUGIN_VERSION,
			];

		wp_register_script(
			self::HANDLE_EDITOR,
			CRTODAY_PLUGIN_URL . 'build/index.js',
			$asset['dependencies'],
			$asset['version'],
			true
		);

		wp_localize_script(
			self::HANDLE_EDITOR,
			'crtodaySourceOptions',
			CRToday_Plugin_Settings::get_all_source_options()
		);

		wp_localize_script(
			self::HANDLE_EDITOR,
			'crtodayCurrencies',
			CRToday_Plugin_Settings::get_currencies_for_editor()
		);

		wp_localize_script(
			self::HANDLE_EDITOR,
			'crtodayEditorCurrenciesUi',
			[
				'flagBaseUrl' => CRTODAY_PLUGIN_URL . 'assets/flags/webp/128/',
				'groupOrder'  => CRToday_Plugin_Settings::get_currency_group_order(),
				'groupLabels' => CRToday_Plugin_Settings::get_currency_group_labels(),
				'labels'      => [
					'searchCurrency'         => __('Search currency…', 'currencyrate-today-currency-blocks'),
					'noResults'              => __('No currencies found', 'currencyrate-today-currency-blocks'),
					'noCurrenciesSelected'   => __('No currencies selected', 'currencyrate-today-currency-blocks'),
					/* translators: %d: selected currency count */
					'selectedCurrenciesPattern' => __('%d currencies selected', 'currencyrate-today-currency-blocks'),
					'sourceGroupBuiltIn'     => __('Built-in', 'currencyrate-today-currency-blocks'),
					'sourceGroupLocal'       => __('Local', 'currencyrate-today-currency-blocks'),
					'exchangeUrlPlaceholder' => 'https://example.com/convert?amount={amount}&from={from}&to={to}&result={result}&bid={bid}&ask={ask}',
					'converterUrlPlaceholder'=> 'https://example.com/convert?amount={amount}&from={from}&to={to}&result={result}',
				],
			]
		);

		wp_set_script_translations(
			self::HANDLE_EDITOR,
			'currencyrate-today-currency-blocks',
			CRTODAY_PLUGIN_DIR . 'languages'
		);

		$style_file = self::detect_style_file();
		if ($style_file !== null) {
			$style_path    = CRTODAY_PLUGIN_DIR . 'build/' . $style_file;
			$style_version = file_exists($style_path)
				? (string) filemtime($style_path)
				: (string) $asset['version'];

			wp_register_style(
				self::HANDLE_STYLE,
				CRTODAY_PLUGIN_URL . 'build/' . $style_file,
				[],
				$style_version
			);
		}
	}

	private static function detect_style_file(): ?string {
		if (file_exists(CRTODAY_PLUGIN_DIR . 'build/style-index.css')) {
			return 'style-index.css';
		}

		if (file_exists(CRTODAY_PLUGIN_DIR . 'build/index.css')) {
			return 'index.css';
		}

		return null;
	}

	public static function render(array $attributes): string {
		$settings      = self::sanitize_attributes($attributes);
		$stateful      = self::resolve_snapshot($settings);
		$pricing_mode  = $stateful['viewModel']['pricingMode'] ?? 'mid';

		$rows = [];
		foreach ($stateful['viewModel']['rates'] as $rate) {
			$rows[] = self::build_row($rate, $settings, $pricing_mode);
		}

		/* Seamless mode: force-strip all visual chrome */
		if ($settings['seamless']) {
			$settings['theme']      = 'light';
			$settings['shadow']     = false;
			$settings['radius']     = 0;
			$settings['accentMode'] = 'none';
		}

		$root_classes = array_filter([
			'crtoday-widget',
			'crtoday-exchange-rates',
			self::is_row_cta_requested($settings) ? 'crtoday-has-row-cta' : '',
			$settings['showChange'] ? 'crtoday-has-change' : 'crtoday-no-change',
			self::has_adjustment((float) $settings['adjustmentPercent']) ? 'crtoday-has-adjustment' : '',
			'crtoday-theme-' . $settings['theme'],
			$settings['shadow'] ? 'crtoday-has-shadow' : 'crtoday-no-shadow',
			$settings['seamless'] ? 'crtoday-seamless' : '',
			'crtoday-state-' . $stateful['state'],
			$settings['compactMode'] ? 'crtoday-compact' : '',
			$settings['fitWidth'] ? 'crtoday-fit-width' : '',
			$settings['showFlags'] ? '' : 'crtoday-no-flags',
			'crtoday-accent-' . $settings['accentMode'],
			'crtoday-pricing-' . $pricing_mode,
		]);

		$base_flag_html    = self::build_base_flag($settings['base']);
		$source_info       = CRToday_Widget_Template::resolve_source_label(
			$settings['sourceId'],
			$settings['deal'],
			$settings['sourceLabel']
		);
		$col_header_html   = self::build_column_header($rows, $settings, $pricing_mode);
		$rates_html        = self::build_rates_body($rows, $stateful['state']);

		$base_label = self::format_currency_label(
			$settings['base'],
			$settings['currencyLabelMode'],
			(bool) ($settings['showCurrencySymbols'] ?? false)
		);

		if (self::bd_compare($settings['amount'], '1') !== 0 && self::bd_compare($settings['amount'], '0') > 0) {
			$base_label = self::format_amount_for_base_label($settings['amount']) . ' ' . $base_label;
		}

		$base_html = sprintf(
			'<div class="crtoday-base">%s<span class="crtoday-base-label">%s</span></div>',
			$base_flag_html,
			esc_html($base_label)
		);

		$header_html       = CRToday_Widget_Template::build_header($base_html, $source_info['label'], $source_info['verified']);
		$footer_html       = CRToday_Widget_Template::build_footer($settings['showTimestamp'], (string) ($stateful['viewModel']['timestamp'] ?? ''), (bool) ($settings['attribution'] ?? false));
		$disclaimer_html   = CRToday_Widget_Template::build_disclaimer_dialog((bool) ($settings['attribution'] ?? false));
		$disclaimer_script = CRToday_Widget_Template::build_disclaimer_script();
		$structured_data_html = self::build_structured_data_html($stateful['viewModel'], $settings, $pricing_mode);
		$adjustment_html   = '';

		if (self::has_adjustment((float) $settings['adjustmentPercent'])) {
			$adjustment_html = sprintf(
				'<div class="crtoday-adjustment-note-wrap"><div class="crtoday-adjustment-note">%s</div></div>',
					esc_html(sprintf(
						/* translators: %s: formatted percentage value */
						__('Adjusted by %s%%', 'currencyrate-today-currency-blocks'),
					self::format_adjustment_percent((float) $settings['adjustmentPercent'])
				))
			);
		}

		$inline_style = self::build_accent_style_vars($settings);

		if (wp_script_is(self::HANDLE_FRONTEND, 'registered')) {
			wp_enqueue_script(self::HANDLE_FRONTEND);
		}

		$locale = str_replace('_', '-', determine_locale());

		$html = sprintf(
			'<div class="%1$s" dir="%11$s" data-crtoday-version="1" data-crtoday-state="%2$s" data-crtoday-decimals="%12$s" data-crtoday-locale="%13$s" style="%3$s">'
			. '<div class="crtoday-card">'
			. '<div class="crtoday-accent"></div>'
			. '%4$s'
			. '<div class="crtoday-rates">%5$s%6$s</div>'
			. '%7$s%8$s%9$s'
			. '</div>'
			. '</div>'
			. '%10$s',
			esc_attr(implode(' ', $root_classes)),
			esc_attr($stateful['state']),
			esc_attr($inline_style),
			$header_html,
			$col_header_html,
			$rates_html,
			$adjustment_html,
			$footer_html,
			$disclaimer_html,
			$disclaimer_script,
			esc_attr(is_rtl() ? 'rtl' : 'ltr'),
			esc_attr((string) $settings['decimals']),
			esc_attr($locale)
		);

		return $html . $structured_data_html;
	}

	private static function build_structured_data_html(array $view_model, array $settings, string $pricing_mode): string {
		if (! ($settings['enableStructuredData'] ?? false)) {
			return '';
		}

		if (! isset($view_model['rates']) || ! is_array($view_model['rates']) || $view_model['rates'] === []) {
			return '';
		}

		$base = strtoupper((string) ($settings['base'] ?? ''));
		$reference_value = (float) ($settings['amount'] ?? 1);
		if ($reference_value <= 0) {
			$reference_value = 1;
		}

		$items = [];
		foreach ($view_model['rates'] as $rate) {
			$quote = strtoupper((string) ($rate['code'] ?? ''));
			if ($quote === '' || $quote === $base) {
				continue;
			}

			$mid_str = self::normalize_to_string($rate['mid'] ?? null);
			$bid_str = ((float) ($rate['bid'] ?? 0) > 0) ? (string) ((float) $rate['bid']) : null;
			$ask_str = ((float) ($rate['ask'] ?? 0) > 0) ? (string) ((float) $rate['ask']) : null;

			if ($settings['invertRates']) {
				if ($mid_str !== null) {
					$mid_str = self::bd_invert($mid_str, $settings['decimals']);
				}
				$orig_bid = $bid_str;
				$orig_ask = $ask_str;
				$bid_str = ($orig_ask !== null) ? self::bd_invert($orig_ask, $settings['decimals']) : null;
				$ask_str = ($orig_bid !== null) ? self::bd_invert($orig_bid, $settings['decimals']) : null;
			}

			if (self::bd_compare($settings['amount'], '1') !== 0) {
				if ($mid_str !== null) {
					$mid_str = self::bd_multiply($mid_str, $settings['amount'], $settings['decimals']);
				}
				if ($bid_str !== null) {
					$bid_str = self::bd_multiply($bid_str, $settings['amount'], $settings['decimals']);
				}
				if ($ask_str !== null) {
					$ask_str = self::bd_multiply($ask_str, $settings['amount'], $settings['decimals']);
				}
			}

			if (self::has_adjustment((float) ($settings['adjustmentPercent'] ?? 0))) {
				$factor = self::adjustment_factor_string((float) $settings['adjustmentPercent']);
				if ($mid_str !== null) {
					$mid_str = self::bd_multiply($mid_str, $factor, $settings['decimals']);
				}
				if ($bid_str !== null) {
					$bid_str = self::bd_multiply($bid_str, $factor, $settings['decimals']);
				}
				if ($ask_str !== null) {
					$ask_str = self::bd_multiply($ask_str, $factor, $settings['decimals']);
				}
			}

			$valid_from = (string) ($view_model['timestamp'] ?? '');
			if ($pricing_mode === 'bidask') {
				if ($bid_str !== null) {
					$items[] = ['base' => $base, 'quote' => $quote, 'rateType' => 'bid', 'price' => (float) $bid_str, 'validFrom' => $valid_from, 'referenceValue' => $reference_value];
				}
				if ($ask_str !== null) {
					$items[] = ['base' => $base, 'quote' => $quote, 'rateType' => 'ask', 'price' => (float) $ask_str, 'validFrom' => $valid_from, 'referenceValue' => $reference_value];
				}
				if ($bid_str === null && $ask_str === null && $mid_str !== null) {
					$items[] = ['base' => $base, 'quote' => $quote, 'rateType' => 'mid', 'price' => (float) $mid_str, 'validFrom' => $valid_from, 'referenceValue' => $reference_value];
				}
			} elseif ($mid_str !== null) {
				$items[] = ['base' => $base, 'quote' => $quote, 'rateType' => 'mid', 'price' => (float) $mid_str, 'validFrom' => $valid_from, 'referenceValue' => $reference_value];
			}
		}

		return CRToday_Widget_Template::build_exchange_rate_json_ld($items);
	}

	/* ── Render sub-components ── */

	private static function build_base_flag(string $base): string {
		$flag = CRToday_Plugin_Settings::get_currency_flag($base);
		if ($flag === null) {
			return '';
		}

		return sprintf(
			'<img class="crtoday-base-flag" src="%1$s" alt="%2$s" loading="lazy" decoding="async" width="32" height="32" />',
			esc_url(CRTODAY_PLUGIN_URL . 'assets/flags/webp/128/' . $flag . '.webp'),
			esc_attr(sprintf(
				/* translators: %s: currency code */
				__('Flag for %s', 'currencyrate-today-currency-blocks'),
				$base
			))
		);
	}

	private static function build_column_header(array $rows, array $settings, string $pricing_mode): string {
		if ($rows === []) {
			return '';
		}

		$delta_spacer = $settings['showChange']
			? sprintf(
				'<span class="crtoday-rates-header-label crtoday-rates-header-spacer-delta">%s</span>',
				esc_html(__('24h', 'currencyrate-today-currency-blocks'))
			)
			: '';
		$cta_spacer = self::is_row_cta_requested($settings)
			? '<span class="crtoday-rates-header-spacer crtoday-rates-header-spacer-cta"></span>'
			: '';

		if ($pricing_mode === 'bidask') {
			return sprintf(
				'<div class="crtoday-rates-header">'
				. '<span class="crtoday-rates-header-spacer"></span>'
				. '<span class="crtoday-rates-header-label">%1$s</span>'
				. '<span class="crtoday-rates-header-label">%2$s</span>'
				. '%3$s'
				. '%4$s'
				. '</div>',
				esc_html(__('Buy', 'currencyrate-today-currency-blocks')),
				esc_html(__('Sell', 'currencyrate-today-currency-blocks')),
				$delta_spacer,
				$cta_spacer
			);
		}

		return sprintf(
			'<div class="crtoday-rates-header">'
			. '<span class="crtoday-rates-header-spacer"></span>'
			. '<span class="crtoday-rates-header-label">%1$s</span>'
			. '%2$s'
			. '%3$s'
			. '</div>',
			esc_html(__('Rate', 'currencyrate-today-currency-blocks')),
			$delta_spacer,
			$cta_spacer
		);
	}

	private static function build_rates_body(array $rows, string $state): string {
		if ($rows !== []) {
			return implode('', $rows);
		}

		$msg = $state === 'error'
			? __('Rates temporarily unavailable', 'currencyrate-today-currency-blocks')
			: __('No rates available', 'currencyrate-today-currency-blocks');

		return sprintf('<div class="crtoday-empty-msg">%s</div>', esc_html($msg));
	}

	private static function sanitize_attributes(array $attributes): array {
		$a = wp_parse_args($attributes, self::DEFAULT_ATTRIBUTES);

		$allowed_themes = ['auto', 'light', 'dark'];
		$theme          = in_array($a['theme'], $allowed_themes, true) ? $a['theme'] : 'auto';

		$allowed_mode = ['auto', 'mid', 'derivedMid'];
		$value_mode   = in_array($a['valueModePreference'], $allowed_mode, true) ? $a['valueModePreference'] : 'auto';

		$label_mode = $a['currencyLabelMode'] === 'name' ? 'name' : 'code';
		$interval   = in_array((int) $a['intervalMin'], [15, 30, 60], true) ? (int) $a['intervalMin'] : 15;
		$decimals   = max(0, min(8, (int) $a['decimals']));
		$radius     = max(0, min(32, (int) $a['radius']));
		$base       = strtoupper((string) $a['base']);

		/* ── Per-block source resolution ── */
		$source_id   = strtoupper((string) ($a['sourceId'] ?? 'FEX'));
		$deal        = strtoupper((string) ($a['deal'] ?? 'DEFAULT'));
		$resolved    = CRToday_Plugin_Settings::resolve_source($source_id, $deal);
		if ($resolved === null) {
			/* Fallback to default built-in source */
			$resolved = CRToday_Plugin_Settings::resolve_source('FEX', 'DEFAULT');
			$source_id = 'FEX';
			$deal      = 'DEFAULT';
		}
		$source_base = strtoupper((string) ($resolved['base'] ?? 'USD'));

		$symbols = [];
		if (is_array($a['symbols'])) {
			foreach ($a['symbols'] as $symbol) {
				$normalized = strtoupper((string) $symbol);
				if (preg_match('/^[A-Z]{3}$/', $normalized) === 1) {
					$symbols[$normalized] = true;
				}
			}
		}

		if ($symbols === []) {
			$symbols = ['EUR' => true, 'GBP' => true, 'JPY' => true];
		}

		$allowed_accent_mode = ['color-border', 'stripe', 'none'];
		$accent_mode = in_array($a['accentMode'], $allowed_accent_mode, true) ? $a['accentMode'] : 'stripe';

		$accent_presets = CRToday_Plugin_Settings::get_accent_theme_presets();
		$accent_theme = strtolower((string) ($a['accentTheme'] ?? 'default'));
		if (! isset($accent_presets[$accent_theme])) {
			$accent_theme = 'default';
		}

		$accent_angle = max(0, min(360, (int) ($a['accentAngle'] ?? 135)));

		return [
			'sourceId'            => $source_id,
			'deal'                => $deal,
			'sourceBase'          => $source_base,
			'sourceType'          => $resolved['type'] ?? 'builtin',
			'base'                => $base,
			'intervalMin'         => $interval,
			'symbols'             => array_keys($symbols),
			'valueModePreference' => $value_mode,
			'theme'               => $theme,
			'layout'              => $a['layout'] === 'list' ? 'list' : 'table',
			'showChange'          => (bool) $a['showChange'],
			'showRange'           => (bool) $a['showRange'],
			'showTimestamp'       => (bool) $a['showTimestamp'],
			'sourceLabel'         => mb_substr(sanitize_text_field((string) $a['sourceLabel']), 0, 20),
			'decimals'            => $decimals,
			'shadow'              => (bool) $a['shadow'],
			'seamless'            => (bool) $a['seamless'],
			'radius'              => $radius,
			'currencyLabelMode'   => $label_mode,
			'showCurrencySymbols' => (bool) ($a['showCurrencySymbols'] ?? false),
			'invertRates'         => (bool) $a['invertRates'],
			'amount'              => self::sanitize_amount((string) $a['amount']),
			'adjustmentPercent'   => self::sanitize_adjustment_percent($a['adjustmentPercent'] ?? 0),
			'showCtaButton'       => (bool) $a['showCtaButton'],
			'ctaButtonText'       => mb_substr(sanitize_text_field((string) ($a['ctaButtonText'] ?? '')), 0, 40),
			'ctaUrlTemplate'      => mb_substr(trim((string) wp_kses((string) ($a['ctaUrlTemplate'] ?? ''), [])), 0, 500),
			'compactMode'         => (bool) $a['compactMode'],
			'showFlags'           => (bool) $a['showFlags'],
			'attribution'         => (bool) ($a['attribution'] ?? true),
			'fitWidth'            => (bool) $a['fitWidth'],
			'enableStructuredData'=> (bool) ($a['enableStructuredData'] ?? false),
			'accentTheme'         => $accent_theme,
			'accentAngle'         => $accent_angle,
			'accentMode'          => $accent_mode,
		];
	}

	private static function resolve_snapshot(array $settings): array {
		/* ── Local source: build view model from stored rates ── */
		if (($settings['sourceType'] ?? 'builtin') === 'local') {
			$local_vm = self::resolve_local_view_model($settings);
			if ($local_vm !== null) {
				return [
					'state'     => self::view_model_state($local_vm),
					'viewModel' => $local_vm,
				];
			}
			return [
				'state'     => 'error',
				'viewModel' => self::empty_view_model($settings),
			];
		}

		/* ── Remote (built-in) source: cache + fetch ── */
		$ttl       = self::cache_ttl();
		$cache_key = self::cache_key($settings);
		$cached    = get_transient($cache_key);
		$fresh     = is_array($cached)
			&& isset($cached['savedAt'])
			&& (time() - (int) $cached['savedAt']) < $ttl;

		/* 1. Fresh transient → return immediately */
		if ($fresh && isset($cached['viewModel'])) {
			$cached_saved_at = max(0, (int) ($cached['savedAt'] ?? 0));
			$cached_api_ts = self::parse_view_model_timestamp((string) (($cached['viewModel']['timestamp'] ?? '')));
			CRToday_Plugin_Settings::record_builtin_source_fetch(
				(string) ($settings['sourceId'] ?? 'FEX'),
				(string) ($settings['deal'] ?? 'DEFAULT'),
				$cached_saved_at > 0 ? $cached_saved_at : time(),
				$cached_api_ts
			);

			return [
				'state'     => self::view_model_state($cached['viewModel']),
				'viewModel' => $cached['viewModel'],
			];
		}

		/* 2. Stale → attempt HTTP fetch to accumulate history */
		if (self::try_acquire_fetch_lock($cache_key)) {
			try {
				$fetched = self::fetch_remote_view_model($settings);
			} finally {
				self::release_fetch_lock($cache_key);
			}
			if ($fetched !== null) {
				set_transient(
					$cache_key,
					[
						'savedAt'   => time(),
						'viewModel' => $fetched,
					],
					$ttl
				);

				return [
					'state'     => self::view_model_state($fetched),
					'viewModel' => $fetched,
				];
			}
		}

		/* 3. HTTP failed or lock contention → fall back to DB */
		$db_view_model = self::fetch_db_view_model($settings);
		if ($db_view_model !== null) {
			set_transient(
				$cache_key,
				[
					'savedAt'   => time(),
					'viewModel' => $db_view_model,
				],
				$ttl
			);

			return [
				'state'     => self::view_model_state($db_view_model),
				'viewModel' => $db_view_model,
			];
		}

		/* 4. DB empty → stale transient as last resort */
		if (is_array($cached) && isset($cached['viewModel'])) {
			$cached_saved_at = max(0, (int) ($cached['savedAt'] ?? 0));
			$cached_api_ts = self::parse_view_model_timestamp((string) (($cached['viewModel']['timestamp'] ?? '')));
			CRToday_Plugin_Settings::record_builtin_source_fetch(
				(string) ($settings['sourceId'] ?? 'FEX'),
				(string) ($settings['deal'] ?? 'DEFAULT'),
				$cached_saved_at > 0 ? $cached_saved_at : time(),
				$cached_api_ts
			);

			return [
				'state'     => 'stale',
				'viewModel' => $cached['viewModel'],
			];
		}

		return [
			'state'     => 'error',
			'viewModel' => self::empty_view_model($settings),
		];
	}

	private static function fetch_remote_view_model(array $settings): ?array {
		$resolved = CRToday_Plugin_Settings::resolve_source(
			(string) ($settings['sourceId'] ?? 'FEX'),
			(string) ($settings['deal'] ?? 'DEFAULT'),
		);
		if ($resolved === null || ($resolved['type'] ?? '') !== 'builtin') {
			return null; /* Local sources are handled by resolve_local_view_model */
		}
		$endpoint = (string) $resolved['endpoint'];

		$endpoint = apply_filters(
			'crtoday_exchange_rates_endpoint',
			$endpoint,
			$settings
		);

		$response = wp_remote_get(
			$endpoint,
			[
				'timeout' => self::API_TIMEOUT,
			]
		);

		if (is_wp_error($response)) {
			return null;
		}

		$status = (int) wp_remote_retrieve_response_code($response);
		if ($status < 200 || $status >= 300) {
			return null;
		}

		$body    = wp_remote_retrieve_body($response);
		$payload = json_decode($body, true);
		if (! is_array($payload) || ! isset($payload['rates']) || ! is_array($payload['rates'])) {
			return null;
		}

		/* Use API timestamp when available, fall back to current time */
		$api_ts_unix = (isset($payload['ts']) && is_numeric($payload['ts'])) ? (int) $payload['ts'] : 0;
		$fetch_ts_unix = time();
		CRToday_Raw_Snapshot_Repository::save_remote_payload(
			(string) ($settings['sourceId'] ?? 'FEX'),
			(string) ($settings['deal'] ?? 'DEFAULT'),
			strtoupper((string) ($payload['base'] ?? 'USD')),
			$payload,
			$fetch_ts_unix,
			($api_ts_unix > 0 ? $api_ts_unix : $fetch_ts_unix),
			(int) ($settings['intervalMin'] ?? 15)
		);

		return self::build_view_model_from_payload($settings, $payload, $fetch_ts_unix, $api_ts_unix);
	}

	private static function fetch_db_view_model(array $settings): ?array {
		$interval_min = (int) ($settings['intervalMin'] ?? 15);
		if (! in_array($interval_min, [15, 30, 60], true)) {
			$interval_min = 15;
		}

		$snapshot = CRToday_Raw_Snapshot_Repository::get_latest_payload(
			(string) ($settings['sourceId'] ?? 'FEX'),
			(string) ($settings['deal'] ?? 'DEFAULT'),
			$interval_min
		);
		if (! is_array($snapshot)) {
			return null;
		}

		$payload = $snapshot['payload'] ?? null;
		if (! is_array($payload) || ! isset($payload['rates']) || ! is_array($payload['rates'])) {
			return null;
		}

		$fetch_ts_unix = max(1, (int) ($snapshot['fetch_ts'] ?? 0));
		$api_ts_unix = (int) ($snapshot['source_ts'] ?? 0);
		if ($api_ts_unix <= 0 && isset($payload['ts']) && is_numeric($payload['ts'])) {
			$api_ts_unix = (int) $payload['ts'];
		}

		return self::build_view_model_from_payload($settings, $payload, $fetch_ts_unix, $api_ts_unix);
	}

	private static function build_view_model_from_payload(array $settings, array $payload, int $fetch_ts_unix, int $api_ts_unix): ?array {
		$view_model = self::derive_view_model_from_payload($settings, $payload, $api_ts_unix, $fetch_ts_unix, true);
		if ($view_model === null) {
			return null;
		}

		return self::apply_change_from_reference($view_model, $settings);
	}

	private static function derive_view_model_from_payload(
		array $settings,
		array $payload,
		int $api_ts_unix,
		int $fetch_ts_unix,
		bool $record_fetch_metrics
	): ?array {
		$api_ts = $api_ts_unix > 0
			? gmdate('c', $api_ts_unix)
			: gmdate('c', $fetch_ts_unix);

		if ($record_fetch_metrics) {
			CRToday_Plugin_Settings::record_builtin_source_fetch(
				(string) ($settings['sourceId'] ?? 'FEX'),
				(string) ($settings['deal'] ?? 'DEFAULT'),
				max(1, $fetch_ts_unix),
				$api_ts_unix
			);
		}

		/* ── Cross-rate rebase preparation ── */
		$api_base     = strtoupper((string) ($payload['base'] ?? 'USD'));
		$target_base  = strtoupper($settings['base']);
		$needs_rebase = ($api_base !== $target_base);

		$base_mid = 1.0;
		$base_bid = 0.0;
		$base_ask = 0.0;

		if ($needs_rebase) {
			$base_entry = $payload['rates'][$target_base] ?? null;
			if ($base_entry === null) {
				return null; /* Cannot rebase without target base in API response */
			}

			if (is_array($base_entry)) {
				$base_mid = isset($base_entry['mid']) && is_numeric($base_entry['mid']) ? (float) $base_entry['mid'] : 0.0;
				$base_bid = isset($base_entry['bid']) && is_numeric($base_entry['bid']) ? (float) $base_entry['bid'] : 0.0;
				$base_ask = isset($base_entry['ask']) && is_numeric($base_entry['ask']) ? (float) $base_entry['ask'] : 0.0;

				if ($base_mid <= 0 && $base_bid > 0 && $base_ask > 0) {
					$base_mid = ($base_bid + $base_ask) / 2;
				}
			} elseif (is_numeric($base_entry)) {
				$base_mid = (float) $base_entry;
			} else {
				return null;
			}

			if ($base_mid <= 0) {
				return null;
			}
		}

		$view_model = [
			'base'        => $target_base,
			'timestamp'   => $api_ts,
			'source'      => $settings['sourceId'],
			'pricingMode' => 'mid',
			'rates'       => [],
		];

		$has_bidask      = false;

		foreach ($settings['symbols'] as $code) {
			$upper_code = strtoupper($code);

			/* Skip the base currency itself — it is always 1:1 */
			if ($upper_code === $target_base) {
				continue;
			}

			$mid = null;
			$bid = 0.0;
			$ask = 0.0;

			if ($needs_rebase && $upper_code === $api_base) {
				/*
				 * The symbol IS the API base (e.g., showing USD when base=UAH).
				 * API base is implicitly 1.0 — cross-rate is 1 / base_rate.
				 */
				$mid = 1.0 / $base_mid;
				$bid = ($base_ask > 0) ? 1.0 / $base_ask : 0.0;
				$ask = ($base_bid > 0) ? 1.0 / $base_bid : 0.0;
			} else {
				$entry = $payload['rates'][$code] ?? null;

				/*
				 * Accept two formats:
				 * 1. Nested object: {"ask": N, "bid": N, "mid": N}
				 * 2. Flat numeric:  N
				 */
				if (is_array($entry)) {
					$mid = isset($entry['mid']) && is_numeric($entry['mid']) ? (float) $entry['mid'] : null;
					$bid = isset($entry['bid']) && is_numeric($entry['bid']) ? (float) $entry['bid'] : 0.0;
					$ask = isset($entry['ask']) && is_numeric($entry['ask']) ? (float) $entry['ask'] : 0.0;

					if ($mid === null && $bid > 0 && $ask > 0) {
						$mid = ($bid + $ask) / 2;
					}
				} elseif (is_numeric($entry)) {
					$mid = (float) $entry;
				}

				if ($mid === null || $mid <= 0) {
					continue;
				}

				/* ── Apply cross-rate rebase ── */
				if ($needs_rebase) {
					$mid = $mid / $base_mid;
					$bid = ($bid > 0 && $base_ask > 0) ? $bid / $base_ask : 0.0;
					$ask = ($ask > 0 && $base_bid > 0) ? $ask / $base_bid : 0.0;
				}
			}

			if ($mid === null || $mid <= 0) {
				continue;
			}

			if ($bid > 0 || $ask > 0) {
				$has_bidask = true;
			}

			$view_model['rates'][] = [
				'code'         => $code,
				'mid'          => $mid,
				'bid'          => $bid,
				'ask'          => $ask,
				'changePct'    => null,
				'changePctBid' => null,
				'changePctAsk' => null,
			];
		}

		$view_model['pricingMode'] = $has_bidask ? 'bidask' : 'mid';

		return $view_model;
	}

	/**
	 * Build a view model from a local (manual) source.
	 * Same structure as the remote view model so renderers work identically.
	 */
	private static function resolve_local_view_model(array $settings): ?array {
		$resolved = CRToday_Plugin_Settings::resolve_source(
			(string) ($settings['sourceId'] ?? ''),
			'DEFAULT',
		);
		if ($resolved === null || $resolved['type'] !== 'local') {
			return null;
		}

		$local_rates  = $resolved['rates'] ?? [];
		$pricing_mode = $resolved['pricing_mode'] ?? 'mid';
		$source_base  = strtoupper((string) ($resolved['base'] ?? 'USD'));
		$target_base  = strtoupper((string) ($settings['base'] ?? $source_base));
		$updated_at   = (int) ($resolved['updated_at'] ?? 0);

		$api_ts = $updated_at > 0 ? gmdate('c', $updated_at) : gmdate('c');

		$view_model = [
			'base'       => $target_base,
			'timestamp'  => $api_ts,
			'source'     => $settings['sourceId'],
			'pricingMode'=> $pricing_mode,
			'rates'      => [],
		];

		/* ── Rebase if target_base differs from source_base ── */
		$needs_rebase = ($source_base !== $target_base);
		$base_mid = 1.0;
		$base_bid = 0.0;
		$base_ask = 0.0;

		if ($needs_rebase) {
			$base_entry = $local_rates[$target_base] ?? null;
			if ($base_entry === null) {
				return null; /* Cannot rebase without target currency */
			}
			$base_mid = (float) ($base_entry['mid'] ?? 0);
			$base_bid = (float) ($base_entry['bid'] ?? 0);
			$base_ask = (float) ($base_entry['ask'] ?? 0);
			if ($base_mid <= 0 && $base_bid > 0 && $base_ask > 0) {
				$base_mid = ($base_bid + $base_ask) / 2;
			}
			if ($base_mid <= 0) {
				return null;
			}
		}

		$has_bidask = false;
		$symbols = array_map('strtoupper', (array) ($settings['symbols'] ?? []));

		foreach ($symbols as $code) {
			if ($code === $target_base) {
				continue;
			}

			$entry = $local_rates[$code] ?? null;
			if ($entry === null) {
				continue;
			}

			$mid = (float) ($entry['mid'] ?? 0);
			$bid = (float) ($entry['bid'] ?? 0);
			$ask = (float) ($entry['ask'] ?? 0);

			if ($mid <= 0 && $bid > 0 && $ask > 0) {
				$mid = ($bid + $ask) / 2;
			}

			if ($needs_rebase && $base_mid > 0) {
				$mid = $mid / $base_mid;
				if ($bid > 0 && $base_ask > 0) {
					$bid = $bid / $base_ask;
				}
				if ($ask > 0 && $base_bid > 0) {
					$ask = $ask / $base_bid;
				}
			}

			if ($mid <= 0) {
				continue;
			}

			if ($bid > 0 || $ask > 0) {
				$has_bidask = true;
			}

			$view_model['rates'][] = [
				'code'         => $code,
				'mid'          => $mid,
				'bid'          => $bid,
				'ask'          => $ask,
				'changePct'    => null,
				'changePctBid' => null,
				'changePctAsk' => null,
			];
		}

		if ($has_bidask) {
			$view_model['pricingMode'] = 'bidask';
		}

		return self::apply_change_from_reference($view_model, $settings);
	}

	private static function apply_change_from_reference(array $view_model, array $settings): array {
		if (! isset($view_model['rates']) || ! is_array($view_model['rates']) || $view_model['rates'] === []) {
			return $view_model;
		}

		$is_builtin_source = (string) ($settings['sourceType'] ?? 'builtin') === 'builtin';
		if ($is_builtin_source) {
			return self::apply_change_from_reference_snapshot($view_model, $settings);
		}

		$view_model['rates'] = self::apply_change_to_rates($view_model['rates'], null);

		return $view_model;
	}

	private static function apply_change_from_reference_snapshot(array $view_model, array $settings): array {
		$source_id = (string) ($settings['sourceId'] ?? 'FEX');
		$deal = (string) ($settings['deal'] ?? 'DEFAULT');
		$interval_min = (int) ($settings['intervalMin'] ?? 15);
		if (! in_array($interval_min, [15, 30, 60], true)) {
			$interval_min = 15;
		}

		$current_ts = self::parse_view_model_timestamp((string) ($view_model['timestamp'] ?? ''));
		$reference_snapshot = CRToday_Raw_Snapshot_Repository::get_change_reference_payload(
			$source_id,
			$deal,
			$interval_min,
			$current_ts,
			self::CHANGE_WINDOW_SECONDS
		);

		if (! is_array($reference_snapshot)) {
			$view_model['rates'] = self::apply_change_to_rates($view_model['rates'], null);
			return $view_model;
		}

		$payload = $reference_snapshot['payload'] ?? null;
		if (! is_array($payload) || ! isset($payload['rates']) || ! is_array($payload['rates'])) {
			$view_model['rates'] = self::apply_change_to_rates($view_model['rates'], null);
			return $view_model;
		}

		$reference_api_ts = (int) ($reference_snapshot['source_ts'] ?? 0);
		$reference_fetch_ts = max(1, (int) ($reference_snapshot['fetch_ts'] ?? 0));
		$reference_view_model = self::derive_view_model_from_payload(
			$settings,
			$payload,
			$reference_api_ts,
			$reference_fetch_ts,
			false
		);

		if (! is_array($reference_view_model)) {
			$view_model['rates'] = self::apply_change_to_rates($view_model['rates'], null);
			return $view_model;
		}

		$reference_point = self::build_reference_change_point($reference_view_model);
		$view_model['rates'] = self::apply_change_to_rates($view_model['rates'], $reference_point);

		return $view_model;
	}

	/**
	 * @deprecated Use parse_iso_timestamp() from CRToday_Rate_Helpers trait instead.
	 */
	private static function parse_view_model_timestamp(string $timestamp): int {
		return self::parse_iso_timestamp($timestamp, time());
	}

	private static function build_reference_change_point(array $view_model): array {
		$rates_index = [];
		$hash_items  = [];

		foreach ((array) ($view_model['rates'] ?? []) as $rate) {
			if (! is_array($rate)) {
				continue;
			}

			$code = strtoupper((string) ($rate['code'] ?? ''));
			if ($code === '') {
				continue;
			}

			$mid = self::normalize_positive_float($rate['mid'] ?? null);
			$bid = self::normalize_positive_float($rate['bid'] ?? null);
			$ask = self::normalize_positive_float($rate['ask'] ?? null);

			$rates_index[$code] = [
				'mid' => $mid,
				'bid' => $bid,
				'ask' => $ask,
			];

			$hash_items[] = $code . ':'
				. ($mid !== null ? (string) $mid : '-') . ':'
				. ($bid !== null ? (string) $bid : '-') . ':'
				. ($ask !== null ? (string) $ask : '-');
		}

		sort($hash_items);

		$ts = self::parse_view_model_timestamp((string) ($view_model['timestamp'] ?? ''));

		return [
			'ts'    => $ts,
			'rates' => $rates_index,
			'hash'  => hash('sha256', implode('|', $hash_items)),
		];
	}

	private static function apply_change_to_rates(array $rates, ?array $reference_point): array {
		$reference_rates = [];
		if ($reference_point !== null && isset($reference_point['rates']) && is_array($reference_point['rates'])) {
			$reference_rates = $reference_point['rates'];
		}

		$has_reference = $reference_rates !== [];

		foreach ($rates as $idx => $rate) {
			if (! is_array($rate)) {
				continue;
			}

			$code = strtoupper((string) ($rate['code'] ?? ''));
			$ref = isset($reference_rates[$code]) && is_array($reference_rates[$code])
				? $reference_rates[$code]
				: [];

			$current_mid = self::normalize_positive_float($rate['mid'] ?? null);
			$current_bid = self::normalize_positive_float($rate['bid'] ?? null);
			$current_ask = self::normalize_positive_float($rate['ask'] ?? null);

			$ref_mid = self::normalize_positive_float($ref['mid'] ?? null);
			$ref_bid = self::normalize_positive_float($ref['bid'] ?? null);
			$ref_ask = self::normalize_positive_float($ref['ask'] ?? null);

			$rates[$idx]['changePct'] = self::calculate_change_percent($current_mid, $ref_mid, $has_reference);
			$rates[$idx]['changePctBid'] = self::calculate_change_percent($current_bid, $ref_bid, $has_reference);
			$rates[$idx]['changePctAsk'] = self::calculate_change_percent($current_ask, $ref_ask, $has_reference);
		}

		return $rates;
	}

	private static function normalize_positive_float($value): ?float {
		if (! is_numeric($value)) {
			return null;
		}

		$float = (float) $value;
		if (! is_finite($float) || $float <= 0) {
			return null;
		}

		return $float;
	}

	private static function calculate_change_percent(?float $current, ?float $reference, bool $has_reference): ?float {
		if (! $has_reference) {
			return null;
		}

		if ($current === null || $reference === null || $reference <= 0) {
			return null;
		}

		$change = (($current - $reference) / $reference) * 100;
		if (! is_finite($change)) {
			return null;
		}

		/* Changes below 0.01 % are indistinguishable from zero after rounding. */
		if (abs($change) < 0.01) {
			return 0.0;
		}

		return $change;
	}

	/**
	 * Build a trend SVG arrow span for a given change value.
	 */
	private static function build_trend_svg(?float $change): string {
		/* Treat sub-0.01 % as neutral — no directional arrow, no colour. */
		$is_up   = $change !== null && $change >= 0.01;
		$is_down = $change !== null && $change <= -0.01;

		$trend_class = 'crtoday-trend';
		if ($is_up) {
			$trend_class .= ' crtoday-trend-up';
		} elseif ($is_down) {
			$trend_class .= ' crtoday-trend-down';
		} else {
			$trend_class .= ' crtoday-trend-neutral';
		}

		if ($is_up) {
			$svg = '<svg class="crtoday-trend-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z"/></svg>';
		} elseif ($is_down) {
			$svg = '<svg class="crtoday-trend-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z"/></svg>';
		} else {
			$svg = '<svg class="crtoday-trend-icon crtoday-trend-icon-neutral" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="12" r="3"/></svg>';
		}

		return sprintf('<span class="%s">%s</span>', esc_attr($trend_class), $svg);
	}

	/**
	 * Build a single delta pill span for a given change value.
	 */
	private static function build_delta_pill(?float $change, int $decimals): string {
		/* Treat sub-0.01 % as neutral — no colour highlight. */
		$is_up   = $change !== null && $change >= 0.01;
		$is_down = $change !== null && $change <= -0.01;

		$delta_class = 'crtoday-delta';
		if ($is_up) {
			$delta_class .= ' crtoday-delta-up';
		} elseif ($is_down) {
			$delta_class .= ' crtoday-delta-down';
		} else {
			$delta_class .= ' crtoday-delta-neutral';
		}

		return sprintf(
			'<span class="%1$s">%2$s</span>',
			esc_attr($delta_class),
			esc_html(self::format_change($change, min($decimals, 2)))
		);
	}

	private static function empty_view_model(array $settings): array {
		return [
			'base'       => $settings['base'],
			'timestamp'  => gmdate('c'),
			'source'     => $settings['sourceId'],
			'pricingMode'=> 'mid',
			'rates'      => [],
		];
	}

	private static function view_model_state(array $view_model): string {
		if (! isset($view_model['rates']) || ! is_array($view_model['rates']) || $view_model['rates'] === []) {
			return 'empty';
		}

		return 'ready';
	}

	private static function build_row(array $rate, array $settings, string $pricing_mode): string {
		$code       = (string) ($rate['code'] ?? '');
		$mid_raw    = $rate['mid'] ?? null;
		$bid_raw    = (float) ($rate['bid'] ?? 0);
		$ask_raw    = (float) ($rate['ask'] ?? 0);
		$change_flt     = self::normalize_to_float($rate['changePct'] ?? null);
		$change_bid_flt = self::normalize_to_float($rate['changePctBid'] ?? null);
		$change_ask_flt = self::normalize_to_float($rate['changePctAsk'] ?? null);
		$flag       = CRToday_Plugin_Settings::get_currency_flag($code);

		$currency_label = self::format_currency_label(
			$code,
			$settings['currencyLabelMode'],
			(bool) ($settings['showCurrencySymbols'] ?? false)
		);

		/* --- Flag inside circular wrap --- */
		$flag_inner = '';
		if ($flag !== null) {
			$flag_url = CRTODAY_PLUGIN_URL . 'assets/flags/webp/128/' . $flag . '.webp';
			$flag_alt = sprintf(
				/* translators: %s: currency code */
				__('Flag for %s', 'currencyrate-today-currency-blocks'),
				$code
			);
			$flag_inner = sprintf(
				'<img class="crtoday-flag" src="%1$s" alt="%2$s" loading="lazy" decoding="async" width="32" height="32" />',
				esc_url($flag_url),
				esc_attr($flag_alt)
			);
		}

		/* --- Convert to decimal strings --- */
		$mid_str = self::normalize_to_string($mid_raw);
		$bid_str = ($bid_raw > 0) ? (string) $bid_raw : null;
		$ask_str = ($ask_raw > 0) ? (string) $ask_raw : null;

		/* --- Inversion: 1/rate, swaps bid↔ask --- */
		if ($settings['invertRates']) {
			if ($mid_str !== null) {
				$mid_str = self::bd_invert($mid_str, $settings['decimals']);
			}
			$orig_bid = $bid_str;
			$orig_ask = $ask_str;
			$bid_str = ($orig_ask !== null) ? self::bd_invert($orig_ask, $settings['decimals']) : null;
			$ask_str = ($orig_bid !== null) ? self::bd_invert($orig_bid, $settings['decimals']) : null;
		}

		/* --- Amount multiplication --- */
		if (self::bd_compare($settings['amount'], '1') !== 0) {
			if ($mid_str !== null) {
				$mid_str = self::bd_multiply($mid_str, $settings['amount'], $settings['decimals']);
			}
			if ($bid_str !== null) {
				$bid_str = self::bd_multiply($bid_str, $settings['amount'], $settings['decimals']);
			}
			if ($ask_str !== null) {
				$ask_str = self::bd_multiply($ask_str, $settings['amount'], $settings['decimals']);
			}
		}

		/* --- Percentage adjustment --- */
		if (self::has_adjustment((float) ($settings['adjustmentPercent'] ?? 0))) {
			$factor = self::adjustment_factor_string((float) $settings['adjustmentPercent']);
			if ($mid_str !== null) {
				$mid_str = self::bd_multiply($mid_str, $factor, $settings['decimals']);
			}
			if ($bid_str !== null) {
				$bid_str = self::bd_multiply($bid_str, $factor, $settings['decimals']);
			}
			if ($ask_str !== null) {
				$ask_str = self::bd_multiply($ask_str, $factor, $settings['decimals']);
			}
		}

		/* --- Trend SVG arrow --- */
		$trend_html = self::build_trend_svg($change_flt);

		/* --- Value columns based on pricing mode --- */
		$bid_raw_text = null;
		$ask_raw_text = null;
		$result_raw_text = null;

		if ($pricing_mode === 'bidask') {
			$bid_text = ($bid_str !== null) ? self::format_decimal($bid_str, $settings['decimals']) : '—';
			$ask_text = ($ask_str !== null) ? self::format_decimal($ask_str, $settings['decimals']) : '—';
			$bid_raw_text = self::format_decimal_raw($bid_str, $settings['decimals']);
			$ask_raw_text = self::format_decimal_raw($ask_str, $settings['decimals']);

			/* Both zero in bidask mode → fall back to mid for both columns */
			if ($bid_str === null && $ask_str === null && $mid_str !== null) {
				$mid_text = self::format_decimal($mid_str, $settings['decimals']);
				$mid_raw_text = self::format_decimal_raw($mid_str, $settings['decimals']);
				$bid_text = $mid_text;
				$ask_text = $mid_text;
				$bid_raw_text = $mid_raw_text;
				$ask_raw_text = $mid_raw_text;
			}

			$result_raw_text = $bid_raw_text ?? self::format_decimal_raw($mid_str, $settings['decimals']) ?? $ask_raw_text;

			/* Separate trend arrows for bid and ask */
			$trend_bid_html = self::build_trend_svg($change_bid_flt);
			$trend_ask_html = self::build_trend_svg($change_ask_flt);

			$values_html = sprintf(
				'<span class="crtoday-bid"><span class="crtoday-rate-value"%1$s>%2$s</span>%3$s</span><span class="crtoday-ask"><span class="crtoday-rate-value"%4$s>%5$s</span>%6$s</span>',
				$bid_raw_text !== null ? sprintf(' data-crtoday-raw="%s"', esc_attr($bid_raw_text)) : '',
				esc_html($bid_text),
				$trend_bid_html,
				$ask_raw_text !== null ? sprintf(' data-crtoday-raw="%s"', esc_attr($ask_raw_text)) : '',
				esc_html($ask_text),
				$trend_ask_html
			);
		} else {
			$value_text = self::format_decimal($mid_str, $settings['decimals']);
			$value_raw_text = self::format_decimal_raw($mid_str, $settings['decimals']);
			$result_raw_text = $value_raw_text;
			$values_html = sprintf(
				'<span class="crtoday-mid"><span class="crtoday-rate-value"%1$s>%2$s</span>%3$s</span>',
				$value_raw_text !== null ? sprintf(' data-crtoday-raw="%s"', esc_attr($value_raw_text)) : '',
				esc_html($value_text),
				$trend_html
			);
		}

		/* --- Delta pill with directional class --- */
		$delta_html = '';
		if ($settings['showChange']) {
			if ($pricing_mode === 'bidask') {
				/* Separate delta pills for bid and ask, stacked vertically */
				$delta_bid_html = self::build_delta_pill($change_bid_flt, $settings['decimals']);
				$delta_ask_html = self::build_delta_pill($change_ask_flt, $settings['decimals']);
				$delta_html = sprintf(
					'<div class="crtoday-delta-pair">%s%s</div>',
					$delta_bid_html,
					$delta_ask_html
				);
			} else {
				$delta_html = self::build_delta_pill($change_flt, $settings['decimals']);
			}
		}

		$delta_cell_html = $settings['showChange']
			? ($delta_html !== ''
				? $delta_html
				: '<span class="crtoday-row-spacer crtoday-row-spacer-delta" aria-hidden="true"></span>')
			: '';

		$cta_html = self::build_row_cta($settings, $code, $result_raw_text, $bid_raw_text, $ask_raw_text);

		return sprintf(
			'<div class="crtoday-row" data-crtoday-currency="%1$s">'
			. '<div class="crtoday-pair"><span class="crtoday-flag-wrap">%2$s</span><span class="crtoday-symbol">%3$s</span></div>'
			. '%4$s'
			. '%5$s%6$s'
			. '</div>',
			esc_attr($code),
			$flag_inner,
			esc_html($currency_label),
			$values_html,
			$delta_cell_html,
			$cta_html
		);
	}

	private static function is_row_cta_requested(array $settings): bool {
		return ! empty($settings['showCtaButton']);
	}

	private static function build_row_cta(
		array $settings,
		string $to_currency,
		?string $result_raw,
		?string $bid_raw,
		?string $ask_raw
	): string {
		if (! self::is_row_cta_requested($settings)) {
			return '';
		}

		$template = (string) ($settings['ctaUrlTemplate'] ?? '');
		$amount   = (string) ($settings['amount'] ?? '1');
		$from     = (string) ($settings['base'] ?? 'USD');
		$text     = trim((string) ($settings['ctaButtonText'] ?? ''));
		if ($text === '') {
			$text = __('Convert', 'currencyrate-today-currency-blocks');
		}

		$vars = [
			'amount' => $amount,
			'from'   => $from,
			'to'     => $to_currency,
			'result' => $result_raw ?? '',
			'bid'    => $bid_raw ?? '',
			'ask'    => $ask_raw ?? '',
		];

		$resolved_href = '';
		if (trim($template) !== '' && $result_raw !== null) {
			$href = preg_replace_callback(
				'/\{(amount|from|to|result|bid|ask)\}/',
				static function (array $matches) use ($vars): string {
					$key = $matches[1] ?? '';
					return rawurlencode($vars[$key] ?? '');
				},
				$template
			);

			if (is_string($href) && $href !== '') {
				$resolved_href = esc_url($href);
			}
		}

		if ($resolved_href === '') {
			return sprintf(
				'<a class="crtoday-row-cta crtoday-row-cta-disabled" href="#" aria-disabled="true" tabindex="-1">%1$s</a>',
				esc_html($text)
			);
		}

		return sprintf(
			'<a class="crtoday-row-cta" href="%1$s">%2$s</a>',
			$resolved_href,
			esc_html($text)
		);
	}

	private static function cache_key(array $settings): string {
		$payload = [
			'sourceId'            => $settings['sourceId'],
			'deal'                => $settings['deal'] ?? 'DEFAULT',
			'base'                => $settings['base'],
			'symbols'             => $settings['symbols'],
			'valueModePreference' => $settings['valueModePreference'],
		];

		return 'crtoday_rates_' . md5(wp_json_encode($payload));
	}

	private static function normalize_to_string($value): ?string {
		if (! is_numeric($value)) {
			return null;
		}

		$str = (string) $value;
		if (CRToday_BigDecimal::of($str)->isLessThanOrEqualTo(CRToday_BigDecimal::zero())) {
			return null;
		}

		return $str;
	}

	private static function normalize_to_float($value): ?float {
		if (! is_numeric($value)) {
			return null;
		}

		$float = (float) $value;
		if (! is_finite($float)) {
			return null;
		}

		return $float;
	}

	/* ---------- CRToday_BigDecimal helpers ---------- */

	/**
	 * Map config string → CRToday_RoundingMode constant.
	 */
	private static function resolve_rounding_mode(): int {
		$config = CRToday_Plugin_Settings::get_config();
		$mode   = $config['rounding_mode'] ?? 'half_even';

		switch ($mode) {
			case 'half_even':
				return CRToday_RoundingMode::HALF_EVEN;
			case 'down':
				return CRToday_RoundingMode::DOWN;
			default:
				return CRToday_RoundingMode::HALF_UP;
		}
	}

	private static function internal_scale(int $decimals): int {
		return max($decimals + 4, 12);
	}

	private static function bd_invert(string $value, int $decimals): ?string {
		$bd = CRToday_BigDecimal::of($value);
		if ($bd->isLessThanOrEqualTo(CRToday_BigDecimal::zero())) {
			return null;
		}

		return (string) CRToday_BigDecimal::one()
			->dividedBy($bd, self::internal_scale($decimals), CRToday_RoundingMode::HALF_UP);
	}

	private static function bd_multiply(string $a, string $b, int $decimals): string {
		return (string) CRToday_BigDecimal::of($a)
			->multipliedBy(CRToday_BigDecimal::of($b))
			->toScale(self::internal_scale($decimals), CRToday_RoundingMode::HALF_UP);
	}

	private static function bd_compare(string $a, string $b): int {
		return CRToday_BigDecimal::of($a)->compareTo(CRToday_BigDecimal::of($b));
	}

	private static function format_amount_for_base_label(string $amount): string {
		if (strpos($amount, '.') === false) {
			return $amount;
		}

		$normalized = rtrim($amount, '0');
		$normalized = rtrim($normalized, '.');

		return $normalized === '' ? '0' : $normalized;
	}

	private static function sanitize_amount(string $raw): string {
		$cleaned = preg_replace('/[^0-9.]/', '', $raw) ?? '';

		if ($cleaned === '' || CRToday_BigDecimal::of($cleaned === '' ? '0' : $cleaned)->isLessThanOrEqualTo(CRToday_BigDecimal::zero())) {
			return '1';
		}

		return $cleaned;
	}

	private static function adjustment_factor_string(float $percent): string {
		$factor = 1 + ($percent / 100);
		$formatted = rtrim(rtrim(sprintf('%.8F', $factor), '0'), '.');
		return $formatted === '' ? '1' : $formatted;
	}

	private static function format_decimal(?string $value, int $decimals): string {
		if ($value === null) {
			return '—';
		}

		/* Round to display precision using configured rounding mode (CRToday_BigDecimal) */
		$rm      = self::resolve_rounding_mode();
		$rounded = (string) CRToday_BigDecimal::of($value)->toScale($decimals, $rm);

		/* Use number_format_i18n for locale-aware separators (single allowed float cast) */
		return number_format_i18n((float) $rounded, $decimals);
	}

	private static function format_decimal_raw(?string $value, int $decimals): ?string {
		if ($value === null) {
			return null;
		}

		$rm = self::resolve_rounding_mode();
		return (string) CRToday_BigDecimal::of($value)->toScale($decimals, $rm);
	}

	private static function format_change(?float $value, int $decimals): string {
		if ($value === null) {
			return '—';
		}

		/* Sub-0.01 % → display as unsigned zero. */
		if (abs($value) < 0.01) {
			return number_format_i18n(0.0, $decimals) . '%';
		}

		$sign = $value > 0 ? '+' : '';

		return $sign . number_format_i18n($value, $decimals) . '%';
	}

	private static function t(string $key): string {
		$dictionary = [
			'state.ready' => __('Up to date', 'currencyrate-today-currency-blocks'),
			'state.stale' => __('Showing last available snapshot', 'currencyrate-today-currency-blocks'),
			'state.empty' => __('No rates available', 'currencyrate-today-currency-blocks'),
			'state.error' => __('Exchange rates are temporarily unavailable', 'currencyrate-today-currency-blocks'),
		];

		return $dictionary[$key] ?? $key;
	}

	private static function currency_name(string $code): string {
		return CRToday_Plugin_Settings::get_currency_name($code);
	}

	private static function format_currency_label(string $code, string $label_mode, bool $show_symbols): string {
		$label = $label_mode === 'name'
			? self::currency_name($code)
			: $code;

		if (! $show_symbols) {
			return $label;
		}

		$symbol = CRToday_Plugin_Settings::get_currency_symbol($code);
		if ($symbol === null || $symbol === '') {
			return $label;
		}

		return $symbol . ' ' . $label;
	}

}
