<?php

declare(strict_types=1);

namespace SecurWP\Modules\Settings;

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

use SecurWP\Includes\SettingsStore;
use SecurWP\Modules\Firewall\FirewallConfigService;
use SecurWP\Modules\Firewall\FirewallSettings;
use SecurWP\Modules\Hardening\Hardening;
use SecurWP\Modules\Headers\Headers;
use SecurWP\Modules\TwoFactor\TwoFactor;

/**
 * Handles bulk import and export of SecurWP plugin settings.
 *
 * Export produces a versioned JSON-serialisable array with all secret
 * values stripped. Import validates version compatibility, whitelists
 * allowed setting columns, sanitises incoming values with per-module
 * rules, applies changes in order, and reports explicit rollback failures
 * when a later write cannot be fully restored.
 */
class ImportExport {

	/**
	 * Map of export keys to SettingsStore columns.
	 *
	 * Uses the legacy option key names as export keys for backward
	 * compatibility with existing export files.
	 */
	private const COLUMN_MAP = [
		'securwp_firewall_settings'     => SettingsStore::COL_FIREWALL,
		'securwp_hardening_settings'    => SettingsStore::COL_HARDENING,
		'securwp_notification_settings' => SettingsStore::COL_NOTIFICATIONS,
		'securwp_2fa_settings'          => SettingsStore::COL_TWOFA,
		'securwp_headers_settings'      => SettingsStore::COL_HEADERS,
		'securwp_cloudflare_settings'   => SettingsStore::COL_CLOUDFLARE,
	];

	/**
	 * Keys that must NEVER be exported or imported.
	 *
	 * Secrets are preserved from local storage during import.
	 */
	private const SECRET_KEYS = [
		'captcha_secret_key',
		'geoip_license_key',
		'api_token',
		'global_api_key',
		'license_key',
		'site_token',
		'site_secret',
		'site_token_enc',
		'site_secret_enc',
		'webhook_url',
		'webhook_provider_urls',
	];

	/**
	 * Build an export payload from the current site settings.
	 *
	 * @return array{version: string, exported_at: int, settings: array<string, mixed>}
	 */
	public function export(): array {
		$version = defined( 'SECURWP_VERSION' ) ? SECURWP_VERSION : '2.7.0';

		$settings = [];
		foreach ( self::COLUMN_MAP as $export_key => $column ) {
			$value = SettingsStore::get( $column );
			if ( ! empty( $value ) ) {
				if ( $column === SettingsStore::COL_FIREWALL && is_array( $value ) ) {
					unset( $value['origin_enforcement_mode'] );
				}
				$settings[ $export_key ] = $this->strip_secrets( $value );
			}
		}

		return [
			'version'     => $version,
			'exported_at' => time(),
			'settings'    => $settings,
		];
	}

	/**
	 * Import settings from a previously-exported payload.
	 *
	 * @param array $data The full export array (version, settings, ...).
	 * @return \WP_Error|array<string,mixed> Structured success payload on success, WP_Error on failure.
	 */
	public function import( array $data ) {
		if ( ! isset( $data['version'], $data['settings'] ) ) {
			return new \WP_Error(
				'invalid_format',
				'Import data must contain "version" and "settings" keys.',
				[ 'status' => 400 ]
			);
		}

		if ( ! is_array( $data['settings'] ) ) {
			return new \WP_Error(
				'invalid_format',
				'The "settings" key must be an array.',
				[ 'status' => 400 ]
			);
		}

		$current_version = defined( 'SECURWP_VERSION' ) ? SECURWP_VERSION : '2.7.0';
		$import_major    = $this->get_major_version( (string) $data['version'] );
		$current_major   = $this->get_major_version( $current_version );

		if ( $import_major !== $current_major ) {
			return new \WP_Error(
				'version_mismatch',
				sprintf(
					'Major version mismatch: import file is v%s but this site runs v%s.',
					$data['version'],
					$current_version
				),
				[ 'status' => 400 ]
			);
		}

		$operations = [];
		foreach ( $data['settings'] as $key => $value ) {
			if ( ! isset( self::COLUMN_MAP[ $key ] ) || ! is_array( $value ) ) {
				continue;
			}

			$column   = self::COLUMN_MAP[ $key ];
			$current  = SettingsStore::get( $column );
			$stripped = $this->strip_secrets( $value );
			try {
				$sanitized = $this->sanitize_column_payload( $column, $stripped );
			} catch ( \InvalidArgumentException $exception ) {
				return new \WP_Error(
					'import_invalid_firewall_rules',
					sanitize_text_field( $exception->getMessage() ),
					[ 'status' => 400 ]
				);
			}
			$preserved = $this->extract_secrets( $current );

			$merged = array_replace_recursive( $current, $sanitized );
			$merged = array_replace_recursive( $merged, $preserved );

			$operations[] = [
				'column'  => $column,
				'current' => $current,
				'next'    => $merged,
			];
		}

		if ( empty( $operations ) ) {
			return new \WP_Error(
				'no_supported_settings',
				'Import payload did not contain any supported settings sections.',
				[
					'status' => 400,
					'operation_status' => 'failed',
					'applied_columns' => [],
					'rollback_failed' => false,
					'rollback_failures' => [],
				]
			);
		}

		$applied = [];
		foreach ( $operations as $operation ) {
			$next  = $operation['next'];
			if ( $operation['column'] === SettingsStore::COL_FIREWALL ) {
				try {
					$result = FirewallConfigService::prepare_imported_settings(
						$next,
						FirewallConfigService::CONTEXT_NON_INTERACTIVE
					);
					$next = $result['settings'];
				} catch ( \InvalidArgumentException $exception ) {
					$rollback_result = $this->rollback_import( $applied );
					return new \WP_Error(
						'import_invalid_firewall_rules',
						sanitize_text_field( $exception->getMessage() ),
						[
							'status' => 400,
							'operation_status' => 'failed',
							'applied_columns' => $this->extract_operation_columns( $applied ),
							'rollback_failed' => ! empty( $rollback_result['failed_columns'] ),
							'rollback_failures' => $rollback_result['failed_columns'],
						]
					);
				}
			}

			$saved = $operation['column'] === SettingsStore::COL_FIREWALL
				? FirewallConfigService::persist_non_interactive_settings( $next )
				: SettingsStore::save( $operation['column'], $next );
			if ( ! $saved ) {
				$rollback_result = $this->rollback_import( $applied );
				$message = 'Failed to save imported settings. Changes were rolled back.';
				if ( ! empty( $rollback_result['failed_columns'] ) ) {
					$message = 'Failed to save imported settings and rollback did not fully restore the previous state.';
				}

				return new \WP_Error(
					'import_save_failed',
					$message,
					[
						'status' => 500,
						'operation_status' => 'failed',
						'applied_columns' => $this->extract_operation_columns( $applied ),
						'rollback_failed' => ! empty( $rollback_result['failed_columns'] ),
						'rollback_failures' => $rollback_result['failed_columns'],
					]
				);
			}
			$operation['next'] = $next;
			$applied[] = $operation;
		}

		return [
			'ok' => true,
			'status' => 'success',
			'applied_columns' => $this->extract_operation_columns( $applied ),
		];
	}

	/**
	 * Best-effort rollback for already-applied import operations.
	 *
	 * Rollback failures are returned to the caller so a partial restore is never
	 * reported as a clean success.
	 *
	 * @param array<int, array{column:string,current:array,next:array}> $applied
	 * @return array{failed_columns: array<int,string>}
	 */
	private function rollback_import( array $applied ): array {
		$reversed = array_reverse( $applied );
		$failed_columns = [];

		foreach ( $reversed as $operation ) {
			try {
				if ( $operation['column'] === SettingsStore::COL_FIREWALL ) {
					if ( ! FirewallConfigService::persist_non_interactive_settings( $operation['current'] ) ) {
						$failed_columns[] = $operation['column'];
					}
					continue;
				}

				if ( ! SettingsStore::save( $operation['column'], $operation['current'] ) ) {
					$failed_columns[] = $operation['column'];
				}
			} catch ( \Throwable $ignored ) {
				$failed_columns[] = (string) ( $operation['column'] ?? '' );
			}
		}

		return [
			'failed_columns' => array_values( array_unique( array_filter( $failed_columns, 'is_string' ) ) ),
		];
	}

	/**
	 * @param array<int, array{column:string,current:array,next:array}> $operations
	 * @return array<int,string>
	 */
	private function extract_operation_columns( array $operations ): array {
		$columns = [];
		foreach ( $operations as $operation ) {
			$column = (string) ( $operation['column'] ?? '' );
			if ( $column !== '' ) {
				$columns[] = $column;
			}
		}

		return array_values( array_unique( $columns ) );
	}

	/**
	 * Sanitize imported settings per destination column.
	 *
	 * @param string $column SettingsStore column.
	 * @param array  $payload Imported data for that column.
	 * @return array
	 */
	private function sanitize_column_payload( string $column, array $payload ): array {
		switch ( $column ) {
			case SettingsStore::COL_FIREWALL:
				return $this->sanitize_firewall_settings( $payload );

			case SettingsStore::COL_HARDENING:
				return Hardening::sanitize_settings_patch( $payload );

			case SettingsStore::COL_TWOFA:
				return $this->sanitize_twofa_settings( $payload );

			case SettingsStore::COL_HEADERS:
				return $this->sanitize_headers_settings( $payload );

			case SettingsStore::COL_NOTIFICATIONS:
				return $this->sanitize_notifications_settings( $payload );

			case SettingsStore::COL_CLOUDFLARE:
				return $this->sanitize_cloudflare_settings( $payload );
		}

		return $this->sanitize_recursive_mixed( $payload );
	}

	/**
	 * Firewall settings sanitize.
	 *
	 * @param array $payload Imported payload.
	 * @return array
	 */
	private function sanitize_firewall_settings( array $payload ): array {
		unset( $payload['origin_enforcement_mode'] );

		return FirewallSettings::sanitize(
			$this->sanitize_by_template( $payload, FirewallSettings::defaults() )
		);
	}

	/**
	 * Two-factor settings sanitize.
	 *
	 * @param array $payload Imported payload.
	 * @return array
	 */
	private function sanitize_twofa_settings( array $payload ): array {
		$defaults  = TwoFactor::get_default_settings();
		$sanitized = $this->sanitize_by_template( $payload, $defaults );

		$wp_roles = function_exists( 'wp_roles' ) ? wp_roles() : null;
		$valid_map = ( $wp_roles && is_array( $wp_roles->roles ) ) ? $wp_roles->roles : [];
		$roles = [];
		foreach ( (array) ( $sanitized['enforce_for_roles'] ?? [] ) as $role ) {
			$role = sanitize_key( (string) $role );
			if ( $role !== '' && isset( $valid_map[ $role ] ) ) {
				$roles[] = $role;
			}
		}
		$sanitized['enforce_for_roles'] = array_values( array_unique( $roles ) );

		$allowed_methods = [];
		foreach ( (array) ( $sanitized['allowed_methods'] ?? [] ) as $method ) {
			$method = sanitize_key( (string) $method );
			if ( in_array( $method, [ 'totp', 'email' ], true ) ) {
				$allowed_methods[] = $method;
			}
		}
		$sanitized['allowed_methods'] = array_values( array_unique( $allowed_methods ) );
		if ( empty( $sanitized['allowed_methods'] ) ) {
			$sanitized['allowed_methods'] = [ 'totp' ];
		}

		$sanitized['remember_device_days'] = max( 1, min( 365, absint( $sanitized['remember_device_days'] ?? 30 ) ) );
		$sanitized['grace_period_days']    = max( 0, min( 30, absint( $sanitized['grace_period_days'] ?? 3 ) ) );

		return $sanitized;
	}

	/**
	 * Headers settings sanitize.
	 *
	 * @param array $payload Imported payload.
	 * @return array
	 */
	private function sanitize_headers_settings( array $payload ): array {
		return Headers::sanitize_settings_patch( Headers::get_default_settings(), $payload );
	}

	/**
	 * Notification settings sanitize.
	 *
	 * @param array $payload Imported payload.
	 * @return array
	 */
	private function sanitize_notifications_settings( array $payload ): array {
		$notification_settings = new \SecurWP\Modules\Notifications\NotificationSettings();
		$defaults = $notification_settings->get_storage_defaults();
		$sanitized = $this->sanitize_by_template( $payload, $defaults );

		$sanitized['recipient_email'] = \SecurWP\Modules\Notifications\NotificationSettings::normalize_recipient_email_string(
			$sanitized['recipient_email'] ?? ''
		);
		$sanitized['recipient_admin_contacts'] = \SecurWP\Modules\Notifications\NotificationSettings::sanitize_recipient_admin_contacts(
			$sanitized['recipient_admin_contacts'] ?? []
		);

		unset( $sanitized['webhook_url'] );
		unset( $sanitized['webhook_provider_urls'] );
		unset( $sanitized['webhook_providers'] );
		unset( $sanitized['webhook_configured'] );
		unset( $sanitized['webhook_active_provider_configured'] );
		unset( $sanitized['available_admin_contacts'] );

		return $sanitized;
	}

	/**
	 * Cloudflare settings sanitize (safe import subset).
	 *
	 * @param array $payload Imported payload.
	 * @return array
	 */
	private function sanitize_cloudflare_settings( array $payload ): array {
		$defaults = [
			'settings_version' => 3,
			'auth_mode'        => 'api_token',
			'auth_email'       => '',
			'zone_id'          => '',
			'account_id'       => '',
			'enabled'          => false,
			'policy'           => [
				'action'                     => 'managed_challenge',
				'fallback_to_access_rules'   => true,
				'sync_local_banlist'         => true,
				'sync_local_allowlist'       => true,
				'auto_block_firewall_events' => true,
				'event_threshold'            => 3,
				'event_window_minutes'       => 10,
				'event_ban_minutes'          => 120,
				'max_block_entries'          => 5000,
				'country_mode'               => 'off',
				'countries'                  => [],
				'networks'                   => [],
				'user_agents'                => [],
				'bypass_verified_bots'       => true,
				'block_verified_bots'        => false,
				'block_heuristic_bots'       => false,
				'manual_block_entries'       => [],
				'manual_allow_entries'       => [],
			],
			'lists'            => [
				'block_name'         => 'securwp_block_ip',
				'allow_name'         => 'securwp_allow_ip',
				'block_id'           => '',
				'allow_id'           => '',
				'pending_operations' => [],
			],
			'limits'           => [],
			'sync_status'      => [],
			'plan'             => [],
		];

		$sanitized = $this->sanitize_by_template( $payload, $defaults );

		$mode = sanitize_key( (string) ( $sanitized['auth_mode'] ?? 'api_token' ) );
		$sanitized['auth_mode'] = in_array( $mode, [ 'api_token', 'global_key' ], true ) ? $mode : 'api_token';
		$sanitized['auth_email'] = sanitize_email( (string) ( $sanitized['auth_email'] ?? '' ) );
		$sanitized['zone_id'] = sanitize_text_field( (string) ( $sanitized['zone_id'] ?? '' ) );
		$sanitized['account_id'] = sanitize_text_field( (string) ( $sanitized['account_id'] ?? '' ) );
		$sanitized['settings_version'] = max( 1, (int) ( $sanitized['settings_version'] ?? 3 ) );

		$policy = is_array( $sanitized['policy'] ?? null ) ? $sanitized['policy'] : [];
		$action = sanitize_key( (string) ( $policy['action'] ?? 'managed_challenge' ) );
		$policy['action'] = in_array( $action, [ 'block', 'managed_challenge', 'challenge', 'js_challenge', 'log' ], true ) ? $action : 'managed_challenge';
		$policy['event_threshold'] = max( 2, min( 50, (int) ( $policy['event_threshold'] ?? 3 ) ) );
		$policy['event_window_minutes'] = max( 1, min( 1440, (int) ( $policy['event_window_minutes'] ?? 10 ) ) );
		$policy['event_ban_minutes'] = max( 5, min( 10080, (int) ( $policy['event_ban_minutes'] ?? 120 ) ) );
		$policy['max_block_entries'] = max( 10, min( 50000, (int) ( $policy['max_block_entries'] ?? 5000 ) ) );
		$policy['country_mode'] = in_array(
			sanitize_key( (string) ( $policy['country_mode'] ?? 'off' ) ),
			[ 'off', 'block', 'allow' ],
			true
		) ? sanitize_key( (string) $policy['country_mode'] ) : 'off';
		$policy['countries'] = $this->sanitize_country_codes( $policy['countries'] ?? [] );
		$policy['networks'] = $this->sanitize_ip_entries( $policy['networks'] ?? [] );
		$policy['manual_block_entries'] = $this->sanitize_ip_entries( $policy['manual_block_entries'] ?? [] );
		$policy['manual_allow_entries'] = $this->sanitize_ip_entries( $policy['manual_allow_entries'] ?? [] );
		$policy['user_agents'] = $this->sanitize_user_agents( $policy['user_agents'] ?? [] );
		$sanitized['policy'] = $policy;

		$lists = is_array( $sanitized['lists'] ?? null ) ? $sanitized['lists'] : [];
		$lists['block_name'] = $this->sanitize_list_name( (string) ( $lists['block_name'] ?? 'securwp_block_ip' ), 'securwp_block_ip' );
		$lists['allow_name'] = $this->sanitize_list_name( (string) ( $lists['allow_name'] ?? 'securwp_allow_ip' ), 'securwp_allow_ip' );
		$lists['block_id'] = sanitize_text_field( (string) ( $lists['block_id'] ?? '' ) );
		$lists['allow_id'] = sanitize_text_field( (string) ( $lists['allow_id'] ?? '' ) );
		$lists['pending_operations'] = $this->sanitize_text_list( $lists['pending_operations'] ?? [], 400, 120 );
		$sanitized['lists'] = $lists;

		return $sanitized;
	}

	/**
	 * Recursively remove secret keys from a nested array.
	 *
	 * @param array $data Input array.
	 * @return array Cleaned array.
	 */
	private function strip_secrets( array $data ): array {
		$secrets = array_flip( self::SECRET_KEYS );

		foreach ( $data as $key => $value ) {
			if ( isset( $secrets[ $key ] ) ) {
				unset( $data[ $key ] );
			} elseif ( is_array( $value ) ) {
				$data[ $key ] = $this->strip_secrets( $value );
			}
		}

		return $data;
	}

	/**
	 * Extract only secret key/value pairs from a nested array.
	 *
	 * @param array $data Input array.
	 * @return array Array containing only the secret keys and their values.
	 */
	private function extract_secrets( array $data ): array {
		$secrets = array_flip( self::SECRET_KEYS );
		$result  = [];

		foreach ( $data as $key => $value ) {
			if ( isset( $secrets[ $key ] ) ) {
				$result[ $key ] = $value;
			} elseif ( is_array( $value ) ) {
				$nested = $this->extract_secrets( $value );
				if ( ! empty( $nested ) ) {
					$result[ $key ] = $nested;
				}
			}
		}

		return $result;
	}

	/**
	 * Sanitize incoming values against a template shape.
	 *
	 * @param mixed $incoming Incoming value.
	 * @param mixed $template Template/default value.
	 * @return mixed
	 */
	private function sanitize_by_template( $incoming, $template ) {
		if ( is_bool( $template ) ) {
			return (bool) $incoming;
		}

		if ( is_int( $template ) ) {
			return absint( $incoming );
		}

		if ( is_float( $template ) ) {
			return (float) $incoming;
		}

		if ( is_string( $template ) ) {
			return sanitize_text_field( (string) $incoming );
		}

		if ( is_array( $template ) ) {
			if ( ! is_array( $incoming ) ) {
				return $template;
			}

			if ( $template === [] ) {
				return $this->sanitize_recursive_mixed( $incoming );
			}

			$output = $template;
			foreach ( $template as $key => $default_value ) {
				if ( array_key_exists( $key, $incoming ) ) {
					$output[ $key ] = $this->sanitize_by_template( $incoming[ $key ], $default_value );
				}
			}

			return $output;
		}

		return $incoming;
	}

	/**
	 * Generic recursive sanitizer for mixed arrays.
	 *
	 * @param mixed $value Raw value.
	 * @return mixed
	 */
	private function sanitize_recursive_mixed( $value ) {
		if ( is_array( $value ) ) {
			$result = [];
			foreach ( $value as $key => $item ) {
				$clean_key = is_string( $key ) ? sanitize_text_field( $key ) : $key;
				$result[ $clean_key ] = $this->sanitize_recursive_mixed( $item );
			}
			return $result;
		}

		if ( is_string( $value ) ) {
			return sanitize_text_field( $value );
		}

		if ( is_bool( $value ) ) {
			return (bool) $value;
		}

		if ( is_int( $value ) ) {
			return (int) $value;
		}

		if ( is_float( $value ) ) {
			return (float) $value;
		}

		return null;
	}

	/**
	 * @param mixed $input
	 * @return string[]
	 */
	private function sanitize_ip_entries( $input ): array {
		$items = $this->normalize_list_input( $input );
		$clean = [];
		foreach ( $items as $value ) {
			$normalized = $this->sanitize_ip_or_cidr( $value );
			if ( $normalized !== '' ) {
				$clean[] = $normalized;
			}
		}
		return array_values( array_slice( array_unique( $clean ), 0, 50000 ) );
	}

	/**
	 * @param mixed $input
	 * @return string[]
	 */
	private function sanitize_country_codes( $input ): array {
		$items = $this->normalize_list_input( $input );
		$clean = [];
		foreach ( $items as $value ) {
			$code = strtoupper( sanitize_text_field( trim( $value ) ) );
			if ( preg_match( '/^[A-Z]{2}$/', $code ) === 1 ) {
				$clean[] = $code;
			}
		}
		return array_values( array_slice( array_unique( $clean ), 0, 250 ) );
	}

	/**
	 * @param mixed $input
	 * @return string[]
	 */
	private function sanitize_user_agents( $input ): array {
		$items = $this->normalize_list_input( $input );
		$clean = [];
		foreach ( $items as $value ) {
			$value = strtolower( sanitize_text_field( trim( $value ) ) );
			if ( $value !== '' ) {
				$clean[] = substr( $value, 0, 120 );
			}
		}
		return array_values( array_slice( array_unique( $clean ), 0, 200 ) );
	}

	/**
	 * @param mixed $input
	 * @return string[]
	 */
	private function sanitize_text_list( $input, int $max_items, int $max_len ): array {
		$items = $this->normalize_list_input( $input );
		$clean = [];
		foreach ( $items as $item ) {
			$item = sanitize_text_field( trim( $item ) );
			if ( $item !== '' ) {
				$clean[] = substr( $item, 0, $max_len );
			}
		}
		return array_values( array_slice( array_unique( $clean ), 0, $max_items ) );
	}

	/**
	 * @param string $name
	 * @param string $fallback
	 * @return string
	 */
	private function sanitize_list_name( string $name, string $fallback ): string {
		$name = strtolower( $name );
		$name = preg_replace( '/[^a-z0-9_]/', '_', $name ) ?: '';
		$name = trim( $name, '_' );
		if ( $name === '' ) {
			$name = $fallback;
		}
		return substr( $name, 0, 50 );
	}

	/**
	 * @param string $value
	 * @return string
	 */
	private function sanitize_ip_or_cidr( string $value ): string {
		$value = trim( $value );
		if ( $value === '' ) {
			return '';
		}

		if ( filter_var( $value, FILTER_VALIDATE_IP ) ) {
			return $value;
		}

		if ( strpos( $value, '/' ) === false ) {
			return '';
		}

		[ $subnet, $bits ] = explode( '/', $value, 2 );
		$subnet = trim( $subnet );
		$bits   = trim( $bits );

		if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) || ! ctype_digit( $bits ) ) {
			return '';
		}

		$bits_int = (int) $bits;
		if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			if ( $bits_int < 1 || $bits_int > 32 ) {
				return '';
			}
		} elseif ( $bits_int < 1 || $bits_int > 128 ) {
			return '';
		}

		return $subnet . '/' . $bits_int;
	}

	/**
	 * @param mixed $input
	 * @return string[]
	 */
	private function normalize_list_input( $input ): array {
		if ( is_string( $input ) ) {
			$decoded = json_decode( $input, true );
			if ( is_array( $decoded ) ) {
				$input = $decoded;
			}
		}

		if ( is_array( $input ) ) {
			$flat = [];
			foreach ( $input as $item ) {
				if ( is_array( $item ) ) {
					foreach ( $item as $nested ) {
						if ( is_scalar( $nested ) ) {
							$flat[] = (string) $nested;
						}
					}
					continue;
				}
				if ( is_scalar( $item ) ) {
					$flat[] = (string) $item;
				}
			}
			return $this->split_lines_and_commas( $flat );
		}

		if ( is_scalar( $input ) ) {
			return $this->split_lines_and_commas( [ (string) $input ] );
		}

		return [];
	}

	/**
	 * @param string[] $values
	 * @return string[]
	 */
	private function split_lines_and_commas( array $values ): array {
		$items = [];
		foreach ( $values as $value ) {
			$parts = preg_split( '/[\r\n,]+/', $value, -1, PREG_SPLIT_NO_EMPTY );
			if ( ! is_array( $parts ) ) {
				continue;
			}
			foreach ( $parts as $part ) {
				$part = trim( $part );
				if ( $part !== '' ) {
					$items[] = $part;
				}
			}
		}
		return $items;
	}

	/**
	 * Extract the major version number from a semver string.
	 *
	 * @param string $version Semantic version string (e.g. "2.7.0").
	 * @return int Major version number.
	 */
	private function get_major_version( string $version ): int {
		$parts = explode( '.', $version );
		return (int) ( $parts[0] ?? 0 );
	}
}
