<?php
/**
 * iCal import and export logic.
 *
 * @package Vachr Short-Term Rental Reservations
 * @license GPLv2 or later
 */
if (!defined('ABSPATH')) exit;

class VSTRB_iCal {
	// iCal sync relies on custom table queries and transactions by design.
	// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	const CANCELLED_META_KEY = '_vstrb_ical_cancelled_at';
	const CANCELLED_PURGE_DAYS = 14;

	public static function detect_provider($url) {
		$url = strtolower($url);
		// Airbnb - supports multiple country domains
		if (strpos($url, 'airbnb.') !== false) return 'airbnb';
		// Booking.com - supports multiple country domains
		if (strpos($url, 'booking.') !== false) return 'booking';
		if (strpos($url, 'e-chalupy.cz') !== false) return 'e_chalupy';
		if (strpos($url, 'megaubytovanie.sk') !== false) return 'megaubytovanie';
		if (strpos($url, 'hauzi.sk') !== false || strpos($url, 'hauzi.cz') !== false) return 'hauzi';
		if (strpos($url, 'bezrealitky.cz') !== false || strpos($url, 'bezrealitky.sk') !== false) return 'bezrealitky';
		
		// Extract domain name for unknown providers
		$parsed = wp_parse_url($url);
		if (isset($parsed['host'])) {
			// Remove www. prefix if present
			$domain = preg_replace('/^www\./i', '', $parsed['host']);
			return $domain;
		}
		
		return 'other';
	}

	public static function normalize_provider($provider, $url = '') {
		$provider_raw = trim((string) $provider);
		$provider_lc = strtolower($provider_raw);

		if ($provider_lc === '' || $provider_lc === 'other') {
			$provider_lc = strtolower((string) self::detect_provider((string) $url));
			$provider_raw = $provider_lc;
		}

		if (strpos($provider_lc, 'airbnb') !== false) return 'airbnb';
		if (strpos($provider_lc, 'booking') !== false) return 'booking';
		if (in_array($provider_lc, ['e_chalupy', 'e-chalupy', 'e-chalupy.cz', 'echalupy'], true)) return 'e_chalupy';
		if (strpos($provider_lc, 'megaubytovanie') !== false) return 'megaubytovanie';
		if (strpos($provider_lc, 'hauzi') !== false) return 'hauzi';
		if (strpos($provider_lc, 'bezrealitky') !== false) return 'bezrealitky';

		if ($provider_raw === '') {
			return 'other';
		}

		return sanitize_text_field($provider_raw);
	}

	private static function source_aliases_for_provider($provider) {
		$provider = sanitize_text_field((string) $provider);
		$sources = [];
		if ($provider !== '') {
			$sources[] = 'ical_' . $provider;
		}

		$legacy_aliases = [
			'e_chalupy' => ['ical_e-chalupy', 'ical_e-chalupy.cz', 'ical_echalupy'],
			'airbnb' => ['ical_airbnb.com'],
			'booking' => ['ical_booking.com', 'ical_booking_com'],
		];
		if (isset($legacy_aliases[$provider])) {
			$sources = array_merge($sources, $legacy_aliases[$provider]);
		}

		if ($provider !== '' && $provider !== 'other') {
			$sources[] = 'ical_other';
		}

		return array_values(array_unique(array_filter($sources)));
	}

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

		$unit_id = (int)$req->get_param('unit_id');
		$token = sanitize_text_field($req->get_param('token'));
		$direct_only = self::is_direct_only_export($req);

		if (!VSTRB_DB::unit_exists($unit_id)) return new WP_REST_Response('unit_not_found', 404);

		$saved = get_post_meta($unit_id, '_vstrb_ical_token', true);
		if (!$saved || !hash_equals($saved, $token)) return new WP_REST_Response('forbidden', 403);

		$sql = "SELECT id, date_from, date_to, status, source FROM {$t['bookings']} WHERE unit_id=%d AND status IN ('pending','confirmed','blocked')";
		if ($direct_only) {
			$sql .= " AND (source IS NULL OR LEFT(source, 5) <> 'ical_')";
		}

		$rows = $wpdb->get_results($wpdb->prepare(
			$sql,
			$unit_id
		), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.

		$lines = [];
		$lines[] = "BEGIN:VCALENDAR";
		$lines[] = "VERSION:2.0";
		$lines[] = "PRODID:-//ApartmentsBooking//EN";
		$lines[] = "CALSCALE:GREGORIAN";
		$lines[] = "METHOD:PUBLISH";

		foreach ($rows as $r) {
			$uid = 'vstrb-' . $unit_id . '-' . $r['id'] . '@' . wp_parse_url(home_url(), PHP_URL_HOST);
			$dtstart = str_replace('-', '', $r['date_from']);
			$dtend = str_replace('-', '', $r['date_to']); // iCal DTEND is exclusive for all-day
			$summary = ($r['status'] === 'blocked') ? 'Blocked' : 'Reserved';
			$lines[] = "BEGIN:VEVENT";
			$lines[] = "UID:$uid";
			$lines[] = "DTSTAMP:" . gmdate('Ymd\THis\Z');
			$lines[] = "DTSTART;VALUE=DATE:$dtstart";
			$lines[] = "DTEND;VALUE=DATE:$dtend";
			$lines[] = "SUMMARY:" . self::escape($summary);
			$lines[] = "END:VEVENT";
		}

			$lines[] = "END:VCALENDAR";
			$ical = implode("\r\n", $lines) . "\r\n";

			return new WP_REST_Response($ical, 200, [
				'Content-Type' => 'text/calendar; charset=UTF-8',
				'Content-Disposition' => 'inline; filename="unit-' . $unit_id . '.ics"',
			]);
	}

	private static function is_direct_only_export($req) {
		if (!($req instanceof WP_REST_Request)) {
			return false;
		}

		$route = (string) $req->get_route();
		if (strpos($route, '/vstrb/v1/ical/export/direct') === 0) {
			return true;
		}

		$scope = strtolower(sanitize_text_field((string) $req->get_param('scope')));
		if (in_array($scope, ['direct', 'internal', 'local'], true)) {
			return true;
		}

		return (bool) rest_sanitize_boolean($req->get_param('direct_only'));
	}

	private static function escape($s) {
		$s = (string)$s;
		$s = str_replace("\\", "\\\\", $s);
		$s = str_replace("\n", "\\n", $s);
		$s = str_replace([",",";"], ["\\,","\\;"], $s);
		return $s;
	}

	private static function invalidate_calendar_cache($unit_id) {
		$unit_id = (int) $unit_id;
		if ($unit_id <= 0) {
			return;
		}

		$key = 'vstrb_v_' . $unit_id;
		$group = 'vstrb_calendar';
		$current = wp_cache_get($key, $group);
		if ($current === false || !is_numeric($current)) {
			wp_cache_set($key, 2, $group, 3600);
			return;
		}

		$incr = wp_cache_incr($key, 1, $group);
		if ($incr === false) {
			wp_cache_set($key, ((int) $current) + 1, $group, 3600);
		}
	}

	public static function import_unit_feeds($unit_id) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$feeds = $wpdb->get_results($wpdb->prepare(
			"SELECT * FROM {$t['ical_feeds']} WHERE unit_id=%d AND is_active=1",
			$unit_id
		), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.

		if (!$feeds) return ['ok'=>true, 'message'=>'No active imports.'];

		$imported = 0;
		foreach ($feeds as $f) {
			$url = $f['url'];
			$provider = self::normalize_provider($f['provider'] ?? '', $url);
			if ($provider !== (string) ($f['provider'] ?? '')) {
				$wpdb->update($t['ical_feeds'], ['provider' => $provider], ['id' => $f['id']]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
			}
			$res = wp_remote_get($url, ['timeout'=>20, 'redirection'=>3]);
			if (is_wp_error($res)) {
				$wpdb->update($t['ical_feeds'], ['last_error'=>$res->get_error_message(), 'last_import_at'=>current_time('mysql')], ['id'=>$f['id']]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
				continue;
			}
			$response_code = (int) wp_remote_retrieve_response_code($res);
			if ($response_code < 200 || $response_code >= 300) {
				$wpdb->update($t['ical_feeds'], ['last_error'=>sprintf('iCal source returned HTTP %d.', $response_code), 'last_import_at'=>current_time('mysql')], ['id'=>$f['id']]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
				continue;
			}
			$body = wp_remote_retrieve_body($res);
			try {
				$events = self::parse_ics_events($body);
				if (empty($events)) {
					throw new Exception('No valid iCal events found; cleanup skipped to protect existing reservations.');
				}
				$n = self::upsert_blocks_from_events($unit_id, $events, $provider);
				$imported += $n;
				$wpdb->update($t['ical_feeds'], ['last_error'=>null, 'last_import_at'=>current_time('mysql')], ['id'=>$f['id']]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
			} catch (Exception $e) {
				$wpdb->update($t['ical_feeds'], ['last_error'=>$e->getMessage(), 'last_import_at'=>current_time('mysql')], ['id'=>$f['id']]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
			}
		}

		// Always bump cache version after import run to avoid stale calendar ranges.
		self::invalidate_calendar_cache($unit_id);

		return ['ok'=>true, 'message'=> sprintf(
			/* translators: %d: number of imported or updated blocks */
			esc_html__('Done. Imported/updated blocks: %d', 'vachr-short-term-rental-reservations'), 
			(int)$imported
		)];
	}

	private static function delete_blocks_by_ids($unit_id, $booking_ids) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$ids = array_values(array_unique(array_filter(array_map('intval', (array) $booking_ids), function($id) {
			return $id > 0;
		})));
		if (empty($ids)) {
			return 0;
		}

		$id_placeholders = implode(', ', array_fill(0, count($ids), '%d'));

		$wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.

		$deleted_days = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$t['booking_days']} WHERE booking_id IN ({$id_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				...$ids
			)
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		if ($deleted_days === false) {
			$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
			return 0;
		}

		$deleted_services = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$t['booking_services']} WHERE booking_id IN ({$id_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				...$ids
			)
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		if ($deleted_services === false) {
			$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
			return 0;
		}

		$deleted_meta = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$t['booking_meta']} WHERE booking_id IN ({$id_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				...$ids
			)
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		if ($deleted_meta === false) {
			$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
			return 0;
		}

		$deleted_bookings = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$t['bookings']} WHERE unit_id=%d AND id IN ({$id_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				...array_merge([(int) $unit_id], $ids)
			)
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		if ($deleted_bookings === false) {
			$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
			return 0;
		}

		$wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.

		if ((int) $deleted_bookings > 0) {
			self::invalidate_calendar_cache($unit_id);
		}

		return (int) $deleted_bookings;
	}

	private static function upsert_booking_meta($booking_id, $meta_key, $meta_value) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$booking_id = (int) $booking_id;
		$meta_key = sanitize_key((string) $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 DESC LIMIT 1",
			$booking_id,
			$meta_key
		)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.

		if ($existing_id > 0) {
			return false !== $wpdb->update(
				$t['booking_meta'],
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- The custom booking metadata table stores arbitrary integration values.
				['meta_value' => (string) $meta_value],
				['id' => $existing_id]
			); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		}

		return false !== $wpdb->insert(
			$t['booking_meta'],
			[
				'booking_id' => $booking_id,
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The custom booking metadata table uses this field as its metadata key.
				'meta_key' => $meta_key,
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- The custom booking metadata table stores arbitrary integration values.
				'meta_value' => (string) $meta_value,
			]
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
	}

	private static function delete_booking_meta($booking_id, $meta_key) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$booking_id = (int) $booking_id;
		$meta_key = sanitize_key((string) $meta_key);
		if ($booking_id <= 0 || $meta_key === '') {
			return false;
		}

		return false !== $wpdb->delete(
			$t['booking_meta'],
			[
				'booking_id' => $booking_id,
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The custom booking metadata table uses this field as its metadata key.
				'meta_key' => $meta_key,
			]
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
	}

	private static function lock_booking_days($unit_id, $booking_id, $date_from, $date_to) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$unit_id = (int) $unit_id;
		$booking_id = (int) $booking_id;
		if ($unit_id <= 0 || $booking_id <= 0 || strtotime($date_to) <= strtotime($date_from)) {
			return false;
		}

		$d1 = new DateTime($date_from);
		$d2 = new DateTime($date_to);
		while ($d1 < $d2) {
			$day = $d1->format('Y-m-d');
			$ins = $wpdb->query($wpdb->prepare(
				"INSERT IGNORE INTO {$t['booking_days']} (unit_id, day, booking_id) VALUES (%d, %s, %d)",
				$unit_id,
				$day,
				$booking_id
			)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
			if ($ins !== 1) {
				return false;
			}
			$d1->modify('+1 day');
		}

		return true;
	}

	private static function should_skip_import_event($event, $provider, $external_booking_id = null) {
		$provider = self::normalize_provider((string) $provider);
		$summary = strtolower(trim((string) ($event['summary'] ?? '')));
		$external_booking_id = trim((string) $external_booking_id);

		if (
			$provider === 'airbnb'
			&& $external_booking_id === ''
			&& strpos($summary, 'not available') !== false
		) {
			return true;
		}

		return false;
	}

	private static function has_external_links($booking_id) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$booking_id = (int) $booking_id;
		if ($booking_id <= 0) {
			return true;
		}

		$linked_counts = [
			(int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$t['guest_registrations']} WHERE booking_id=%d", $booking_id)),
			(int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$t['invoices']} WHERE booking_id=%d", $booking_id)),
			(int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$t['payments']} WHERE booking_id=%d", $booking_id)),
			(int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$t['booking_services']} WHERE booking_id=%d", $booking_id)),
		]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table queries.

		foreach ($linked_counts as $count) {
			if ($count > 0) {
				return true;
			}
		}

		return false;
	}

	private static function purge_old_unlinked_cancelled_blocks($unit_id, array $sources) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$unit_id = (int) $unit_id;
		$sources = array_values(array_unique(array_filter(array_map(static function($source) {
			$source = sanitize_text_field((string) $source);
			return $source !== '' ? $source : null;
		}, $sources))));
		if ($unit_id <= 0 || empty($sources)) {
			return 0;
		}

		$cutoff = gmdate('Y-m-d H:i:s', current_time('timestamp') - (self::CANCELLED_PURGE_DAYS * DAY_IN_SECONDS));
		$source_placeholders = implode(', ', array_fill(0, count($sources), '%s'));
		$params = array_merge([$unit_id], $sources, [self::CANCELLED_META_KEY, $cutoff]);

		$rows = $wpdb->get_results(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The source placeholder list and replacement arguments are built dynamically from the validated source list.
			$wpdb->prepare(
				"SELECT b.id
				 FROM {$t['bookings']} b
				 INNER JOIN {$t['booking_meta']} m ON m.booking_id = b.id
				 WHERE b.unit_id=%d
				   AND b.source IN ({$source_placeholders})
				   AND b.status='cancelled'
				   AND m.meta_key=%s
				   AND m.meta_value <= %s",
				...$params
			)
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		if (!is_array($rows) || empty($rows)) {
			return 0;
		}

		$ids_to_delete = [];
		foreach ($rows as $row) {
			$booking_id = (int) ($row->id ?? 0);
			if ($booking_id > 0 && !self::has_external_links($booking_id)) {
				$ids_to_delete[] = $booking_id;
			}
		}

		return self::delete_blocks_by_ids($unit_id, $ids_to_delete);
	}

	private static function cleanup_future_blocks_for_sources($unit_id, array $sources, $keep_booking_ids = [], $send_cancellation_notifications = false) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$unit_id = (int) $unit_id;
		$sources = array_values(array_unique(array_filter(array_map(static function($source) {
			$source = sanitize_text_field((string) $source);
			return $source !== '' ? $source : null;
		}, $sources))));
		if ($unit_id <= 0 || empty($sources)) {
			return 0;
		}

		$today = current_time('Y-m-d');

		$keep_ids = array_values(array_unique(array_filter(array_map('intval', (array) $keep_booking_ids), function($id) {
			return $id > 0;
		})));

		$source_placeholders = implode(', ', array_fill(0, count($sources), '%s'));
		$params = array_merge([$unit_id], $sources, [$today]);
		$sql = "SELECT *
			FROM {$t['bookings']}
			WHERE unit_id=%d
			  AND source IN ({$source_placeholders})
			  AND status='blocked'
			  AND date_to > %s";

		if (!empty($keep_ids)) {
			$keep_placeholders = implode(', ', array_fill(0, count($keep_ids), '%d'));
			$sql .= " AND id NOT IN ({$keep_placeholders})"; // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			$params = array_merge($params, $keep_ids);
		}

		$cache_key = 'vstrb_cleanup_rows_' . md5(wp_json_encode(array($unit_id, $sources, $today, $keep_ids)));
		$rows_to_cancel = wp_cache_get($cache_key, 'vstrb_ical');
		if (false === $rows_to_cancel) {
			$rows_to_cancel = $wpdb->get_results($wpdb->prepare($sql, ...$params)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
			if (!is_array($rows_to_cancel)) {
				$rows_to_cancel = array();
			}
			wp_cache_set($cache_key, $rows_to_cancel, 'vstrb_ical', 300);
		}
		$ids_to_cancel = array_values(array_unique(array_filter(array_map(static function($row) {
			return (int) ($row->id ?? 0);
		}, $rows_to_cancel), static function($id) {
			return $id > 0;
		})));

		$cancelled = 0;
		if (!empty($ids_to_cancel)) {
			$id_placeholders = implode(', ', array_fill(0, count($ids_to_cancel), '%d'));
			$wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
			$deleted_days = $wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$t['booking_days']} WHERE booking_id IN ({$id_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
					...$ids_to_cancel
				)
			); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
			if ($deleted_days === false) {
				$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
				return 0;
			}

			$cancelled_result = $wpdb->query(
				$wpdb->prepare(
					"UPDATE {$t['bookings']} SET status='cancelled' WHERE unit_id=%d AND id IN ({$id_placeholders}) AND status='blocked'", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
					...array_merge([$unit_id], $ids_to_cancel)
				)
			); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
			if ($cancelled_result === false) {
				$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
				return 0;
			}
			$cancelled = (int) $cancelled_result;

			$wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.

			$cancelled_at = current_time('mysql');
			foreach ($ids_to_cancel as $booking_id) {
				self::upsert_booking_meta($booking_id, self::CANCELLED_META_KEY, $cancelled_at);
			}
			wp_cache_delete($cache_key, 'vstrb_ical');
			if ($send_cancellation_notifications && class_exists('VSTRB_Emails')) {
				foreach ($rows_to_cancel as $booking) {
					VSTRB_Emails::notify_ical_booking_cancelled($booking);
				}
			}
		}

		$purged = self::purge_old_unlinked_cancelled_blocks($unit_id, $sources);
		if ($cancelled > 0 || $purged > 0) {
			self::invalidate_calendar_cache($unit_id);
		}

		return $cancelled + $purged;
	}

	public static function cleanup_future_blocks_for_provider($unit_id, $provider, $keep_booking_ids = [], $send_cancellation_notifications = false) {
		global $wpdb;
		$t = VSTRB_DB::tables();

		$unit_id = (int) $unit_id;
		$provider = sanitize_text_field((string) $provider);
		if ($provider === '') {
			return 0;
		}

		$sources = self::source_aliases_for_provider($provider);
		if ($provider !== 'other') {
			$active_other_feeds = (int) $wpdb->get_var($wpdb->prepare(
				"SELECT COUNT(*) FROM {$t['ical_feeds']} WHERE unit_id=%d AND provider=%s AND is_active=1",
				$unit_id,
				'other'
			));
			if ($active_other_feeds > 0) {
				$sources = array_values(array_diff($sources, ['ical_other']));
			}
		}

		return self::cleanup_future_blocks_for_sources(
			$unit_id,
			$sources,
			$keep_booking_ids,
			$send_cancellation_notifications
		);
	}

	public static function parse_ics_events($ics) {
		$ics = (string) $ics;
		if (stripos($ics, 'BEGIN:VCALENDAR') === false || stripos($ics, 'END:VCALENDAR') === false) {
			throw new Exception('Invalid iCal response received; cleanup skipped.');
		}

		// Enhanced iCal parser: reads DTSTART/DTEND/UID/SUMMARY/DESCRIPTION per VEVENT
		$ics = str_replace("\r\n", "\n", $ics);
		$lines = explode("\n", $ics);

		// unfold lines (RFC: lines starting with space are continuation)
		$unfolded = [];
		foreach ($lines as $line) {
			if ($line === '') continue;
			if (preg_match('/^[ \t]/', $line) && !empty($unfolded)) {
				$unfolded[count($unfolded)-1] .= trim($line);
			} else {
				$unfolded[] = trim($line);
			}
		}

		$events = [];
		$in = false;
		$cur = [];
		foreach ($unfolded as $line) {
			if ($line === 'BEGIN:VEVENT') { $in = true; $cur = []; continue; }
			if ($line === 'END:VEVENT') {
				if (!empty($cur['DTSTART']) && !empty($cur['DTEND'])) {
					$events[] = [
						'start' => $cur['DTSTART'],
						'end' => $cur['DTEND'],
						'uid' => $cur['UID'] ?? null,
						'summary' => $cur['SUMMARY'] ?? null,
						'description' => $cur['DESCRIPTION'] ?? null,
					];
				}
				$in = false;
				$cur = [];
				continue;
			}
			if (!$in) continue;

			if (stripos($line, 'DTSTART') === 0) {
				$cur['DTSTART'] = self::parse_date_value($line);
			} elseif (stripos($line, 'DTEND') === 0) {
				$cur['DTEND'] = self::parse_date_value($line);
			} elseif (stripos($line, 'UID:') === 0) {
				$cur['UID'] = self::parse_text_value($line);
			} elseif (stripos($line, 'SUMMARY:') === 0) {
				$cur['SUMMARY'] = self::parse_text_value($line);
			} elseif (stripos($line, 'DESCRIPTION:') === 0) {
				$cur['DESCRIPTION'] = self::parse_text_value($line);
			}
		}

		return $events;
	}

	private static function parse_date_value($line) {
		// Examples:
		// DTSTART;VALUE=DATE:20260105
		// DTSTART:20260105
		// DTSTART:20260226T140000
		// DTSTART:20260226T220000Z
		// DTSTART;TZID=Europe/Prague:20260226T230000
		$parts = explode(':', $line, 2);
		$prop = trim($parts[0] ?? '');
		$val = trim($parts[1] ?? '');
		if ($val === '') {
			throw new Exception('Unsupported DTSTART/DTEND format.');
		}

		$meta = [
			'value' => '',
			'tzid' => '',
		];
		$prop_parts = explode(';', $prop);
		foreach ($prop_parts as $idx => $pp) {
			if ($idx === 0) continue;
			$kv = explode('=', $pp, 2);
			$key = strtoupper(trim($kv[0] ?? ''));
			$v = trim($kv[1] ?? '');
			if ($key === 'VALUE') {
				$meta['value'] = strtoupper($v);
			} elseif ($key === 'TZID') {
				$meta['tzid'] = $v;
			}
		}

		// All-day values are date-only and should not be timezone-shifted.
		if ($meta['value'] === 'DATE' || preg_match('/^\d{8}$/', $val)) {
			return substr($val, 0, 4) . '-' . substr($val, 4, 2) . '-' . substr($val, 6, 2);
		}

		$timezone_string = (string) get_option('timezone_string', '');
		if ($timezone_string === '') {
			$offset = (float) get_option('gmt_offset', 0);
			$sign = $offset < 0 ? '-' : '+';
			$offset = abs($offset);
			$timezone_string = sprintf('%s%02d:%02d', $sign, floor($offset), round(($offset - floor($offset)) * 60));
		}
		try {
			$site_tz = new DateTimeZone($timezone_string);
		} catch (Exception $e) {
			$site_tz = new DateTimeZone('UTC');
		}
		$src_tz = $site_tz;
		if ($meta['tzid'] !== '') {
			try {
				$src_tz = new DateTimeZone($meta['tzid']);
			} catch (Exception $e) {
				$src_tz = $site_tz;
			}
		}

		$dt = null;
		if (preg_match('/^\d{8}T\d{6}Z$/', $val)) {
			$dt = DateTime::createFromFormat('Ymd\THis\Z', $val, new DateTimeZone('UTC'));
		} elseif (preg_match('/^\d{8}T\d{6}[+-]\d{4}$/', $val)) {
			$dt = DateTime::createFromFormat('Ymd\THisO', $val);
		} elseif (preg_match('/^\d{8}T\d{6}$/', $val)) {
			$dt = DateTime::createFromFormat('Ymd\THis', $val, $src_tz);
		} elseif (preg_match('/^\d{8}T\d{4}$/', $val)) {
			$dt = DateTime::createFromFormat('Ymd\THi', $val, $src_tz);
		}

		if (!$dt instanceof DateTime) {
			throw new Exception('Unsupported DTSTART/DTEND format.');
		}

		$dt->setTimezone($site_tz);
		return $dt->format('Y-m-d');
	}

	private static function parse_text_value($line) {
		// Extract text value after colon, unescape iCal special chars
		$parts = explode(':', $line, 2);
		$val = $parts[1] ?? '';
		$val = trim($val);
		// Unescape iCal encoding
		$val = str_replace(['\\n', '\\,', '\\;', '\\\\'], ["\n", ',', ';', '\\'], $val);
		return $val;
	}

	private static function extract_booking_id($summary, $description, $provider) {
		// Try to extract reservation/booking ID from summary or description
		$text = ($summary ?? '') . ' ' . ($description ?? '');
		
		// Airbnb patterns
		if ($provider === 'airbnb') {
			if (preg_match('/\b(HM[A-Z0-9]{10,})\b/i', $text, $m)) return $m[1];
			if (preg_match('/confirmation code[:\s]+([A-Z0-9]{8,})/i', $text, $m)) return $m[1];
			if (preg_match('/reservation[:\s]+([A-Z0-9]{8,})/i', $text, $m)) return $m[1];
		}
		
		// Booking.com patterns
		if ($provider === 'booking') {
			if (preg_match('/booking\.com[\s\w]*[:\s]+(\d{10,})/i', $text, $m)) return $m[1];
			if (preg_match('/reservation number[:\s]+(\d{8,})/i', $text, $m)) return $m[1];
			if (preg_match('/\b(\d{10})\b/', $text, $m)) return $m[1]; // 10-digit number
		}
		
		// Generic patterns
		if (preg_match('/\b([A-Z]{2}\d{8,})\b/', $text, $m)) return $m[1];
		if (preg_match('/booking[:\s#]+([A-Z0-9-]{6,})/i', $text, $m)) return $m[1];
		if (preg_match('/reservation[:\s#]+([A-Z0-9-]{6,})/i', $text, $m)) return $m[1];
		
		return null;
	}

	private static function upsert_blocks_from_events($unit_id, $events, $provider) {
		// Strategy (MVP):
		// - Create "blocked" bookings for each event if not exists with same date_from/date_to/source
		// - Keep historical iCal blocks, but remove stale future ones that disappeared from feed
		// - Store iCal metadata (UID, summary, description, external booking ID)
		if (empty($events)) {
			return 0;
		}

		global $wpdb;
		$t = VSTRB_DB::tables();
		$count = 0;
		$source = 'ical_' . $provider;
		$source_aliases = self::source_aliases_for_provider($provider);
		$existing_by_uid = [];
		$existing_by_range = [];
		$existing_block_by_id = [];
		$all_uids = [];
		$seen_booking_ids = [];

		foreach ($events as $ev) {
			if (!empty($ev['uid'])) {
				$all_uids[] = (string) $ev['uid'];
			}
		}
		$all_uids = array_values(array_unique($all_uids));

		if (!empty($all_uids)) {
			$uid_placeholders = implode(', ', array_fill(0, count($all_uids), '%s'));
			$source_placeholders = implode(', ', array_fill(0, count($source_aliases), '%s'));
			$existing_uid_rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT id, ical_uid, date_from, date_to, status
					 FROM {$t['bookings']}
					 WHERE unit_id=%d AND source IN ({$source_placeholders}) AND status IN ('blocked','cancelled') AND ical_uid IN ({$uid_placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
					...array_merge(array((int) $unit_id), $source_aliases, $all_uids)
				),
				ARRAY_A
			);
			foreach ($existing_uid_rows as $eur) {
				$eid = (int) $eur['id'];
				$existing_by_uid[(string) $eur['ical_uid']] = $eid;
				$existing_block_by_id[$eid] = [
					'date_from' => (string) $eur['date_from'],
					'date_to' => (string) $eur['date_to'],
					'status' => (string) $eur['status'],
				];
			}
		}

		$existing_block_rows = $wpdb->get_results(
			$wpdb->prepare(
				 "SELECT id, date_from, date_to, status
				 FROM {$t['bookings']}
				 WHERE unit_id=%d AND source IN (" . implode(', ', array_fill(0, count($source_aliases), '%s')) . ") AND status IN ('blocked','cancelled')", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				...array_merge([(int) $unit_id], $source_aliases)
			),
			ARRAY_A
		);
		foreach ($existing_block_rows as $ebr) {
			$eid = (int) $ebr['id'];
			$existing_by_range[$ebr['date_from'] . '|' . $ebr['date_to']] = $eid;
			if (!isset($existing_block_by_id[$eid])) {
				$existing_block_by_id[$eid] = [
					'date_from' => (string) $ebr['date_from'],
					'date_to' => (string) $ebr['date_to'],
					'status' => (string) $ebr['status'],
				];
			}
		}

		foreach ($events as $ev) {
			$df = $ev['start'];
			$dt = $ev['end'];
			if (strtotime($dt) <= strtotime($df)) continue;

			$ical_uid = $ev['uid'] ?? null;
			$ical_summary = $ev['summary'] ?? null;
			$ical_description = $ev['description'] ?? null;
			$external_booking_id = self::extract_booking_id($ical_summary, $ical_description, $provider);
			if (self::should_skip_import_event($ev, $provider, $external_booking_id)) {
				continue;
			}

			// Check if exists by UID first, then by date range
			$exists = null;
			if ($ical_uid && isset($existing_by_uid[(string) $ical_uid])) {
				$exists = (int) $existing_by_uid[(string) $ical_uid];
			}
			if (!$exists && isset($existing_by_range[$df . '|' . $dt])) {
				$exists = (int) $existing_by_range[$df . '|' . $dt];
			}
			
			// If exists, update metadata
			if ($exists) {
				$current = $existing_block_by_id[$exists] ?? null;
				$is_range_same = $current
					&& $current['date_from'] === $df
					&& $current['date_to'] === $dt;

				if ($is_range_same) {
					$was_cancelled = isset($current['status']) && $current['status'] === 'cancelled';
					$wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
					$updated = $wpdb->update($t['bookings'], [
						'status' => 'blocked',
						'source' => $source,
						'ical_uid' => $ical_uid,
						'ical_summary' => $ical_summary,
						'ical_description' => $ical_description,
						'external_booking_id' => $external_booking_id,
					], ['id' => $exists]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
					if ($updated === false) {
						$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
						continue;
					}
					if ($was_cancelled && !self::lock_booking_days($unit_id, $exists, $df, $dt)) {
						$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
						continue;
					}
					$wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
					self::delete_booking_meta($exists, self::CANCELLED_META_KEY);
					$existing_block_by_id[$exists]['status'] = 'blocked';
					$seen_booking_ids[$exists] = true;
					continue;
				}

				$wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
				$updated = $wpdb->update($t['bookings'], [
					'status' => 'blocked',
					'date_from' => $df,
					'date_to' => $dt,
					'source' => $source,
					'ical_uid' => $ical_uid,
					'ical_summary' => $ical_summary,
					'ical_description' => $ical_description,
					'external_booking_id' => $external_booking_id,
				], ['id' => $exists]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
				if ($updated === false) {
					$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
					continue;
				}

				$deleted = $wpdb->delete($t['booking_days'], ['booking_id' => $exists]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
				if ($deleted === false) {
					$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
					continue;
				}

				$d1 = new DateTime($df);
				$d2 = new DateTime($dt);
				$conflict = false;
				while ($d1 < $d2) {
					$day = $d1->format('Y-m-d');
					$ins = $wpdb->query($wpdb->prepare(
						"INSERT IGNORE INTO {$t['booking_days']} (unit_id, day, booking_id) VALUES (%d, %s, %d)",
						$unit_id, $day, $exists
					)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
					if ($ins !== 1) { $conflict = true; break; }
					$d1->modify('+1 day');
				}
				if ($conflict) {
					$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
					continue;
				}

				$wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
				self::delete_booking_meta($exists, self::CANCELLED_META_KEY);
				if ($current) {
					unset($existing_by_range[$current['date_from'] . '|' . $current['date_to']]);
				}
				$existing_by_range[$df . '|' . $dt] = $exists;
				$existing_block_by_id[$exists] = [
					'date_from' => $df,
					'date_to' => $dt,
					'status' => 'blocked',
				];
				$seen_booking_ids[$exists] = true;
				$count++;
				continue;
			}

			// create with locking days (transaction per block)
			$wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
			$ok = $wpdb->insert($t['bookings'], [
				'unit_id'=>$unit_id,
				'status'=>'blocked',
				'source'=>$source,
				'date_from'=>$df,
				'date_to'=>$dt,
				'guests'=>1,
				'adults'=>1,
				'children'=>0,
				'currency'=> (get_post_meta($unit_id, '_vstrb_currency', true) ?: 'EUR'),
				'ical_uid' => $ical_uid,
				'ical_summary' => $ical_summary,
				'ical_description' => $ical_description,
				'external_booking_id' => $external_booking_id,
			]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
			if (!$ok) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
 continue; }
			$bid = (int)$wpdb->insert_id;
			if ($ical_uid) {
				$existing_by_uid[(string) $ical_uid] = $bid;
			}
			$existing_by_range[$df . '|' . $dt] = $bid;
			$existing_block_by_id[$bid] = [
				'date_from' => $df,
				'date_to' => $dt,
			];
			$seen_booking_ids[$bid] = true;

			$d1 = new DateTime($df);
			$d2 = new DateTime($dt);
			$conflict = false;
			while ($d1 < $d2) {
				$day = $d1->format('Y-m-d');
				$ins = $wpdb->query($wpdb->prepare(
					"INSERT IGNORE INTO {$t['booking_days']} (unit_id, day, booking_id) VALUES (%d, %s, %d)",
					$unit_id, $day, $bid
				)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
				if ($ins !== 1) { $conflict = true; break; }
				$d1->modify('+1 day');
			}
			if ($conflict) {
				$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
				continue;
			}

			$wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table transaction.
			$count++;
		}

		$cleanup_sources = $source_aliases;
		if ($source !== 'ical_other') {
			$active_other_feeds = (int) $wpdb->get_var($wpdb->prepare(
				"SELECT COUNT(*) FROM {$t['ical_feeds']} WHERE unit_id=%d AND provider=%s AND is_active=1",
				(int) $unit_id,
				'other'
			));
			if ($active_other_feeds > 0) {
				$cleanup_sources = array_values(array_diff($cleanup_sources, ['ical_other']));
			}
		}

		self::cleanup_future_blocks_for_sources($unit_id, $cleanup_sources, array_keys($seen_booking_ids), true);

		return $count;
	}
	// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
}

// Cron hook
add_action('vstrb_ical_import_cron', function() {
	$unit_ids = get_posts(array(
		'post_type' => 'vstrb_unit',
		'numberposts' => -1,
		'post_status' => array('publish', 'draft', 'private'),
		'fields' => 'ids',
	));
	foreach ($unit_ids as $unit_id) {
		VSTRB_iCal::import_unit_feeds((int)$unit_id);
	}
});
