<?php

namespace SugarCalendarBookings;

use SugarCalendarBookings\Availability\WorkingScheduleDefaults;
use SugarCalendarBookings\Notifications\Emails\EmailDefaults;
use SugarCalendarBookings\Support\Helpers\Data;
use SugarCalendarBookings\Support\Helpers\Date;

/**
 * Class Options.
 *
 * Plugin options.
 *
 * @since 1.0.0
 */
class Options {

	/**
	 * Option name in the `options` DB table.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	const OPTION_NAME = 'sugar_calendar_bookings';

	/**
	 * Options that are stored in separate options in the DB.
	 *
	 * @since 1.0.0
	 *
	 * @var array
	 */
	const INDIVIDUAL_OPTIONS = [
		'availability',
		'emails',
		'import',
	];

	/**
	 * Options data.
	 *
	 * @since 1.0.0
	 *
	 * @var array
	 */
	private $main_options = null;

	/**
	 * Individual options.
	 *
	 * @since 1.0.0
	 *
	 * @var array
	 */
	private $individual_options = [];

	/**
	 * Get option by key.
	 *
	 * @since 1.0.0
	 *
	 * @param string $key           Option key.
	 * @param mixed  $default_value Default value.
	 *
	 * @return mixed
	 */
	public function get( $key, $default_value = null ) {

		$group = explode( '.', $key )[0];

		if ( in_array( $group, self::INDIVIDUAL_OPTIONS, true ) ) {
			$this->maybe_load_option( $group );

			$options = $this->individual_options;
		} else {
			$this->maybe_load_option();

			$options = $this->main_options;
		}

		if ( $default_value === null ) {
			$default_value = '__default__';
		}

		$options = $this->filter_options( $options );

		$value = Data::get( $options, $key, $default_value );

		if ( $value === '__default__' ) {
			$value = Data::get( $this->get_default(), $key );
		}

		return $value;
	}

	/**
	 * Save options to DB.
	 *
	 * @since 1.0.0
	 *
	 * @param array $data  Options data.
	 * @param bool  $merge Whether to merge with existing options or overwrite all option with provided.
	 */
	public function update( $data, $merge = true ) {

		$data = $this->process( $data );

		// Update individual options.
		foreach ( self::INDIVIDUAL_OPTIONS as $group ) {
			if ( isset( $data[ $group ] ) ) {
				$group_data = $data[ $group ];

				if ( $merge ) {
					$group_data = $this->merge_options( $this->get( $group ), $group_data );
				}

				$this->update_option( $group_data, $group );

				unset( $data[ $group ] );
			}
		}

		// Update main options.
		if ( ! empty( $data ) ) {
			if ( $merge ) {
				$data = $this->merge_options( $this->get_main_options(), $data );
			}

			$this->update_option( $data );
		}
	}

	/**
	 * Process options data. Clean and set default values if needed.
	 *
	 * @since 1.0.0
	 *
	 * @param array $data Options data.
	 *
	 * @return array
	 */
	private function process( $data ) {

		Data::walk_recursive( $data, [ $this, 'process_option' ] );

		return $data;
	}

	/**
	 * Merge options.
	 *
	 * @since 1.0.0
	 *
	 * @param array $old_options Old options.
	 * @param array $new_options New options.
	 *
	 * @return array
	 */
	private function merge_options( $old_options, $new_options ) {

		$merged = $old_options;

		foreach ( $new_options as $key => $value ) {
			if (
				is_array( $value ) &&
				! empty( $value ) &&
				isset( $merged [ $key ] ) &&
				is_array( $merged [ $key ] ) &&
				// Do not merge numeric arrays and replace completely.
				array_key_first( $value ) !== 0
			) {
				$merged [ $key ] = $this->merge_options( $merged [ $key ], $value );
			} else {
				$merged [ $key ] = $value;
			}
		}

		return $merged;
	}

	/**
	 * Get all options.
	 *
	 * @since 1.0.0
	 *
	 * @return array
	 */
	public function get_main_options() {

		$this->maybe_load_option();

		return $this->main_options;
	}

	/**
	 * Load option.
	 *
	 * @since 1.0.0
	 *
	 * @param string $group Option group.
	 */
	private function maybe_load_option( $group = null ) {

		if (
			( $group !== null && isset( $this->individual_options[ $group ] ) ) ||
			( $group === null && $this->main_options !== null )
		) {
			return;
		}

		$option_name = self::OPTION_NAME;

		if ( $group ) {
			$option_name .= '_' . $group;
		}

		$options = get_option( $option_name, [] );
		$options = ! empty( $options ) && is_array( $options ) ? $options : [];

		if ( $group ) {
			$this->individual_options[ $group ] = $options;
		} else {
			$this->main_options = $options;
		}
	}

	/**
	 * Update options.
	 *
	 * @since 1.0.0
	 *
	 * @param array  $data  Options data.
	 * @param string $group Option group.
	 */
	private function update_option( $data, $group = null ) {

		$option_name = self::OPTION_NAME;

		if ( $group ) {
			$option_name .= '_' . $group;
		}

		update_option( $option_name, $data );

		if ( $group ) {
			$this->individual_options[ $group ] = $data;
		} else {
			$this->main_options = $data;
		}
	}

	/**
	 * Process option. Clean and set default value if needed.
	 *
	 * This method is callback for `Collection::walk_recursive`.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed  $value Option value. Passed by reference.
	 * @param string $key   Option key.
	 */
	public function process_option( &$value, $key ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded

		switch ( $key ) {
			case 'display.start_of_week':
			case 'appointments.advance_minimum_days':
			case 'appointments.advance_maximum_days':
				$value = intval( $value );
				break;

			case 'display.date_format':
			case 'display.time_format':
				$value = sanitize_option( 'date_format', $value );
				break;

			case 'appointments.advance_minimum':
			case 'appointments.advance_maximum':
				$value = (bool) $value;
				break;

			case 'payments.currency':
				$value = strtoupper( sanitize_key( $value ) );
				break;

			case 'payments.currency_position':
			case 'appointments.default_status':
			case 'antispam.method':
			case 'antispam.recaptcha.theme':
			case 'antispam.turnstile.theme':
				$value = sanitize_key( $value );
				break;

			case 'display.timezone':
			case 'payments.thousands_separator':
			case 'payments.decimal_separator':
			case 'availability.working_schedule.overrides.start_date':
			case 'availability.working_schedule.overrides.end_date':
			case 'employee.name':
			case 'employee.phone':
			case 'antispam.recaptcha_v2.site_key':
			case 'antispam.recaptcha_v2.secret_key':
			case 'antispam.recaptcha_v3.site_key':
			case 'antispam.recaptcha_v3.secret_key':
			case 'antispam.recaptcha.fail_message':
			case 'antispam.turnstile.site_key':
			case 'antispam.turnstile.secret_key':
			case 'antispam.turnstile.fail_message':
				$value = sanitize_text_field( $value );
				break;

			case 'employee.email':
				$value = sanitize_email( $value );
				break;

			case 'availability.working_schedule.regular.weekday':
			case 'availability.working_schedule.regular.periods.start':
			case 'availability.working_schedule.regular.periods.end':
			case 'availability.working_schedule.overrides.periods.start':
			case 'availability.working_schedule.overrides.periods.end':
			case 'availability.working_schedule.regular.type':
				$value = is_numeric( $value ) ? intval( $value ) : sanitize_key( $value );
				break;

			case 'emails.new_booking_attendee.enabled':
			case 'emails.new_booking_employee.enabled':
			case 'emails.booking_update_attendee.enabled':
			case 'emails.booking_update_employee.enabled':
			case 'emails.booking_cancellation_attendee.enabled':
			case 'emails.booking_cancellation_employee.enabled':
			case 'emails.appointment_reminder_attendee.enabled':
			case 'emails.appointment_reminder_employee.enabled':
			case 'stripe.test_mode':
			case 'antispam.no_conflict':
				$value = (bool) $value;
				break;

			case 'emails.from_email':
				$value = sanitize_email( $value );
				break;

			case 'emails.from_name':
				$value = sanitize_text_field( $value );
				break;

			case 'stripe.test_user_id':
			case 'stripe.live_user_id':
			case 'stripe.test_publishable_key':
			case 'stripe.live_publishable_key':
			case 'stripe.test_secret_key':
			case 'stripe.live_secret_key':
				// TODO: maybe encrypt keys.
				$value = sanitize_text_field( $value );
				break;

			case 'license.key':
				$value = sanitize_key( $value );
				break;

			case 'license.type':
				$value = sanitize_key( $value );
				break;

			case 'license.is_expired':
			case 'license.is_disabled':
			case 'license.is_invalid':
			case 'license.is_limit_reached':
				$value = (bool) $value;
				break;

			case 'antispam.recaptcha.threshold':
				// Treat empty/non-numeric input as "use the default" — otherwise
				// floatval('') would silently store 0.0, which makes v3 accept
				// every score (a no-op captcha).
				if ( $value === '' || ! is_numeric( $value ) ) {
					$value = 0.5;

					break;
				}

				$value = max( 0.0, min( 1.0, floatval( $value ) ) );
				break;

			default:
				// Integration enabled flags: integrations.{provider}.enabled.
				if ( str_starts_with( $key, 'integrations.' ) && str_ends_with( $key, '.enabled' ) ) {
					$value = (bool) $value; // phpcs:ignore WPForms.Formatting.EmptyLineAfterAssigmentVariables.AddEmptyLine
					break;
				}

				$value = wp_kses_post( $value );
		}
	}

	/**
	 * Get default options.
	 *
	 * @since 1.0.0
	 *
	 * @return array
	 */
	public function get_default() {

		return [
			'display'      => [
				'timezone'      => '',
				'start_of_week' => get_option( 'start_of_week' ),
				'date_format'   => get_option( 'date_format' ),
				'time_format'   => get_option( 'time_format' ),
			],
			'misc'         => [
				'disable_integrations' => false,
			],
			'appointments' => [
				'default_status'       => 'approved',
				'advance_minimum'      => false,
				'advance_minimum_days' => 1,
				'advance_maximum'      => false,
				'advance_maximum_days' => 31,
			],
			'payments'     => [
				'currency'            => 'USD',
				'currency_position'   => 'before',
				'thousands_separator' => ',',
				'decimal_separator'   => '.',
				'gateways'            => [
					'stripe' => [
						'enabled' => true,
					],
					'onsite' => [
						'enabled' => true,
					],
				],
			],
			'availability' => [
				'working_schedule' => WorkingScheduleDefaults::to_array(),
			],
			'emails'       => EmailDefaults::to_array(),
			'stripe'       => [
				'test_mode'            => false,
				'test_user_id'         => '',
				'test_publishable_key' => '',
				'test_secret_key'      => '',
				'live_user_id'         => '',
				'live_publishable_key' => '',
				'live_secret_key'      => '',
			],
			'antispam'     => [
				'method'       => 'none',
				'no_conflict'  => false,
				// v2 and v3 keep separate site/secret slots so admins can
				// configure both methods without losing the other's
				// credentials when switching sub-methods.
				'recaptcha_v2' => [
					'site_key'   => '',
					'secret_key' => '',
				],
				'recaptcha_v3' => [
					'site_key'   => '',
					'secret_key' => '',
				],
				'recaptcha'    => [
					'theme'        => 'auto',
					'threshold'    => 0.5,
					'fail_message' => '',
				],
				'turnstile'    => [
					'site_key'     => '',
					'secret_key'   => '',
					'theme'        => 'auto',
					'fail_message' => '',
				],
			],
			'integrations' => [
				'zoom' => [
					'enabled' => true,
				],
			],
			'license'      => [
				'key'              => '',
				'type'             => 'lite',
				'is_expired'       => false,
				'is_disabled'      => false,
				'is_invalid'       => false,
				'is_limit_reached' => false,
			],
		];
	}

	/**
	 * Allows to modify option values.
	 *
	 * @since 1.0.0
	 *
	 * @param array $options Options.
	 *
	 * @return array
	 */
	private function filter_options( $options ) {

		// We need to proxy the timezone from display settings.
		// This way, we don't need to sync on the timezone update.
		if ( isset( $options['availability'] ) ) {
			$options['availability']['working_schedule']['timezone'] = Date::get_default_timezone_string();
		}

		return $options;
	}
}
