<?php
/**
 * REST API endpoints.
 *
 * @package Vachr Short-Term Rental Reservations
 * @license GPLv2 or later
 */

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

class VSTRB_REST {
	// This class uses custom plugin tables and explicit SQL for pricing/performance logic.
	// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value, PluginCheck.Security.DirectDB.UnescapedDBParameter
	private static function resolve_locale($slug) {
		if (function_exists('PLL') && PLL()->model) {
			$lang = PLL()->model->get_language($slug);
			if ($lang && !empty($lang->locale)) {
				return $lang->locale;
			}
		}
		return $slug;
	}

	private static function load_textdomain_for_locale($locale) {
		$locale = sanitize_text_field((string) $locale);
		if ($locale === '') {
			return;
		}
		$domain = 'vachr-short-term-rental-reservations';
		$mofile = VSTRB_PLUGIN_DIR . 'languages/' . $domain . '-' . $locale . '.mo';
		if (is_readable($mofile)) {
			unload_textdomain($domain, true);
			load_textdomain($domain, $mofile);
		}
	}

	private static function normalize_locale_code($locale) {
		$locale = strtolower(trim((string) $locale));
		return str_replace('-', '_', $locale);
	}

	private static function normalize_content_language_code($locale) {
		$locale = self::normalize_locale_code($locale);
		if (preg_match('/^([a-z]{2})/', $locale, $m)) {
			return $m[1];
		}
		return '';
	}

	private static function build_unit_placeholder_values($unit_id, $locale = '', $context = []) {
		$unit_id = (int) $unit_id;
		$lang = self::normalize_content_language_code($locale);
		$supplier_account = VSTRB_Payment_QR::supplier_account_details();
		$values = [
			'apartment_id' => (string) $unit_id,
			'apartment_name' => get_the_title($unit_id),
			'unit_id' => (string) $unit_id,
			'unit_name' => get_the_title($unit_id),
			'site_name' => get_bloginfo('name'),
			'checkin_time' => (string) get_post_meta($unit_id, '_vstrb_checkin', true),
			'checkout_time' => (string) get_post_meta($unit_id, '_vstrb_checkout', true),
			'capacity' => (string) get_post_meta($unit_id, '_vstrb_capacity', true),
			'iban' => (string) ($supplier_account['iban'] ?? ''),
			'bank_account' => (string) ($supplier_account['account_number'] ?? ''),
			'account_number' => (string) ($supplier_account['account_number'] ?? ''),
			'account_country' => (string) ($supplier_account['country'] ?? ''),
		];

		foreach ($context as $key => $value) {
			if (is_scalar($value)) {
				$values[strtolower((string) $key)] = (string) $value;
			}
		}
		$invoice_number = (string) ($values['invoice_number'] ?? '');
		$payment_reference = (string) ($values['payment_reference'] ?? $invoice_number);
		$variable_symbol = preg_replace('/\D+/', '', $payment_reference);
		$values['variable_symbol'] = $variable_symbol !== '' ? substr($variable_symbol, -10) : '';
		$values['payment_reference'] = $payment_reference;
		$values['amount'] = (string) ($values['payment_amount'] ?? $values['payment_amount_due'] ?? '');
		$values['currency'] = (string) ($values['payment_currency'] ?? '');

		$custom = get_post_meta($unit_id, '_vstrb_content_placeholders', true);
		if (is_array($custom)) {
			foreach ($custom as $key => $row) {
				$key = strtolower(trim((string) $key));
				if ($key === '') {
					continue;
				}
				if (is_array($row)) {
					$i18n = isset($row['i18n']) && is_array($row['i18n']) ? $row['i18n'] : [];
					if ($lang !== '' && isset($i18n[$lang]) && trim(wp_strip_all_tags((string) $i18n[$lang])) !== '') {
						$values[$key] = (string) $i18n[$lang];
					} else {
						$values[$key] = (string) ($row['value'] ?? '');
					}
				} else {
					$values[$key] = (string) $row;
				}
			}
		}

		return $values;
	}

	private static function apply_unit_placeholders($content, $unit_id, $locale = '', $context = []) {
		$content = (string) $content;
		if ($content === '' || strpos($content, '{{') === false) {
			return $content;
		}
		$values = self::build_unit_placeholder_values($unit_id, $locale, $context);
		return (string) preg_replace_callback('/\{\{\s*([a-zA-Z0-9_]+)(?:\|([^}]*))?\s*\}\}/', static function($m) use ($values) {
			$key = strtolower((string) ($m[1] ?? ''));
			if (array_key_exists($key, $values) && $values[$key] !== '') {
				return (string) $values[$key];
			}
			return isset($m[2]) ? (string) $m[2] : '';
		}, $content);
	}

	private static function resolve_unit_content($unit_id, $field, $locale = '', $context = []) {
		$unit_id = (int) $unit_id;
		if (!VSTRB_DB::unit_exists($unit_id)) {
			return '';
		}
		$lang = self::normalize_content_language_code($locale);
		if ($lang === '') {
			$lang = self::normalize_content_language_code(get_locale());
		}
		$field = in_array($field, ['bank_transfer_instructions'], true) ? $field : 'bank_transfer_instructions';
		$meta_map = [
			'bank_transfer_instructions' => '_vstrb_bank_transfer_instructions_i18n',
		];
		$template_map = [
			'bank_transfer_instructions' => '_vstrb_bank_transfer_instructions_template_id',
		];

		$values = get_post_meta($unit_id, $meta_map[$field], true);
		if (is_array($values)) {
			if ($lang !== '' && !empty($values[$lang])) {
				return self::apply_unit_placeholders((string) $values[$lang], $unit_id, $lang, $context);
			}
			foreach ($values as $value) {
				if (trim(wp_strip_all_tags((string) $value)) !== '') {
					return self::apply_unit_placeholders((string) $value, $unit_id, $lang, $context);
				}
			}
		}

		$template_id = (string) get_post_meta($unit_id, $template_map[$field], true);
		$templates = get_option('vstrb_unit_content_templates', []);
		if ($template_id !== '' && is_array($templates) && isset($templates[$template_id]) && is_array($templates[$template_id])) {
			$template_values = $templates[$template_id][$field] ?? [];
			if (is_array($template_values)) {
				if ($lang !== '' && !empty($template_values[$lang])) {
					return self::apply_unit_placeholders((string) $template_values[$lang], $unit_id, $lang, $context);
				}
				foreach ($template_values as $value) {
					if (trim(wp_strip_all_tags((string) $value)) !== '') {
						return self::apply_unit_placeholders((string) $value, $unit_id, $lang, $context);
					}
				}
			}
		}

		return self::apply_unit_placeholders((string) get_option('vstrb_payment_bank_details', ''), $unit_id, $lang, $context);
	}

	private static function locale_matches($meta_locale, $requested_locale) {
		$meta = self::normalize_locale_code($meta_locale);
		$req = self::normalize_locale_code($requested_locale);
		if ($meta === '' || $req === '') {
			return false;
		}
		if ($meta === $req) {
			return true;
		}
		return (strpos($meta, $req . '_') === 0 || strpos($req, $meta . '_') === 0);
	}

	private static function parse_decimal_input($raw, $default = null) {
		if (is_array($raw)) {
			return $default;
		}
		$val = trim((string) $raw);
		if ($val === '') {
			return $default;
		}
		$val = str_replace(' ', '', $val);
		$val = str_replace(',', '.', $val);
		if (!is_numeric($val)) {
			return $default;
		}
		return (float) $val;
	}

	private static function sanitize_scalar_text($value, $default = '') {
		if (is_array($value) || is_object($value)) {
			return (string) $default;
		}
		return sanitize_text_field((string) $value);
	}

	private static function sanitize_scalar_email($value) {
		if (is_array($value) || is_object($value)) {
			return '';
		}
		$email = sanitize_email((string) $value);
		return is_email($email) ? $email : '';
	}

	private static function request_json_array($req) {
		$data = $req->get_json_params();
		return is_array($data) ? $data : [];
	}

	private static function sanitize_currency_code($value, $default = 'EUR') {
		$currency = strtoupper(self::sanitize_scalar_text($value, $default));
		if (!preg_match('/^[A-Z]{3}$/', $currency)) {
			return strtoupper((string) $default);
		}
		return $currency;
	}

	private static function sanitize_card_last4($value) {
		$value = preg_replace('/\D+/', '', (string) $value);
		return substr($value, -4);
	}

	private static function gateway_datetime_to_mysql($value) {
		if (is_numeric($value)) {
			return gmdate('Y-m-d H:i:s', (int) $value);
		}
		$value = self::sanitize_scalar_text($value);
		if ($value === '') {
			return null;
		}
		$timestamp = strtotime($value);
		return $timestamp ? gmdate('Y-m-d H:i:s', $timestamp) : null;
	}

	private static function sanitize_payment_gateway_details($details) {
		$details = is_array($details) ? $details : [];
		$out = [
			'gateway_status' => self::sanitize_scalar_text($details['gateway_status'] ?? ''),
			'gateway_payment_status' => self::sanitize_scalar_text($details['gateway_payment_status'] ?? ''),
			'payment_method_type' => self::sanitize_scalar_text($details['payment_method_type'] ?? ''),
			'card_brand' => self::sanitize_scalar_text($details['card_brand'] ?? ''),
			'card_last4' => self::sanitize_card_last4($details['card_last4'] ?? ''),
			'card_country' => strtoupper(self::sanitize_scalar_text($details['card_country'] ?? '')),
			'gateway_created_at' => self::gateway_datetime_to_mysql($details['gateway_created_at'] ?? null),
			'gateway_paid_at' => self::gateway_datetime_to_mysql($details['gateway_paid_at'] ?? null),
		];
		if (!preg_match('/^[A-Z]{2}$/', $out['card_country'])) {
			$out['card_country'] = '';
		}
		return $out;
	}

	private static function is_valid_ymd($value) {
		if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
			return false;
		}
		$dt = DateTime::createFromFormat('Y-m-d', $value);
		return $dt && $dt->format('Y-m-d') === $value;
	}

	private static function is_valid_ym($value) {
		if (!is_string($value) || !preg_match('/^\d{4}-\d{2}$/', $value)) {
			return false;
		}
		$dt = DateTime::createFromFormat('Y-m', $value);
		return $dt && $dt->format('Y-m') === $value;
	}

	private static function sanitize_rate_apply_mode($value) {
		$mode = self::sanitize_scalar_text($value, 'date_range');
		$allowed = ['always', 'date_range', 'months_of_year', 'weekdays', 'annual_dates', 'days_of_year'];
		return in_array($mode, $allowed, true) ? $mode : 'date_range';
	}

	private static function parse_rate_months_mask($mask) {
		$out = [];
		$mask = self::sanitize_scalar_text($mask, '');
		if ($mask === '') {
			return $out;
		}
		foreach (explode(',', $mask) as $month) {
			$m = (int) trim((string) $month);
			if ($m >= 1 && $m <= 12 && !in_array($m, $out, true)) {
				$out[] = $m;
			}
		}
		sort($out, SORT_NUMERIC);
		return $out;
	}

	private static function parse_rate_weekdays_mask($mask) {
		$out = [];
		$mask = self::sanitize_scalar_text($mask, '');
		if ($mask === '') {
			return $out;
		}
		foreach (explode(',', $mask) as $weekday) {
			$d = (int) trim((string) $weekday);
			if ($d >= 1 && $d <= 7 && !in_array($d, $out, true)) {
				$out[] = $d;
			}
		}
		sort($out, SORT_NUMERIC);
		return $out;
	}

	private static function parse_rate_annual_dates_mask($mask) {
		$out = [];
		$mask = self::sanitize_scalar_text($mask, '');
		if ($mask === '') {
			return $out;
		}
		foreach (explode(',', $mask) as $part) {
			$part = trim((string) $part);
			if (preg_match('/^\d{2}-\d{2}$/', $part)) {
				$out[] = ['from' => $part, 'to' => $part];
			} elseif (preg_match('/^(\d{2}-\d{2}):(\d{2}-\d{2})$/', $part, $m)) {
				$out[] = ['from' => $m[1], 'to' => $m[2]];
			}
		}
		return $out;
	}

	private static function annual_mmdd_matches_range($current, $from, $to) {
		if (!preg_match('/^\d{2}-\d{2}$/', (string) $current) || !preg_match('/^\d{2}-\d{2}$/', (string) $from) || !preg_match('/^\d{2}-\d{2}$/', (string) $to)) {
			return false;
		}
		if ($from <= $to) {
			return ($current >= $from && $current <= $to);
		}
		return ($current >= $from || $current <= $to);
	}

	private static function day_of_year_matches_range($current_day, $from_day, $to_day) {
		$day = (int) $current_day;
		$from = (int) $from_day;
		$to = (int) $to_day;
		if ($day < 1 || $day > 366 || $from < 1 || $from > 366 || $to < 1 || $to > 366) {
			return false;
		}
		// Wrapped ranges are supported (e.g. 350 -> 20).
		if ($from <= $to) {
			return ($day >= $from && $day <= $to);
		}
		return ($day >= $from || $day <= $to);
	}

	private static function rate_matches_temporal_scope($day, $rate) {
		$apply_mode = self::sanitize_rate_apply_mode($rate['apply_mode'] ?? 'date_range');
		if ($apply_mode === 'always') {
			return true;
		}
		if ($apply_mode === 'months_of_year') {
			$months = self::parse_rate_months_mask($rate['months_mask'] ?? '');
			if (!$months) {
				return false;
			}
			$month = (int) (new DateTime($day))->format('n');
			return in_array($month, $months, true);
		}
		if ($apply_mode === 'weekdays') {
			$weekdays = self::parse_rate_weekdays_mask($rate['months_mask'] ?? '');
			if (!$weekdays) {
				return false;
			}
			$dow = (int) (new DateTime($day))->format('N');
			return in_array($dow, $weekdays, true);
		}
		if ($apply_mode === 'annual_dates') {
			$patterns = self::parse_rate_annual_dates_mask($rate['months_mask'] ?? '');
			if (!$patterns) {
				return false;
			}
			$md = (new DateTime($day))->format('m-d');
			foreach ($patterns as $pattern) {
				if (self::annual_mmdd_matches_range($md, $pattern['from'], $pattern['to'])) {
					return true;
				}
			}
			return false;
		}
		if ($apply_mode === 'days_of_year') {
			$current_day = (int) (new DateTime($day))->format('z') + 1;
			$from_day = isset($rate['day_of_year_from']) ? (int) $rate['day_of_year_from'] : 0;
			$to_day = isset($rate['day_of_year_to']) ? (int) $rate['day_of_year_to'] : 0;
			return self::day_of_year_matches_range($current_day, $from_day, $to_day);
		}
		$date_from = (string) ($rate['date_from'] ?? '');
		$date_to = (string) ($rate['date_to'] ?? '');
		if ($date_from === '' || $date_to === '') {
			return false;
		}
		return !($day < $date_from || $day > $date_to);
	}

	private static function sanitize_payment_method($value, $default = 'cash') {
		$method = strtolower(self::sanitize_scalar_text($value, $default));
		$allowed = ['card', 'bank_transfer', 'cash', 'manual'];
		return in_array($method, $allowed, true) ? $method : $default;
	}

	private static function parse_booking_payment_choice($value) {
		$choice = strtolower(self::sanitize_scalar_text($value, 'cash'));
		$choice = str_replace('-', '_', $choice);
		$out = [
			'method' => 'cash',
			'provider' => '',
		];
		if (in_array($choice, ['card', 'card_stripe', 'stripe'], true)) {
			$out['method'] = 'card';
			$out['provider'] = 'stripe';
		} elseif (in_array($choice, ['card_mollie', 'mollie'], true)) {
			$out['method'] = 'card';
			$out['provider'] = 'mollie';
		} elseif ($choice === 'bank_transfer') {
			$out['method'] = 'bank_transfer';
			$out['provider'] = 'bank_transfer';
		} elseif ($choice === 'manual') {
			$out['method'] = 'manual';
			$out['provider'] = 'manual';
		}
		return $out;
	}

	private static function sanitize_invoice_prefix($prefix) {
		$prefix = strtoupper((string) $prefix);
		$prefix = preg_replace('/[^A-Z0-9\-_]/', '', $prefix);
		return trim((string) $prefix);
	}

	private static function sanitize_invoice_number_format($format) {
		$format = (string) $format;
		$format = str_replace(["\r", "\n", "\t"], '', $format);
		$format = preg_replace('/[^A-Za-z0-9\-\_\/\{\}\.\#]/', '', $format);
		$format = trim((string) $format);
		return $format !== '' ? $format : '{yyyy}{id}';
	}

	private static function build_booking_invoice_number($booking_id) {
		$prefix = self::sanitize_invoice_prefix((string) get_option('vstrb_invoice_prefix', 'INV-'));
		$format = self::sanitize_invoice_number_format((string) get_option('vstrb_invoice_number_format', '{yyyy}{id}'));
		$id = (int) $booking_id;
		$now = current_time('timestamp');

		$invoice_no = strtr($format, [
			'{prefix}' => $prefix,
			'{id}' => (string) $id,
			'{group_id}' => (string) $id,
			'{yyyy}' => date_i18n('Y', $now),
			'{yy}' => date_i18n('y', $now),
			'{mm}' => date_i18n('m', $now),
			'{dd}' => date_i18n('d', $now),
			'{date}' => date_i18n('Ymd', $now),
			'{date_dash}' => date_i18n('Y-m-d', $now),
		]);

		$invoice_no = trim((string) $invoice_no);
		if ($invoice_no === '') {
			$invoice_no = gmdate('Y') . $id;
		}
		return $invoice_no;
	}

	private static function booking_meta_upsert($booking_id, $meta_key, $meta_value) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$booking_id = (int) $booking_id;
		$meta_key = self::sanitize_scalar_text($meta_key);
		if ($booking_id <= 0 || $meta_key === '') {
			return false;
		}

		$existing_id = (int) $wpdb->get_var($wpdb->prepare(
			"SELECT id FROM {$t['booking_meta']} WHERE booking_id=%d AND meta_key=%s ORDER BY id ASC LIMIT 1",
			$booking_id,
			$meta_key
		));

		if ($existing_id > 0) {
			return false !== $wpdb->update(
				$t['booking_meta'],
				['meta_value' => (string) $meta_value],
				['id' => $existing_id],
				['%s'],
				['%d']
			);
		}

		return false !== $wpdb->insert(
			$t['booking_meta'],
			[
				'booking_id' => $booking_id,
				'meta_key' => $meta_key,
				'meta_value' => (string) $meta_value,
			],
			['%d', '%s', '%s']
		);
	}

	private static function booking_id_by_invoice_number($invoice_number) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$invoice_number = self::sanitize_scalar_text($invoice_number);
		if ($invoice_number === '') {
			return 0;
		}
		if (!empty($t['invoices'])) {
			$invoice_booking_id = (int) $wpdb->get_var($wpdb->prepare(
				"SELECT booking_id FROM {$t['invoices']} WHERE invoice_number=%s ORDER BY id DESC LIMIT 1",
				$invoice_number
			));
			if ($invoice_booking_id > 0) {
				return $invoice_booking_id;
			}
		}
		return (int) $wpdb->get_var($wpdb->prepare(
			"SELECT booking_id FROM {$t['booking_meta']} WHERE meta_key='invoice_number' AND meta_value=%s ORDER BY id DESC LIMIT 1",
			$invoice_number
		));
	}

	private static function booking_id_by_payment_reference($reference) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$reference = self::sanitize_scalar_text($reference);
		if ($reference === '') {
			return 0;
		}

		if (!empty($t['payments'])) {
			$payment_booking_id = (int) $wpdb->get_var($wpdb->prepare(
				"SELECT booking_id FROM {$t['payments']} WHERE provider_reference=%s OR gateway_session_id=%s ORDER BY id DESC LIMIT 1",
				$reference,
				$reference
			));
			if ($payment_booking_id > 0) {
				return $payment_booking_id;
			}
		}

		$meta_booking_id = (int) $wpdb->get_var($wpdb->prepare(
			"SELECT booking_id FROM {$t['booking_meta']} WHERE meta_key IN ('payment_reference','invoice_number') AND meta_value=%s ORDER BY id DESC LIMIT 1",
			$reference
		));
		if ($meta_booking_id > 0) {
			return $meta_booking_id;
		}

		return self::booking_id_by_invoice_number($reference);
	}

	private static function xml_node_to_array($node) {
		$out = [];
		foreach ($node->attributes() as $key => $value) {
			$out[(string) $key] = (string) $value;
		}
		$text = trim((string) $node);
		if ($text !== '' && count($node->children()) === 0) {
			$out['_text'] = $text;
			$out['value'] = $text;
		}
		foreach ($node->children() as $key => $child) {
			$value = self::xml_node_to_array($child);
			if ($value === []) {
				$value = trim((string) $child);
			}
			if (isset($out[$key])) {
				if (!is_array($out[$key]) || !array_key_exists(0, $out[$key])) {
					$out[$key] = [$out[$key]];
				}
				$out[$key][] = $value;
			} else {
				$out[$key] = $value;
			}
		}
		return $out;
	}

	private static function bank_notify_payload($req) {
		$data = self::request_json_array($req);
		if (!$data) {
			$data = $req->get_body_params();
			$data = is_array($data) ? $data : [];
		}

		if (!$data) {
			$body = trim((string) $req->get_body());
			if ($body !== '' && strpos($body, '<') === 0 && function_exists('simplexml_load_string')) {
				$xml = @simplexml_load_string($body);
				if ($xml) {
					$data = [(string) $xml->getName() => self::xml_node_to_array($xml)];
				}
			}
		}

		if (!$data) {
			$data = [];
		}

		$query = $req->get_query_params();
		if (is_array($query)) {
			$data['_query'] = $query;
		}
		return $data;
	}

	private static function bank_payload_flatten($value, $prefix = '') {
		$out = [];
		if (!is_array($value)) {
			return $out;
		}
		foreach ($value as $key => $item) {
			$key = is_int($key) ? (string) $key : (string) $key;
			$path = ($prefix === '') ? $key : $prefix . '.' . $key;
			if (is_array($item)) {
				if (isset($item['_text']) && is_scalar($item['_text'])) {
					$out[$path] = (string) $item['_text'];
				}
				$out += self::bank_payload_flatten($item, $path);
			} elseif (is_scalar($item) || $item === null) {
				$out[$path] = (string) $item;
			}
		}
		return $out;
	}

	private static function bank_payload_find_first($flat, $aliases) {
		$flat = is_array($flat) ? $flat : [];
		$aliases = array_map('strtolower', (array) $aliases);
		foreach ($flat as $path => $value) {
			$value = trim((string) $value);
			if ($value === '') {
				continue;
			}
			$normalized_path = strtolower(str_replace(['-', '_'], '', (string) $path));
			$parts = explode('.', $normalized_path);
			$last = end($parts);
			foreach ($aliases as $alias) {
				$normalized_alias = str_replace(['-', '_', '.'], '', $alias);
				if ($last === $normalized_alias || $normalized_path === $normalized_alias) {
					return $value;
				}
			}
		}
		return '';
	}

	private static function normalize_bank_notify_data($data, $req = null) {
		$flat = self::bank_payload_flatten(is_array($data) ? $data : []);
		$token = self::bank_payload_find_first($flat, [
			'token', 'webhook_token', 'webhookToken', 'secret', 'shared_secret', 'sharedSecret', 'api_key', 'apiKey',
		]);
		if ($token === '' && $req) {
			$token = self::sanitize_scalar_text($req->get_header('x-vstrb-token') ?: $req->get_header('x-webhook-token') ?: $req->get_header('authorization'));
			if (stripos($token, 'bearer ') === 0) {
				$token = trim(substr($token, 7));
			}
		}

		$reference = self::bank_payload_find_first($flat, [
			'reference', 'payment_reference', 'paymentReference', 'provider_reference', 'providerReference',
			'variable_symbol', 'variableSymbol', 'vs', 'variabilni_symbol', 'variabilniSymbol', 'ks', 'ss',
			'transaction_id', 'transactionId', 'transactionid', 'bank_reference', 'bankReference',
			'end_to_end_id', 'endToEndId', 'endtoendid', 'remittance_information', 'remittanceInformation',
			'ustrd', 'message', 'note', 'comment', 'user_identification', 'userIdentification',
		]);
		$invoice_number = self::bank_payload_find_first($flat, [
			'invoice_number', 'invoiceNumber', 'invoice_no', 'invoiceNo', 'invoice', 'bill_number', 'billNumber',
		]);
		$booking_id = (int) self::bank_payload_find_first($flat, [
			'booking_id', 'bookingId', 'reservation_id', 'reservationId', 'order_id', 'orderId',
		]);
		$amount = self::bank_payload_find_first($flat, [
			'amount', 'amount_value', 'amountValue', 'value', 'transaction_amount', 'transactionAmount',
			'amt', 'instdamt', 'txamt', 'castka', 'objem', 'sum',
		]);
		$currency = self::bank_payload_find_first($flat, [
			'currency', 'currency_code', 'currencyCode', 'ccy', 'mena',
		]);

		return [
			'token' => self::sanitize_scalar_text($token),
			'booking_id' => $booking_id,
			'invoice_number' => self::sanitize_scalar_text($invoice_number),
			'reference' => self::sanitize_scalar_text($reference),
			'amount' => self::parse_decimal_input($amount, null),
			'currency' => self::sanitize_currency_code($currency ?: 'EUR'),
			'raw_format' => self::sanitize_scalar_text(self::bank_payload_find_first($flat, ['format', 'source', 'provider', 'bank'])),
		];
	}

	private static function invoice_upsert_for_booking($booking_id, $invoice_number, $status, $total_amount, $currency, $billing = [], $paid_at = null) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$booking_id = (int) $booking_id;
		$invoice_number = self::sanitize_scalar_text($invoice_number);
		if ($booking_id <= 0 || $invoice_number === '' || empty($t['invoices'])) {
			return 0;
		}

		$status = self::sanitize_scalar_text($status, 'issued');
		if (!in_array($status, ['issued', 'paid', 'cancelled'], true)) {
			$status = 'issued';
		}
		$total_amount = self::parse_decimal_input($total_amount, 0);
		$currency = self::sanitize_currency_code($currency ?: 'EUR');
		$paid_at = $paid_at ? self::sanitize_scalar_text($paid_at) : null;
		$billing = is_array($billing) ? $billing : [];

		$data = [
			'booking_id' => $booking_id,
			'invoice_number' => $invoice_number,
			'status' => $status,
			'total_amount' => number_format((float) $total_amount, 2, '.', ''),
			'currency' => $currency,
			'billing_enabled' => !empty($billing['billing_enabled']) ? 1 : 0,
			'billing_name' => self::sanitize_scalar_text($billing['billing_name'] ?? ''),
			'billing_company_id' => self::sanitize_scalar_text($billing['billing_company_id'] ?? ''),
			'billing_vat_id' => self::sanitize_scalar_text($billing['billing_vat_id'] ?? ''),
			'billing_street' => self::sanitize_scalar_text($billing['billing_street'] ?? ''),
			'billing_city' => self::sanitize_scalar_text($billing['billing_city'] ?? ''),
			'billing_zip' => self::sanitize_scalar_text($billing['billing_zip'] ?? ''),
			'billing_country' => self::sanitize_scalar_text($billing['billing_country'] ?? ''),
		];
		if ($status === 'paid' && $paid_at) {
			$data['paid_at'] = $paid_at;
		}

		$existing_id = (int) $wpdb->get_var($wpdb->prepare(
			"SELECT id FROM {$t['invoices']} WHERE booking_id=%d ORDER BY id DESC LIMIT 1",
			$booking_id
		));

		if ($existing_id > 0) {
			$wpdb->update($t['invoices'], $data, ['id' => $existing_id]);
			return $existing_id;
		}

		$data['invoice_language'] = self::sanitize_scalar_text((string) get_option('vstrb_invoice_language', ''));
		$data['issued_at'] = current_time('mysql');
		$inserted = $wpdb->insert($t['invoices'], $data);
		return $inserted ? (int) $wpdb->insert_id : 0;
	}

	private static function payment_upsert_for_booking($booking_id, $invoice_id, $method, $provider, $status, $amount, $currency, $provider_reference = '', $gateway_session_id = '', $paid_at = null, $gateway_details = []) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$booking_id = (int) $booking_id;
		if ($booking_id <= 0 || empty($t['payments'])) {
			return 0;
		}
		$invoice_id = (int) $invoice_id;
		$method = self::sanitize_payment_method($method ?: 'manual', 'manual');
		if (!in_array($method, ['card', 'bank_transfer', 'cash', 'manual'], true)) {
			$method = 'manual';
		}
		$status = self::sanitize_scalar_text($status, 'pending');
		if (!in_array($status, ['pending', 'paid', 'failed', 'refunded', 'cancelled'], true)) {
			$status = 'pending';
		}
		$provider = self::sanitize_scalar_text($provider);
		$provider_reference = self::sanitize_scalar_text($provider_reference);
		$gateway_session_id = self::sanitize_scalar_text($gateway_session_id);
		$amount_value = self::parse_decimal_input($amount, null);
		$currency = self::sanitize_currency_code($currency ?: 'EUR');
		$paid_at = $paid_at ? self::sanitize_scalar_text($paid_at) : null;
		$gateway_details = self::sanitize_payment_gateway_details($gateway_details);

		$data = [
			'booking_id' => $booking_id,
			'invoice_id' => ($invoice_id > 0 ? $invoice_id : null),
			'method' => $method,
			'provider' => $provider,
			'status' => $status,
			'amount' => ($amount_value !== null ? number_format((float) $amount_value, 2, '.', '') : null),
			'currency' => $currency,
			'provider_reference' => $provider_reference,
			'gateway_session_id' => $gateway_session_id,
			'gateway_status' => $gateway_details['gateway_status'],
			'gateway_payment_status' => $gateway_details['gateway_payment_status'],
			'payment_method_type' => $gateway_details['payment_method_type'],
			'card_brand' => $gateway_details['card_brand'],
			'card_last4' => $gateway_details['card_last4'],
			'card_country' => $gateway_details['card_country'],
		];
		if ($gateway_details['gateway_created_at']) {
			$data['gateway_created_at'] = $gateway_details['gateway_created_at'];
		}
		if ($gateway_details['gateway_paid_at']) {
			$data['gateway_paid_at'] = $gateway_details['gateway_paid_at'];
		}
		if ($status === 'paid' && $paid_at) {
			$data['paid_at'] = $paid_at;
		}

		$existing_id = (int) $wpdb->get_var($wpdb->prepare(
			"SELECT id FROM {$t['payments']} WHERE booking_id=%d ORDER BY id DESC LIMIT 1",
			$booking_id
		));
		if ($existing_id > 0) {
			$wpdb->update($t['payments'], $data, ['id' => $existing_id]);
			return $existing_id;
		}

		$inserted = $wpdb->insert($t['payments'], $data);
		return $inserted ? (int) $wpdb->insert_id : 0;
	}

	private static function payment_set_gateway_session($booking_id, $gateway_session_id) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$booking_id = (int) $booking_id;
		$gateway_session_id = self::sanitize_scalar_text($gateway_session_id);
		if ($booking_id <= 0 || $gateway_session_id === '' || empty($t['payments'])) {
			return false;
		}
		return false !== $wpdb->query($wpdb->prepare(
			"UPDATE {$t['payments']} SET gateway_session_id=%s WHERE booking_id=%d",
			$gateway_session_id,
			$booking_id
		));
	}

	private static function mark_booking_as_paid($booking_id, $provider = '', $provider_reference = '', $amount = null, $currency = '', $gateway_details = []) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$booking_id = (int) $booking_id;
		if ($booking_id <= 0) {
			return false;
		}

		$provider = self::sanitize_scalar_text($provider);
		$provider_reference = self::sanitize_scalar_text($provider_reference);
		$currency = self::sanitize_currency_code($currency ?: 'EUR');
		$amount_value = self::parse_decimal_input($amount, null);
		$paid_at = current_time('mysql');

		$invoice_id = 0;
		$invoice_number = '';
		if (!empty($t['invoices'])) {
			$invoice_row = $wpdb->get_row($wpdb->prepare(
				"SELECT id, invoice_number, total_amount, currency, billing_enabled, billing_name, billing_company_id, billing_vat_id, billing_street, billing_city, billing_zip, billing_country
				 FROM {$t['invoices']}
				 WHERE booking_id=%d
				 ORDER BY id DESC
				 LIMIT 1",
				$booking_id
			), ARRAY_A);
			if ($invoice_row) {
				$invoice_id = (int) ($invoice_row['id'] ?? 0);
				$invoice_number = self::sanitize_scalar_text($invoice_row['invoice_number'] ?? '');
				self::invoice_upsert_for_booking(
					$booking_id,
					$invoice_number,
					'paid',
					$invoice_row['total_amount'] ?? $amount_value,
					$invoice_row['currency'] ?? $currency,
					[
						'billing_enabled' => !empty($invoice_row['billing_enabled']) ? 1 : 0,
						'billing_name' => $invoice_row['billing_name'] ?? '',
						'billing_company_id' => $invoice_row['billing_company_id'] ?? '',
						'billing_vat_id' => $invoice_row['billing_vat_id'] ?? '',
						'billing_street' => $invoice_row['billing_street'] ?? '',
						'billing_city' => $invoice_row['billing_city'] ?? '',
						'billing_zip' => $invoice_row['billing_zip'] ?? '',
						'billing_country' => $invoice_row['billing_country'] ?? '',
					],
					$paid_at
				);
			}
		}

		self::booking_meta_upsert($booking_id, 'payment_status', 'paid');
		self::booking_meta_upsert($booking_id, 'payment_paid_at', $paid_at);
		self::booking_meta_upsert($booking_id, 'invoice_status', 'paid');
		self::booking_meta_upsert($booking_id, 'invoice_paid_at', $paid_at);
		if ($provider !== '') {
			self::booking_meta_upsert($booking_id, 'payment_provider', $provider);
		}
		if ($provider_reference !== '') {
			self::booking_meta_upsert($booking_id, 'payment_reference', $provider_reference);
		}
		if ($amount_value !== null) {
			self::booking_meta_upsert($booking_id, 'payment_amount', number_format((float) $amount_value, 2, '.', ''));
			self::booking_meta_upsert($booking_id, 'payment_currency', $currency);
		}
		self::payment_upsert_for_booking(
			$booking_id,
			$invoice_id,
			(self::sanitize_payment_method(in_array($provider, ['stripe', 'mollie'], true) ? 'card' : ($provider === 'bank_transfer' ? 'bank_transfer' : ($provider === 'cash' ? 'cash' : 'manual')))),
			$provider,
			'paid',
			$amount_value,
			$currency,
			$provider_reference,
			'',
			$paid_at,
			$gateway_details
		);
		if ($invoice_number !== '') {
			self::booking_meta_upsert($booking_id, 'invoice_number', $invoice_number);
		}

		$autoconfirm = (int) get_option('vstrb_payment_autoconfirm_on_paid', 1);
		if ($autoconfirm === 1) {
			$wpdb->update($t['bookings'], ['status' => 'confirmed'], ['id' => $booking_id], ['%s'], ['%d']);
		}
		return true;
	}

	private static function get_request_referer_url($req) {
		$ref = self::sanitize_scalar_text($req->get_header('referer') ?? '');
		if ($ref !== '' && wp_http_validate_url($ref)) {
			return $ref;
		}
		return home_url('/');
	}

	private static function stripe_signature_is_valid($payload, $header, $secret) {
		$payload = (string) $payload;
		$header = (string) $header;
		$secret = (string) $secret;
		if ($payload === '' || $header === '' || $secret === '') {
			return false;
		}

		$parts = [];
		foreach (explode(',', $header) as $item) {
			$pair = array_map('trim', explode('=', $item, 2));
			if (count($pair) !== 2) {
				continue;
			}
			$parts[$pair[0]][] = $pair[1];
		}
		$timestamp = isset($parts['t'][0]) ? (int) $parts['t'][0] : 0;
		$signatures = $parts['v1'] ?? [];
		if ($timestamp <= 0 || empty($signatures)) {
			return false;
		}
		if (abs(time() - $timestamp) > 300) {
			return false;
		}
		$signed_payload = $timestamp . '.' . $payload;
		$expected = hash_hmac('sha256', $signed_payload, $secret);
		foreach ($signatures as $sig) {
			if (hash_equals($expected, (string) $sig)) {
				return true;
			}
		}
		return false;
	}

	private static function stripe_create_checkout_session($args) {
		$secret_key = trim((string) get_option('vstrb_payment_stripe_secret_key', ''));
		if ($secret_key === '') {
			return ['error' => 'stripe_not_configured'];
		}

		$booking_id = (int) ($args['booking_id'] ?? 0);
		$invoice_number = self::sanitize_scalar_text($args['invoice_number'] ?? '');
		$amount = self::parse_decimal_input($args['amount'] ?? null, null);
		$currency = strtolower(self::sanitize_currency_code($args['currency'] ?? 'EUR'));
		$customer_email = self::sanitize_scalar_email($args['customer_email'] ?? '');
		$success_url = esc_url_raw((string) ($args['success_url'] ?? ''));
		$cancel_url = esc_url_raw((string) ($args['cancel_url'] ?? ''));

		if ($booking_id <= 0 || $amount === null || $amount <= 0 || $success_url === '' || $cancel_url === '') {
			return ['error' => 'invalid_stripe_args'];
		}

		$unit_amount = (int) round(((float) $amount) * 100);
		if ($unit_amount <= 0) {
			return ['error' => 'invalid_amount'];
		}

		$body = [
			'mode' => 'payment',
			'success_url' => $success_url,
			'cancel_url' => $cancel_url,
			'line_items[0][quantity]' => 1,
			'line_items[0][price_data][currency]' => $currency,
			'line_items[0][price_data][unit_amount]' => $unit_amount,
			'line_items[0][price_data][product_data][name]' => 'Booking #' . $booking_id,
			'metadata[booking_id]' => (string) $booking_id,
			'metadata[invoice_number]' => $invoice_number,
		];
		if ($customer_email !== '') {
			$body['customer_email'] = $customer_email;
		}

		$response = wp_remote_post('https://api.stripe.com/v1/checkout/sessions', [
			'timeout' => 20,
			'headers' => [
				'Authorization' => 'Bearer ' . $secret_key,
			],
			'body' => $body,
		]);

		if (is_wp_error($response)) {
			return ['error' => 'stripe_request_failed'];
		}
		$code = (int) wp_remote_retrieve_response_code($response);
		$raw_body = wp_remote_retrieve_body($response);
		$parsed = json_decode((string) $raw_body, true);
		if (!is_array($parsed)) {
			$parsed = [];
		}
		if ($code < 200 || $code >= 300) {
			return ['error' => 'stripe_error', 'status' => $code, 'detail' => self::sanitize_scalar_text($parsed['error']['message'] ?? '')];
		}

		return [
			'id' => self::sanitize_scalar_text($parsed['id'] ?? ''),
			'url' => esc_url_raw((string) ($parsed['url'] ?? '')),
			'status' => self::sanitize_scalar_text($parsed['status'] ?? ''),
			'payment_status' => self::sanitize_scalar_text($parsed['payment_status'] ?? ''),
			'payment_method_types' => isset($parsed['payment_method_types']) && is_array($parsed['payment_method_types']) ? $parsed['payment_method_types'] : [],
			'created' => $parsed['created'] ?? null,
		];
	}

	private static function stripe_payment_gateway_details($session) {
		$session = is_array($session) ? $session : [];
		$method_type = '';
		if (isset($session['payment_method_types']) && is_array($session['payment_method_types'])) {
			$payment_method_types = $session['payment_method_types'];
			$method_type = self::sanitize_scalar_text(reset($payment_method_types));
		}
		return self::sanitize_payment_gateway_details([
			'gateway_status' => $session['status'] ?? '',
			'gateway_payment_status' => $session['payment_status'] ?? '',
			'payment_method_type' => $method_type,
			'gateway_created_at' => $session['created'] ?? null,
		]);
	}

	private static function mollie_api_request($method, $endpoint, $body = null) {
		$api_key = trim((string) get_option('vstrb_payment_mollie_api_key', ''));
		if ($api_key === '') {
			return ['error' => 'mollie_not_configured'];
		}

		$url = 'https://api.mollie.com/v2/' . ltrim((string) $endpoint, '/');
		$args = [
			'timeout' => 20,
			'headers' => [
				'Authorization' => 'Bearer ' . $api_key,
				'Accept' => 'application/json',
			],
			'method' => strtoupper((string) $method),
		];
		if ($body !== null) {
			$args['headers']['Content-Type'] = 'application/json';
			$args['body'] = wp_json_encode($body);
		}

		$response = wp_remote_request($url, $args);
		if (is_wp_error($response)) {
			return ['error' => 'mollie_request_failed'];
		}
		$code = (int) wp_remote_retrieve_response_code($response);
		$raw_body = wp_remote_retrieve_body($response);
		$parsed = json_decode((string) $raw_body, true);
		if (!is_array($parsed)) {
			$parsed = [];
		}
		if ($code < 200 || $code >= 300) {
			return [
				'error' => 'mollie_error',
				'status' => $code,
				'detail' => self::sanitize_scalar_text($parsed['detail'] ?? ($parsed['title'] ?? '')),
			];
		}
		return $parsed;
	}

	private static function mollie_create_payment($args) {
		$booking_id = (int) ($args['booking_id'] ?? 0);
		$invoice_number = self::sanitize_scalar_text($args['invoice_number'] ?? '');
		$amount = self::parse_decimal_input($args['amount'] ?? null, null);
		$currency = self::sanitize_currency_code($args['currency'] ?? 'EUR');
		$redirect_url = esc_url_raw((string) ($args['redirect_url'] ?? ''));
		$webhook_url = esc_url_raw((string) ($args['webhook_url'] ?? ''));

		if ($booking_id <= 0 || $amount === null || $amount <= 0 || $redirect_url === '' || $webhook_url === '') {
			return ['error' => 'invalid_mollie_args'];
		}

		$payment = self::mollie_api_request('POST', 'payments', [
			'amount' => [
				'currency' => $currency,
				'value' => number_format((float) $amount, 2, '.', ''),
			],
			'description' => 'Booking #' . $booking_id,
			'redirectUrl' => $redirect_url,
			'webhookUrl' => $webhook_url,
			'metadata' => [
				'booking_id' => (string) $booking_id,
				'invoice_number' => $invoice_number,
			],
		]);

		if (!empty($payment['error'])) {
			return $payment;
		}
		return [
			'id' => self::sanitize_scalar_text($payment['id'] ?? ''),
			'url' => esc_url_raw((string) ($payment['_links']['checkout']['href'] ?? '')),
			'status' => self::sanitize_scalar_text($payment['status'] ?? ''),
		];
	}

	private static function mollie_payment_gateway_details($payment) {
		$payment = is_array($payment) ? $payment : [];
		$details = (isset($payment['details']) && is_array($payment['details'])) ? $payment['details'] : [];
		return self::sanitize_payment_gateway_details([
			'gateway_status' => $payment['status'] ?? '',
			'gateway_payment_status' => $payment['status'] ?? '',
			'payment_method_type' => $payment['method'] ?? '',
			'card_brand' => $details['cardLabel'] ?? ($details['cardAudience'] ?? ''),
			'card_last4' => $details['cardNumber'] ?? '',
			'card_country' => $details['cardCountryCode'] ?? '',
			'gateway_created_at' => $payment['createdAt'] ?? null,
			'gateway_paid_at' => $payment['paidAt'] ?? null,
		]);
	}

	private static function booking_id_by_gateway_reference($reference) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$reference = self::sanitize_scalar_text($reference);
		if ($reference === '' || empty($t['payments'])) {
			return 0;
		}
		return (int) $wpdb->get_var($wpdb->prepare(
			"SELECT booking_id FROM {$t['payments']} WHERE provider_reference=%s OR gateway_session_id=%s ORDER BY id DESC LIMIT 1",
			$reference,
			$reference
		));
	}

	private static function get_currency_rates_map($base_currency = 'EUR') {
		$base = strtoupper(trim((string) $base_currency));
		if ($base === '') {
			$base = 'EUR';
		}

		$rates = [$base => 1.0];
		$raw = (string) get_option('vstrb_currency_rates', '');
		$pairs = array_map('trim', explode(',', strtoupper($raw)));
		foreach ($pairs as $pair) {
			if ($pair === '' || strpos($pair, '=') === false) {
				continue;
			}
			[$code, $val] = array_map('trim', explode('=', $pair, 2));
			$code = preg_replace('/[^A-Z]/', '', $code);
			if (strlen($code) !== 3) {
				continue;
			}
			$rate = self::parse_decimal_input($val, null);
			if ($rate === null || $rate <= 0) {
				continue;
			}
			$rates[$code] = (float) $rate;
		}
		$rates[$base] = 1.0;
		return $rates;
	}

	private static function convert_price_by_rate($amount, $from_currency, $to_currency, $rates) {
		$amt = self::parse_decimal_input($amount, null);
		$from = strtoupper(trim((string) $from_currency));
		$to = strtoupper(trim((string) $to_currency));
		if ($amt === null || $from === '' || $to === '') {
			return null;
		}
		if ($from === $to) {
			return round((float) $amt, 2);
		}
		$from_rate = isset($rates[$from]) ? (float) $rates[$from] : 0.0;
		$to_rate = isset($rates[$to]) ? (float) $rates[$to] : 0.0;
		if ($from_rate <= 0 || $to_rate <= 0) {
			return null;
		}
		return round((((float) $amt / $from_rate) * $to_rate), 2);
	}

	private static function service_price_from_meta_rows($meta_rows, $target_currency, $global_price = 0) {
		$target = strtoupper(trim((string) $target_currency));
		if ($target === '') {
			$target = 'EUR';
		}

		$auto_fx = false;
		$base_currency = strtoupper(trim((string) get_option('vstrb_base_currency', 'EUR')));
		if ($base_currency === '') {
			$base_currency = 'EUR';
		}
		$base_amount = null;
		$target_key = 'price_' . strtolower($target);

		foreach ($meta_rows as $mr) {
			$key = strtolower(trim((string) ($mr['meta_key'] ?? '')));
			$value = $mr['meta_value'] ?? null;

			if ($key === $target_key) {
				$explicit = self::parse_decimal_input($value, null);
				if ($explicit !== null) {
					return (float) $explicit;
				}
			}
			if ($key === 'price_auto_fx') {
				$auto_fx = in_array(strtolower(trim((string) $value)), ['1', 'yes', 'true', 'on'], true);
				continue;
			}
			if ($key === 'price_base_currency') {
				$candidate = strtoupper(trim((string) $value));
				if (strlen($candidate) === 3) {
					$base_currency = $candidate;
				}
				continue;
			}
			if ($key === 'price_base_amount') {
				$base_candidate = self::parse_decimal_input($value, null);
				if ($base_candidate !== null) {
					$base_amount = (float) $base_candidate;
				}
			}
		}

		if (!$auto_fx) {
			return null;
		}

		if ($base_amount === null) {
			$base_key = 'price_' . strtolower($base_currency);
			foreach ($meta_rows as $mr) {
				$key = strtolower(trim((string) ($mr['meta_key'] ?? '')));
				if ($key !== $base_key) {
					continue;
				}
				$base_candidate = self::parse_decimal_input($mr['meta_value'] ?? null, null);
				if ($base_candidate !== null) {
					$base_amount = (float) $base_candidate;
					break;
				}
			}
		}

		if ($base_amount === null) {
			$base_amount = self::parse_decimal_input($global_price, null);
		}

		if ($base_amount === null) {
			return null;
		}

		$rates = self::get_currency_rates_map($base_currency);
		$converted = self::convert_price_by_rate($base_amount, $base_currency, $target, $rates);
		return ($converted !== null) ? (float) $converted : null;
	}

	private static function services_cache_version() {
		$v = wp_cache_get('vstrb_v_services', 'vstrb_services');
		if (false === $v) {
			$v = 1;
			wp_cache_set('vstrb_v_services', $v, 'vstrb_services', 3600);
		}
		return (int) $v;
	}

	public static function serve_raw_ical_export($served, $result, $request, $server) {
		if (!($result instanceof WP_REST_Response) || !($request instanceof WP_REST_Request)) {
			return $served;
		}

		$route = (string) $request->get_route();
		if (strpos($route, '/vstrb/v1/ical/export') !== 0) {
			return $served;
		}

		$headers = $result->get_headers();
		$content_type = isset($headers['Content-Type']) ? strtolower((string) $headers['Content-Type']) : '';
		if (strpos($content_type, 'text/calendar') === false) {
			return $served;
		}

		$data = $result->get_data();
		if (!is_string($data)) {
			return $served;
		}

		status_header((int) $result->get_status());
		foreach ($headers as $name => $value) {
			$server->send_header($name, $value);
		}

		echo $data; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw ICS payload required.
		return true;
	}

	public static function register() {
		register_rest_route('vstrb/v1', '/calendar', [
			'methods' => 'GET',
			'callback' => [__CLASS__, 'calendar'],
			'permission_callback' => '__return_true',
			'args' => [
				'unit_id' => ['required'=>true],
				'month' => ['required'=>true], // YYYY-MM
				'months' => ['required'=>false, 'default'=>1],
			]
		]);

		register_rest_route('vstrb/v1', '/services', [
			'methods' => 'GET',
			'callback' => [__CLASS__, 'services'],
			'permission_callback' => '__return_true',
			'args' => [
				'unit_id' => ['required'=>true],
				'currency' => ['required'=>false, 'default'=>'EUR'],
				'locale' => ['required'=>false],
			]
		]);

		register_rest_route('vstrb/v1', '/quote', [
			'methods' => 'POST',
			'callback' => [__CLASS__, 'quote'],
			'permission_callback' => '__return_true',
		]);

		register_rest_route('vstrb/v1', '/book', [
			'methods' => 'POST',
			'callback' => [__CLASS__, 'book'],
			// Public booking allowed, but still requires basic validation.
			'permission_callback' => '__return_true',
		]);

		register_rest_route('vstrb/v1', '/payment/stripe/webhook', [
			'methods' => 'POST',
			'callback' => [__CLASS__, 'stripe_webhook'],
			'permission_callback' => '__return_true',
		]);

		register_rest_route('vstrb/v1', '/payment/mollie/webhook', [
			'methods' => ['GET', 'POST'],
			'callback' => [__CLASS__, 'mollie_webhook'],
			'permission_callback' => '__return_true',
		]);

		register_rest_route('vstrb/v1', '/payment/mark-paid', [
			'methods' => 'POST',
			'callback' => [__CLASS__, 'mark_paid'],
			'permission_callback' => function() {
				return current_user_can('manage_options');
			},
		]);

		register_rest_route('vstrb/v1', '/payment/bank-notify', [
			'methods' => 'POST',
			'callback' => [__CLASS__, 'bank_notify'],
			'permission_callback' => '__return_true',
		]);

		register_rest_route('vstrb/v1', '/ical/export', [
			'methods' => 'GET',
			'callback' => ['VSTRB_iCal', 'export'],
			'permission_callback' => '__return_true',
		]);

		register_rest_route('vstrb/v1', '/ical/export.ics', [
			'methods' => 'GET',
			'callback' => ['VSTRB_iCal', 'export'],
			'permission_callback' => '__return_true',
		]);

		register_rest_route('vstrb/v1', '/ical/export/(?P<unit_id>\d+)/(?P<token>[a-zA-Z0-9]+)\.ics', [
			'methods' => 'GET',
			'callback' => ['VSTRB_iCal', 'export'],
			'permission_callback' => '__return_true',
		]);

		register_rest_route('vstrb/v1', '/ical/export/direct/(?P<unit_id>\d+)/(?P<token>[a-zA-Z0-9]+)\.ics', [
			'methods' => 'GET',
			'callback' => ['VSTRB_iCal', 'export'],
			'permission_callback' => '__return_true',
		]);

		register_rest_route('vstrb/v1', '/contact-fields', [
			'methods' => 'GET',
			'callback' => [__CLASS__, 'contact_fields'],
			'permission_callback' => '__return_true',
			'args' => [
				'locale' => ['required'=>false],
			]
		]);

		register_rest_route('vstrb/v1', '/guest-register', [
			'methods' => 'POST',
			'callback' => [__CLASS__, 'guest_register'],
			'permission_callback' => '__return_true',
		]);
	}

	public static function calendar($req) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$unit_id = (int)$req->get_param('unit_id');
		$month = self::sanitize_scalar_text($req->get_param('month'));
		$months = max(1, min(12, (int)$req->get_param('months'))); // limit to 1-12 months

		if (!VSTRB_DB::unit_exists($unit_id)) {
			return new WP_REST_Response(['error'=>'unit_not_found'], 404);
		}
		if (get_post_meta($unit_id, '_vstrb_is_paused', true)) {
			return new WP_REST_Response(['error'=>'unit_paused'], 400);
		}

		if (!self::is_valid_ym($month)) {
			return new WP_REST_Response(['error'=>'invalid_month'], 400);
		}

		$from = $month . '-01';
		$dt = new DateTime($from);
		$dt_end = clone $dt;
		$dt_end->modify('+' . $months . ' months');

		$to = $dt_end->format('Y-m-d');

		// Get occupied days
		$v = wp_cache_get('vstrb_v_' . $unit_id, 'vstrb_calendar');
		if (false === $v) {
			$v = 1;
			wp_cache_set('vstrb_v_' . $unit_id, $v, 'vstrb_calendar', 3600);
		}

		$cache_key = 'vstrb_occupied_' . $v . '_' . md5($unit_id . $from . $to);
		$rows = wp_cache_get($cache_key, 'vstrb_calendar');

		if (false === $rows) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$rows = $wpdb->get_results($wpdb->prepare(
				"SELECT day, booking_id FROM {$t['booking_days']} WHERE unit_id=%d AND day >= %s AND day < %s",
				$unit_id, $from, $to
			), ARRAY_A);
			wp_cache_set($cache_key, $rows, 'vstrb_calendar', 3600);
		}

		$occupied = [];
		foreach ($rows as $r) {
			$occupied[$r['day']] = (int)$r['booking_id'];
		}
		$closed_days = self::get_closed_days($unit_id, $from, $to);
		$closed_lookup = array_fill_keys($closed_days, true);

		// For nicer UI: get booking ranges overlapping this month
		$cache_key_ranges = 'vstrb_ranges_' . $v . '_' . md5($unit_id . $from . $to);
		$ranges = wp_cache_get($cache_key_ranges, 'vstrb_calendar');

		if (false === $ranges) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$ranges = $wpdb->get_results($wpdb->prepare(
				"SELECT id, date_from, date_to, status FROM {$t['bookings']}
				 WHERE unit_id=%d AND status IN ('pending','confirmed','blocked')
				 AND NOT (date_to <= %s OR date_from >= %s)",
				$unit_id, $from, $to
			), ARRAY_A);
			wp_cache_set($cache_key_ranges, $ranges, 'vstrb_calendar', 3600);
		}

		$cal_price_mode = self::get_calendar_price_mode($unit_id);

		// Modes where per-day prices in calendar cells make no sense (total depends on stay length)
		$price_mode = $cal_price_mode !== 'off'
			? (get_post_meta($unit_id, '_vstrb_price_mode', true) ?: 'per_unit_night')
			: '';
		$per_day_modes = ['per_unit_night', 'per_person_night'];
		$show_day_prices = $cal_price_mode !== 'off' && in_array($price_mode, $per_day_modes, true);

		$day_prices = [];
		$day_price_kinds = [];
		$min_price = null;
		$cal_currency = 'EUR';
		if ($cal_price_mode !== 'off') {
			$cal_currency = self::sanitize_currency_code($req->get_param('currency') ?? 'EUR');
			$guests_for_price = 1;

			if ($show_day_prices) {
				// Per-day modes: iterate free days to build cell prices and find minimum
				$dt_iter = new DateTime($from);
				$dt_end_iter = new DateTime($to);
				while ($dt_iter < $dt_end_iter) {
					$day_str = $dt_iter->format('Y-m-d');
					if (!isset($occupied[$day_str]) && empty($closed_lookup[$day_str])) {
						// For calendar price preview, ignore min_nights so seasonal override prices are visible per-day.
						$info = self::day_price($unit_id, $day_str, $guests_for_price, $cal_currency, false, true);
						$info_cal = self::day_price($unit_id, $day_str, $guests_for_price, $cal_currency, true);
						$diff = round($info_cal['surcharge'] - $info_cal['discount'], 2);
						if (!empty($info['applied_highlight'])) {
							if ($diff < 0) {
								$day_price_kinds[$day_str] = 'disc';
							} elseif ($diff > 0) {
								$day_price_kinds[$day_str] = 'surch';
							}
						}
						if ($cal_price_mode === 'price' && $info['base'] > 0) {
							$day_prices[$day_str] = round($info['base'], 2);
						} elseif ($cal_price_mode === 'diff') {
							if ($diff != 0) {
								$base_for_pct = (float) ($info_cal['base'] ?? 0);
								if ($base_for_pct > 0) {
									$day_prices[$day_str] = round(($diff / $base_for_pct) * 100, 1);
								} else {
									$day_prices[$day_str] = $diff;
								}
							}
						}
						if ($info['base'] > 0 && ($min_price === null || $info['base'] < $min_price)) {
							$min_price = $info['base'];
						}
					}
					$dt_iter->modify('+1 day');
				}
			} else {
				// Non-per-day modes (per_stay, per_night, per_person): read base price directly
				$get_meta = function($key) use ($unit_id, $cal_currency) {
					$val = get_post_meta($unit_id, $key . '_' . $cal_currency, true);
					if ($val === '' && $cal_currency !== 'EUR') $val = get_post_meta($unit_id, $key . '_EUR', true);
					if ($val === '') $val = get_post_meta($unit_id, $key, true);
					return (float)$val;
				};
				switch ($price_mode) {
					case 'per_stay':   $raw = $get_meta('_vstrb_price_per_stay');   break;
					case 'per_night':  $raw = $get_meta('_vstrb_price_per_night');  break;
					case 'per_person': $raw = $get_meta('_vstrb_price_per_person'); break;
					default:           $raw = 0;
				}
				if ($raw > 0) {
					$min_price = $raw;
				}
			}
		}

		return new WP_REST_Response([
			'month' => $month,
			'from' => $from,
			'to' => $to,
			'occupiedDays' => array_keys($occupied),
			'closedDays' => $closed_days,
			'ranges' => $ranges,
			'calPriceMode' => $cal_price_mode,
			'dayPrices' => $day_prices,
			'dayPriceKinds' => $day_price_kinds,
			'currency' => $cal_currency,
			'priceMode' => $price_mode,
			'minPrice' => $min_price !== null ? round($min_price, 2) : null,
		], 200);
	}

	public static function services($req) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$unit_id = (int)$req->get_param('unit_id');
		if (!VSTRB_DB::unit_exists($unit_id)) return new WP_REST_Response(['error'=>'unit_not_found'], 404);

		$currency = self::sanitize_currency_code($req->get_param('currency') ?? 'EUR');
		
		// Fetch all services enabled for this unit (any currency)
		$cache_v = self::services_cache_version();
		$cache_key = 'vstrb_services_' . $cache_v . '_' . md5($unit_id . $currency);
		$raw_items = wp_cache_get($cache_key, 'vstrb_services');

		if (false === $raw_items) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$raw_items = $wpdb->get_results($wpdb->prepare(
				"SELECT s.id, s.name, s.code, 
					   s.pricing_type as global_pricing_type,
					   s.price as global_price,
					   s.description,
					   us.price_override,
					   us.pricing_type as override_pricing_type,
					   us.is_optional,
					   us.currency
				FROM {$t['services']} s
				INNER JOIN {$t['unit_services']} us ON us.service_id=s.id
				WHERE us.unit_id=%d AND s.is_active=1
				ORDER BY s.sort_order ASC, s.name ASC, (us.currency = %s) DESC, (us.currency = 'EUR') DESC",
				$unit_id, $currency
			), ARRAY_A);
			wp_cache_set($cache_key, $raw_items, 'vstrb_services', 3600);
		}
		$req_locale = self::sanitize_scalar_text($req->get_param('locale') ?? '');
		$svc_switched = false;
		if ($req_locale && $req_locale !== substr(get_locale(), 0, 2)) {
			$resolved_locale = self::resolve_locale($req_locale);
			$svc_switched = switch_to_locale($resolved_locale);
			if ($svc_switched) {
				self::load_textdomain_for_locale($resolved_locale);
			}
		}
		$items = [];
		$seen = [];
		$service_ids = [];
		foreach ($raw_items as $row) {
			$sid = (int) $row['id'];
			if (!isset($seen[$sid])) {
				$seen[$sid] = true;
				$service_ids[] = $sid;
			}
		}

		$service_price_map = [];
		$service_meta_map = [];
		if (!empty($service_ids)) {
			$ids_placeholders = implode(', ', array_fill(0, count($service_ids), '%d'));
			$bulk_cache_key = 'vstrb_services_bulk_' . $cache_v . '_' . md5(implode(',', $service_ids) . '|' . $currency . '|' . $req_locale);
			$bulk_cache = wp_cache_get($bulk_cache_key, 'vstrb_services');
			if (is_array($bulk_cache) && isset($bulk_cache['prices'], $bulk_cache['meta'])) {
				$service_price_map = $bulk_cache['prices'];
				$service_meta_map = $bulk_cache['meta'];
			} else {
				$price_rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT service_id, meta_value
						 FROM {$t['service_meta']}
						 WHERE meta_key=%s AND service_id IN ({$ids_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
						...array_merge(array('price_' . $currency), $service_ids)
					),
					ARRAY_A
				);
				foreach ($price_rows as $price_row) {
					$service_price_map[(int) $price_row['service_id']] = $price_row['meta_value'];
				}

				$meta_rows_all = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT service_id, meta_key, meta_value, locale
						 FROM {$t['service_meta']}
						 WHERE service_id IN ({$ids_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
						...$service_ids
					),
					ARRAY_A
				);
				foreach ($meta_rows_all as $meta_row) {
					$sid = (int) $meta_row['service_id'];
					if (!isset($service_meta_map[$sid])) {
						$service_meta_map[$sid] = [];
					}
					$service_meta_map[$sid][] = $meta_row;
				}

				wp_cache_set(
					$bulk_cache_key,
					[
						'prices' => $service_price_map,
						'meta' => $service_meta_map,
					],
					'vstrb_services',
					3600
				);
			}
		}
		$seen = [];

		foreach ($raw_items as $row) {
			$sid = $row['id'];
			if (isset($seen[$sid])) continue;
			$seen[$sid] = true;

				$item = [
					'id' => (int) $row['id'],
					'name' => self::sanitize_scalar_text($row['name']),
					'code' => self::sanitize_scalar_text($row['code']),
					'description' => sanitize_textarea_field((string) ($row['description'] ?? '')),
					'pricing_type' => self::sanitize_scalar_text($row['override_pricing_type'] ?: $row['global_pricing_type']),
					'is_optional' => (int) $row['is_optional'],
				];

			$meta_rows = $service_meta_map[(int) $item['id']] ?? [];

			// Determine price: unit_override (for this curr) > explicit meta(price_CUR) > auto FX from base > global_price
			// Since our query orders by matching currency first, we can check if this row IS the matching currency.
			if ($row['currency'] === $currency && !is_null($row['price_override'])) {
				$item['price'] = (float)$row['price_override'];
			} else {
				$meta_price = $service_price_map[(int) $row['id']] ?? null;
				if (is_null($meta_price)) {
					$meta_price = self::service_price_from_meta_rows($meta_rows, $currency, $row['global_price']);
				}
				$item['price'] = !is_null($meta_price) ? (float)$meta_price : (float)$row['global_price'];
			}

			$meta = [];
			$translated_name_local = null;
			$translated_name_global = null;
			$translated_desc_local = null;
			$translated_desc_global = null;
			foreach ($meta_rows as $mr) {
				$meta_key_raw = (string) ($mr['meta_key'] ?? '');
				$meta_key = strtolower(trim($meta_key_raw));
				$meta_value = (string) ($mr['meta_value'] ?? '');
				$meta_locale = (string) ($mr['locale'] ?? '');

				if (strpos($meta_key, 'price_') === 0) continue;

				if (in_array($meta_key, ['label', 'name'], true)) {
					if (self::locale_matches($meta_locale, $req_locale) && $translated_name_local === null) {
						$translated_name_local = $meta_value;
					} elseif (self::normalize_locale_code($meta_locale) === '' && $translated_name_global === null) {
						$translated_name_global = $meta_value;
					}
					continue;
				}

				if (in_array($meta_key, ['description', 'desc'], true)) {
					if (self::locale_matches($meta_locale, $req_locale) && $translated_desc_local === null) {
						$translated_desc_local = $meta_value;
					} elseif (self::normalize_locale_code($meta_locale) === '' && $translated_desc_global === null) {
						$translated_desc_global = $meta_value;
					}
					continue;
				}

				$meta[] = [
					'key' => $meta_key_raw,
					'value' => $meta_value,
					'locale' => $meta_locale
				];
			}
			if ($translated_name_local !== null) {
					$item['name'] = self::sanitize_scalar_text($translated_name_local);
				} elseif ($translated_name_global !== null) {
					$item['name'] = self::sanitize_scalar_text($translated_name_global);
				} else {
					// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- service name is user-defined data stored in DB, not a static string literal
				$item['name'] = self::sanitize_scalar_text(__($row['name'], 'vachr-short-term-rental-reservations'));
				}
			if ($translated_desc_local !== null) {
				$item['description'] = sanitize_textarea_field($translated_desc_local);
			} elseif ($translated_desc_global !== null) {
				$item['description'] = sanitize_textarea_field($translated_desc_global);
			} elseif (!empty($row['description'])) {
					// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- service description is user-defined data stored in DB, not a static string literal
				$item['description'] = sanitize_textarea_field(__($row['description'], 'vachr-short-term-rental-reservations'));
			}
			$item['meta_data'] = $meta;
			$items[] = $item;
		}

		if ($svc_switched) {
			restore_current_locale();
			self::load_textdomain_for_locale(function_exists('determine_locale') ? determine_locale() : get_locale());
		}
		return new WP_REST_Response(['services' => $items], 200);
	}

	public static function contact_fields($req) {
		global $wpdb;
		$t = VSTRB_DB::tables();
		$locale = self::sanitize_scalar_text($req->get_param('locale') ?? '');
		$unit_id = (int)$req->get_param('unit_id');

		if ($unit_id > 0) {
			$sql = $wpdb->prepare(
				"SELECT f.id, f.field_key 
				 FROM {$t['contact_fields']} f
				 INNER JOIN {$t['unit_contact_fields']} uf ON uf.field_id = f.id
				 WHERE f.is_active=1 AND uf.unit_id=%d 
				 ORDER BY f.sort_order ASC",
				$unit_id
			);
		} else {
			$sql = "SELECT id, field_key FROM {$t['contact_fields']} WHERE is_active=1 ORDER BY sort_order ASC";
		}
		
		$cache_key = 'vstrb_contact_fields_' . md5($sql);
		$fields = wp_cache_get($cache_key, 'vstrb_contact_fields');

		if (false === $fields) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
			$fields = $wpdb->get_results($sql, ARRAY_A);
			wp_cache_set($cache_key, $fields, 'vstrb_contact_fields', 3600);
		}
		
		$res = [];
		$field_ids = array_values(array_unique(array_map('intval', array_column($fields, 'id'))));
		$label_locale_map = [];
		$label_global_map = [];
		if (!empty($field_ids)) {
			$field_ids_placeholders = implode(', ', array_fill(0, count($field_ids), '%d'));
			$labels_cache_key = 'vstrb_cf_labels_' . md5(implode(',', $field_ids) . '|' . $locale);
			$labels_cache = wp_cache_get($labels_cache_key, 'vstrb_contact_fields');
			if (is_array($labels_cache) && isset($labels_cache['locale'], $labels_cache['global'])) {
				$label_locale_map = $labels_cache['locale'];
				$label_global_map = $labels_cache['global'];
			} else {
				if ($locale !== '') {
					$label_locale_rows = $wpdb->get_results(
						$wpdb->prepare(
							"SELECT field_id, meta_value
							 FROM {$t['contact_field_meta']}
							 WHERE meta_key='label' AND locale=%s AND field_id IN ({$field_ids_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
							...array_merge(array($locale), $field_ids)
						),
						ARRAY_A
					);
					foreach ($label_locale_rows as $lr) {
						$label_locale_map[(int) $lr['field_id']] = $lr['meta_value'];
					}
				}

				$label_global_rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT field_id, meta_value
						 FROM {$t['contact_field_meta']}
						 WHERE meta_key='label' AND (locale IS NULL OR locale = '') AND field_id IN ({$field_ids_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
						...$field_ids
					),
					ARRAY_A
				);
				foreach ($label_global_rows as $lr) {
					$fid = (int) $lr['field_id'];
					if (!isset($label_locale_map[$fid])) {
						$label_global_map[$fid] = $lr['meta_value'];
					}
				}

				wp_cache_set(
					$labels_cache_key,
					[
						'locale' => $label_locale_map,
						'global' => $label_global_map,
					],
					'vstrb_contact_fields',
					3600
				);
			}
		}

		foreach ($fields as $f) {
			$fid = (int) $f['id'];
			$label = $label_locale_map[$fid] ?? ($label_global_map[$fid] ?? '');
			if (!$label) {
				$label = $f['field_key'];
			}

				$res[] = [
					'key' => self::sanitize_scalar_text($f['field_key']),
					'label' => self::sanitize_scalar_text($label),
				];
			}

		return new WP_REST_Response(['fields' => $res], 200);
	}

	private static function date_range_days($date_from, $date_to) {
		$days = [];
		$d1 = new DateTime($date_from);
		$d2 = new DateTime($date_to);
		// nights: [from, to)
		while ($d1 < $d2) {
			$days[] = $d1->format('Y-m-d');
			$d1->modify('+1 day');
		}
		return $days;
	}

	private static function has_booking_conflict($unit_id, $date_from, $date_to) {
		global $wpdb;
		if (!self::is_valid_ymd($date_from) || !self::is_valid_ymd($date_to) || $date_to <= $date_from) {
			return false;
		}
		$t = VSTRB_DB::tables();
		// booking_days stores every reserved night as [date_from, date_to).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return (bool) $wpdb->get_var($wpdb->prepare(
			"SELECT 1 FROM {$t['booking_days']} WHERE unit_id = %d AND day >= %s AND day < %s LIMIT 1",
			(int) $unit_id,
			$date_from,
			$date_to
		));
	}

	private static function mmdd_matches_range($mmdd, $from, $to) {
		if (!preg_match('/^\d{2}-\d{2}$/', (string) $mmdd) || !preg_match('/^\d{2}-\d{2}$/', (string) $from) || !preg_match('/^\d{2}-\d{2}$/', (string) $to)) {
			return false;
		}
		if ($from <= $to) {
			return $mmdd >= $from && $mmdd <= $to;
		}
		return $mmdd >= $from || $mmdd <= $to;
	}

	private static function get_closed_days($unit_id, $date_from, $date_to) {
		$periods = get_post_meta((int) $unit_id, '_vstrb_closed_periods', true);
		if (!is_array($periods) || empty($periods) || !self::is_valid_ymd($date_from) || !self::is_valid_ymd($date_to)) {
			return [];
		}

		$closed = [];
		$d1 = new DateTime($date_from);
		$d2 = new DateTime($date_to);
		while ($d1 < $d2) {
			$day = $d1->format('Y-m-d');
			$month = (int) $d1->format('n');
			$mmdd = $d1->format('m-d');

			foreach ($periods as $period) {
				if (!is_array($period)) {
					continue;
				}
				$type = (string) ($period['type'] ?? '');
				if ($type === 'date_range') {
					$from = (string) ($period['from'] ?? '');
					$to = (string) ($period['to'] ?? '');
					if (self::is_valid_ymd($from) && self::is_valid_ymd($to) && $day >= $from && $day <= $to) {
						$closed[$day] = true;
						break;
					}
				} elseif ($type === 'months') {
					$months = isset($period['months']) && is_array($period['months']) ? array_map('intval', $period['months']) : [];
					if (in_array($month, $months, true)) {
						$closed[$day] = true;
						break;
					}
				} elseif ($type === 'days_of_year') {
					$from = (string) ($period['from'] ?? '');
					$to = (string) ($period['to'] ?? '');
					if (self::mmdd_matches_range($mmdd, $from, $to)) {
						$closed[$day] = true;
						break;
					}
				}
			}

			$d1->modify('+1 day');
		}

		return array_keys($closed);
	}

	private static function has_closed_day($unit_id, $date_from, $date_to) {
		return !empty(self::get_closed_days($unit_id, $date_from, $date_to));
	}

	private static function has_closed_calendar_date($unit_id, $date_from, $date_to) {
		if (!self::is_valid_ymd($date_from) || !self::is_valid_ymd($date_to)) {
			return false;
		}
		$inclusive_to = new DateTime($date_to);
		$inclusive_to->modify('+1 day');
		return self::has_closed_day($unit_id, $date_from, $inclusive_to->format('Y-m-d'));
	}

	private static function rate_rule_specificity_weight($rate) {
		$rule = (string) ($rate['rule'] ?? '');
		$date_from = (string) ($rate['date_from'] ?? '');
		$date_to = (string) ($rate['date_to'] ?? '');
		$apply_mode = self::sanitize_rate_apply_mode($rate['apply_mode'] ?? 'date_range');
		// Explicit finite date-window overrides (e.g. "Autumn Sep 1-30") should beat broad seasonal windows.
		$has_finite_window = ($apply_mode === 'date_range' && $date_from !== '' && $date_to !== '' && $date_to !== '9999-12-31');
		if ($has_finite_window && in_array($rule, ['per_unit_night', 'per_person_night', 'fixed'], true)) {
			return 15;
		}
		switch ($rule) {
			case 'christmas':
			case 'new_year':
			case 'single_night':
				return 10;
			case 'weekend':
			case 'workday':
			case 'winter':
			case 'summer':
			case 'off_season':
				return 20;
			case 'fixed':
			case 'per_person_night':
			case 'per_unit_night':
			default:
				return 90;
		}
	}

	private static function decode_rate_label_i18n($raw) {
		if (!is_string($raw) || trim($raw) === '') {
			return [];
		}
		$decoded = json_decode($raw, true);
		if (!is_array($decoded)) {
			return [];
		}
		$out = [];
		foreach ($decoded as $loc => $val) {
			$loc_key = self::sanitize_scalar_text($loc, '');
			$val_txt = self::sanitize_scalar_text($val, '');
			if ($loc_key === '' || $val_txt === '') {
				continue;
			}
			$out[$loc_key] = $val_txt;
		}
		return $out;
	}

	private static function resolve_rate_label_for_locale($rate, $locale, $fallback) {
		$show_name = !empty($rate['show_name_in_breakdown']);
		if (!$show_name) {
			return $fallback;
		}

		$label_i18n = self::decode_rate_label_i18n((string) ($rate['breakdown_label_i18n'] ?? ''));
		$locale = self::sanitize_scalar_text($locale, '');
		if ($locale !== '' && !empty($label_i18n)) {
			if (isset($label_i18n[$locale]) && $label_i18n[$locale] !== '') {
				return $label_i18n[$locale];
			}
			foreach ($label_i18n as $loc => $value) {
				if (self::locale_matches($loc, $locale) && $value !== '') {
					return $value;
				}
			}
		}

		$name = self::sanitize_scalar_text((string) ($rate['name'] ?? ''), '');
		return $name !== '' ? $name : $fallback;
	}

	private static function rates_cache_version() {
		$v = wp_cache_get('vstrb_v_rates', 'vstrb_rates');
		if (false === $v) {
			$v = 1;
			wp_cache_set('vstrb_v_rates', $v, 'vstrb_rates', 3600);
		}
		return (int) $v;
	}

	private static function rate_matches_rule_for_day($rate, $day, $nights) {
		$dt_obj = new DateTime($day);
		$dow = (int) $dt_obj->format('N');
		$month = (int) $dt_obj->format('n');
		$md = $dt_obj->format('m-d');

		switch ((string) ($rate['rule'] ?? '')) {
			case 'per_person_night':
			case 'per_unit_night':
			case 'fixed':
				return true;
			case 'weekend':
				// $day is the start date of the billed night: Fri->Sat or Sat->Sun only.
				return in_array($dow, [5, 6], true);
			case 'workday':
				return $dow < 5;
			case 'christmas':
				return in_array($md, ['12-24', '12-25', '12-26'], true);
			case 'new_year':
				return $md === '12-31';
			case 'winter':
				return in_array($month, [12, 1, 2, 3], true);
			case 'summer':
				return in_array($month, [7, 8], true);
			case 'off_season':
				return in_array($month, [4, 5, 6, 9, 10, 11], true);
			case 'single_night':
				return (int) $nights === 1;
		}

		return false;
	}

	private static function rate_unit_override_slot($rate) {
		$operation = (string) ($rate['operation'] ?? 'replace');
		$rule = (string) ($rate['rule'] ?? '');
		$stay_ops = ['discount_amount_stay', 'discount_percent_stay', 'surcharge_amount_stay', 'surcharge_percent_stay'];

		if ($operation === 'replace') {
			return 'replace|' . $rule;
		}
		if (in_array($operation, $stay_ops, true)) {
			return 'stay_adjust|' . $operation . '|' . $rule;
		}
		return 'day_adjust|' . $operation . '|' . $rule;
	}

	private static function base_price($unit_id, $date_from, $date_to, $guests, $currency = 'EUR', $cal_only = false, $ignore_min_nights = false, $locale = '') {
		// MVP pricing:
		// - base mode from post meta
		// - optional seasonal override via vstrb_rates
		//   (higher minimum stay threshold always wins, then lower priority number)
		$mode = get_post_meta($unit_id, '_vstrb_price_mode', true) ?: 'per_unit_night';
		
		$get_meta = function($key) use ($unit_id, $currency) {
			$val = get_post_meta($unit_id, $key . '_' . $currency, true);
			if ($val === '' && $currency !== 'EUR') $val = get_post_meta($unit_id, $key . '_EUR', true);
			if ($val === '') $val = get_post_meta($unit_id, $key, true);
			return (float)$val;
		};

		$pppn = $get_meta('_vstrb_price_per_person_night');
		$pun  = $get_meta('_vstrb_price_per_unit_night');
		$per_stay = $get_meta('_vstrb_price_per_stay');
		$per_night = $get_meta('_vstrb_price_per_night');
		$per_person = $get_meta('_vstrb_price_per_person');

		$nights = max(0, (new DateTime($date_to))->diff(new DateTime($date_from))->days);
		if ($nights <= 0) return [0, 0, 0, 0, ['highlight' => false], [], []];

		$base = 0;
		switch ($mode) {
			case 'per_person_night':
				$base = $pppn * $guests * $nights;
				break;
			case 'per_unit_night':
				$base = $pun * $nights;
				break;
			case 'per_stay':
				$base = $per_stay;
				break;
			case 'per_night':
				$base = $per_night * $nights;
				break;
			case 'per_person':
				$base = $per_person * $guests;
				break;
			default:
				$base = $pun * $nights; // fallback to per_unit_night
		}

		// Rates override: sum per-night based on matching rates (simple: pick best rate per day)
		global $wpdb;
		$t = VSTRB_DB::tables();

		$days = self::date_range_days($date_from, $date_to);
		if (!$days) return [$base, $nights, 0, 0, ['highlight' => false], [], []];

		$cache_v = self::rates_cache_version();
		$cache_key = 'vstrb_rates_' . $cache_v . '_' . md5($unit_id . $currency . $date_from . $date_to);
		$rates = wp_cache_get($cache_key, 'vstrb_rates');

		if (false === $rates) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$rates = $wpdb->get_results($wpdb->prepare(
				"SELECT * FROM {$t['rates']}
				 WHERE unit_id IN (0, %d) AND currency=%s AND is_active=1 AND NOT (date_to < %s OR date_from > %s)
				 ORDER BY COALESCE(min_nights, 0) DESC, priority ASC, id ASC",
				$unit_id, $currency, $date_from, $date_to
			), ARRAY_A);
			wp_cache_set($cache_key, $rates, 'vstrb_rates', 3600);
		}

		if (!$rates) return [$base, $nights, 0, 0, ['highlight' => false], [], []];

		$cal_ops = ['discount_percent', 'discount_amount', 'surcharge_percent', 'surcharge_amount'];
		$eligible_rates = [];
		foreach ($rates as $r) {
			$rate_min_nights = isset($r['min_nights']) ? (int)$r['min_nights'] : 0;
			if (!$ignore_min_nights && $rate_min_nights > 0 && $nights < $rate_min_nights) {
				continue;
			}
			if ($cal_only) {
				if (!in_array($r['operation'] ?? '', $cal_ops, true)) continue;
				if ($rate_min_nights > 0) continue;
			}
			$eligible_rates[] = $r;
		}

		if (!$eligible_rates) return [$base, $nights, 0, 0, ['highlight' => false], [], []];
		usort($eligible_rates, static function ($a, $b) use ($ignore_min_nights) {
			$aw = self::rate_rule_specificity_weight($a);
			$bw = self::rate_rule_specificity_weight($b);
			if ($aw !== $bw) return $aw <=> $bw;

			if (!$ignore_min_nights) {
				$amin = isset($a['min_nights']) ? (int)$a['min_nights'] : 0;
				$bmin = isset($b['min_nights']) ? (int)$b['min_nights'] : 0;
				if ($amin !== $bmin) return $bmin <=> $amin;
			}

			$aprio = isset($a['priority']) ? (int)$a['priority'] : 100;
			$bprio = isset($b['priority']) ? (int)$b['priority'] : 100;
			if ($aprio !== $bprio) return $aprio <=> $bprio;

			$aid = isset($a['id']) ? (int)$a['id'] : 0;
			$bid = isset($b['id']) ? (int)$b['id'] : 0;
			return $aid <=> $bid;
		});

		$sum = 0.0;
		$total_discount = 0.0;
		$total_surcharge = 0.0;
		$discount_amount_nights = 0;
		$discount_amount_stays = 0;
		$discount_percent_nights = 0;
		$discount_percent_stays = 0;
		$stay_adjust_rates = [];
		$first_day_applied_meta = ['highlight' => false];
		$discount_lines = [];
		$surcharge_lines = [];
		$adjustment_ops = ['discount_percent', 'discount_amount', 'discount_amount_stay', 'discount_percent_stay', 'surcharge_percent', 'surcharge_amount', 'surcharge_amount_stay', 'surcharge_percent_stay'];
		$add_adjustment_line = static function (&$bucket, $rate, $amount, $fallback_label) use ($locale) {
			$amount = (float) $amount;
			if ($amount <= 0 || !is_array($rate)) {
				return;
			}
			$rate_id = (int) ($rate['id'] ?? 0);
			$key = $rate_id > 0 ? (string) $rate_id : md5(wp_json_encode($rate));
			if (!isset($bucket[$key])) {
				$bucket[$key] = [
					'label' => self::resolve_rate_label_for_locale($rate, $locale, $fallback_label),
					'amount' => 0.0,
				];
			}
			$bucket[$key]['amount'] += $amount;
		};

		foreach ($days as $day) {
			$day_base = 0.0;
			switch ($mode) {
				case 'per_person_night':
					$day_base = $pppn * $guests;
					break;
				case 'per_unit_night':
					$day_base = $pun;
					break;
				case 'per_night':
					$day_base = $per_night;
					break;
				case 'per_stay':
				case 'per_person':
					$day_base = 0;
					break;
				default:
					$day_base = $pun;
			}

			$matched_replace = null;
			$matched_adjust_day = null;
			$day_rates = [];
			$unit_specific_slots = [];

			foreach ($eligible_rates as $r) {
				if (!self::rate_matches_temporal_scope($day, $r)) continue;
				if (!self::rate_matches_rule_for_day($r, $day, $nights)) continue;

				$day_rates[] = $r;
				if ((int) ($r['unit_id'] ?? 0) === (int) $unit_id) {
					$unit_specific_slots[self::rate_unit_override_slot($r)] = true;
				}
			}

			if (!empty($unit_specific_slots)) {
				$day_rates = array_values(array_filter($day_rates, static function ($rate) use ($unit_id, $unit_specific_slots) {
					if ((int) ($rate['unit_id'] ?? 0) === (int) $unit_id) {
						return true;
					}
					return empty($unit_specific_slots[self::rate_unit_override_slot($rate)]);
				}));
			}

			foreach ($day_rates as $r) {
				$r_op = (string) ($r['operation'] ?? 'replace');
				$is_stay_adjust = in_array($r_op, ['discount_amount_stay', 'discount_percent_stay', 'surcharge_amount_stay', 'surcharge_percent_stay'], true);
				if ($r_op === 'replace' && $matched_replace === null) {
					$matched_replace = $r;
				} elseif (in_array($r_op, $adjustment_ops, true)) {
					if ($is_stay_adjust) {
						$stay_rate_id = (int) ($r['id'] ?? 0);
						$stay_key = $stay_rate_id > 0 ? (string) $stay_rate_id : md5(wp_json_encode($r));
						if (!isset($stay_adjust_rates[$stay_key])) {
							$stay_adjust_rates[$stay_key] = $r;
						}
					} elseif (!$is_stay_adjust && $matched_adjust_day === null) {
						$matched_adjust_day = $r;
					}
				}
				if ($matched_replace !== null && $matched_adjust_day !== null) {
					break;
				}
			}

			$day_total = $day_base;
			if ($matched_replace !== null) {
				$r_price = (float) ($matched_replace['price'] ?? 0);
				switch ((string) ($matched_replace['rule'] ?? '')) {
					case 'per_person_night':
						$day_total = $r_price * $guests;
						break;
					case 'per_unit_night':
					case 'weekend':
					case 'workday':
					case 'christmas':
					case 'new_year':
					case 'winter':
					case 'summer':
					case 'off_season':
					case 'single_night':
					case 'fixed':
					default:
						$day_total = $r_price;
						break;
				}
			}

			$meta_rate = $matched_replace;
			if ($matched_adjust_day !== null) {
				$meta_rate = $matched_adjust_day;
				$r_price = (float) ($matched_adjust_day['price'] ?? 0);
				$r_op = (string) ($matched_adjust_day['operation'] ?? '');
				$base_for_adj = $day_total;

				if ($r_op === 'discount_percent') {
					$discount_amount = $base_for_adj * ($r_price / 100);
					$day_total = max(0, $base_for_adj - $discount_amount);
					$total_discount += $discount_amount;
					$add_adjustment_line($discount_lines, $matched_adjust_day, $discount_amount, 'Discount');
					if ($discount_amount > 0) {
						$discount_percent_nights++;
					}
				} elseif ($r_op === 'discount_amount') {
					$day_total = max(0, $base_for_adj - $r_price);
					$applied_discount_amount = ($base_for_adj - $day_total);
					$total_discount += $applied_discount_amount;
					$add_adjustment_line($discount_lines, $matched_adjust_day, $applied_discount_amount, 'Discount');
					if ($applied_discount_amount > 0) {
						$discount_amount_nights++;
					}
				} elseif ($r_op === 'surcharge_percent') {
					$surcharge_amount = $base_for_adj * ($r_price / 100);
					$day_total = $base_for_adj + $surcharge_amount;
					$total_surcharge += $surcharge_amount;
					$add_adjustment_line($surcharge_lines, $matched_adjust_day, $surcharge_amount, 'Surcharge');
				} elseif ($r_op === 'surcharge_amount') {
					$day_total = $base_for_adj + $r_price;
					$total_surcharge += $r_price;
					$add_adjustment_line($surcharge_lines, $matched_adjust_day, max(0, $r_price), 'Surcharge');
				}
			}

			$sum += $day_total;
			if (!isset($first_day_applied_meta['operation']) && $meta_rate !== null) {
				$first_day_applied_meta = [
					'operation' => (string) ($meta_rate['operation'] ?? 'replace'),
					'rule' => (string) ($meta_rate['rule'] ?? ''),
					'highlight' => !empty($meta_rate['highlight_in_calendar']),
				];
			}
		}

		// For per_stay and per_person modes, if rates were applied... 
		// Logic is tricky because these modes aren't per-night.
		// But our discount logic allows per-night discounts. 
		// If a discount was applied to the "Base Daily Price", we implicitly converted the booking to daily calculation.
		// Ideally, if any rate is applied, we trust $sum.
		
		if (!empty($stay_adjust_rates)) {
			foreach ($stay_adjust_rates as $stay_adjust_rate) {
				$stay_op = (string) ($stay_adjust_rate['operation'] ?? '');
				$stay_price = (float) ($stay_adjust_rate['price'] ?? 0);
				if ($stay_op === 'discount_amount_stay') {
					$applied_discount = min($sum, max(0, $stay_price));
					$sum = max(0, $sum - $stay_price);
					$total_discount += $applied_discount;
					$add_adjustment_line($discount_lines, $stay_adjust_rate, $applied_discount, 'Discount');
					if ($applied_discount > 0) {
						$discount_amount_stays = 1;
					}
				} elseif ($stay_op === 'discount_percent_stay') {
					$discount_pct = max(0, $stay_price);
					$discount_amount = $sum * ($discount_pct / 100);
					$discount_amount = min($sum, $discount_amount);
					$sum = max(0, $sum - $discount_amount);
					$total_discount += $discount_amount;
					$add_adjustment_line($discount_lines, $stay_adjust_rate, $discount_amount, 'Discount');
					if ($discount_amount > 0) {
						$discount_percent_stays = 1;
					}
				} elseif ($stay_op === 'surcharge_amount_stay') {
					$sum += max(0, $stay_price);
					$total_surcharge += max(0, $stay_price);
					$add_adjustment_line($surcharge_lines, $stay_adjust_rate, max(0, $stay_price), 'Surcharge');
				} elseif ($stay_op === 'surcharge_percent_stay') {
					$surcharge_pct = max(0, $stay_price);
					$surcharge_amount = $sum * ($surcharge_pct / 100);
					$sum += $surcharge_amount;
					$total_surcharge += $surcharge_amount;
					$add_adjustment_line($surcharge_lines, $stay_adjust_rate, $surcharge_amount, 'Surcharge');
				}
			}
		}

		$discount_kind = 'none';
		if ($discount_amount_stays > 0) {
			$discount_kind = 'amount_stay';
		} elseif ($discount_percent_stays > 0) {
			$discount_kind = 'percent_stay';
		} elseif ($discount_amount_nights > 0 && $discount_percent_nights > 0) {
			$discount_kind = 'mixed';
		} elseif ($discount_amount_nights > 0) {
			$discount_kind = 'amount';
		} elseif ($discount_percent_nights > 0) {
			$discount_kind = 'percent';
		}

		$discount_breakdown = [
			'kind' => $discount_kind,
			'amount_nights' => $discount_amount_nights,
			'amount_stays' => $discount_amount_stays,
			'percent_nights' => $discount_percent_nights,
			'percent_stays' => $discount_percent_stays,
		];

		$discount_lines = array_values(array_map(static function ($line) {
			return [
				'label' => self::sanitize_scalar_text($line['label'] ?? 'Discount', 'Discount'),
				'amount' => round((float) ($line['amount'] ?? 0), 2),
			];
		}, $discount_lines));
		$surcharge_lines = array_values(array_map(static function ($line) {
			return [
				'label' => self::sanitize_scalar_text($line['label'] ?? 'Surcharge', 'Surcharge'),
				'amount' => round((float) ($line['amount'] ?? 0), 2),
			];
		}, $surcharge_lines));

		return [$sum, $nights, $total_discount, $total_surcharge, $first_day_applied_meta, $discount_breakdown, $discount_lines, $surcharge_lines];
	}

	private static function get_calendar_price_mode($unit_id) {
		$unit_val = get_post_meta($unit_id, '_vstrb_cal_price_mode', true);
		if ($unit_val && in_array($unit_val, ['off', 'price', 'diff'], true)) {
			return $unit_val;
		}
		$global = (string) get_option('vstrb_cal_price_mode', 'price');
		return in_array($global, ['off', 'price', 'diff'], true) ? $global : 'price';
	}

	private static function day_price($unit_id, $day, $guests, $currency, $cal_only = false, $ignore_min_nights = false) {
		$date_to = (new DateTime($day))->modify('+1 day')->format('Y-m-d');
		[$sum, , $disc, $surch, $meta] = self::base_price($unit_id, $day, $date_to, $guests, $currency, $cal_only, $ignore_min_nights, '');
		return [
			'price' => $sum,
			'base' => $sum + $disc - $surch,
			'discount' => $disc,
			'surcharge' => $surch,
			'applied_highlight' => !empty($meta['highlight']),
		];
	}

	private static function services_price($unit_id, $services_selected, $date_from, $date_to, $guests, $currency = 'EUR', $locale = '') {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$service_ids = array_values(array_filter(array_map('intval', $services_selected)));
		$selected_where = 'us.is_optional=0';
		if ($service_ids) {
			$placeholders = implode(',', array_fill(0, count($service_ids), '%%d'));
			$selected_where = "(s.id IN ($placeholders) OR us.is_optional=0)";
		}

		$sql = sprintf(
			"SELECT s.id, s.name, s.price as global_price,
					COALESCE(us.pricing_type, s.pricing_type) AS pricing_type,
					us.price_override,
					us.currency
			 FROM %s s
			 INNER JOIN %s us ON us.service_id=s.id
			 WHERE us.unit_id=%%d AND {$selected_where} AND s.is_active=1
			 ORDER BY s.sort_order ASC, s.name ASC, (us.currency = %%s) DESC, (us.currency = 'EUR') DESC",
			$t['services'],
			$t['unit_services']
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results($wpdb->prepare(
			$sql, // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			...array_merge(array($unit_id), array_values($service_ids), array($currency))
		), ARRAY_A);

		$service_meta_map = [];
		if (!empty($rows)) {
			$meta_service_ids = [];
			foreach ($rows as $row_item) {
				$sid = (int) ($row_item['id'] ?? 0);
				if ($sid > 0 && !in_array($sid, $meta_service_ids, true)) {
					$meta_service_ids[] = $sid;
				}
			}
			if (!empty($meta_service_ids)) {
				$meta_ids_placeholders = implode(', ', array_fill(0, count($meta_service_ids), '%d'));
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$meta_rows = $wpdb->get_results(
					$wpdb->prepare(
						// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- placeholders are in $meta_ids_placeholders variable
						"SELECT service_id, meta_key, meta_value, locale FROM {$t['service_meta']} WHERE service_id IN ({$meta_ids_placeholders})",
						...$meta_service_ids
					),
					ARRAY_A
				);
				foreach ($meta_rows as $mr) {
					$sid = (int) ($mr['service_id'] ?? 0);
					if (!isset($service_meta_map[$sid])) {
						$service_meta_map[$sid] = [];
					}
					$service_meta_map[$sid][] = $mr;
				}
			}
		}

		$nights = max(0, (new DateTime($date_to))->diff(new DateTime($date_from))->days);

		$switched = false;
		if ($locale && $locale !== substr(get_locale(), 0, 2)) {
			switch_to_locale(self::resolve_locale($locale));
			$switched = true;
		}

		$sum = 0.0;
		$line = [];
		$seen = [];
		foreach ($rows as $r) {
			if (isset($seen[$r['id']])) continue;
			$seen[$r['id']] = true;
			$meta_rows = $service_meta_map[(int) $r['id']] ?? [];

			if ($r['currency'] === $currency && !is_null($r['price_override'])) {
				$p = (float)$r['price_override'];
			} else {
				$resolved_price = self::service_price_from_meta_rows($meta_rows, $currency, $r['global_price']);
				$p = !is_null($resolved_price) ? (float) $resolved_price : (float) $r['global_price'];
			}
			$qty = 1.0;
			switch ($r['pricing_type']) {
				case 'free': $p = 0; break;
				case 'per_stay': $qty = 1; break;
				case 'per_night': $qty = $nights; break;
				case 'per_person': $qty = $guests; break;
				case 'per_person_night': $qty = $guests * $nights; break;
			}
			$item_total = $p * $qty;
			$sum += $item_total;

			// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- service name is user-defined data stored in DB, not a static string literal
			$display_name = sanitize_text_field(__($r['name'], 'vachr-short-term-rental-reservations'));
			if ($locale) {
				$translated_local = null;
				$translated_global = null;
				foreach ($meta_rows as $mr) {
					$mk = strtolower(trim((string) ($mr['meta_key'] ?? '')));
					if (!in_array($mk, ['label', 'name'], true)) continue;
					$ml = (string) ($mr['locale'] ?? '');
					if (self::locale_matches($ml, $locale) && $translated_local === null) {
						$translated_local = (string) $mr['meta_value'];
					} elseif (self::normalize_locale_code($ml) === '' && $translated_global === null) {
						$translated_global = (string) $mr['meta_value'];
					}
				}
				if ($translated_local !== null) {
					$display_name = sanitize_text_field($translated_local);
				} elseif ($translated_global !== null) {
					$display_name = sanitize_text_field($translated_global);
				}
			}

			$line[] = [
				'id' => (int)$r['id'],
				'name' => $display_name,
				'unit_price' => $p,
				'qty' => $qty,
				'total' => $item_total
			];
		}
		if ($switched) {
			restore_current_locale();
		}
		return [$sum, $line];
	}

	public static function calculate_price_snapshot($unit_id, $date_from, $date_to, $guests, $currency = 'EUR', $services = [], $locale = '') {
		$unit_id = (int) $unit_id;
		$date_from = self::sanitize_scalar_text($date_from);
		$date_to = self::sanitize_scalar_text($date_to);
		$guests = max(1, (int) $guests);
		$currency = self::sanitize_currency_code($currency);
		$locale = self::sanitize_scalar_text($locale);
		$services = is_array($services) ? $services : [];

		if (!VSTRB_DB::unit_exists($unit_id) || !self::is_valid_ymd($date_from) || !self::is_valid_ymd($date_to) || strtotime($date_to) <= strtotime($date_from)) {
			return null;
		}

		$get_meta = function($key) use ($unit_id, $currency) {
			$val = get_post_meta($unit_id, $key . '_' . $currency, true);
			if ($val === '' && $currency !== 'EUR') {
				$val = get_post_meta($unit_id, $key . '_EUR', true);
			}
			if ($val === '') {
				$val = get_post_meta($unit_id, $key, true);
			}
			return (float) $val;
		};

		[$base, $nights, $discount_total, $surcharge_total, , $discount_breakdown, $discount_lines, $surcharge_lines] = self::base_price($unit_id, $date_from, $date_to, $guests, $currency, false, false, $locale);
		[$svc_sum, $svc_lines] = self::services_price($unit_id, $services, $date_from, $date_to, $guests, $currency, $locale);
		$display_base = $base + $discount_total - $surcharge_total;
		$total = round($base + $svc_sum, 2);
		$refundable_deposit = max(0, $get_meta('_vstrb_refundable_deposit'));
		$total_with_deposit = round($total + $refundable_deposit, 2);
		$discount_breakdown = is_array($discount_breakdown) ? $discount_breakdown : [];

		return [
			'nights' => $nights,
			'base' => round($display_base, 2),
			'discount_total' => round($discount_total, 2),
			'discount_kind' => (string) ($discount_breakdown['kind'] ?? 'none'),
			'discount_amount_nights' => (int) ($discount_breakdown['amount_nights'] ?? 0),
			'discount_amount_stays' => (int) ($discount_breakdown['amount_stays'] ?? 0),
			'discount_percent_nights' => (int) ($discount_breakdown['percent_nights'] ?? 0),
			'discount_percent_stays' => (int) ($discount_breakdown['percent_stays'] ?? 0),
			'discount_lines' => is_array($discount_lines) ? $discount_lines : [],
			'surcharge_total' => round($surcharge_total, 2),
			'surcharge_lines' => is_array($surcharge_lines) ? $surcharge_lines : [],
			'refundable_deposit' => round($refundable_deposit, 2),
			'services' => $svc_lines,
			'services_total' => round($svc_sum, 2),
			'total' => $total,
			'total_with_deposit' => $total_with_deposit,
			'currency' => $currency,
		];
	}

	public static function quote($req) {
		$data = self::request_json_array($req);
		$is_admin_user = current_user_can('manage_options');
		$unit_id = (int)($data['unit_id'] ?? 0);
		$date_from = self::sanitize_scalar_text($data['date_from'] ?? '');
		$date_to = self::sanitize_scalar_text($data['date_to'] ?? '');
		$adults = max(1, (int)($data['adults'] ?? $data['guests'] ?? 1));
		$children = max(0, (int)($data['children'] ?? 0));
		$guests = $adults + $children;
		$services = isset($data['services']) && is_array($data['services']) ? $data['services'] : [];
		$currency = self::sanitize_currency_code($data['currency'] ?? 'EUR');
		$locale = self::sanitize_scalar_text($data['locale'] ?? '');
		$get_meta = function($key) use ($unit_id, $currency) {
			$val = get_post_meta($unit_id, $key . '_' . $currency, true);
			if ($val === '' && $currency !== 'EUR') $val = get_post_meta($unit_id, $key . '_EUR', true);
			if ($val === '') $val = get_post_meta($unit_id, $key, true);
			return (float) $val;
		};

		if (!VSTRB_DB::unit_exists($unit_id)) return new WP_REST_Response(['error'=>'unit_not_found'], 404);
		if (get_post_meta($unit_id, '_vstrb_is_paused', true)) return new WP_REST_Response(['error'=>'unit_paused'], 400);
		if (!self::is_valid_ymd($date_from) || !self::is_valid_ymd($date_to)) {
			return new WP_REST_Response(['error'=>'invalid_dates'], 400);
		}
		if (strtotime($date_to) <= strtotime($date_from)) return new WP_REST_Response(['error'=>'invalid_range'], 400);
		if (self::has_closed_calendar_date($unit_id, $date_from, $date_to)) {
			return new WP_REST_Response(['error'=>'unit_closed'], 409);
		}
		if (self::has_booking_conflict($unit_id, $date_from, $date_to)) {
			return new WP_REST_Response(['error'=>'date_conflict'], 409);
		}
		$capacity = max(0, (int) get_post_meta($unit_id, '_vstrb_capacity', true));
		if ($capacity > 0 && $guests > $capacity) {
			return new WP_REST_Response(['error'=>'guest_capacity_violation', 'limit'=>$capacity], 400);
		}

		[$base, $nights, $discount_total, $surcharge_total, , $discount_breakdown, $discount_lines, $surcharge_lines] = self::base_price($unit_id, $date_from, $date_to, $guests, $currency, false, false, $locale);
		$display_base = $base + $discount_total - $surcharge_total;
		$discount_breakdown = is_array($discount_breakdown) ? $discount_breakdown : [];

		// Validation: Min/Max stay
		$min_stay = (int)get_post_meta($unit_id, '_vstrb_min_stay', true);
		$max_stay = (int)get_post_meta($unit_id, '_vstrb_max_stay', true);
		if (!$is_admin_user && $min_stay > 0 && $nights < $min_stay) return new WP_REST_Response(['error'=>'min_stay_violation', 'limit'=>$min_stay], 400);
		if ($max_stay > 0 && $nights > $max_stay) return new WP_REST_Response(['error'=>'max_stay_violation', 'limit'=>$max_stay], 400);

		[$svc_sum, $svc_lines] = self::services_price($unit_id, $services, $date_from, $date_to, $guests, $currency, $locale);
		$refundable_deposit = max(0, $get_meta('_vstrb_refundable_deposit'));

		$total = $base + $svc_sum;

		return new WP_REST_Response([
			'nights' => $nights,
			'base' => round($display_base, 2),
			'discount_total' => round($discount_total, 2),
			'discount_kind' => (string) ($discount_breakdown['kind'] ?? 'none'),
			'discount_amount_nights' => (int) ($discount_breakdown['amount_nights'] ?? 0),
			'discount_amount_stays' => (int) ($discount_breakdown['amount_stays'] ?? 0),
			'discount_percent_nights' => (int) ($discount_breakdown['percent_nights'] ?? 0),
			'discount_percent_stays' => (int) ($discount_breakdown['percent_stays'] ?? 0),
			'discount_lines' => is_array($discount_lines) ? $discount_lines : [],
			'surcharge_total' => round($surcharge_total, 2),
			'surcharge_lines' => is_array($surcharge_lines) ? $surcharge_lines : [],
			'refundable_deposit' => round($refundable_deposit, 2),
			'services' => $svc_lines,
			'services_total' => round($svc_sum, 2),
			'total' => round($total, 2),
			'total_with_deposit' => round($total + $refundable_deposit, 2),
			'currency' => $currency,
		], 200);
	}

	public static function book($req) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$data = self::request_json_array($req);
		$is_admin_user = current_user_can('manage_options');
		$unit_id = (int)($data['unit_id'] ?? 0);
		$date_from = self::sanitize_scalar_text($data['date_from'] ?? '');
		$date_to = self::sanitize_scalar_text($data['date_to'] ?? '');
		$adults = max(1, (int)($data['adults'] ?? $data['guests'] ?? 1));
		$children = max(0, (int)($data['children'] ?? 0));
		$guests = $adults + $children;
		$services = isset($data['services']) && is_array($data['services']) ? $data['services'] : [];
		$currency = self::sanitize_currency_code($data['currency'] ?? 'EUR');
		$locale = self::sanitize_scalar_text($data['locale'] ?? '');
		$name = self::sanitize_scalar_text($data['name'] ?? '');
		$lastname = self::sanitize_scalar_text($data['lastname'] ?? '');
		$gender = self::sanitize_scalar_text($data['gender'] ?? '');
		$country = strtoupper(self::sanitize_scalar_text($data['country'] ?? ''));
		$email = self::sanitize_scalar_email($data['email'] ?? '');
		$phone = self::sanitize_scalar_text($data['phone'] ?? '');
		$note = sanitize_textarea_field($data['note'] ?? '');
		$hp             = self::sanitize_scalar_text($data['hp'] ?? '');
		$captcha        = trim(self::sanitize_scalar_text($data['captcha'] ?? ''));
		$captcha_token  = trim(self::sanitize_scalar_text($data['captcha_token'] ?? ''));
		$custom_fields  = isset($data['custom_fields']) && is_array($data['custom_fields']) ? $data['custom_fields'] : [];
		$payment_choice = self::parse_booking_payment_choice($data['payment_method'] ?? 'cash');
		$payment_method = self::sanitize_payment_method($payment_choice['method'], 'cash');
		$payment_provider = self::sanitize_scalar_text($payment_choice['provider']);
		$billing_raw = (isset($data['billing']) && is_array($data['billing'])) ? $data['billing'] : [];
		$billing_enabled = !empty($billing_raw['enabled']) ? 1 : 0;
		$billing = [
			'billing_enabled'    => $billing_enabled,
			'billing_name'       => self::sanitize_scalar_text($billing_raw['name'] ?? ''),
			'billing_company_id' => self::sanitize_scalar_text($billing_raw['company_id'] ?? ''),
			'billing_vat_id'     => self::sanitize_scalar_text($billing_raw['vat_id'] ?? ''),
			'billing_street'     => self::sanitize_scalar_text($billing_raw['street'] ?? ''),
			'billing_city'       => self::sanitize_scalar_text($billing_raw['city'] ?? ''),
			'billing_zip'        => self::sanitize_scalar_text($billing_raw['zip'] ?? ''),
			'billing_country'    => self::sanitize_scalar_text($billing_raw['country'] ?? ''),
		];
		if ($billing_enabled && $billing['billing_name'] === '' && $billing['billing_company_id'] === '') {
			$billing['billing_name'] = trim($name . ' ' . $lastname);
		}
		if (!$billing_enabled) {
			$billing = [
				'billing_enabled' => 0,
				'billing_name' => '',
				'billing_company_id' => '',
				'billing_vat_id' => '',
				'billing_street' => '',
				'billing_city' => '',
				'billing_zip' => '',
				'billing_country' => '',
			];
		}

		if (!VSTRB_DB::unit_exists($unit_id)) {
			return new WP_REST_Response(['error'=>'unit_not_found'], 404);
		}

		// Anti-bot check
		if (!empty($hp)) {
			return new WP_REST_Response(['error'=>'robot_detected'], 400);
		}
		if (!$is_admin_user && !VSTRB_Shortcodes::verify_captcha($captcha_token, $captcha)) {
			return new WP_REST_Response(['error'=>'captcha_invalid'], 400);
		}
		if (get_post_meta($unit_id, '_vstrb_is_paused', true)) {
			return new WP_REST_Response(['error'=>'unit_paused'], 400);
		}
		if (!self::is_valid_ymd($date_from) || !self::is_valid_ymd($date_to)) {
			return new WP_REST_Response(['error'=>'invalid_dates'], 400);
		}
		if (strtotime($date_to) <= strtotime($date_from)) {
			return new WP_REST_Response(['error'=>'invalid_range'], 400);
		}
		if (self::has_closed_calendar_date($unit_id, $date_from, $date_to)) {
			return new WP_REST_Response(['error'=>'unit_closed'], 409);
		}
		if (self::has_booking_conflict($unit_id, $date_from, $date_to)) {
			return new WP_REST_Response(['error'=>'date_conflict'], 409);
		}
		$capacity = max(0, (int) get_post_meta($unit_id, '_vstrb_capacity', true));
		if ($capacity > 0 && $guests > $capacity) {
			return new WP_REST_Response(['error'=>'guest_capacity_violation', 'limit'=>$capacity], 400);
		}
		if (!$name || !$lastname || !$email || !$phone) {
			return new WP_REST_Response(['error'=>'missing_contact'], 400);
		}
		if ($country !== '' && !VSTRB_Countries::is_valid($country)) {
			return new WP_REST_Response(['error'=>'invalid_country'], 400);
		}
		if ($billing_enabled) {
			if (
				($billing['billing_name'] === '' && $billing['billing_company_id'] === '') ||
				$billing['billing_street'] === '' ||
				$billing['billing_city'] === '' ||
				$billing['billing_zip'] === '' ||
				$billing['billing_country'] === ''
			) {
				return new WP_REST_Response(['error' => 'missing_billing'], 400);
			}
		}

		$get_meta = function($key) use ($unit_id, $currency) {
			$val = get_post_meta($unit_id, $key . '_' . $currency, true);
			if ($val === '' && $currency !== 'EUR') $val = get_post_meta($unit_id, $key . '_EUR', true);
			if ($val === '') $val = get_post_meta($unit_id, $key, true);
			return (float) $val;
		};

		// Quote
		[$base, $nights, $discount_total, $surcharge_total, , $discount_breakdown, $discount_lines, $surcharge_lines] = self::base_price($unit_id, $date_from, $date_to, $guests, $currency, false, false, $locale);
		$display_base = $base + $discount_total - $surcharge_total;
		$discount_breakdown = is_array($discount_breakdown) ? $discount_breakdown : [];

		// Validation: Min/Max stay
		$min_stay = (int)get_post_meta($unit_id, '_vstrb_min_stay', true);
		$max_stay = (int)get_post_meta($unit_id, '_vstrb_max_stay', true);
		if (!$is_admin_user && $min_stay > 0 && $nights < $min_stay) {
			return new WP_REST_Response(['error'=>'min_stay_violation', 'limit'=>$min_stay], 400);
		}
		if ($max_stay > 0 && $nights > $max_stay) {
			return new WP_REST_Response(['error'=>'max_stay_violation', 'limit'=>$max_stay], 400);
		}

		[$svc_sum, $svc_lines] = self::services_price($unit_id, $services, $date_from, $date_to, $guests, $currency, $locale);
		$total = round($base + $svc_sum, 2);
		$refundable_deposit = max(0, $get_meta('_vstrb_refundable_deposit'));
		$total_with_deposit = round($total + $refundable_deposit, 2);
		$price_breakdown = [
			'nights' => $nights,
			'base' => round($display_base, 2),
			'discount_total' => round($discount_total, 2),
			'discount_kind' => (string) ($discount_breakdown['kind'] ?? 'none'),
			'discount_amount_nights' => (int) ($discount_breakdown['amount_nights'] ?? 0),
			'discount_amount_stays' => (int) ($discount_breakdown['amount_stays'] ?? 0),
			'discount_percent_nights' => (int) ($discount_breakdown['percent_nights'] ?? 0),
			'discount_percent_stays' => (int) ($discount_breakdown['percent_stays'] ?? 0),
			'discount_lines' => is_array($discount_lines) ? $discount_lines : [],
			'surcharge_total' => round($surcharge_total, 2),
			'surcharge_lines' => is_array($surcharge_lines) ? $surcharge_lines : [],
			'refundable_deposit' => round($refundable_deposit, 2),
			'services' => $svc_lines,
			'services_total' => round($svc_sum, 2),
			'total' => $total,
			'total_with_deposit' => $total_with_deposit,
			'currency' => $currency,
		];
		$include_refundable_deposit = ((int) get_option('vstrb_payment_include_refundable_deposit', 0) === 1);
		$online_methods = ['card', 'bank_transfer'];
		$payment_amount_due = (in_array($payment_method, $online_methods, true) && $include_refundable_deposit)
			? $total_with_deposit
			: $total;
		$invoice_number = '';
		$payment_reference = '';
		$invoice_id = 0;
		$payment_status = 'pending_cash';
		if ($payment_method === 'card') {
			$payment_status = 'pending_gateway';
			if (!in_array($payment_provider, ['stripe', 'mollie'], true)) {
				$payment_provider = 'stripe';
			}
		} elseif ($payment_method === 'bank_transfer') {
			$payment_status = 'pending_bank';
			$payment_provider = 'bank_transfer';
		} elseif ($payment_method === 'cash') {
			$payment_provider = 'cash';
		}
		$should_issue_invoice = !empty($billing['billing_enabled']);

		$status = 'pending';
		// Admin: Allow immediate confirmation
		if (!empty($data['confirm_now']) && current_user_can('manage_options')) {
			$status = 'confirmed';
		}

		// Transaction + lock days via booking_days UNIQUE(unit_id, day)
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query('START TRANSACTION');

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$ok = $wpdb->insert($t['bookings'], [
			'unit_id' => $unit_id,
			'status' => $status,
			'source' => 'direct',
			'date_from' => $date_from,
			'date_to' => $date_to,
			'guests' => $guests,
			'adults' => $adults,
			'children' => $children,
			'customer_name' => $name,
			'customer_lastname' => $lastname,
			'customer_gender' => $gender,
			'customer_country' => $country,
			'customer_email' => $email,
			'customer_phone' => $phone,
			'note' => $note,
			'total_price' => $total,
			'currency' => $currency,
		]);

		if (!$ok) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->query('ROLLBACK');
			return new WP_REST_Response(['error'=>'db_insert_failed'], 500);
		}

		$booking_id = (int)$wpdb->insert_id;

		// Insert booking_days locks
		$days = self::date_range_days($date_from, $date_to);
		$sql_ins = "INSERT INTO {$t['booking_days']} (unit_id, day, booking_id) VALUES (%d, %s, %d)";
		foreach ($days as $day) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$ins = $wpdb->query($wpdb->prepare(
				$sql_ins,
				(int)$unit_id, $day, (int)$booking_id
			));
			if ($ins === false) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->query('ROLLBACK');
				return new WP_REST_Response(['error'=>'date_conflict'], 409);
			}
		}

		// Booking services rows
		foreach ($svc_lines as $sl) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->insert($t['booking_services'], [
				'booking_id' => $booking_id,
				'service_id' => (int)$sl['id'],
				'qty' => (float)$sl['qty'],
				'price' => (float)$sl['unit_price'],
			]);
		}

		// Save Custom Fields (Metadata)
		if (!empty($custom_fields)) {
			// Get allowed fields for this unit
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$allowed_keys = $wpdb->get_col($wpdb->prepare(
				"SELECT f.field_key 
				 FROM {$t['contact_fields']} f
				 INNER JOIN {$t['unit_contact_fields']} uf ON uf.field_id = f.id
				 WHERE uf.unit_id = %d AND f.is_active = 1",
				$unit_id
			));

				foreach ($custom_fields as $key => $val) {
				$key = self::sanitize_scalar_text($key);
				$val = self::sanitize_scalar_text($val);
				if ($key === '') {
					continue;
				}
				// Verify if this key is allowed.
				if (in_array($key, $allowed_keys, true)) {
						// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
						$wpdb->insert($t['booking_meta'], [
							'booking_id' => $booking_id,
							'meta_key' => $key,
							'meta_value' => $val
						]);
				}
			}
		}

		if ($should_issue_invoice) {
			$invoice_number = self::build_booking_invoice_number($booking_id);
			$invoice_id = self::invoice_upsert_for_booking(
				$booking_id,
				$invoice_number,
				'issued',
				$total,
				$currency,
				$billing
			);
			self::booking_meta_upsert($booking_id, 'invoice_number', $invoice_number);
			self::booking_meta_upsert($booking_id, 'invoice_status', 'issued');
			self::booking_meta_upsert($booking_id, 'invoice_issued_at', current_time('mysql'));
			self::booking_meta_upsert($booking_id, 'invoice_currency', $currency);
			self::booking_meta_upsert($booking_id, 'invoice_total', number_format((float) $total, 2, '.', ''));
			self::booking_meta_upsert($booking_id, 'billing_enabled', (string) (int) $billing['billing_enabled']);
			self::booking_meta_upsert($booking_id, 'billing_name', $billing['billing_name']);
			self::booking_meta_upsert($booking_id, 'billing_company_id', $billing['billing_company_id']);
			self::booking_meta_upsert($booking_id, 'billing_vat_id', $billing['billing_vat_id']);
			self::booking_meta_upsert($booking_id, 'billing_street', $billing['billing_street']);
			self::booking_meta_upsert($booking_id, 'billing_city', $billing['billing_city']);
			self::booking_meta_upsert($booking_id, 'billing_zip', $billing['billing_zip']);
			self::booking_meta_upsert($booking_id, 'billing_country', $billing['billing_country']);
		}
		$payment_reference = $invoice_number !== '' ? $invoice_number : (string) $booking_id;
		self::booking_meta_upsert($booking_id, 'payment_reference', $payment_reference);
		self::booking_meta_upsert($booking_id, 'payment_method', $payment_method);
		self::booking_meta_upsert($booking_id, 'payment_status', $payment_status);
		if ($payment_provider !== '') {
			self::booking_meta_upsert($booking_id, 'payment_provider', $payment_provider);
			self::booking_meta_upsert($booking_id, 'payment_gateway', $payment_provider);
		}
		self::booking_meta_upsert($booking_id, 'payment_amount_due', number_format((float) $payment_amount_due, 2, '.', ''));
		self::booking_meta_upsert($booking_id, 'payment_currency', $currency);
		if ($locale !== '') {
			self::booking_meta_upsert($booking_id, 'customer_locale', $locale);
		}
		self::booking_meta_upsert($booking_id, 'payment_created_at', current_time('mysql'));
		self::booking_meta_upsert($booking_id, 'refundable_deposit', number_format((float) $refundable_deposit, 2, '.', ''));
		self::booking_meta_upsert($booking_id, 'total_with_deposit', number_format((float) $total_with_deposit, 2, '.', ''));
		self::booking_meta_upsert($booking_id, 'price_breakdown', wp_json_encode($price_breakdown));
		self::payment_upsert_for_booking(
			$booking_id,
			$invoice_id,
			$payment_method,
			$payment_provider,
			($payment_method === 'cash' ? 'paid' : 'pending'),
			$payment_amount_due,
			$currency
		);
		if ($payment_method === 'cash') {
			self::mark_booking_as_paid($booking_id, 'cash', '', $total, $currency);
		}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->query('COMMIT');

		// Invalidate calendar cache for this unit
		$v = wp_cache_get('vstrb_v_' . $unit_id, 'vstrb_calendar');
		if (false === $v) {
			wp_cache_set('vstrb_v_' . $unit_id, 2, 'vstrb_calendar', 3600);
		} else {
			wp_cache_incr('vstrb_v_' . $unit_id, 1, 'vstrb_calendar');
		}

		$payment_redirect_url = '';
		$payment_instructions = '';
		$payment_instructions_missing = false;
		$payment_gateway_error = '';
		$payment_qr_payload = '';
		if ($payment_method === 'card' && $payment_provider === 'stripe') {
			$referer_url = self::get_request_referer_url($req);
			$success_url = (string) get_option('vstrb_payment_stripe_success_url', '');
			$cancel_url = (string) get_option('vstrb_payment_stripe_cancel_url', '');
			if ($success_url === '') {
				$success_url = add_query_arg(['vstrb_payment' => 'success', 'booking_id' => $booking_id], $referer_url);
			}
			if ($cancel_url === '') {
				$cancel_url = add_query_arg(['vstrb_payment' => 'cancel', 'booking_id' => $booking_id], $referer_url);
			}
			$stripe_session = self::stripe_create_checkout_session([
				'booking_id' => $booking_id,
				'invoice_number' => $invoice_number,
				'amount' => $payment_amount_due,
				'currency' => $currency,
				'customer_email' => $email,
				'success_url' => $success_url,
				'cancel_url' => $cancel_url,
			]);
			if (!empty($stripe_session['id']) && !empty($stripe_session['url'])) {
				$stripe_session_id = self::sanitize_scalar_text($stripe_session['id']);
				self::booking_meta_upsert($booking_id, 'payment_gateway_session_id', $stripe_session_id);
				self::payment_upsert_for_booking(
					$booking_id,
					$invoice_id,
					'card',
					'stripe',
					'pending',
					$payment_amount_due,
					$currency,
					'',
					$stripe_session_id,
					null,
					self::stripe_payment_gateway_details($stripe_session)
				);
				$payment_redirect_url = esc_url_raw((string) $stripe_session['url']);
				self::booking_meta_upsert($booking_id, 'payment_gateway_url', $payment_redirect_url);
			} else {
				$payment_gateway_error = self::sanitize_scalar_text($stripe_session['error'] ?? 'stripe_session_failed');
				self::booking_meta_upsert($booking_id, 'payment_gateway_error', $payment_gateway_error);
			}
		} elseif ($payment_method === 'card' && $payment_provider === 'mollie') {
			$referer_url = self::get_request_referer_url($req);
			$success_url = (string) get_option('vstrb_payment_mollie_success_url', '');
			if ($success_url === '') {
				$success_url = add_query_arg(['vstrb_payment' => 'success', 'booking_id' => $booking_id], $referer_url);
			}
			$mollie_payment = self::mollie_create_payment([
				'booking_id' => $booking_id,
				'invoice_number' => $invoice_number,
				'amount' => $payment_amount_due,
				'currency' => $currency,
				'redirect_url' => $success_url,
				'webhook_url' => rest_url('vstrb/v1/payment/mollie/webhook'),
			]);
			if (!empty($mollie_payment['id']) && !empty($mollie_payment['url'])) {
				$mollie_id = self::sanitize_scalar_text($mollie_payment['id']);
				self::booking_meta_upsert($booking_id, 'payment_gateway_session_id', $mollie_id);
				self::booking_meta_upsert($booking_id, 'payment_gateway_intent_id', $mollie_id);
				self::payment_upsert_for_booking(
					$booking_id,
					$invoice_id,
					'card',
					'mollie',
					'pending',
					$payment_amount_due,
					$currency,
					$mollie_id,
					$mollie_id,
					null,
					[
						'gateway_status' => self::sanitize_scalar_text($mollie_payment['status'] ?? ''),
						'gateway_payment_status' => self::sanitize_scalar_text($mollie_payment['status'] ?? ''),
					]
				);
				$payment_redirect_url = esc_url_raw((string) $mollie_payment['url']);
				self::booking_meta_upsert($booking_id, 'payment_gateway_url', $payment_redirect_url);
			} else {
				$payment_gateway_error = self::sanitize_scalar_text($mollie_payment['error'] ?? 'mollie_payment_failed');
				self::booking_meta_upsert($booking_id, 'payment_gateway_error', $payment_gateway_error);
			}
		} elseif ($payment_method === 'bank_transfer') {
			$payment_instructions = self::resolve_unit_content($unit_id, 'bank_transfer_instructions', $locale, [
				'booking_id' => $booking_id,
				'invoice_number' => $invoice_number,
				'payment_reference' => $payment_reference,
				'payment_amount' => number_format_i18n((float) $payment_amount_due, 2),
				'payment_amount_due' => number_format_i18n((float) $payment_amount_due, 2),
				'payment_currency' => $currency,
				'total' => number_format_i18n((float) $total, 2),
				'total_price' => number_format_i18n((float) $total, 2),
				'refundable_deposit' => number_format_i18n((float) $refundable_deposit, 2),
				'guest_name' => trim($name . ' ' . $lastname),
				'guest_first_name' => $name,
				'guest_last_name' => $lastname,
				'guest_email' => $email,
				'date_from' => $date_from,
				'date_to' => $date_to,
			]);
			$payment_instructions_missing = ($payment_instructions === '');
			$payment_qr_payload = VSTRB_Payment_QR::payload(
				$payment_amount_due,
				$currency,
				$payment_reference
			);
		}

		// Email notifications are sent after payment setup so card payment links can be included.
		VSTRB_Emails::notify_admin_new_booking($booking_id);
		if ($status === 'confirmed') {
			VSTRB_Emails::notify_customer_confirmed($booking_id);
		} else {
			VSTRB_Emails::notify_customer_booking_received($booking_id);
		}

		return new WP_REST_Response([
			'ok' => true,
			'booking_id' => $booking_id,
			'status' => $status,
			'total' => $total,
			'payment_amount_due' => round($payment_amount_due, 2),
			'currency' => $currency,
			'payment_method' => $payment_method,
			'payment_status' => $payment_status,
			'refundable_deposit' => round($refundable_deposit, 2),
			'total_with_deposit' => round($total_with_deposit, 2),
			'invoice_number' => $invoice_number !== '' ? $invoice_number : null,
			'payment_redirect_url' => ($payment_redirect_url !== '' ? $payment_redirect_url : null),
			'payment_instructions' => ($payment_instructions !== '' ? $payment_instructions : null),
			'payment_instructions_missing' => $payment_instructions_missing,
			'payment_qr_payload' => ($payment_qr_payload !== '' ? $payment_qr_payload : null),
			'payment_error' => ($payment_gateway_error !== '' ? $payment_gateway_error : null),
		], 201);
	}

	public static function stripe_webhook($req) {
		$payload = (string) $req->get_body();
		$signature = self::sanitize_scalar_text($req->get_header('stripe-signature') ?? '');
		$secret = (string) get_option('vstrb_payment_stripe_webhook_secret', '');
		if ($secret === '') {
			return new WP_REST_Response(['error' => 'webhook_secret_missing'], 400);
		}
		if (!self::stripe_signature_is_valid($payload, $signature, $secret)) {
			return new WP_REST_Response(['error' => 'invalid_signature'], 400);
		}

		$event = json_decode($payload, true);
		if (!is_array($event) || empty($event['type'])) {
			return new WP_REST_Response(['error' => 'invalid_payload'], 400);
		}
		$type = self::sanitize_scalar_text($event['type']);
		if ($type !== 'checkout.session.completed') {
			return new WP_REST_Response(['ok' => true, 'ignored' => true], 200);
		}

		$object = $event['data']['object'] ?? [];
		if (!is_array($object)) {
			return new WP_REST_Response(['error' => 'invalid_event_object'], 400);
		}
		$metadata = (isset($object['metadata']) && is_array($object['metadata'])) ? $object['metadata'] : [];
		$booking_id = (int) ($metadata['booking_id'] ?? 0);
		if ($booking_id <= 0) {
			$invoice_no = self::sanitize_scalar_text($metadata['invoice_number'] ?? '');
			$booking_id = self::booking_id_by_invoice_number($invoice_no);
		}
		if ($booking_id <= 0) {
			return new WP_REST_Response(['error' => 'booking_not_found'], 404);
		}

		$session_id = self::sanitize_scalar_text($object['id'] ?? '');
		$payment_intent = self::sanitize_scalar_text($object['payment_intent'] ?? '');
		$currency = self::sanitize_currency_code($object['currency'] ?? 'EUR');
		$amount_total = isset($object['amount_total']) ? ((float) $object['amount_total']) / 100 : null;

		self::mark_booking_as_paid($booking_id, 'stripe', ($payment_intent !== '' ? $payment_intent : $session_id), $amount_total, $currency, self::stripe_payment_gateway_details($object));
		if ($session_id !== '') {
			self::booking_meta_upsert($booking_id, 'payment_gateway_session_id', $session_id);
			self::payment_set_gateway_session($booking_id, $session_id);
		}
		if ($payment_intent !== '') {
			self::booking_meta_upsert($booking_id, 'payment_gateway_intent_id', $payment_intent);
		}

		return new WP_REST_Response([
			'ok' => true,
			'booking_id' => $booking_id,
		], 200);
	}

	public static function mollie_webhook($req) {
		if (strtoupper((string) $req->get_method()) !== 'POST') {
			return new WP_REST_Response([
				'ok' => true,
				'endpoint' => 'mollie_webhook',
				'message' => 'Mollie webhook endpoint is registered. Use POST requests for payment updates.',
			], 200);
		}

		$data = $req->get_body_params();
		if (!is_array($data) || empty($data['id'])) {
			$data = self::request_json_array($req);
		}

		$resource = self::sanitize_scalar_text($data['resource'] ?? '');
		$type = self::sanitize_scalar_text($data['type'] ?? '');
		$payment_id = self::sanitize_scalar_text($data['id'] ?? '');

		if ($resource === 'event') {
			if ($type === 'hook.ping') {
				return new WP_REST_Response([
					'ok' => true,
					'event' => $type,
					'ignored' => true,
				], 200);
			}

			$entity_id = self::sanitize_scalar_text($data['entityId'] ?? '');
			if ($entity_id === '' && isset($data['_embedded']['entity']['id'])) {
				$entity_id = self::sanitize_scalar_text($data['_embedded']['entity']['id']);
			}

			if (strpos($entity_id, 'tr_') === 0) {
				$payment_id = $entity_id;
			} else {
				return new WP_REST_Response([
					'ok' => true,
					'event' => ($type !== '' ? $type : 'unknown'),
					'ignored' => true,
				], 200);
			}
		}

		if ($payment_id === '') {
			return new WP_REST_Response(['error' => 'missing_payment_id'], 400);
		}

		$payment = self::mollie_api_request('GET', 'payments/' . rawurlencode($payment_id));
		if (!empty($payment['error'])) {
			return new WP_REST_Response(['error' => $payment['error']], 400);
		}

		$metadata = (isset($payment['metadata']) && is_array($payment['metadata'])) ? $payment['metadata'] : [];
		$booking_id = (int) ($metadata['booking_id'] ?? 0);
		if ($booking_id <= 0) {
			$invoice_no = self::sanitize_scalar_text($metadata['invoice_number'] ?? '');
			$booking_id = self::booking_id_by_invoice_number($invoice_no);
		}
		if ($booking_id <= 0) {
			$booking_id = self::booking_id_by_gateway_reference($payment_id);
		}
		if ($booking_id <= 0) {
			return new WP_REST_Response(['error' => 'booking_not_found'], 404);
		}

		$status = self::sanitize_scalar_text($payment['status'] ?? '');
		$currency = self::sanitize_currency_code($payment['amount']['currency'] ?? 'EUR');
		$amount = self::parse_decimal_input($payment['amount']['value'] ?? null, null);
		$gateway_details = self::mollie_payment_gateway_details($payment);

		if ($status === 'paid') {
			self::mark_booking_as_paid($booking_id, 'mollie', $payment_id, $amount, $currency, $gateway_details);
		} elseif (in_array($status, ['failed', 'expired', 'canceled', 'cancelled'], true)) {
			self::payment_upsert_for_booking(
				$booking_id,
				0,
				'card',
				'mollie',
				($status === 'expired' ? 'failed' : ($status === 'canceled' ? 'cancelled' : $status)),
				$amount,
				$currency,
				$payment_id,
				$payment_id,
				null,
				$gateway_details
			);
			self::booking_meta_upsert($booking_id, 'payment_status', 'failed');
			self::booking_meta_upsert($booking_id, 'payment_provider', 'mollie');
		}

		self::booking_meta_upsert($booking_id, 'payment_gateway_session_id', $payment_id);
		self::booking_meta_upsert($booking_id, 'payment_gateway_intent_id', $payment_id);

		return new WP_REST_Response([
			'ok' => true,
			'booking_id' => $booking_id,
			'status' => $status,
		], 200);
	}

	public static function mark_paid($req) {
		$data = self::request_json_array($req);
		$booking_id = (int) ($data['booking_id'] ?? 0);
		if ($booking_id <= 0) {
			$invoice_number = self::sanitize_scalar_text($data['invoice_number'] ?? '');
			$booking_id = self::booking_id_by_invoice_number($invoice_number);
		}
		if ($booking_id <= 0) {
			return new WP_REST_Response(['error' => 'booking_not_found'], 404);
		}

		$provider = self::sanitize_scalar_text($data['provider'] ?? 'manual');
		$reference = self::sanitize_scalar_text($data['reference'] ?? '');
		$amount = self::parse_decimal_input($data['amount'] ?? null, null);
		$currency = self::sanitize_currency_code($data['currency'] ?? 'EUR');

		self::mark_booking_as_paid($booking_id, $provider, $reference, $amount, $currency);
		return new WP_REST_Response(['ok' => true, 'booking_id' => $booking_id], 200);
	}

	public static function bank_notify($req) {
		$data = self::normalize_bank_notify_data(self::bank_notify_payload($req), $req);
		$incoming_token = self::sanitize_scalar_text($data['token'] ?? '');
		$expected_token = (string) get_option('vstrb_payment_bank_webhook_token', '');
		if ($expected_token === '' || !hash_equals($expected_token, $incoming_token)) {
			return new WP_REST_Response(['error' => 'unauthorized'], 401);
		}

		$booking_id = (int) ($data['booking_id'] ?? 0);
		$matched_by = $booking_id > 0 ? 'booking_id' : '';
		if ($booking_id <= 0) {
			$invoice_number = self::sanitize_scalar_text($data['invoice_number'] ?? '');
			$booking_id = self::booking_id_by_invoice_number($invoice_number);
			if ($booking_id > 0) {
				$matched_by = 'invoice_number';
			}
		}
		if ($booking_id <= 0) {
			$reference_lookup = self::sanitize_scalar_text($data['reference'] ?? '');
			$booking_id = self::booking_id_by_payment_reference($reference_lookup);
			if ($booking_id > 0) {
				$matched_by = 'reference';
			}
		}
		if ($booking_id <= 0) {
			return new WP_REST_Response(['error' => 'booking_not_found'], 404);
		}

		$reference = self::sanitize_scalar_text($data['reference'] ?? '');
		$amount = self::parse_decimal_input($data['amount'] ?? null, null);
		$currency = self::sanitize_currency_code($data['currency'] ?? 'EUR');
		self::mark_booking_as_paid($booking_id, 'bank_transfer', $reference, $amount, $currency);
		return new WP_REST_Response([
			'ok' => true,
			'booking_id' => $booking_id,
			'matched_by' => ($matched_by !== '' ? $matched_by : 'unknown'),
			'reference' => ($reference !== '' ? $reference : null),
		], 200);
	}

	private static function map_guest_source_to_booking_source($guest_source) {
		$source = strtolower(trim((string) $guest_source));
		$map = [
			'booking_com' => 'ical_booking',
			'airbnb' => 'ical_airbnb',
			'vrbo' => 'ical_vrbo',
			'expedia' => 'ical_expedia',
			'direct' => 'direct',
		];
		return $map[$source] ?? '';
	}

	private static function find_exact_booking_match_for_guest_registration($unit_id, $date_from, $date_to, $email, $source, $booking_reference) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$unit_id = (int) $unit_id;
		if (!self::is_valid_ymd($date_from) || !self::is_valid_ymd($date_to)) {
			return null;
		}

		$source_filter = self::map_guest_source_to_booking_source($source);
		$normalized_ref = trim((string) $booking_reference);
		$email = trim((string) $email);
		$is_internal_auto_ref = (strpos($normalized_ref, 'VSTRB-') === 0);

		$candidate_statuses_sql = "('pending','confirmed','blocked')";
		$base_sql = "SELECT id, unit_id FROM {$t['bookings']} WHERE date_from = %s AND date_to = %s AND status IN {$candidate_statuses_sql}";
		$base_params = [$date_from, $date_to];
		if ($unit_id > 0) {
			$base_sql .= " AND unit_id = %d";
			$base_params[] = $unit_id;
		}

		$source_sql = $base_sql;
		$source_params = $base_params;
		if ($source_filter !== '') {
			$source_sql .= " AND source = %s";
			$source_params[] = $source_filter;
		}

		if ($normalized_ref !== '' && !$is_internal_auto_ref) {
			if (preg_match('/^\d+$/', $normalized_ref)) {
				foreach ([[$source_sql, $source_params], [$base_sql, $base_params]] as $query_parts) {
					$sql = $query_parts[0] . " AND id = %d";
					$params = array_merge($query_parts[1], [(int) $normalized_ref]);
					$rows = $wpdb->get_results($wpdb->prepare($sql, ...$params), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
					if (count($rows) === 1) {
						return ['booking_id' => (int) $rows[0]['id'], 'unit_id' => (int) $rows[0]['unit_id']];
					}
				}
			}

			foreach ([[$source_sql, $source_params], [$base_sql, $base_params]] as $query_parts) {
				$sql = $query_parts[0] . " AND external_booking_id = %s";
				$params = array_merge($query_parts[1], [$normalized_ref]);
				$rows = $wpdb->get_results($wpdb->prepare($sql, ...$params), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				if (count($rows) === 1) {
					return ['booking_id' => (int) $rows[0]['id'], 'unit_id' => (int) $rows[0]['unit_id']];
				}
			}
		}

		if ($email !== '') {
			foreach ([[$source_sql, $source_params], [$base_sql, $base_params]] as $query_parts) {
				$sql = $query_parts[0] . " AND customer_email = %s";
				$params = array_merge($query_parts[1], [$email]);
				$rows = $wpdb->get_results($wpdb->prepare($sql, ...$params), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				if (count($rows) === 1) {
					return ['booking_id' => (int) $rows[0]['id'], 'unit_id' => (int) $rows[0]['unit_id']];
				}
			}
		}

		if ($unit_id > 0) {
			$rows = $wpdb->get_results($wpdb->prepare($base_sql, ...$base_params), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			if (count($rows) === 1) {
				return ['booking_id' => (int) $rows[0]['id'], 'unit_id' => (int) $rows[0]['unit_id']];
			}
			return null;
		}

		$rows = $wpdb->get_results($wpdb->prepare($base_sql, ...$base_params), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		if (count($rows) === 1) {
			return ['booking_id' => (int) $rows[0]['id'], 'unit_id' => (int) $rows[0]['unit_id']];
		}

		return null;
	}

	public static function guest_register($req) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$data      = self::request_json_array($req);
		$unit_id   = (int)($data['unit_id'] ?? 0);
		$date_from = self::sanitize_scalar_text($data['date_from'] ?? '');
		$date_to   = self::sanitize_scalar_text($data['date_to'] ?? '');
		$email     = self::sanitize_scalar_email($data['email'] ?? '');
		$hp             = self::sanitize_scalar_text($data['hp'] ?? '');
		$captcha        = trim(self::sanitize_scalar_text($data['captcha'] ?? ''));
		$captcha_token  = trim(self::sanitize_scalar_text($data['captcha_token'] ?? ''));
		$guests         = isset($data['guests']) && is_array($data['guests']) ? $data['guests'] : [];
		$allowed_sources = ['booking_com', 'airbnb', 'vrbo', 'expedia', 'direct', 'other'];
		$source_raw       = self::sanitize_scalar_text($data['source'] ?? '');
		$source           = in_array($source_raw, $allowed_sources, true) ? $source_raw : '';
		$booking_reference = self::sanitize_scalar_text($data['booking_reference'] ?? '');
		$billing_raw = (isset($data['billing']) && is_array($data['billing'])) ? $data['billing'] : [];
		$billing_enabled = !empty($billing_raw['enabled']) ? 1 : 0;
		$billing = [
			'billing_enabled'    => $billing_enabled,
			'billing_name'       => self::sanitize_scalar_text($billing_raw['name'] ?? ''),
			'billing_company_id' => self::sanitize_scalar_text($billing_raw['company_id'] ?? ''),
			'billing_vat_id'     => self::sanitize_scalar_text($billing_raw['vat_id'] ?? ''),
			'billing_street'     => self::sanitize_scalar_text($billing_raw['street'] ?? ''),
			'billing_city'       => self::sanitize_scalar_text($billing_raw['city'] ?? ''),
			'billing_zip'        => self::sanitize_scalar_text($billing_raw['zip'] ?? ''),
			'billing_country'    => self::sanitize_scalar_text($billing_raw['country'] ?? ''),
		];
		if (!$billing_enabled) {
			$billing['billing_name'] = '';
			$billing['billing_company_id'] = '';
			$billing['billing_vat_id'] = '';
			$billing['billing_street'] = '';
			$billing['billing_city'] = '';
			$billing['billing_zip'] = '';
			$billing['billing_country'] = '';
		}
		if (empty($booking_reference)) {
			$booking_reference = 'VSTRB-' . $unit_id . '-' . gmdate('Ymd') . '-' . strtoupper(substr(md5(uniqid('', true)), 0, 6));
		}

		// Anti-bot
		if (!empty($hp)) {
			return new WP_REST_Response(['error' => 'robot_detected'], 400);
		}
		if (!VSTRB_Shortcodes::verify_captcha($captcha_token, $captcha)) {
			return new WP_REST_Response(['error' => 'captcha_invalid'], 400);
		}

		if ($unit_id <= 0 || !VSTRB_DB::unit_exists($unit_id)) {
			return new WP_REST_Response(['error' => 'unit_not_found'], 404);
		}

		if (!$date_from || !$date_to || !$email || empty($guests)) {
			return new WP_REST_Response(['error' => 'missing_fields'], 400);
		}

		foreach ([$date_from, $date_to] as $d) {
			if (!self::is_valid_ymd($d)) {
				return new WP_REST_Response(['error' => 'invalid_date_format'], 400);
			}
		}

		if (strtotime($date_to) <= strtotime($date_from)) {
			return new WP_REST_Response(['error' => 'invalid_date_range'], 400);
		}

		// Sanitize and validate each guest
		$clean_guests = [];
		foreach ($guests as $g) {
			if (!is_array($g)) {
				return new WP_REST_Response(['error' => 'invalid_guest_payload'], 400);
			}
				$first_name  = self::sanitize_scalar_text($g['first_name'] ?? '');
				$last_name   = self::sanitize_scalar_text($g['last_name'] ?? '');
				$dob         = self::sanitize_scalar_text($g['date_of_birth'] ?? '');
				$street      = self::sanitize_scalar_text($g['street'] ?? '');
				$city        = self::sanitize_scalar_text($g['city'] ?? '');
				$state       = self::sanitize_scalar_text($g['state'] ?? '');
				$doc_type    = self::sanitize_scalar_text($g['document_type'] ?? '');
				$doc_number  = self::sanitize_scalar_text($g['document_number'] ?? '');
				$nationality = self::sanitize_scalar_text($g['nationality'] ?? '');

			if (!$first_name || !$last_name || !$dob || !$street || !$city || !$doc_type || !$doc_number || !$nationality) {
				return new WP_REST_Response(['error' => 'missing_fields'], 400);
			}
			if (!self::is_valid_ymd($dob)) {
				return new WP_REST_Response(['error' => 'invalid_date_format'], 400);
			}

			$clean_guests[] = [
				'first_name'      => $first_name,
				'last_name'       => $last_name,
				'date_of_birth'   => $dob,
				'street'          => $street,
				'city'            => $city,
				'state'           => $state,
				'document_type'   => $doc_type,
				'document_number' => $doc_number,
				'nationality'     => $nationality,
			];
		}

		// Insert all guests
		$gr_table = $t['guest_registrations'];
		$matched_booking = self::find_exact_booking_match_for_guest_registration(
			$unit_id,
			$date_from,
			$date_to,
			$email,
			$source,
			$booking_reference
		);
		$matched_booking_id = is_array($matched_booking) ? (int) ($matched_booking['booking_id'] ?? 0) : 0;
		$registration_unit_id = is_array($matched_booking) && (int) ($matched_booking['unit_id'] ?? 0) > 0 ? (int) $matched_booking['unit_id'] : $unit_id;
		$ids = [];
		foreach ($clean_guests as $g) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$ok = $wpdb->insert($gr_table, array_merge($g, [
				'unit_id'          => $registration_unit_id,
				'booking_id'       => ($matched_booking_id > 0 ? $matched_booking_id : null),
				'date_from'        => $date_from,
				'date_to'          => $date_to,
				'email'            => $email,
				'source'           => $source,
				'booking_reference' => $booking_reference,
			], $billing));
			if (!$ok) {
				return new WP_REST_Response(['error' => 'db_error'], 500);
			}
			$ids[] = (int) $wpdb->insert_id;
		}

		VSTRB_Emails::notify_admin_guest_registration_group($registration_unit_id, $ids, $date_from, $date_to, $email);
		VSTRB_Emails::notify_guest_registration_group_received($registration_unit_id, $ids, $date_from, $date_to, $email);

		return new WP_REST_Response([
			'ok' => true,
			'ids' => $ids,
			'count' => count($ids),
			'booking_id' => ($matched_booking_id > 0 ? $matched_booking_id : null),
			'booking_linked_automatically' => ($matched_booking_id > 0),
		], 201);
	}
	// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value, PluginCheck.Security.DirectDB.UnescapedDBParameter
}

add_filter('rest_pre_serve_request', ['VSTRB_REST', 'serve_raw_ical_export'], 10, 4);
