<?php
/**
 * GravityView legacy templating engine class.
 *
 * PSR-4 migration of the legacy GravityView_View class from includes/class-template.php.
 * This is the legacy template loader used by old-style GravityView templates.
 *
 * @package GravityKit\GravityView\View
 * @license GPL2+
 * @since 3.0.0
 */

namespace GravityKit\GravityView\View;

use GV\Grid;

class ViewLegacy extends \GV\Gamajo_Template_Loader {

	/**
	 * Prefix for filter names.
	 *
	 * @var string $filter_prefix
	 */
	protected $filter_prefix = 'gravityview';

	/**
	 * Directory name where custom templates for this plugin should be found in the theme.
	 *
	 * @var string $theme_template_directory
	 */
	protected $theme_template_directory = 'gravityview';

	/**
	 * Reference to the root directory path of this plugin.
	 *
	 * @var string $plugin_directory
	 */
	protected $plugin_directory = GRAVITYVIEW_DIR;

	/**
	 * Store templates locations that have already been located
	 *
	 * @var array $located_templates
	 */
	protected $located_templates = [];

	/**
	 * The name of the template, like "list", "table", or "datatables"
	 *
	 * @var string $template_part_slug
	 */
	protected $template_part_slug = '';

	/**
	 * The name of the file part, like "body" or "single"
	 *
	 * @var string $template_part_name
	 */
	protected $template_part_name = '';

	/**
	 * @var int $form_id Gravity Forms form ID
	 */
	protected $form_id = null;

	/**
	 * @var int $view_id View ID
	 * @todo: this needs to be public until extensions support 1.7+
	 */
	public $view_id = null;

	/**
	 * @var array $fields Fields for the form
	 */
	protected $fields = [];

	/**
	 * @var string $context Current screen. Defaults to "directory" or "single"
	 */
	protected $context = 'directory';

	/**
	 * @var int|null $post_id If in embedded post or page, the ID of it
	 */
	protected $post_id = null;

	/**
	 * @var array $form Gravity Forms form array at ID $form_id
	 */
	protected $form = null;

	/**
	 * @var array $atts Configuration for the View
	 */
	protected $atts = [];

	/**
	 * @var array $entries Entries for the current result. Single item in array for single entry View
	 */
	protected $entries = [];

	/**
	 * @var int $total_entries Total entries count for the current result.
	 */
	protected $total_entries = 0;

	/**
	 * @var string $back_link_label The label to display back links
	 */
	protected $back_link_label = '';

	/**
	 * @var array $paging Array with `offset` and `page_size` keys
	 */
	protected $paging = [];

	/**
	 * @var array $sorting Array with `sort_field` and `sort_direction` keys
	 */
	protected $sorting = [];

	/**
	 * @var bool $hide_until_searched Whether to hide the results until a search is performed
	 * @since 1.5.4
	 */
	protected $hide_until_searched = false;

	/**
	 * Current entry in the loop
	 *
	 * @var array $_current_entry
	 */
	protected $_current_entry = [];

	/**
	 * @var array $_current_field
	 */
	protected $_current_field = [];

	/**
	 * @var ViewLegacy $instance
	 */
	static $instance = null;

	/**
	 * The current field data.
	 *
	 * @deprecated 1.6.2
	 */
	public $field_data;

	public $search_fields;

	public $search_field;

	public $permalink_fields;

	public $search_layout;

	public $search_mode;

	public $search_class;

	public $search_clear;

	public $updated;

	/**
	 * Construct the view object
	 *
	 * @param  array $atts Associative array to set the data of
	 */
	function __construct( $atts = [] ) {

		$atts = wp_parse_args(
			$atts,
			[
				'form_id' => null,
				'view_id' => null,
				'fields'  => null,
				'context' => null,
				'post_id' => null,
				'form'    => null,
				'atts'    => null,
			]
		);

		foreach ( $atts as $key => $value ) {
			if ( is_null( $value ) ) {
				continue;
			}
			$this->{$key} = $value;
		}

		// Add granular overrides
		add_filter( $this->filter_prefix . '_get_template_part', [ $this, 'add_id_specific_templates' ], 10, 3 );

		// Prevent hook accumulation when the constructor is called multiple times.
		if ( self::$instance ) {
			remove_action( 'gravityview/template/before', [ self::$instance, 'render_widget_hooks' ] );
			remove_action( 'gravityview/template/after', [ self::$instance, 'render_widget_hooks' ] );
		}

		add_action( 'gravityview/template/before', [ $this, 'render_widget_hooks' ] );
		add_action( 'gravityview/template/after', [ $this, 'render_widget_hooks' ] );

		/**
		 * Clear the current entry after the loop is done
		 *
		 * @since 1.7.3
		 */
		add_action( 'gravityview_footer', [ $this, 'clearCurrentEntry' ], 500 );

		self::$instance = &$this;
	}

	/**
	 * @param null $passed_post
	 *
	 * @return ViewLegacy
	 */
	static function getInstance( $passed_post = null ) {

		if ( empty( self::$instance ) ) {
			self::$instance = new self( $passed_post );
		}

		return self::$instance;
	}

	/**
	 * @param string|null $key The key to a specific attribute of the current field
	 * @return array|mixed|null If $key is set and attribute exists at $key, return that. If not set, return NULL. Otherwise, return current field array
	 */
	public function getCurrentField( $key = null ) {

		if ( ! empty( $key ) ) {
			if ( isset( $this->_current_field[ $key ] ) ) {
				return $this->_current_field[ $key ];
			}
			return null;
		}

		return $this->_current_field;
	}

	public function setCurrentFieldSetting( $key, $value ) {

		if ( ! empty( $this->_current_field ) ) {
			$this->_current_field['field_settings'][ $key ] = $value;
		}
	}

	public function getCurrentFieldSetting( $key ) {
		$settings = $this->getCurrentField( 'field_settings' );

		if ( $settings && ! empty( $settings[ $key ] ) ) {
			return $settings[ $key ];
		}

		return null;
	}

	/**
	 * @param array $passed_field
	 */
	public function setCurrentField( $passed_field ) {

		$existing_field = $this->getCurrentField();

		$set_field = wp_parse_args( $passed_field, $existing_field );

		$this->_current_field = $set_field;

		/**
		 * Backward compatibility
		 *
		 * @deprecated 1.6.2
		 */
		$this->field_data = $set_field;
	}

	/**
	 * @param string|null $key The key to a specific field in the fields array
	 * @return array|mixed|null If $key is set and field exists at $key, return that. If not set, return NULL. Otherwise, return array of fields.
	 */
	public function getAtts( $key = null ) {

		if ( ! empty( $key ) ) {
			if ( isset( $this->atts[ $key ] ) ) {
				return $this->atts[ $key ];
			}
			return null;
		}

		return $this->atts;
	}

	/**
	 * @param array $atts
	 */
	public function setAtts( $atts ) {
		$this->atts = $atts;
	}

	/**
	 * @return array
	 */
	public function getForm() {
		return $this->form;
	}

	/**
	 * @param array $form
	 */
	public function setForm( $form ) {
		$this->form = $form;
	}

	/**
	 * @return int|null
	 */
	public function getPostId() {
		return $this->post_id;
	}

	/**
	 * @param int|null $post_id
	 */
	public function setPostId( $post_id ) {
		$this->post_id = $post_id;
	}

	/**
	 * @return string
	 */
	public function getContext() {
		return $this->context;
	}

	/**
	 * @param string $context
	 */
	public function setContext( $context ) {
		$this->context = $context;
	}

	/**
	 * @param string|null $key The key to a specific field in the fields array
	 * @return array|mixed|null If $key is set and field exists at $key, return that. If not set, return NULL. Otherwise, return array of fields.
	 */
	public function getFields( $key = null ) {

		$fields = empty( $this->fields ) ? null : $this->fields;

		if ( $fields && ! empty( $key ) ) {
			$fields = isset( $fields[ $key ] ) ? $fields[ $key ] : null;
		}

		return $fields;
	}

	/**
	 * Get the fields for a specific context
	 *
	 * @since 1.19.2
	 *
	 * @param string $context [Optional] "directory", "single", or "edit"
	 *
	 * @return array Array of GravityView field layout configurations
	 */
	public function getContextFields( $context = '' ) {

		if ( '' === $context ) {
			$context = $this->getContext();
		}

		$fields = $this->getFields();

		foreach ( (array) $fields as $key => $context_fields ) {

			// Formatted as `{context}_{template id}-{zone name}`, so we want just the $context to match against
			$matches = explode( '_', $key );

			if ( isset( $matches[0] ) && $matches[0] === $context ) {
				return $context_fields;
			}
		}

		return [];
	}

	/**
	 * @param array $fields
	 */
	public function setFields( $fields ) {
		$this->fields = $fields;
	}

	/**
	 * @param string $key The key to a specific field in the fields array
	 * @return array|mixed|null If $key is set and field exists at $key, return that. If not set, return NULL. Otherwise, return array of fields.
	 */
	public function getField( $key ) {

		if ( ! empty( $key ) ) {
			if ( isset( $this->fields[ $key ] ) ) {
				return $this->fields[ $key ];
			}
		}

		return null;
	}

	/**
	 * @param string $key The key to a specific field in the fields array
	 * @param mixed  $value The value to set for the field
	 */
	public function setField( $key, $value ) {
		$this->fields[ $key ] = $value;
	}

	/**
	 * @return int
	 */
	public function getViewId() {
		return absint( $this->view_id );
	}

	/**
	 * @param int $view_id
	 */
	public function setViewId( $view_id ) {
		$this->view_id = intval( $view_id );
	}

	/**
	 * @return int
	 */
	public function getFormId() {
		return $this->form_id;
	}

	/**
	 * @param int $form_id
	 */
	public function setFormId( $form_id ) {
		$this->form_id = $form_id;
	}

	/**
	 * @return array
	 */
	public function getEntries() {
		return $this->entries;
	}

	/**
	 * @param array $entries
	 */
	public function setEntries( $entries ) {
		$this->entries = $entries;
	}

	/**
	 * @return int
	 */
	public function getTotalEntries() {
		return (int) $this->total_entries;
	}

	/**
	 * @param int $total_entries
	 */
	public function setTotalEntries( $total_entries ) {
		$this->total_entries = intval( $total_entries );
	}

	/**
	 * @return array
	 */
	public function getPaging() {

		$default_params = [
			'offset'    => 0,
			'page_size' => 20,
		];

		return wp_parse_args( $this->paging, $default_params );
	}

	/**
	 * @param array $paging
	 */
	public function setPaging( $paging ) {
		$this->paging = $paging;
	}

	/**
	 * Get an array with pagination information
	 *
	 * @since 1.13
	 *
	 * @return array {
	 *  @type int $first The starting entry number (counter, not ID)
	 *  @type int $last The last displayed entry number (counter, not ID)
	 *  @type int $total The total number of entries
	 * }
	 */
	public function getPaginationCounts() {

		$paging    = $this->getPaging();
		$offset    = $paging['offset'];
		$page_size = $paging['page_size'];
		$total     = $this->getTotalEntries();

		if ( empty( $total ) ) {
			\gravityview()->log->debug( 'No entries. Returning empty array.' );

			return [];
		}

		$first = empty( $offset ) ? 1 : $offset + 1;

		// If the page size + starting entry is larger than total, the total is the max.
		$last = ( $offset + $page_size > $total ) ? $total : $offset + $page_size;

		/**
		 * Modify the displayed pagination numbers.
		 *
		 * @since 1.13
		 *
		 * @param array $counts Array with `$first`, `$last`, `$total` numbers in that order.
		 */
		[ $first, $last, $total ] = apply_filters( 'gravityview_pagination_counts', [ $first, $last, $total ] );

		return [
			'first' => (int) $first,
			'last'  => (int) $last,
			'total' => (int) $total,
		];
	}

	/**
	 * @return array
	 */
	public function getSorting() {

		$defaults_params = [
			'sort_field'     => 'date_created',
			'sort_direction' => 'ASC',
			'is_numeric'     => false,
		];

		return wp_parse_args( $this->sorting, $defaults_params );
	}

	/**
	 * @param array $sorting
	 */
	public function setSorting( $sorting ) {
		$this->sorting = $sorting;
	}

	/**
	 * @param boolean $do_replace Perform merge tag and shortcode processing on the label. Default: true.
	 * @since 2.0
	 *
	 * @deprecated 3.0.0 $template->get_back_label();
	 *
	 * @return string
	 */
	public function getBackLinkLabel( $do_replace = true ) {
		if ( $do_replace ) {
			$back_link_label = \GravityView_API::replace_variables( $this->back_link_label, $this->getForm(), $this->getCurrentEntry() );
			return do_shortcode( $back_link_label );
		}

		return $this->back_link_label;
	}

	/**
	 * @param string $back_link_label
	 */
	public function setBackLinkLabel( $back_link_label ) {
		$this->back_link_label = $back_link_label;
	}

	/**
	 * @return boolean
	 */
	public function isHideUntilSearched() {
		return $this->hide_until_searched;
	}

	/**
	 * @param boolean $hide_until_searched
	 */
	public function setHideUntilSearched( $hide_until_searched ) {
		$this->hide_until_searched = $hide_until_searched;
	}

	/**
	 * @return string
	 */
	public function getTemplatePartSlug() {
		return $this->template_part_slug;
	}

	/**
	 * @param string $template_part_slug
	 */
	public function setTemplatePartSlug( $template_part_slug ) {
		$this->template_part_slug = $template_part_slug;
	}

	/**
	 * @return string
	 */
	public function getTemplatePartName() {
		return $this->template_part_name;
	}

	/**
	 * @param string $template_part_name
	 */
	public function setTemplatePartName( $template_part_name ) {
		$this->template_part_name = $template_part_name;
	}

	/**
	 * Return the current entry. If in the loop, the current entry. If single entry, the currently viewed entry.
	 *
	 * @return array
	 */
	public function getCurrentEntry() {

		if ( in_array( $this->getContext(), [ 'edit', 'single' ] ) ) {
			$entries = $this->getEntries();
			$entry   = isset( $entries[0] ) ? $entries[0] : $this->_current_entry;
		} else {
			$entry = $this->_current_entry;
		}

		/** @since 1.16 Fixes DataTables empty entry issue */
		if ( empty( $entry ) && ! empty( $this->_current_field['entry'] ) ) {
			$entry = $this->_current_field['entry'];
		}

		return $entry;
	}

	/**
	 * @param array $current_entry
	 * @return void
	 */
	public function setCurrentEntry( $current_entry ) {
		$this->_current_entry = $current_entry;
	}

	/**
	 * Clear the current entry after all entries in the loop have been displayed.
	 *
	 * @since 1.7.3
	 * @return void
	 */
	public function clearCurrentEntry() {
		$this->_current_entry = null;
	}

	/**
	 * Render an output zone, as configured in the Admin
	 *
	 * @since 1.16.4 Added $echo parameter
	 *
	 * @param string $zone The zone name, like 'footer-left'
	 * @param array  $atts
	 * @param bool   $echo Whether to print the output
	 *
	 * @deprecated 3.0.0 will never get called in new templates.
	 *
	 * @return string|null
	 */
	public function renderZone( $zone = '', $atts = [], $echo = true ) {

		if ( empty( $zone ) ) {
			\gravityview()->log->error( 'No zone defined.' );
			return null;
		}

		$defaults = [
			'slug'       => $this->getTemplatePartSlug(),
			'context'    => $this->getContext(),
			'entry'      => $this->getCurrentEntry(),
			'form'       => $this->getForm(),
			'hide_empty' => $this->getAtts( 'hide_empty' ),
		];

		$final_atts = wp_parse_args( $atts, $defaults );

		$output = '';

		$final_atts['zone_id'] = "{$final_atts['context']}_{$final_atts['slug']}-{$zone}";

		$fields = $this->getField( $final_atts['zone_id'] );

		// Backward compatibility
		if ( 'table' === $this->getTemplatePartSlug() ) {
			/**
			 * Modify the fields displayed in a table.
			 *
			 * @since 2.0.6.1
			 * @deprecated 3.0.0 `gravityview/template/table/fields` instead.
			 *
			 * @param array             $fields Array of field configurations.
			 * @param ViewLegacy        $this   The current View object.
			 */
			$fields = \GravityView_Deprecated_Hook_Notices::apply_filters( 'gravityview_table_cells', [ $fields, $this ], '2.55', 'gravityview/template/table/fields' );

			// The `gravityview/template/table/fields` hook is intentionally NOT applied here.
			// Its signature requires a `\GV\Template_Context` as the 2nd arg, which is unavailable
			// in this legacy rendering path (only a `\GravityView_View` exists). The new hook is
			// applied in the 2.0 templates: class-gv-template-entry-table.php and
			// class-gv-template-view-table.php.
		}

		if ( empty( $fields ) ) {

			\gravityview()->log->warning( 'Empty zone configuration for {zone_id}.', [ 'zone_id' => $final_atts['zone_id'] ] );

			return null;
		}

		$field_output = '';
		foreach ( $fields as $field ) {
			$final_atts['field'] = $field;

			$context = $this->build_legacy_field_context( $field, $final_atts['entry'] );

			$field_output .= gravityview_field_output( $final_atts, $context );
		}

		/**
		 * If a zone has no field output, choose whether to show wrapper.
		 *
		 * False by default to keep backward compatibility.
		 *
		 * @since 1.7.6
		 * @since 2.0 Added $context parameter.
		 *
		 * @param bool                       $hide_empty_zone Default: false.
		 * @param \GV\Template_Context|null  $context         The context. Null here since this path is deprecated.
		 */
		if ( empty( $field_output ) && apply_filters( 'gravityview/render/hide-empty-zone', false, null ) ) {
			return null;
		}

		if ( ! empty( $final_atts['wrapper_class'] ) ) {
			$output .= '<div class="' . gravityview_sanitize_html_class( $final_atts['wrapper_class'] ) . '">';
		}

		$output .= $field_output;

		if ( ! empty( $final_atts['wrapper_class'] ) ) {
			$output .= '</div>';
		}

		if ( $echo ) {
			echo $output;
		}

		return $output;
	}

	/**
	 * Wraps the singleton's legacy state into a modern \GV\Template_Context for a single field.
	 *
	 * Used by the deprecated `GravityView_View::renderZone()` rendering path and the legacy list
	 * templates so the new `gravityview/template/field_output/context`,
	 * `gravityview/field_output/context/{$tag}`, `gravityview/field_output/html`, etc. filters
	 * receive a usable context instead of null. Returns null when the legacy state cannot be
	 * wrapped, in which case `gravityview_field_output()` falls back to its deprecated path.
	 *
	 * Handles joined Views by resolving the field's source form via `$field['form_id']` against
	 * `$gv_view->joins`, mirroring `\GV\View::get_source()`.
	 *
	 * @since 2.60.0
	 *
	 * @param array $field A GravityView field configuration array, with at least an `id` key.
	 * @param array $entry The Gravity Forms entry array currently being rendered.
	 *
	 * @return \GV\Template_Context|null The wrapped context, or null when wrapping isn't possible.
	 */
	public function build_legacy_field_context( array $field, array $entry ): ?\GV\Template_Context {
		if ( empty( $entry['id'] ) || ! isset( $field['id'] ) ) {
			return null;
		}

		$gv_view = \GV\View::by_id( $this->getViewId() );

		if ( ! $gv_view ) {
			return null;
		}

		$gv_entry = \GV\GF_Entry::from_entry( $entry );

		if ( ! $gv_entry ) {
			return null;
		}

		// Resolve the field's source form so joined-form fields aren't silently misrouted.
		$field_form = $gv_view->form;

		if ( ! empty( $field['form_id'] ) && isset( $gv_view->form->ID ) && $field['form_id'] != $gv_view->form->ID ) {
			foreach ( $gv_view->joins as $join ) {
				if ( isset( $join->join_on->ID ) && $join->join_on->ID == $field['form_id'] ) {
					$field_form = $join->join_on;
					break;
				}
			}
		}

		$gv_field = is_numeric( $field['id'] )
			? \GV\GF_Field::by_id( $field_form, $field['id'] )
			: \GV\Internal_Field::by_id( $field['id'] );

		if ( ! $gv_field ) {
			return null;
		}

		return \GV\Template_Context::from_template( [
			'view'  => $gv_view,
			'field' => $gv_field,
			'entry' => $gv_entry,
		] );
	}

	/**
	 * In order to improve lookup times, we store located templates in a local array.
	 *
	 * This improves performance by up to 1/2 second on a 250 entry View with 7 columns showing
	 *
	 * @inheritdoc
	 * @see Gamajo_Template_Loader::locate_template()
	 * @return null|string NULL: Template not found; String: path to template
	 */
	function locate_template( $template_names, $load = false, $require_once = true ) {

		if ( is_string( $template_names ) && isset( $this->located_templates[ $template_names ] ) ) {

			$located = $this->located_templates[ $template_names ];

		} else {

			// Set $load to always false so we handle it here.
			$located = parent::locate_template( $template_names, false, $require_once );

			if ( is_string( $template_names ) ) {
				$this->located_templates[ $template_names ] = $located;
			}
		}

		if ( $load && $located ) {
			load_template( $located, $require_once );
		}

		return $located;
	}

	/**
	 * Magic Method: Instead of throwing an error when a variable isn't set, return null.
	 *
	 * @param  string $name Key for the data retrieval.
	 * @return mixed|null    The stored data.
	 */
	public function __get( $name ) {
		if ( isset( $this->{$name} ) ) {
			return $this->{$name};
		} else {
			return null;
		}
	}

	/**
	 * Enable overrides of GravityView templates on a granular basis
	 *
	 * The loading order is:
	 *
	 * - view-[View ID]-table-footer.php
	 * - form-[Form ID]-table-footer.php
	 * - page-[ID of post or page where view is embedded]-table-footer.php
	 * - table-footer.php
	 *
	 * @see  Gamajo_Template_Loader::get_template_file_names() Where the filter is
	 * @param array  $templates Existing list of templates.
	 * @param string $slug      Name of the template base, example: `table`, `list`, `datatables`, `map`
	 * @param string $name      Name of the template part, example: `body`, `footer`, `head`, `single`
	 *
	 * @return array $templates Modified template array, merged with existing $templates values
	 */
	function add_id_specific_templates( $templates, $slug, $name ) {

		$additional = [];

		// form-19-table-body.php
		$additional[] = sprintf( 'form-%d-%s-%s.php', $this->getFormId(), $slug, $name );

		if ( $view_id = $this->getViewId() ) {
			// view-3-table-body.php
			$additional[] = sprintf( 'view-%d-%s-%s.php', $view_id, $slug, $name );
		}

		if ( $this->getPostId() ) {

			// page-19-table-body.php
			$additional[] = sprintf( 'page-%d-%s-%s.php', $this->getPostId(), $slug, $name );
		}

		// Combine with existing table-body.php and table.php
		$templates = array_merge( $additional, $templates );

		\gravityview()->log->debug( 'List of Template Files', [ 'data' => $templates ] );

		return $templates;
	}

	/**
	 * Render a template part
	 *
	 * @since 2.42
	 *
	 * @param string $slug The template slug (like "table").
	 * @param string $name The template part name (like "body").
	 * @param bool $require_once Whether to require_once the template file. Default: true.
	 * @param array $data Data to pass to the template, available to the template as a $data global. Default: array().
	 */
	public function render( $slug, $name, $require_once = true, $data = [] ) {

		$this->setTemplatePartSlug( $slug );

		$this->setTemplatePartName( $name );

		// Set the data to be available in the template as a $data global.
		$this->set_template_data( $data );

		$template_file = $this->get_template_part( $slug, $name, false );

		\gravityview()->log->debug( 'Rendering Template File: {path}', [ 'path' => $template_file ] );

		if ( ! empty( $template_file ) ) {

			if ( $require_once ) {
				require_once $template_file;
			} else {
				require $template_file;
			}
		}

		// Clear the data after rendering.
		$this->unset_template_data();
	}

	/**
	 * Output the widgets on before/after hooks.
	 *
	 * @param int|\GV\Template_Context $view_id_or_context The View ID or the context.
	 *
	 * @return void
	 */
	public function render_widget_hooks( $view_id_or_context ) {

		/**
		 * @deprecated 3.0.0 argument is deprecated. Pass a \GV\Template_Context instead.
		 */
		if ( is_numeric( $view_id_or_context ) ) {
			$view          = \GV\View::by_id( $view_id_or_context );
			$is_single     = 'single' == gravityview_get_context();
			$total_entries = self::getInstance()->getTotalEntries();

			/**
			 * Fake new context for legacy template code.
			 */
			$view_id_or_context = \GV\Template_Context::from_template(
				[
					'view' => $view,
				]
			);

		} elseif ( $view_id_or_context instanceof \GV\Template_Context ) {
			$view          = $view_id_or_context->view;
			$is_single     = (bool) $view_id_or_context->request->is_entry();
			$total_entries = $view_id_or_context->entries ? $view_id_or_context->entries->count() : 0;

		} else {
			\gravityview()->log->error( 'No View ID or template context provided to render_widget_hooks' );
			return;
		}

		if ( $is_single ) {
			\gravityview()->log->debug( 'Not rendering widgets; single entry' );
			return;
		}

		switch ( current_filter() ) {
			default:
			case 'gravityview/template/before':
			case 'gravityview_before':
				$zone = 'header';
				break;
			case 'gravityview/template/after':
			case 'gravityview_after':
				$zone = 'footer';
				break;
		}

		$widgets = $view->widgets->by_position( "$zone*" );

		/**
		 * Prevent output if no widgets to show.
		 *
		 * @since 1.16
		 */
		if ( ! $widgets->count() ) {
			\gravityview()->log->debug(
				'No widgets for View #{view_id} in zone {zone}',
				[
					'view_id' => $view->ID,
					'zone'    => $zone,
				]
			);
			return;
		}

		// Prevent being called twice for the same filter, but allow shortcode contexts.
		$current_filter = current_filter();

		$filter_action_key = "gravityview/widgets/$zone/{$view->ID}/$current_filter/rendered";

		// Simple shortcode detection - just check if 'do_shortcode' is in the call stack.
		// Use a static cache to avoid repeated backtrace calls within the same request.
		static $shortcode_context_cache = [];

		$cache_key = "{$current_filter}_{$view->ID}";

		if ( ! isset( $shortcode_context_cache[ $cache_key ] ) ) {
			// Only do the backtrace lookup once per filter+View combination.
			$shortcode_context_cache[ $cache_key ] = in_array( 'do_shortcode', wp_debug_backtrace_summary( null, 0, false ) );
		}

		if ( $shortcode_context_cache[ $cache_key ] ) {
			// Use a simple unique identifier for shortcode contexts.
			$shortcode_key = md5( $current_filter . $view->ID . microtime( true ) );

			$filter_action_key .= "/sc_$shortcode_key";
		}

		if ( did_action( $filter_action_key ) ) {
			\gravityview()->log->debug( 'Not rendering {zone}; already rendered for {filter}', [ 'zone' => $zone, 'filter' => $current_filter ] );

			return;
		}

		$rows = Grid::get_rows_from_collection( $widgets, $zone );

		// TODO: Move to sep. method, use an action instead
		wp_enqueue_style( 'gravityview_default_style' );

		$default_css_class = 'gv-grid gv-widgets-' . $zone;

		if ( ! $total_entries ) {
			$default_css_class .= ' gv-widgets-no-results';
		}

		if ( ! $total_entries && ! gravityview()->request->is_search() && 3 === (int) $view->settings->get( 'no_entries_options', '0' ) ) {
			$default_css_class .= ' gv-hidden';
		}

		/**
		 * The CSS class applied to the widget container div.
		 *
		 * @since 1.16.2
		 *
		 * @param string $css_class Default: `gv-grid gv-widgets-{zone}` where `{zone}` is replaced by the current `$zone` value. If the View has no results, adds ` gv-widgets-no-results`.
		 * @param string $zone Current widget zone, either `header` or `footer`.
		 * @param array  $widgets Array of widget configurations for the current zone.
		 */
		$css_class = apply_filters( 'gravityview/widgets/wrapper_css_class', $default_css_class, $zone, $widgets->as_configuration() );

		$css_class = gravityview_sanitize_html_class( $css_class );

		// TODO Convert to partials
		?>
		<div class="<?php echo $css_class; ?>">
			<?php foreach ( $rows as $row ) { ?>
				<div class="gv-grid-row">
				<?php
				foreach ( $row as $col => $areas ) {
					$is_right = ( '2-2' === $col || strpos( $col, ' right' ) !== false );
					$column   = $col . ' gv-' . ( $is_right ? 'right' : 'left' );
					?>
					<div class="gv-grid-col-<?php echo esc_attr( $column ); ?>">
						<?php
						if ( ! empty( $areas ) ) {
							foreach ( $areas as $area ) {
								foreach ( $widgets->by_position( $zone . '_' . $area['areaid'] )->all() as $widget ) {
									do_action( sprintf( 'gravityview/widgets/%s/render', $widget->get_widget_id() ), $widget->configuration->all(), null, $view_id_or_context );
								}
							}
						}
						?>
					</div>
				<?php } // $row ?>
				</div>
			<?php } // $rows ?>
		</div>

		<?php

		/**
		 * Prevent widgets from being called twice for the same filter.
		 * Checking for loop_start prevents themes and plugins that pre-process shortcodes from triggering the action before displaying. Like, ahem, the Divi theme and WordPress SEO plugin
		 */
		if ( did_action( 'wp_head' ) ) {
			do_action( $filter_action_key );
		}
	}

	/**
	 * Include a file inside this context.
	 *
	 * @param string $path A path to the legacy template to include.
	 *
	 * @return void
	 */
	public function _include( $path ) {
		if ( file_exists( $path ) ) {
			include $path;
		}
	}
}
