<?php

use GOSP\GravityOps\Core\SuiteCore\SuiteCore;
use GOSP\GravityOps\Core\Utils\AssetHelper;
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * GravityOps_Search class
 *
 * Main class for the GravityOps_Search plugin that extends GFAddOn
 */
class GravityOps_Search extends GFAddOn {
    // phpcs:disable PSR2.Classes.PropertyDeclaration.Underscore
    use \GOSP\GravityOps\Core\Traits\SingletonTrait;
    /**
     * The current version of the plugin
     *
     * @var string Version of the plugin.
     */
    protected $_version = GRAVITYOPS_SEARCH_VERSION;

    /**
     * A string representing the slug used for the plugin.
     *
     * @var string Plugin slug.
     */
    protected $_slug = 'gravityops-search';

    /**
     * The full file path of the current script.
     *
     * @var string Full path to this class file.
     */
    protected $_full_path = __FILE__;

    /**
     * The full title of the plugin
     *
     * @var string Title of the plugin to be used in UI.
     */
    protected $_title = 'GravityOps Search';

    /**
     * The short title of the plugin.
     *
     * @var string Short version of the plugin title to be used in menus.
     */
    protected $_short_title = 'GravityOps Search';

    /**
     * Prefix used for shared options.
     *
     * @var string
     */
    private string $prefix = 'gosp_';

    /**
     * An instance of the AssetHelper class.
     *
     * @var AssetHelper
     */
    private AssetHelper $asset_helper;

    /**
     * Shortcode builder instance.
     *
     * @var GravityOps_Search_Builder
     */
    private GravityOps_Search_Builder $builder;

    // phpcs:enable PSR2.Classes.PropertyDeclaration.Underscore
    /**
     * Handles hooks and loading of language files.
     */
    public function init() {
        parent::init();
        add_shortcode( 'gravops_search', [$this, 'gravops_search'] );
        delete_option( "{$this->prefix}migration_done" );
        // todo remove in next release; cleans up the leftover 1.1.0 migration flag
    }

    /**
     * Deletes all plugin created data during uninstall.
     *
     * @return void
     */
    public function uninstall() {
        parent::uninstall();
        delete_option( "{$this->prefix}search_count" );
        delete_option( "{$this->prefix}review_prompter_usage_count" );
        delete_option( GravityOps_Search_Builder::OPTION_KEY );
        delete_metadata(
            'user',
            0,
            GravityOps_Search_Builder::META_KEY,
            '',
            true
        );
    }

    /**
     * Initializes the admin functionalities for the application.
     *
     * Sets up the necessary hooks and actions to configure the admin area, including adding the top-level menu.
     *
     * @return void This method does not return any value.
     */
    public function init_admin() {
        parent::init_admin();
        $tabs = [
            'overview' => [
                'label'    => 'Overview',
                'type'     => 'render',
                'callback' => [$this, 'gops_render_overview'],
            ],
        ];
        $tabs['help'] = [
            'label' => 'Help',
            'type'  => 'help',
        ];
        $freemius_tabs = $this->get_freemius_tabs();
        $tabs = array_merge( $tabs, $freemius_tabs );
        // Register the GravityOps AdminShell page for GravityOps Search (free).
        // Tabs: Overview (render), Help (render), Affiliation (external link)
        SuiteCore::instance()->shell()->register_plugin_page( $this->_slug, [
            'title'      => $this->_title,
            'menu_title' => $this->_short_title,
            'subtitle'   => '',
            'links'      => [],
            'docs_path'  => dirname( __DIR__ ) . '/docs',
            'tabs'       => $tabs,
        ] );
        $param = 'https://wordpress.org/support/plugin/gravityops-search/reviews/#new-post';
        $review_prompter = SuiteCore::instance()->review_prompter( $this->prefix, $this->_title, $param );
        $review_prompter->init();
        $review_prompter->maybe_show_review_request( $this->get_usage_count(), 10 );
        SuiteCore::instance()->register_survey_product(
            $this->prefix,
            $this->_title,
            $this->_version,
            [$this, 'get_plan_name']
        );
    }

    /**
     * Resolves the current plan name for survey payloads.
     *
     * @return string
     */
    private function get_plan_name() : string {
        $plan = gosp_fs()->get_plan();
        return ( is_object( $plan ) ? $plan->name : (( gosp_fs()->is_free_plan() ? 'free' : 'unknown' )) );
    }

    /**
     * Filter the AdminShell tabs to hide the Account tab if the user is not registered.
     *
     * @return array
     */
    private function get_freemius_tabs() {
        return SuiteCore::instance()->shell()->freemius_tabs(
            $this->_slug,
            [],
            '',
            false,
            gosp_fs()
        );
    }

    /**
     * Icon shown next to the plugin's Gravity Forms settings tab.
     *
     * @return string
     */
    public function get_menu_icon() {
        return ( SuiteCore::instance()->suite_menu()->get_plugin_icon_url( $this->_slug ) ?: 'dashicons-search' );
    }

    /**
     * Redirects the Gravity Forms settings tab to this plugin's GravityOps page.
     *
     * Defining this method makes Gravity Forms register the settings tab and its icon
     * ( GFAddOn::has_plugin_settings_page() ); the override forwards the click to the
     * dedicated GravityOps page rather than rendering a settings form here.
     *
     * @return void
     */
    public function plugin_settings_page() {
        wp_safe_redirect( esc_url_raw( admin_url( 'admin.php?page=' . $this->_slug ) ) );
        exit;
    }

    /**
     * Render: GravityOps → Search → Overview
     */
    public function gops_render_overview() {
        echo '<div class="gops-card gops-card--brand">';
        echo '<h2 class="gops-title" style="margin:0 0 8px;">Overview</h2>';
        echo '<p>GravityOps Search lets you look up and display Gravity Forms entry data anywhere using simple shortcodes.
                 Think of it as an infinitely customizable VLOOKUP for your forms.</p>';
        echo '<ul style="margin:8px 0 0 18px;">';
        echo '<li>Search across one or many entries and return specific fields.</li>';
        echo '<li>Combine fields, control sorting, and format output.</li>';
        echo '<li>Use in notifications, confirmations, or front‑end content.</li>';
        echo '</ul>';
        echo '</div>';
    }

    /**
     * Processes the gravops_search shortcode to perform searching and displaying Gravity Forms entries
     * based on specified criteria and attributes.
     *
     * @param array  $atts An associative array of attributes, or default values.
     * @param string $content Content of the shortcode, typically search values separated by '|'.
     *
     * @return string|false Formatted search results or false if search fails due to missing attributes or invalid setup.
     */
    public function gravops_search( $atts, $content = null ) {
        $result = apply_filters( 'gogv_shortcode_process', $content );
        if ( $result !== $content ) {
            return $result;
        }
        $this->increment_usage_count();
        $atts = shortcode_atts( [
            'target'                   => '0',
            'search'                   => '',
            'operators'                => '',
            'display'                  => '',
            'sort_key'                 => 'id',
            'sort_direction'           => 'DESC',
            'sort_is_num'              => true,
            'secondary_sort_key'       => '',
            'secondary_sort_direction' => 'DESC',
            'unique'                   => false,
            'limit'                    => '1',
            'search_mode'              => 'all',
            'separator'                => '',
            'search_empty'             => false,
            'default'                  => '',
            'link'                     => false,
            'search_label'             => '',
            'search_type'              => '',
            'field_match'              => 'first',
        ], $atts, 'gravops_search' );
        // Allow everything wp_kses_post allows plus <a> and its attributes
        $allowed_tags = wp_kses_allowed_html( 'post' );
        $a_tags = [
            'href'   => true,
            'title'  => true,
            'target' => true,
            'rel'    => true,
            'class'  => true,
            'id'     => true,
            'style'  => true,
        ];
        $allowed_tags['a'] = $a_tags + ($allowed_tags['a'] ?? []);
        $content = html_entity_decode( $content, ENT_QUOTES );
        $form_id = array_map( 'intval', explode( ',', $atts['target'] ) );
        $search_criteria = [];
        $search_criteria['status'] = 'active';
        $search_criteria['field_filters'] = [];
        $search_criteria['field_filters']['mode'] = ( in_array( strtolower( $atts['search_mode'] ), ['all', 'any'], true ) ? strtolower( $atts['search_mode'] ) : 'all' );
        $has_search = !empty( $atts['search'] ) || !empty( $atts['search_label'] ) || !empty( $atts['search_type'] );
        if ( $has_search && empty( $atts['display'] ) && !$atts['search_empty'] ) {
            return '';
        }
        $search_ids = array_map( fn( $search_id ) => GFCommon::replace_variables( $search_id, [], [] ), explode( ',', $atts['search'] ) );
        $search_ids = array_map( 'trim', $search_ids );
        // Parse operators if provided
        $operators = [];
        if ( !empty( $atts['operators'] ) ) {
            $operators = array_map( 'trim', explode( ',', $atts['operators'] ) );
        }
        $content_values = array_map( 'trim', explode( '|', $content ) );
        $field_match = ( in_array( strtolower( $atts['field_match'] ), ['first', 'all'], true ) ? strtolower( $atts['field_match'] ) : 'first' );
        $supported_operators = [
            '=',
            'is',
            'is not',
            'isnot',
            '!=',
            'contains',
            'like',
            'not in',
            'notin',
            'in',
            'lt',
            'gt',
            'gt=',
            'lt='
        ];
        foreach ( $search_ids as $index => $search_id ) {
            if ( empty( $search_id ) ) {
                continue;
            }
            $current_field = GFAPI::get_field( $form_id[0], $search_id );
            if ( $current_field && 'number' === $current_field['type'] ) {
                $content_values[$index] = str_replace( ',', '', $content_values[$index] );
            }
            // Add operator if provided for this field
            if ( !empty( $operators[$index] ) ) {
                if ( str_contains( $content_values[$index], 'array(' ) && in_array( $operators[$index], ['in', 'notin', 'not in'], true ) ) {
                    $json_string = str_replace( ['array(', ')', "'"], ['[', ']', '"'], $content_values[$index] );
                    $content_values[$index] = json_decode( $json_string, true );
                    $content_values[$index] = array_map( fn( $value ) => GFCommon::replace_variables( $value, [], [] ), $content_values[$index] );
                    $field_filter = [
                        'key'   => $search_id,
                        'value' => $content_values[$index],
                    ];
                } else {
                    $field_filter = [
                        'key'   => $search_id,
                        'value' => GFCommon::replace_variables( $content_values[$index], [], [] ),
                    ];
                }
                if ( in_array( $operators[$index], $supported_operators, true ) ) {
                    $operators[$index] = str_replace( 'gt', '>', $operators[$index] );
                    $operators[$index] = str_replace( 'lt', '<', $operators[$index] );
                    $field_filter['operator'] = $operators[$index];
                }
            } else {
                $field_filter = [
                    'key'   => $search_id,
                    'value' => GFCommon::replace_variables( $content_values[$index], [], [] ),
                ];
            }
            $search_criteria['field_filters'][] = $field_filter;
        }
        // Label-based and type-based search filter resolution.
        // Content pipe positions: search IDs first, then search_label entries, then search_type entries.
        $label_search_offset = count( $search_ids );
        if ( !empty( $atts['search_label'] ) ) {
            $labels = array_map( 'trim', explode( ',', $atts['search_label'] ) );
            foreach ( $labels as $label_idx => $label ) {
                if ( '' === $label ) {
                    continue;
                }
                $content_index = $label_search_offset + $label_idx;
                $value = $content_values[$content_index] ?? '';
                $operator = ( isset( $operators[$content_index] ) ? trim( $operators[$content_index] ) : '' );
                foreach ( $this->resolve_fields_by_label( $form_id, $label, $field_match ) as $fid ) {
                    $filter = [
                        'key' => $fid,
                    ];
                    if ( !empty( $operator ) && in_array( $operator, ['in', 'notin', 'not in'], true ) && str_contains( $value, 'array(' ) ) {
                        $json_string = str_replace( ['array(', ')', "'"], ['[', ']', '"'], $value );
                        $filter['value'] = array_map( fn( $v ) => GFCommon::replace_variables( $v, [], [] ), json_decode( $json_string, true ) ?? [] );
                    } else {
                        $filter['value'] = GFCommon::replace_variables( $value, [], [] );
                    }
                    if ( !empty( $operator ) && in_array( $operator, $supported_operators, true ) ) {
                        $filter['operator'] = str_replace( 'lt', '<', str_replace( 'gt', '>', $operator ) );
                    }
                    $search_criteria['field_filters'][] = $filter;
                }
            }
        }
        $type_search_offset = $label_search_offset + (( !empty( $atts['search_label'] ) ? count( array_map( 'trim', explode( ',', $atts['search_label'] ) ) ) : 0 ));
        if ( !empty( $atts['search_type'] ) ) {
            $types = array_map( 'trim', explode( ',', $atts['search_type'] ) );
            foreach ( $types as $type_idx => $type ) {
                if ( '' === $type ) {
                    continue;
                }
                $content_index = $type_search_offset + $type_idx;
                $value = $content_values[$content_index] ?? '';
                $operator = ( isset( $operators[$content_index] ) ? trim( $operators[$content_index] ) : '' );
                foreach ( $this->resolve_fields_by_type( $form_id, $type, $field_match ) as $fid ) {
                    $filter = [
                        'key' => $fid,
                    ];
                    if ( !empty( $operator ) && in_array( $operator, ['in', 'notin', 'not in'], true ) && str_contains( $value, 'array(' ) ) {
                        $json_string = str_replace( ['array(', ')', "'"], ['[', ']', '"'], $value );
                        $filter['value'] = array_map( fn( $v ) => GFCommon::replace_variables( $v, [], [] ), json_decode( $json_string, true ) ?? [] );
                    } else {
                        $filter['value'] = GFCommon::replace_variables( $value, [], [] );
                    }
                    if ( !empty( $operator ) && in_array( $operator, $supported_operators, true ) ) {
                        $filter['operator'] = str_replace( 'lt', '<', str_replace( 'gt', '>', $operator ) );
                    }
                    $search_criteria['field_filters'][] = $filter;
                }
            }
        }
        $sorting = [
            'key'        => sanitize_text_field( $atts['sort_key'] ),
            'direction'  => ( in_array( strtoupper( $atts['sort_direction'] ), ['ASC', 'DESC', 'RAND'], true ) ? strtoupper( $atts['sort_direction'] ) : 'DESC' ),
            'is_numeric' => !(strtolower( $atts['sort_is_num'] ) === 'false') && $atts['sort_is_num'],
        ];
        $secondary_sort_key = sanitize_text_field( $atts['secondary_sort_key'] );
        $secondary_sort_direction = ( in_array( strtoupper( $atts['secondary_sort_direction'] ), ['ASC', 'DESC'], true ) ? strtoupper( $atts['secondary_sort_direction'] ) : 'DESC' );
        $primary_sort_date_field = $this->get_date_field_for_sort_key( $form_id, (string) $sorting['key'] );
        $primary_sort_is_date = $primary_sort_date_field || $this->is_entry_datetime_sort_key( (string) $sorting['key'] );
        $secondary_sort_date_field = $this->get_date_field_for_sort_key( $form_id, $secondary_sort_key );
        $secondary_sort_is_date = '' !== $secondary_sort_key && ($secondary_sort_date_field || $this->is_entry_datetime_sort_key( $secondary_sort_key ));
        $force_full_fetch_for_sort = 'RAND' !== $sorting['direction'] && ($primary_sort_is_date || '' !== $secondary_sort_key);
        $paging_offset = 0;
        $total_count = 0;
        if ( 'all' !== strtolower( $atts['limit'] ) ) {
            $original_limit = ( empty( $atts['limit'] ) ? 1 : (int) $atts['limit'] );
            if ( $force_full_fetch_for_sort ) {
                $atts['limit'] = 'all';
            }
        }
        if ( empty( $atts['limit'] ) ) {
            $page_size = 1;
        } elseif ( 'all' === strtolower( $atts['limit'] ) ) {
            $page_size = 25;
        } else {
            $page_size = min( intVal( $atts['limit'] ), 25 );
        }
        $paging = [
            'offset'    => $paging_offset,
            'page_size' => $page_size,
        ];
        $entries = GFAPI::get_entries(
            $form_id,
            $search_criteria,
            $sorting,
            $paging,
            $total_count
        );
        if ( 'all' === $atts['limit'] || intVal( $atts['limit'] ) > 25 ) {
            $count = count( $entries );
            while ( $total_count > $count ) {
                $paging['offset'] += 25;
                $new_entries = GFAPI::get_entries(
                    $form_id,
                    $search_criteria,
                    $sorting,
                    $paging,
                    $total_count
                );
                array_push( $entries, ...$new_entries );
                // $entries = array_merge( $entries, $new_entries );
                if ( is_numeric( $atts['limit'] ) && count( $entries ) > $atts['limit'] ) {
                    break;
                }
                $count = count( $entries );
            }
            if ( is_numeric( $atts['limit'] ) ) {
                $entries = array_slice( $entries, 0, intVal( $atts['limit'] ) );
            }
        }
        if ( empty( $entries ) ) {
            // If default contains multiple values, use the first one
            $default_values = array_map( 'trim', explode( '||', $atts['default'] ) );
            return wp_kses_post( $default_values[0] ?? '' );
        }
        if ( 'RAND' !== $sorting['direction'] && $primary_sort_is_date ) {
            usort( $entries, function ( $entry1, $entry2 ) use($sorting, $primary_sort_date_field) {
                return $this->compare_entries_by_sort_key(
                    $entry1,
                    $entry2,
                    (string) $sorting['key'],
                    (string) $sorting['direction'],
                    false,
                    $primary_sort_date_field
                );
            } );
        }
        if ( !empty( $secondary_sort_key ) && 'RAND' !== $sorting['direction'] ) {
            $grouped_entries = [];
            foreach ( $entries as $entry ) {
                $primary_key_value = $entry[$sorting['key']] ?? '';
                // Use the primary sort key as the group key
                $grouped_entries[$primary_key_value][] = $entry;
            }
            // Sort each group based on the secondary sort key
            foreach ( $grouped_entries as &$group ) {
                usort( $group, function ( $entry1, $entry2 ) use(
                    $secondary_sort_key,
                    $secondary_sort_direction,
                    $secondary_sort_is_date,
                    $secondary_sort_date_field
                ) {
                    return $this->compare_entries_by_sort_key(
                        $entry1,
                        $entry2,
                        $secondary_sort_key,
                        $secondary_sort_direction,
                        !$secondary_sort_is_date,
                        $secondary_sort_date_field
                    );
                } );
            }
            unset($group);
            // Clean up the reference variable to avoid potential bugs
            // Flatten groups back into a single array, retaining primary sort order
            $entries = [];
            foreach ( $grouped_entries as $group ) {
                $entries = array_merge( $entries, $group );
            }
        }
        if ( isset( $original_limit ) && $original_limit < count( $entries ) ) {
            $entries = array_slice( $entries, 0, $original_limit );
        }
        $results = [];
        $atts['display'] = $this->convert_curly_shortcodes( $atts['display'] );
        // Mask nested gravops_search shortcodes [gravops_search ...]...[/gravops_search]
        // Mask only the display attribute value inside nested gravops_search shortcodes
        $nested_gravops_search_map = [];
        $masked_display = $atts['display'];
        // Mask display attribute in [gravops_search ... display="..."]...[/gravops_search]
        $masked_display = preg_replace_callback( '/(\\[gravops_search[^\\]]*?\\sdisplay=("|\')(.*?)(\\2)[^\\]]*\\])/i', function ( $m ) use(&$nested_gravops_search_map) {
            $key = '__NESTED_GOSEARCH_DISPLAY_' . count( $nested_gravops_search_map ) . '__';
            $nested_gravops_search_map[$key] = $m[3];
            // Replace only the display value
            return str_replace( $m[3], $key, $m[0] );
        }, $masked_display );
        // Updated regex: only match curly-brace {id}, {gos:id}, {gos:id;default} and plain gos:id (not just numbers)
        $regex = '/{(gos:)?([^{};]+)(;([^{}]+))?}|\\bgos:([0-9]+)\\b/';
        preg_match_all(
            $regex,
            $masked_display,
            $matches,
            PREG_SET_ORDER
        );
        $display_ids = [];
        $tag_defaults = [];
        if ( empty( $matches ) ) {
            $display_ids = array_map( 'sanitize_text_field', explode( ',', $masked_display ) );
            $display_ids = array_map( 'trim', $display_ids );
        } else {
            foreach ( $matches as $match ) {
                // If curly-brace format, use those capture groups
                if ( isset( $match[2] ) && '' !== $match[2] ) {
                    $field_id = $match[2];
                    if ( !empty( $match[4] ) ) {
                        $tag_defaults[$field_id] = $match[4];
                    }
                    $display_ids[] = sanitize_text_field( $field_id );
                    // If plain gos:id format
                } elseif ( isset( $match[5] ) && '' !== $match[5] ) {
                    $field_id = $match[5];
                    $display_ids[] = sanitize_text_field( $field_id );
                }
            }
        }
        $display_ids = array_unique( $display_ids );
        $multi_input_present = false;
        // Parse default values
        $default_values = array_map( 'trim', explode( '||', $atts['default'] ) );
        $default_count = count( $default_values );
        foreach ( $entries as $entry ) {
            $entry_results = [];
            foreach ( $display_ids as $index => $display_id ) {
                if ( 'meta' === $display_id ) {
                    if ( !empty( $atts['separator'] ) ) {
                        $entry_results[$display_id] = implode( $atts['separator'], array_keys( $entry ) );
                    } else {
                        $entry_results[$display_id] = '<ul><li>' . implode( '</li><li>', array_keys( $entry ) ) . '</li></ul>';
                    }
                    continue;
                }
                if ( 'num_results' === $display_id ) {
                    $entry_results[$display_id] = '{num_results}';
                    continue;
                }
                $field = GFAPI::get_field( $entry['form_id'], $display_id );
                // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
                if ( $field && 'number' === $field->type ) {
                    $field_value = GFCommon::format_number(
                        $entry[$display_id],
                        $field['numberFormat'],
                        $entry['currency'],
                        true
                    );
                } elseif ( $field && 'date' === $field->type ) {
                    $field_value = GFCommon::date_display( $entry[$display_id], 'Y-m-d', $field->dateFormat );
                } elseif ( $field && $this->is_multi_input_field( $field ) && !str_contains( $display_id, '.' ) ) {
                    $multi_input_present = true;
                    $ids = array_column( $field['inputs'], 'id' );
                    $field_results = [];
                    foreach ( $ids as $id ) {
                        if ( !empty( $entry[$id] ) ) {
                            $field_results[] = $entry[$id];
                        }
                    }
                    $field_value = implode( ' ', $field_results );
                } else {
                    $field_value = $entry[$display_id] ?? '';
                    if ( '' === $field_value ) {
                        $temp = GFCommon::replace_variables( '{' . $display_id . '}', GFAPI::get_form( $entry['form_id'] ), $entry );
                        if ( '{' . $display_id . '}' !== $temp ) {
                            $field_value = $temp;
                        }
                    }
                }
                // Use default value if field value is empty
                if ( '' === $field_value || is_null( $field_value ) ) {
                    // Check if there's a tag-specific default value for this field
                    if ( isset( $tag_defaults[$display_id] ) ) {
                        $field_value = $tag_defaults[$display_id];
                    } elseif ( 1 === $default_count ) {
                        // Otherwise use the global default values
                        // If there's only one default value, use it for all display values
                        $field_value = $default_values[0];
                    } elseif ( $index < $default_count ) {
                        // If there are multiple default values, use the corresponding one
                        $field_value = $default_values[$index];
                    } else {
                        $field_value = '';
                    }
                }
                $entry_results[$display_id] = $field_value;
            }
            // We only need to filter if the default value is empty
            if ( '' === $atts['default'] || is_null( $atts['default'] ) ) {
                $entry_results = array_filter( $entry_results, fn( $value ) => '' !== $value && !is_null( $value ) );
            }
            if ( !empty( $matches ) ) {
                $display_format = $masked_display;
                foreach ( $display_ids as $index => $display_id ) {
                    if ( 'num_results' === $display_id ) {
                        continue;
                    }
                    $value = $entry_results[$display_id] ?? '';
                    // If the value is empty and this is the first placeholder, use tag-specific default if available
                    if ( !$value && 0 === $index ) {
                        if ( isset( $tag_defaults[$display_id] ) ) {
                            $value = $tag_defaults[$display_id];
                        } else {
                            $display_format = '';
                            break;
                        }
                    }
                    // Replace curly-brace formats first
                    $display_format = str_replace( '{gos:' . $display_id . '}', $value, $display_format );
                    $display_format = str_replace( '{' . $display_id . '}', $value, $display_format );
                    // Replace {gos:id;default-value} format
                    $pattern = '/{gos:' . preg_quote( $display_id, '/' ) . ';[^{}]+}/';
                    $display_format = preg_replace( $pattern, $value, $display_format );
                    $pattern = '/{' . preg_quote( $display_id, '/' ) . ';[^{}]+}/';
                    $display_format = preg_replace( $pattern, $value, $display_format );
                    // Replace plain gos:id only when not part of a larger word or attribute (not preceded/followed by [\w\.:])
                    $display_format = preg_replace( '/(?<![\\w\\.:])gos:' . preg_quote( $display_id, '/' ) . '(?![\\w\\.:])/', $value, $display_format );
                }
                // Restore masked display attributes in nested gravops_search
                if ( !empty( $nested_gravops_search_map ) ) {
                    $display_format = strtr( $display_format, $nested_gravops_search_map );
                }
                $result_text = $display_format;
                if ( $atts['link'] ) {
                    $result_text = '<a target="_blank" href="' . esc_url( admin_url( 'admin.php?page=gf_entries&view=entry&id=' . $entry['form_id'] . '&lid=' . $entry['id'] ) ) . '">' . $result_text . '</a>';
                }
                $results[] = $result_text;
            } else {
                $result_text = implode( ', ', $entry_results );
                if ( $atts['link'] ) {
                    $result_text = '<a target="_blank"  href="' . esc_url( admin_url( 'admin.php?page=gf_entries&view=entry&id=' . $entry['form_id'] . '&lid=' . $entry['id'] ) ) . '">' . $result_text . '</a>';
                }
                $results[] = $result_text;
            }
        }
        $results = array_map( 'trim', $results );
        $results = array_filter( $results, fn( $value ) => '' !== $value && !is_null( $value ) );
        if ( empty( $results ) ) {
            // If default contains multiple values, use the first one
            $default_values = array_map( 'trim', explode( '||', $atts['default'] ) );
            return wp_kses_post( $default_values[0] ?? '' );
        }
        if ( empty( $atts['separator'] ) ) {
            $separator = ( count( $display_ids ) > 1 || $multi_input_present ? '; ' : ', ' );
        } elseif ( strtolower( '__none__' ) === $atts['separator'] ) {
            $separator = '';
        } else {
            $separator = $atts['separator'];
        }
        // Process shortcodes first, then apply uniqueness to the final output
        $final_results = array_map( function ( $result ) use($allowed_tags) {
            return wp_kses( do_shortcode( $result ), $allowed_tags );
        }, $results );
        if ( $atts['unique'] ) {
            $final_results = array_unique( $final_results );
        }
        $final_results = array_map( function ( $result ) use($final_results) {
            $count = count( $final_results );
            $result = str_replace( '{gos:num_results}', $count, $result );
            return str_replace( '{num_results}', $count, $result );
        }, $final_results );
        return implode( $separator, $final_results );
    }

    /**
     * Compares two entries by sort key and direction with date-aware fallback.
     *
     * @param array      $entry1      First entry.
     * @param array      $entry2      Second entry.
     * @param string     $sort_key    Field ID, entry property, or meta key.
     * @param string     $direction   Sort direction (ASC or DESC).
     * @param bool       $is_numeric  Whether non-date values should be treated as numeric.
     * @param mixed|null $date_field  GF date field object when sorting by date field.
     *
     * @return int
     */
    private function compare_entries_by_sort_key(
        array $entry1,
        array $entry2,
        string $sort_key,
        string $direction,
        bool $is_numeric,
        $date_field = null
    ) : int {
        $is_date_sort = $date_field || $this->is_entry_datetime_sort_key( $sort_key );
        $value1 = $entry1[$sort_key] ?? '';
        $value2 = $entry2[$sort_key] ?? '';
        if ( $is_date_sort ) {
            $timestamp1 = $this->parse_date_sort_value( $value1, $date_field, $this->is_entry_datetime_sort_key( $sort_key ) );
            $timestamp2 = $this->parse_date_sort_value( $value2, $date_field, $this->is_entry_datetime_sort_key( $sort_key ) );
            if ( null === $timestamp1 && null === $timestamp2 ) {
                return 0;
            }
            if ( null === $timestamp1 ) {
                return 1;
            }
            if ( null === $timestamp2 ) {
                return -1;
            }
            $comparison = $timestamp1 <=> $timestamp2;
            return ( 'ASC' === strtoupper( $direction ) ? $comparison : -$comparison );
        }
        if ( $is_numeric && is_numeric( $value1 ) && is_numeric( $value2 ) ) {
            $comparison = (float) $value1 <=> (float) $value2;
            return ( 'ASC' === strtoupper( $direction ) ? $comparison : -$comparison );
        }
        $comparison = strcasecmp( (string) $value1, (string) $value2 );
        return ( 'ASC' === strtoupper( $direction ) ? $comparison : -$comparison );
    }

    /**
     * Parses a sort value into a unix timestamp for date-aware sorting.
     *
     * @param mixed      $value               Raw entry value.
     * @param mixed|null $date_field          GF date field object when available.
     * @param bool       $is_entry_datetime   True when sorting entry datetime properties.
     *
     * @return int|null
     */
    private function parse_date_sort_value( $value, $date_field = null, bool $is_entry_datetime = false ) : ?int {
        $value = trim( (string) $value );
        if ( '' === $value ) {
            return null;
        }
        if ( $is_entry_datetime ) {
            $timestamp = strtotime( $value );
            return ( false === $timestamp ? null : $timestamp );
        }
        $field_format = $this->get_date_field_php_format( $date_field );
        $formats = array_filter( [
            $field_format,
            'Y-m-d',
            'Y/m/d',
            'Y.m.d',
            'd/m/Y',
            'm/d/Y',
            'd-m-Y',
            'm-d-Y',
            'd.m.Y',
            'm.d.Y'
        ] );
        foreach ( $formats as $format ) {
            $parsed = DateTime::createFromFormat( '!' . $format, $value );
            if ( $parsed instanceof DateTime ) {
                return $parsed->getTimestamp();
            }
        }
        $timestamp = strtotime( $value );
        return ( false === $timestamp ? null : $timestamp );
    }

    /**
     * Returns the GF date field when the key points to a date field; otherwise null.
     *
     * @param array  $form_id   Form IDs.
     * @param string $sort_key  Sort key from shortcode attrs.
     *
     * @return mixed|null
     */
    private function get_date_field_for_sort_key( array $form_id, string $sort_key ) {
        if ( '' === $sort_key || !is_numeric( $sort_key ) || empty( $form_id ) ) {
            return null;
        }
        $field = GFAPI::get_field( (int) $form_id[0], $sort_key );
        if ( !$field ) {
            return null;
        }
        return ( 'date' === $this->get_field_type( $field ) ? $field : null );
    }

    /**
     * Resolves a GF field type from object/array field structures.
     *
     * @param mixed $field Field object/array.
     *
     * @return string
     */
    private function get_field_type( $field ) : string {
        if ( is_object( $field ) && isset( $field->type ) ) {
            return (string) $field->type;
        }
        if ( is_array( $field ) && isset( $field['type'] ) ) {
            return (string) $field['type'];
        }
        return '';
    }

    /**
     * Returns true for entry properties that store datetime values.
     *
     * @param string $sort_key Sort key.
     *
     * @return bool
     */
    private function is_entry_datetime_sort_key( string $sort_key ) : bool {
        return in_array( $sort_key, ['date_created', 'date_updated', 'payment_date'], true );
    }

    /**
     * Maps GF dateFormat values to PHP date formats.
     *
     * @param mixed|null $field GF field object/array.
     *
     * @return string
     */
    private function get_date_field_php_format( $field = null ) : string {
        $date_format = '';
        if ( is_object( $field ) && isset( $field->dateFormat ) ) {
            $date_format = (string) $field->dateFormat;
        } elseif ( is_array( $field ) && isset( $field['dateFormat'] ) ) {
            $date_format = (string) $field['dateFormat'];
        }
        $map = [
            'mdy'       => 'm/d/Y',
            'dmy'       => 'd/m/Y',
            'dmy_dash'  => 'd-m-Y',
            'dmy_dot'   => 'd.m.Y',
            'ymd_slash' => 'Y/m/d',
            'ymd_dash'  => 'Y-m-d',
            'ymd_dot'   => 'Y.m.d',
        ];
        return $map[$date_format] ?? 'Y-m-d';
    }

    /**
     * Determines if a given field is a multi-input field.
     *
     * @param mixed $field The field configuration array. Expected to contain 'type' and optionally 'inputType' keys.
     *
     * @return bool True if the field is a multi-input field, false otherwise.
     */
    private function is_multi_input_field( $field ) : bool {
        return 'name' === $field['type'] || 'address' === $field['type'] || 'checkbox' === $field['type'] || ('image_choice' === $field['type'] || 'multi_choice' === $field['type']) && 'checkbox' === $field['inputType'];
    }

    /**
     * Converts custom curly bracket shortcodes into standard WordPress-style shortcodes.
     *
     * Converts content with shortcodes in the format `{{shortcode attributes}}content{{/shortcode}}`
     * to `[shortcode attributes]content[/shortcode]`. Handles standalone shortcodes and unmatched closing tags.
     *
     * @param string $content The content containing curly bracket shortcodes.
     *
     * @return string The converted content with standard WordPress-style shortcodes.
     */
    private function convert_curly_shortcodes( $content ) {
        /* @var array<int, array{0: string, 1: int}> $open_match */
        while ( preg_match(
            '/\\{\\{(\\w+)\\b(.*?)\\}\\}/s',
            $content,
            $open_match,
            PREG_OFFSET_CAPTURE
        ) ) {
            $tag = $open_match[1][0];
            $attrs = $open_match[2][0];
            $start_pos = $open_match[0][1];
            $end_tag = '{{/' . $tag . '}}';
            $end_pos = strpos( $content, $end_tag, $start_pos );
            if ( false === $end_pos ) {
                break;
                // malformed shortcode
            }
            $open_len = strlen( $open_match[0][0] );
            $inner = substr( $content, $start_pos + $open_len, $end_pos - $start_pos - $open_len );
            $replacement = '[' . $tag . $attrs . ']' . $inner . '[/' . $tag . ']';
            $content = substr_replace(
                $content,
                $replacement,
                $start_pos,
                $end_pos + strlen( $end_tag ) - $start_pos
            );
        }
        // Handle standalone shortcodes like {{shortcode attr=...}} → [shortcode attr=...]
        $content = preg_replace_callback( '/\\{\\{(?!\\/)([^\\{\\}\\/]+?)\\s*\\}\\}/', fn( $m ) => '[' . $m[1] . ']', $content );
        // Handle unmatched closing tags {{/shortcode}} → [/shortcode]
        return preg_replace( '/\\{\\{\\/(\\w+)\\s*\\}\\}/', '[/$1]', $content );
    }

    /**
     * Returns total search usage count.
     *
     * @return int
     */
    private function get_usage_count() {
        return (int) get_option( "{$this->prefix}search_count", 0 );
    }

    /**
     * Increments the tracked search usage count.
     *
     * @return void
     */
    private function increment_usage_count() {
        update_option( "{$this->prefix}search_count", $this->get_usage_count() + 1 );
    }

    /**
     * Returns field IDs from the given forms whose label matches the target (case-insensitive).
     * With $field_match='first' returns one ID per form; 'all' returns every matching field across all forms.
     *
     * @param int[]  $form_ids    Form IDs (pass [0] to search all active forms).
     * @param string $label       Label to match.
     * @param string $field_match 'first' or 'all'.
     *
     * @return string[]
     */
    private function resolve_fields_by_label( array $form_ids, string $label, string $field_match ) : array {
        if ( [0] === $form_ids ) {
            $form_ids = array_column( GFAPI::get_forms(), 'id' );
        }
        $found = [];
        $target = strtolower( trim( $label ) );
        foreach ( $form_ids as $fid ) {
            $form = GFAPI::get_form( (int) $fid );
            if ( !$form || empty( $form['fields'] ) ) {
                continue;
            }
            foreach ( $form['fields'] as $field ) {
                $arr = ( is_array( $field ) ? $field : (array) $field );
                $field_label = strtolower( trim( $arr['label'] ?? $arr['adminLabel'] ?? '' ) );
                if ( $target === $field_label ) {
                    $id = (string) ($arr['id'] ?? '');
                    if ( '' !== $id && !in_array( $id, $found, true ) ) {
                        $found[] = $id;
                    }
                    if ( 'first' === $field_match ) {
                        break;
                    }
                }
            }
        }
        return $found;
    }

    /**
     * Returns field IDs from the given forms whose type matches the target (case-insensitive).
     * With $field_match='first' returns one ID per form; 'all' returns every matching field across all forms.
     *
     * @param int[]  $form_ids    Form IDs (pass [0] to search all active forms).
     * @param string $type        Field type to match.
     * @param string $field_match 'first' or 'all'.
     *
     * @return string[]
     */
    private function resolve_fields_by_type( array $form_ids, string $type, string $field_match ) : array {
        if ( [0] === $form_ids ) {
            $form_ids = array_column( GFAPI::get_forms(), 'id' );
        }
        $found = [];
        $target = strtolower( trim( $type ) );
        foreach ( $form_ids as $fid ) {
            $form = GFAPI::get_form( (int) $fid );
            if ( !$form || empty( $form['fields'] ) ) {
                continue;
            }
            foreach ( $form['fields'] as $field ) {
                $arr = ( is_array( $field ) ? $field : (array) $field );
                $field_type = strtolower( trim( $arr['type'] ?? '' ) );
                if ( $target === $field_type ) {
                    $id = (string) ($arr['id'] ?? '');
                    if ( '' !== $id && !in_array( $id, $found, true ) ) {
                        $found[] = $id;
                    }
                    if ( 'first' === $field_match ) {
                        break;
                    }
                }
            }
        }
        return $found;
    }

}
