<?php
/**
 * Exchange Rate Card block — SSR renderer + shortcode handler.
 *
 * Gutenberg block: crtoday/rate-card
 * Shortcode:       [crtoday_rate_card]
 *
 * @package CRTodayCurrencyBlocks
 */

declare(strict_types=1);

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

final class CRToday_Rate_Card_Block {

	use CRToday_Color_Helpers;
	use CRToday_Decimal_Helpers;
	use CRToday_Rate_Helpers;

	private const SHORTCODE                = 'crtoday_rate_card';
	private const AJAX_PREVIEW_ACTION      = 'crtoday_rate_card_shortcode_preview';
	private const HANDLE_FRONTEND          = 'crtoday-rate-card-frontend';
	private const CHANGE_WINDOW_SECONDS    = 86400;
	private const MIN_CHART_POINTS         = 3;

	public const DEFAULT_ATTRIBUTES = [
		'sourceId'           => 'FEX',
		'deal'               => 'STANDARD',
		'from'               => 'EUR',
		'to'                 => 'USD',
		'amount'             => '1',
		'adjustmentPercent'  => 0,
		'theme'              => 'light',
		'decimals'           => 4,
		'showFlags'          => true,
		'showChange'         => true,
		'currencyLabelMode'  => 'code',
		'showTimestamp'      => true,
		'attribution'        => true,
		'shadow'             => true,
		'seamless'           => false,
		'radius'             => 12,
		'accentTheme'        => 'default',
		'accentAngle'        => 135,
		'accentMode'         => 'none',
		'fullWidth'          => false,
		'compactMode'        => true,
		'showChart'          => true,
		'showRange'          => false,
		'sourceLabel'        => '',
	];

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

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

		register_block_type(
			CRTODAY_PLUGIN_DIR . 'blocks/rate-card',
			[
				'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']);

		/* Load chart frontend in the block editor so MutationObserver
		   can pick up ServerSideRender output. */
		add_action('enqueue_block_editor_assets', static function (): void {
			if (wp_script_is(self::HANDLE_FRONTEND, 'registered')) {
				wp_enqueue_script(self::HANDLE_FRONTEND);
			}
		});
	}

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

	/**
	 * [crtoday_rate_card from="EUR" to="USD" source="FEX:STANDARD" …]
	 *
	 * @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
		);

		$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']);

		$alias_map = [
			'adjustment_percent'  => 'adjustmentPercent',
			'adjustment'          => 'adjustmentPercent',
			'show_flags'          => 'showFlags',
			'show_change'         => 'showChange',
			'currency_label_mode' => 'currencyLabelMode',
			'show_timestamp'      => 'showTimestamp',
			'accent_theme'        => 'accentTheme',
			'accent_angle'        => 'accentAngle',
			'accent_mode'         => 'accentMode',
			'full_width'          => 'fullWidth',
			'show_range'          => 'showRange',
		];
		foreach ($alias_map as $snake => $camel) {
			if (isset($atts[$snake])) {
				$atts[$camel] = $atts[$snake];
				unset($atts[$snake]);
			}
		}

		foreach (['decimals', 'radius', 'accentAngle'] as $num_key) {
			$atts[$num_key] = (int) $atts[$num_key];
		}
		if (isset($atts['adjustmentPercent'])) {
			$atts['adjustmentPercent'] = (float) $atts['adjustmentPercent'];
		}
		foreach (['showFlags', 'showChange', 'showTimestamp', 'shadow', 'seamless', 'fullWidth', 'attribution', 'compactMode', 'showChart', 'showRange'] as $bool_key) {
			if (isset($atts[$bool_key])) {
				$atts[$bool_key] = filter_var($atts[$bool_key], FILTER_VALIDATE_BOOLEAN);
			}
		}

		if (wp_style_is('crtoday-block-style', 'registered')) {
			wp_enqueue_style('crtoday-block-style');
		}

		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']);
		}

		/* Cast types */
		foreach (['decimals', 'radius', 'accentAngle'] as $k) {
			if (isset($raw[$k])) {
				$raw[$k] = (int) $raw[$k];
			}
		}
		if (isset($raw['adjustmentPercent'])) {
			$raw['adjustmentPercent'] = (float) $raw['adjustmentPercent'];
		}
		foreach (['showFlags', 'showChange', 'showTimestamp', 'shadow', 'seamless', 'fullWidth', 'attribution', 'compactMode', 'showChart', 'showRange'] 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);
		$rate     = $stateful['viewModel']['rates'][0] ?? null;

		$pricing_mode = $stateful['viewModel']['pricingMode'] ?? 'mid';

		/* Seamless mode */
		if ($settings['seamless']) {
			$settings['theme']      = 'light';
			$settings['shadow']     = false;
			$settings['radius']     = 0;
			$settings['accentMode'] = 'none';
		}

		$root_classes = array_filter([
			'crtoday-widget',
			'crtoday-rate-card',
			'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['fullWidth'] ? '' : 'crtoday-fit-width',
			$settings['showFlags'] ? '' : 'crtoday-no-flags',
			'crtoday-accent-' . $settings['accentMode'],
			'crtoday-pricing-' . $pricing_mode,
			self::has_adjustment((float) $settings['adjustmentPercent']) ? 'crtoday-has-adjustment' : '',
		]);

		/* Pair label — standalone row inside the card (no shared header) */
		$pair_html = self::build_pair_header($rate, $settings);

		$body_html       = self::build_body($rate, $settings, $pricing_mode);
		$adjustment_html = self::build_adjustment_note($settings);

		/* Day's Range bar — only when enabled + data available */
		$range_html = '';
		if ($settings['showRange'] && $stateful['state'] === 'ready' && $rate !== null) {
			$current_mid = self::normalize_to_string($rate['mid'] ?? null);
			if ($current_mid !== null) {
				$range_html = self::build_range_bar($settings, $current_mid);
			}
		}

		/* Chart (sparkline + period tabs) — only when enabled + data available */
		$chart_html = '';
		if ($settings['showChart'] && $stateful['state'] === 'ready') {
			$chart_html = self::build_chart_section($settings);
		}

		/* Source-centric footer: source label left, disclaimer + timestamp right */
		$source_info = CRToday_Widget_Template::resolve_source_label(
			$settings['sourceId'],
			$settings['deal'],
			$settings['sourceLabel']
		);
		$header_html = '';
		if (! $source_info['is_own_source']) {
			$header_html = CRToday_Widget_Template::build_header(
				'',
				$source_info['label'],
				$source_info['verified']
			);
		}
		$timestamp_str     = (string) ($stateful['viewModel']['timestamp'] ?? '');
		$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']
		);
		$disclaimer_html   = CRToday_Widget_Template::build_disclaimer_dialog($settings['attribution'], $source_info['name'], $timestamp_str);
		$disclaimer_script = CRToday_Widget_Template::build_disclaimer_script();

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

		if ($settings['radius'] > 0) {
			$inline_style .= '--crtoday-card-radius:' . (int) $settings['radius'] . 'px;';
		}

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

		return $html;
	}

	/* ================================================================
	 * Pair header (flags + label)
	 * ================================================================ */

	private static function build_pair_header(?array $rate, array $settings): string {
		$from = $settings['from'];
		$to   = $settings['to'];

		/* Flags */
		$flags_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="32" height="32" />',
					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="32" height="32" />',
					esc_url(CRTODAY_PLUGIN_URL . 'assets/flags/webp/128/' . $to_flag . '.webp')
				);
			}
			if ($imgs !== '') {
				$flags_html = sprintf('<span class="crtoday-rate-card-flags">%s</span>', $imgs);
			}
		}

		/* Label */
		if ($settings['currencyLabelMode'] === 'name') {
			$from_label = CRToday_Plugin_Settings::get_currency_name($from);
			$to_label   = CRToday_Plugin_Settings::get_currency_name($to);
		} else {
			$from_label = $from;
			$to_label   = $to;
		}

		$label_html = sprintf(
			'<span class="crtoday-rate-card-label">%s <span class="crtoday-rate-card-arrow">→</span> %s</span>',
			esc_html($from_label),
			esc_html($to_label)
		);

		return sprintf(
			'<div class="crtoday-rate-card-pair">%s%s</div>',
			$flags_html,
			$label_html
		);
	}

	/* ================================================================
	 * Body (rate value + change badge + optional bid/ask)
	 * ================================================================ */

	private static function build_body(?array $rate, array $settings, string $pricing_mode): string {
		if ($rate === null) {
			return sprintf(
				'<div class="crtoday-rate-card-body"><span class="crtoday-rate-card-value">%s</span></div>',
				esc_html__('—', 'currencyrate-today-currency-blocks')
			);
		}

		$amount  = $settings['amount'];
		$adj_pct = (float) $settings['adjustmentPercent'];
		$dec     = $settings['decimals'];

		/*
		 * Bid/Ask mode: show Buy and Sell as the primary rows, each with
		 * its own 24h change badge. No mid rate displayed.
		 */
		if ($pricing_mode === 'bidask') {
			return self::build_bidask_body($rate, $settings);
		}

		/* Mid-only mode */
		$mid_str = self::normalize_to_string($rate['mid'] ?? null);
		$mid_str = self::adjust_value($mid_str, $amount, $adj_pct, $dec);
		$mid_fmt = self::format_decimal($mid_str, $dec);

		/* Change badge */
		$change_html = '';
		if ($settings['showChange']) {
			$change_flt  = self::normalize_to_float($rate['changePct'] ?? null);
			$change_html = self::build_change_badge($change_flt);
		}

		return sprintf(
			'<div class="crtoday-rate-card-body">'
			. '<span class="crtoday-rate-card-value">%s</span>'
			. '%s'
			. '</div>',
			esc_html($mid_fmt),
			$change_html
		);
	}

	/**
	 * Bid/Ask primary layout — Buy and Sell shown as equal rows.
	 */
	private static function build_bidask_body(array $rate, array $settings): string {
		$amount  = $settings['amount'];
		$adj_pct = (float) $settings['adjustmentPercent'];
		$dec     = $settings['decimals'];

		$mid_str = self::normalize_to_string($rate['mid'] ?? null);
		$mid_str = self::adjust_value($mid_str, $amount, $adj_pct, $dec);
		$mid_fmt = self::format_decimal($mid_str, $dec);

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

		$bid_str = self::adjust_value($bid_str, $amount, $adj_pct, $dec);
		$ask_str = self::adjust_value($ask_str, $amount, $adj_pct, $dec);

		$bid_fmt = ($bid_str !== null) ? self::format_decimal($bid_str, $dec) : $mid_fmt;
		$ask_fmt = ($ask_str !== null) ? self::format_decimal($ask_str, $dec) : $mid_fmt;

		/* Change badges */
		$bid_change_html = '';
		$ask_change_html = '';
		if ($settings['showChange']) {
			$bid_change_html = self::build_change_badge(
				self::normalize_to_float($rate['changePctBid'] ?? null)
			);
			$ask_change_html = self::build_change_badge(
				self::normalize_to_float($rate['changePctAsk'] ?? null)
			);
		}

		return sprintf(
			'<div class="crtoday-rate-card-bidask">'
			. '<div class="crtoday-rate-card-bidask-row">'
			. '<span class="crtoday-rate-card-bidask-label">%s</span>'
			. '<span class="crtoday-rate-card-bidask-value">%s</span>'
			. '%s'
			. '</div>'
			. '<div class="crtoday-rate-card-bidask-row">'
			. '<span class="crtoday-rate-card-bidask-label">%s</span>'
			. '<span class="crtoday-rate-card-bidask-value">%s</span>'
			. '%s'
			. '</div>'
			. '</div>',
			esc_html__('Buy', 'currencyrate-today-currency-blocks'),
			esc_html($bid_fmt),
			$bid_change_html,
			esc_html__('Sell', 'currencyrate-today-currency-blocks'),
			esc_html($ask_fmt),
			$ask_change_html
		);
	}

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

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

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

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

	private static function build_adjustment_note(array $settings): string {
		if (! self::has_adjustment((float) $settings['adjustmentPercent'])) {
			return '';
		}

		return 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'])
			))
		);
	}

	/* ================================================================
	 * Sanitize attributes
	 * ================================================================ */

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

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

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

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

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

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

		return [
			'sourceId'          => $source_id,
			'deal'              => $deal,
			'sourceBase'        => strtoupper((string) ($resolved['base'] ?? 'USD')),
			'sourceType'        => $resolved['type'] ?? 'builtin',
			'from'              => $from,
			'to'                => $to,
			'pairs'             => [['from' => $from, 'to' => $to]],
			'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,
			'showTimestamp'     => (bool) $a['showTimestamp'],
			'attribution'       => (bool) $a['attribution'],
			'shadow'            => (bool) $a['shadow'],
			'seamless'          => (bool) $a['seamless'],
			'radius'            => max(0, min(32, (int) $a['radius'])),
			'accentTheme'       => $accent_theme,
			'accentAngle'       => $accent_angle,
			'accentMode'        => $accent_mode,
			'fullWidth'         => (bool) $a['fullWidth'],
			'compactMode'       => (bool) $a['compactMode'],
			'showChart'         => (bool) $a['showChart'],
			'showRange'         => (bool) $a['showRange'],
			'sourceLabel'       => sanitize_text_field((string) $a['sourceLabel']),
		];
	}

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

	/**
	 * @param mixed $entry
	 * @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];
	}

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

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

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

	private static function resolve_snapshot(array $settings): array {
		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,
		];
	}

	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,
			self::CHANGE_WINDOW_SECONDS
		);
		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 */
			$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 */
			$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;
	}

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

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

	/* ================================================================
	 * Day's Range bar (low … high + progress indicator)
	 * ================================================================ */

	/**
	 * Build the "Day's Range" section showing the 24h low–high range
	 * with a progress bar indicating where the current rate sits.
	 *
	 * Graceful degradation:
	 *  - 0 data points → returns empty string (nothing rendered).
	 *  - 1 data point  → low = high = current; bar fills 50%.
	 *  - 2+ points     → proper low/high with calculated position.
	 *
	 * @param array  $settings Sanitized block settings.
	 * @param string $current_mid Current mid-rate string (already cross-rated, before amount/adjustment).
	 */
	private static function build_range_bar(array $settings, string $current_mid): string {
		$source = (string) $settings['sourceId'];
		$deal   = (string) $settings['deal'];
		$base   = (string) $settings['sourceBase'];
		$from   = (string) $settings['from'];
		$to     = (string) $settings['to'];

		$now    = time();
		$points = CRToday_Chart_Api::build_cross_rate_series(
			$source, $deal, $base, $from, $to,
			$now - self::CHANGE_WINDOW_SECONDS, $now
		);

		if (count($points) === 0) {
			return '';
		}

		$amount  = $settings['amount'];
		$adj_pct = (float) $settings['adjustmentPercent'];
		$dec     = $settings['decimals'];

		/* Extract min / max from the series */
		$rates = array_column($points, 'rate');
		$low   = (float) min($rates);
		$high  = (float) max($rates);

		$current = (float) $current_mid;
		if ($current <= 0.0) {
			return '';
		}

		/* When all values equal (single point or flat market) → 50% */
		if ($high <= $low) {
			$pct = 50.0;
		} else {
			$pct = (($current - $low) / ($high - $low)) * 100.0;
			$pct = max(0.0, min(100.0, $pct));
		}

		/* Format displayed values through the same pipeline as rates */
		$low_fmt  = self::format_decimal(
			self::adjust_value((string) $low, $amount, $adj_pct, $dec),
			$dec
		);
		$high_fmt = self::format_decimal(
			self::adjust_value((string) $high, $amount, $adj_pct, $dec),
			$dec
		);

		/* Percentage spread: (high − low) / low × 100 */
		$spread_pct = ($low > 0.0) ? (($high - $low) / $low) * 100.0 : 0.0;
		$spread_fmt = number_format($spread_pct, 2, '.', '');

		return sprintf(
			'<div class="crtoday-rate-card-range">'
			. '<span class="crtoday-rate-card-range-label">%s</span>'
			. '<span class="crtoday-rate-card-range-pct">%s%%</span>'
			. '<div class="crtoday-rate-card-range-bar-row">'
			. '<span class="crtoday-rate-card-range-low">%s</span>'
			. '<div class="crtoday-rate-card-range-track">'
			. '<div class="crtoday-rate-card-range-fill" style="width:%s%%"></div>'
			. '</div>'
			. '<span class="crtoday-rate-card-range-high">%s</span>'
			. '</div>'
			. '</div>',
			esc_html__( "Day's Range", 'currencyrate-today-currency-blocks' ),
			esc_html($spread_fmt),
			esc_html($low_fmt),
			esc_attr(number_format($pct, 2, '.', '')),
			esc_html($high_fmt)
		);
	}

	/* ================================================================
	 * Chart section (sparkline + period tabs)
	 * ================================================================ */

	/**
	 * Build the sparkline chart container with initial 24h data baked in.
	 *
	 * The frontend JS (`rate-card-frontend.ts`) reads the inline JSON from
	 * `data-crtoday-chart-points` and renders an SVG sparkline. Period tab
	 * clicks fetch fresh data from the REST API.
	 *
	 * Periods with no data in the DB are hidden automatically.
	 */
	private static function build_chart_section(array $settings): string {
		$source  = (string) $settings['sourceId'];
		$deal    = (string) $settings['deal'];
		$base    = (string) $settings['sourceBase'];
		$from    = (string) $settings['from'];
		$to      = (string) $settings['to'];

		$now = time();

		/* Ordered periods — first available wins as default.
		 * 24h / 7d are lenient (just need MIN_CHART_POINTS).
		 * 30d / 90d require that accumulated data actually spans most of the
		 * requested window — otherwise the tab is misleading. */
		$periods = [
			'24h' => ['seconds' => 86400,   'min_coverage' => 0.0],
			'7d'  => ['seconds' => 604800,  'min_coverage' => 0.0],
			'30d' => ['seconds' => 2592000, 'min_coverage' => 0.8],
			'90d' => ['seconds' => 7776000, 'min_coverage' => 0.8],
		];

		/* Discover which periods have data and find initial period */
		$available     = [];
		$initial_key   = null;
		$initial_points = [];

		foreach ($periods as $key => $cfg) {
			$seconds      = $cfg['seconds'];
			$min_coverage = $cfg['min_coverage'];

			$points = CRToday_Chart_Api::build_cross_rate_series(
				$source, $deal, $base, $from, $to,
				$now - $seconds, $now
			);

			if (count($points) < self::MIN_CHART_POINTS) {
				continue;
			}

			/* For longer periods, ensure data actually spans the window */
			if ($min_coverage > 0.0) {
				$first_ts = (int) $points[0]['ts'];
				$last_ts  = (int) $points[count($points) - 1]['ts'];
				$span     = $last_ts - $first_ts;
				if ($span < $seconds * $min_coverage) {
					continue;
				}
			}

			$available[$key] = true;
			if ($initial_key === null) {
				$initial_key    = $key;
				$initial_points = $points;
			}
		}

		/* No data at all — no chart */
		if ($initial_key === null) {
			return '';
		}

		/* Build tab HTML — only available periods, first gets active class */
		$tabs_html = '';
		foreach ($available as $key => $_) {
			$is_active = ($key === $initial_key);
			$active    = $is_active ? ' crtoday-chart-tab-active' : '';
			$tabindex  = $is_active ? '0' : '-1';
			$tabs_html .= sprintf(
				'<button type="button" class="crtoday-chart-tab%s" role="tab" aria-selected="%s" tabindex="%s" data-crtoday-period="%s">%s</button>',
				$active,
				$is_active ? 'true' : 'false',
				esc_attr($tabindex),
				esc_attr($key),
				esc_html($key)
			);
		}

		/* Encode points for inline data */
		$json = wp_json_encode($initial_points, JSON_UNESCAPED_UNICODE);

		/* REST endpoint URL */
		$api_url = rest_url('crtoday/v1/chart');

		/* Enqueue chart JS */
		if (wp_script_is(self::HANDLE_FRONTEND, 'registered')) {
			wp_enqueue_script(self::HANDLE_FRONTEND);
		}

		return sprintf(
			'<div class="crtoday-chart-wrap"'
			. ' data-crtoday-chart-points="%s"'
			. ' data-crtoday-chart-api="%s"'
			. ' data-crtoday-chart-source="%s"'
			. ' data-crtoday-chart-deal="%s"'
			. ' data-crtoday-chart-from="%s"'
			. ' data-crtoday-chart-to="%s"'
			. ' data-crtoday-i18n-chart="%s"'
			. ' data-crtoday-i18n-loading="%s"'
			. '>'
			. '<div class="crtoday-chart-area"></div>'
			. '<div class="crtoday-sr-only crtoday-chart-sr-status" role="status" aria-live="polite"></div>'
			. '<div class="crtoday-chart-tabs" role="tablist">%s</div>'
			. '</div>',
			esc_attr($json),
			esc_attr($api_url),
			esc_attr($source),
			esc_attr($deal),
			esc_attr($from),
			esc_attr($to),
			esc_attr(__('Price chart', 'currencyrate-today-currency-blocks')),
			esc_attr(__('Loading…', 'currencyrate-today-currency-blocks')),
			$tabs_html
		);
	}

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

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