<?php

declare( strict_types=1 );

namespace DocketConnector\Docket;

use DocketConnector\Common\Utilities\Arrays;
use DocketConnector\Common\Utilities\Debug;
use DocketConnector\Common\Utilities\Http;
use DocketConnector\Common\Utilities\Strings;
use DocketConnector\Common\Utilities\Timing;
use Exception;

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

if ( ! class_exists( InvoiceData::class ) ) {
	/**
	 * Docket invoice data processing.
	 */
	class InvoiceData {

		/** @var Arrays */
		private $arrays;

		/**
		 * The source of the invoice data so the converter knows how to format from data array to Docket.
		 *
		 * @see sanitize_key() So this will always be lowercase. 'Gravity Forms' becomes 'gravityforms'.
		 *
		 * @var string Examples: 'gravityforms' (only one currently implemented), 'woocommerce'.
		 */
		private $source = '';

		/**
		 * The raw invoice data from the class constructor.
		 *
		 * @var array
		 */
		private $raw_data = [];

		/**
		 * The formatted array of invoice data to be sent to Docket via JSON.
		 *
		 * Should only be set if confirmed as having all required fields and allowed optional fields.
		 *
		 * @see all_data_is_valid()
		 *
		 * @var array
		 */
		private $formatted_data = [];

		/**
		 * InvoiceData constructor.
		 *
		 * @param string $source Examples: 'gravityforms', 'woocommerce'.
		 * @param array  $data   An array of data to get formatted in preparation for sending to Docket's Invoice API.
		 */
		public function __construct(
			string $source = '',
			array $data = []
		) {
			$this->arrays   = new Arrays();
			$this->source   = sanitize_key( $source );
			$this->raw_data = $data;
		}

		/**
		 * Generate a 34-character random ID with a hyphen after every 5 characters (5 sets of 6 characters).
		 *
		 * Example: '7ee296-8e5b67-13e909-cfc4e1-ddd73c'.
		 *
		 * @return string
		 */
		public function generate_random_line_item_id() {
			// Always 15 characters.
			try {
				// Better way so try it first.
				$random = random_bytes( 15 );
			}
			catch ( Exception $exception ) {
				// Shouldn't happen, but we don't want to bail.
				$random = openssl_random_pseudo_bytes( 15 );

				// Just get a 15 character number.
				if ( empty( $random ) ) {
					$random = rand( 100000000000000, 999999999999999 );
				}
			}

			// 15 characters always becomes 30 characters.
			$random = bin2hex( $random );

			// Insert a hyphen every 6 characters, not ending in a hyphen.
			return rtrim( chunk_split( $random, 6, '-' ), '-' );
		}

		/**
		 * Get the allowed Line Items array keys.
		 *
		 * @since 1.1.1 Added 'label' as an optional item.
		 *
		 * @param string $which Either 'allowed' for all, 'required', or 'optional'.
		 *
		 * @return array The list of Line Item array keys.
		 */
		public function get_invoice_line_item_array_keys( string $which = 'allowed' ): array {
			$required = [
				'index',
				'name', // Line item's raw value (e.g. SKU).
				'rate',
				'quantity',
				'amount',
			];

			$optional = [
				'description',
				'is_tax',
				'label', // Line item's full text.
			];

			$allowed = array_merge( $required, $optional );

			return $$which;
		}

		/**
		 * In order to be valid, the data array may not have any array keys other than this array's keys.
		 *
		 * @param string $which Either 'allowed' for all, 'required', or 'optional'.
		 *
		 * @return string[] Key is the required associative array key. Value is the boolean callback to check. If not
		 *                  boolean, such as `is_email()`, will still work because the callback value gets cast to bool.
		 */
		public function get_invoice_array_rules( string $which = 'allowed' ): array {
			$http    = new Http();
			$strings = new Strings();
			$timing  = new Timing();

			$required = [
				'task_flag_docket' => [
					'required'  => true,
					'validator' => [ $strings, 'is_1_or_0_string' ],
				],
				'invoice_subtotal'   => [
					'required'  => true,
					'validator' => 'is_numeric',
				],
				'invoice_total'      => [
					'required'  => true,
					'validator' => 'is_numeric',
				],
				'payment_amount'     => [
					'required'  => true,
					'validator' => 'is_numeric',
				],
				'invoice_balance'    => [
					'required'  => true,
					'validator' => 'is_numeric',
				],
				'line_items'         => [
					'required'  => true,
					'validator' => [ $this, 'is_valid_docket_line_items_array' ],
				],
				'order_date'         => [
					'required'  => true,
					'validator' => [ $timing, 'is_fully_formatted_iso_8601_datetime_string' ],
				],
				'source'             => [
					'required'  => true,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
			];

			$optional = [
				'invoice_tax'      => [
					'required'  => false,
					'validator' => 'is_numeric',
				],
				'sales_rep'        => [
					'required'  => false,
					'validator' => [ $this, 'is_valid_docket_person_name_array' ],
				],
				'sales_rep_text'   => [
					'required'  => false,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
				'name_entity'      => [
					'required'  => false,
					'validator' => [ $this, 'is_valid_docket_person_name_array' ],
				],
				'name_entity_text' => [
					'required'  => false,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
				'name_person'      => [
					'required'  => false,
					'validator' => [ $this, 'is_valid_docket_person_name_array' ],
				],
				'name_person_text' => [
					'required'  => false,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
				'email'            => [
					'required'  => false,
					'validator' => 'is_email',
				],
				'phone'            => [
					'required'  => false,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
				'address_billing'  => [
					'required'  => false,
					'validator' => [ $this, 'is_valid_docket_address_array' ],
				],
				'address_billing_text' => [
					'required'  => false,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
				'address_service'      => [
					'required'  => false,
					'validator' => [ $this, 'is_valid_docket_address_array' ],
				],
				'address_service_text' => [
					'required'  => false,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
				'invoice_currency'     => [
					'required'  => false,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
				'shipping_description' => [
					'required'  => false,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
				'shipping_price'       => [
					'required'  => false,
					'validator' => 'is_numeric',
				],
				'payment_date'         => [
					'required'  => false,
					'validator' => [ $timing, 'is_fully_formatted_iso_8601_datetime_string' ],
				],
				'payment_method'       => [
					'required'  => false,
					'validator' => [ $strings, 'is_non_blank_string' ],
				],
				'source_url'           => [
					'required'  => false,
					'validator' => [ $http, 'is_url_http_or_https' ],
				],
				'custom'               => [
					'required'  => false,
					'validator' => [ $this->arrays, 'is_associative_array' ],
				],
			];

			$allowed = array_merge( $required, $optional );

			return $$which;
		}

		/**
		 * Get the allowed Person Name array keys.
		 *
		 * @param string $which Either 'allowed' for all, 'required', or 'optional'.
		 *
		 * @return array The list of Person Name array keys.
		 */
		public function get_person_name_array_keys( string $which = 'allowed' ): array {
			$required = [];

			$optional = [
				'prefix',
				'first',
				'middle',
				'last',
				'suffix',
			];

			$allowed = array_merge( $required, $optional );

			return $$which;
		}

		/**
		 * Get the allowed Address array keys.
		 *
		 * @param string $which Either 'allowed' for all, 'required', or 'optional'.
		 *
		 * @return array The list of Address array keys.
		 */
		public function get_address_array_keys( string $which = 'allowed' ): array {
			$required = [
				'street',
				'city',
				'state',
				'zip',
			];

			$optional = [
				'street2',
				'country_name',
				'country_code',
			];

			$allowed = array_merge( $required, $optional );

			return $$which;
		}

		/**
		 * Whether the given array has the required array keys and valid values for a Docket API person name.
		 *
		 * @param mixed $value
		 *
		 * @return bool
		 */
		public function is_valid_docket_person_name_array( $value ): bool {
			if ( ! $this->arrays->is_associative_array( $value ) ) {
				return false;
			}

			$allowed = $this->get_person_name_array_keys();

			// Bail if any non-allowed array keys.
			if ( ! $this->arrays->are_all_keys_of_first_array_also_in_second_array( array_filter( $value ), array_flip( $allowed ) ) ) {
				return false;
			}

			// Bail if any values are non-scalar.
			if ( ! $this->arrays->are_all_array_values_null_or_scalar( $value ) ) {
				return false;
			}

			return true;
		}

		/**
		 * Whether the given array has the required array keys and valid values for a Docket API address.
		 *
		 * @see \GFPaymentAddOn::billing_info_fields() Docket's requirements match Gravity Forms'.
		 *
		 * @param mixed $value
		 *
		 * @return bool
		 */
		public function is_valid_docket_address_array( $value ): bool {
			if ( ! $this->arrays->is_associative_array( $value ) ) {
				return false;
			}

			$allowed = $this->get_address_array_keys();

			// Bail if any non-allowed array keys.
			if ( ! $this->arrays->are_all_keys_of_first_array_also_in_second_array( array_filter( $value ), array_flip( $allowed ) ) ) {
				return false;
			}

			// Bail if any values are non-scalar.
			if ( ! $this->arrays->are_all_array_values_null_or_scalar( $value ) ) {
				return false;
			}

			return true;
		}

		/**
		 * Whether the given array has the required array keys and valid values for a Docket API invoice's line items.
		 *
		 * @param mixed $value
		 *
		 * @return bool
		 */
		public function is_valid_docket_line_items_array( $value ): bool {
			// Each line item should have a random string as its key.
			if ( ! $this->arrays->is_associative_array( $value ) ) {
				return false;
			}

			// Each line item.
			foreach ( $value as $k => $v ) {
				if ( ! $this->arrays->is_associative_array( $v ) ) {
					return false;
				}

				$allowed = $this->get_invoice_line_item_array_keys();

				// Bail if any non-allowed array keys. We purposefully don't filter out empties.
				if ( ! $this->arrays->are_all_keys_of_first_array_also_in_second_array( $v, array_flip( $allowed ) ) ) {
					return false;
				}

				// Bail if any values are non-scalar.
				if ( ! $this->arrays->are_all_array_values_null_or_scalar( $v ) ) {
					return false;
				}
			}

			return true;
		}

		private function is_invoice_data_item_valid(
			string $item,
			$value
		): bool {
			$rules = $this->get_invoice_array_rules();

			if ( ! array_key_exists( $item, $rules ) ) {
				return false;
			}

			$validator = $rules[ $item ]['validator'];

			// If rule doesn't exist.
			if ( ! is_callable( $validator ) ) {
				return false;
			}

			// Callback may not return boolean, such as is_email().
			return (bool) call_user_func( $validator, $value );
		}

		/**
		 * Get the formatted Docket Invoice data array. Null if not valid to send to Docket API.
		 *
		 * @return array|null An array ready to send to Docket Invoice API (via JSON), or null (to avoid JSON of '[]')
		 *                    if the raw data didn't meet all requirements, including matching the specified source.
		 */
		public function get_formatted_docket_invoice_data(): ?array {
			$this->set_formatted_docket_invoice_data();

			if ( empty( $this->formatted_data ) ) {
				( new Debug() )->output_to_log( __LINE__ );

				return null;
			}

			return $this->formatted_data;
		}

		private function set_formatted_docket_invoice_data(): void {
			if ( ! empty( $this->formatted_data ) ) {
				( new Debug() )->output_to_log( __LINE__ );

				return;
			}

			$data = $this->raw_data;

			// Remove NULLs and empty strings.
			$data = $this->arrays->recursive_remove_all_null_and_blank_strings( $data );

			// Prep before full check.
			if ( empty( $data['order_date'] ) ) {
				$data['order_date'] = ( new Timing() )->get_now_utc_iso_8601_datetime_string();
			}

			$data['source'] = $this->source;

			// Avoid long digit floats becoming long JSON strings.
			$data = $this->arrays->recursive_convert_all_scalar_to_string( $data ); // Will make long float strings.
			$data = $this->arrays->recursive_lessen_lengthy_decimals( $data, 3, 2 ); // Will reduce long float strings to 2 digits max.

			// Do full check.
			if ( ! $this->all_data_is_valid( $data ) ) {
				( new Debug() )->output_to_log( __LINE__ );

				return;
			}

			// Set data if passed full check.
			$this->formatted_data = $data;
		}

		/**
		 * Confirm whether the invoice data has the right shape (all required array keys and array value types).
		 *
		 * @todo Abstract out to make it so each "is valid" function can reuse. Right now we're just doing it all at once at the end.
		 *
		 * @param array $data Subject data ready to format to JSON then to Docket API if it passes.
		 *
		 * @return bool Whether the passed data was of the correct array shape to send to the Docket API as JSON.
		 */
		private function all_data_is_valid( array $data ): bool {
			// Check no disallowed keys.
			if ( ! $this->arrays->are_all_keys_of_first_array_also_in_second_array( $data, $this->get_invoice_array_rules() ) ) {
				$grr = [
					'line#' => __LINE__,
					'data'  => $data,
					'rules' => $this->get_invoice_array_rules(),
				];

				( new Debug() )->output_to_log( $grr );

				return false;
			}

			// Check all have valid format.
			foreach ( $data as $key => $value ) {
				if ( ! $this->is_invoice_data_item_valid( $key, $value ) ) {
					$grr = [
						'line'  => __LINE__,
						'data'  => $data,
						'key'   => $key,
						'value' => $value,
					];

					( new Debug() )->output_to_log( $grr );

					return false;
				}
			}

			// Check if any required are missing.
			foreach ( $this->get_invoice_array_rules( 'required' ) as $rules_key => $rules_array ) {
				if ( ! array_key_exists( $rules_key, $data ) ) {
					$grr = [
						'line'        => __LINE__,
						'required'    => $this->get_invoice_array_rules( 'required' ),
						'rules_key'   => $rules_key,
						'rules_array' => $rules_array,
						'data'        => $data,
					];

					( new Debug() )->output_to_log( $grr );

					return false;
				}
			}

			// Check if any required pairs (i.e. one or the other is required even though all are optional) are missing.
			if (
				! array_key_exists( 'name_entity', $data )
				&& ! array_key_exists( 'name_entity_text', $data )
				&& ! array_key_exists( 'name_person', $data )
				&& ! array_key_exists( 'name_person_text', $data )
			) {
				( new Debug() )->output_to_log( __LINE__ );

				return false;
			}

			return true;
		}

	}
}