<?php
/**
 * Complete Yatoon backups and restores.
 *
 * @package Yatoon_Booking_System
 */

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

class YATOON_Backup_Migration {
	const FORMAT_VERSION         = 1;
	const EXPORT_BATCH           = 1000;
	const MAX_ARCHIVE_BYTES      = 268435456; // 256 MB compressed.
	const MAX_UNCOMPRESSED_BYTES = 1073741824; // 1 GB uncompressed.
	const MAX_ARCHIVE_ENTRIES    = 5000;
	const NULL_MARKER            = '__YATOON_NULL_4_4_2__';

	public static function init() {
		add_action( 'admin_post_yatoon_download_full_backup', array( __CLASS__, 'handle_full_backup' ) );
		add_action( 'admin_post_yatoon_restore_full_backup', array( __CLASS__, 'handle_full_restore' ) );
	}

	/**
	 * Render backup and restore controls.
	 */
	public static function render_controls() {
		?>
		<section id="yatoon-complete-backup" class="yatoon-import-card yatoon-full-backup-card">
			<h2><?php esc_html_e( 'Complete Yatoon backup & restore', 'yatoon-booking-system' ); ?></h2>
			<p><?php esc_html_e( 'Create a restorable archive of every Yatoon data table, non-secret setting, and referenced uploaded image. Use it to move the complete Yatoon installation to another WordPress site.', 'yatoon-booking-system' ); ?></p>
			<div class="yatoon-backup-columns">
				<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
					<?php wp_nonce_field( 'yatoon_download_full_backup' ); ?>
					<input type="hidden" name="action" value="yatoon_download_full_backup">
					<h3><?php esc_html_e( '1. Download complete backup', 'yatoon-booking-system' ); ?></h3>
					<p class="yatoon-import-help"><?php esc_html_e( 'Includes services and options, staff and assignments, clients, appointments, forms, resources, packages, gift cards, payments, reports, logs, settings, and referenced images when present.', 'yatoon-booking-system' ); ?></p>
					<button class="button button-primary" type="submit"><?php esc_html_e( 'Download full Yatoon backup', 'yatoon-booking-system' ); ?></button>
				</form>
				<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" enctype="multipart/form-data">
					<?php wp_nonce_field( 'yatoon_restore_full_backup' ); ?>
					<input type="hidden" name="action" value="yatoon_restore_full_backup">
					<h3><?php esc_html_e( '2. Preview or restore backup', 'yatoon-booking-system' ); ?></h3>
					<label for="yatoon-full-backup-file"><?php esc_html_e( 'Yatoon backup ZIP', 'yatoon-booking-system' ); ?></label>
					<input id="yatoon-full-backup-file" name="backup_file" type="file" accept=".zip,application/zip" required>
					<label><input type="checkbox" name="preview_only" value="1" checked> <?php esc_html_e( 'Preview only (recommended first)', 'yatoon-booking-system' ); ?></label>
					<label class="yatoon-restore-confirm"><input type="checkbox" name="confirm_replace" value="1"> <?php esc_html_e( 'I understand that Restore replaces current Yatoon data.', 'yatoon-booking-system' ); ?></label>
					<button class="button" type="submit"><?php esc_html_e( 'Preview / restore backup', 'yatoon-booking-system' ); ?></button>
				</form>
			</div>
			<p class="yatoon-export-note"><strong><?php esc_html_e( 'Security:', 'yatoon-booking-system' ); ?></strong> <?php esc_html_e( 'Passwords, API keys, access tokens, webhook secrets, license keys, and payment credentials are intentionally excluded. Reconnect Square and other integrations on the destination site.', 'yatoon-booking-system' ); ?></p>
		</section>
		<?php
	}

	public static function handle_full_backup() {
		self::authorize( 'yatoon_download_full_backup' );
		self::require_zip();
		$path = wp_tempnam( 'yatoon-full-backup.zip' );
		try {
			self::build_full_backup( $path );
			self::send_file( $path, 'yatoon-complete-backup-' . gmdate( 'Y-m-d-His' ) . '.zip' );
		} catch ( Throwable $error ) {
			if ( $path ) {
				wp_delete_file( $path );
			}
			wp_die( esc_html( $error->getMessage() ) );
		}
	}

	public static function handle_full_restore() {
		self::authorize( 'yatoon_restore_full_backup' );
		self::require_zip();
		try {
			$path     = self::validated_zip_upload();
			$manifest = self::read_manifest( $path );
			$preview  = ! empty( $_POST['preview_only'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authorize() verifies this action before any request data is read.
			if ( $preview ) {
				$rows = array_sum( wp_list_pluck( (array) $manifest['tables'], 'rows' ) );
				$message = sprintf(
					/* translators: 1: table count, 2: row count, 3: settings, 4: images. */
					__( 'Backup is valid: %1$d tables, %2$d rows, %3$d settings, and %4$d referenced images. Uncheck Preview only and confirm replacement to restore it.', 'yatoon-booking-system' ),
					count( $manifest['tables'] ),
					$rows,
					(int) ( $manifest['settings_count'] ?? 0 ),
					count( (array) ( $manifest['media'] ?? array() ) )
				);
			} else {
				if ( empty( $_POST['confirm_replace'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authorize() verifies this action before any request data is read.
					throw new RuntimeException( __( 'Confirm that Restore may replace current Yatoon data.', 'yatoon-booking-system' ) );
				}
				$result  = self::restore_archive( $path, $manifest );
				$message = sprintf(
					/* translators: 1: table count, 2: row count, 3: settings, 4: images. */
					__( 'Restore complete: %1$d tables, %2$d rows, %3$d settings, and %4$d images restored. Reconnect external integrations before going live.', 'yatoon-booking-system' ),
					$result['tables'],
					$result['rows'],
					$result['settings'],
					$result['media']
				);
			}
			set_transient( 'yatoon_import_result_' . get_current_user_id(), array( 'message' => $message ), 2 * MINUTE_IN_SECONDS );
		} catch ( Throwable $error ) {
			set_transient( 'yatoon_import_result_' . get_current_user_id(), array( 'error' => true, 'message' => $error->getMessage() ), 2 * MINUTE_IN_SECONDS );
		}
		wp_safe_redirect( admin_url( 'admin.php?page=yatoon-import' ) );
		exit;
	}

	private static function authorize( $nonce ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to manage backups.', 'yatoon-booking-system' ) );
		}
		check_admin_referer( $nonce );
	}

	private static function require_zip() {
		if ( ! class_exists( 'ZipArchive' ) ) {
			wp_die( esc_html__( 'The PHP ZIP extension is required for this operation.', 'yatoon-booking-system' ) );
		}
	}

	/**
	 * Build a complete restorable backup at a temporary path.
	 */
	private static function build_full_backup( $path ) {
		global $wpdb;
		if ( ! $path ) {
			throw new RuntimeException( __( 'The backup file could not be created.', 'yatoon-booking-system' ) );
		}
		$zip = new ZipArchive();
		if ( true !== $zip->open( $path, ZipArchive::OVERWRITE ) ) {
			throw new RuntimeException( __( 'The backup ZIP could not be opened.', 'yatoon-booking-system' ) );
		}
		$temp_files = array();
		$media_ids  = array();
		$manifest   = array(
			'format'         => 'yatoon-complete-backup',
			'format_version' => self::FORMAT_VERSION,
			'plugin_version' => defined( 'YATOON_VERSION' ) ? YATOON_VERSION : '',
			'created_utc'    => gmdate( 'c' ),
			'source_url'     => home_url( '/' ),
			'table_prefix'   => $wpdb->prefix,
			'null_marker'    => self::NULL_MARKER,
			'tables'         => array(),
			'media'          => array(),
		);

		foreach ( self::plugin_tables() as $table ) {
			$short = substr( $table, strlen( $wpdb->prefix ) );
			$file  = wp_tempnam( $short . '.csv' );
			if ( ! $file ) {
				throw new RuntimeException( __( 'A temporary backup file could not be created.', 'yatoon-booking-system' ) );
			}
			$temp_files[] = $file;
			$meta         = self::export_table( $table, $file, $media_ids );
			$entry        = 'tables/' . $short . '.csv';
			$zip->addFile( $file, $entry );
			$manifest['tables'][] = array(
				'name'    => $short,
				'entry'   => $entry,
				'rows'    => $meta['rows'],
				'columns' => $meta['columns'],
				'sha256'  => hash_file( 'sha256', $file ),
			);
		}

		$settings                    = self::export_settings();
		$manifest['settings_count']  = count( $settings['included'] );
		$manifest['excluded_secrets'] = $settings['excluded'];
		$settings_json               = wp_json_encode( $settings['included'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		$zip->addFromString( 'settings/options.json', $settings_json );
		$manifest['settings_sha256'] = hash( 'sha256', $settings_json );

		foreach ( array_keys( $media_ids ) as $attachment_id ) {
			$media = self::add_attachment_to_zip( $zip, $attachment_id );
			if ( $media ) {
				$manifest['media'][] = $media;
			}
		}

		$zip->addFromString(
			'README.txt',
			"Yatoon Booking complete backup\r\n\r\n" .
			"This archive is restorable from Yatoon > Tools > Import & Migration.\r\n" .
			"It contains every Yatoon data table, non-secret Yatoon setting, and referenced uploaded image found at export time.\r\n" .
			"Passwords, API keys, access/refresh tokens, webhook secrets, payment credentials, and license keys are excluded.\r\n" .
			"Reconnect Square, payment, calendar, SMS, and other external integrations after moving sites.\r\n" .
			"This archive contains personal and business data. Store it securely.\r\n"
		);
		$manifest_json = wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		$zip->addFromString( 'manifest.json', $manifest_json );
		$zip->close();
		foreach ( $temp_files as $file ) {
			wp_delete_file( $file );
		}
	}

	private static function plugin_tables() {
		global $wpdb;
		$like   = $wpdb->esc_like( $wpdb->prefix . 'yatoon_' ) . '%';
		$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) );
		return array_values(
			array_filter(
				$tables,
				function ( $table ) use ( $wpdb ) {
					return 0 === strpos( $table, $wpdb->prefix . 'yatoon_' ) && (bool) preg_match( '/^[A-Za-z0-9_]+$/', $table );
				}
			)
		);
	}

	private static function table_exists( $table ) {
		global $wpdb;
		return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
	}

	private static function export_table( $table, $path, &$media_ids ) {
		global $wpdb;
		$columns = $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`", 0 ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$handle  = fopen( $path, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		if ( ! $handle || empty( $columns ) ) {
			throw new RuntimeException( __( 'A Yatoon table could not be exported.', 'yatoon-booking-system' ) );
		}
		fputcsv( $handle, $columns );
		$sql_columns = '`' . implode( '`,`', $columns ) . '`';
		$offset      = 0;
		$count       = 0;
		do {
			$rows = $wpdb->get_results( $wpdb->prepare( "SELECT {$sql_columns} FROM `{$table}` LIMIT %d OFFSET %d", self::EXPORT_BATCH, $offset ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			foreach ( $rows as $row ) {
				foreach ( $row as $column => $value ) {
					if ( null !== $value && preg_match( '/(?:attachment|image|logo)_id$/', $column ) && ctype_digit( (string) $value ) && (int) $value > 0 ) {
						$media_ids[ (int) $value ] = true;
					} elseif ( null !== $value && preg_match( '/(?:image|photo|logo)_url$/', $column ) && function_exists( 'attachment_url_to_postid' ) ) {
						$id = attachment_url_to_postid( (string) $value );
						if ( $id ) {
							$media_ids[ (int) $id ] = true;
						}
					}
				}
				fputcsv( $handle, array_map( array( __CLASS__, 'raw_csv_value' ), array_values( $row ) ) );
				++$count;
			}
			$offset += self::EXPORT_BATCH;
		} while ( count( $rows ) === self::EXPORT_BATCH );
		fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		return array( 'rows' => $count, 'columns' => $columns );
	}

	private static function raw_csv_value( $value ) {
		return null === $value ? self::NULL_MARKER : (string) $value;
	}

	private static function export_settings() {
		global $wpdb;
		$like_yatoon = $wpdb->esc_like( 'yatoon_' ) . '%';
		$like_legacy = $wpdb->esc_like( 'sbs_' ) . '%';
		$rows = $wpdb->get_results(
			$wpdb->prepare( "SELECT option_name,option_value,autoload FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s ORDER BY option_name", $like_yatoon, $like_legacy ),
			ARRAY_A
		);
		$included = array();
		$excluded = array();
		foreach ( $rows as $row ) {
			if ( self::is_secret_setting( $row['option_name'] ) ) {
				$excluded[] = $row['option_name'];
				continue;
			}
			$included[] = $row;
		}
		return array( 'included' => $included, 'excluded' => $excluded );
	}

	private static function is_secret_setting( $name ) {
		return (bool) preg_match( '/(?:secret|password|private[_-]?key|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|signature[_-]?key|webhook[_-]?(?:key|token|secret)|license|portal[_-]?(?:otp|session|token)|feed[_-]?token)/i', (string) $name );
	}

	private static function add_attachment_to_zip( $zip, $attachment_id ) {
		$file = get_attached_file( $attachment_id );
		if ( ! $file || ! is_readable( $file ) || ! is_file( $file ) ) {
			return null;
		}
		$entry = 'media/' . (int) $attachment_id . '-' . sanitize_file_name( basename( $file ) );
		if ( ! $zip->addFile( $file, $entry ) ) {
			return null;
		}
		return array(
			'old_id'   => (int) $attachment_id,
			'old_url'  => (string) wp_get_attachment_url( $attachment_id ),
			'entry'    => $entry,
			'filename' => sanitize_file_name( basename( $file ) ),
			'mime'     => (string) get_post_mime_type( $attachment_id ),
			'title'    => (string) get_the_title( $attachment_id ),
			'alt'      => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
			'sha256'   => hash_file( 'sha256', $file ),
		);
	}

	private static function validated_zip_upload() {
		// Upload paths cannot be text-sanitized; the authenticated caller and the ZIP structure, size, path, and manifest checks below validate this array.
		$file = isset( $_FILES['backup_file'] ) ? $_FILES['backup_file'] : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		if ( empty( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) {
			throw new RuntimeException( __( 'Choose a Yatoon backup ZIP.', 'yatoon-booking-system' ) );
		}
		if ( UPLOAD_ERR_OK !== (int) $file['error'] || (int) $file['size'] <= 0 || (int) $file['size'] > self::MAX_ARCHIVE_BYTES ) {
			throw new RuntimeException( __( 'The backup ZIP could not be read or is larger than 256 MB.', 'yatoon-booking-system' ) );
		}
		if ( 'zip' !== strtolower( pathinfo( sanitize_file_name( $file['name'] ), PATHINFO_EXTENSION ) ) ) {
			throw new RuntimeException( __( 'Only a .zip Yatoon backup is accepted.', 'yatoon-booking-system' ) );
		}
		$zip = new ZipArchive();
		if ( true !== $zip->open( $file['tmp_name'] ) ) {
			throw new RuntimeException( __( 'The backup ZIP is damaged or unreadable.', 'yatoon-booking-system' ) );
		}
		if ( $zip->numFiles > self::MAX_ARCHIVE_ENTRIES ) {
			$zip->close();
			throw new RuntimeException( __( 'The backup ZIP contains too many files.', 'yatoon-booking-system' ) );
		}
		$total = 0;
		for ( $i = 0; $i < $zip->numFiles; ++$i ) {
			$stat = $zip->statIndex( $i );
			$name = (string) ( $stat['name'] ?? '' );
			if ( preg_match( '#(?:^|/)\.\.(?:/|$)#', str_replace( '\\', '/', $name ) ) || preg_match( '#^(?:/|[A-Za-z]:)#', $name ) ) {
				$zip->close();
				throw new RuntimeException( __( 'The backup ZIP contains an unsafe path.', 'yatoon-booking-system' ) );
			}
			$total += (int) ( $stat['size'] ?? 0 );
			if ( $total > self::MAX_UNCOMPRESSED_BYTES ) {
				$zip->close();
				throw new RuntimeException( __( 'The expanded backup is too large.', 'yatoon-booking-system' ) );
			}
		}
		$zip->close();
		return $file['tmp_name'];
	}

	private static function read_manifest( $path ) {
		$zip = new ZipArchive();
		$zip->open( $path );
		$json = $zip->getFromName( 'manifest.json' );
		$zip->close();
		$manifest = json_decode( (string) $json, true );
		if ( ! is_array( $manifest ) || 'yatoon-complete-backup' !== ( $manifest['format'] ?? '' ) || self::FORMAT_VERSION !== (int) ( $manifest['format_version'] ?? 0 ) || empty( $manifest['tables'] ) ) {
			throw new RuntimeException( __( 'This is not a supported complete Yatoon backup.', 'yatoon-booking-system' ) );
		}
		foreach ( $manifest['tables'] as $table ) {
			if ( empty( $table['name'] ) || ! preg_match( '/^yatoon_[a-z0-9_]+$/', $table['name'] ) || empty( $table['entry'] ) || 0 !== strpos( $table['entry'], 'tables/' ) ) {
				throw new RuntimeException( __( 'The backup manifest contains an invalid table.', 'yatoon-booking-system' ) );
			}
		}
		return $manifest;
	}

	private static function restore_archive( $path, $manifest ) {
		global $wpdb;
		if ( class_exists( 'YATOON_Release_Safety' ) ) {
			$snapshot = YATOON_Release_Safety::create_backup( 'pre-full-restore' );
			if ( is_wp_error( $snapshot ) ) {
				throw new RuntimeException( __( 'A safety snapshot could not be created, so Restore was stopped before changing data.', 'yatoon-booking-system' ) );
			}
		}
		$zip       = new ZipArchive();
		$zip->open( $path );
		$media_map = array( 'ids' => array(), 'urls' => array() );
		$temp      = array();
		$prepared  = array();
		try {
			foreach ( $manifest['tables'] as $table ) {
				$file = wp_tempnam( $table['name'] . '.csv' );
				$data = $zip->getFromName( $table['entry'] );
				if ( false === $data || ! $file ) {
					throw new RuntimeException( __( 'A required table file is missing from the backup.', 'yatoon-booking-system' ) );
				}
				$written = file_put_contents( $file, $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
				if ( false === $written || ! hash_equals( (string) $table['sha256'], hash_file( 'sha256', $file ) ) ) {
					throw new RuntimeException( __( 'A backup table failed its integrity check.', 'yatoon-booking-system' ) );
				}
				$temp[]     = $file;
				$prepared[] = array( 'meta' => $table, 'file' => $file );
			}
			$settings_json = $zip->getFromName( 'settings/options.json' );
			if ( false === $settings_json || ! hash_equals( (string) ( $manifest['settings_sha256'] ?? '' ), hash( 'sha256', $settings_json ) ) ) {
				throw new RuntimeException( __( 'Backup settings failed their integrity check.', 'yatoon-booking-system' ) );
			}
			$settings = json_decode( $settings_json, true );
			if ( ! is_array( $settings ) ) {
				throw new RuntimeException( __( 'Backup settings are invalid.', 'yatoon-booking-system' ) );
			}
			$media_map = self::restore_media( $zip, (array) ( $manifest['media'] ?? array() ) );
			$wpdb->query( 'SET FOREIGN_KEY_CHECKS=0' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			foreach ( self::plugin_tables() as $current_table ) {
				$wpdb->query( "DELETE FROM `{$current_table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
			}
			$rows = 0;
			foreach ( $prepared as $item ) {
				$rows += self::restore_table( $item['meta'], $item['file'], $media_map );
			}
			$wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$wpdb->query( 'SET FOREIGN_KEY_CHECKS=1' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$setting_count = 0;
			self::delete_current_nonsecret_settings();
			foreach ( $settings as $setting ) {
				$name = (string) ( $setting['option_name'] ?? '' );
				if ( ( 0 !== strpos( $name, 'yatoon_' ) && 0 !== strpos( $name, 'sbs_' ) ) || self::is_secret_setting( $name ) ) {
					continue;
				}
				$value = self::safe_unserialize( (string) ( $setting['option_value'] ?? '' ) );
				$value = self::remap_setting_media( $name, $value, $media_map );
				update_option( $name, $value, in_array( (string) ( $setting['autoload'] ?? '' ), array( 'yes', 'on', 'auto-on', 'auto' ), true ) );
				++$setting_count;
			}
		} catch ( Throwable $error ) {
			$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$wpdb->query( 'SET FOREIGN_KEY_CHECKS=1' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			foreach ( array_values( $media_map['ids'] ) as $attachment_id ) {
				wp_delete_attachment( (int) $attachment_id, true );
			}
			throw $error;
		} finally {
			$zip->close();
			foreach ( $temp as $file ) {
				wp_delete_file( $file );
			}
		}
		return array( 'tables' => count( $prepared ), 'rows' => $rows, 'settings' => $setting_count, 'media' => count( $media_map['ids'] ) );
	}

	/**
	 * Unserialize a restored option value without ever instantiating an object.
	 *
	 * maybe_unserialize() calls unserialize() with default options, which will
	 * happily build objects out of an `O:` payload and fire their __wakeup()/
	 * __destruct() magic methods. Restoring a backup is admin-gated and the
	 * archive is SHA-256 checked, but "the attacker already had to be an admin"
	 * is not a reason to leave a PHP object-injection gadget chain reachable --
	 * an admin restoring a file someone else handed them is exactly the
	 * realistic path here. allowed_classes => false turns any serialized object
	 * into __PHP_Incomplete_Class instead of executing anything, and plugin
	 * settings are only ever scalars/arrays, so nothing legitimate is lost.
	 *
	 * @since 4.5.0
	 * @param string $value Raw option_value from the archive.
	 * @return mixed
	 */
	private static function safe_unserialize( $value ) {
		if ( ! is_serialized( $value ) ) {
			return $value;
		}
		if ( 0 === strpos( ltrim( $value ), 'O:' ) || false !== strpos( $value, ';O:' ) ) {
			// Serialized object in a settings backup is never legitimate.
			return '';
		}
		$decoded = @unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		return ( false === $decoded && 'b:0;' !== $value ) ? '' : $decoded;
	}

	private static function delete_current_nonsecret_settings() {
		global $wpdb;
		$like_yatoon = $wpdb->esc_like( 'yatoon_' ) . '%';
		$like_legacy = $wpdb->esc_like( 'sbs_' ) . '%';
		$names = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
				$like_yatoon,
				$like_legacy
			)
		);
		foreach ( $names as $name ) {
			if ( ! self::is_secret_setting( $name ) ) {
				delete_option( $name );
			}
		}
	}

	private static function remap_setting_media( $name, $value, $media_map ) {
		if ( is_array( $value ) ) {
			foreach ( $value as $key => $item ) {
				$value[ $key ] = self::remap_setting_media( $name . '_' . $key, $item, $media_map );
			}
			return $value;
		}
		if ( is_object( $value ) ) {
			foreach ( get_object_vars( $value ) as $key => $item ) {
				$value->{$key} = self::remap_setting_media( $name . '_' . $key, $item, $media_map );
			}
			return $value;
		}
		if ( is_string( $value ) ) {
			$value = str_replace( array_keys( $media_map['urls'] ), array_values( $media_map['urls'] ), $value );
		}
		if ( preg_match( '/(?:attachment|image|photo|logo|media|gallery)/i', $name ) && isset( $media_map['ids'][ (int) $value ] ) ) {
			return (int) $media_map['ids'][ (int) $value ];
		}
		return $value;
	}

	private static function restore_media( $zip, $media ) {
		$map = array( 'ids' => array(), 'urls' => array() );
		if ( empty( $media ) ) {
			return $map;
		}
		require_once ABSPATH . 'wp-admin/includes/image.php';
		foreach ( $media as $item ) {
			$data = $zip->getFromName( (string) ( $item['entry'] ?? '' ) );
			if ( false === $data || ! hash_equals( (string) ( $item['sha256'] ?? '' ), hash( 'sha256', $data ) ) ) {
				continue;
			}
			$upload = wp_upload_bits( sanitize_file_name( $item['filename'] ?? 'yatoon-image' ), null, $data );
			if ( ! empty( $upload['error'] ) ) {
				continue;
			}
			$id = wp_insert_attachment(
				array(
					'post_mime_type' => sanitize_mime_type( $item['mime'] ?? '' ),
					'post_title'     => sanitize_text_field( $item['title'] ?? '' ),
					'post_status'    => 'inherit',
				),
				$upload['file']
			);
			if ( is_wp_error( $id ) ) {
				continue;
			}
			wp_update_attachment_metadata( $id, wp_generate_attachment_metadata( $id, $upload['file'] ) );
			update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $item['alt'] ?? '' ) );
			$map['ids'][ (int) $item['old_id'] ] = (int) $id;
			if ( ! empty( $item['old_url'] ) ) {
				$map['urls'][ (string) $item['old_url'] ] = (string) wp_get_attachment_url( $id );
			}
		}
		return $map;
	}

	private static function restore_table( $meta, $file, $media_map ) {
		global $wpdb;
		$table = $wpdb->prefix . $meta['name'];
		if ( ! self::table_exists( $table ) ) {
			return 0;
		}
		$available = $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`", 0 ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$handle    = fopen( $file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$headers   = fgetcsv( $handle );
		if ( ! is_array( $headers ) ) {
			fclose( $handle );
			return 0;
		}
		$keep  = array_intersect( $headers, $available );
		$count = 0;
		while ( ( $values = fgetcsv( $handle ) ) !== false ) {
			$values = array_pad( $values, count( $headers ), '' );
			$row    = array_combine( $headers, array_slice( $values, 0, count( $headers ) ) );
			$data   = array();
			foreach ( $keep as $column ) {
				$value = $row[ $column ];
				if ( self::NULL_MARKER === $value ) {
					$value = null;
				} elseif ( preg_match( '/(?:attachment|image|logo)_id$/', $column ) && isset( $media_map['ids'][ (int) $value ] ) ) {
					$value = $media_map['ids'][ (int) $value ];
				} elseif ( preg_match( '/(?:image|photo|logo)_url$/', $column ) && isset( $media_map['urls'][ $value ] ) ) {
					$value = $media_map['urls'][ $value ];
				}
				$data[ $column ] = $value;
			}
			if ( $data && false === $wpdb->insert( $table, $data ) ) {
				fclose( $handle );
				/* translators: %s: Yatoon database table name. */
				throw new RuntimeException( sprintf( __( 'Restore failed while writing %s.', 'yatoon-booking-system' ), $meta['name'] ) );
			}
			++$count;
		}
		fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		return $count;
	}

	private static function build_target_package( $path, $target ) {
		$zip = new ZipArchive();
		if ( true !== $zip->open( $path, ZipArchive::OVERWRITE ) ) {
			throw new RuntimeException( __( 'The migration ZIP could not be created.', 'yatoon-booking-system' ) );
		}
		$temp = array();
		if ( 'bookly' === $target ) {
			$file   = self::write_bookly_appointments_csv();
			$temp[] = $file;
			$zip->addFile( $file, 'bookly-appointments-import.csv' );
			$readme = "Yatoon to Bookly migration\r\n\r\n" .
				"In current Bookly: open Bookly > Diagnostics > Import appointments. Upload bookly-appointments-import.csv and map its columns.\r\n" .
				"Bookly's importer can create and link staff, services, customers, appointments, and payments from this file. Preview first and use Bookly's rollback if needed.\r\n";
		} else {
			$files = self::write_amelia_files();
			foreach ( $files as $name => $file ) {
				$temp[] = $file;
				$zip->addFile( $file, $name );
			}
			$readme = "Yatoon to Amelia migration\r\n\r\n" .
				"Amelia natively imports customers from CSV. Use amelia-customers.csv in Amelia > Customers > Import.\r\n" .
				"Amelia does not provide a universal native CSV importer for services, employees, appointments, payments, and all relationships.\r\n" .
				"The remaining CSV worksheets preserve those records for Amelia API (where available), an Amelia migration service, or manual mapping.\r\n";
		}
		$backup = wp_tempnam( 'yatoon-complete-backup.zip' );
		self::build_full_backup( $backup );
		$temp[] = $backup;
		$zip->addFile( $backup, 'complete-yatoon-backup.zip' );
		$zip->addFromString( 'README.txt', $readme . "\r\nA complete restorable Yatoon backup is included as complete-yatoon-backup.zip.\r\n" );
		$zip->addFromString( 'COMPATIBILITY.txt', self::compatibility_report( $target ) );
		$zip->close();
		foreach ( $temp as $file ) {
			wp_delete_file( $file );
		}
	}

	private static function write_bookly_appointments_csv() {
		$path   = wp_tempnam( 'bookly-appointments-import.csv' );
		$handle = fopen( $path, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$headers = array( 'Start Date', 'End Date', 'Duration Minutes', 'Staff Name', 'Service Name', 'Price', 'Customer Name', 'Customer Email', 'Customer Phone', 'Appointment Status', 'Payment Status', 'Paid Amount', 'Notes', 'Yatoon Booking ID' );
		fwrite( $handle, "\xEF\xBB\xBF" );
		fputcsv( $handle, $headers );
		foreach ( self::appointment_rows() as $row ) {
			$duration = max( 1, (int) round( ( strtotime( $row['end_time'] ) - strtotime( $row['start_time'] ) ) / 60 ) );
			$paid = in_array( strtolower( $row['payment_status'] ), array( 'paid', 'completed', 'succeeded' ), true ) ? $row['total_price'] : 0;
			fputcsv( $handle, array_map( array( __CLASS__, 'safe_csv_value' ), array( $row['start_time'], $row['end_time'], $duration, $row['staff_name'], $row['service_name'], $row['total_price'], $row['customer_name'], $row['customer_email'], $row['customer_phone'], $row['status'], $row['payment_status'], $paid, $row['notes'], $row['id'] ) ) );
		}
		fclose( $handle );
		return $path;
	}

	private static function write_amelia_files() {
		$files = array();
		$clients = self::query_table_rows( 'yatoon_clients' );
		$customer_path = wp_tempnam( 'amelia-customers.csv' );
		$review_path   = wp_tempnam( 'amelia-customers-with-headers.csv' );
		$customer      = fopen( $customer_path, 'wb' );
		$review        = fopen( $review_path, 'wb' );
		fwrite( $customer, "\xEF\xBB\xBF" );
		fwrite( $review, "\xEF\xBB\xBF" );
		fputcsv( $review, array( 'First Name', 'Last Name', 'Email', 'Phone', 'Note' ) );
		foreach ( $clients as $client ) {
			$parts = preg_split( '/\s+/', trim( (string) ( $client['customer_name'] ?? '' ) ), 2 );
			$row   = array( $parts[0] ?? '', $parts[1] ?? '', $client['customer_email'] ?? '', $client['customer_phone'] ?? '', $client['notes'] ?? '' );
			fputcsv( $customer, array_map( array( __CLASS__, 'safe_csv_value' ), $row ) );
			fputcsv( $review, array_map( array( __CLASS__, 'safe_csv_value' ), $row ) );
		}
		fclose( $customer );
		fclose( $review );
		$files['amelia-customers.csv']              = $customer_path;
		$files['review/amelia-customers-with-headers.csv'] = $review_path;
		$files['worksheets/amelia-services.csv']    = self::write_array_csv( self::query_table_rows( 'yatoon_services' ) );
		$files['worksheets/amelia-employees.csv']   = self::write_array_csv( self::query_table_rows( 'yatoon_staff' ) );
		$files['worksheets/amelia-employee-services.csv'] = self::write_array_csv( self::query_table_rows( 'yatoon_staff_services' ) );
		$files['worksheets/amelia-appointments.csv'] = self::write_array_csv( self::appointment_rows() );
		return $files;
	}

	private static function write_array_csv( $rows ) {
		$path   = wp_tempnam( 'yatoon-migration.csv' );
		$handle = fopen( $path, 'wb' );
		fwrite( $handle, "\xEF\xBB\xBF" );
		$headers = ! empty( $rows ) ? array_keys( reset( $rows ) ) : array( 'no_records' );
		fputcsv( $handle, $headers );
		foreach ( $rows as $row ) {
			fputcsv( $handle, array_map( array( __CLASS__, 'safe_csv_value' ), array_values( $row ) ) );
		}
		fclose( $handle );
		return $path;
	}

	private static function query_table_rows( $short ) {
		global $wpdb;
		$table = $wpdb->prefix . $short;
		return self::table_exists( $table ) ? $wpdb->get_results( "SELECT * FROM `{$table}`", ARRAY_A ) : array(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	private static function appointment_rows() {
		global $wpdb;
		$bookings = self::query_table_rows( 'yatoon_bookings' );
		$services = wp_list_pluck( self::query_table_rows( 'yatoon_services' ), 'name', 'id' );
		$staff    = wp_list_pluck( self::query_table_rows( 'yatoon_staff' ), 'name', 'id' );
		$output   = array();
		foreach ( $bookings as $booking ) {
			$service_id = (int) ( $booking['service_id'] ?? 0 );
			$staff_id   = (int) ( $booking['staff_id'] ?? 0 );
			$start      = (string) ( $booking['start_time'] ?? $booking['start_at'] ?? '' );
			$end        = (string) ( $booking['end_time'] ?? $booking['end_at'] ?? '' );
			if ( ! $end && $start ) {
				$duration = (int) ( $booking['duration_minutes'] ?? 30 );
				$end = gmdate( 'Y-m-d H:i:s', strtotime( $start ) + max( 1, $duration ) * MINUTE_IN_SECONDS );
			}
			$output[] = array(
				'id'             => (int) ( $booking['id'] ?? 0 ),
				'start_time'     => $start,
				'end_time'       => $end,
				'service_name'   => (string) ( $services[ $service_id ] ?? $booking['service_name'] ?? 'Yatoon service' ),
				'staff_name'     => (string) ( $staff[ $staff_id ] ?? $booking['staff_name'] ?? 'Any staff' ),
				'customer_name'  => (string) ( $booking['customer_name'] ?? '' ),
				'customer_email' => (string) ( $booking['customer_email'] ?? '' ),
				'customer_phone' => (string) ( $booking['customer_phone'] ?? '' ),
				'status'         => (string) ( $booking['status'] ?? 'pending' ),
				'payment_status' => (string) ( $booking['payment_status'] ?? 'unpaid' ),
				'total_price'    => (string) ( $booking['total_price'] ?? $booking['price'] ?? 0 ),
				'notes'          => (string) ( $booking['notes'] ?? '' ),
			);
		}
		return $output;
	}

	private static function safe_csv_value( $value ) {
		$value = null === $value ? '' : (string) $value;
		return preg_match( '/^[=+\-@\t\r]/u', $value ) ? "'" . $value : $value;
	}

	private static function compatibility_report( $target ) {
		return "Migration compatibility report\r\n\r\n" .
			"Core records supplied: services, service options where representable, staff, staff/service assignments, customers, appointments, status, prices, payment status, and notes.\r\n" .
			"Destination: {$target}.\r\n\r\n" .
			"Yatoon-specific or edition-dependent features may not have a one-to-one destination field: Square object/version IDs, design-photo relationships, multi-person grouping, resources, gift cards, packages, memberships, loyalty rules, automation, custom forms, consent evidence, audit/sync logs, deposits, commissions, inventory, and provider credentials.\r\n" .
			"Nothing is discarded from this package: complete-yatoon-backup.zip preserves the full Yatoon state for exact Yatoon restore. Provider credentials are excluded for security.\r\n";
	}

	private static function send_file( $path, $filename ) {
		if ( ! $path || ! is_readable( $path ) ) {
			throw new RuntimeException( __( 'The download file could not be created.', 'yatoon-booking-system' ) );
		}
		while ( ob_get_level() ) {
			ob_end_clean();
		}
		nocache_headers();
		header( 'Content-Type: application/zip' );
		header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $filename ) . '"' );
		header( 'Content-Length: ' . (string) filesize( $path ) );
		header( 'X-Content-Type-Options: nosniff' );
		readfile( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
		wp_delete_file( $path );
		exit;
	}
}
