<?php

namespace GravityKit\GravityExport\Save\Addon;

use GFAPI;
use GFExcel\Addon\AddonHelperTrait;
use GFExcel\Addon\AddonInterface;
use GFExcel\Addon\AddonTrait;
use GFExcel\GFExcel;
use GravityKit\GravityExport\Addon\GravityExportAddon;
use GravityKit\GravityExport\Filters\Addon\FiltersFeedAddon;
use GravityKit\GravityExport\Foundation\Helpers\Arr;
use GravityKit\GravityExport\Save\Exception\SaveException;
use GravityKit\GravityExport\Save\Feature;
use GravityKit\GravityExport\Save\Service\ConnectionManagerService;
use GravityKit\GravityExport\Save\Service\PasswordService;
use GravityKit\GravityExport\Save\Service\StorageService;
use GravityKit\GravityExport\Save\StorageType\FlySystemStorageType;
use GravityKit\GravityExport\Save\StorageType\Local;
use GravityKit\GravityExport\Save\StorageType\StorageTypeInterface;
use GravityKit\GravityExport\QueryFilters\QueryFilters;

/**
 * An add-on that provides settings for multiple store methods.
 *
 * @since 1.0
 */
class SaveAddon extends SaveAddonVariables implements AddonInterface {
	use AddonTrait;
	use AddonHelperTrait;

	/**
	 * @since 1.0
	 * @var string String used as a nonce scalar value and assets handle.
	 */
	public const AJAX_TEST_CONNECTION_ACTION = 'gravityexport_save_test_connection';

	/**
	 * @since 1.0
	 * @var string String used as a nonce scalar value and assets handle.
	 */
	public const NONCE_AND_ASSETS_HANDLE = 'gravityexport_save';

	/**
	 * @since 1.0
	 * @var string The field that holds the storage type.
	 */
	public const STORAGE_TYPE = 'storage_type';

	/**
	 * @since 1.0
	 * @var string The field that holds the storage title.
	 */
	public const STORAGE_TITLE = 'feedName';

	/**
	 * @since $ver$
	 * @var int The source feed.
	 */
	public const STORAGE_SOURCE_FEED = 'source_feed';

	/**
	 * The field that holds what kind of file we are rendering.
	 *
	 * @since 1.0
	 * @var string
	 */
	public const FILE_TYPE = 'file_type';

	/**
	 * @since 1.0
	 * @var string File type that represents all entries.
	 */
	public const FILE_ENTRIES_ALL = 'all';

	/**
	 * @since 1.0
	 * @var string File type that represents a single entry.
	 */
	public const FILE_ENTRIES_SINGLE = 'single';

	/**
	 * @since $ver$
	 * @var string The field that holds what triggers the export.
	 */
	public const EXPORT_TRIGGER = 'export_trigger';

	/**
	 * @since $ver$
	 * @var string Export trigger that represents exporting on a new entry (default).
	 */
	public const EXPORT_TRIGGER_ENTRY = 'entry';

	/**
	 * @since $ver$
	 * @var string Export trigger that represents exporting manually.
	 */
	public const EXPORT_TRIGGER_MANUAL = 'manual';

	/**
	 * @since 1.2.0
	 * @var string Setting that holds whether to export a feed on entry updates as well.
	 */
	public const EXPORT_ON_UPDATE = 'export_on_update';

	/**
	 * The callback permalink action.
	 * @since $ver$
	 */
	public const PERMALINK_CALLBACK_ACTION = 'gravitykit/gravityexport/save/callback';

	/**
	 * The cronjob permalink action.
	 * @since $ver$
	 */
	private const PERMALINK_CRONJOB_FEED_ACTION = 'gravitykit/gravityexport/save/feed/(\d+)/([^/]+)';

	/**
	 * @since 1.0
	 * @var string Feed settings permissions.
	 */
	protected $_capabilities_form_settings = 'gravityforms_export_entries';

	/**
	 * @since x.x
	 * @var string Relative path to file from plugins directory.
	 */
	protected $_path = 'gravityexport/add-ons/save/src/Addon/SaveAddon.php';

	/**
	 * @since x.x
	 * @var string Full path to this file.
	 */
	protected $_full_path = __FILE__;

	/**
	 * @since 1.0
	 * @var StorageTypeInterface[] Holds all instances of the storage types.
	 */
	private $storage_types = [];

	/**
	 * @since 1.0
	 * @var StorageService The storage service.
	 */
	private $storage_service;

	/**
	 * @since 1.0
	 * @var ConnectionManagerService Connection manager service.
	 */
	private $connection_manager_service;

	/**
	 * @since 1.0
	 * @var PasswordService Password service.
	 */
	private $password_service;

	/**
	 * @since 1.0
	 * @var QueryFilters Query Filters instance.
	 */
	private $query_filters;

	/**
	 * @inheritdoc
	 * @since 1.0
	 */
	public function __construct( StorageService $storage_service, PasswordService $password_service, ConnectionManagerService $connection_manager_service ) {
		parent::__construct();

		$this->storage_service            = $storage_service;
		$this->password_service           = $password_service;
		$this->connection_manager_service = $connection_manager_service;

		$this->query_filters = new QueryFilters();
	}

	/**
	 * @inheritdoc
	 * @since 1.0
	 */
	public function init(): void {
		$this->_title       = __( 'GravityExport Save', 'gk-gravityexport' );
		$this->_short_title = __( 'GravityExport Save', 'gk-gravityexport' );

		parent::init();

		add_action(
			'wp_ajax_' . self::AJAX_TEST_CONNECTION_ACTION,
			\Closure::fromCallable( [ $this, 'testConnection' ] )
		);

		add_filter( 'gform_after_update_entry', function ( $form, $entry_id ): void {
			$this->process_update_entry( $entry_id );
		}, 10, 2 );

		add_filter( 'gravityview-inline-edit/entry-updated', function ( $update_result, $entry ) {
			$this->process_update_entry( $entry );

			return $update_result;
		}, 10, 2 );

		add_filter( 'gravityview/edit_entry/after_update', function ( $form, $entry_id ): void {
			$this->process_update_entry( $entry_id );
		}, 10, 2 );

		add_filter( 'gform_noconflict_scripts', \Closure::fromCallable( [ $this, 'allowlist_ui_assets' ] ) );
		add_filter( 'gform_noconflict_styles', \Closure::fromCallable( [ $this, 'allowlist_ui_assets' ] ) );

		add_action( 'request', \Closure::fromCallable( [ $this, 'set_request_feed_id' ] ), 10 );
		// We give time for the FilterAddOn to respond and hook up any filters if it has to.
		add_action( 'request', \Closure::fromCallable( [ $this, 'handle_save_feed_hook' ] ), 25 );

		add_action( 'query_vars', \Closure::fromCallable( [ $this, 'query_vars' ] ) );
		add_action( 'wp', \Closure::fromCallable( [ $this, 'callback_request' ] ) );

		add_action( 'gk/gravityexport/save/action/save-feed', [ $this, 'action_save_feed' ] );

		$this->activateFeatures();
		$this->addPermalinkRules();

		add_action( 'shutdown', function () {
			$this->storage_service->clearHistory();
		} );
	}

	/**
	 * Return the plugin's icon for the plugin/form settings menu.
	 *
	 * @since 1.0
	 *
	 * @return string
	 */
	public function get_menu_icon(): string {
		return '<svg style="height: 24px;" enable-background="new 0 0 226 148" height="148" viewBox="0 0 226 148" width="226" xmlns="http://www.w3.org/2000/svg"><path d="m176.8 118.8c-1.6 1.6-4.1 1.6-5.7 0l-5.7-5.7c-1.6-1.6-1.6-4.1 0-5.7l27.6-27.4h-49.2c-4.3 39.6-40 68.2-79.6 63.9s-68.2-40-63.9-79.6 40.1-68.2 79.7-63.9c25.9 2.8 48.3 19.5 58.5 43.5.6 1.5-.1 3.3-1.7 3.9-.4.1-.7.2-1.1.2h-9.9c-1.9 0-3.6-1.1-4.4-2.7-14.7-27.1-48.7-37.1-75.8-22.4s-37.2 48.8-22.4 75.9 48.8 37.2 75.9 22.4c15.5-8.4 26.1-23.7 28.6-41.2h-59.4c-2.2 0-4-1.8-4-4v-8c0-2.2 1.8-4 4-4h124.7l-27.5-27.5c-1.6-1.6-1.6-4.1 0-5.7l5.7-5.7c1.6-1.6 4.1-1.6 5.7 0l41.1 41.2c3.1 3.1 3.1 8.2 0 11.3z"/></svg>';
	}

	/**
	 * Returns an array of default filename formats.
	 *
	 * @since 1.0
	 *
	 * @return array[] Array with `value`, `label`, and `title` keys.
	 */
	public static function get_filename_formats(): array {

		$formats = [
			[
				'value'  => 'all-default',
				'label'  => sprintf( esc_html_x( 'Default Format: %s', '%s is replaced by the filename structure', 'gk-gravityexport' ), '"gravityexport-{form_id}-{form_title}-{YYYY}-{MM}-{DD}"' ),
				'format' => 'gravityexport-{form_id}-{form_title}-{YYYY}-{MM}-{DD}',
			],
			[
				'value'  => 'all-{form_id}-{YYYY}-{MM}-{DD}',
				'label'  => sprintf( esc_html_x( 'Form ID and Date: %s', '%s is replaced by the filename structure', 'gk-gravityexport' ), '"{form_id}-{YYYY}-{MM}-{DD}"' ),
				'format' => '{form_id}-{YYYY}-{MM}-{DD}',
			],
			[
				'value'  => 'single-default',
				'label'  => sprintf( esc_html_x( 'Default Format: %s', '%s is replaced by the filename structure', 'gk-gravityexport' ), '"gravityexport-{form_id}-{form_title}-{YYYY}-{MM}-{DD}-{entry_id}"' ),
				'format' => 'gravityexport-{form_id}-{form_title}-{YYYY}-{MM}-{DD}-entry-{entry_id}',
			],
			[
				'value'  => 'single-{form_id}-{entry_id}',
				'label'  => sprintf( esc_html_x( 'Form ID and Entry ID: %s', '%s is replaced by the filename structure', 'gk-gravityexport' ), '"form-{form_id}-entry-{entry_id}"' ),
				'format' => 'form-{form_id}-entry-{entry_id}',
			],
			[
				'value'  => 'custom',
				'label'  => esc_html__( 'Custom Filename', 'gk-gravityexport' ),
				'format' => null,
			],
		];

		/**
		 * @filter `gk/gravityexport/save/setting/filename-formats' Modify the filename formats list in Save. To remove ability to define custom formats, remove array where "value" === "custom".
		 *
		 * @since  1.0
		 *
		 * @param array[] $formats Associative array with `value`, `label`, and `format` keys.
		 */
		return apply_filters( 'gk/gravityexport/save/settings/filename-formats', $formats );
	}

	/**
	 * Returns the fields set for every feed.
	 *
	 * @since 1.0
	 * @return array[] The fields.
	 */
	public function feed_settings_fields(): array {
		$feed = $this->get_current_feed();
		$form = $this->get_current_form();

		if ( ! $form ) {
			return [];
		}

		$settings_description = '';

		if ( ! self::is_download_enabled() ) {
			$form_id = (int) rgget( 'id' );

			$settings_description = strtr( esc_html__( 'Files will not be saved until you [url]enable the download[/url].', 'gk-gravityexport' ), array(
				'[url]'  => '<a href="' . esc_url( admin_url( sprintf( 'admin.php?page=gf_edit_forms&view=settings&subview=%s&id=%s', GFExcel::$slug, $form_id ) ) ) . '">',
				'[/url]' => '</a>',
			) );

			$settings_description = sprintf( '<p class="alert warning">%s</p>', $settings_description );
		} else if ( $feed && ! (bool) $feed['is_active'] ) {
			$settings_description = strtr( esc_html__( 'Files will not be saved until you [url]activate this feed[/url].', 'gk-gravityexport' ), array(
				'[url]'  => '<a href="' . esc_url( admin_url( sprintf( '/admin.php?page=gf_edit_forms&view=settings&subview=%s&id=%s', $this->_slug, $feed['form_id'] ) ) ) . '">',
				'[/url]' => '</a>',
			) );

			$settings_description = sprintf( '<p class="alert warning">%s</p>', $settings_description );
		}

		return array_merge(
			[
				[
					'title'       => esc_html__( 'General Settings', 'gk-gravityexport' ),
					'description' => $settings_description,
					'fields'      => $this->feed_general_settings_fields(),
				],
			],
			[
				[
					'title'       => esc_html__( 'File Settings', 'gk-gravityexport' ),
					'description' => '',
					'fields'      => [
						[
							'label'       => esc_html__( 'Filename Format', 'gk-gravityexport' ),
							'type'        => 'radio',
							'name'        => 'filename_format',
							'description' => '',
							'value'       => 'all-default',
							'choices'     => self::get_filename_formats(),
						],
						[
							'label'         => esc_html__( 'Custom Filename', 'gk-gravityexport' ),
							'type'          => 'text',
							'name'          => 'filename',
							'class'         => 'code medium merge-tag-support mt-position-right mt-hide_all_fields',
							'description'   => wpautop( sprintf( esc_html__( 'Enter a custom filename for the export. If a file exists with the same name, it will be overwritten. Use Merge Tags to generate unique filenames. Leave empty to use the default filename.

The following replacements are also available: %s

Filenames will be sanitized using the %s function. Most non-alphanumeric characters will be replaced with hyphens.', 'gk-gravityexport' ),
								'
			<ul class="ul-disc">
				<li><code>{DATE}</code> ' . esc_html__( 'Date in ISO-8601 format (YYYY-MM-DD HH:MM:SS)', 'gk-gravityexport' ) . '</li>
				<li><code>{TIMESTAMP}</code> ' . esc_html__( 'Server timestamp', 'gk-gravityexport' ) . '</li> 
				<li><code>{YYYY}</code> ' . esc_html__( 'A full numeric representation of a year, 4 digits', 'gk-gravityexport' ) . '</li>
				<li><code>{YY}</code> ' . esc_html__( 'A two digit representation of a year', 'gk-gravityexport' ) . '</li>
				<li><code>{MM}</code> ' . esc_html__( 'Numeric representation of a month, with leading zeros', 'gk-gravityexport' ) . '</li>
				<li><code>{MONTH}</code> ' . esc_html__( 'A full textual representation of a month, such as January or March', 'gk-gravityexport' ) . '</li>
				<li><code>{DD}</code> ' . esc_html__( 'Day of the month, 2 digits with leading zeros', 'gk-gravityexport' ) . '</li>
			</ul>
			',
								'<a href="https://developer.wordpress.org/reference/functions/sanitize_file_name/" target="_blank"><code>sanitize_file_name()</code><span class="dashicons dashicons-external" title="' . esc_attr__( 'This link opens in a new window.', 'gk-gravityexport' ) . '"></span></a>'
							) ),
							'placeholder'   => GFExcel::getFilename( $form ),
							'save_callback' => function ( $field, $original_value ) {

								$value = $original_value;

								// Remove extensions from the file name; they're not needed.
								$value = preg_replace( '/\.(' . GFExcel::getPluginFileExtensions( true ) . ')$/is', '', $value );

								// Strip Merge Tags and replace with placeholders so they're not stripped during sanitization.
								preg_match_all( '/{(.+?)}/ism', $original_value, $merge_tag_matches, PREG_SET_ORDER );

								foreach ( $merge_tag_matches as $i => $match ) {
									[ $full_match ] = $match;

									$value = str_replace( $full_match, 'gravityexport_merge_tag_' . $i, $value );
								}

								$value = sanitize_file_name( $value );

								// Let's add back in the Merge Tags!
								foreach ( $merge_tag_matches as $i => $match ) {
									list( $full_match, $match_contents ) = $match;

									$value = str_replace( 'gravityexport_merge_tag_' . $i, $full_match, $value );
								}

								return $value;
							},
						],
						[
							'label'   => esc_html__( 'File Extension', 'gk-gravityexport' ),
							'type'    => 'select',
							'name'    => 'file_extension',
							'class'   => 'small-text',
							'choices' => array_map( static function ( $extension ) {
								return
									[
										'name'  => 'file_extension',
										'label' => '.' . $extension,
										'value' => $extension,
									];
							}, GFExcel::getPluginFileExtensions() ),
						],
					]
				]
			],
			[
				[
					'title'  => esc_html__( 'Storage Type', 'gk-gravityexport' ),
					'fields' => [
						[
							'label'    => esc_html__( 'Type', 'gk-gravityexport' ),
							'type'     => 'radio',
							'name'     => self::STORAGE_TYPE,
							'tooltip'  => esc_html__( 'Select the type of storage you wish to add.', 'gk-gravityexport' ),
							'choices'  => $this->storage_types_options(),
							'required' => true,
							'onchange' => 'jQuery("#gform-settings").submit();',
						],
					]
				]
			],
			$this->getStorageTypeFieldSettings(),
			[
				[
					'title'      => esc_html__( 'Filter Settings', 'gk-gravityexport' ),
					'fields'     => [
						[
							'name'        => 'download_filters',
							'full_screen' => false,
							'label'       => esc_html__( 'Conditional Logic', 'gk-gravityexport' ),
							'tooltip'     => 'export_conditional_logic',
							'type'        => 'html',
							'html'        => '<div id="gk-query-filters"></div>',
						],
					],
					'dependency' => [
						'live'   => true,
						'fields' => [
							[
								'field'  => self::STORAGE_SOURCE_FEED,
								'values' => [ '0' ],
							],
						],
					],
				],
			],
			[
				[
					'fields' => [
						[ 'type' => 'save' ],
					],
				],
			]
		);
	}

	/**
	 * The fields for the general settings.
	 *
	 * @since 1.0
	 * @return mixed[] The fields.
	 */
	private function feed_general_settings_fields(): array {

		$filter_feed_options = $this->source_feed_options();

		$filter_feed_description = strtr( esc_html__( /** @lang text */ 'Select a filter feed to use the configured feed\'s [b]Enabled Fields[/b] and [b]Conditional Logic[/b] settings when generating the export file.', 'gk-gravityexport' ), [
			'[b]'  => '<strong>',
			'[/b]' => '</strong>',
		] );

		if ( 1 === sizeof( $filter_feed_options ) ) {
			$filter_feed_description = strtr( esc_html__( 'To filter the entries and fields included in an export, first [url]create a filter feed[/url], return here, then select the feed from the list.', 'gk-gravityexport' ), [
				'[url]'  => '<a href="' . esc_url( admin_url( 'admin.php?page=gf_edit_forms&view=settings&subview=gravityexport-filter-sets&fid=0&id=' . rgget( 'id' ) ) ) . '">',
				'[/url]' => '</a>',
			] );
		}


		return [
			[
				'label'    => esc_html__( 'Title', 'gk-gravityexport' ),
				'type'     => 'text',
				'name'     => self::STORAGE_TITLE,
				'class'    => 'large-text',
				'required' => true,
			],
			[
				'label'    => esc_html__( 'Export Type', 'gk-gravityexport' ),
				'type'     => 'radio',
				'name'     => self::FILE_TYPE,
				'required' => true,
				'value'    => self::FILE_ENTRIES_ALL,
				'choices'  => $this->file_type_options(),
			],
			[
				'label'       => esc_html__( 'Export Filters', 'gk-gravityexport' ),
				'type'        => 'select',
				'description' => $filter_feed_description,
				'name'        => self::STORAGE_SOURCE_FEED,
				'required'    => true,
				'choices'     => $filter_feed_options,
			],
			[
				'name'       => self::FILE_TYPE . '_description_single',
				'type'       => 'html',
				'html'       => sprintf( '<div class="alert info">%s</div>', esc_html__( 'When an entry does not match the feed\'s Conditional Logic rules, no file will be created.', 'gk-gravityexport' ) ),
				'dependency' => [
					'live'     => true,
					'operator' => 'ALL',
					'fields'   => [
						[
							'field'  => self::STORAGE_SOURCE_FEED,
							'values' => array_values( array_filter( Arr::pluck( $filter_feed_options, 'value' ) ) ),
						],
						[
							'field'  => self::FILE_TYPE,
							'values' => [ self::FILE_ENTRIES_SINGLE ],
						],
					],
				],
			],
			[
				'label'      => esc_html__( 'Export trigger', 'gk-gravityexport' ),
				'type'       => 'radio',
				'name'       => self::EXPORT_TRIGGER,
				'tooltip'    => esc_html__( 'Select what should trigger this feed to export.', 'gk-gravityexport' ),
				'required'   => true,
				'value'      => self::EXPORT_TRIGGER_ENTRY,
				'choices'    => $this->export_trigger_options(),
				'dependency' => [
					'live'   => true,
					'fields' => [
						[
							'field'  => self::FILE_TYPE,
							'values' => [ self::FILE_ENTRIES_ALL ],
						],
					],
				],
			],
			[
				'type'       => 'checkbox',
				'name'       => self::EXPORT_ON_UPDATE,
				'label'      => esc_html__( 'Export on update', 'gk-gravityexport' ),
				'choices'    => [
					[
						'name'          => self::EXPORT_ON_UPDATE,
						'label'         => esc_html__( 'Trigger this feed when entries are updated', 'gk-gravityexport' ),
						'default_value' => true,
					],
				],
				'dependency' => [
					'live'     => true,
					'operator' => 'ANY',
					'fields'   => [
						[
							'field'  => self::FILE_TYPE,
							'values' => [ self::FILE_ENTRIES_SINGLE ],
						],
						[
							'field'  => self::EXPORT_TRIGGER,
							'values' => [ self::EXPORT_TRIGGER_ENTRY ],
						],
					],
				],
			],
			[
				'type'       => 'checkbox',
				'name'       => Feature\CopyFileUploads::SETTING_COPY_FILES,
				'label'      => esc_html__( 'Copy file uploads with entry', 'gk-gravityexport' ),
				'tooltip'    => esc_html__( 'Only file uploads that are in the exported fields list will be copied.', 'gk-gravityexport' ),
				'choices'    => [
					[
						'name'  => Feature\CopyFileUploads::SETTING_COPY_FILES,
						'label' => esc_html__( 'Copy all file upload fields with the exported entry', 'gk-gravityexport' ),
					],
				],
				'dependency' => [
					'live'     => true,
					'operator' => 'ALL',
					'fields'   => [
						[
							'field'  => self::FILE_TYPE,
							'values' => [ self::FILE_ENTRIES_SINGLE ],
						],
						[
							'field'  => self::STORAGE_TYPE,
							'values' => $this->getNonLocalStorageIds(),
						],
					],
				],
			],
			[
				'type'          => 'text',
				'name'          => 'manual_feed_url',
				'label'         => esc_html__( 'Public trigger URL', 'gk-gravityexport' ),
				'readonly'      => true,
				'save_callback' => '__return_null',
				'default_value' => $this->getManualFeedUrl(),
				'dependency'    => [
					'live'     => $this->get_current_feed_id() > 0,
					'callback' => [
						// only show the field if the feed already exists.
						'php' => function (): bool {
							return $this->get_current_feed_id() > 0;
						}
					],
					'fields'   => [
						[
							'field'  => self::FILE_TYPE,
							'values' => [ self::FILE_ENTRIES_ALL ],
						],
						[
							'field'  => self::EXPORT_TRIGGER,
							'values' => [ self::EXPORT_TRIGGER_MANUAL ],
						],
					],
				],
				'after_input'   => sprintf(
					'<button type="button" class="button copy-attachment-url" data-clipboard-target="[name=_gform_setting_manual_feed_url]">
						<span class="success hidden" aria-hidden="true">%s</span>
						<span class="dashicons dashicons-clipboard"></span> %s
					</button>',
					esc_html__( 'Copied!', 'gk-gravityexport' ),
					esc_html__( 'Copy URL to Clipboard', 'gk-gravityexport' )
				),
			],
			[
				'type'       => 'html',
				'name'       => 'export_trigger_help',
				'dependency' => [
					'live'   => true,
					'fields' => [
						[
							'field'  => self::FILE_TYPE,
							'values' => [ self::FILE_ENTRIES_ALL ],
						],
						[
							'field'  => self::EXPORT_TRIGGER,
							'values' => [ self::EXPORT_TRIGGER_MANUAL ],
						],
					],
				],
				'html'       => '<div class="alert info">' .
				                strtr(
					                esc_html__( '[b]Note:[/b] When using a manual trigger you are responsible for calling a public URL to trigger the export. [url]Learn more about triggering exports[/url].', 'gk-gravityexport' ),
					                [
						                '[b]'    => '<strong>',
						                '[/b]'   => '</strong>',
						                '[url]'  => '<a target="_blank" href="https://docs.gravitykit.com/article/872-trigger-manual-export">',
						                '[/url]' => '</a>',
					                ]
				                )
				                . '</div>',
			],
		];
	}

	/**
	 * @inheritdoc
	 *
	 * Override base method from \GFExcel\Addon\AddonHelperTrait that adds additional and unnecessary markup
	 *
	 * @since 1.0
	 */
	public function settings_select( $field, $echo = true ): string {
		// Do not remove, if though it looks useless.
		return parent::settings_select( $field, $echo );
	}

	/**
	 * The available storage type options.
	 *
	 * @since 1.0
	 * @return string[]
	 */
	private function storage_types_options(): array {

		$storageTypes = $this->getStorageTypes();

		$options = [];
		foreach ( $storageTypes as $storageType ) {

			$is_disabled = $storageType->isDisabled();

			$option = [
				'id'    => $storageType->getId(),
				'label' => $is_disabled ?
					strtr(
						esc_html_x( 'Configure [method] under GravityKit Settings', 'Placeholders inside [] are not to be translated.', 'gk-gravityexport' ),
						[
							'[method]' => $storageType->getTitle(),
						]
					) : $storageType->getTitle(),
				'value' => $storageType->getId(),
				'icon'  => $storageType->getIcon(),
			];

			if ( $is_disabled ) {
				$option['disabled'] = 'disabled';
			}

			$options[] = $option;
		}

		return $options;
	}

	/**
	 * Transforms the storage types into a settings field array for that type.
	 *
	 * @since 1.0
	 * @return mixed[] The storage type options.
	 */
	private function getStorageTypeFieldSettings(): array {
		return array_reduce(
			$this->getStorageTypes(),
			function ( array $settings, StorageTypeInterface $storage ): array {
				$hide_connection_test = $storage->getId() === FlySystemStorageType::FILESYSTEM_LOCAL;

				$settings[] = [
					'title'      => sprintf( esc_html_x( '%s Configuration', 'The name of the type of storage (eg: Dropbox, FTP, Local)', 'gk-gravityexport' ), esc_html__( $storage->getTitle(), 'gk-gravityexport' ) ),
					'fields'     => array_merge( $storage->getFeedFields( $this ), [
						[
							'label'    => '',
							'type'     => 'callback',
							'hidden'   => $hide_connection_test,
							'name'     => '',
							'callback' => function () {
								echo '<div id="connection-test-result" aria-live="assertive"><p hidden><!-- Result is dynamically populated by JS --></p></div><button id="connection-test" type="button" class="button button-secondary">' . esc_html__( 'Test Connection', 'gk-gravityexport' ) . '<span class="spinner" hidden></span></button>';
							}
						]
					] ),
					'dependency' => [
						'field'  => self::STORAGE_TYPE,
						'values' => [ $storage->getId() ],
					],
				];

				return $settings;
			},
			[]
		);
	}

	/**
	 * Get all instances of storage types.
	 *
	 * @since 1.0
	 * @return StorageTypeInterface[]
	 */
	private function getStorageTypes(): array {
		return $this->storage_types;
	}

	/**
	 * Process the feed on the correct storage type if available.
	 *
	 * Note: this method is called when an entry is submitted. Not when the feed is process manually.
	 *
	 * @since 1.0
	 *
	 * @param array $feed  Feed information.
	 * @param array $entry Entry information.
	 * @param array $form  Form information.
	 *
	 * @return void
	 */
	public function process_feed( $feed, $entry, $form ): void {
		if ( $this->isManuallyTriggeredFeed( $feed ) ) {
			return;
		}

		$storageType = $this->getStorageTypeByFeed( $feed );
		if ( ! $storageType ) {
			return;
		}

		/**
		 * If the save feed does not have a filter feed as a source, it uses the feed ID of the `gravityexport-lite` add-on.
		 * This is why we have to hook into the `gfexcel_get_entries` hook on *that* feed id, but we need the filter
		 * settings from the save add on.
		 */
		$source_id = StorageService::getSourceFeedId( $feed );
		add_filter(
			$get_entries_hook = sprintf( 'gfexcel_get_entries_%s_%s', $feed['form_id'], $source_id ),
			$get_entries_callback = function ( $form_id, int $feed_id, array $search_criteria, array $sorting, array $paging ) use ( $feed, $source_id ): ?array {
				$source_feed_id = rgars( $feed, 'meta/' . SaveAddon::STORAGE_SOURCE_FEED, 0 );
				if ( ! $source_feed_id ) {
					$source_id = $feed['id'];
				}

				return $this->getEntries( $feed['form_id'], $source_id, $search_criteria, $sorting, $paging );
			},
			10,
			5
		);

		try {
			$hook = 'gfexcel_output_rows_' . $form['id'];

			add_filter( $hook, $callback = function ( $rows ): array {
				if ( count( $rows ) === 0 ) {
					throw new SaveException( __( 'No entries to save.', 'gk-gravityexport' ) );
				}

				return $rows;
			} );

			$storageType->processEntry( $form, $entry, $feed );

			/**
			 * Event dispatched when the export was triggered by an entry.
			 * @since $ver$
			 */
			do_action( 'gk/gravityexport/save/exported/entry', $entry, $form, $feed, $storageType );

			GravityExportAddon::getLogger()->debug( sprintf(
				'Entry (%d) processed by storage type "%s".',
				$entry['id'] ?? 0,
				$storageType->getTitle()
			) );
		} catch ( SaveException $e ) {
			GravityExportAddon::getLogger()->error( $e->getMessage() );
		} finally {
			remove_filter( $hook, $callback );
			remove_filter( $get_entries_hook, $get_entries_callback );
		}
	}

	/**
	 * Whether the provided feed has a manual trigger for exporting entries.
	 * @since $ver$
	 */
	private function isManuallyTriggeredFeed( array $feed ): bool {
		$meta      = rgar( $feed, 'meta', [] );
		$file_type = rgar( $meta, self::FILE_TYPE, self::FILE_ENTRIES_ALL );
		$trigger   = rgar( $meta, self::EXPORT_TRIGGER, self::EXPORT_TRIGGER_ENTRY );

		return $file_type === self::FILE_ENTRIES_ALL && $trigger !== self::EXPORT_TRIGGER_ENTRY;
	}

	/**
	 * Registers bulk actions for this feed.
	 * @since $ver$
	 */
	public function get_bulk_actions(): array {
		return array_merge( parent::get_bulk_actions(), [
			'duplicate' => esc_html__( 'Duplicate', 'gk-gravityexport' ),
			'process'   => esc_html__( 'Process', 'gk-gravityexport' ),
		] );
	}

	/**
	 * @inheritDoc
	 * @since $ver$
	 */
	public function can_duplicate_feed( $id ): bool {
		return true;
	}

	/**
	 * @inheritDoc
	 * @since $ver$
	 */
	public function process_single_action( $action ) {
		parent::process_single_action( $action );

		if ( $action !== 'process' ) {
			return;
		}

		$feed_id = absint( rgpost( 'single_action_argument' ) );
		$this->processFeeds( [ $feed_id ] );
	}

	/**
	 * @inheritDoc
	 * @since $ver$
	 */
	public function process_bulk_action( $action ): void {
		parent::process_bulk_action( $action );

		$feeds = rgpost( 'feed_ids', [] );
		if ( $action !== 'process' || empty( $feeds ) ) {
			return;
		}

		$this->processFeeds( (array) $feeds );
	}

	/**
	 * Processes a single action.
	 *
	 * @since 1.0
	 *
	 * @param array<int> $feeds The feed id's to process.
	 */
	protected function processFeeds( array $feeds ): void {
		// Reset any unwanted messages.
		\GFCommon::$errors   = [];
		\GFCommon::$messages = [];

		foreach ( $feeds as $feed_id ) {
			$feed = $this->get_feed( $feed_id );
			$this->handleFeed( $feed );
		}

		$message = count( $feeds ) === 1
			? esc_html__( 'Feed has been processed.', 'gk-gravityexport' )
			: esc_html__( 'Feeds have been processed.', 'gk-gravityexport' );

		$this->add_message( $message );
		// Display again since GF has executed this method before we added our changes.
		\GFCommon::display_admin_message();
	}

	/**
	 * Helper method that handles the storage of the feed.
	 *
	 * @since $ver$
	 *
	 * @param array $feed The feed object.
	 */
	private function handleFeed( array $feed ) {
		$source_id = StorageService::getSourceFeedId( $feed );

		add_filter(
			$get_entries_hook = sprintf( 'gfexcel_get_entries_%s_%s', $feed[ 'form_id' ], $source_id ),
			$get_entries_callback = function (
				$form_id,
				int $feed_id,
				array $search_criteria,
				array $sorting,
				array $paging
			) use ( $feed, $source_id ): ?array {
				$source_feed_id = rgars( $feed, 'meta/' . SaveAddon::STORAGE_SOURCE_FEED, 0 );
				if ( ! $source_feed_id ) {
					$source_id = $feed[ 'id' ];
				}

				return $this->getEntries( $feed[ 'form_id' ], $source_id, $search_criteria, $sorting, $paging );
			},
			10,
			5
		);

		try {
			if ( $storageType = $this->getStorageTypeByFeed( $feed ) ) {
				$storageType->processForm(
					$feed[ 'form_id' ],
					$feed[ 'meta' ],
					$feed
				);
				GravityExportAddon::getLogger()->debug(
					sprintf(
						'Feed (%d) processed by storage type "%s".',
						$feed[ 'id' ] ?? 0,
						$storageType->getTitle()
					)
				);

				/**
				 * Event dispatched when export was manually triggered.
				 *
				 * @since 1.6.0
				 */
				do_action( 'gk/gravityexport/save/exported/manual', $feed );
			}
		} catch ( SaveException $e ) {
			$this->add_error_message( $e->getMessage() );
			GravityExportAddon::getLogger()->error( $e->getMessage() );
		} finally {
			remove_filter( $get_entries_hook, $get_entries_callback );
		}
	}

	/**
	 * @inheritDoc
	 * @since $ver$
	 */
	public function get_action_links(): array {
		$feed_id = '_id_';

		$links  = parent::get_action_links();
		$delete = $links['delete'];
		unset( $links['delete'] );

		return array_merge( $links, [
			'process' => '<a href="#" onclick="gaddon.processFeed(\'' . esc_js( $feed_id ) . '\');" onkeypress="gaddon.processFeed(\'' . esc_js( $feed_id ) . '\');">' . esc_html__( 'Process', 'gk-gravityexport' ) . '</a>',
			'delete'  => $delete,
		] );
	}

	/**
	 * Retrieve the correct storage type instance for this feed, if available.
	 *
	 * @since 1.0
	 *
	 * @param array $feed             The feed information
	 * @param bool  $include_inactive Whether to include inactive storage types.
	 *
	 * @return StorageTypeInterface|null the instance
	 */
	private function getStorageTypeByFeed( array $feed, bool $include_inactive = false ): ?StorageTypeInterface {
		if ( ( ! $feed['is_active'] && ! $include_inactive ) ||
		     ! isset( $feed['meta'][ self::STORAGE_TYPE ] ) ||
		     ! array_key_exists( $feed['meta'][ self::STORAGE_TYPE ], $this->getStorageTypes() )
		) {
			return null;
		}

		return $this->getStorageTypes()[ $feed['meta'][ self::STORAGE_TYPE ] ];
	}

	/**
	 * The available file type options.
	 *
	 * @since 1.0
	 * @return string[]
	 */
	private function file_type_options(): array {
		return [
			[ 'label' => esc_html__( 'All Entries', 'gk-gravityexport' ), 'value' => self::FILE_ENTRIES_ALL ],
			[ 'label' => esc_html__( 'Single Entry', 'gk-gravityexport' ), 'value' => self::FILE_ENTRIES_SINGLE ],
		];
	}

	/**
	 * The available export trigger  options.
	 *
	 * @since $ver$
	 * @return string[] The options.
	 */
	private function export_trigger_options(): array {
		return [
			[
				'label' => esc_html__( 'On every new entry', 'gk-gravityexport' ),
				'value' => self::EXPORT_TRIGGER_ENTRY,
				'icon'  => 'fa-keyboard-o',
			],
			[
				'label' => esc_html__( 'Manual trigger', 'gk-gravityexport' ),
				'value' => self::EXPORT_TRIGGER_MANUAL,
				'icon'  => 'fa-hand-pointer-o',
			],
		];
	}

	/**
	 * Returns the manual feed trigger URL.
	 * @since $ver$
	 * @return string The manual feed trigger.
	 */
	private function getManualFeedUrl(): string {
		$feed_id = (int) $this->get_current_feed_id();

		if ( 0 === $feed_id ) {
			return '';
		}

		return sprintf(
			get_option( 'permalink_structure' )
				? '%s/gravitykit/gravityexport/save/feed/%d/%s'
				: '%s/index.php?gravityexport_cronjob=%d:%s',
			get_site_url(),
			$feed_id,
			$this->getCronjobSaveSecret( $feed_id )
		);
	}

	/**
	 * Extracts and sets the correct feed ID from the Cronjob and validates that it is allowed.
	 *
	 * @since 1.6.0
	 *
	 * @param array $query_vars The request vars.
	 *
	 * @return array The request vars.
	 */
	private function set_request_feed_id( array $query_vars ): array
	{
		if ( ! isset( $query_vars[ 'gravityexport_cronjob' ] ) ) {
			return $query_vars;
		}

		[ $feed_id, $feed_secret ] = explode( ':', $query_vars[ 'gravityexport_cronjob' ], 2 );
		$feed_id = (int) $feed_id;
		if (
			! ( $feed = $this->get_feed( $feed_id ) ) // Ensure feed exists
			|| rgar( $feed[ 'meta' ] ?? [], self::EXPORT_TRIGGER ) !== self::EXPORT_TRIGGER_MANUAL // Ensure manual trigger is active
			|| $feed_secret !== $this->getCronjobSaveSecret( $feed_id ) // Ensure the provided secret is valid
		) {
			wp_die( esc_html__( 'Feed not found', 'gk-gravityexport' ), 'GravityExport', 404 );
		}

		$feed_id = StorageService::getSourceFeedId( $feed );

		// Set the feed ID for the download.
		$query_vars[ 'gfexcel_download_feed' ] = $feed_id;

		return $query_vars;
	}
	/**
	 * Handles a call to the manual feed trigger hook.
	 *
	 * @param array $query_vars The request vars.
	 *
	 * @return array|void
	 */
	private function handle_save_feed_hook( array $query_vars ) {
		if ( ! isset( $query_vars['gravityexport_cronjob'] ) ) {
			return $query_vars;
		}

		[ $feed_id ] = explode( ':', $query_vars[ 'gravityexport_cronjob' ], 2 );
		$feed_id = (int) $feed_id;

		/**
		 * Note: validation is already handled in {@see self::set_request_feed_id()}.
		 */
		$feed = $this->get_feed( $feed_id );
		if ( ! $feed ) {
			// Sanity check.
			wp_die( esc_html__( 'Feed not found', 'gk-gravityexport' ), 'GravityExport', 404 );
		}

		try {
			$this->handleFeed( $feed );
			wp_die( esc_html__( 'Feed exported', 'gk-gravityexport' ), 'GravityExport', 200 );
		} catch ( SaveException $e ) {
			wp_die( esc_html__( 'Feed failed', 'gk-gravityexport' ) . ': ' . 'Something went wrong while saving the feed. Please make sure the connection works.', 'GravityExport', 500 );
		}
	}

	/**
	 * @inheritdoc
	 * Make sure the storage type is in human-readable format for the column.
	 * @since 1.0
	 */
	public function get_column_value( $item, $column ) {
		$value = parent::get_column_value( $item, $column );
		if ( ! $storage = $this->getStorageTypeByFeed( $item, true ) ) {
			return $value;
		}

		if ( $column === self::STORAGE_TYPE ) {
			return esc_html__( $storage->getTitle(), 'gk-gravityexport' );
		}

		if ( in_array( $column, [ self::FILE_TYPE, self::EXPORT_TRIGGER ], true ) ) {
			$method  = $column . '_options';
			$options = array_reduce( $this->{$method}(), static function ( array $options, $option ): array {
				$options[ $option['value'] ] = $option['label'];

				return $options;
			}, [] );

			return rgar( $options, $value, self::EXPORT_TRIGGER_ENTRY );
		}

		return $value;
	}

	/**
	 * The value of the export_trigger column in the feed.
	 *
	 * @since $ver$
	 *
	 * @param array $feed The feed object.
	 *
	 * @return string The column value.
	 */
	protected function get_column_value_export_trigger( array $feed ): string {
		$meta = rgar( $feed, 'meta', [] );
		if ( rgar( $meta, self::FILE_TYPE, self::FILE_ENTRIES_ALL ) === self::FILE_ENTRIES_SINGLE ) {
			return self::EXPORT_TRIGGER_ENTRY;
		}

		return (string) rgar( $meta, self::EXPORT_TRIGGER, self::EXPORT_TRIGGER_ENTRY );
	}

	/**
	 * Add multiple storage types.
	 *
	 * @since 1.0
	 *
	 * @param StorageTypeInterface[] $storage_types The provided storage types.
	 */
	public function addStorageTypes( array $storage_types ): void {
		foreach ( $storage_types as $storage_type ) {
			$this->addStorageType( $storage_type );
		}
	}

	/**
	 * Adds a storage type.
	 *
	 * @since 1.0
	 *
	 * @param StorageTypeInterface $storageType The storage type.
	 */
	public function addStorageType( StorageTypeInterface $storageType ): void {
		if ( isset( $this->storage_types[ $id = $storageType->getId() ] ) ) {
			throw new \InvalidArgumentException( sprintf(
				'A storage type with the ID "%s" already exists.',
				$id
			) );
		}

		$this->storage_types[ $id ] = $storageType;
	}

	/**
	 * Returns the target path for the provided feed.
	 * @since $ver$
	 *
	 * @param StorageTypeInterface $storage_type The storage type.
	 * @param array                $meta         The metadata object.
	 * @param array                $feed         The feed object.
	 * @param array                $entry        the entry object.
	 *
	 * @return string The target path.
	 */
	public static function get_target_path( array $meta, array $feed, array $entry = [] ): string {
		$path = (string) rgar( $meta, FlySystemStorageType::FIELD_STORAGE_PATH, '' );

		if ( $path !== '' ) {
			$parts = explode( '/', $path );
			$form  = GFAPI::get_form( rgar( $feed, 'form_id', 0 ) ) ?: null;
			foreach ( $parts as $i => $part ) {
				$parts[ $i ] = self::process_filename( $part, $form, [ $entry ] );
			}
			$path = implode( '/', $parts );
		}

		return $path;
	}

	/**
	 * Parses the file name used when saving the file.
	 *
	 * @see StorageService::renderFile()
	 *
	 * @param array|null $form    Gravity Forms form array connected to the feed.
	 * @param array|null $entries Array of entries being processed (single or many)
	 *
	 * @param array      $feed    Gravity Forms form feed.
	 *
	 * @return string
	 */
	public static function get_filename( array $feed, ?array $form, ?array $entries = array() ): string {
		$filename_format = rgars( $feed, 'meta/filename_format', 'custom' );

		$filename = rgars( $feed, 'meta/filename', null );

		if ( 'custom' !== $filename_format ) {
			$default_formats = self::get_filename_formats();

			foreach ( $default_formats as $default_format ) {
				if ( $filename_format === $default_format['value'] ) {
					$filename = $default_format['format'];
					break;
				}
			}
		}

		return self::process_filename( $filename, $form ?? [], $entries );
	}

	/**
	 * Returns a filename that has been sanitized with replaced variables.
	 *
	 * @uses \GFCommon::replace_variables()
	 * @uses sanitize_file_name()
	 *
	 * @param array|null $entries  Array of entries being processed (single or many)
	 *
	 * @param string     $filename The starting name of the file, before replacement and sanitization.
	 * @param array|null $form     Gravity Forms form array connected to the feed.
	 *
	 * @return string
	 */
	private static function process_filename( string $filename, ?array $form, ?array $entries = array() ): string {

		$date_function = function_exists( 'wp_date' ) ? 'wp_date' : 'date';

		$filename = strtr( $filename, array(
			'{DATE}'      => $date_function( 'c' ),
			'{TIMESTAMP}' => $date_function( 'U' ),
			'{YYYY}'      => $date_function( 'Y' ),
			'{YY}'        => $date_function( 'y' ),
			'{MM}'        => $date_function( 'm' ),
			'{MONTH}'     => $date_function( 'F' ),
			'{DD}'        => $date_function( 'd' ),
		) );

		$entry = $entries ? $entries[0] : array();

		$filename = \GFCommon::replace_variables( $filename, $form, $entry );

		return sanitize_file_name( $filename );
	}

	/**
	 * @inheritdoc
	 * @since 1.0
	 */
	public function get_current_settings(): ?array {
		$settings = parent::get_current_settings();

		// Fix fields to retrieve from database.
		$refreshed_fields = [ 'filename' ];
		$feed             = $this->get_feed( $this->get_current_feed_id() );

		foreach ( $refreshed_fields as $key ) {
			$settings[ $key ] = rgar( $feed['meta'] ?? [], $key );
		}

		if ( empty( $feed ) ) {
			return $settings;
		}

		// Let storage type update their settings too.
		foreach ( $this->getStorageTypes() as $storageType ) {
			$settings = $storageType->getStorageSettings( $settings, $feed );
		}

		return $settings;
	}

	/**
	 * @inheritdoc
	 * @since 1.0
	 */
	public function scripts(): array {
		if ( $this->is_feed_edit_page() ) {
			$this->query_filters
				->with_form( $this->get_current_form() )
				->enqueue_scripts( [
					'input_element_name' => $this->getFieldNamePrefix() . 'conditional_logic',
					'conditions'         => rgar( $this->get_current_settings() ?? [], 'conditional_logic' )
				] );
		}

		$assets_dir = plugin_dir_url( GK_GRAVITYEXPORT_PLUGIN_FILE );

		return array_merge( parent::scripts(), [
			[
				'handle'   => 'gk-gravityexport-clipboard',
				'src'      => $assets_dir . 'assets/js/clipboard.js',
				'deps'     => [ 'jquery', 'wp-a11y', 'wp-i18n', 'clipboard' ],
				'enqueue'  => [
					[ 'admin_page' => 'form_settings', 'tab' => $this->get_slug() ],
				],
				'callback' => function () {
					$script = <<<JS
(function($) {
    $(document).ready(function() {
        addClipboard('%s','%s');
    });
})(jQuery);
JS;
					$script = sprintf(
						$script,
						esc_attr( '.copy-attachment-url' ),
						esc_attr__( 'The file URL has been copied to your clipboard.', 'gk-gravityexport' )
					);

					wp_add_inline_script( 'gk-gravityexport-clipboard', $script );
				},
			],
			[
				'handle'  => self::NONCE_AND_ASSETS_HANDLE,
				'src'     => $assets_dir . 'assets/js/gravityexport-save.js',
				'strings' => [
					'ajaxAction'                          => self::AJAX_TEST_CONNECTION_ACTION,
					'nonce'                               => wp_create_nonce( self::NONCE_AND_ASSETS_HANDLE ),
					'formInputFieldNamePrefix'            => $this->getFieldNamePrefix(),
					'formInputFieldParentContainerPrefix' => $this->is_gravityforms_supported( '2.5-beta' ) ? 'gform_setting_' : 'gaddon-setting-row-',
					'incompleteTestMessage'               => esc_html__( 'We could not perform the connection test due to a network or server error.', 'gk-gravityexport' ),
				],
				'enqueue' => [
					[ 'admin_page' => 'form_settings', 'tab' => $this->get_slug() ],
				],
			]
		] );
	}

	/**
	 * @inheritdoc
	 * @since 1.0
	 */
	public function styles(): array {
		if ( $this->is_feed_edit_page() ) {
			$this->query_filters->enqueue_styles();
		}

		return array_merge( parent::styles(), [
			[
				'handle'  => self::NONCE_AND_ASSETS_HANDLE,
				'src'     => plugin_dir_url( GK_GRAVITYEXPORT_PLUGIN_FILE ) . 'assets/css/gravityexport-save.css',
				'enqueue' => [
					[ 'admin_page' => 'form_settings', 'tab' => $this->get_slug() ],
				],
			],
		] );
	}

	/**
	 * AJAX-function to test connection.
	 *
	 * @since 1.0
	 *
	 * @return void
	 */
	public function testConnection(): void {
		$defaults = array(
			'_nonce'   => null,
			'service'  => '',
			'settings' => [],
		);

		$request = wp_parse_args( $_REQUEST, $defaults );

		if ( ! check_ajax_referer( self::NONCE_AND_ASSETS_HANDLE, '_nonce', false ) ) {
			wp_die( false, false, array( 'response' => 403 ) );
		}

		if ( 'dropbox' === rgar( $request, 'service' ) ) {
			$request['settings']['token_provider'] = $this->getStorageTypes()['dropbox']->getTokenProvider();
		}

		$settings                    = rgar( $request, 'settings', [] );
		$request['settings']['path'] = SaveAddon::get_target_path( [
			FlySystemStorageType::FIELD_STORAGE_PATH => $settings['path']
		], [] );

		try {
			$this->connection_manager_service->testConnection( $request['service'], $request['settings'] );

			wp_send_json_success( esc_html__( 'Your connection is properly configured.', 'gk-gravityexport' ) );
		} catch ( \Exception $e ) {
			wp_send_json_error( $e->getMessage() );
		}
	}

	/**
	 * Return version-adjusted GF setting field name prefix
	 *
	 * @return string
	 */
	private function getFieldNamePrefix(): string {
		return $this->is_gravityforms_supported( '2.5-beta' ) ? '_gform_setting_' : '_gaddon_setting_';
	}

	/**
	 * Applies filters and fetches DB entries.
	 *
	 * @since 1.0
	 *
	 * @param int|null $form_id         GF form ID.
	 * @param int      $feed_id         GF feed ID.
	 * @param array    $search_criteria Search criteria (status, field filters).
	 * @param array    $sorting         Sorting options (key, direction).
	 * @param array    $paging          Sorting options (offset, page size).
	 *
	 * @return array|null Filtered entries or null.
	 */
	private function getEntries( ?int $form_id, int $feed_id, array $search_criteria, array $sorting, array $paging ): ?array {
		$feed              = $this->get_current_feed() ?: $this->get_feed( $feed_id );
		$conditional_logic = rgars( $feed, 'meta/conditional_logic', 'null' );

		if ( ! $form_id && $feed ) {
			$form_id = $feed['form_id'] ?? null;
		}

		if ( ! $form_id || ! $feed || 'null' === $conditional_logic ) {
			return null;
		}

		try {
			$conditions = $this->query_filters
				->with_form( \GFAPI::get_form( $form_id ) )
				->with_filters( $conditional_logic )
				->get_query_conditions();
		} catch ( \Exception $e ) {
			return null;
		}

		$search_criteria = [
			'status'        => rgar( $search_criteria, 'status', 'active' ),
			'field_filters' => rgar( $search_criteria, 'field_filters', [] )
		];

		$query = new \GF_Query( $feed['form_id'], $search_criteria, $sorting, $paging );

		$query_parts = $query->_introspect();

		$query->where( \GF_Query_Condition::_and( $query_parts['where'], $conditions ) );

		return $query->get();
	}

	/**
	 * Adds UI assets to GF's "no conflict" list.
	 *
	 * @since 1.0.1
	 *
	 * @param array $assets
	 *
	 * @return array
	 */
	private function allowlist_ui_assets( array $assets ): array {
		$assets[] = $this->query_filters::ASSETS_HANDLE;

		return $assets;
	}

	/**
	 * Registers a public callback route for the save add-on.
	 * @since $ver$
	 */
	private function addPermalinkRules(): void {
		$rewrite_rules = get_option( 'rewrite_rules' );
		$flush_rules   = false;

		$rules = [
			self::PERMALINK_CALLBACK_ACTION     => 'index.php?gravityexport_callback=1',
			self::PERMALINK_CRONJOB_FEED_ACTION => 'index.php?gravityexport_cronjob=$matches[1]:$matches[2]',
		];

		foreach ( $rules as $rule => $redirect ) {
			$regex = '^' . $rule . '$';
			add_rewrite_rule( $regex, $redirect, 'top' );

			if ( ! ( $rewrite_rules[ $regex ] ?? null ) ) {
				$flush_rules = true;
			}
		}

		if ( $flush_rules ) {
			flush_rewrite_rules();
		}
	}

	/**
	 * Activate features.
	 * @since $ver$
	 */
	private function activateFeatures(): void {
		new Feature\CopyFileUploads;
	}

	/**
	 * Add query vars to url.
	 * @since $ver$
	 *
	 * @param array $query_vars The current query vars.
	 *
	 * @return array The updated query vars.
	 */
	private function query_vars( array $query_vars ): array {
		$query_vars[] = 'gravityexport_cronjob';
		$query_vars[] = 'gravityexport_callback';

		return $query_vars;
	}

	/**
	 * Request URL that fires a callback event.
	 *
	 * @since $ver$
	 *
	 * @param \WP $wp The WP instance.
	 *
	 * @return void
	 */
	private function callback_request( \WP $wp ): void {
		$query_vars = $wp->query_vars ?? [];
		if ( ! isset( $query_vars['gravityexport_callback'] ) ) {
			return;
		}

		global $wp_query;

		do_action( self::PERMALINK_CALLBACK_ACTION, $this, $wp_query );

		// Default to 404 if no request action was intercepted.
		$wp_query->set_404();
	}

	/**
	 * Returns the callback url for this add-on.
	 * @since $ver$
	 * @return string The callback URL.
	 */
	public function getCallbackUrl(): string {
		$blog_url = get_bloginfo( 'url' );

		return sprintf(
			'%s/%s',
			$blog_url,
			get_option( 'permalink_structure' )
				? self::PERMALINK_CALLBACK_ACTION
				: 'index.php?gravityexport_callback=1'
		);
	}

	/**
	 * The source feed options.
	 * @since $ver$
	 * @return string[][] The options.
	 */
	private function source_feed_options(): array {
		$options = [
			[
				'label' => esc_html__( 'None: Do not filter, export all field values', 'gk-gravityexport' ),
				'value' => 0
			],
		];

		if ( class_exists( FiltersFeedAddon::class ) ) {
			$feeds = FiltersFeedAddon::get_instance()->get_feeds( rgget( 'id' ) );

			foreach ( $feeds as $feed ) {

				if ( ! (bool) $feed['is_active'] ) {
					continue;
				}

				$options[] = [
					'label' => rgars( $feed ?? [], 'meta/feedName', sprintf( esc_html__( 'Feed %d', 'gk-gravityexport' ), $feed['id'] ) ),
					'value' => $feed['id'],
				];
			}
		}

		return $options;
	}

	/**
	 * Returns the secret used to verify the cronjob save url.
	 * @since $ver$
	 * @return string The secret.
	 */
	private function getCronjobSaveSecret( int $feed_id ): string {
		return wp_hash( (string) $feed_id, 'auth' );
	}

	/**
	 * Whether the provided feed should be triggered on an entry update.
	 * @since 1.2.0
	 *
	 * @param array $feed The feed object.
	 *
	 * @return bool
	 */
	private function isExportOnUpdateFeed( array $feed ): bool {
		if ( $this->isManuallyTriggeredFeed( $feed ) ) {
			return false;
		}

		$meta = rgar( $feed, 'meta', [] );

		// `rgar` will not return the proper result on `0`.
		return (bool) ( $meta[ self::EXPORT_ON_UPDATE ] ?? true );
	}

	/**
	 * Triggers the feeds that need triggering on entry update.
	 *
	 * @since $ver$
	 *
	 * @param int|array $entry The entry ID or entry.
	 */
	private function process_update_entry( $entry ): void {
		// Remove any feeds that should not be triggered on updating.
		add_filter( "gform_{$this->_slug}_pre_process_feeds", function ( array $feeds ) {
			return array_filter( $feeds, function ( array $feed ): bool {
				return $this->isExportOnUpdateFeed( $feed );
			} );
		} );

		// Entry might be the entry ID.
		if ( is_numeric( $entry ) ) {
			$entry = \GFAPI::get_entry( $entry );
		}

		if ( ! is_array( $entry ) ) {
			return;
		}

		$form = \GFAPI::get_form( $entry['form_id'] );
		if ( ! is_array( $form ) ) {
			return;
		}

		$this->maybe_process_feed( $entry, $form );
	}

	/**
	 * Returns the storage id's that are not local..
	 * @since $ver$
	 * @return string[]
	 */
	private function getNonLocalStorageIds(): array {
		$ids = [];

		foreach ( $this->getStorageTypes() as $storage_type ) {
			if ( ! $storage_type instanceof Local ) {
				$ids[] = $storage_type->getId();
			}
		}

		return $ids;
	}

	/**
	 * Processes the `gk/gravityexport/save/action/save-feed` action.
	 *
	 * This hook can be used to manually trigger the processing of a feed. Useful for plugins like `WP Control`.
	 * @since $ver$
	 *
	 * @param int|null $feed_id The feed ID to process.
	 * @filter gk/gravityexport/save/action/save-feed The filter this hook responds to.
	 */
	public function action_save_feed( $feed_id = null ): void {
		$feed = $this->get_feed( (int) ( $feed_id ?? 0 ) );
		if ( ! $feed || ! $this->isManuallyTriggeredFeed( $feed ) ) {
			return;
		}

		$this->handleFeed( $feed );
	}
}
