<?php

namespace GP_Entry_Blocks;

use GF_Query_Column;
use GF_Query_Literal;
use GF_Query_Series;
use GFAPI;
use GF_Query;
use GF_Query_Condition;
use GFCommon;
use GFFormsModel;

/**
 * Gravity Forms entry querying class. Instances are attached to Entries block using the Entries block UUID so the
 * queried entries (or entry) can be shared amongst all inner blocks.
 *
 * This class also handles validating and performing actions on entries such as deleting.
 *
 * @todo Do we need to do anything with inactive forms?
 * @todo Add PHP filters.
 * @todo Limits
 * @todo URL rewrite
 *
 * @since 1.0
 */
class GF_Queryer {
	static $hooks_added = false;

	/** @var \WP_Block|null Current Entries Block block being rendered. */
	private static $current_block = null;

	/** @var array Instances of GF_Queryer. */
	private static $instances = array();

	/** @var int The current form ID set in the Entries block. */
	public $form_id;

	/** @var array|null Array of Gravity Forms entries. */
	public $entries;

	/** @var int|null Total number of entries returned. Typically used for pagination. */
	public $total_count;

	/** @var array|null The current GF entry if displaying a single entry. */
	public $entry = null;

	/** @var array The Entries block context. */
	public $block_context;

	/** @var GF_Query */
	public $query;

	public function __construct( $form_id, $block_context ) {
		$this->form_id       = $form_id;
		$this->block_context = $block_context;

		$this->hooks();
		$this->get_entry();

		if ( $this->is_entries() ) {
			$this->get_entries();
		}
	}

	public function hooks() {
		if ( self::$hooks_added ) {
			return;
		}

		add_filter( 'gpeb_filter', array( $this, 'process_filter' ), 10, 2 );

		add_filter( 'gpeb_filter_value', array( $this, 'filter_value_extract_custom_value' ), 10 );
		add_filter( 'gpeb_filter_value', array( $this, 'filter_value_replace_date_merge_tags' ), 10 );
		add_filter( 'gpeb_filter_value', array( $this, 'filter_value_replace_gf_merge_tags' ), 10 );
		add_filter( 'gpeb_filter_value', array( $this, 'filter_value_replace_special_values' ), 10 );
	}

	/**
	 * @param $uuid
	 *
	 * @return GF_Queryer | null
	 */
	public static function get( $uuid ) {
		return ! empty( self::$instances[ $uuid ] ) ? self::$instances[ $uuid ] : null;
	}

	/**
	 * @param array $block_context Block context.
	 *
	 * @return GF_Queryer
	 */
	public static function create( $block_context ) {
		$uuid    = $block_context['gp-entry-blocks/uuid'];
		$form_id = $block_context['gp-entry-blocks/formId'];

		self::$instances[ $uuid ] = new self( $form_id, $block_context );

		return self::$instances[ $uuid ];
	}

	/**
	 * Fetch existing or create new GF_Queryer if one does not exist.
	 *
	 * This approach is used as InnerBlocks in Gutenberg are rendered prior to ancestor blocks.
	 *
	 * @param array $context Block context.
	 *
	 * @return GF_Queryer
	 */
	public static function attach( $context ) {
		$existing = self::get( $context['gp-entry-blocks/uuid'] );

		if ( $existing ) {
			return $existing;
		}

		return self::create( $context );
	}

	/**
	 * Attaches to the instance of the current block being rendered.
	 *
	 * @return GF_Queryer|null
	 */
	public static function attach_to_current_block() {
		if ( ! self::$current_block || empty( self::$current_block->context ) ) {
			return null;
		}

		$current_block_instance = self::get( self::$current_block->context['gp-entry-blocks/uuid'] );

		if ( ! $current_block_instance ) {
			return null;
		}

		return $current_block_instance;
	}

	/**
	 * Set the current block to easily attach to the instance associated with the current block.
	 *
	 * This is called before a block is rendered.
	 *
	 * @param \WP_Block $block Current Entries Block block.
	 */
	public static function set_current_block( $block ) {
		self::$current_block = $block;
	}

	/**
	 * Clear the current block. This is ran after a block has been rendered.
	 */
	public static function clear_current_block() {
		self::$current_block = null;
	}

	/**
	 * Determine if a single entry is being viewed.
	 *
	 * @return boolean Whether a single entry is being queried.
	 */
	public function is_view_entry() {
		if ( $this->block_context['gp-entry-blocks/mode'] === 'view-single' ) {
			return true;
		}

		if ( $this->block_context['gp-entry-blocks/mode'] === 'edit-single' ) {
			return false;
		}

		$has_view_entry_query_param = ! ! rgget( 'view_entry' );

		/*
		 * Return false if there is no current entry for the current block (typically if the filters don't match)
		 * and there are multiple entry blocks on the same page.
		 */
		if ( $has_view_entry_query_param && count( get_current_post_blocks() ) > 1 && ! $this->entry ) {
			return false;
		}

		return $has_view_entry_query_param;
	}

	/**
	 * Determine if a single entry is being edited. This does not handle authorization.
	 *
	 * @return boolean Whether an entry is being edited.
	 *
	 */
	public function is_edit_entry() {
		if ( $this->block_context['gp-entry-blocks/mode'] === 'edit-single' ) {
			return true;
		}

		if ( $this->block_context['gp-entry-blocks/mode'] === 'view-single' ) {
			return false;
		}

		$has_edit_entry_query_param = ! ! rgget( 'edit_entry' );

		/*
		 * Return false if there is no current entry for the current block (typically if the filters don't match)
		 * and there are multiple entry blocks on the same page.
		 */
		if ( $has_edit_entry_query_param && count( get_current_post_blocks() ) > 1 && ! $this->entry ) {
			return false;
		}

		return $has_edit_entry_query_param;
	}

	/**
	 * Determine if multiple entries should be displayed. Returns false if single view entry or editing an entry.
	 *
	 * @return bool Whether multiple entries are being displayed.
	 */
	public function is_entries() {
		if ( $this->is_view_entry() || $this->is_edit_entry() ) {
			return false;
		}

		$has_edit_or_view_query_param = ! ! rgget( 'edit_entry' ) || ! ! rgget( 'view_entry' );

		/*
		 * Return false to not display block if there is another block handling an edit or view entry.
		 */
		if ( $has_edit_or_view_query_param && count( get_current_post_blocks() ) > 1 && ! $this->entry ) {
			return false;
		}

		return true;
	}

	/**
	 * Query entry provided in parameters if the mode is multi-entry. Otherwise, if edit-single or view-single, query
	 * for the first entry found.
	 *
	 * @return array|\WP_Error|null Gravity Forms entry.
	 */
	public function get_entry() {
		$entry_id = null;

		if ( $this->block_context['gp-entry-blocks/mode'] === 'multi-entry' ) {
			if ( rgget( 'view_entry' ) ) {
				$entry_id = rgget( 'view_entry' );
			} elseif ( rgget( 'edit_entry' ) ) {
				$entry_id = rgget( 'edit_entry' );
			}

			if ( ! $entry_id ) {
				return null;
			}

			if ( ! $this->entry_matches_filters( $entry_id ) ) {
				return null;
			}

			$this->entry = GFAPI::get_entry( $entry_id );
		} else {
			$this->get_entries();

			if ( ! empty( $this->entries ) ) {
				$this->entry = $this->entries[0];
			}
		}

		return $this->entry;
	}

	/**
	 * Query and set current entries.
	 *
	 * @return array|\WP_Error|null Gravity Forms entries result.
	 */
	public function get_entries() {
		$this->query = new GF_Query(
			$this->form_id,
			null,
			$this->sorting(),
			array(
				'page_size' => $this->get_pagesize(),
				'offset'    => $this->get_offset(),
			)
		);

		$this->entries = $this->query
			->where( $this->build_query_where() )
			->get();

		$this->total_count = $this->query->total_found;

		return $this->entries;
	}

	public function build_query_where() {
		$gf_query_where_groups = $this->process_filter_groups();

		$has_status_filter = false;

		foreach ( $gf_query_where_groups as $gf_query_where_index => $gf_query_where_group ) {
			if ( $gf_query_where_group[0]->get_columns()[0]->field_id === 'status' ) {
				$has_status_filter = true;
			}

			$gf_query_where_groups[ $gf_query_where_index ] = call_user_func_array( array(
				'GF_Query_Condition',
				'_and',
			), $gf_query_where_group );
		}

		$where_filter_groups = call_user_func_array( array( 'GF_Query_Condition', '_or' ), $gf_query_where_groups );

		// Exclude all non-active entries unless "Status" is one of the conditionals
		$where_filter_groups = ( ! $has_status_filter ) ? $this->limit_to_active_entries( $where_filter_groups ) : $where_filter_groups;
		$where_filter_groups = $this->include_user_facing_filters( $where_filter_groups );

		return $where_filter_groups;

	}

	public function sorting() {
		$key       = $this->block_context['gp-entry-blocks/orderBy'];
		$direction = $this->block_context['gp-entry-blocks/order'];

		if ( $this->is_entries() ) {
			$key       = rgar( $_GET, 'order_by', $key );
			$direction = rgar( $_GET, 'order', $direction );
		}

		return array(
			'key'       => $key,
			'direction' => $direction,
		);
	}

	public function include_user_facing_filters( $where_filter_groups ) {
		global $wpdb;

		/* Handle custom user-facing filters */
		$filters = rgget( 'filters' );

		if ( ! is_array( $filters ) ) {
			return $where_filter_groups;
		}

		/**
		 * @todo Security: Ensure that these filters are the ones set in the block.
		 */
		$user_facing_filters = array();
		$sub_filter_groups   = array();

		foreach ( $filters as $filter_key => $filter_value ) {
			$filter_key = str_replace( '_', '.', $filter_key );
			$column     = new GF_Query_Column( $filter_key, $this->form_id );
			$field      = GFAPI::get_field( rgget( 'filters_form_id' ), (int) $filter_key );

			/* Handle "Search All" filter. */
			if ( $filter_key === 'all' ) {
				$column = new GF_Query_Column( GF_Query_Column::META, 0 );
			}

			$condition = new GF_Query_Condition(
				$column,
				GF_Query_Condition::LIKE,
				new GF_Query_Literal( $this->get_sql_value( 'contains', $filter_value ) ) // how will this work with checkboxes?
			);

			/**
			 * Checkboxes should use ORs rather than an AND for every checkbox.
			 */
			if ( $field ) {
				switch ( $field->get_input_type() ) {
					case 'checkbox':
						if ( ! isset( $sub_filter_groups[ $field->id ] ) ) {
							$sub_filter_groups[ $field->id ] = array();
						}

						$sub_filter_groups[ $field->id ][] = $condition;
						continue 2;

					/**
					 * To handle name fields in an intuitive way when searching for full name, we need to concatenate the meta values belonging
					 * to each input in the name field.
					 */
					case 'name':
						$table_name = GFFormsModel::get_entry_meta_table_name();
						$alias      = $this->query->_alias( null );

						$subquery = $wpdb->prepare(
							// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
							"SELECT 1 FROM `{$table_name}` WHERE `meta_key` LIKE %s AND `entry_id` = `{$alias}`.`id` HAVING GROUP_CONCAT(`meta_value` SEPARATOR \" \") LIKE %s",
							$field->id . '.%',
							$this->get_sql_value( 'contains', $filter_value )
						);

						$condition = new GF_Query_Condition(
							new \GF_Query_Call( 'EXISTS', array( $subquery ) )
						);
						break;
				}
			}

			$user_facing_filters[] = $condition;
		}

		foreach ( $sub_filter_groups as $sub_group_field_id => $sub_group ) {
			$sub_filter_groups[ $sub_group_field_id ] = call_user_func_array( array( 'GF_Query_Condition', '_or' ), $sub_group );
		}

		$user_facing_filters = array_merge( $user_facing_filters, $sub_filter_groups );

		return call_user_func_array( array( 'GF_Query_Condition', '_and' ), array(
			$where_filter_groups,
			call_user_func_array( array( 'GF_Query_Condition', '_and' ), $user_facing_filters ),
		) );
	}

	public function limit_to_active_entries( $where_filter_groups ) {

		$where_active = new GF_Query_Condition(
			new GF_Query_Column( 'status' ),
			GF_Query_Condition::EQ,
			new GF_Query_Literal( 'active' )
		);

		return call_user_func_array( array( 'GF_Query_Condition', '_and' ), array(
			$where_filter_groups,
			$where_active,
		) );

	}

	public function process_filter_groups() {
		$properties    = gp_entry_blocks()->entry_filters->get_entry_properties( $this->form_id );
		$filter_groups = $this->block_context['gp-entry-blocks/filters'];

		if ( ! is_array( $filter_groups ) ) {
			/** This filter is documented below. */
			return apply_filters( 'gpeb_entries_query', array(), $this->form_id, $this->block_context );
		}

		$processed_filter_groups = array();

		foreach ( $filter_groups as $filter_group_index => $filter_group ) {
			foreach ( $filter_group as $filter ) {
				/**
				 * Filter to modify the values used in the query filters. This is useful for adding replacements for
				 * Gravity Forms merge tags, replacing special values such as Current Post/User ID, etc.
				 *
				 * @since 1.0
				 *
				 * @param string        $filter_value The current filter value.
				 * @param array         $filter       The current filter.
				 * @param GF_Queryer    $gf_queryer   Current instance of GF_Queryer.
				 */
				$filter_value = apply_filters( 'gpeb_filter_value', $filter['value'], $filter, $this );

				if ( ! $filter['property'] ) {
					continue;
				}

				$property = rgar( $properties, $filter['property'] );

				if ( ! $property ) {
					continue;
				}

				$group          = rgar( $property, 'group' );
				$wp_filter_name = 'gpeb_filter_' . $filter['property'];

				if ( ! has_filter( $wp_filter_name ) && $group ) {
					$wp_filter_name = 'gpeb_filter_group_' . $group;
				}

				if ( ! has_filter( $wp_filter_name ) ) {
					$wp_filter_name = 'gpeb_filter';
				}

				// @todo Document these filter variations.
				$processed_filter_groups = apply_filters(
					$wp_filter_name,
					$processed_filter_groups,
					array(
						'filter_value'       => $filter_value,
						'filter'             => $filter,
						'filter_group'       => $filter_group,
						'filter_group_index' => $filter_group_index,
						'form_id'            => $this->form_id,
						'property'           => $property,
						'property_id'        => $filter['property'],
					)
				);
			}
		}

		/**
		 * Filter to modify the filter groups used in the query. This is useful for adding additional filters or removing existing filters.
		 *
		 * @param array $filter_groups The current filter groups.
		 * @param int   $form_id       The current form ID.
		 * @param array $block_context The current block context.
		 */
		$processed_filter_groups = apply_filters( 'gpeb_entries_query', $processed_filter_groups, $this->form_id, $this->block_context );

		return $processed_filter_groups;
	}

	public function process_filter( $gf_query_where, $args ) {

		/**
		 * @var $filter_value
		 * @var $filter
		 * @var $filter_group
		 * @var $filter_group_index
		 * @var $form_id
		 * @var $property
		 * @var $property_id
		 */
		// phpcs:ignore WordPress.PHP.DontExtract.extract_extract
		extract( $args, EXTR_SKIP );

		if ( ! isset( $gf_query_where[ $filter_group_index ] ) ) {
			$gf_query_where[ $filter_group_index ] = array();
		}

		switch ( strtoupper( $filter['operator'] ) ) {
			case 'CONTAINS':
				$operator     = GF_Query_Condition::LIKE;
				$filter_value = $this->get_sql_value( $filter['operator'], $filter_value );
				break;
			case 'DOES_NOT_CONTAIN':
				$operator     = GF_Query_Condition::NLIKE;
				$filter_value = $this->get_sql_value( $filter['operator'], $filter_value );
				break;
			case 'STARTS_WITH':
				$operator     = GF_Query_Condition::LIKE;
				$filter_value = $this->get_sql_value( $filter['operator'], $filter_value );
				break;
			case 'ENDS_WITH':
				$operator     = GF_Query_Condition::LIKE;
				$filter_value = $this->get_sql_value( $filter['operator'], $filter_value );
				break;
			case 'IS NOT':
			case 'ISNOT':
			case '<>':
				$operator = GF_Query_Condition::NEQ;
				break;
			case 'LIKE':
				$operator = GF_Query_Condition::LIKE;
				break;
			case 'NOT IN':
				$operator = GF_Query_Condition::NIN;
				break;
			case 'IN':
				$operator = GF_Query_Condition::IN;
				break;
			case '>=':
				$operator = GF_Query_Condition::GTE;
				break;
			case '<=':
				$operator = GF_Query_Condition::LTE;
				break;
			case '<':
				$operator = GF_Query_Condition::LT;
				break;
			case '>':
				$operator = GF_Query_Condition::GT;
				break;
			case 'IS':
			case '=':
				$operator = GF_Query_Condition::EQ;
				// Implemented to support Checkbox fields as a Form Field Value filters.
				if ( is_array( $filter_value ) ) {
					$operator = GF_Query_Condition::IN;
				}
				break;
			default:
				return $gf_query_where;
		}

		if ( is_array( $filter_value ) ) {
			foreach ( $filter_value as &$_filter_value ) {
				$_filter_value = new GF_Query_Literal( $_filter_value );
			}
			unset( $_filter_value );
			$filter_value = new GF_Query_Series( $filter_value );
		} else {
			$source_field = GFAPI::get_field( $form_id, absint( $property_id ) );
			$is_field     = is_a( $source_field, 'GF_Field' );

			if ( $is_field && $source_field->type === 'number' ) {
				$filter_value = floatval( $filter_value );
			}

			$filter_value = new GF_Query_Literal( $filter_value );
		}

		$gf_query_where[ $filter_group_index ][] = new GF_Query_Condition(
			new GF_Query_Column( $property_id, (int) $form_id ),
			$operator,
			$filter_value
		);

		return $gf_query_where;

	}


	public function filter_value_extract_custom_value( $value ) {
		return preg_replace( '/^gf_custom:?/', '', $value );
	}

	/**
	 * Replace date merge tags for Query Filters such as {today}. Inspired by GP Conditional Logic.
	 *
	 * @param $value string
	 *
	 * @return string
	 */
	public function filter_value_replace_date_merge_tags( $value ) {
		$matches = parse_merge_tags( $value );

		foreach ( $matches as $match ) {
			list( $full_value, $tag, $modifier ) = array_pad( $match, 3, '' );

			$tag = strtolower( $tag );

			switch ( $tag ) {
				case 'today':
					// supports modifier (i.e. '+30 days'), modify time retrieved for {today} by the modifier
					$time  = ! $modifier ? time() : strtotime( $modifier, time() );
					$value = gmdate( 'Y-m-d', $time );
			}
		}

		return $value;
	}

	public function filter_value_replace_gf_merge_tags( $value ) {
		return GFCommon::replace_variables_prepopulate( $value, false, false, true );
	}

	public function filter_value_replace_special_values( $value ) {

		if ( ! is_scalar( $value ) || strpos( $value, 'special_value:' ) !== 0 ) {
			return $value;
		}

		$special_value       = str_replace( 'special_value:', '', $value );
		$special_value_parts = explode( ':', $special_value );

		switch ( $special_value_parts[0] ) {
			case 'current_user':
				$user = wp_get_current_user();

				if ( $user && $user->ID > 0 ) {
					return $user->{$special_value_parts[1]};
				}

				break;
			case 'current_post':
				$post            = get_post();
				$referer         = rgar( $_SERVER, 'HTTP_REFERER' );
				$referer_post_id = $referer ? url_to_postid( $referer ) : null;

				if ( ! $post && $referer && $referer_post_id ) {
					$post = get_post( $referer_post_id );
				}

				if ( $post ) {
					return $post->{$special_value_parts[1]};
				}

				break;
		}

		/* No current post or user, return impossible ID */
		return -1;

	}

	public function get_sql_value( $operator, $value ) {

		global $wpdb;

		switch ( $operator ) {
			case 'starts_with':
				return $wpdb->esc_like( $value ) . '%';

			case 'ends_with':
				return '%' . $wpdb->esc_like( $value );

			case 'contains':
			case 'does_not_contain':
				return '%' . $wpdb->esc_like( $value ) . '%';

			case 'is_in':
			case 'is_not_in':
				$value = is_array( $value ) ? $value : array_map( 'trim', explode( ',', $value ) );

				return array_map( $wpdb->esc_like, $value );

			default:
				return $value;
		}

	}

	/**
	 * @return int
	 */
	public function get_pagesize() {
		if ( $this->block_context['gp-entry-blocks/mode'] !== 'multi-entry' || rgget( 'view_entry' ) || rgget( 'edit_entry' ) ) {
			return 1;
		}

		return $this->block_context['gp-entry-blocks/limit'];
	}

	/**
	 * @return int
	 */
	public function get_offset() {
		return (int) rgget( 'offset' );
	}

	/**
	 * Determines if the entry supplied matches the form and entry filters set for the current GF_Queryer instance.
	 *
	 * @param array|int $entry Entry or the ID of the entry to check.
	 *
	 * @return boolean Whether or not the entry matches the form and filters.
	 */
	public function entry_matches_filters( $entry ) {
		if ( is_numeric( $entry ) ) {
			$entry = GFAPI::get_entry( $entry );
		}

		if ( ! $entry || is_wp_error( $entry ) ) {
			return false;
		}

		if ( (int) rgar( $entry, 'form_id' ) !== $this->form_id ) {
			return false;
		}

		$query = new GF_Query(
			$this->form_id,
			null,
			$this->sorting()
		);

		/* Add a condition for the current entry ID to existing filters. */
		$where = GF_Query_Condition::_and(
			$this->build_query_where(),
			new GF_Query_Condition(
				new GF_Query_Column( 'id' ),
				GF_Query_Condition::EQ,
				new GF_Query_Literal( $entry['id'] )
			)
		);

		$query
			->where( $where )
			->get();

		return $query->total_found === 1;
	}
}
