<?php
/**
 * Shortcode class
 *
 * provides core functionality for rendering a shortcode's output
 *
 * common functionality we will handle here:
 *  choosing a template
 *  capturing the output of the template
 *  loading the plugin settings
 *  defining the default shortcode attributes array
 *  setting up the shortcode attributes array
 *  maintaining loop pointers
 *  instantiating Field_Group and Field objects for the display loop
 *  converting dynamic value notation to the value it represents
 *  performing a field key replace on blocks of text for emails and user feedback
 * 
 * @package    WordPress
 * @subpackage Participants Database Plugin
 * @author     Roland Barker <webdesign@xnau.com>
 * @copyright  2015 xnau webdesign
 * @license    GPL2
 * @version    3.3
 * @link       http://xnau.com/wordpress-plugins/
 *
 */
if ( !defined( 'ABSPATH' ) )
  die;

abstract class PDb_Shortcode {

  /**
   * @var string name stem of the shortcode
   */
  public $module;

  /**
   * @var object the instance of the class for singleton pattern
   */
  public static $instance;

  /**
   *
   * @var string a namespacing prefix
   */
  public $prefix;

  /**
   * @var string holds the name of the template
   */
  protected $template_name;

  /**
   * @var string holds the template file path
   */
  protected $template;

  /**
   * @var string the current template version; 0 if no version found
   */
  protected $template_version;

  /**
   * @var string holds the output for the shortcode
   */
  protected $output = '';

  /**
   * @var array default values for standard shortcode attributes
   */
  protected $shortcode_defaults;

  /**
   * @var array holds the current shorcode attributes
   */
  public $shortcode_atts;

  /**
   * @var array a selected array of fields to display
   */
  public $display_columns = false;

  /**
   * holds the field groups array which will contain all the groups info and their fields
   *
   * this will be the main object the template iterates through
   * @var \PDb_Record_Item
   */
  private $record;
  
  /**
   * @var array of group data objects
   */
  public $groups = array();

  /**
   * an array of all the hidden fields in a record, name=>value pairs
   * 
   * @var array of defined hidden field names=>value pairs
   */
  public $hidden_fields = array();

  /**
   * holds the current record ID
   * @var int
   */
  public $participant_id;

  /**
   * the array of current record fields; false if the ID is invalid
   * @var array
   */
  public $participant_values;

  /**
   *
   * @var string|bool permalink to the page the form submits to
   */
  public $submission_page = false;

  /**
   * @var string holds any validation error html generated by the validation class
   */
  protected $error_html = '';

  /**
   * @var string holds the module wrap class name
   */
  public $wrap_class;

  /**
   * @var string the class name to apply to empty fields
   */
  const emptyclass = 'blank-field';

  /**
   * @var array groups to be displayed
   */
  public $display_groups;

  /**
   * @var \PDb_Field_Group_Item the current Field_Group object
   */
  public $group;

  /**
   * @var int the number of displayable groups in the record
   */
  public $group_count;

  /**
   * @var int group iteration pointer
   */
  public $current_group_pointer = 1;

  /**
   * @var PDb_Field_Item the current Field object
   */
  public $field;

  /**
   * @var int field iteration pointer
   */
  public $current_field_pointer = 1;

  /**
   * @var array all the records are held in this array
   */
  public $records;

  /**
   * @var int the number of records after filtering
   */
  public $num_records;

  /**
   * @var int record iteration pointer
   */
  public $current_record_pointer = 1;

  /**
   * @var array all field objects used by the shortcode
   * 
   * access to this property is by __get only now because we're are phasing this 
   * out in favor of directly accessing Participants_Db::$fields
   */
  private $fields;

  /**
   * @var int the instance index of the current object instance
   */
  public $instance_index;

  /**
   * instantiates the shortcode object
   *
   * @global WP_Post $post
   * @param array  $shortcode_atts              the raw parameters passed in from the shortcode
   * @param array  $subclass_shortcode_defaults additional shortcode attributes to use as defined
   *                                            in the instantiating subclass
   *
   */
  public function __construct( $shortcode_atts, $subclass_shortcode_defaults = array() )
  {
    // increment the index each time this class is instantiated
    Participants_Db::$instance_index++;

    $this->set_instance_index();

    // set the global shortcode flag and trigger the action on the first instantiation of this class
    $this->plugin_shortcode_action();

    if ( has_action( 'wp_enqueue_scripts', array('Participants_Db', 'include_assets') ) === false ) {
      /*
       *  if the assets have not been enqueued, do that now
       * 
       * this might be necssary if the shortcode was invoked in another context 
       * besides being in the content, where it would be detected
       * 
       */
      Participants_Db::include_assets();
    }

    $this->prefix = Participants_Db::$prefix;

    $module = isset( $subclass_shortcode_defaults['module'] ) ? $subclass_shortcode_defaults['module'] : 'unknown';

    global $post;

    $this->shortcode_defaults = array(
        'title' => '',
        'class' => '',
        'template' => 'default',
        'fields' => '',
        'groups' => '',
        'action' => '',
        'instance_index' => $this->instance_index,
        'target_instance' => ($module == 'search' ? '1' : $this->instance_index), // if no target instance is specified, assume it's the first instance
        'target_page' => '',
        'record_id' => false,
        'filtering' => 0, // this is set to '1' if we're coming here from an AJAX call
        'autocomplete' => 'off',
        'submit_button' => Participants_Db::plugin_setting( 'signup_button_text' ),
        'post_id' => is_object( $post ) ? $post->ID : '',
        'content' => '',
        'id' => 0,
    );

//    error_log(__METHOD__.' incoming shorcode atts:'.print_r($shortcode_atts,1));
    // set up the shortcode_atts property
    $this->_setup_shortcode_atts( $shortcode_atts, $subclass_shortcode_defaults );
    
//    error_log(__METHOD__.' shorcode atts:'.print_r($this->shortcode_atts,1));

    $this->module = $this->shortcode_atts['module'];

    // now only access the fields property via __get so we dont load it any more
    //$this->_setup_fields();

    /*
     * save the shotcode attributes to the session array
     * 
     * skip this if doing AJAX because it would just store the default values, not 
     * the actual values from the shortcode 
     */
    if ( $this->shortcode_atts['filtering'] != 1 ) {

      if ( filter_input( INPUT_GET, 'pdb-shortcode_clear', FILTER_DEFAULT, Participants_Db::string_sanitize() ) ) {
        Participants_Db::$session->clear( 'shortcode_atts' );
      }

      Participants_Db::$session->update( 'shortcode_atts', $this->shortcode_session() );
    }
    
    /**
     * @filter pdb-dynamic_wrap_class
     * @prarm string the current dynamic class names
     * @return string
     */
    $dynamic_class = Participants_Db::apply_filters( 'dynamic_wrap_class', Participants_Db::plugin_setting_is_true('scroll_to_error', false ) ? 'pdb-scroll-to-error' : '' );

    $this->wrap_class = $this->prefix . $this->module . ' ' . $this->prefix . 'instance-' . $this->instance_index . ' ' . $dynamic_class;

    $this->_set_display_columns();
    $this->_set_display_groups();

    $this->wrap_class = trim( $this->wrap_class ) . ( empty( $this->shortcode_atts['class'] ) ? '' : ' ' . trim( $this->shortcode_atts['class'] ) );
    // set the template to use
    $this->set_template( $this->shortcode_atts['template'] );
    
    // this is to enable the use of the display property in the style attribute #2936
    add_filter( 'safe_style_css', function( $styles ) {
      $styles[] = 'display';
      return $styles;
    } );

    /**
     * @action pdb-shortcode_set
     * @param object the current shortcode class object
     */
    do_action( Participants_Db::$prefix . 'shortcode_set', $this );
  }
  
  /**
   * provides the boolean value for a shortcode attribute
   * 
   * this allows for various different ways of stating the value of the attribute 
   * in the shortcode
   * 
   * @param string $attribute
   * @return bool
   */
  public function attribute_true( $attribute ) {
    $state = false;
    if ( ! isset( $this->shortcode_atts[$attribute] ) ) {
      return $state;
    }
    if ( is_bool( $this->shortcode_atts[$attribute] ) ) {
      return $this->shortcode_atts[$attribute];
    }
    switch ($this->shortcode_atts[$attribute]) {
      case 'true':
      case '1':
      case 'yes':
        $state = true;
        break;
    }
    return $state;
  }
  
  /**
   * sets the output property
   * 
   * @param string $content
   */
  public function set_output( $content )
  {
    $this->output = wp_kses_post($content);
  }

  /**
   * dumps the output of the template into the output property
   *
   */
  protected function _print_from_template()
  {
    if ( false === $this->template ) {

      $this->output = '<p class="alert alert-error">' . sprintf( _x( '<%1$s>The template %2$s was not found.</%1$s> Please make sure the name is correct and the template file is in the correct location.', 'message to show if the plugin cannot find the template', 'participants-database' ), 'strong', $this->template ) . '</p>';

      return false;
    }

    ob_start();

    if ( PDB_DEBUG && stripos( $this->template, 'bare' ) === false ) {
      echo '<!-- template: ' . esc_html( $this->template_basename( $this->template ) ) . ' -->';
    }

    /**
     * @filter pdb-before_include_shortcode_template
     * @param Object the current shortcode object
     */
    Participants_Db::do_action( 'before_include_shortcode_template', $this );

    // this will be included in the subclass context
    $this->_include_template();

    if ( PDB_DEBUG && stripos( $this->template, 'bare' ) === false ) {
      echo '<!-- end template: ' . esc_html( $this->template_basename( $this->template ) ) . ' -->';
    }
    
    /**
     * @filter pdb-after_include_shortcode_template
     * @param Object the current shortcode object
     */
    Participants_Db::do_action( 'after_include_shortcode_template', $this );

    /**
     * @filter 'pdb-{$module}_shortcode_output'
     * 
     * @param string content the shortcode output
     * 
     * all shortcode output is passed through the filter before printing
     */
    $this->output = apply_filters( Participants_Db::$prefix . $this->module . '_shortcode_output', $this->strip_linebreaks( ob_get_clean() ) );
  }

  /**
   * conditionally removes line breaks from a buffered output
   * 
   * @param string $input the buffer input
   * @return string processed string
   */
  protected function strip_linebreaks( $input )
  {

    if ( Participants_Db::plugin_setting_is_true( 'strip_linebreaks' ) ) {

      $input = str_replace( PHP_EOL, '', $input );
    }
    return $input;
  }

  /**
   * sets up the template
   *
   * sets the template properties of the object
   *
   * @param string $name the name stem of the specified template
   * 
   */
  protected function set_template( $name )
  {
    $this->template_name = $name;
    $this->_find_template();
  }

  /**
   * selects the template to use
   *
   * @return null
   */
  private function _find_template()
  {
    $this->template = \PDb_shortcodes\template::template_path( $this->template_name, $this->module );
    $this->template_version = \PDb_shortcodes\template::template_version( $this->template );
  }

  /**
   * includes the shortcode template
   *
   * this is a dummy function that must be defined in the subclass because the
   * template has to be included in the subclass context
   */
  abstract protected function _include_template();
  
  /**
   * tells if the current page load is getting a valid record
   * 
   * @return bool true if a record was located
   */
  public function record_found()
  {
    return $this->participant_id > 0 && ! empty( $this->participant_values );
  }

  /**
   * sets up the shortcode attributes array
   *
   * @param array $shortcode_atts raw parameters passed in from the shortcode
   * @param array $add_atts an array of subclass-specific attributes to add
   */
  private function _setup_shortcode_atts( $shortcode_atts, $add_atts )
  {
    $defaults = array_merge( $this->shortcode_defaults, $add_atts );

    $this->shortcode_atts = shortcode_atts( $defaults, $shortcode_atts, 'pdb_' . $defaults['module'] );
  }
  
  /**
   * outputs a "record not found" message
   *
   * the message is defined int he plugin settings
   */
  protected function _not_found()
  {

    $this->output = empty( Participants_Db::$plugin_options['no_record_error_message'] ) ? '' : '<p class="alert alert-error">' . Participants_Db::plugin_setting( 'no_record_error_message' ) . '</p>';
  }

  /**
   * collects any validation errors from the last submission
   *
   */
  protected function _get_validation_errors()
  {

    if ( is_object( Participants_Db::$validation_errors ) ) {

      $this->error_html = Participants_Db::$validation_errors->get_error_html();
    }
  }

  /**
   * prints the error messages html
   *
   * @param string $container wraps the whole error message element, must include
   *                          2 %s placeholders: first for a class name, then one for the content
   * @param string $wrap      wraps each error message, must have %s placeholders for the content.
   *
   */
  public function print_errors( $container = false, $wrap = false )
  {
    if ( is_object( Participants_Db::$validation_errors ) ) {

      if ( $container ) {
        Participants_Db::$validation_errors->set_error_html( $container, $wrap );
      }

      echo wp_kses( Participants_Db::$validation_errors->get_error_html(), Participants_Db::allowed_html('form') );
    }
  }

  /**
   * gets the current errors
   * 
   * @return array of PDb_Validation_Error_Message objects
   */
  public function get_errors()
  {

    if ( is_object( Participants_Db::$validation_errors ) ) {

      $errors = Participants_Db::$validation_errors->get_validation_errors();
      if ( !$this->_empty( $errors ) ) {
        return $errors;
      }
    }
    return false;
  }

  /*   * **************
   * ITERATION CONTROL

    /**
   * checks if there is still another group of fields to show
   *
   */

  public function have_groups()
  {

    return $this->current_group_pointer <= $this->group_count;
  }

  /**
   * gets the next group
   *
   * increments the group pointer
   *
   */
  public function the_group()
  {
    // the first time through, use current()
    if ( $this->current_group_pointer == 1 )
      $this->group = new PDb_Field_Group_Item( current( $this->groups ), $this->module );
    else
      $this->group = new PDb_Field_Group_Item( next( $this->groups ), $this->module );

    $this->reset_field_counter();

    $this->current_group_pointer++;
  }

  /**
   * checks if there is still another field to show
   *
   * @param object $group the current group out of the iterator
   */
  public function have_fields()
  {
    $field_count = is_object( $this->group ) ? $this->group->field_count : count( $this->display_columns );

    return $this->current_field_pointer <= $field_count;
  }

  /**
   * gets the next field; advances the count
   *
   */
  public function the_field()
  {
    // the first time through, use current()
    if ( $this->current_field_pointer == 1 ) {
      if ( is_object( $this->group ) ) {
        $this->field = current( $this->group->fields );
        $record_id = $this->participant_id;
      } else {
        $this->field = current( $this->record->fields );
        $record_id = $this->record->record_id;
      }
    } else {
      if ( is_object( $this->group ) ) {
        $this->field = next( $this->group->fields );
        $record_id = $this->participant_id;
      } else {
        $this->field = next( $this->record->fields );
        $record_id = $this->record->record_id;
      }
    }
    
    if ( ! $this->field->record_id ) {
      $this->field->set_record_id( $record_id );
    }
    
    $this->field->set_module( $this->module );

    /*
     * if pre-fill values for the signup form are present in the GET array, set them
     */
    $get_var_value = filter_input( INPUT_GET, $this->field->name(), FILTER_DEFAULT, Participants_Db::string_sanitize() );
    if ( in_array( $this->module, array('signup', 'retrieve') ) and ! empty( $get_var_value ) ) {
      $this->field->set_value( $get_var_value );
    }

    $this->current_field_pointer++;
  }

  /**
   * resets the field counter
   */
  protected function reset_field_counter()
  {
    $this->current_field_pointer = 1;
  }

  /**
   * gets the next record
   *
   * increments the record pointer
   *
   */
  public function the_record()
  {
    // the first time through, use current()
    if ( $this->current_record_pointer == 1 ) {

      $the_record = current( $this->records );
    } else {

      $the_record = next( $this->records );
    }

    $this->record = new PDb_Record_Item( (array) $the_record, key( $this->records ), $this->module );

    $this->reset_field_counter();

    $this->current_record_pointer++;
  }

  /**
   * sets up the template iteration object
   *
   * this takes all the fields that are going to be displayed and organizes them
   * under their group so we can easily run through them in the template
   */
  protected function _setup_iteration()
  {
    $this->_setup_hidden_fields();

    $groups = Participants_Db::get_groups();

    foreach ( $this->display_groups as $group_name ) {

      $group_fields = $this->_get_group_fields( $group_name );

      $this->groups[$group_name] = (object) $groups[$group_name];
      $this->groups[$group_name]->fields = new stdClass();

      $field_count = 0;
      $all_empty_fields = true;

      foreach ( $group_fields as $field ) {

        /* @var $field PDb_Field_Item */

        // set the current value of the field
        $this->_set_field_value( $field );

        /*
         * hidden fields are stored separately for modules that use them as
         * hidden input fields
         */
        /**
         * @filter pdb-add_field_to_iterator
         * 
         * determines whether a field should be added to the iterator, filter 
         * can be used to remove a field from the iterator at the last minute
         * 
         * @param bool default setting
         * @param PDb_Field_Item the current field
         * @param PDb_Shortcode current object
         * @return bool
         */
        if ( Participants_Db::apply_filters( 'add_field_to_iterator', $this->field_should_be_added( $field ), $field, $this ) ) {
          /*
           * add the field object to the record object
           */
          if ( in_array( $field->name(), $this->display_columns ) ) {
            $field_count++;
            if ( $field->has_value() ) {
              $all_empty_fields = false;
            }
            
            /**
             * @filter pdb-before_field_added_to_iterator
             * @param PDb_Field_Item $field
             */
            do_action( 'pdb-before_field_added_to_iterator', $field );
    
            $this->groups[$group_name]->fields->{$field->name()} = $field;
          }
        }
      }
      if ( $field_count === 0 ) {
        // remove the empty group from the iterator
        unset( $this->groups[$group_name] );
      } elseif ( $all_empty_fields ) {
        $this->groups[$group_name]->class[] = 'empty-field-group';
      }
    }

    // save the number of groups
    $this->group_count = count( $this->groups );
  }
  
  /**
   * determines if the field should be added to the iterator
   * 
   * @param PDb_Field_Item $field
   * @return bool true if the field is to be added
   */
  protected function field_should_be_added( $field )
  {  
    return !$field->is_hidden_field();
  }

  /**
   * sets up the hidden fields array
   * 
   * in this class, this simply adds all defined hidden fields
   * 
   * @return null
   */
  protected function _setup_hidden_fields()
  {
    foreach ( Participants_Db::field_defs() as $field ) {
      /* @var $field PDb_Form_Field_Def */
      if ( $field->is_hidden_field() ) {
        $field_item = new PDb_Field_Item( $field );
        $this->_set_field_value( $field_item );
        $this->hidden_fields[$field_item->name()] = $field_item->value();
      }
    }
  }

  /*
   * FIELD GROUPS
   */

  /**
   * gets the field attributes for all fields in a specified group
   * 
   * @var string $group the name of the group of fields to get
   * @return array of field objects
   */
  private function _get_group_fields( $group )
  {
    $group_fields = array();
    foreach ( $this->display_columns as $column ) {
      
      $field = Participants_Db::$fields[$column];
      /* @var $field PDb_Form_Field_Def */
      
      if ( $field->group() === $group ) {
        
        $group_fields[$field->name()] = new PDb_Field_Item( $field );
        
        $group_fields[$field->name()]->set_module( $this->module );
        
        $group_fields[$field->name()]->set_record_id( $this->participant_id );
      }
    }
    
    return $group_fields;
  }

  /**
   * sets up the display groups
   * 
   * first, attempts to get the list from the shortcode, then uses the defined as 
   * visible list from the database
   *
   * if the shortcode "groups" attribute is used, it overrides the global group 
   * visibility settings
   *
   * @global wpdb $wpdb
   * @return null
   */
  protected function _set_display_groups()
  {
    global $wpdb;
    $groups = array();
    if ( !empty( $this->shortcode_atts['fields'] ) ) {

      foreach ( $this->display_columns as $column ) {
        $groups[Participants_Db::$fields[$column]->group()] = true;
      }

      $groups = array_keys( $groups );
    } elseif ( !empty( $this->shortcode_atts['groups'] ) ) {

      /*
       * process the shortcode groups attribute and get the list of groups defined
       */
      $group_list = array();
      $groups_attribute = explode( ',', str_replace( array(' '), '', $this->shortcode_atts['groups'] ) );
      foreach ( $groups_attribute as $item ) {
        if ( Participants_Db::is_group( $item ) )
          $group_list[] = trim( $item );
      }
      if ( count( $group_list ) !== 0 ) {
        /*
         * get a list of all defined groups
         */
        $sql = 'SELECT g.name 
                  FROM ' . Participants_Db::$groups_table . ' g WHERE g.mode IN ("' . implode( '","', array_keys( PDb_Manage_Fields::group_display_modes() ) ) . '") ORDER BY FIELD( g.name, "' . implode( '","', $group_list ) . '")';

        $result = $wpdb->get_results( $sql, ARRAY_N );
        foreach ( $result as $group ) {
          if ( in_array( current( $group ), $group_list ) ) {
            $groups[] = current( $group );
          }
        }
      }
    }
    if ( count( $groups ) === 0 ) {

      $orderby = 'g.order ASC';
      
      switch ( $this->module ) {
        case 'signup':
          // get only signup-enabled fields from public groups
          $sql = 'SELECT DISTINCT g.name 
                  FROM ' . Participants_Db::$groups_table . ' g  
                  JOIN ' . Participants_Db::$fields_table . ' f ON f.group = g.name 
                  WHERE f.signup = "1" AND g.mode = "public" ORDER BY ' . $orderby;
          break;
        
        case 'record':
          // get field from public and private groups
          $sql = 'SELECT DISTINCT g.name 
                FROM ' . Participants_Db::$groups_table . ' g 
                WHERE g.mode IN ("public","private") ORDER BY ' . $orderby;
          break;
        
        case 'retrieve':
          // fields from all display groups are available here
          $sql = 'SELECT DISTINCT g.name 
                FROM ' . Participants_Db::$groups_table . ' g 
                WHERE g.mode IN ("public","private", "admin") ORDER BY ' . $orderby;
          break;
    
        default:
          
          $sql = 'SELECT g.name 
                FROM ' . Participants_Db::$groups_table . ' g
                WHERE 1=1 AND g.mode = "public" ORDER BY ' . $orderby;
      }
      
      $cachekey = md5($sql);
      
      $groups = wp_cache_get($cachekey);
      
      if ( ! $groups ) {
        
        $groups = [];

        $result = $wpdb->get_results( $sql, ARRAY_N );

        foreach ( $result as $group ) {
          $groups[] = current( $group );
        }
        
        wp_cache_set( $cachekey, $groups, '', Participants_Db::cache_expire() );
      }
    }
    
    $this->display_groups = empty($groups) ? array() : $groups;
  }

  /**
   * sets the field value of the passed-in field object
   * 
   * uses the default value if no stored value is present
   * 
   * as of version 1.5.5 we slightly changed how this works: formerly, the default 
   * value was only used in the record module if the "persistent" flag was set, now 
   * the default value is used anyway. Seems more intuitive to let the default value 
   * be used if it's set, and not require the persistent flag. The default value is 
   * always used in the signup module.
   *
   *
   * @param PDb_Field_Item|PDb_Form_Field_Def $field the current field object
   */
  protected function _set_field_value( $field )
  {
    if ( !is_a( $field, 'PDb_Field_Item' ) ) {
      $field = new PDb_Field_Item( $field );
    }
      
    /*
     * get the value from the record; if it is empty, use the default value if the 
     * "persistent" flag is set.
     */
    $record_value = isset( $this->participant_values[$field->name()] ) ? $this->participant_values[$field->name()] : '';
    $value = $record_value;

    // replace it with the submitted value if provided, escaping the input
    if ( in_array( $this->module, array('record', 'signup', 'retrieve') ) && isset( $_POST[$field->name()] ) && ! $field->is_templated_field() )
    {  
      $value = filter_input( INPUT_POST, $field->name(), FILTER_CALLBACK, array( 'options' => 'PDb_Shortcode::esc_submitted_value' ) );
      
      global $pdb_uploaded_files;
      
      // if the field is an upload, the new value has already been set in PDb_File_Uploads
      if ( isset( $pdb_uploaded_files[$field->name()] ) )
      {
        $value = $pdb_uploaded_files[$field->name()];
      }
    }

    /*
     * internal fields are rendered as read-only
     */
    if ( $field->is_internal_field() ) {
      $this->display_as_readonly( $field );
    }
    
    if ( $field->is_hidden_field() ) {
      
      if ( in_array( $this->module, array('signup', 'record', 'retrieve') ) ) {

        /**
         * use the dynamic value if no value has been set
         * 
         * @version 1.6.2.6 only set this if the value is empty or equal to the default
         */
        $dynamic_value = $field->is_dynamic_hidden_field() ? $field->dynamic_value() : $field->default_value();
        $value = $this->_empty( $record_value ) || $record_value === $field->default_value() ? $dynamic_value : $record_value;
        /*
         * add to the display columns if not already present so it will be processed 
         * in the form submission
         */
        $this->display_columns += array($field->name());
      } else {
        // show this one as a readonly field
        $this->display_as_readonly( $field );
      }
    }

    $field->set_value( $value );
  }

  /**
   * builds a validated array of selected fields
   * 
   * this looks for the 'field' attribute in the shortcode and if it finds it, goes 
   * through the list of selected fields and sets up an array of valid fields that 
   * can be used in a database query 
   */
  protected function _set_display_columns()
  {
    // if this has already been set, we're done
    if ( is_array( $this->display_columns ) )
      return;

    $this->display_columns = array();

    if ( ! empty( $this->shortcode_atts['fields'] ) ) {
      $this->display_columns = self::field_list( $this->shortcode_atts['fields'] );
    }

    /*
     * if the field list has not been defined in the shortcode, get it from the global settings
     */
    if ( count( $this->display_columns ) == 0 ) {
      $this->_set_shortcode_display_columns();
    }
  }

  /**
   * parses a field list into a validated array of fieldnames
   * 
   * @param string|array $list comma-separated list of names
   * @return array
   */
  public static function field_list( $list )
  {
    $field_list = array();
    $raw_list = is_array( $list ) ? $list : explode( ',', str_replace( array("'", '"', ' ', "\r", "\n"), '', $list ) );

    if ( is_array( $raw_list ) ) {

      foreach ( $raw_list as $column ) {

        if ( PDb_Form_Field_Def::is_field( $column ) ) { // formerly PDb_Form_Field_Def::is_main_field( $column ) #2659

          $field_list[$column] = $column; // prevent accidental duplicates from getting added twice
        }
      }
    }
    
    return array_values( $field_list );
  }

  /**
   * sets up the array of display columns
   * 
   * @global wpdb $wpdb
   */
  protected function _set_shortcode_display_columns()
  {

    if ( empty( $this->display_groups ) ) {
      $this->_set_display_groups();
    }

    $groups = 'field.group IN ("' . implode( '","', $this->display_groups ) . '")';

    global $wpdb;

    $where = '';
    switch ( $this->module ) {

      case 'signup':
        $where .= 'WHERE field.signup = 1 AND ' . $groups . ' AND field.form_element NOT IN (' . $this->suppressed_elements() . ')';
        break;

      case 'retrieve':
        $in = array( Participants_Db::plugin_setting( 'retrieve_link_identifier' ) );
        
        if ( Participants_Db::plugin_setting_is_true( 'retrieve_form_captcha' ) )
        {
          $in[] = self::get_captcha_fieldname();
        }
        $where .= 'WHERE field.name IN ("' . implode( '","', $in ) . '")';
        break;

      case 'record':
        $where .= 'WHERE ' . $groups . ' AND field.form_element NOT IN (' . $this->suppressed_elements() . ')';
        break;

      case 'list':
      default:
        $where .= 'WHERE ' . $groups . ' AND field.form_element NOT IN (' . $this->suppressed_elements() . ')';
    }

    $sql = '
      SELECT field.name
      FROM ' . Participants_Db::$fields_table . ' field
      JOIN ' . Participants_Db::$groups_table . ' fieldgroup ON field.group = fieldgroup.name 
      ' . $where . ' ORDER BY fieldgroup.order, field.order ASC';

    $this->display_columns = $wpdb->get_col( $sql );
  }
  
  /**
   * finds the name of the CAPTCHA field
   * 
   * @global wpdb $wpdb
   * @return string field name
   */
  public static function get_captcha_fieldname()
  {
    $cachekey = 'captcha_fieldname';
    
    $fieldname = wp_cache_get( $cachekey );
    
    if ( $fieldname === false )
    {
      global $wpdb;
      $fieldname = $wpdb->get_var( 'SELECT f.name FROM ' . Participants_Db::$fields_table . ' f WHERE f.form_element = "captcha" ORDER BY f.order ASC LIMIT 1' );
      
      if ( ! $fieldname )
      {
        $fieldname = '';
        Participants_Db::debug_log('No CAPTCHA field defined');
      }
      
      wp_cache_set( $cachekey, $fieldname, '', Participants_Db::cache_expire() );
    }
    
    return $fieldname;
  }

  /**
   * provides a list of suppressed form elements by module
   * 
   * @return string comma-separated quoted string for use in an sql query
   */
  private function suppressed_elements()
  {
    switch ( $this->module ) {
      case 'signup':
        $list = array('placeholder', 'hidden');
        break;
      case 'record':
        $list = array('captcha', 'placeholder', 'hidden');
        break;
      case 'list':
      default:
        $list = array('captcha', 'placeholder');
        break;
    }
    /**
     * @filter pdb-display_column_suppressed_form_elements
     * @param array of form elements to exclude from the display
     * @param PDb_Shortcode the shortcode instance
     * @return array of form elements to exclude
     */
    return '"' . implode( '","', Participants_Db::apply_filters( 'display_column_suppressed_form_elements', $list, $this ) ) . '"';
  }

  /**
   * gets the column and column order for participant listing
   * returns a sorted array, omitting any non-displyed columns
   *
   * @param string $set selects the set of columns to get:
   *                    admin or display (frontend)
   * @global wpdb $wpdb
   * @return array of column names, ordered and indexed by the set order
   */
  public static function get_list_display_columns( $set = 'admin_column' )
  {

    global $wpdb;

    $column_set = array();
    // enforce the default value
    $set = $set === 'display_column' ? $set : 'admin_column';

    $sql = '
      SELECT f.name, f.' . $set . '
      FROM ' . Participants_Db::$fields_table . ' f 
      WHERE f.' . $set . ' > 0 
      ORDER BY  f.order';

    $columns = $wpdb->get_results( $sql, ARRAY_A );

    if ( is_array( $columns ) && !empty( $columns ) ) {
      foreach ( $columns as $column ) {

        $column_set[$column[$set]] = $column['name'];
      }

      ksort( $column_set );
    }

    return $column_set;
  }

  /**
   * get a single column object
   * 
   * @param string $name Name of the field to get
   * @return object|bool the result set object or bool false
   */
  public static function get_column_atts( $name )
  {
    $result = clone Participants_Db::$fields[$name];
    return is_object( $result ) ? $result : false;
  }

  /**
   * escape a value from a form submission
   *
   * can handle both single values and arrays
   * 
   * @param string|array $value
   * @return sanitized value
   */
  public static function esc_submitted_value( $value )
  {
    switch (true)
    {
      case is_array( $value ):
        array_walk_recursive( $value, array(__CLASS__, '_esc_element') );
        return $value;
      
      case is_string( $value ):
        return self::_esc_value( $value );
      
      default:
        // only strings or arrays are allowed #3095
        return '';
    }
  }

  /**
   * escapes an array element
   * 
   * @param string $value the element value
   */
  public static function _esc_element( &$value )
  {
    $value = self::_esc_value( $value );
  }

  /**
   * escape a value from a form submission
   * 
   * @param string
   * 
   * @return the value, escaped
   */
  private static function _esc_value( $value )
  {
    return filter_var( trim( $value ), FILTER_DEFAULT, Participants_Db::string_sanitize() );
  }

  /**
   * temporarily sets a field to a read-only text line field 
   * 
   * @param PDb_Field_Item $field
   */
  protected function display_as_readonly( $field )
  {
    $field->make_readonly();
  }

  /**
   * parses the dynamic value key and obtains the corresponding dynamic value
   * 
   * returns empty string if no dynamic value is found or the value doesn't 
   * represent a dynamic value key
   *
   * @return string
   *
   */
  public function get_dynamic_value( $value )
  {
    return Participants_Db::get_dynamic_value( $value );
  }

  /**
   * prints the form open tag and all hidden fields
   * 
   * The incoming hidden fields are merged with the default fields
   * 
   * @param array $hidden array of hidden fields to print
   * @return null
   */
  protected function _print_form_head( $hidden = '' )
  {
    $uri_components = parse_url( $_SERVER['REQUEST_URI'] );

    /*
     * @ver 1.6.2.6
     * add filter 'pdb-{module}_form_action_attribute'
     */
    printf( 
            '<form method="post" enctype="multipart/form-data"  autocomplete="%s" action="%s" >', 
            $this->shortcode_atts['autocomplete'], 
            Participants_Db::apply_filters( $this->module . '_form_action_attribute', $_SERVER['REQUEST_URI'] )
    );
    
    
    $default_hidden_fields = [
        'action' => $this->module,
        'subsource' => Participants_Db::PLUGIN_NAME,
        'shortcode_page' => $uri_components['path'] . (isset( $uri_components['query'] ) ? '?' . $uri_components['query'] : ''),
        'thanks_page' => $this->submission_page,
        'instance_index' => $this->instance_index,
        'pdb_data_keys' => $this->_form_data_keys(),
        'session_hash' => Participants_Db::nonce( Participants_Db::$main_submission_nonce_key ),
    ];

    if ( Participants_Db::is_multipage_form() ) {
      $default_hidden_fields['previous_multipage'] = $default_hidden_fields['shortcode_page'];
    }

    $hidden = is_array( $hidden ) ? $hidden : array();

    $hidden_fields = $this->hidden_fields + $hidden + $default_hidden_fields;

    PDb_FormElement::print_hidden_fields( $hidden_fields );
  }

  /**
   * supplies a template file name and path from the content root
   * 
   * this is for labeling the template file used in the HTML comments
   * 
   * @return string the template filename with a partial path
   */
  public function template_basename()
  {
    if ( PDB_DEBUG > 0 && current_user_can( 'manage_options') ) {
      $path = $this->template;
    } else {
      $path = '';
      $paths = explode( '/', str_replace( '\\', '/', $this->template ) );
      for ( $i = 3; $i > 0; $i-- ) {
        $path = '/' . array_pop( $paths ) . $path;
      }
    }
    return ltrim( $path, '/' );
  }

  /**
   * supplies the fields value
   * 
   * @param string  $name of the property to get
   * @return mixed
   */
  public function __get( $name )
  {
    if ( $name === 'fields' ) {
      return Participants_Db::field_defs();
    }
    
    // backward compatibility #2769
    if ( $name === 'record' ) {
      
      if ( is_null( $this->record ) ) {
        
        return $this->groups;
      } else {
        return $this->record;
      }
      
    }
    return null;
  }

  /**
   * sets up the fields property, which contains all field objects
   * 
   */
  protected function _setup_fields()
  {
    $this->fields = array();

    foreach ( Participants_Db::field_defs() as $column ) {
      /* @var $column PDb_Form_Field_Def */
      $this->fields[$column->name()] = new PDb_Field_Item( (object) array(
                  'name' => $column->name(),
                  'module' => $this->module,
              ) );
    }
  }

  /**
   * prints the submit button
   *
   * @param string $class a classname for the submit button, defaults to 'button-primary'
   * @param string $button_value submit button text
   * 
   * @return null
   */
  public function print_submit_button( $class = 'button-primary', $button_value = '' )
  {

    $pattern = '<input class="%s pdb-submit" type="submit" value="%s" name="save" >';

    printf( $pattern, $class, $button_value );
  }

  /**
   * closes the form tag
   */
  protected function print_form_close()
  {

    echo '</form>';
  }

  /**
   * prints a "next" button for multi-page forms
   * 
   * this is simply an anchor to the thanks page
   * 
   * @return string
   */
  public function print_next_button()
  {
    if ( strlen( $this->submission_page ) > 0 ) {
      printf( '<a type="button" class="button button-secondary" href="%s" >%s</a>', $this->submission_page, __( 'next', 'participants-database' ) );
    }
  }

  /**
   * sets the form submission page
   */
  protected function _set_submission_page()
  {

    if ( empty( $this->submission_page ) ) {
      $this->submission_page = $_SERVER['REQUEST_URI'];
    }
  }

  /**
   * sets the current form status
   * 
   * this is used to determine the submission status of a form; primarily to determine 
   * if a multi-page form is in process
   * 
   * @param string $status the new status string or null
   */
  public function set_form_status( $status = 'normal' )
  {
    Participants_Db::$session->set( 'form_status', $status );
  }

  /**
   * gets the current form status
   * 
   * @return string the status string: normal, multipage, or complete
   */
  public function get_form_status()
  {
    return Participants_Db::$session->get( 'form_status', 'normal' );
  }

  /**
   * sets up the shortcode atts session save
   * 
   * @return array the shortcode atts session save
   */
  protected function shortcode_session()
  {
    return array(
        $this->shortcode_atts['post_id'] => array(
            $this->module => array(
                $this->instance_index => $this->shortcode_atts
            )
        )
    );
  }

  /**
   * clears the multipage form session values
   */
  public function clear_multipage_session()
  {
    foreach ( array('pdbid', 'form_status', 'previous_multipage') as $key ) {
      Participants_Db::$session->clear( $key );
    }
  }

  /**
   * sets up the pdb_data_keys value
   * 
   * the purpose of this value is to tell the submission processor which fields 
   * to process. This is a security measure so that trying to spoof the submission 
   * by adding extra fields, editing readonly fields or deleting fields in the 
   * browser HTML won't work.
   * 
   * readonly fields and hidden fields that have values set are not included in the 
   * set because they are not processed in this context
   * 
   * @return string the value for the pdb_data_keys field
   */
  protected function _form_data_keys()
  {
    $displayed = array();
    
    foreach ( $this->display_columns as $column ) {
      $field = Participants_Db::$fields[$column];
      /* @var $field PDb_Form_Field_Def */
      /**
       * @filter pdb-readonly_exempt_module
       * @param string name of the module which allows wirting readonly fields
       * @param PDb_Form_Field_Def  the current field
       */
      if (
              (!$field->is_hidden_field() || $field->form_element() === 'captcha' ) &&
              ( $this->module === Participants_Db::apply_filters( 'readonly_exempt_module', 'signup', $field ) || !$field->is_readonly() )
      ) {
        $displayed[] = $field->name();
      }
    }

    return implode( '.', PDb_Base::get_field_indices( array_unique( array_merge( $displayed, array_keys( $this->hidden_fields ) ) ) ) );
//    return PDb_Base::xcrypt(implode('.', PDb_Base::get_field_indices(array_unique(array_merge($displayed, array_keys($this->hidden_fields))))));
  }

  /**
   * provides an empty class designator
   *
   * @param PDb_Field_Item $field  object
   * @return string the class name
   */
  public function get_empty_class( $field = false )
  {
    if ( $field === false ) {
      $field = $this->field;
    }
    $emptyclass = strpos( $field->form_element(), 'image' ) !== false ? 'image-' . self::emptyclass : self::emptyclass;

    /**
     * @filter pdb-field_empty_class
     * @param string  the empty class name to use
     * @param PDb_Field_Item the current field
     * @return string the empty class to use
     */
    return Participants_Db::apply_filters( 'field_empty_class', ( $this->apply_empty_class( $field ) ? $emptyclass : '' ), $field );
  }
  
  /**
   * tells whether to apply the empty class to the element
   * 
   * @param PDb_Field_Item $field
   * @return bool true to apply the empty class
   */
  protected function apply_empty_class( $field )
  {
    return ! $field->has_content();
  }

  /**
   * tests a value for emptiness
   *
   * returns true for empty strings, array, objects or null value...everything else 
   * is considered not empty.
   *
   * @param mixed $value the value to test
   * @return bool
   */
  protected function _empty( $value )
  {

    if ( !isset( $value ) )
      return true;

    // if it is an array or object, collapse it
    if ( is_object( $value ) )
      $value = get_object_vars( $value );

    if ( is_array( $value ) )
      $value = implode( '', $value );

    return $value === '';
  }

  /**
   * triggers the shortcode present action the first time a plugin shortcode is instantiated
   * 
   */
  public function plugin_shortcode_action()
  {
    if ( !Participants_Db::$shortcode_present ) {
      Participants_Db::$shortcode_present = true;
      do_action( Participants_Db::$prefix . 'shortcode_present' );
    }
  }

  /**
   * sets the current index value
   * 
   * @param int $index used to set the index value, omit to use assigned index
   * 
   * @return int the assigned index value
   */
  protected function set_instance_index( $index = '' )
  {
    if ( empty( $this->instance_index ) )
      $this->instance_index = empty( $index ) ? Participants_Db::$instance_index : $index;
  }

}
