<?PHP
#
#   FILE:  SearchParameterSet.php
#
#   Part of the ScoutLib application support library
#   Copyright 2015 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu
#

/**
* Set of parameters used to perform a search.
*/
class SearchParameterSet
{

    # ---- SETUP / CONFIGURATION  ---------------------------------------------
    /** @name Setup / Configuration  */ /*@(*/

    /**
    * Class constructor, used to create a new set or reload an existing
    * set from previously-constructed data.
    * @param string $Data Existing search parameter set data, previously
    *       retrieved with SearchParameterSet::Data().  (OPTIONAL)
    * @see SearchParameterSet::Data()
    */
    public function __construct($Data = NULL)
    {
        # if set data supplied
        if ($Data !== NULL)
        {
            # set internal values from data
            $this->LoadFromData($Data);
        }
    }

    /**
    * Class clone handler, implemented so that clones will be deep
    * copies rather than PHP5's default shallow copy.
    */
    public function __clone()
    {
        foreach ($this->Subgroups as &$Group)
        {
            $Group = clone $Group;
        }
    }

    /**
    * Register function used to retrieve a canonical value for a field.  This
    * This function should accept a single mixed parameter and return a canonical
    * integer value for the field.
    * @param callable $Func Function to call.
    * @throws InvalidArgumentException If function supplied that is not callable.
    */
    public static function SetCanonicalFieldFunction($Func)
    {
        if (is_callable($Func))
        {
            self::$CanonicalFieldFunction = $Func;
        }
        else
        {
            throw new InvalidArgumentException("Invalid function supplied.");
        }
    }

    /**
    * Register function used to retrieve a printable value for a field.
    * This function should accept a single mixed parameter and return a
    * human-readable name for the field.
    * @param callable $Func Function to call.
    * @throws InvalidArgumentException If function supplied that is not callable.
    */
    public static function SetPrintableFieldFunction($Func)
    {
        if (is_callable($Func))
        {
            self::$PrintableFieldFunction = $Func;
        }
        else
        {
            throw new InvalidArgumentException("Invalid function supplied.");
        }
    }

    /**
    * Get/set URL parameter prefix.
    */


    /*@)*/
    # ---- SET CONSTRUCTION ---------------------------------------------------
    /** @name Set Construction  */ /*@(*/

    /**
    * Add search parameter to set.  If a canonical field function is set,
    * the field to search can be anything accepted by that function, otherwise
    * the $Field argument must be a type usable as an array index (e.g. an
    * integer or a string).
    * @param mixed $SearchStrings String or array of strings to search for.
    * @param mixed $Field Field to search.  (OPTIONAL – defaults to
    *       keyword search if no field specified)
    * @see SearchParameterSet::SetCanonicalFieldFunction()
    */
    public function AddParameter($SearchStrings, $Field = NULL)
    {
        # normalize field value if supplied
        $Field = self::NormalizeField($Field);

        # make sure search strings are an array
        if (!is_array($SearchStrings))
                {  $SearchStrings = array($SearchStrings);  }

        # for each search string
        foreach ($SearchStrings as $String)
        {
            # if field specified
            if ($Field !== NULL)
            {
                # add strings to search values for field
                $this->SearchStrings[$Field][] = $String;
            }
            else
            {
                # add strings to keyword search values
                $this->KeywordSearchStrings[] = $String;
            }
        }
    }

    /**
    * Remove search parameter from set.
    * @param mixed $SearchStrings String or array of strings to match, or
    *       NULL to remove all entries that match the specified field.
    * @param mixed $Field Field to match.  (OPTIONAL - defaults to keyword
    *       search match if no field specified)
    * @see SearchParameterSet::SetCanonicalFieldFunction()
    */
    public function RemoveParameter($SearchStrings, $Field = NULL)
    {
        # normalize field value if supplied
        $Field = self::NormalizeField($Field);

        # if search strings specified
        if ($SearchStrings != NULL)
        {
            # make sure search strings are an array
            if (!is_array($SearchStrings))
                    {  $SearchStrings = array($SearchStrings);  }

            # for each search string
            foreach ($SearchStrings as $String)
            {
                # if field specified
                if ($Field !== NULL)
                {
                    # if there are search parameters for this field
                    if (isset($this->SearchStrings[$Field]))
                    {
                        # remove any matching search parameters
                        $NewSearchStrings = array();
                        foreach ($this->SearchStrings[$Field] as $Value)
                        {
                            if ($Value != $String)
                            {
                                $NewSearchStrings[] = $Value;
                            }
                        }
                        if (count($NewSearchStrings))
                        {
                            $this->SearchStrings[$Field] = $NewSearchStrings;
                        }
                        else
                        {
                            unset($this->SearchStrings[$Field]);
                        }
                    }
                }
                else
                {
                    # remove any matching keyword search parameters
                    $NewSearchStrings = array();
                    foreach ($this->KeywordSearchStrings as $Value)
                    {
                        if ($Value != $String)
                        {
                            $NewSearchStrings[] = $Value;
                        }
                    }
                    $this->KeywordSearchStrings = $NewSearchStrings;
                }
            }
        }
        else
        {
            # if field specified
            if ($Field !== NULL)
            {
                # clear any search strings for this field
                if (isset($this->SearchStrings[$Field]))
                {
                    unset($this->SearchStrings[$Field]);
                }
            }
            else
            {
                # clear all keyword search parameters
                $this->KeywordSearchStrings = array();
            }
        }

        # for each parameter subgroup
        $NewSubgroups = array();
        foreach ($this->Subgroups as $Group)
        {
            # remove parameter from subgroup
            $Group->RemoveParameter($SearchStrings, $Field);

            # if the subgroup is not empty
            if ($Group->ParameterCount())
            {
                # keep subgroup
                $NewSubgroups[] = $Group;
            }
        }
        $this->Subgroups = $NewSubgroups;
    }

    /**
    * Get/set logic for set.
    * @param string $NewValue New setting, either "AND" or "OR" or
    *       SearchEngine logic constants.  (OPTIONAL)
    * @return string Current logic setting..
    * @throws InvalidArgumentException If new setting is invalid.
    */
    public function Logic($NewValue = NULL)
    {
        # if new value supplied
        if ($NewValue !== NULL)
        {
            # normalize value
            $NormValue = ($NewValue == SearchEngine::LOGIC_OR)
                    ? "OR"
                    : (($NewValue == SearchEngine::LOGIC_AND)
                            ? "AND"
                            : strtoupper($NewValue));

            # error out if value appears invalid
            if (($NormValue !== "AND") && ($NormValue !== "OR"))
            {
                throw new InvalidArgumentException("New logic setting"
                        ." is invalid (".$NewValue.").");
            }

            # save new setting
            $this->Logic = $NormValue;
        }

        # return current logic setting to caller
        return $this->Logic;
    }

    /**
    * Get/set allowed item types.  By default there are no restrictions on
    * item types.
    * @param mixed $ItemTypes Allowed item type or array of item types.  To
    *       clear any restrictions, pass in NULL.  (OPTIONAL)
    * @return array Allowed item types, or NULL if no restrictions
    *       on item type are set.
    */
    public function ItemTypes($ItemTypes = FALSE)
    {
        if ($ItemTypes !== FALSE)
        {
            if (!is_array($ItemTypes))
            {
                $ItemTypes = array($ItemTypes);
            }
            $this->ItemTypes = $ItemTypes;
        }
        return $this->ItemTypes;
    }

    /**
    * Add subgroup of search parameters to set.
    * @param SearchParameterSet $Set Subgroup to add.
    */
    public function AddSet(SearchParameterSet $Set)
    {
        if ($Set->ParameterCount()==0)
        {
            throw new Exception("Attempt to add empty subgroup");
        }

        # add subgroup to privilege set
        $this->Subgroups[] = $Set;
    }


    /*@)*/
    # ---- DATA RETRIEVAL -----------------------------------------------------
    /** @name Data Retrieval  */ /*@(*/

    /**
    * Get number of search parameters in set, including those in subgroups.
    * @return int Parameter count.
    */
    public function ParameterCount()
    {
        $Count = count($this->KeywordSearchStrings);
        foreach ($this->SearchStrings as $Field => $Strings)
        {
            $Count += count($Strings);
        }
        foreach ($this->Subgroups as $Group)
        {
            $Count += $Group->ParameterCount();
        }
        return $Count;
    }

    /**
    * Get search strings in set.
    * @param bool $IncludeSubgroups If TRUE, include search strings from
    *       any parameter subgroups.  (OPTIONAL, defaults to FALSE)
    * @return array Array of arrays of search strings, with canonical field
    *       identifiers for the root index.
    */
    public function GetSearchStrings($IncludeSubgroups = FALSE)
    {
        $SearchStrings = $this->SearchStrings;
        if ($IncludeSubgroups)
        {
            foreach ($this->Subgroups as $Group)
            {
                $SubStrings = $Group->GetSearchStrings(TRUE);
                foreach ($SubStrings as $Field => $Strings)
                {
                    if (isset($SearchStrings[$Field]))
                    {
                        $SearchStrings[$Field] = array_merge(
                                $SearchStrings[$Field], $Strings);
                    }
                    else
                    {
                        $SearchStrings[$Field] = $Strings;
                    }
                }
            }
        }
        return $SearchStrings;
    }

    /**
    * Get search strings for specified field.
    * @param mixed $Field Field identifier.
    * @param bool $IncludeSubgroups If TRUE, search strings for subgroups
    *       will be returned as well.  (OPTIONAL, defaults to TRUE)
    * @return array Search strings.
    * @see SearchParameterSet::SetCanonicalFieldFunction()
    */
    public function GetSearchStringsForField($Field, $IncludeSubgroups = TRUE)
    {
        # normalize field value
        $Field = self::NormalizeField($Field);

        # start with our string values
        $Strings = isset($this->SearchStrings[$Field])
                ? $this->SearchStrings[$Field] : array();

        # if strings from subgroups should also be returned
        if ($IncludeSubgroups)
        {
            # for each subgroup
            foreach ($this->Subgroups as $Group)
            {
                # add any strings from that subgroup
                $Strings = array_merge($Strings,
                        $Group->GetSearchStringsForField($Field));
            }
        }

        # return all strings found to caller
        return $Strings;
    }

    /**
    * Get keyword search strings in set.
    * @return array Array of keyword search strings.
    */
    public function GetKeywordSearchStrings()
    {
        return $this->KeywordSearchStrings;
    }

    /**
    * Get parameter subgroups.
    * @return array Array of Parameter subgroup (SearchParameterSet) objects.
    */
    public function GetSubgroups()
    {
        return $this->Subgroups;
    }

    /**
    * Get fields used in search parameters (including subgroups).
    * @return array list of canonical field identifiers.
    */
    public function GetFields()
    {
        # retrieve our fields
        $Fields = array_keys($this->SearchStrings);

        # for each subgroup
        foreach ($this->Subgroups as $Group)
        {
            # add fields from subgroup to the list
            $Fields = array_merge($Fields, $Group->GetFields());
        }

        # filter out duplicates and sort to ensure consistency
        $Fields = array_unique($Fields);
        sort($Fields);

        # return list of field identifiers to caller
        return $Fields;
    }


    /*@)*/
    # ---- DATA TRANSLATION ---------------------------------------------------
    /** @name Data Translation  */ /*@(*/

    /**
    * Get/set search parameter set data, in the form of an opaque string.
    * This method can be used to retrieve an opaque string containing
    * set data, which can then be saved (e.g. to a database) and later used
    * to reload a search parameter set.  (Use instead of serialize() to avoid
    * future issues with internal class changes.)
    * @param string $NewValue New search parameter set data.  (OPTIONAL)
    * @return string Current search parameter set data (opaque value).
    * @throws InvalidArgumentException If incoming set data appears invalid.
    */
    public function Data($NewValue = NULL)
    {
        # if new data supplied
        if ($NewValue !== NULL)
        {
            # unpack set data and load
            $this->LoadFromData($NewValue);
        }

        # serialize current data and return to caller
        $Data = array();
        if ($this->Logic !== "AND") {  $Data["Logic"] = $this->Logic;  }
        if (count($this->SearchStrings))
                {  $Data["SearchStrings"] = $this->SearchStrings;  }
        if (count($this->KeywordSearchStrings))
        {
            $Data["KeywordSearchStrings"] = $this->KeywordSearchStrings;
        }
        if (count($this->Subgroups))
        {
            foreach ($this->Subgroups as $Subgroup)
            {
                $Data["Subgroups"][] = $Subgroup->Data();
            }
        }
        return serialize($Data);
    }

    /**
    * Get/set search parameter set, in the form of URL parameters.
    * @param string $NewValue New parameter set in the form of a URL
    *       parameter string or URL parameter array.  (OPTIONAL)
    * @return array URL parameter values, with parameter names for the index..
    */
    public function UrlParameters($NewValue = NULL)
    {
        # if new value supplied
        if ($NewValue !== NULL)
        {
            # set new parameters
            $this->SetFromUrlParameters($NewValue);
        }

        # get existing search parameters as URL parameters
        $Params = $this->GetAsUrlParameters();

        # sort parameters by parameter name to normalize result
        ksort($Params);

        # return parameters to caller
        return $Params;
    }

    /**
    * Get/set search parameter set, in the form of an URL parameter string.
    * @param string $NewValue New parameter set in the form of a URL
    *       parameter string or URL parameter array.  (OPTIONAL)
    * @return string URL parameter string.
    */
    public function UrlParameterString($NewValue = NULL)
    {
        # get/set parameters
        $Params = $this->UrlParameters($NewValue);

        # combine values into string
        $ParamString = "";
        $Separator = "";
        foreach ($Params as $Index => $Value)
        {
            $ParamString .= $Separator.$Index."=".urlencode($Value);
            $Separator = "&";
        }

        # return string to caller
        return $ParamString;
    }

    /**
    * Get text description of search parameter set.
    * @param bool $IncludeHtml Whether to include HTML tags for formatting.
    *       (OPTIONAL, defaults to TRUE)
    * @param bool $StartWithBreak Whether to start string with BR tag.
    *       (OPTIONAL, defaults to TRUE)
    * @param int $TruncateLongWordsTo Number of characters to truncate long
    *       words to (use 0 for no truncation).  (OPTIONAL, defaults to 0)
    * @param string $Indent For internal (recursive) use only.
    * @return string Text description of search parameters.
    */
    public function TextDescription($IncludeHtml = TRUE, $StartWithBreak = TRUE,
            $TruncateLongWordsTo = 0, $Indent = "")
    {
        # define list of phrases used to represent logical operators
        $OperatorPhrases = array(
                "=" => "is",
                "==" => "is",
                ">" => "is greater than",
                "<" => "is less than",
                ">=" => "is at least",
                "<=" => "is no more than",
                "!" => "is not",
                "!=" => "is not",
                "^" => "begins with",
                "$" => "ends with",
                );

        # set characters used to indicate literal strings
        $LiteralStart = $IncludeHtml ? "<i>" : "\"";
        $LiteralEnd = $IncludeHtml ? "</i>" : "\"";
        $LiteralBreak = $IncludeHtml ? "<br>\n" : "\n";
        $Indent .= $IncludeHtml ? "&nbsp;&nbsp;&nbsp;&nbsp;" : "  ";

        # for each keyword search string
        $Descriptions = array();
        foreach ($this->KeywordSearchStrings as $SearchString)
        {
            # escape search string if appropriate
            if ($IncludeHtml)
            {
                $SearchString = defaulthtmlentities($SearchString);
            }

            # add string to list of descriptions
            $Descriptions[] = $LiteralStart.$SearchString.$LiteralEnd;
        }

        # for each field with search strings
        foreach ($this->SearchStrings as $FieldId => $SearchStrings)
        {
            # retrieve field name
            $FieldName = call_user_func(self::$PrintableFieldFunction, $FieldId);

            # for each search string
            foreach ($SearchStrings as $SearchString)
            {
                # extract operator from search string
                $MatchResult = preg_match('/^([=><!^$]+)(.+)/',
                        $SearchString, $Matches);

                # determine operator phrase
                if (($MatchResult == 1) && isset($OperatorPhrases[$Matches[1]]))
                {
                    $OpPhrase = $OperatorPhrases[$Matches[1]];
                    $SearchString = $Matches[2];
                }
                else
                {
                    $OpPhrase = "contains";
                }

                # escape field name and search string if appropriate
                if ($IncludeHtml)
                {
                    $FieldName = defaulthtmlentities($FieldName);
                    $SearchString = defaulthtmlentities($SearchString);
                }

                # assemble field and operator and value into description
                $Descriptions[] = $FieldName." ".$OpPhrase." "
                        .$LiteralStart.$SearchString.$LiteralEnd;
            }
        }

        # for each subgroup
        foreach ($this->Subgroups as $Subgroup)
        {
            # retrieve description for subgroup
            if ($Subgroup->ParameterCount() == 1)
            {
                $Descriptions[] = $Subgroup->TextDescription($IncludeHtml,
                        $StartWithBreak, $TruncateLongWordsTo, $Indent);
            }
            elseif ($Subgroup->ParameterCount() > 1)
            {
                $Descriptions[] = "(".$Subgroup->TextDescription($IncludeHtml,
                        $StartWithBreak, $TruncateLongWordsTo, $Indent).")";
            }
        }

        # join descriptions with appropriate conjunction
        $Descrip = join($LiteralBreak.$Indent." ".strtolower($this->Logic)." ",
                $Descriptions);

        # if caller requested that long words be truncated
        if ($TruncateLongWordsTo > 4)
        {
            # break description into words
            $Words = explode(" ", $Descrip);

            # for each word
            $NewDescrip = "";
            foreach ($Words as $Word)
            {
                # if word is longer than specified length
                if (strlen(strip_tags($Word)) > $TruncateLongWordsTo)
                {
                    # truncate word and add ellipsis
                    $Word = NeatlyTruncateString($Word, $TruncateLongWordsTo - 3);
                }

                # add word to new description
                $NewDescrip .= " ".$Word;
            }

            # set description to new description
            $Descrip = $NewDescrip;
        }

        # return description to caller
        return $Descrip;
    }


    /*@)*/
    # ---- BACKWARD COMPATIBILITY ---------------------------------------------
    /** @name Backward Compatibility  */ /*@(*/

    /**
    * Retrieve search parameters in legacy array format.  This method is
    * provided for backward compatibility only, and its use is deprecated.
    * Searches conducted using the legacy format may not return identical
    * results because the legacy format does not support nested search groups.
    * @return array Parameters in legacy array format.
    * @see SearchEngine::GroupedSearch()
    */
    public function GetAsLegacyArray()
    {
        $Legacy = array();

        $Group = $this->ConvertToLegacyGroup();
        if (count($Group))
        {
            $Legacy["MAIN"] = $Group;
        }

        # for each subgroup
        foreach ($this->Subgroups as $Subgroup)
        {
            # skip empty search groups
            if (count($Subgroup->SearchStrings)==0)
            {
                continue;
            }

            $SubLegacy = $Subgroup->ConvertToLegacyGroup();

            # give an index based on the FieldId of the first
            # element in the SearchStrings
            $FieldId = call_user_func(
                self::$CanonicalFieldFunction,
                current(array_keys($SubLegacy["SearchStrings"])) );

            # add groups from legacy array to our array
            if (!isset($Legacy[$FieldId]))
            {
                $Legacy[$FieldId] = $SubLegacy;
            }
            else
            {
                $Num = count($Legacy[$FieldId]);
                $Legacy[$FieldId."-".$Num] = $SubLegacy;
            }

            if (count($Subgroup->Subgroups))
            {
                throw new Exception(
                    "Attempt to convert SearchParameterSet containing nested subgroups "
                    ."to legacy format");
            }
        }

        # return array to caller
        return $Legacy;
    }

    /**
    * Set search parameters from legacy array format.
    * @param array $SearchGroups Legacy format search groups.
    */
    public function SetFromLegacyArray($SearchGroups)
    {
        # clear current settings
        $this->KeywordSearchStrings = array();
        $this->SearchStrings = array();
        $this->Subgroups = array();

        # iterate over legacy search groups
        foreach ($SearchGroups as $GroupId => $SearchGroup)
        {
            if ($GroupId == "MAIN")
            {
                # add terms from the main search group to ourself
                $this->LoadFromLegacyGroup($SearchGroup);
            }
            else
            {
                # create subgroups for other groups
                $Subgroup = new SearchParameterSet();
                $Subgroup->LoadFromLegacyGroup($SearchGroup);

                # add any non-empty groups
                if ($Subgroup->ParameterCount())
                {
                    $this->AddSet($Subgroup);
                }
            }
        }
    }

    /**
    * Set search parameters from legacy URL string.
    * @param string $ParameterString Legacy url string.
    */
    public function SetFromLegacyUrl($ParameterString)
    {
        # clear current settings
        $this->KeywordSearchStrings = array();
        $this->SearchStrings = array();
        $this->Subgroups = array();

        # extact array of parameters from passed string
        $GetVars = ParseQueryString($ParameterString);

        # iterate over the provided parameters
        foreach ($GetVars as $Key => $Val)
        {
            # if this param gives search information
            if (preg_match("/^([FGH])(K|[0-9]+)$/", $Key, $Matches))
            {
                # extract what kind of search it was which field
                $Type = $Matches[1];
                $FieldId = $Matches[2];

                # for 'contains' searches
                if ($Type == "F")
                {
                    # add this to our search strings
                    $this->AddParameter( $Val,
                            ($FieldId == "K" ? NULL : $FieldId) );
                }
                else
                {
                    # otherwise, create a subgroup for this parameter
                    $Subgroup = new SearchParameterSet();

                    # set logic based on the search type
                    $Subgroup->Logic($Type=="H" ? "AND" : "OR");

                    # extract the values and add them to a subgroup
                    $Values = explode("-", $Val);
                    $Subgroup->AddParameter(
                        self::TranslateLegacySearchValues($FieldId, $Values),
                        $FieldId );

                    # if subgroup was non-empty, slurp it up
                    if ($Subgroup->ParameterCount())
                    {
                        $this->AddSet($Subgroup);
                    }
                }
            }
        }
    }

    /**
    * Determine if a URL uses legacy format.
    * @param string $ParameterString Url parameter string
    * @return bool True for legacy URLs, false otherwise
    */
    public static function IsLegacyUrl($ParameterString)
    {
        $QueryVars = ParseQueryString($ParameterString);

        return (array_key_exists("Q", $QueryVars) &&
                $QueryVars["Q"] == "Y") ? TRUE : FALSE ;
    }

    /**
    * Convert legacy URL to the current URL format.
    * @param string $ParameterString Legacy url parameter string
    * @return string converted URL parameter string
    */
    public static function ConvertLegacyUrl($ParameterString)
    {
        $SearchParams = new SearchParameterSet();
        $SearchParams->SetFromLegacyUrl($ParameterString);

        return $SearchParams->UrlParameterString();
    }

    /**
    * Register function used to converrt values from the format used
    * in a Legacy URL string to the modern version.
    * @param callable $Func Function to convert legacy URL parameter
    *     values into modern versions
    */
    public static function SetLegacyUrlTranslationFunction($Func)
    {
        if (is_callable($Func))
        {
            self::$LegacyUrlTranslationFunction = $Func;
        }
        else
        {
            throw new InvalidArgumentException("Invalid function supplied.");
        }
    }

    /**
    * Translate legacy search values to modern equivalents if possible.
    * @param mixed $Field Supplied field
    * @param mixed $Values Supplied values
    * @return array of translated values
    */
    public static function TranslateLegacySearchValues($Field, $Values)
    {
        if (($Field !== NULL) && isset(self::$LegacyUrlTranslationFunction))
        {
            $Values = call_user_func(self::$LegacyUrlTranslationFunction,
                    $Field, $Values);
        }
        return $Values;
    }

    /*@)*/
    # ---- PRIVATE INTERFACE -------------------------------------------------

    private $KeywordSearchStrings = array();
    private $ItemTypes = NULL;
    private $Logic = self::DEFAULT_LOGIC;
    private $SearchStrings = array();
    private $Subgroups = array();

    static private $CanonicalFieldFunction;
    static private $PrintableFieldFunction;
    static private $LegacyUrlTranslationFunction;
    static private $UrlParameterPrefix = "F";

    const DEFAULT_LOGIC = "AND";
    const URL_KEYWORDFREE_RANGE = "A-JL-Z";
    const URL_KEYWORD_INDICATOR = "K";
    const URL_LOGIC_INDICATOR = "00";

    /**
    * Load set from serialized data.
    * @param string $Serialized Set data.
    * @throws InvalidArgumentException If incoming set data appears invalid.
    */
    private function LoadFromData($Serialized)
    {
        # unpack new data
        $Data = unserialize($Serialized);
        if (!is_array($Data))
        {
            throw new InvalidArgumentException("Incoming set data"
                    ." appears invalid.");
        }

        # load logic
        $this->Logic = isset($Data["Logic"]) ? $Data["Logic"] : "AND";

        # load search strings
        $this->SearchStrings = isset($Data["SearchStrings"])
                ? $Data["SearchStrings"] : array();
        $this->KeywordSearchStrings = isset($Data["KeywordSearchStrings"])
                ? $Data["KeywordSearchStrings"] : array();

        # load any subgroups
        $this->Subgroups = array();
        if (isset($Data["Subgroups"]))
        {
            foreach ($Data["Subgroups"] as $SubgroupData)
            {
                $this->Subgroups[] = new SearchParameterSet($SubgroupData);
            }
        }
    }

    /**
    * Convert a SearchParameterSet to a legacy array subgroup.
    * @return legacy array subgroup
    */
    private function ConvertToLegacyGroup()
    {
        $Group = array();
        # for each set of search strings
        foreach ($this->SearchStrings as $Field => $Strings)
        {
            # get text name of field
            $FieldName = call_user_func(self::$PrintableFieldFunction, $Field);

            # add set to group
            $Group["SearchStrings"][$FieldName] = $Strings;
        }

        # for each keyword search string
        foreach ($this->KeywordSearchStrings as $String)
        {
            # add string to keyword entry in group
            $Group["SearchStrings"]["XXXKeywordXXX"][] = $String;
        }

        # if we had any search terms
        if (count($Group))
        {
            # smash single-value arrays to a scalar
            foreach ($Group["SearchStrings"] as &$Tgt)
            {
                if (count($Tgt) == 1)
                {
                    $Tgt = current($Tgt);
                }
            }

            # set logic for search group
            $Group["Logic"] = ($this->Logic == "OR")
                ? SearchEngine::LOGIC_OR : SearchEngine::LOGIC_AND;
        }

        return $Group;
    }

    /**
    * Load from legacy format search group.
    * @param array $Group Legacy search group
    */
    private function LoadFromLegacyGroup($Group)
    {
        # set logic appropriately
        $this->Logic(
            ($Group["Logic"] == SearchEngine::LOGIC_OR) ?
            "OR" : "AND" );

        # if this group had no search strings, we're done
        if (!isset($Group["SearchStrings"]))
        {
            return;
        }

        # otherwise, load the search strings
        foreach ($Group["SearchStrings"] as $Field => $Params)
        {
            # skip empty groups
            if (count($Params)==0)
            {
                continue;
            }

            $this->AddParameter( $Params,
                    ($Field == "XXXKeywordXXX" ? NULL : $Field) );

        }
    }

    /**
    * Get the search set parameter set as an URL parameter string.
    * @param string $SetPrefix Prefix to use after the URL parameter prefix.
    * @return array Array of URL parameters.
    */
    private function GetAsUrlParameters($SetPrefix = "")
    {
        # for each search string group in set
        $Params = array();
        foreach ($this->SearchStrings as $FieldId => $Values)
        {
            # get numeric version of field ID if not already numeric
            if (!is_numeric($FieldId))
            {
                $FieldId = call_user_func(self::$CanonicalFieldFunction, $FieldId);
            }

            # for each search string in group
            $ParamSuffix = "";
            foreach ($Values as $Value)
            {
                # check for too many search strings for this field
                if ($ParamSuffix == "Z")
                {
                    throw new Exception("Maximum search parameter complexity"
                            ." exceeded:  more than 26 search parameters for"
                            ." field ID ".$FieldId.".");
                }

                # add search string to URL
                $Params[self::$UrlParameterPrefix.$SetPrefix
                        .$FieldId.$ParamSuffix] = $Value;
                $ParamSuffix = ($ParamSuffix == "") ? "A"
                        : chr(ord($ParamSuffix) + 1);
            }
        }

        # for each keyword search string
        $ParamSuffix = "";
        foreach ($this->KeywordSearchStrings as $Value)
        {
            # check for too many keyword search strings
            if ($ParamSuffix == "Z")
            {
                throw new Exception("Maximum search parameter complexity"
                        ." exceeded:  more than 26 keyword search parameters.");
            }

            # add search string to URL
            $Params[self::$UrlParameterPrefix.$SetPrefix
                    .self::URL_KEYWORD_INDICATOR.$ParamSuffix] = $Value;
            $ParamSuffix = ($ParamSuffix == "") ? "A"
                    : chr(ord($ParamSuffix) + 1);
        }

        # add logic if not default
        if ($this->Logic != self::DEFAULT_LOGIC)
        {
            $Params[self::$UrlParameterPrefix.$SetPrefix
                    .self::URL_LOGIC_INDICATOR] = $this->Logic;
        }

        # for each search parameter subgroup
        $SetLetter = "A";
        foreach ($this->Subgroups as $Subgroup)
        {
            # check for too many subgroups
            if ($SetLetter == "Z")
            {
                throw new Exception("Maximum search parameter complexity"
                        ." exceeded:  more than 24 search parameter subgroups.");
            }

            # retrieve URL string for subgroup and add it to URL
            $Params = array_merge($Params, $Subgroup->GetAsUrlParameters(
                    $SetPrefix.$SetLetter));

            # move to next set letter
            $SetLetter = ($SetLetter == chr(ord(self::URL_KEYWORD_INDICATOR) - 1))
                    ? chr(ord(self::URL_KEYWORD_INDICATOR) + 1)
                    : chr(ord($SetLetter) + 1);
        }

        # return constructed URL parameter string to caller
        return $Params;
    }

    /**
    * Set search parameter from URL parameters in the same format as
    * produced by GetAsUrlParameters().
    * @param string $UrlParameters URL parameter string or array.
    * @see SearchParameterSet::GetAsUrlParameters()
    */
    private function SetFromUrlParameters($UrlParameters)
    {
        # if string was passed in
        if (is_string($UrlParameters))
        {
            # split string into parameter array
            $Params = explode("&", $UrlParameters);

            # pare down parameter array to search parameter elements
            #       and strip off search parameter prefix
            $NewUrlParameters = array();
            foreach ($Params as $Param)
            {
                if (strpos($Param, self::$UrlParameterPrefix) === 0)
                {
                    list($Index, $Value) = explode("=", $Param);
                    $NewUrlParameters[$Index] = urldecode($Value);
                }
            }
            $UrlParameters = $NewUrlParameters;
        }

        # for each search parameter
        foreach ($UrlParameters as $ParamName => $SearchString)
        {
            # strip off standard search parameter prefix
            $ParamName = substr($ParamName, strlen(self::$UrlParameterPrefix));

            # split parameter into component parts
            $SplitResult = preg_match("/^([".self::URL_KEYWORDFREE_RANGE."]*)"
                    ."([0-9".self::URL_KEYWORD_INDICATOR."]+)([A-Z]*)$/",
                    $ParamName, $Matches);

            # if split was successful
            if ($SplitResult === 1)
            {
                # pull components from split pieces
                $SetPrefix = $Matches[1];
                $FieldId = $Matches[2];
                $ParamSuffix = $Matches[3];

                # if set prefix indicates parameter is part of our set
                if ($SetPrefix == "")
                {
                    switch ($FieldId)
                    {
                        case self::URL_LOGIC_INDICATOR:
                            # set logic
                            $this->Logic($SearchString);
                            break;

                        case self::URL_KEYWORD_INDICATOR:
                            # add string to keyword searches
                            $this->KeywordSearchStrings[] = $SearchString;
                            break;

                        default:
                            # add string to searches for appropriate field
                            $this->SearchStrings[$FieldId][] = $SearchString;
                            break;
                    }
                }
                else
                {
                    # add parameter to array for subgroup
                    $SubgroupIndex = $SetPrefix[0];
                    $SubgroupPrefix = (strlen($SetPrefix) > 1)
                            ? substr($SetPrefix, 1) : "";
                    $SubgroupParamIndex = self::$UrlParameterPrefix
                            .$SubgroupPrefix.$FieldId.$ParamSuffix;
                    $SubgroupParameters[$SubgroupIndex][$SubgroupParamIndex]
                            = $SearchString;
                }
            }
        }

        # if subgroups were found
        if (isset($SubgroupParameters))
        {
            # for each identified subgroup
            foreach ($SubgroupParameters as $SubgroupIndex => $Parameters)
            {
                # create subgroup and set parameters
                $Subgroup = new SearchParameterSet();
                $Subgroup->SetFromUrlParameters($Parameters);

                # add subgroup to our set
                $this->Subgroups[] = $Subgroup;
            }
        }
    }

    /**
    * Normalize supplied field to canonical field identifier if possible.
    * @param mixed $Field Supplied field.
    * @return int Canonical field identifier.
    */
    private static function NormalizeField($Field)
    {
        if (($Field !== NULL) && isset(self::$CanonicalFieldFunction))
        {
            $Field = call_user_func(self::$CanonicalFieldFunction, $Field);
        }
        return $Field;
    }
}
