<?php

declare( strict_types=1 );

namespace DocketConnector\GravityForms;

use DocketConnector\Common\Settings\Choices;
use DocketConnector\Common\Utilities\Http;
use DocketConnector\Common\Utilities\Strings;
use DocketConnector\Common\Utilities\Timing;
use DocketConnector\Docket\Api;
use DocketConnector\PluginData;
use GF_Field;
use GF_Field_Address;
use GF_Field_Name;
use GF_Fields;
use GFAPI;
use GFCommon;
use GFFeedAddOn;
use GFFormsModel;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Abort if this file is called directly.
}

if (
	! class_exists( AbstractFeedAddOn::class )
	&& class_exists( GFFeedAddOn::class )
) {
	/**
	 * The Gravity Forms add-on abstract class specific to this plugin.
	 *
	 * @link https://docs.gravityforms.com/gffeedaddon/
	 * @link https://github.com/gravityforms/simplefeedaddon
	 * @link https://docs.gravityforms.com/logging-and-debugging/
	 */
	abstract class AbstractFeedAddOn extends GFFeedAddOn {

		/**
		 * @var string The prefix for feed settings fields so we can get data from the Entry in a less cluttered way.
		 */
		public $_mapping_prefix = 'docket_';

		/**
		 * @var string The field slug for the Enabled setting.
		 */
		public $_field_slug_enabled = 'enabled';

		/**
		 * @var string The entry meta key suffix for logging the number of times processing this feed was attempted.
		 */
		private $_meta_key_suffix_count_feed_attempts = 'feed_attempts';

		/**
		 * @var string The entry meta key suffix for logging the number of times processing this feed was successful.
		 */
		private $_meta_key_suffix_count_feed_successes = 'feed_successes';

		/** @var static|null */
		public static $_instance = null;

		public function __construct() {
			// ! SET THESE IN YOUR OWN CLASS !
			/*
			$this->_slug      = 'some_underscore_string';
			$this->_title     = esc_html_x(
				'My Docket Add-On',
				'Gravity Forms Add-On title',
				'docket-connector'
			);
			*/

			// Optionally, override the other properties.
			$this->_full_path                = __FILE__;
			$this->_version                  = PluginData::plugin_version();
			$this->_min_gravityforms_version = Setup::$gravity_forms_version_min;
			$this->_path                     = PluginData::plugin_basename();
			$this->_url                      = Setup::$gravity_forms_affiliate_link;

			// Capabilities.
			$cap                               = Setup::get_gravity_forms_capability();
			$this->_capabilities_settings_page = $cap;
			$this->_capabilities_form_settings = $cap;
			$this->_capabilities               = [ $cap ];

			parent::__construct();
		}

		/**
		 * Required in order to be identified as a Gravity Forms Add-On.
		 *
		 * @return static
		 */
		public static function get_instance(): AbstractFeedAddOn {
			if ( empty( static::$_instance ) ) {
				static::$_instance = new static();
			}

			return static::$_instance;
		}

		/**
		 * Undo only being able to detect a Gravity Forms Add-On if it's loaded from the main plugin file.
		 *
		 * @inheritDoc
		 */
		public function update_path(): void {
		}

		/**
		 * @inheritDoc
		 *
		 * Don't load this plugin's translation files a second time, since we already did before getting to this point.
		 */
		public function load_text_domain(): void {
		}

		/**
		 * @inheritDoc
		 *
		 * @link https://docs.gravityforms.com/add-delayed-payment-support-feed-add/
		 */
		public function init() {
			parent::init();

			$this->add_delayed_payment_support(
				[
					'option_label' => esc_html_x(
						'Send to the Docket API only when payment is received.',
						'Gravity Forms feed delayed payment support',
						'docket-connector'
					),
				]
			);
		}

		/**
		 * @inheritDoc
		 */
		public function note_avatar(): string {
			return PluginData::plugin_dir_url() . 'src/Admin/images/docket-logo.svg';
		}

		/**
		 * Get the plugin's icon for Gravity Forms settings menus.
		 *
		 * @param string $size The Height and Width, with your own dimensions.
		 *
		 * @return string
		 */
		public function get_docket_icon_for_settings( string $size = '30px' ): string {
			return sprintf(
				'<img style="height: %1$s; width: %1$s" src="%2$s" alt="Docket logo" title="%3$s">',
				esc_attr( $size ),
				esc_url( $this->note_avatar() ),
				esc_attr( $this->_title )
			);
		}

		/**
		 * @inheritDoc
		 */
		public function form_settings_icon(): string {
			return $this->get_docket_icon_for_settings();
		}

		/**
		 * @inheritDoc
		 */
		public function plugin_settings_icon(): string {
			return $this->get_docket_icon_for_settings();
		}

		/**
		 * @inheritDoc
		 */
		public function app_settings_icon(): string {
			return $this->get_docket_icon_for_settings();
		}

		/**
		 * @inheritDoc
		 *
		 * @see \GFAddOn::settings() How Sections get processed.
		 * @see \GFAddOn::single_setting_row() How each Field gets processed.
		 */
		public function plugin_settings_fields(): array {
			return [
				[
					'fields' => [
						// Globlly Enabled checkbox.
						[
							'name'        => $this->_field_slug_enabled,
							'type'        => 'checkbox',
							'label'       => esc_html_x(
								'Globally Enabled',
								'Gravity Forms plugin settings fields',
								'docket-connector'
							),
							'description' => esc_html_x(
								'Only uncheck if you want to disable *all* data feeds for this Add-On across *all* forms.',
								'Gravity Forms plugin settings fields',
								'docket-connector'
							),
							'choices'     => [
								[
									'label'         => esc_html_x(
										'Enabled',
										'Gravity Forms plugin settings fields',
										'docket-connector'
									),
									'name'          => $this->_field_slug_enabled,
									'default_value' => true,
								],
							],
						],
						// API Key text input.
						[
							'name'                => ( new Choices() )->slug_api_key,
							'validation_callback' => [ $this, 'validate_api_key' ],
							'type'                => 'text',
							'class'               => 'medium',
							'label'               => esc_html_x(
								'API Key',
								'Gravity Forms plugin settings fields',
								'docket-connector'
							),
							'description'         => esc_html_x(
								'Data feeds will not work until this is entered. Get your API key from your Docket account settings, under the Integrations tab.',
								'Gravity Forms plugin settings fields',
								'docket-connector'
							),
						],
					],
				],
			];
		}

		public function get_current_form_id() {
			$form = $this->get_current_form();

			if (
				! is_array( $form )
				|| ! isset( $form['id'] )
				|| ! is_numeric( $form['id'] )
			) {
				return null;
			}

			return absint( $form['id'] );
		}

		public function get_current_form_edit_link() {
			if ( ! GFCommon::current_user_can_any( 'gravityforms_edit_forms' ) ) {
				return null;
			}

			$form_id = $this->get_current_form_id();

			if ( empty( $form_id ) ) {
				return null;
			}

			return admin_url( sprintf( 'admin.php?page=gf_edit_forms&id=%d', $form_id ) );
		}

		/**
		 * @inheritDoc
		 */
		public function can_create_feed(): bool {
			return ! empty( $this->get_api_key() );
		}

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

		/**
		 * @inheritDoc
		 */
		public function feed_settings_fields(): array {
			$settings = [];

			$settings[] = [
				'title'  => esc_html_x(
					'Setup',
					'Gravity Forms feed settings',
					'docket-connector'
				),
				'fields' => [
					[
						'name'     => 'feedName',
						'label'    => esc_html_x(
							'Name',
							'Gravity Forms feed settings',
							'docket-connector'
						),
						'type'     => 'text',
						'required' => true,
						'class'    => 'medium',
						'tooltip'  => sprintf(
							'<h6>%s</h6>%s',
							esc_html_x(
								'Name',
								'Gravity Forms feed settings',
								'docket-connector'
							),
							esc_html_x(
								'Enter a feed name to uniquely identify this setup.',
								'Gravity Forms feed settings',
								'docket-connector'
							)
						),
					],
					$this->get_docket_task_fields(),
					$this->get_custom_conditional_logic_fields(),
				],
			];

			$to_map = $this->get_custom_feed_settings_fields_to_map();

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

			$settings[] = [
				'title'  => esc_html_x(
					'Field Mapping',
					'Gravity Forms feed settings',
					'docket-connector'
				),
				'fields' => $to_map,
			];

			// Not supported yet because, if enabled, the validation and including in the payload is not yet coded.
			$enable_dynamic_field_map = false;

			/**
			 * Whether to enable the dynamic field map settings.
			 *
			 * @since TBD @todo
			 *
			 * @param bool $enable_dynamic_field_map Whether to enable the dynamic field map settings.
			 *
			 * @return bool
			 */
			//$enable_dynamic_field_map = (bool) apply_filters( PluginData::plugin_text_domain_underscores() . '_gf_enable_dynamic_field_map', $enable_dynamic_field_map );

			if ( $enable_dynamic_field_map ) {
				// @link https://docs.gravityforms.com/dynamic_field_map-field/
				$settings[] = [
					'title'  => esc_html_x(
						'Custom Data',
						'Gravity Forms feed settings',
						'docket-connector'
					),
					'fields' => [
						[
							'name'                => 'customMappedData',
							'label'               => esc_html_x(
								'Custom Data Mapping',
								'Gravity Forms feed settings',
								'docket-connector'
							),
							'type'                => 'dynamic_field_map',
							'exclude_field_types' => 'creditcard',
							'tooltip'             => sprintf(
								'<h6>%s</h6>%s',
								esc_html_x(
									'Name',
									'Gravity Forms feed settings',
									'docket-connector'
								),
								esc_html_x(
									'Optionally, add your own data to the Docket API payload. (Consult Docket Support first!)',
									'Gravity Forms feed settings',
									'docket-connector'
								)
							),
							'validation_callback' => [ $this, 'validate_custom_meta' ], // Fatal until implemented.
						],
					],
				];
			}

			return $settings;
		}

		/**
		 * Whether the add-on is globally enabled.
		 *
		 * @return bool Whether the add-on is globally enabled.
		 */
		public function is_globally_enabled(): bool {
			return (bool) $this->get_plugin_setting( $this->_field_slug_enabled );
		}

		/**
		 * Whether the current form is set up for commerce (i.e. has some sort of Pricing field added).
		 *
		 * Note that the form may not yet have a payment field, such as 'creditcard' field, since some payment gateways
		 * process payments off-site (e.g. PayPal Standard or physical check), which is why we don't check for that.
		 *
		 * @see \GFFormDisplay::has_price_field() Basically this method, if it were public.
		 * @see \GFPaymentAddOn::get_payment_choices()
		 * @see GFCommon::get_order_total()
		 *
		 * @return bool Whether the current form is set up for commerce.
		 */
		public function form_has_commerce(): bool {
			$form = $this->get_current_form();

			if (
				! isset( $form['fields'] )
				|| ! is_array( $form['fields'] )
			) {
				return false;
			}

			foreach ( $form['fields'] as $field ) {
				if (
					$field instanceof GF_Field
					&& GFCommon::is_product_field( $field->get_input_type() )
				) {
					return true;
				}
			}

			return false;
		}

		/**
		 * Whether an Entry has a successful single payment - not incomplete or refunded and not for a subscription.
		 *
		 * @see \GFPaymentAddOn::process_capture() Where 'is_fulfilled' gets set.
		 * @see \GFPaymentAddOn::get_column_value_transactionType() The allowed transaction types.
		 * @see \GFPaymentAddOn::entry_post_save() Transaction type of subscription (2) or not (1).
		 *
		 * @param array $entry
		 *
		 * @return bool
		 */
		public function entry_has_product_payment( array $entry ): bool {
			if (
				! isset( $entry['payment_amount'] )
				|| ! is_numeric( $entry['payment_amount'] )
				|| empty( $entry['is_fulfilled'] )
				|| 1 !== (int) $entry['is_fulfilled']
				|| empty( $entry['payment_status'] )
				|| 'Paid' !== $entry['payment_status']
				|| 2 === (int) $entry['transaction_type'] // 2 = subscription, 1 = product / single payment
			) {
				return false;
			}

			return true;
		}

		/**
		 * Get the value of the API Key setting.
		 *
		 * @return string The API Key or an empty string.
		 */
		public function get_api_key(): string {
			return (string) $this->get_plugin_setting( ( new Choices() )->slug_api_key );
		}

		/**
		 * Get the label of a choice field from its value.
		 *
		 * Useful for getting the Label accompanying the Value of a Select or Product field.
		 *
		 * @since 1.1.1
		 *
		 * @param array      $form     A Gravity Forms form array.
		 * @param int|string $field_id The ID of a select/multi-option field from the given form.
		 * @param scalar     $value    The value for which to find the label. Must not be an empty string.
		 *
		 * @return string Empty string if error or non-matching value, else the value's accompanying Label.
		 */
		public function get_field_option_label_from_value(
			array $form,
			$field_id,
			$value
		): string {
			if ( ! is_scalar( $value ) ) {
				return '';
			}

			$field = GFAPI::get_field( $form, $field_id );

			if (
				! $field instanceof GF_Field
				|| empty( $field->choices )
				|| ! is_array( $field->choices )
			) {
				return '';
			}

			foreach ( $field->choices as $choice ) {
				if (
					! isset( $choice['text'] ) // Label.
					|| ! isset( $choice['value'] ) // Value.
					|| '' === $choice['value'] // Avoid unexpected Value comparison.
				) {
					continue;
				}

				if ( $choice['value'] === $value ) {
					return $choice['text'];
				}
			}

			return '';
		}

		/**
		 * Given a field, return the field label with the field ID appended, e.g. "Field Name [#__]" format.
		 *
		 * @param GF_Field $field
		 *
		 * @return string
		 */
		public function get_field_label_with_id( GF_Field $field ): string {
			return esc_html( sprintf( '%s [#%d]', GFCommon::get_label( $field ), $field->id ) );
		}

		/**
		 * @inheritDoc
		 */
		public function get_column_value( $item, $column ): string {
			if ( is_callable( [ $this, "get_column_value_{$column}" ] ) ) {
				return (string) call_user_func( [ $this, "get_column_value_{$column}" ], $item );
			}

			// Function names aren't prefixed to avoid having to rename them if prefix changes.
			$wo_prefix = ( new Strings() )
				->stringy( $column )
				->replaceBeginning( $this->_mapping_prefix, '' )
				->toString();

			if ( is_callable( [ $this, "get_column_value_{$wo_prefix}" ] ) ) {
				return (string) call_user_func( [ $this, "get_column_value_{$wo_prefix}" ], $item );
			} elseif ( isset( $item[ $column ] ) ) {
				return (string) $item[ $column ];
			} elseif ( isset( $item['meta'][ $column ] ) ) {
				return (string) $item['meta'][ $column ];
			}

			return '';
		}

		/**
		 * Get the value of the Docket Task Flag setting as boolean (1/0) string.
		 *
		 * If not set, retrieved value will be ''. If checked, '1'. If unchecked, '0'.
		 *
		 * @param array $feed
		 *
		 * @return string Either '1' if checked or '0' if not.
		 */
		public function get_task_flag_docket_value( array $feed ): string {
			$value = rgars( $feed, 'meta/task_flag_docket' );

			if ( '1' === $value ) {
				return $value;
			}

			return '0';
		}

		/**
		 * Get the display text of a feed column's selected ID.
		 *
		 * Used by the get_column_value_{$column} functions.
		 *
		 * @see  \GFFeedAddOn::get_column_value() This method is used for sending the value to that method.
		 * @see  \GFPaymentAddOn::get_column_value_amount() Callback example for the 'amount' slug.
		 *
		 * @param array  $feed
		 * @param string $column_slug
		 *
		 * @return string
		 */
		public function get_column_value_from_column_slug( array $feed, string $column_slug ): string {
			$form = $this->get_current_form();

			$field_id = rgars( $feed, 'meta/' . $column_slug );

			if ( empty( $field_id ) ) {
				return '';
			}

			$field = GFFormsModel::get_field( $form, $field_id );

			if ( ! $field instanceof GF_Field ) {
				return '';
			}

			return $this->get_field_label_with_id( $field );
		}

		/**
		 * @inheritDoc
		 *
		 * @return array
		 */
		public function option_choices(): array {
			return [];
		}

		/**
		 * Given an array of fields, return an array of selectable options like "Field Name [#__]" (field ID) format.
		 *
		 * @see \GFAddOn::build_choices_from_fields()
		 *
		 * @param array $fields
		 *
		 * @return array
		 */
		public function build_choices_from_fields_with_id( array $fields ): array {
			$choices = [];

			foreach ( $fields as $field ) {
				if ( ! $field instanceof GF_Field ) {
					continue;
				}

				$choices[] = [
					'value' => $field->id,
					'label' => $this->get_field_label_with_id( $field ),
				];
			}

			return $choices;
		}

		/**
		 * Get the fields by type for a Name.
		 *
		 * @see GF_Field::get_input_type() wp-content/plugins/gravityforms/includes/fields/class-gf-field-____.php
		 *
		 * @return array
		 */
		public function get_name_field_choices(): array {
			$form = $this->get_current_form();

			$types = [
				'name',
				'text',
				'hidden',
			];

			/**
			 * Customize the field types available to the selector.
			 *
			 * @since 1.0.0
			 *
			 * @param array            $types The field types allowed to be selected.
			 * @param array|false|null $form  The current form.
			 *
			 * @return array
			 */
			$types = (array) apply_filters(
				PluginData::plugin_text_domain_underscores() . '_' . __FUNCTION__,
				$types,
				$form
			);

			$fields = GFAPI::get_fields_by_type( $form, $types );

			$choices = [
				[
					'label' => esc_html_x(
						'— Select a name field',
						'Gravity Forms feed option',
						'docket-connector'
					),
					'value' => '',
				],
			];

			$choices = array_merge( $choices, $this->build_choices_from_fields_with_id( $fields ) );

			return $choices;
		}

		public function get_email_address_field_choices(): array {
			$form = $this->get_current_form();

			$types = [
				'email',
				'text',
				'hidden',
			];

			/**
			 * Customize the field types available to the selector.
			 *
			 * @since 1.0.0
			 *
			 * @param array            $types The field types allowed to be selected.
			 * @param array|false|null $form  The current form.
			 *
			 * @return array
			 */
			$types = (array) apply_filters(
				PluginData::plugin_text_domain_underscores() . '_' . __FUNCTION__,
				$types,
				$form
			);

			$fields = GFAPI::get_fields_by_type( $form, $types );

			/**
			 * Customize the fields available to the selector.
			 *
			 * @since 1.0.0
			 *
			 * @param array $fields The fields allowed to be selected.
			 *
			 * @return array
			 */
			$fields = (array) apply_filters( PluginData::plugin_text_domain_underscores() . '_' . __FUNCTION__, $fields );

			$choices = [
				[
					'label' => esc_html_x(
						'— Select an email field',
						'Gravity Forms feed option',
						'docket-connector'
					),
					'value' => '',
				],
			];

			$choices = array_merge( $choices, $this->build_choices_from_fields_with_id( $fields ) );

			return $choices;
		}

		public function get_phone_field_choices(): array {
			$form = $this->get_current_form();

			$types = [
				'phone',
				'text',
				'hidden',
			];

			/**
			 * Customize the field types available to the selector.
			 *
			 * @since 1.1.0
			 *
			 * @param array            $types The field types allowed to be selected.
			 * @param array|false|null $form  The current form.
			 *
			 * @return array
			 */
			$types = (array) apply_filters(
				PluginData::plugin_text_domain_underscores() . '_' . __FUNCTION__,
				$types,
				$form
			);

			$fields = GFAPI::get_fields_by_type( $form, $types );

			/**
			 * Customize the fields available to the selector.
			 *
			 * @since 1.0.0
			 *
			 * @param array $fields The fields allowed to be selected.
			 *
			 * @return array
			 */
			$fields = (array) apply_filters( PluginData::plugin_text_domain_underscores() . '_' . __FUNCTION__, $fields );

			$choices = [
				[
					'label' => esc_html_x(
						'— Select a phone field',
						'Gravity Forms feed option',
						'docket-connector'
					),
					'value' => '',
				],
			];

			$choices = array_merge( $choices, $this->build_choices_from_fields_with_id( $fields ) );

			return $choices;
		}

		public function get_address_field_choices(): array {
			$form = $this->get_current_form();

			$types = [
				'address',
				'text',
				'hidden',
			];

			/**
			 * Customize the field types available to the selector.
			 *
			 * @since 1.0.0
			 *
			 * @param array            $types The field types allowed to be selected.
			 * @param array|false|null $form  The current form.
			 *
			 * @return array
			 */
			$types = (array) apply_filters(
				PluginData::plugin_text_domain_underscores() . '_' . __FUNCTION__,
				$types,
				$form
			);

			$fields = GFAPI::get_fields_by_type( $form, $types );

			/**
			 * Customize the fields available to the selector.
			 *
			 * @since 1.0.0
			 *
			 * @param array $fields The fields allowed to be selected.
			 *
			 * @return array
			 */
			$fields = (array) apply_filters( PluginData::plugin_text_domain_underscores() . '_' . __FUNCTION__, $fields );

			$choices = [
				[
					'label' => esc_html_x(
						'— Select an address field',
						'Gravity Forms feed option',
						'docket-connector'
					),
					'value' => '',
				],
			];

			$choices = array_merge( $choices, $this->build_choices_from_fields_with_id( $fields ) );

			return $choices;
		}

		public function get_amount_field_choices(): array {
			$form = $this->get_current_form();

			/**
			 * @see \GFCommon::is_valid_for_calcuation()
			 * @see \GFCommon::is_product_field()
			 */
			$types = [
				'price',
				'quantity',
				'shipping',
				'singleshipping',
				'total',
				'calculation',
				'number',
				'hidden',
				// Gravity Perks eCommerce Fields:
				'discount',
				'subtotal',
				'tax',
			];

			/**
			 * Customize the field types available to the selector.
			 *
			 * @since 1.0.0
			 *
			 * @param array            $types The field types allowed to be selected.
			 * @param array|false|null $form  The current form.
			 *
			 * @return array
			 */
			$types = (array) apply_filters(
				PluginData::plugin_text_domain_underscores() . '_' . __FUNCTION__,
				$types,
				$form
			);

			$fields = GFAPI::get_fields_by_type( $form, $types );

			$choices = [
				[
					'label' => esc_html_x(
						'— Select an amount field',
						'Gravity Forms feed option',
						'docket-connector'
					),
					'value' => '',
				],
			];

			$choices = array_merge( $choices, $this->build_choices_from_fields_with_id( $fields ) );

			return $choices;
		}

		/**
		 * Validates the Docket API Key, adding an error to the Field array if invalid.
		 *
		 * @todo
		 *
		 * @param array $field The setting properties.
		 */
		public function validate_api_key( array $field ): void {
			$choices = new Choices();

			$settings = $this->get_posted_settings();

			// Unexpected but just in case.
			if ( ! isset( $settings[ $choices->slug_api_key ] ) ) {
				return;
			}

			$value = (string) $settings[ $choices->slug_api_key ];

			// Allow blanks.
			if ( '' === $value ) {
				return;
			}

			$value = $choices->sanitize_docket_api_key( $value );

			if ( '' === $value ) {
				$this->set_field_error(
					$field,
					esc_html_x(
						'Please enter a valid Docket API key. There might have been a copy-paste error.',
						'Gravity Forms Docket API key validation error',
						'docket-connector'
					)
				);
			}
		}

		/**
		 * The Gravity Forms settings for fields to map, if any.
		 *
		 * @return array
		 */
		public function get_custom_feed_settings_fields_to_map(): array {
			return [];
		}

		/**
		 * Basic conditional logic field. Override to empty array if your Add-On should not support conditions.
		 *
		 * @return array
		 */
		public function get_custom_conditional_logic_fields(): array {
			return [
				'name'    => 'conditionalLogic',
				'label'   => esc_html_x(
					'Conditional Logic',
					'Gravity Forms feed setting field',
					'docket-connector'
				),
				'type'    => 'feed_condition',
				'tooltip' => '<h6>'
					. esc_html_x(
						'Conditional Logic',
						'Gravity Forms feed setting field',
						'docket-connector'
					)
					. '</h6>'
					. esc_html_x(
						'When conditions are enabled, form submissions will only be sent to Docket when the conditions are met. When disabled, all form submissions will be sent to Docket. Regardless of conditions, data will not be sent to Docket until a payment is received, if required.',
						'Gravity Forms feed setting field',
						'docket-connector'
					),
			];
		}

		/**
		 * Docket Task field(s).
		 *
		 * @return array
		 */
		public function get_docket_task_fields(): array {
			return [
				'name'    => 'task_flag_docket_field',
				'label'   => esc_html_x(
					'Create Docket Task',
					'Gravity Forms feed setting field',
					'docket-connector'
				),
				'type'    => 'checkbox',
				'tooltip' => '<h6>'
					. esc_html_x(
						'Create Docket Task',
						'Gravity Forms feed setting field',
						'docket-connector'
					)
					. '</h6>'
					. esc_html_x(
						'If checked, Docket will trigger task creation upon successfully receiving data from a submission.',
						'Gravity Forms feed setting field',
						'docket-connector'
					),
				'choices' => [
					[
						'label' => esc_html_x(
							'Create Docket Task',
							'Gravity Forms feed setting field',
							'docket-connector'
						),
						'name'  => 'task_flag_docket', // Do NOT start with $_mapping_prefix.
						'value' => '1',
					],
				],
			];
		}

		/**
		 * Append this plugin's name and version number to all entry notes -- also affects add_feed_error() usage.
		 *
		 * @see \GFAddOn::add_note()
		 *
		 * @param int|string  $entry_id  The Entry ID.
		 * @param string      $note      The Entry note content.
		 * @param string|null $note_type The Entry note type, such as 'success'.
		 */
		public function add_note( $entry_id, $note, $note_type = null ): void {
			parent::add_note(
				$entry_id,
				$this->append_plugin_debug_info( $note ),
				$note_type
			);
		}

		/**
		 * Append a new line and this plugin's name and version number to any string.
		 *
		 * @param string $string
		 *
		 * @return string
		 */
		private function append_plugin_debug_info( string $string ): string {
			return sprintf(
				'%s%s[%s v%s]',
				$string,
				PHP_EOL,
				PluginData::get_plugin_display_name(),
				PluginData::plugin_version()
			);
		}

		/**
		 * Whether is an automated attempt (not a button click) and entry lacks a previously-successful attempt.
		 *
		 * @since 1.1.2
		 *
		 * @param int $entry_id
		 *
		 * @return bool
		 */
		public function should_allow_feed_processing_attempt( int $entry_id ): bool {
			// If manual button click, always allow to continue.
			if ( MetaBox::is_an_invoice_button_click() ) {
				return true;
			}

			// Else, only allow to continue if no prior successes.
			return 0 === $this->get_entry_meta_count_feed_successes( $entry_id );
		}

		/**
		 * @inheritDoc
		 */
		public function process_feed( $feed, $entry, $form ) {
			// Bail if major unexpected.
			if ( empty( $entry['id'] ) ) {
				return $entry;
			}

			$entry_id = (int) $entry['id'];

			$this->update_entry_meta_add_feed_attempt( $entry_id );

			// Log that we are processing feed.
			$this->log_debug( __METHOD__ . "(): Form #{$form['id']} - starting and recorded a feed attempt for Entry #{$entry_id}." );
			$this->log_debug( __METHOD__ . '(): raw Feed: ' . PHP_EOL . print_r( $feed, true ) );
			$this->log_debug( __METHOD__ . '(): raw Entry: ' . PHP_EOL . print_r( $entry, true ) );
			$this->log_debug( __METHOD__ . '(): raw Form: ' . PHP_EOL . print_r( $form, true ) );
			$this->log_debug( __METHOD__ . '(): raw $_GET: ' . PHP_EOL . print_r( $_GET, true ) );
			$this->log_debug( __METHOD__ . '(): raw $_POST: ' . PHP_EOL . print_r( $_POST, true ) );

			if ( ! $this->should_allow_feed_processing_attempt( $entry_id ) ) {
				$this->log_debug( __METHOD__ . '(): BAILED: should not allow feed processing attempt (has a previously-successful attempt and was not a manual button click to re-send to Docket).' . PHP_EOL );

				return $entry;
			}

			if ( ! $this->is_globally_enabled() ) {
				$this->add_feed_error(
					esc_html_x(
						'Unable to process feed because of being globally disabled.',
						'Gravity Forms feed processing',
						'docket-connector'
					),
					$feed,
					$entry,
					$form
				);

				return $entry;
			}

			if ( ! $this->get_api_key() ) {
				$this->add_feed_error(
					esc_html_x(
						'Unable to process feed because of not having a valid Docket API key.',
						'Gravity Forms feed processing',
						'docket-connector'
					),
					$feed,
					$entry,
					$form
				);

				return $entry;
			}

			if ( false === $this->entry_has_product_payment( $entry ) ) {
				$this->log_debug( __METHOD__ . '(): Entry has not yet logged a successful payment so we might try again later (automatically upon the "gform_post_payment_completed" action hook), else you can manually try resending from the admin view of the entry. We did not add a user-facing feed error note, but we did count this as an unsuccessful attempt.' );

				return $entry;
			}

			$mapped_field_ids = $this->get_feed_mapping_fields( $feed );

			if ( empty( $mapped_field_ids ) ) {
				$this->add_feed_error(
					esc_html_x(
						'Unable to process feed because of not having any field values for mapped fields, which is surprising…',
						'Gravity Forms feed processing',
						'docket-connector'
					),
					$feed,
					$entry,
					$form
				);

				return $entry;
			}

			$payload_body = $this->get_docket_api_call_body( $mapped_field_ids, $feed, $entry, $form );

			if ( empty( $payload_body ) ) {
				$this->add_feed_error(
					esc_html_x(
						'Unable to process feed because the form entry data did not satisfy the requirements to be sent to Docket. You may need to re-map your fields or contact Docket Support to resolve this issue for future submissions.',
						'Gravity Forms feed processing',
						'docket-connector'
					),
					$feed,
					$entry,
					$form
				);

				return $entry;
			}

			$api = new Api( 'invoice', $this->get_api_key() );

			$api_url = $api->get_api_url();

			$this->log_debug( __METHOD__ . '(): API URL was: ' . $api_url );

			if ( empty( $api_url ) ) {
				$this->add_feed_error(
					esc_html_x(
						'Unable to process feed because of not being able to obtain a valid Docket API URL, which is surprising…',
						'Gravity Forms feed processing',
						'docket-connector'
					),
					$feed,
					$entry,
					$form
				);

				return $entry;
			}

			$response = $this->send_docket_payload( $api_url, $payload_body );

			if (
				empty( $response )
				|| ! empty( $response['error'] )
			) {
				$this->add_feed_error(
					esc_html_x(
						'Unable to process feed because the processing of the form entry data did not satisfy the requirements to be sent to Docket, or the Docket API sent an error. You may need to re-map your fields or contact Docket Support to resolve this issue for future submissions. Error message passed along:',
						'Gravity Forms feed processing',
						'docket-connector'
					) . ' ' . $response['message'],
					$feed,
					$entry,
					$form
				);

				return $entry;
			}

			$this->update_entry_meta_add_feed_success( $entry_id );

			$this->add_note( $entry_id, $this->get_entry_success_note( $entry ), 'success' );

			$this->log_debug( __METHOD__ . '(): SUCCESS: Made it all the way to the end of processing the feed successfully.' . PHP_EOL );

			return $entry;
		}

		/**
		 * Get the data array to send as the body of the payload to the Docket API.
		 *
		 * @see    \GFAddOn::get_field_value() We don't solely use this because it forces to a string and the Docket API
		 *                                  wants arrays of data for things like a Gravity Forms 'address' field type.
		 *
		 * @since  1.0.0
		 *
		 * @param string[] $mapped_field_ids The array of mapped field IDs from the prefixed Feed settings.
		 * @param array    $feed             The current Feed object.
		 * @param array    $entry            The current Entry object.
		 * @param array    $form             The current Form object.
		 *
		 * @return array|null
		 */
		abstract public function get_docket_api_call_body(
			array $mapped_field_ids,
			array $feed,
			array $entry,
			array $form
		): ?array;

		/**
		 * Get the note text when the Feed processing was fully successful.
		 *
		 * @param array $entry
		 *
		 * @return string
		 */
		public function get_entry_success_note( array $entry ): string {
			return esc_html_x(
				'Successfully sent data to the Docket API.',
				'Docket feed success message Entry note',
				'docket-connector'
			);
		}

		/**
		 * Get the map of field names (only Feed settings with our prefix, which gets removed) and field IDs.
		 *
		 * @link https://docs.gravityforms.com/mapped-field-values-during-feed-processing/
		 *
		 * @param array $feed
		 *
		 * @return string[] Map of un-prefixed field names and the mapped field IDs.
		 */
		public function get_feed_mapping_fields( array $feed ): array {
			// Bail on bad attempt.
			if ( empty( $feed['meta'] ) ) {
				return [];
			}

			$strings = new Strings();

			$mapping_fields = [];

			// Loop through feed meta, skipping feed meta keys not starting with the mapping prefix or not numeric value.
			foreach ( $feed['meta'] as $key => $value ) {
				$stringy = $strings->stringy( $key );

				if (
					! $stringy->startsWith( $this->_mapping_prefix )
					|| ! is_numeric( $value ) // We're expecting '43' or '43.1' for mapping field settings.
				) {
					continue;
				}

				$field = $stringy->replaceBeginning( $this->_mapping_prefix, '' )->toString();

				// All are strings so '89' or '89.3' are handled.
				$mapping_fields[ $field ] = (string) $value;
			}

			$this->log_debug( __METHOD__ . '(): Mapped field IDs: ' . PHP_EOL . print_r( $mapping_fields, true ) );

			return $mapping_fields;
		}

		/**
		 * Get the ISO 8601 formatted date string from a Gravity Forms Entry.
		 *
		 * They're stored as UTC but not in the format so this is just a lookup then converter.
		 *
		 * @see \GFFormsModel::get_lead_db_columns() An Entry's date fields.
		 *
		 * @param array  $entry      The Gravity Forms Entry array.
		 * @param string $date_field One of these: 'date_created', 'date_updated', 'payment_date'.
		 *
		 * @return string|null The ISO 8601 date string or null if the $date_field was not found (e.g. 'payment_date'
		 *                     for an Entry without one) or the stored string was unable to be parsed correctly.
		 */
		public function get_gf_entry_date_iso_8601(
			array $entry,
			string $date_field
		): ?string {
			$date_field = strtolower( $date_field );

			$date = rgar( $entry, $date_field );

			if ( empty( $date ) ) {
				return null;
			}

			$date = GFCommon::format_date( $date, false, 'c', false );

			if ( ! ( new Timing() )->is_fully_formatted_iso_8601_datetime_string( $date ) ) {
				return null;
			}

			return $date;
		}

		/**
		 * @inheritDoc
		 *
		 * Overridden to ensure we're getting values consistently.
		 */
		public function get_field_value( $form, $entry, $field_id ) {
			return $this->get_field_value_for_docket_api( $form, $entry, $field_id, false );
		}

		/**
		 * Get the value of a form field's "Description" text.
		 *
		 * @param array      $form     The Form array.
		 * @param int|string $field_id The Field ID, such as 4 or '4.1'.
		 * @param string     $default  The string to return if we encounter an error.
		 *
		 * @return string The value of the form field's "Description" text. May contain HTML.
		 */
		public function get_field_description(
			array $form,
			$field_id,
			string $default = ''
		): string {
			$field = GFFormsModel::get_field( $form, $field_id );

			if (
				! $field instanceof GF_Field
				|| ! property_exists( $field, 'description' )
				|| ! isset( $field->description ) // Gets set dynamically.
			) {
				return $default;
			}

			return (string) $field->get_description( $field->description, '' );
		}

		/**
		 * Returns the value of the selected field prepped for the Docket API.
		 *
		 * @param array      $form
		 * @param array      $entry
		 * @param string|int $field_id
		 * @param bool       $raw_value True to return array fields (e.g. address) as arrays. False for concatenated string.
		 *
		 * @return string|array|null The field value. Null if the Field ID was not found in the Entry.
		 */
		public function get_field_value_for_docket_api(
			array $form,
			array $entry,
			$field_id,
			bool $raw_value = true
		) {
			$field_value = null;

			$field = GFFormsModel::get_field( $form, $field_id );

			if ( ! $field instanceof GF_Field ) {
				return $field_value;
			}

			$input_type = $field->get_input_type();

			$is_integer = $field_id == (int) $field_id;

			switch ( strtolower( $field_id ) ) {

				case 'form_title':
					$field_value = rgar( $form, 'title' );
					break;

				case 'date_created':
					$date_created = $this->get_gf_entry_date_iso_8601( $entry, $field_id );

					if ( empty( $date_created ) ) {
						//the date created may not yet be populated if this function is called during the validation phase and the entry is not yet created
						$timing      = new Timing();
						$field_value = (string) $timing->get_now_utc_iso_8601_datetime_string();
					} else {
						$field_value = $date_created;
					}
					break;

				case 'ip':
				case 'source_url':
				case 'id':
					$field_value = rgar( $entry, strtolower( $field_id ) );
					break;

				default:
					if ( $is_integer ) {
						if ( $input_type == 'address' ) {
							if ( $raw_value ) {
								$field_value = $this->get_docket_address_array( $entry, $field_id );
							} else {
								$field_value = $this->get_full_address( $entry, $field_id );
							}
							break;
						} elseif ( $input_type == 'name' ) {
							if ( $raw_value ) {
								$field_value = $this->get_docket_person_name_array( $entry, $field_id );
							} else {
								$field_value = $this->get_full_name( $entry, $field_id );
							}
							break;
						}
					}

					if ( $raw_value ) {
						$field_value = rgar( $entry, $field_id );
						break;
					}

					if ( is_callable( [ $this, "get_{$input_type}_field_value" ] ) ) {
						$field_value = call_user_func( [ $this, "get_{$input_type}_field_value" ], $entry, $field_id, $field );
						break;
					}

					$field_value = $field->get_value_export( $entry, $field_id );
			}

			/**
			 * A generic filter allowing the field value to be overridden. Form and field id modifiers supported.
			 *
			 * @since 1.9.15.12
			 *
			 * @param array  $form        The Form currently being processed.
			 * @param array  $entry       The Entry currently being processed.
			 * @param string $field_id    The ID of the Field currently being processed.
			 * @param string $slug        The add-on slug e.g. gravityformsactivecampaign.
			 *
			 * @param string $field_value The value to be overridden.
			 *
			 * @return string
			 */
			$field_value = gf_apply_filters( [ 'gform_addon_field_value', $form['id'], $field_id ], $field_value, $form, $entry, $field_id, $this->_slug );

			return $this->maybe_override_field_value( $field_value, $form, $entry, $field_id );
		}

		/**
		 * Send the payload to the Docket API. Returns an array with an error flag and accompanying message.
		 *
		 * @see    get_docket_api_call_body()
		 *
		 * @see    \WP_Http::request()
		 *
		 * @since  1.0.0
		 *
		 * @param string $api_url The Docket API URL to which the data will be sent.
		 * @param array  $body    The body of the payload data array to send to the Docket API.
		 *
		 * @return array An array with 'error' boolean and 'message' string, either from local (e.g. WP_Error) or
		 *               passed from the API response if successful.
		 */
		public function send_docket_payload(
			string $api_url,
			array $body
		): array {
			// Docket API calls should only ever be HTTPS, not HTTP, SSL, FTP, etc.
			if (
				empty( ( new Http() )->is_https_url( $api_url ) )
				|| empty( $body ) // Don't accidentally send '[]' (an empty array encoded as a JSON string).
			) {
				return [
					'error'   => true,
					'message' => esc_html_x(
						'Bailed early from a really bad attempt to send data to the Docket API.',
						'Gravity Forms feed processing',
						'docket-connector'
					),
				];
			}

			$args = [
				'httpversion' => '1.1',
				/**
				 * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept
				 * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
				 */
				'headers'     => [
					'Accept'       => 'application/json',
					'Content-Type' => 'application/json',
				],
				'body'        => wp_json_encode( $body ),
			];

			$this->log_debug( __METHOD__ . '(): raw API payload body: ' . PHP_EOL . print_r( $body, true ) );
			$this->log_debug( __METHOD__ . '(): full API args: ' . PHP_EOL . print_r( $args, true ) );

			$response = wp_safe_remote_post( $api_url, $args );

			$this->log_debug( __METHOD__ . '(): full API response: ' . PHP_EOL . print_r( $response, true ) );

			$error_msg = ( new Http() )->get_api_response_error_message( $response );

			if ( ! empty( $error_msg ) ) {
				$error_msg = sprintf(
					'HTTP Code: %d. Message: %s',
					(int) wp_remote_retrieve_response_code( $response ),
					$error_msg
				);

				$this->log_debug( __METHOD__ . '(): the API attempt was unsuccessful: ' . $error_msg );

				return [
					'error'   => true,
					'message' => $error_msg,
				];
			}

			// Handle API 2XX code but JSON of `{"valid":false}`
			$response_body = json_decode( wp_remote_retrieve_body( $response ), true );

			if ( empty( $response_body['valid'] ) ) {
				$api_says_invalid = esc_html_x(
					'The Docket API successfully received the data but indicated that it was invalid.',
					'Docket API response says invalid',
					'docket-connector'
				);

				return [
					'error'   => true,
					'message' => $api_says_invalid,
				];
			}

			return [
				'error'   => false,
				'message' => wp_remote_retrieve_response_message( $response ),
			];
		}

		/**
		 * Get the Gravity Forms Entry value for an address field for the Docket API.
		 *
		 * @see \DocketConnector\Docket\InvoiceData::get_address_array_keys()
		 * @see GF_Field_Address::get_value_export()
		 *
		 * @param array      $entry
		 * @param int|string $field_id
		 *
		 * @return null|array Null if major error or empty array result, else an array matching Docket API requirements.
		 */
		public function get_docket_address_array(
			array $entry,
			$field_id
		): ?array {
			$address_field = GF_Fields::get( 'address' );

			if (
				! $address_field instanceof GF_Field_Address
				|| ! method_exists( $address_field, 'get_country_code' )
			) {
				return null;
			}

			$input_type = $address_field->get_input_type();

			if ( $address_field->type !== $input_type ) {
				return null;
			}

			$street_value  = str_replace( '  ', ' ', trim( rgar( $entry, $field_id . '.1' ) ) );
			$street2_value = str_replace( '  ', ' ', trim( rgar( $entry, $field_id . '.2' ) ) );
			$city_value    = str_replace( '  ', ' ', trim( rgar( $entry, $field_id . '.3' ) ) );
			$state_value   = str_replace( '  ', ' ', trim( rgar( $entry, $field_id . '.4' ) ) );
			$zip_value     = trim( rgar( $entry, $field_id . '.5' ) );
			$country_value = trim( rgar( $entry, $field_id . '.6' ) );

			if (
				empty( $street_value )
				&& ! empty( $street2_value )
			) {
				$street_value  = $street2_value;
				$street2_value = '';
			}

			$result = [
				'street'       => $street_value,
				'street2'      => $street2_value,
				'city'         => $city_value,
				'state'        => $state_value,
				'zip'          => $zip_value,
				'country_name' => $country_value,
				'country_code' => $address_field->get_country_code( $country_value ),
			];

			$result = array_filter( $result );

			if ( empty( $result ) ) {
				return null;
			}

			return $result;
		}

		/**
		 * Get the Gravity Forms Entry value for a person name field for the Docket API.
		 *
		 * @see \DocketConnector\Docket\InvoiceData::get_person_name_array_keys()
		 * @see GF_Field_Name::get_value_export()
		 *
		 * @param array      $entry
		 * @param int|string $field_id
		 *
		 * @return null|array Null if major error or empty array result, else an array matching Docket API requirements.
		 */
		public function get_docket_person_name_array(
			array $entry,
			$field_id
		): ?array {
			$name_field = GF_Fields::get( 'name' );

			if ( ! $name_field instanceof GF_Field_Name ) {
				return null;
			}

			$input_type = $name_field->get_input_type();

			if ( $name_field->type !== $input_type ) {
				return null;
			}

			$prefix_value = str_replace( '  ', ' ', trim( rgar( $entry, $field_id . '.2' ) ) );
			$first_value  = str_replace( '  ', ' ', trim( rgar( $entry, $field_id . '.3' ) ) );
			$middle_value = str_replace( '  ', ' ', trim( rgar( $entry, $field_id . '.4' ) ) );
			$last_value   = str_replace( '  ', ' ', trim( rgar( $entry, $field_id . '.6' ) ) );
			$suffix_value = str_replace( '  ', ' ', trim( rgar( $entry, $field_id . '.8' ) ) );

			$result = [
				'prefix' => $prefix_value,
				'first'  => $first_value,
				'middle' => $middle_value,
				'last'   => $last_value,
				'suffix' => $suffix_value,
			];

			$result = array_filter( $result );

			if ( empty( $result ) ) {
				return null;
			}

			return $result;
		}

		/**
		 * Get a Gravity Forms Entry's Source URL (the URL from which the form submission happened).
		 *
		 * @param array $entry
		 *
		 * @return string The HTTP or HTTPS URL where the form submission happened, else empty string.
		 */
		public function get_entry_source_url( array $entry ): string {
			if (
				empty( $entry['source_url'] )
				|| ! ( new Http() )->is_url_http_or_https( $entry['source_url'] )
			) {
				return '';
			}

			return esc_url( $entry['source_url'] );
		}

		/**
		 * Get the meta key used to store the number of this Add-On's API attempts.
		 *
		 * @return string
		 */
		public function get_entry_meta_key_feed_attempts(): string {
			return $this->get_slug() . '_' . $this->_meta_key_suffix_count_feed_attempts;
		}

		/**
		 * Get the total count of this Add-On's feed processing attempts.
		 *
		 * @param int $entry_id The Entry ID.
		 *
		 * @return int
		 */
		public function get_entry_meta_count_feed_attempts( int $entry_id ): int {
			return (int) gform_get_meta( $entry_id, $this->get_entry_meta_key_feed_attempts() );
		}

		/**
		 * Update (or initially set) the count of this Add-On's feed processing attempts, increasing it by 1.
		 *
		 * @param int $entry_id The Entry ID.
		 */
		public function update_entry_meta_add_feed_attempt( int $entry_id ): void {
			$previous = $this->get_entry_meta_count_feed_attempts( $entry_id );

			gform_update_meta( $entry_id, $this->get_entry_meta_key_feed_attempts(), $previous + 1 );
		}

		/**
		 * Get the meta key used to store the number of this Add-On's API attempts that were successful.
		 *
		 * @return string
		 */
		public function get_entry_meta_key_successes(): string {
			return $this->get_slug() . '_' . $this->_meta_key_suffix_count_feed_successes;
		}

		/**
		 * Get the total count of this Add-On's API attempts that were successful.
		 *
		 * @param int $entry_id The Entry ID.
		 *
		 * @return int
		 */
		public function get_entry_meta_count_feed_successes( int $entry_id ): int {
			return (int) gform_get_meta( $entry_id, $this->get_entry_meta_key_successes() );
		}

		/**
		 * Update (or initially set) the count of this Add-On's API attempts that were successful, increasing it by 1.
		 *
		 * @param int $entry_id The Entry ID.
		 */
		public function update_entry_meta_add_feed_success( int $entry_id ): void {
			$previous = $this->get_entry_meta_count_feed_successes( $entry_id );

			gform_update_meta( $entry_id, $this->get_entry_meta_key_successes(), $previous + 1 );
		}

		/**
		 * Get the total count of this Add-On's API attempts that were unsuccessful.
		 *
		 * @param int $entry_id The Entry ID.
		 *
		 * @return int
		 */
		public function get_entry_meta_count_feed_failures( int $entry_id ): int {
			$total     = $this->get_entry_meta_count_feed_attempts( $entry_id );
			$successes = $this->get_entry_meta_count_feed_successes( $entry_id );

			return $total - $successes;
		}

	}
}