<?php
/**
 * Currency Converter block — SSR renderer + shortcode handler.
 *
 * Gutenberg block: crtoday/currency-converter
 * Shortcode:       [crtoday_currency_converter]
 * Legacy shortcode:[crtoday_converter]
 *
 * Contract: docs/contracts/converter-contract.md
 *
 * @package CRTodayCurrencyBlocks
 */

declare(strict_types=1);

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

final class CRToday_Converter_Block {

	use CRToday_Color_Helpers;
	use CRToday_Fetch_Lock;
	use CRToday_Rate_Helpers;

	private const HANDLE_FRONTEND = 'crtoday-currency-converter-frontend';
	private const SHORTCODE = 'crtoday_currency_converter';
	private const LEGACY_SHORTCODE = 'crtoday_converter';
	private const AJAX_PREVIEW_ACTION = 'crtoday_currency_converter_shortcode_preview';
	private const LEGACY_AJAX_PREVIEW_ACTION = 'crtoday_converter_shortcode_preview';
	private const API_TIMEOUT     = 8;
	private const FETCH_LOCK_TTL_SECONDS = 20;

	private const DEFAULT_ATTRIBUTES = [
		'sourceId'       => 'FEX',
		'deal'           => 'DEFAULT',
		'fromCurrency'   => 'USD',
		'toCurrency'     => 'EUR',
		'intervalMin'    => 15,
		'amount'         => '1',
		'decimals'       => 2,
		'theme'          => 'light',
		'shadow'         => true,
		'seamless'       => false,
		'radius'         => 16,
		'showFlags'      => true,
		'attribution'    => true,
		'compactMode'    => true,
		'fitWidth'       => true,
		'sourceLabel'    => '',
		'showTimestamp'   => true,
		'adjustmentPercent' => 0,
		'showCtaButton'  => false,
		'ctaButtonText'  => 'Convert',
		'ctaUrlTemplate' => '',
		'enableStructuredData' => false,
		'accentTheme'    => 'default',
		'accentAngle'    => 135,
		'accentMode'     => 'stripe',
	];

	/* ================================================================
	 * Registration
	 * ================================================================ */

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

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

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

	/* ================================================================
	 * Shortcode handler
	 * ================================================================ */

	/**
	 * [crtoday_currency_converter source="FEX:DEFAULT" from="USD" to="EUR" decimals="2" …]
	 *
	 * @param array<string,string>|string $atts
	 */
	public static function render_shortcode($atts): string {
		$atts = shortcode_atts(
			array_merge(self::DEFAULT_ATTRIBUTES, ['source' => '']),
			is_array($atts) ? $atts : [],
			self::SHORTCODE
		);

		/* Parse compound source → 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']);

		/* Shortcode aliases */
		if (isset($atts['from'])) {
			$atts['fromCurrency'] = strtoupper((string) $atts['from']);
		}
		if (isset($atts['to'])) {
			$atts['toCurrency'] = strtoupper((string) $atts['to']);
		}

		/* Cast types */
		foreach (['intervalMin', 'decimals', 'radius'] as $num_key) {
			$atts[$num_key] = (int) $atts[$num_key];
		}
		if (isset($atts['adjustmentPercent'])) {
			$atts['adjustmentPercent'] = (float) $atts['adjustmentPercent'];
		}

		/* Underscore → camelCase aliases for shortcode */
		$alias_map = [
			'show_flags'     => 'showFlags',
			'show_timestamp' => 'showTimestamp',
			'compact_mode'   => 'compactMode',
			'fit_width'      => 'fitWidth',
			'source_label'   => 'sourceLabel',
			'adjustment_percent' => 'adjustmentPercent',
			'adjustment'     => 'adjustmentPercent',
			'show_cta_button'=> 'showCtaButton',
			'cta_button_text'=> 'ctaButtonText',
			'cta_text'       => 'ctaButtonText',
			'cta_url_template'=> 'ctaUrlTemplate',
			'cta_url'        => 'ctaUrlTemplate',
			'enable_structured_data' => 'enableStructuredData',
			'attribution'    => 'attribution',
			'accent_mode'    => 'accentMode',
			'accent_theme'   => 'accentTheme',
			'accent_angle'   => 'accentAngle',
		];
		foreach ($alias_map as $snake => $camel) {
			if (isset($atts[$snake])) {
				$atts[$camel] = $atts[$snake];
				unset($atts[$snake]);
			}
		}
		foreach (['shadow', 'seamless', 'showFlags', 'showTimestamp', 'showCtaButton', 'compactMode', 'fitWidth', 'enableStructuredData', 'attribution'] as $bool_key) {
			if (isset($atts[$bool_key])) {
				$atts[$bool_key] = filter_var($atts[$bool_key], FILTER_VALIDATE_BOOLEAN);
			}
		}

		/* Ensure style is enqueued */
		if (wp_style_is('crtoday-block-style', 'registered')) {
			wp_enqueue_style('crtoday-block-style');
		}

		return self::render($atts);
	}

	/* ================================================================
	 * Render (shared by Gutenberg + shortcode)
	 * ================================================================ */

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

		/*
		 * Support both current and legacy nonce actions.
		 * JS sends nonce for AJAX_PREVIEW_ACTION; legacy action hook
		 * kept for backward compatibility of the AJAX endpoint URL.
		 */
		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']);
		}

		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 (['shadow', 'seamless', 'showFlags', 'showTimestamp', 'showCtaButton', 'compactMode', '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]);
	}

	public static function render(array $attributes): string {
		$s         = self::sanitize_attributes($attributes);
		$snapshot  = self::resolve_snapshot($s['sourceId'], $s['deal']);
		$state     = $snapshot['state'];
		$vm        = $snapshot['viewModel'];

		/* Build a code-keyed rate map for embedded JSON + initial calc */
		$rate_map = [];
		foreach ($vm['rates'] as $rate) {
			$code = (string) ($rate['code'] ?? '');
			if ($code === '') {
				continue;
			}
			$rate_map[$code] = [
				'mid' => (float) ($rate['mid'] ?? 0),
				'bid' => (float) ($rate['bid'] ?? 0),
				'ask' => (float) ($rate['ask'] ?? 0),
			];
		}

		/* Compute initial conversion (configured amount of FROM → TO) */
		$from = $s['fromCurrency'];
		$to   = $s['toCurrency'];
		$base = (string) ($vm['base'] ?? 'USD');
		$amount_value = (float) $s['amount'];
		$pricing = self::resolve_pair_pricing($from, $to, $base, $rate_map);
		$initial_result = self::convert($amount_value, $from, $to, $base, $rate_map, $pricing);
		if ($initial_result !== null) {
			$initial_result = self::apply_adjustment($initial_result, (float) $s['adjustmentPercent']);
		}

		/* Source label (shared) */
		$source_info = CRToday_Widget_Template::resolve_source_label(
			$s['sourceId'],
			$s['deal'],
			$s['sourceLabel']
		);

		/* ── Build HTML ── */

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

		$root_classes = implode(' ', array_filter([
			'crtoday-widget',
			'crtoday-converter',
			'crtoday-theme-' . $s['theme'],
			$s['shadow'] ? 'crtoday-has-shadow' : 'crtoday-no-shadow',
			$s['seamless'] ? 'crtoday-seamless' : '',
			'crtoday-state-' . $state,
			$s['compactMode'] ? 'crtoday-compact' : '',
			$s['fitWidth'] ? 'crtoday-fit-width' : '',
			$s['showFlags'] ? '' : 'crtoday-no-flags',
			'crtoday-accent-' . $s['accentMode'],
			'crtoday-pricing-' . $pricing,
		]));

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

		/* Embedded JSON for client JS */
		$json_data = wp_json_encode([
			'base'        => $base,
			'pricingMode' => $pricing,
			'amount'      => $s['amount'],
			'adjustmentPercent' => (float) $s['adjustmentPercent'],
			'labels'      => [
				'mid' => [
					'from' => __('From', 'currencyrate-today-currency-blocks'),
					'to'   => __('To', 'currencyrate-today-currency-blocks'),
				],
				'bidask' => [
					'from' => __('You give', 'currencyrate-today-currency-blocks'),
					'to'   => __('You receive', 'currencyrate-today-currency-blocks'),
				],
			],
			'sourceLabel' => $source_info['label'],
			'timestamp'   => $vm['timestamp'] ?? '',
			'rates'       => (object) $rate_map,
		], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG);

		/* Flag helpers */
		$from_flag = self::build_flag($from, 24);
		$to_flag   = self::build_flag($to, 24);

		/* Unique IDs for a11y (label → input association) */
		$uid       = wp_unique_id('crtoday-conv-');
		$from_id   = $uid . 'from';
		$to_id     = $uid . 'to';

		$from_label_text = $pricing === 'bidask'
			? __('You give', 'currencyrate-today-currency-blocks')
			: __('From', 'currencyrate-today-currency-blocks');
		$to_label_text = $pricing === 'bidask'
			? __('You receive', 'currencyrate-today-currency-blocks')
			: __('To', 'currencyrate-today-currency-blocks');

		$from_label = sprintf(
			'<label class="crtoday-conv-label" for="%s">%s</label>',
			esc_attr($from_id),
			esc_html($from_label_text)
		);
		$to_label = sprintf(
			'<label class="crtoday-conv-label" for="%s">%s</label>',
			esc_attr($to_id),
			esc_html($to_label_text)
		);

		/* Formatted initial result */
		$result_str = $initial_result !== null
			? number_format($initial_result, $s['decimals'], '.', '')
			: '—';

		/* Shared chrome */
		$title_html        = sprintf(
			'<span class="crtoday-base-label">%s</span>',
			esc_html(__('Currency Converter', 'currencyrate-today-currency-blocks'))
		);
		$header_html       = CRToday_Widget_Template::build_header($title_html, $source_info['label'], $source_info['verified']);
		$footer_html       = CRToday_Widget_Template::build_footer($s['showTimestamp'], (string) ($vm['timestamp'] ?? ''), (bool) ($s['attribution'] ?? false));
		$disclaimer_html   = CRToday_Widget_Template::build_disclaimer_dialog((bool) ($s['attribution'] ?? false));
		$disclaimer_script = CRToday_Widget_Template::build_disclaimer_script();
		$structured_data_html = '';
		$adjustment_html   = '';
		$cta_html          = '';

		if (! empty($s['enableStructuredData']) && $initial_result !== null) {
			$structured_data_html = CRToday_Widget_Template::build_exchange_rate_json_ld([
				[
					'base' => $from,
					'quote' => $to,
					'rateType' => $pricing === 'mid' ? 'mid' : 'effective',
					'price' => (float) $result_str,
					'validFrom' => (string) ($vm['timestamp'] ?? ''),
					'referenceValue' => $amount_value,
				],
			]);
		}

		if (self::has_adjustment((float) $s['adjustmentPercent'])) {
			$adjustment_html = sprintf(
				'<div class="crtoday-conv-adjustment-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) $s['adjustmentPercent'])
				))
			);
		}

		if ($s['showCtaButton']) {
			$cta_html = sprintf(
				'<div class="crtoday-conv-cta-wrap"><a class="crtoday-conv-cta crtoday-conv-cta-disabled" href="#" aria-disabled="true" data-crtoday-cta-template="%1$s">%2$s</a></div>',
				esc_attr($s['ctaUrlTemplate']),
				esc_html($s['ctaButtonText'] !== '' ? $s['ctaButtonText'] : __('Convert', 'currencyrate-today-currency-blocks'))
			);
		}

		/* Swap SVG */
		$swap_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 16V4m0 0L3 8m4-4l4 4"/><path d="M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>';

		/* Chevron SVG */
		$chevron_svg = '<svg class="crtoday-conv-chevron" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';

		/* Enqueue frontend script */
		wp_enqueue_script(self::HANDLE_FRONTEND);

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

		$html = sprintf(
			'<div class="%1$s" data-crtoday-version="1" data-crtoday-state="%2$s" data-crtoday-from="%3$s" data-crtoday-to="%4$s" data-crtoday-decimals="%5$s" data-crtoday-pricing="%6$s" data-crtoday-locale="%30$s" style="%7$s">'
			. '<script type="application/json" class="crtoday-conv-data">%8$s</script>'
			. '<div class="crtoday-card">'
			.   '<div class="crtoday-accent"></div>'
			.   '%9$s'
			.   '<div class="crtoday-rates crtoday-conv-rates">'
			/* FROM row */
			.   '<div class="crtoday-conv-row crtoday-conv-from">'
			.     '%10$s'
			.     '<div class="crtoday-conv-field">'
			.       '<input type="text" id="%11$s" name="%11$s" class="crtoday-conv-input" inputmode="decimal" size="1" value="%31$s" aria-label="%12$s" />'
			.       '<button type="button" class="crtoday-conv-currency" aria-haspopup="listbox" aria-expanded="false">'
			.         '%13$s'
			.         '<span class="crtoday-conv-code">%14$s</span>'
			.         '%15$s'
			.       '</button>'
			.     '</div>'
			.   '</div>'
			/* Swap */
			.   '<div class="crtoday-conv-swap-wrap">'
			.     '<button type="button" class="crtoday-conv-swap" aria-label="%16$s">%17$s</button>'
			.   '</div>'
			/* TO row */
			.   '<div class="crtoday-conv-row crtoday-conv-to">'
			.     '%18$s'
			.     '<div class="crtoday-conv-field">'
			.       '<input type="text" id="%19$s" name="%19$s" class="crtoday-conv-input" inputmode="decimal" size="1" value="%20$s" aria-label="%21$s" />'
			.       '<button type="button" class="crtoday-conv-currency" aria-haspopup="listbox" aria-expanded="false">'
			.         '%22$s'
			.         '<span class="crtoday-conv-code">%23$s</span>'
			.         '%24$s'
			.       '</button>'
			.     '</div>'
			.   '</div>'
			.   '%25$s'
			.   '%26$s'
			.   '</div>'
			/* Shared footer + disclaimer */
			.   '%27$s%28$s'
			. '</div>'
			. '</div>'
			. '%29$s',
			/* 1  */ esc_attr($root_classes),
			/* 2  */ esc_attr($state),
			/* 3  */ esc_attr($from),
			/* 4  */ esc_attr($to),
			/* 5  */ esc_attr((string) $s['decimals']),
			/* 6  */ esc_attr($pricing),
			/* 7  */ esc_attr($inline_style),
			/* 8  */ $json_data,
			/* 9  */ $header_html,
			/* 10 */ $from_label,
			/* 11 */ esc_attr($from_id),
			/* translators: %s: currency code for amount input aria-label. */
			esc_attr(sprintf(__('Amount in %s', 'currencyrate-today-currency-blocks'), $from)),
			/* 13 */ $from_flag,
			/* 14 */ esc_html($from),
			/* 15 */ $chevron_svg,
			/* 16 */ esc_attr(__('Swap currencies', 'currencyrate-today-currency-blocks')),
			/* 17 */ $swap_svg,
			/* 18 */ $to_label,
			/* 19 */ esc_attr($to_id),
			/* 20 */ esc_attr($result_str),
			/* translators: %s: currency code for amount input aria-label. */
			esc_attr(sprintf(__('Amount in %s', 'currencyrate-today-currency-blocks'), $to)),
			/* 22 */ $to_flag,
			/* 23 */ esc_html($to),
			/* 24 */ $chevron_svg,
			/* 25 */ $adjustment_html,
			/* 26 */ $cta_html,
			/* 27 */ $footer_html,
			/* 28 */ $disclaimer_html,
			/* 29 */ $disclaimer_script,
			/* 30 */ esc_attr($locale),
			/* 31 */ esc_attr($s['amount'])
		);

		return $html . $structured_data_html;
	}

	/* ================================================================
	 * Sanitisation
	 * ================================================================ */

	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';

		$interval = in_array((int) ($a['intervalMin'] ?? 15), [15, 30, 60], true) ? (int) $a['intervalMin'] : 15;
		$decimals = max(0, min(8, (int) $a['decimals']));
		$radius   = max(0, min(32, (int) $a['radius']));
		$amount   = self::sanitize_amount((string) ($a['amount'] ?? '1'));

		$source_id = strtoupper((string) ($a['sourceId'] ?? 'FEX'));
		$deal      = strtoupper((string) ($a['deal'] ?? 'DEFAULT'));

		$from = strtoupper((string) ($a['fromCurrency'] ?? 'USD'));
		$to   = strtoupper((string) ($a['toCurrency'] ?? 'EUR'));
		if (! preg_match('/^[A-Z]{3}$/', $from)) {
			$from = 'USD';
		}
		if (! preg_match('/^[A-Z]{3}$/', $to)) {
			$to = 'EUR';
		}

		$allowed_accent = ['color-border', 'stripe', 'none'];
		$accent_mode = in_array($a['accentMode'], $allowed_accent, 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,
			'fromCurrency'   => $from,
			'toCurrency'     => $to,
			'intervalMin'    => $interval,
			'amount'         => $amount,
			'decimals'       => $decimals,
			'theme'          => $theme,
			'shadow'         => (bool) $a['shadow'],
			'seamless'       => (bool) $a['seamless'],
			'radius'         => $radius,
			'showFlags'      => (bool) $a['showFlags'],
			'attribution'    => (bool) ($a['attribution'] ?? true),
			'compactMode'    => (bool) $a['compactMode'],
			'fitWidth'       => (bool) $a['fitWidth'],
			'sourceLabel'    => mb_substr(sanitize_text_field((string) $a['sourceLabel']), 0, 20),
			'showTimestamp'   => (bool) $a['showTimestamp'],
			'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),
			'enableStructuredData' => (bool) ($a['enableStructuredData'] ?? false),
			'accentTheme'    => $accent_theme,
			'accentAngle'    => $accent_angle,
			'accentMode'     => $accent_mode,
		];
	}

	/* ================================================================
	 * Conversion math
	 * ================================================================ */

	/**
	 * Convert an amount from one currency to another.
	 *
	 * Rates are expressed as: 1 unit of BASE = rate units of QUOTE.
	 *
	 * Mid mode:    result = amount × rate[TO].mid / rate[FROM].mid
	 * Bid/ask:     result = amount × rate[TO].bid / rate[FROM].ask
	 *              (worst-case for customer)
	 */
	private static function convert(
		float $amount,
		string $from,
		string $to,
		string $base,
		array $rate_map,
		string $pricing_mode
	): ?float {
		if ($from === $to) {
			return $amount;
		}

		$from_rate = self::get_rate($from, $base, $rate_map, $pricing_mode, 'from');
		$to_rate   = self::get_rate($to, $base, $rate_map, $pricing_mode, 'to');

		if ($from_rate === null || $to_rate === null || $from_rate <= 0) {
			return null;
		}

		return $amount * $to_rate / $from_rate;
	}

	private static function apply_adjustment(float $value, float $percent): float {
		if (! self::has_adjustment($percent)) {
			return $value;
		}

		return $value * (1 + ($percent / 100));
	}

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

		if ($cleaned === '' || ! is_numeric($cleaned) || (float) $cleaned <= 0) {
			return '1';
		}

		$normalized = rtrim(rtrim(sprintf('%.8F', (float) $cleaned), '0'), '.');

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

	/**
	 * Get the effective rate for a currency.
	 *
	 * @param string $direction 'from' uses ask (worse for customer), 'to' uses bid
	 */
	private static function get_rate(
		string $code,
		string $base,
		array $rate_map,
		string $pricing_mode,
		string $direction
	): ?float {
		/* Base currency is always 1.0 */
		if ($code === $base) {
			return 1.0;
		}

		$entry = $rate_map[$code] ?? null;
		if ($entry === null) {
			return null;
		}

		if ($pricing_mode === 'bidask') {
			$rate_key = ($direction === 'from') ? 'ask' : 'bid';
			$rate = (float) ($entry[$rate_key] ?? 0);

			/* Fall back to mid if bid/ask is zero */
			if ($rate <= 0) {
				$rate = (float) ($entry['mid'] ?? 0);
			}
			return $rate > 0 ? $rate : null;
		}

		/* Mid mode */
		$mid = (float) ($entry['mid'] ?? 0);
		return $mid > 0 ? $mid : null;
	}

	private static function has_full_bidask(string $code, string $base, array $rate_map): bool {
		if ($code === $base) {
			return true;
		}

		$entry = $rate_map[$code] ?? null;
		if (! is_array($entry)) {
			return false;
		}

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

		return $bid > 0 && $ask > 0;
	}

	private static function resolve_pair_pricing(string $from, string $to, string $base, array $rate_map): string {
		$from_has_bidask = self::has_full_bidask($from, $base, $rate_map);
		$to_has_bidask   = self::has_full_bidask($to, $base, $rate_map);

		return ($from_has_bidask && $to_has_bidask) ? 'bidask' : 'mid';
	}

	/* ================================================================
	 * Snapshot resolution (rate fetching + caching)
	 * ================================================================ */

	/**
	 * Resolve a rate snapshot for the converter.
	 *
	 * Uses its own cache key pattern (fx_conv_*) that stores ALL rates
	 * from the source without symbol filtering.
	 *
	 * @return array{state:string, viewModel:array}
	 */
	private static function resolve_snapshot(string $source_id, string $deal): array {
		$resolved = CRToday_Plugin_Settings::resolve_source($source_id, $deal);
		if ($resolved === null) {
			$resolved  = CRToday_Plugin_Settings::resolve_source('FEX', 'DEFAULT');
			$source_id = 'FEX';
			$deal      = 'DEFAULT';
		}

		$source_type = $resolved['type'] ?? 'builtin';

		/* ── Local source ── */
		if ($source_type === 'local') {
			$vm = self::build_local_view_model($resolved, $source_id);
			if ($vm !== null) {
				$state = (isset($vm['rates']) && $vm['rates'] !== []) ? 'ready' : 'empty';
				return ['state' => $state, 'viewModel' => $vm];
			}
			return ['state' => 'error', 'viewModel' => self::empty_view_model($source_id)];
		}

		/* ── Remote (built-in) source ── */
		$cache_key = 'crtoday_conv_' . md5($source_id . ':' . $deal);
		$ttl       = self::cache_ttl();
		$cached    = get_transient($cache_key);
		$fresh     = is_array($cached)
			&& isset($cached['savedAt'])
			&& (time() - (int) $cached['savedAt']) < $ttl;

		if ($fresh && isset($cached['viewModel'])) {
			$cached_saved_at = max(0, (int) ($cached['savedAt'] ?? 0));
			$cached_api_ts = self::parse_iso_timestamp((string) ($cached['viewModel']['timestamp'] ?? ''));
			CRToday_Plugin_Settings::record_builtin_source_fetch(
				$source_id,
				$deal,
				$cached_saved_at > 0 ? $cached_saved_at : time(),
				$cached_api_ts
			);

			$vm    = $cached['viewModel'];
			$state = (isset($vm['rates']) && $vm['rates'] !== []) ? 'ready' : 'empty';
			return ['state' => $state, 'viewModel' => $vm];
		}

		$db_fetched = self::fetch_db($source_id, $deal);
		if ($db_fetched !== null) {
			set_transient($cache_key, ['savedAt' => time(), 'viewModel' => $db_fetched], $ttl);
			$state = ($db_fetched['rates'] !== []) ? 'ready' : 'empty';
			return ['state' => $state, 'viewModel' => $db_fetched];
		}

		if (! self::try_acquire_fetch_lock($cache_key)) {
			$db_fetched = self::fetch_db($source_id, $deal);
			if ($db_fetched !== null) {
				set_transient($cache_key, ['savedAt' => time(), 'viewModel' => $db_fetched], $ttl);
				$state = ($db_fetched['rates'] !== []) ? 'ready' : 'empty';
				return ['state' => $state, 'viewModel' => $db_fetched];
			}

			if (is_array($cached) && isset($cached['viewModel'])) {
				$cached_saved_at = max(0, (int) ($cached['savedAt'] ?? 0));
				$cached_api_ts = self::parse_iso_timestamp((string) ($cached['viewModel']['timestamp'] ?? ''));
				CRToday_Plugin_Settings::record_builtin_source_fetch(
					$source_id,
					$deal,
					$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($source_id)];
		}

		/* Fetch from API */
		try {
			$fetched = self::fetch_remote($resolved, $source_id, $deal);
		} finally {
			self::release_fetch_lock($cache_key);
		}
		if ($fetched !== null) {
			set_transient($cache_key, ['savedAt' => time(), 'viewModel' => $fetched], $ttl);
			$state = ($fetched['rates'] !== []) ? 'ready' : 'empty';
			return ['state' => $state, 'viewModel' => $fetched];
		}

		/* Stale fallback */
		if (is_array($cached) && isset($cached['viewModel'])) {
			$cached_saved_at = max(0, (int) ($cached['savedAt'] ?? 0));
			$cached_api_ts = self::parse_iso_timestamp((string) ($cached['viewModel']['timestamp'] ?? ''));
			CRToday_Plugin_Settings::record_builtin_source_fetch(
				$source_id,
				$deal,
				$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($source_id)];
	}

	/**
	 * Fetch ALL rates from a remote API endpoint (no symbol filtering).
	 */
	private static function fetch_remote(array $resolved, string $source_id, string $deal): ?array {
		$endpoint = (string) ($resolved['endpoint'] ?? '');
		if ($endpoint === '') {
			return null;
		}

		$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;
		}

		$api_ts_unix = (isset($payload['ts']) && is_numeric($payload['ts'])) ? (int) $payload['ts'] : 0;
		$fetch_ts_unix = time();
		$config = CRToday_Plugin_Settings::get_config();
		$interval_min = in_array((int) ($config['api_interval'] ?? 15), [15, 30, 60], true)
			? (int) $config['api_interval']
			: 15;
		CRToday_Raw_Snapshot_Repository::save_remote_payload(
			$source_id,
			$deal,
			strtoupper((string) ($payload['base'] ?? 'USD')),
			$payload,
			$fetch_ts_unix,
			($api_ts_unix > 0 ? $api_ts_unix : $fetch_ts_unix),
			$interval_min
		);

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

	private static function fetch_db(string $source_id, string $deal): ?array {
		$config = CRToday_Plugin_Settings::get_config();
		$interval_min = in_array((int) ($config['api_interval'] ?? 15), [15, 30, 60], true)
			? (int) $config['api_interval']
			: 15;

		$snapshot = CRToday_Raw_Snapshot_Repository::get_latest_payload($source_id, $deal, $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($payload, $source_id, $deal, $api_ts_unix, $fetch_ts_unix);
	}

	private static function build_view_model_from_payload(array $payload, string $source_id, string $deal, int $api_ts_unix, int $fetch_ts_unix): ?array {
		$api_ts = $api_ts_unix > 0
			? gmdate('c', $api_ts_unix)
			: gmdate('c', max(1, $fetch_ts_unix));

		CRToday_Plugin_Settings::record_builtin_source_fetch($source_id, $deal, max(1, $fetch_ts_unix), $api_ts_unix);

		$api_base = strtoupper((string) ($payload['base'] ?? 'USD'));

		$view_model = [
			'base'        => $api_base,
			'timestamp'   => $api_ts,
			'source'      => $source_id,
			'pricingMode' => 'mid',
			'rates'       => [],
		];

		$has_bidask = false;

		foreach ($payload['rates'] as $code => $entry) {
			$code = strtoupper((string) $code);
			if ($code === $api_base) {
				continue;
			}

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

			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;
				$has_full_bidask = $bid > 0 && $ask > 0;

				if (! $has_full_bidask) {
					$bid = 0.0;
					$ask = 0.0;
				}

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

			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,
			];
		}

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

		return $view_model;
	}

	/**
	 * Build view model from a local (manual) source — all rates, no filtering.
	 */
	private static function build_local_view_model(array $resolved, string $source_id): ?array {
		$local_rates  = $resolved['rates'] ?? [];
		$pricing_mode = $resolved['pricing_mode'] ?? 'mid';
		$source_base  = strtoupper((string) ($resolved['base'] ?? 'USD'));
		$updated_at   = (int) ($resolved['updated_at'] ?? 0);

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

		$view_model = [
			'base'        => $source_base,
			'timestamp'   => $api_ts,
			'source'      => $source_id,
			'pricingMode' => $pricing_mode,
			'rates'       => [],
		];

		$has_bidask = false;

		foreach ($local_rates as $code => $entry) {
			$code = strtoupper((string) $code);
			if ($code === $source_base) {
				continue;
			}

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

			if (! $has_full_bidask) {
				$bid = 0.0;
				$ask = 0.0;
			}

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

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

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

			$view_model['rates'][] = [
				'code' => $code,
				'mid'  => $mid,
				'bid'  => $bid,
				'ask'  => $ask,
			];
		}

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

		return $view_model;
	}

	/* ================================================================
	 * Helpers
	 * ================================================================ */

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

	private static function build_flag(string $code, int $size): string {
		$flag = CRToday_Plugin_Settings::get_currency_flag($code);
		if ($flag === null) {
			return '';
		}

		$flag_alt = sprintf(
			/* translators: %s: currency code */
			__('Flag for %s', 'currencyrate-today-currency-blocks'),
			$code
		);

		return sprintf(
			'<span class="crtoday-flag-wrap crtoday-flag-wrap-md"><img class="crtoday-flag" src="%1$s" alt="%2$s" loading="lazy" decoding="async" width="%3$d" height="%3$d" /></span>',
			esc_url(CRTODAY_PLUGIN_URL . 'assets/flags/webp/128/' . $flag . '.webp'),
			esc_attr($flag_alt),
			$size
		);
	}

	/* ================================================================
	 * Asset registration
	 * ================================================================ */

	private static function register_frontend_assets(): void {
		$asset_path = CRTODAY_PLUGIN_DIR . 'build/currency-converter-frontend.asset.php';
		$asset      = file_exists($asset_path)
			? require $asset_path
			: [
				'dependencies' => [],
				'version'      => CRTODAY_PLUGIN_VERSION,
			];

		wp_register_script(
			self::HANDLE_FRONTEND,
			CRTODAY_PLUGIN_URL . 'build/currency-converter-frontend.js',
			$asset['dependencies'],
			$asset['version'],
			['strategy' => 'defer', 'in_footer' => true]
		);

		/* Provide currency metadata (name, flag, group) to frontend JS */
		wp_localize_script(
			self::HANDLE_FRONTEND,
			'crtodayConverterCurrencies',
			CRToday_Plugin_Settings::get_currencies_for_editor()
		);

		wp_localize_script(
			self::HANDLE_FRONTEND,
			'crtodayConverterUi',
			[
				'groupLabels' => CRToday_Plugin_Settings::get_currency_group_labels(true),
				'groupOrder'  => CRToday_Plugin_Settings::get_currency_group_order(),
				'labels'      => [
					'selectCurrency' => __('Select currency', 'currencyrate-today-currency-blocks'),
					'searchCurrency' => __('Search currency…', 'currencyrate-today-currency-blocks'),
					'searchCurrencies' => __('Search currencies', 'currencyrate-today-currency-blocks'),
					/* translators: %s: currency code for amount input aria-label. */
					'amountInPattern' => __('Amount in %s', 'currencyrate-today-currency-blocks'),
				],
			]
		);
	}
}
