<?php
/**
 * Exchange Rates Ticker block — SSR renderer + shortcode handler.
 *
 * Gutenberg block: crtoday/ticker
 * Shortcode:       [crtoday_ticker]
 *
 * @package CRTodayCurrencyBlocks
 */

declare(strict_types=1);

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

final class CRToday_Ticker_Block {

	use CRToday_Color_Helpers;
	use CRToday_Decimal_Helpers;
	use CRToday_Rate_Helpers;

	private const HANDLE_FRONTEND = 'crtoday-ticker-frontend';
	private const SHORTCODE       = 'crtoday_ticker';
	private const AJAX_PREVIEW_ACTION = 'crtoday_ticker_shortcode_preview';

	public const DEFAULT_ATTRIBUTES = [
		'sourceId'           => 'FEX',
		'deal'               => 'STANDARD',
		'pairs'              => [
			['from' => 'EUR', 'to' => 'USD'],
			['from' => 'USD', 'to' => 'UAH'],
			['from' => 'GBP', 'to' => 'JPY'],
			['from' => 'AUD', 'to' => 'CAD'],
			['from' => 'CHF', 'to' => 'EUR'],
		],
		'amount'             => '1',
		'adjustmentPercent'  => 0,
		'theme'              => 'light',
		'decimals'           => 2,
		'showFlags'          => true,
		'showChange'         => true,
		'currencyLabelMode'  => 'code',
		'speed'              => 40,
		'direction'          => 'left',
		'pauseOnHover'       => true,
		'borderX'            => false,
		'borderY'            => true,
		'separator'          => 'dot',
		'showTimestamp'       => true,
		'attribution'        => true,
		'compactMode'        => true,
		'sourceLabel'        => '',
	];

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

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

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

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

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

	/**
	 * [crtoday_ticker pairs="EUR/USD,USD/UAH,GBP/JPY" speed="40" …]
	 *
	 * @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']);

		/* Convert comma-separated pairs string → array of {from,to} */
		if (is_string($atts['pairs'])) {
			$raw_pairs = array_filter(array_map('trim', explode(',', $atts['pairs'])));
			$parsed_pairs = [];
			foreach ($raw_pairs as $pair_str) {
				$parts = explode('/', $pair_str, 2);
				if (count($parts) === 2) {
					$parsed_pairs[] = [
						'from' => strtoupper(trim($parts[0])),
						'to'   => strtoupper(trim($parts[1])),
					];
				}
			}
			$atts['pairs'] = $parsed_pairs !== [] ? $parsed_pairs : self::DEFAULT_ATTRIBUTES['pairs'];
		}

		/* snake_case aliases */
		$alias_map = [
			'adjustment_percent' => 'adjustmentPercent',
			'adjustment'         => 'adjustmentPercent',
			'show_flags'         => 'showFlags',
			'show_change'        => 'showChange',
			'currency_label_mode'=> 'currencyLabelMode',
			'pause_on_hover'     => 'pauseOnHover',
			'border_x'           => 'borderX',
			'border_y'           => 'borderY',
			'show_timestamp'     => 'showTimestamp',
			'compact_mode'       => 'compactMode',
			'source_label'       => 'sourceLabel',
		];
		foreach ($alias_map as $snake => $camel) {
			if (isset($atts[$snake])) {
				$atts[$camel] = $atts[$snake];
				unset($atts[$snake]);
			}
		}

		/* Cast types */
		foreach (['decimals', 'speed'] as $num_key) {
			$atts[$num_key] = (int) $atts[$num_key];
		}
		if (isset($atts['adjustmentPercent'])) {
			$atts['adjustmentPercent'] = (float) $atts['adjustmentPercent'];
		}
		foreach (['showFlags', 'showChange', 'pauseOnHover', 'attribution', 'borderX', 'borderY', 'showTimestamp', 'compactMode'] 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');
		}
		if (wp_script_is(self::HANDLE_FRONTEND, 'registered')) {
			wp_enqueue_script(self::HANDLE_FRONTEND);
		}

		return self::render($atts);
	}

	/* ================================================================
	 * AJAX preview handler (admin shortcode generator)
	 * ================================================================ */

	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) */
		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 pairs string → array of {from,to} */
		if (isset($raw['pairs']) && is_string($raw['pairs'])) {
			$raw_pairs = array_filter(array_map('trim', explode(',', $raw['pairs'])));
			$parsed_pairs = [];
			foreach ($raw_pairs as $pair_str) {
				$parts = explode('/', $pair_str, 2);
				if (count($parts) === 2) {
					$parsed_pairs[] = [
						'from' => strtoupper(trim($parts[0])),
						'to'   => strtoupper(trim($parts[1])),
					];
				}
			}
			$raw['pairs'] = $parsed_pairs !== [] ? $parsed_pairs : self::DEFAULT_ATTRIBUTES['pairs'];
		}

		/* Cast types */
		foreach (['decimals', 'speed'] as $k) {
			if (isset($raw[$k])) {
				$raw[$k] = (int) $raw[$k];
			}
		}
		if (isset($raw['adjustmentPercent'])) {
			$raw['adjustmentPercent'] = (float) $raw['adjustmentPercent'];
		}
		foreach (['showFlags', 'showChange', 'pauseOnHover', 'attribution', 'borderX', 'borderY', 'showTimestamp', 'compactMode'] 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]);
	}

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

	public static function render(array $attributes): string {
		$settings = self::sanitize_attributes($attributes);
		$stateful = self::resolve_snapshot($settings);

		$items_html = self::build_ticker_items($stateful['viewModel'], $settings);

		$wrapper_classes = array_filter([
			'crtoday-widget',
			'crtoday-ticker-wrap',
			'crtoday-theme-' . $settings['theme'],
			'crtoday-state-' . $stateful['state'],
			$settings['compactMode'] ? 'crtoday-compact' : '',
		]);

		$ticker_classes = array_filter([
			'crtoday-ticker',
			$settings['showFlags'] ? '' : 'crtoday-no-flags',
			$settings['pauseOnHover'] ? 'crtoday-ticker-pause-hover' : '',
			'crtoday-ticker-dir-' . $settings['direction'],
			$settings['borderY'] ? 'crtoday-border-y' : '',
			$settings['borderX'] ? 'crtoday-border-x' : '',
		]);

		$inline_style = '';

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

		/* Build footer (below ticker) — source name left, disclaimer + timestamp right */
		$timestamp_str = (string) ($stateful['viewModel']['timestamp'] ?? '');
		$source_info   = CRToday_Widget_Template::resolve_source_label(
			$settings['sourceId'],
			$settings['deal'],
			$settings['sourceLabel']
		);
		$header_html = '';
		$footer_html   = CRToday_Widget_Template::build_source_footer(
			$source_info['label'],
			$source_info['verified'],
			$settings['showTimestamp'],
			$timestamp_str,
			(bool) $settings['compactMode'],
			$source_info['is_own_source'],
			! $source_info['is_own_source']
		);
		$disclaimer_html = CRToday_Widget_Template::build_disclaimer_dialog($settings['attribution'], $source_info['name'], $timestamp_str);

		$html = sprintf(
			'<div class="%1$s" dir="%7$s" data-crtoday-version="1" data-crtoday-state="%2$s">'
			. '%12$s'
			. '<div class="%11$s" data-ticker-speed="%9$s" style="%3$s" role="marquee" aria-label="%8$s">'
			. '<div class="crtoday-ticker-track">'
			. '<div class="crtoday-ticker-content">%4$s</div>'
			. '<div class="crtoday-ticker-content" aria-hidden="true">%4$s</div>'
			. '</div>'
			. '</div>'
			. '%5$s'
			. '%10$s'
			. '</div>'
			. '%6$s',
			esc_attr(implode(' ', $wrapper_classes)),
			esc_attr($stateful['state']),
			esc_attr($inline_style),
			$items_html,
			$footer_html,
			CRToday_Widget_Template::build_disclaimer_script(),
			esc_attr(is_rtl() ? 'rtl' : 'ltr'),
			esc_attr(__('Exchange rates ticker', 'currencyrate-today-currency-blocks')),
			esc_attr((string) $settings['speed']),
			$disclaimer_html,
			esc_attr(implode(' ', $ticker_classes)),
			$header_html
		);

		return $html;
	}

	/* ================================================================
	 * Exchange Rates Ticker items builder
	 * ================================================================ */

	private static function build_ticker_items(array $view_model, array $settings): string {
		$rates = $view_model['rates'] ?? [];
		if ($rates === []) {
			return sprintf(
				'<span class="crtoday-ticker-item crtoday-ticker-empty">%s</span>',
				esc_html__('No matching rates for selected currencies', 'currencyrate-today-currency-blocks')
			);
		}

		$separator_char = self::resolve_separator($settings['separator']);
		$pricing_mode   = $view_model['pricingMode'] ?? 'mid';
		$amount         = $settings['amount'];
		$adj_pct        = (float) $settings['adjustmentPercent'];
		$decimals       = $settings['decimals'];
		$items = [];
		$index = 0;

		foreach ($rates as $rate) {
			$from    = (string) ($rate['from'] ?? '');
			$to      = (string) ($rate['to'] ?? '');
			$mid_raw = $rate['mid'] ?? null;

			if ($from === '' || $to === '' || $mid_raw === null) {
				continue;
			}

			$mid_str = self::normalize_to_string($mid_raw);
			if ($mid_str === null) {
				continue;
			}

			$bid_raw = (float) ($rate['bid'] ?? 0);
			$ask_raw = (float) ($rate['ask'] ?? 0);
			$bid_str = ($bid_raw > 0) ? (string) $bid_raw : null;
			$ask_str = ($ask_raw > 0) ? (string) $ask_raw : null;

			/* Amount multiplication + percentage adjustment */
			$mid_str = self::adjust_value($mid_str, $amount, $adj_pct, $decimals);
			$bid_str = self::adjust_value($bid_str, $amount, $adj_pct, $decimals);
			$ask_str = self::adjust_value($ask_str, $amount, $adj_pct, $decimals);

			/* Flag — show the "from" currency flag */
			$flag_html = '';
			if ($settings['showFlags']) {
				$from_flag = CRToday_Plugin_Settings::get_currency_flag($from);
				$to_flag   = CRToday_Plugin_Settings::get_currency_flag($to);
				$imgs = '';
				if ($from_flag !== null) {
					$imgs .= sprintf(
						'<img src="%s" alt="" loading="lazy" decoding="async" width="20" height="20" />',
						esc_url(CRTODAY_PLUGIN_URL . 'assets/flags/webp/128/' . $from_flag . '.webp')
					);
				}
				if ($to_flag !== null) {
					$imgs .= sprintf(
						'<img src="%s" alt="" loading="lazy" decoding="async" width="20" height="20" />',
						esc_url(CRTODAY_PLUGIN_URL . 'assets/flags/webp/128/' . $to_flag . '.webp')
					);
				}
				if ($imgs !== '') {
					$flag_html = sprintf('<span class="crtoday-ticker-flags">%s</span>', $imgs);
				}
			}

			/* Pair label: e.g. "EUR/USD" or "Euro / US Dollar" */
			if ($settings['currencyLabelMode'] === 'name') {
				$from_label = CRToday_Plugin_Settings::get_currency_name($from);
				$to_label   = CRToday_Plugin_Settings::get_currency_name($to);
				$label = $from_label . '/' . $to_label;
			} else {
				$label = $from . '/' . $to;
			}

			/* Separator between items */
			$sep_html = '';
			if ($index > 0 && $separator_char !== '') {
				$sep_html = sprintf(
					'<span class="crtoday-ticker-sep" aria-hidden="true">%s</span>',
					esc_html($separator_char)
				);
			}

			if ($pricing_mode === 'bidask') {
				/* Bid/Ask mode — two values with individual change indicators */
				$bid_text = ($bid_str !== null)
					? self::format_decimal($bid_str, $settings['decimals'])
					: self::format_decimal($mid_str, $settings['decimals']);
				$ask_text = ($ask_str !== null)
					? self::format_decimal($ask_str, $settings['decimals'])
					: self::format_decimal($mid_str, $settings['decimals']);

				$change_bid_html = '';
				$change_ask_html = '';
				if ($settings['showChange']) {
					$change_bid_flt = self::normalize_to_float(
						$rate['changePctBid'] ?? $rate['changePct'] ?? null
					);
					$change_ask_flt = self::normalize_to_float(
						$rate['changePctAsk'] ?? $rate['changePct'] ?? null
					);
					$change_bid_html = self::build_ticker_change($change_bid_flt);
					$change_ask_html = self::build_ticker_change($change_ask_flt);
				}

				$items[] = sprintf(
					'%1$s<span class="crtoday-ticker-item crtoday-ticker-item-bidask">'
					. '%2$s'
					. '<span class="crtoday-ticker-code">%3$s</span>'
					. '<span class="crtoday-ticker-bid">'
					. '<span class="crtoday-ticker-label">%4$s</span>'
					. '<span class="crtoday-ticker-rate">%5$s</span>'
					. '%6$s'
					. '</span>'
					. '<span class="crtoday-ticker-ask">'
					. '<span class="crtoday-ticker-label">%7$s</span>'
					. '<span class="crtoday-ticker-rate">%8$s</span>'
					. '%9$s'
					. '</span>'
					. '</span>',
					$sep_html,
					$flag_html,
					esc_html($label),
					esc_html__('Buy', 'currencyrate-today-currency-blocks'),
					esc_html($bid_text),
					$change_bid_html,
					esc_html__('Sell', 'currencyrate-today-currency-blocks'),
					esc_html($ask_text),
					$change_ask_html
				);
			} else {
				/* Mid-only mode */
				$value_text = self::format_decimal($mid_str, $settings['decimals']);

				$change_html = '';
				if ($settings['showChange']) {
					$change_flt = self::normalize_to_float($rate['changePct'] ?? null);
					$change_html = self::build_ticker_change($change_flt);
				}

				$items[] = sprintf(
					'%1$s<span class="crtoday-ticker-item">'
					. '%2$s'
					. '<span class="crtoday-ticker-code">%3$s</span>'
					. '<span class="crtoday-ticker-rate">%4$s</span>'
					. '%5$s'
					. '</span>',
					$sep_html,
					$flag_html,
					esc_html($label),
					esc_html($value_text),
					$change_html
				);
			}

			$index++;
		}

		/* Trailing separator so the seam between the two duplicate
		   ticker-content divs looks identical to any other gap. */
		if ($separator_char !== '' && $items !== []) {
			$items[] = sprintf(
				'<span class="crtoday-ticker-sep" aria-hidden="true">%s</span>',
				esc_html($separator_char)
			);
		}

		return implode('', $items);
	}

	private static function build_ticker_change(?float $change): string {
		if ($change === null) {
			return '';
		}

		if (abs($change) < 0.005) {
			$dir_class = 'crtoday-ticker-neutral';
			$arrow     = '';
		} elseif ($change > 0) {
			$dir_class = 'crtoday-ticker-up';
			$arrow     = '▲';
		} else {
			$dir_class = 'crtoday-ticker-down';
			$arrow     = '▼';
		}

		$formatted = number_format_i18n(abs($change), 2) . '%';

		return sprintf(
			'<span class="crtoday-ticker-change %1$s">%2$s%3$s</span>',
			esc_attr($dir_class),
			esc_html($arrow),
			esc_html($formatted)
		);
	}

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

	private static function resolve_separator(string $separator): string {
		$map = [
			'dot'   => '·',
			'pipe'  => '|',
			'slash' => '/',
			'none'  => '',
		];

		return $map[$separator] ?? '·';
	}

	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'] : 'light';

		$label_mode = $a['currencyLabelMode'] === 'name' ? 'name' : 'code';
		$decimals   = max(0, min(8, (int) $a['decimals']));
		$speed      = max(10, min(200, (int) $a['speed']));

		/* Direction */
		$direction = in_array($a['direction'], ['left', 'right'], true) ? $a['direction'] : 'left';

		/* Source resolution */
		$source_id = strtoupper((string) $a['sourceId']);
		$deal      = strtoupper((string) $a['deal']);
		$resolved  = CRToday_Plugin_Settings::resolve_source($source_id, $deal);
		if ($resolved === null) {
			$resolved  = CRToday_Plugin_Settings::resolve_source('FEX', 'STANDARD');
			$source_id = 'FEX';
			$deal      = 'STANDARD';
		}
		$source_base = strtoupper((string) ($resolved['base'] ?? 'USD'));

		/* Pairs — validate array of {from, to} objects */
		$pairs = [];
		$raw_pairs = is_array($a['pairs']) ? $a['pairs'] : [];
		foreach ($raw_pairs as $pair) {
			if (! is_array($pair)) {
				continue;
			}
			$from = strtoupper((string) ($pair['from'] ?? ''));
			$to   = strtoupper((string) ($pair['to'] ?? ''));
			if (
				$from !== '' && $to !== '' && $from !== $to
				&& preg_match('/^[A-Z]{3}$/', $from) === 1
				&& preg_match('/^[A-Z]{3}$/', $to) === 1
			) {
				$pairs[] = ['from' => $from, 'to' => $to];
			}
		}
		if ($pairs === []) {
			$pairs = self::DEFAULT_ATTRIBUTES['pairs'];
		}

		$allowed_separators = ['dot', 'pipe', 'slash', 'none'];
		$separator = in_array($a['separator'], $allowed_separators, true) ? $a['separator'] : 'dot';

		return [
			'sourceId'           => $source_id,
			'deal'               => $deal,
			'sourceBase'         => $source_base,
			'sourceType'         => $resolved['type'] ?? 'builtin',
			'pairs'              => $pairs,
			'amount'             => self::sanitize_amount((string) $a['amount']),
			'adjustmentPercent'  => self::sanitize_adjustment_percent($a['adjustmentPercent']),
			'theme'              => $theme,
			'decimals'           => $decimals,
			'showFlags'          => (bool) $a['showFlags'],
			'showChange'         => (bool) $a['showChange'],
			'currencyLabelMode'  => $label_mode,
			'speed'              => $speed,
			'direction'          => $direction,
			'pauseOnHover'       => (bool) $a['pauseOnHover'],
			'borderX'            => (bool) $a['borderX'],
			'borderY'            => (bool) $a['borderY'],
			'separator'          => $separator,
			'showTimestamp'       => (bool) $a['showTimestamp'],
			'radius'             => 0,
			'attribution'        => (bool) $a['attribution'],
			'compactMode'        => (bool) $a['compactMode'],
			'sourceLabel'        => sanitize_text_field((string) $a['sourceLabel']),
		];
	}

	/* ================================================================
	 * Spread helpers (bid / ask / mid)
	 * ================================================================ */

	/**
	 * Parse a rate entry (nested object or scalar) into mid / bid / ask.
	 *
	 * @param mixed $entry  API rate value: {mid, bid, ask} object, or scalar number.
	 * @return array{mid: ?float, bid: float, ask: float}
	 */
	private static function parse_spread_entry($entry): array {
		$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;
		} elseif (is_numeric($entry)) {
			$mid = (float) $entry;
		}

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

		return ['mid' => $mid, 'bid' => $bid, 'ask' => $ask];
	}

	/**
	 * Build mid / bid / ask lookup arrays indexed by currency code.
	 *
	 * @param array  $raw_rates  Rates keyed by currency code (API or local format).
	 * @param string $base       Base currency code (implicitly 1.0).
	 * @return array{0: array<string,float>, 1: array<string,float>, 2: array<string,float>}
	 */
	private static function build_spread_lookups(array $raw_rates, string $base): array {
		$mid_map = [$base => 1.0];
		$bid_map = [$base => 1.0];
		$ask_map = [$base => 1.0];

		foreach ($raw_rates as $code => $entry) {
			$code   = strtoupper((string) $code);
			$spread = self::parse_spread_entry($entry);

			if ($spread['mid'] !== null && $spread['mid'] > 0) {
				$mid_map[$code] = $spread['mid'];
			}
			if ($spread['bid'] > 0) {
				$bid_map[$code] = $spread['bid'];
			}
			if ($spread['ask'] > 0) {
				$ask_map[$code] = $spread['ask'];
			}
		}

		return [$mid_map, $bid_map, $ask_map];
	}

	/**
	 * Compute conservative cross-rates for each currency pair.
	 *
	 * Cross-rate formulas (from data-sources contract):
	 *   cross_mid = mid(to)  / mid(from)
	 *   cross_bid = bid(to)  / ask(from)
	 *   cross_ask = ask(to)  / bid(from)
	 *
	 * @return array{rates: list<array>, has_bidask: bool}
	 */
	private static function compute_cross_rates(
		array $pairs,
		array $mid_vs_base,
		array $bid_vs_base,
		array $ask_vs_base
	): array {
		$rates      = [];
		$has_bidask = false;

		foreach ($pairs as $pair) {
			$from = $pair['from'];
			$to   = $pair['to'];

			$from_mid = $mid_vs_base[$from] ?? null;
			$to_mid   = $mid_vs_base[$to] ?? null;

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

			$cross_mid = $to_mid / $from_mid;
			if ($cross_mid <= 0) {
				continue;
			}

			$cross_bid = 0.0;
			$cross_ask = 0.0;

			$to_bid   = $bid_vs_base[$to] ?? 0.0;
			$from_ask = $ask_vs_base[$from] ?? 0.0;
			$to_ask   = $ask_vs_base[$to] ?? 0.0;
			$from_bid = $bid_vs_base[$from] ?? 0.0;

			if ($to_bid > 0 && $from_ask > 0) {
				$cross_bid = $to_bid / $from_ask;
			}
			if ($to_ask > 0 && $from_bid > 0) {
				$cross_ask = $to_ask / $from_bid;
			}

			if ($cross_bid > 0 || $cross_ask > 0) {
				$has_bidask = true;
			}

			$rates[] = [
				'from'         => $from,
				'to'           => $to,
				'mid'          => $cross_mid,
				'bid'          => $cross_bid,
				'ask'          => $cross_ask,
				'changePct'    => null,
				'changePctBid' => null,
				'changePctAsk' => null,
			];
		}

		return ['rates' => $rates, 'has_bidask' => $has_bidask];
	}

	private static function resolve_snapshot(array $settings): array {
		/* Local source */
		if ($settings['sourceType'] === '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: shared per-source cache */
		$source = CRToday_Source_Cache::resolve(
			(string) $settings['sourceId'],
			(string) $settings['deal']
		);

		if ($source['state'] === 'error' || $source['payload'] === null) {
			return [
				'state'     => 'error',
				'viewModel' => self::empty_view_model($settings),
			];
		}

		$view_model = self::build_view_model_from_payload(
			$settings,
			$source['payload'],
			$source['fetch_ts'],
			$source['api_ts']
		);

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

		$state = $source['state'] === 'stale' ? 'stale' : self::view_model_state($view_model);
		return [
			'state'     => $state,
			'viewModel' => $view_model,
		];
	}

	/* ================================================================
	 * Data pipeline
	 * ================================================================ */

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

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

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

		[$mid_vs_base, $bid_vs_base, $ask_vs_base] = self::build_spread_lookups($payload['rates'], $api_base);
		$cross = self::compute_cross_rates($settings['pairs'], $mid_vs_base, $bid_vs_base, $ask_vs_base);

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

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

	private static function resolve_local_view_model(array $settings): ?array {
		$resolved = CRToday_Plugin_Settings::resolve_source(
			(string) $settings['sourceId'],
			'STANDARD',
		);
		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'));
		$updated_at   = (int) ($resolved['updated_at'] ?? 0);
		$api_ts       = $updated_at > 0 ? gmdate('c', $updated_at) : gmdate('c');

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

		[$mid_vs_base, $bid_vs_base, $ask_vs_base] = self::build_spread_lookups($local_rates, $source_base);
		$cross = self::compute_cross_rates($settings['pairs'], $mid_vs_base, $bid_vs_base, $ask_vs_base);

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

		return $view_model;
	}

	/* ================================================================
	 * Change computation (24h delta)
	 * ================================================================ */

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

		$current_ts = 0;
		$ts_str     = (string) ($view_model['timestamp'] ?? '');
		if ($ts_str !== '') {
			$parsed = strtotime($ts_str);
			if ($parsed !== false && $parsed > 0) {
				$current_ts = $parsed;
			}
		}
		if ($current_ts <= 0) {
			$current_ts = time();
		}

		$ref_snapshot = CRToday_Raw_Snapshot_Repository::get_change_reference_payload(
			$source_id,
			$deal,
			$interval_min,
			$current_ts,
			86400
		);
		if (! is_array($ref_snapshot)) {
			return $view_model;
		}

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

		$ref_api_base = strtoupper((string) ($ref_payload['base'] ?? 'USD'));
		[$ref_mid_vs_base, $ref_bid_vs_base, $ref_ask_vs_base] = self::build_spread_lookups(
			$ref_payload['rates'],
			$ref_api_base
		);

		foreach ($view_model['rates'] as &$rate) {
			$from = strtoupper((string) ($rate['from'] ?? ''));
			$to   = strtoupper((string) ($rate['to'] ?? ''));
			if ($from === '' || $to === '') {
				continue;
			}

			/* Mid change */
			$current_mid = (float) ($rate['mid'] ?? 0);
			$ref_from_mid = $ref_mid_vs_base[$from] ?? null;
			$ref_to_mid   = $ref_mid_vs_base[$to] ?? null;
			if ($current_mid > 0 && $ref_from_mid !== null && $ref_to_mid !== null && $ref_from_mid > 0) {
				$ref_cross_mid = $ref_to_mid / $ref_from_mid;
				if ($ref_cross_mid > 0) {
					$rate['changePct'] = (($current_mid - $ref_cross_mid) / $ref_cross_mid) * 100;
				}
			}

			/* Bid change (conservative cross: bid(to)/ask(from)) */
			$current_bid  = (float) ($rate['bid'] ?? 0);
			$ref_to_bid   = $ref_bid_vs_base[$to] ?? 0.0;
			$ref_from_ask = $ref_ask_vs_base[$from] ?? 0.0;
			if ($current_bid > 0 && $ref_to_bid > 0 && $ref_from_ask > 0) {
				$ref_cross_bid = $ref_to_bid / $ref_from_ask;
				if ($ref_cross_bid > 0) {
					$rate['changePctBid'] = (($current_bid - $ref_cross_bid) / $ref_cross_bid) * 100;
				}
			}

			/* Ask change (conservative cross: ask(to)/bid(from)) */
			$current_ask  = (float) ($rate['ask'] ?? 0);
			$ref_to_ask   = $ref_ask_vs_base[$to] ?? 0.0;
			$ref_from_bid = $ref_bid_vs_base[$from] ?? 0.0;
			if ($current_ask > 0 && $ref_to_ask > 0 && $ref_from_bid > 0) {
				$ref_cross_ask = $ref_to_ask / $ref_from_bid;
				if ($ref_cross_ask > 0) {
					$rate['changePctAsk'] = (($current_ask - $ref_cross_ask) / $ref_cross_ask) * 100;
				}
			}
		}
		unset($rate);

		return $view_model;
	}

	/* ================================================================
	 * Utility methods
	 * ================================================================ */

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

	/* ================================================================
	 * Frontend asset registration
	 * ================================================================ */

	private static function register_frontend_assets(): void {
		$asset_path = CRTODAY_PLUGIN_DIR . 'build/ticker-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/ticker-frontend.js',
			$asset['dependencies'],
			$asset['version'],
			true
		);
	}
}
