<?PHP
#
#   FILE:  SPTSearchEngine.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2011-2016 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu/cwis/
#

class SPTSearchEngine extends SearchEngine
{
    /**
    * Class constructor.
    */
    public function __construct()
    {
        # pass database handle and config values to real search engine object
        parent::__construct("Resources", "ResourceId", "SchemaId");

        # for each schema
        $Schemas = MetadataSchema::GetAllSchemas();
        foreach ($Schemas as $SchemaId => $Schema)
        {
            # for each field defined in schema
            $this->Schemas[$SchemaId] = new MetadataSchema($SchemaId);
            $Fields = $this->Schemas[$SchemaId]->GetFields();
            foreach ($Fields as $FieldId => $Field)
            {
                # save metadata field type
                $this->FieldTypes[$FieldId] = $Field->Type();

                # determine field type for searching
                switch ($Field->Type())
                {
                    case MetadataSchema::MDFTYPE_TEXT:
                    case MetadataSchema::MDFTYPE_PARAGRAPH:
                    case MetadataSchema::MDFTYPE_USER:
                    case MetadataSchema::MDFTYPE_TREE:
                    case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
                    case MetadataSchema::MDFTYPE_OPTION:
                    case MetadataSchema::MDFTYPE_IMAGE:
                    case MetadataSchema::MDFTYPE_FILE:
                    case MetadataSchema::MDFTYPE_URL:
                    case MetadataSchema::MDFTYPE_REFERENCE:
                        $FieldType = self::FIELDTYPE_TEXT;
                        break;

                    case MetadataSchema::MDFTYPE_NUMBER:
                    case MetadataSchema::MDFTYPE_FLAG:
                        $FieldType = self::FIELDTYPE_NUMERIC;
                        break;

                    case MetadataSchema::MDFTYPE_DATE:
                        $FieldType = self::FIELDTYPE_DATERANGE;
                        break;

                    case MetadataSchema::MDFTYPE_TIMESTAMP:
                        $FieldType = self::FIELDTYPE_DATE;
                        break;

                    case MetadataSchema::MDFTYPE_POINT:
                        $FieldType = NULL;
                        break;

                    default:
                        throw Exception("ERROR: unknown field type "
                                .$Field->Type());
                        break;
                }

                if ($FieldType !== NULL)
                {
                    # add field to search engine
                    $this->AddField($FieldId, $FieldType, $Field->SchemaId(),
                            $Field->SearchWeight(),
                            $Field->IncludeInKeywordSearch());
                }
            }
        }
    }

    /**
    * Overloaded version of method to retrieve text from DB.
    * @param int $ItemId ID of item to retrieve value for.
    * @param string $FieldId ID of field to retrieve value for.
    * @return mixed Text value or array of text values or NULL or empty array
    *       if no values available.
    */
    public function GetFieldContent($ItemId, $FieldId)
    {
        # get resource object
        $Resource = new Resource($ItemId);

        # if this is a reference field
        if ($this->FieldTypes[$FieldId] == MetadataSchema::MDFTYPE_REFERENCE)
        {
            # retrieve IDs of referenced items
            $ReferredItemIds = $Resource->Get($FieldId);

            # for each referred item
            $ReturnValue = array();
            foreach ($ReferredItemIds as $RefId)
            {
                # retrieve title value for item and add to returned values
                $RefResource = new Resource($RefId);
                $ReturnValue[] = $RefResource->GetMapped("Title");
            }

            # return referred item titles to caller
            return $ReturnValue;
        }
        else
        {
            # retrieve text (including variants) from resource object and return to caller
            return $Resource->Get($FieldId, FALSE, TRUE);
        }
    }

    /**
    * Perform phrase searching.
    * @param string $FieldId ID of field to search.
    * @param string $Phrase Phrase to look for.
    * @return array of matching ItemIds.
    */
    public function SearchFieldForPhrases($FieldId, $Phrase)
    {
        # normalize and escape search phrase for use in SQL query
        $SearchPhrase = strtolower(addslashes($Phrase));

        # query DB for matching list based on field type
        $Field = new MetadataField($FieldId);
        switch ($Field->Type())
        {
            case MetadataSchema::MDFTYPE_TEXT:
            case MetadataSchema::MDFTYPE_PARAGRAPH:
            case MetadataSchema::MDFTYPE_FILE:
            case MetadataSchema::MDFTYPE_URL:
                $QueryString = "SELECT DISTINCT ResourceId FROM Resources "
                        ."WHERE POSITION('".$SearchPhrase."'"
                            ." IN LOWER(`".$Field->DBFieldName()."`)) ";
                break;

            case MetadataSchema::MDFTYPE_IMAGE:
                $QueryString = "SELECT DISTINCT ResourceId FROM Resources "
                        ."WHERE POSITION('".$SearchPhrase."'"
                            ." IN LOWER(`".$Field->DBFieldName()."AltText`)) ";
                break;

            case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
                $NameTableSize = $this->DB->Query("SELECT COUNT(*) AS NameCount"
                        ." FROM ControlledNames", "NameCount");
                $QueryString = "SELECT DISTINCT ResourceNameInts.ResourceId "
                        ."FROM ResourceNameInts, ControlledNames "
                        ."WHERE POSITION('".$SearchPhrase."' IN LOWER(ControlledName)) "
                        ."AND ControlledNames.ControlledNameId"
                                ." = ResourceNameInts.ControlledNameId "
                        ."AND ControlledNames.FieldId = ".intval($FieldId);
                $SecondQueryString = "SELECT DISTINCT ResourceNameInts.ResourceId "
                        ."FROM ResourceNameInts, ControlledNames, VariantNames "
                        ."WHERE POSITION('".$SearchPhrase."' IN LOWER(VariantName)) "
                        ."AND VariantNames.ControlledNameId"
                                ." = ResourceNameInts.ControlledNameId "
                        ."AND ControlledNames.ControlledNameId"
                                ." = ResourceNameInts.ControlledNameId "
                        ."AND ControlledNames.FieldId = ".intval($FieldId);
                break;

            case MetadataSchema::MDFTYPE_OPTION:
                $QueryString = "SELECT DISTINCT ResourceNameInts.ResourceId "
                        ."FROM ResourceNameInts, ControlledNames "
                        ."WHERE POSITION('".$SearchPhrase."' IN LOWER(ControlledName)) "
                        ."AND ControlledNames.ControlledNameId"
                                ." = ResourceNameInts.ControlledNameId "
                        ."AND ControlledNames.FieldId = ".intval($FieldId);
                break;

            case MetadataSchema::MDFTYPE_TREE:
                $QueryString = "SELECT DISTINCT ResourceClassInts.ResourceId "
                        ."FROM ResourceClassInts, Classifications "
                        ."WHERE POSITION('".$SearchPhrase
                                ."' IN LOWER(ClassificationName)) "
                        ."AND Classifications.ClassificationId"
                                ." = ResourceClassInts.ClassificationId "
                        ."AND Classifications.FieldId = ".intval($FieldId);
                break;

            case MetadataSchema::MDFTYPE_USER:
                $UserId = $this->DB->Query("SELECT UserId FROM APUsers "
                        ."WHERE POSITION('".$SearchPhrase
                                ."' IN LOWER(UserName)) "
                        ."OR POSITION('".$SearchPhrase
                                ."' IN LOWER(RealName))", "UserId");
                if ($UserId != NULL)
                {
                    $QueryString = "SELECT DISTINCT ResourceId FROM ResourceUserInts "
                                     ."WHERE UserId = ".$UserId
                                     ." AND FieldId = ".intval($FieldId);
                }
                break;

            case MetadataSchema::MDFTYPE_NUMBER:
                if ($SearchPhrase > 0)
                {
                    $QueryString = "SELECT DISTINCT ResourceId FROM Resources "
                            ."WHERE `".$Field->DBFieldName()
                                    ."` = ".(int)$SearchPhrase;
                }
                break;

            case MetadataSchema::MDFTYPE_FLAG:
            case MetadataSchema::MDFTYPE_DATE:
            case MetadataSchema::MDFTYPE_TIMESTAMP:
            case MetadataSchema::MDFTYPE_REFERENCE:
                # (these types not yet handled by search engine for phrases)
                break;
        }

        # build match list based on results returned from DB
        if (isset($QueryString))
        {
            $this->DMsg(7, "Performing phrase search query (<i>".$QueryString."</i>)");
            if ($this->DebugLevel > 9) {  $StartTime = microtime(TRUE);  }
            $this->DB->Query($QueryString);
            if ($this->DebugLevel > 9)
            {
                $EndTime = microtime(TRUE);
                if (($StartTime - $EndTime) > 0.1)
                {
                    printf("SE:  Query took %.2f seconds<br>\n",
                            ($EndTime - $StartTime));
                }
            }
            $MatchList = $this->DB->FetchColumn("ResourceId");
            if (isset($SecondQueryString))
            {
                $this->DMsg(7, "Performing second phrase search query"
                        ." (<i>".$SecondQueryString."</i>)");
                if ($this->DebugLevel > 9) {  $StartTime = microtime(TRUE);  }
                $this->DB->Query($SecondQueryString);
                if ($this->DebugLevel > 9)
                {
                    $EndTime = microtime(TRUE);
                    if (($StartTime - $EndTime) > 0.1)
                    {
                        printf("SE:  query took %.2f seconds<br>\n",
                                ($EndTime - $StartTime));
                    }
                }
                $MatchList = $MatchList + $this->DB->FetchColumn("ResourceId");
            }
        }
        else
        {
            $MatchList = array();
        }

        # return list of matching resources to caller
        return $MatchList;
    }

    /**
    * Perform comparison searches.
    * @param array $FieldIds IDs of fields to search.
    * @param array $Operators Search operators.
    * @param array $Values Target values.
    * @param string $Logic Search logic ("AND" or "OR").
    * @return array of ItemIds that matched.
    */
    public function SearchFieldsForComparisonMatches(
            $FieldIds, $Operators, $Values, $Logic)
    {
        # use SQL keyword appropriate to current search logic for combining operations
        $CombineWord = ($Logic == "AND") ? " AND " : " OR ";

        # for each comparison
        foreach ($FieldIds as $Index => $FieldId)
        {
            # skip field if it is not valid
            if (!MetadataSchema::FieldExistsInAnySchema($FieldId) )
            {
                continue;
            }

            $Field = new MetadataField($FieldId);
            $Operator = $Operators[$Index];
            $Value = $Values[$Index];

            $ProcessingType = ($Operator{0} == "@")
                    ? "Modification Comparison" : $Field->Type();
            switch ($ProcessingType)
            {
                case MetadataSchema::MDFTYPE_TEXT:
                case MetadataSchema::MDFTYPE_PARAGRAPH:
                case MetadataSchema::MDFTYPE_NUMBER:
                case MetadataSchema::MDFTYPE_FLAG:
                case MetadataSchema::MDFTYPE_URL:
                    $QueryConditions["Resources"][] = $this->GetTextComparisonSql(
                            $Field->DBFieldName(), $Operator, $Value);
                    break;

                case MetadataSchema::MDFTYPE_USER:
                    $User = new CWUser($Value);
                    $QueryConditions["ResourceUserInts".$FieldId][] =
                        "(UserId = ".$User->Id()." AND FieldId = "
                                .intval($FieldId).")";
                    break;

                case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
                    $QueryIndex = "ResourceNameInts".$FieldId;
                    if (!isset($Queries[$QueryIndex]["A"]))
                    {
                        $Queries[$QueryIndex]["A"] =
                                "SELECT DISTINCT ResourceId"
                                ." FROM ResourceNameInts, ControlledNames "
                                ." WHERE ControlledNames.FieldId = "
                                        .intval($FieldId)
                                ." AND ( ";
                        $CloseQuery[$QueryIndex]["A"] = TRUE;
                        $ComparisonCount[$QueryIndex]["A"] = 1;
                        $ComparisonCountField[$QueryIndex]["A"] = "ControlledName";
                    }
                    else
                    {
                        $Queries[$QueryIndex]["A"] .= " OR ";
                        $ComparisonCount[$QueryIndex]["A"]++;
                    }
                    $Queries[$QueryIndex]["A"] .=
                            "(ResourceNameInts.ControlledNameId"
                                    ." = ControlledNames.ControlledNameId"
                            ." AND ".$this->GetTextComparisonSql(
                                            "ControlledName", $Operator, $Value)
                                    .")";
                    if (!isset($Queries[$QueryIndex]["B"]))
                    {
                        $Queries[$QueryIndex]["B"] =
                                "SELECT DISTINCT ResourceId"
                                . " FROM ResourceNameInts, ControlledNames,"
                                        ." VariantNames "
                                ." WHERE ControlledNames.FieldId = "
                                        .intval($FieldId)
                                ." AND ( ";
                        $CloseQuery[$QueryIndex]["B"] = TRUE;
                        $ComparisonCount[$QueryIndex]["B"] = 1;
                        $ComparisonCountField[$QueryIndex]["B"] = "ControlledName";
                    }
                    else
                    {
                        $Queries[$QueryIndex]["B"] .= " OR ";
                        $ComparisonCount[$QueryIndex]["B"]++;
                    }
                    $Queries[$QueryIndex]["B"] .=
                            "(ResourceNameInts.ControlledNameId"
                                    ." = ControlledNames.ControlledNameId"
                            ." AND ResourceNameInts.ControlledNameId"
                                    ." = VariantNames.ControlledNameId"
                            ." AND ".$this->GetTextComparisonSql(
                                            "VariantName", $Operator, $Value)
                                    .")";
                    break;

                case MetadataSchema::MDFTYPE_OPTION:
                    $QueryIndex = "ResourceNameInts".$FieldId;
                    if (!isset($Queries[$QueryIndex]))
                    {
                        $Queries[$QueryIndex] =
                                "SELECT DISTINCT ResourceId"
                                ." FROM ResourceNameInts, ControlledNames "
                                ." WHERE ControlledNames.FieldId = "
                                        .intval($FieldId)
                                ." AND ( ";
                        $CloseQuery[$QueryIndex] = TRUE;
                        $ComparisonCount[$QueryIndex] = 1;
                        $ComparisonCountField[$QueryIndex] = "ControlledName";
                    }
                    else
                    {
                        $Queries[$QueryIndex] .= " OR ";
                        $ComparisonCount[$QueryIndex]++;
                    }
                    $Queries[$QueryIndex] .=
                            "(ResourceNameInts.ControlledNameId"
                                    ." = ControlledNames.ControlledNameId"
                            ." AND ".$this->GetTextComparisonSql(
                                            "ControlledName", $Operator, $Value)
                                    .")";
                    break;

                case MetadataSchema::MDFTYPE_TREE:
                    $QueryIndex = "ResourceClassInts".$FieldId;
                    if (!isset($Queries[$QueryIndex]))
                    {
                        $Queries[$QueryIndex] = "SELECT DISTINCT ResourceId"
                                ." FROM ResourceClassInts, Classifications"
                                ." WHERE ResourceClassInts.ClassificationId"
                                        ." = Classifications.ClassificationId"
                                ." AND Classifications.FieldId"
                                        ." = ".intval($FieldId)." AND ( ";
                        $CloseQuery[$QueryIndex] = TRUE;
                        $ComparisonCount[$QueryIndex] = 1;
                        $ComparisonCountField[$QueryIndex] = "ClassificationName";
                    }
                    else
                    {
                        $Queries[$QueryIndex] .= " OR ";
                        $ComparisonCount[$QueryIndex]++;
                    }
                    $Queries[$QueryIndex] .= $this->GetTextComparisonSql(
                            "ClassificationName", $Operator, $Value);
                    break;

                case MetadataSchema::MDFTYPE_TIMESTAMP:
                    # if we have an SQL conditional
                    $TimestampConditional = $this->GetTimeComparisonSql(
                            $Field, $Operator, $Value);
                    if ($TimestampConditional)
                    {
                        # add conditional
                        $QueryConditions["Resources"][] = $TimestampConditional;
                    }
                    break;

                case MetadataSchema::MDFTYPE_DATE:
                    $Date = new Date($Value);
                    if ($Date->Precision())
                    {
                        $QueryConditions["Resources"][] =
                                " ( ".$Date->SqlCondition(
                                $Field->DBFieldName()."Begin",
                                $Field->DBFieldName()."End", $Operator)." ) ";
                    }
                    break;

                case MetadataSchema::MDFTYPE_REFERENCE:
                    $QueryIndex = "ReferenceInts".$FieldId;
                    if (!isset($Queries[$QueryIndex]))
                    {
                        $Queries[$QueryIndex] =
                                "SELECT DISTINCT RI.SrcResourceId AS ResourceId"
                                ." FROM ReferenceInts AS RI, Resources AS R "
                                ." WHERE RI.FieldId = ".intval($FieldId)
                                ." AND (";
                        $CloseQuery[$QueryIndex] = TRUE;
                    }
                    else
                    {
                        $Queries[$QueryIndex] .= $CombineWord;
                    }

                    # iterate over all the schemas this field can reference,
                    # gluing together an array of subqueries for the mapped
                    # title field of each as we go
                    $SchemaIds = $Field->ReferenceableSchemaIds();

                    # if no referenceable schemas configured, fall back to
                    # searching all schemas
                    if (count($SchemaIds)==0)
                    {
                        $SchemaIds = MetadataSchema::GetAllSchemaIds();
                    }

                    $Subqueries = array();
                    foreach ($SchemaIds as $SchemaId)
                    {
                        $Schema = new MetadataSchema($SchemaId);
                        $MappedTitle = $Schema->GetFieldByMappedName("Title");

                        $Subqueries[]= $this->GetTextComparisonSql(
                            $MappedTitle->DBFieldName(), $Operator, $Value, "R");
                    }

                    # OR together all the subqueries, add it to the query
                    # for our field
                    $Queries[$QueryIndex] .=
                            "((".implode(" OR ", $Subqueries).")"
                            ." AND R.ResourceId = RI.DstResourceId)";
                    break;

                case "Modification Comparison":
                    # if we have an SQL conditional
                    $TimestampConditional = $this->GetTimeComparisonSql(
                            $Field, $Operator, $Value);
                    if ($TimestampConditional)
                    {
                        # add conditional
                        $QueryConditions["ResourceFieldTimestamps"][] =
                                $TimestampConditional;
                    }
                    break;

                default:
                    throw new Exception("Search of unknown field type ("
                            .$ProcessingType.").");
                    break;
            }
        }

        # if query conditions found
        if (isset($QueryConditions))
        {
            # for each query condition group
            foreach ($QueryConditions as $TargetTable => $Conditions)
            {
                # add entry with conditions to query list
                if (isset($Queries[$TargetTable]))
                {
                    $Queries[$TargetTable] .= $CombineWord
                            .implode($CombineWord, $Conditions);
                }
                else
                {
                    $Queries[$TargetTable] = "SELECT DISTINCT ResourceId"
                            ." FROM ".$TargetTable." WHERE "
                            .implode($CombineWord, $Conditions);
                }
            }
        }

        # if queries found
        if (isset($Queries))
        {
            # for each assembled query
            foreach ($Queries as $QueryIndex => $Query)
            {
                # if query has multiple parts
                if (is_array($Query))
                {
                    # for each part of query
                    $ResourceIds = array();
                    foreach ($Query as $PartIndex => $PartQuery)
                    {
                        # add closing paren if query was flagged to be closed
                        if (isset($CloseQuery[$QueryIndex][$PartIndex]))
                        {
                            $PartQuery .= ") ";
                            if (($Logic == "AND")
                                    && ($ComparisonCount[$QueryIndex][$PartIndex] > 1))
                            {
                                $PartQuery .= "GROUP BY ResourceId"
                                        ." HAVING COUNT(DISTINCT "
                                        .$ComparisonCountField[$QueryIndex][$PartIndex]
                                        .") = "
                                        .$ComparisonCount[$QueryIndex][$PartIndex];
                            }
                        }

                        # perform query and retrieve IDs
                        $this->DMsg(5, "Performing comparison query <i>"
                                .$PartQuery."</i>");
                        $this->DB->Query($PartQuery);
                        $ResourceIds = $ResourceIds
                                + $this->DB->FetchColumn("ResourceId");
                        $this->DMsg(5, "Comparison query produced <i>"
                                .count($ResourceIds)."</i> results");
                    }
                }
                else
                {
                    # add closing paren if query was flagged to be closed
                    if (isset($CloseQuery[$QueryIndex]))
                    {
                        $Query .= ") ";
                        if (($Logic == "Logic")
                                && ($ComparisonCount[$QueryIndex] > 1))
                        {
                            $Query .= "GROUP BY ResourceId"
                                    ." HAVING COUNT(DISTINCT "
                                    .$ComparisonCountField[$QueryIndex]
                                    .") = "
                                    .$ComparisonCount[$QueryIndex];
                        }
                    }

                    # perform query and retrieve IDs
                    $this->DMsg(5, "Performing comparison query <i>".$Query."</i>");
                    $this->DB->Query($Query);
                    $ResourceIds = $this->DB->FetchColumn("ResourceId");
                    $this->DMsg(5, "Comparison query produced <i>"
                            .count($ResourceIds)."</i> results");
                }

                # if we already have some results
                if (isset($Results))
                {
                    # if search logic is set to AND
                    if ($Logic == "AND")
                    {
                        # remove anything from results that was not returned from query
                        $Results = array_intersect($Results, $ResourceIds);
                    }
                    else
                    {
                        # add values returned from query to results
                        $Results = array_unique(array_merge($Results, $ResourceIds));
                    }
                }
                else
                {
                    # set results to values returned from query
                    $Results = $ResourceIds;
                }
            }
        }
        else
        {
            # initialize results to empty list
            $Results = array();
        }

        # return results to caller
        return $Results;
    }

    /**
    * Return item IDs sorted by a specified field.
    * @param int $ItemType Type of item.
    * @param int $FieldId ID of field by which to sort.
    * @param bool $SortDescending If TRUE, sort in descending order, otherwise
    *       sort in ascending order.
    * @return array of ItemIds
    */
    public static function GetItemIdsSortedByField(
            $ItemType, $FieldId, $SortDescending)
    {
        $RFactory = new ResourceFactory($ItemType);
        return $RFactory->GetResourceIdsSortedBy($FieldId, !$SortDescending);
    }

    /**
    * Queue background update for an item.
    * @param mixed $ItemOrItemId Item to update.
    * @param int $TaskPriority Priority for the task, if the default
    *         is not suitable
    */
    public static function QueueUpdateForItem($ItemOrItemId, $TaskPriority = NULL)
    {
        if (is_numeric($ItemOrItemId))
        {
            $ItemId = $ItemOrItemId;
            $Item = new Resource($ItemId);
        }
        else
        {
            $Item = $ItemOrItemId;
            $ItemId = $Item->Id();
        }

        # if no priority was provided, use the default
        if ($TaskPriority === NULL)
        {
            $TaskPriority = self::$TaskPriority;
        }

        # assemble task description
        $Title = $Item->GetMapped("Title");
        if (!strlen($Title))
        {
            $Title = "Item #".$ItemId;
        }
        $TaskDescription = "Update search data for"
                ." <a href=\"r".$ItemId."\"><i>"
                .$Title."</i></a>";

        # queue update
        $GLOBALS["AF"]->QueueUniqueTask(array(__CLASS__, "RunUpdateForItem"),
                array(intval($ItemId)), $TaskPriority, $TaskDescription);
    }

    /**
    * Update search index for an item.
    * @param int $ItemId Item to update.
    */
    public static function RunUpdateForItem($ItemId)
    {
        # bail out if item no longer exists
        $Resource = new Resource($ItemId);

        # bail out if item is a temporary record
        if ($Resource->IsTempResource()) {  return;  }

        # retrieve schema ID of item to use for item type
        $ItemType = $Resource->SchemaId();

        # update search data for resource
        $SearchEngine = new SPTSearchEngine();
        $SearchEngine->UpdateForItem($ItemId, $ItemType);
    }

    /**
    * Generate a list of suggested additional search terms that can be used for
    * faceted searching.
    * @param array $SearchResults A set of results from a from which to generate facets.
    * @param User $User User to employ in permission checks.
    * @return An array of suggestions.  Keys are the field names and
    * values are arrays of (ValueId => SuggestedValue)
    */
    public static function GetResultFacets($SearchResults, $User)
    {
        # classifications and names associated with these search results
        $SearchClasses = array();
        $SearchNames   = array();

        # make sure we're not faceting too many resources
        $SearchResults = array_slice(
            $SearchResults, 0,
            self::$NumResourcesForFacets,
            TRUE);

        # disable DB cache for the search suggestions process,
        #  this avoids memory exhaustion.
        $DB = new Database();
        $DB->Caching(FALSE);

        # number of resources to include in a chunk
        # a mysql BIGINT is at most 21 characters long and the
        # default max_packet_size is 1 MiB, so we can pack about
        # 1 MiB / (22 bytes) = 47,663 ResourceIds into a query before
        # we need to worry about length problems
        $ChunkSize = 47600;

        if (count($SearchResults)>0)
        {
            foreach (array_chunk($SearchResults, $ChunkSize, TRUE) as $Chunk)
            {
                # pull out all the Classifications that were associated
                #       with our search results along with all their parents
                $DB->Query("SELECT ResourceId,ClassificationId FROM ResourceClassInts "
                           ."WHERE ResourceId IN "
                           ."(".implode(",", array_keys($Chunk)).")");
                $Rows = $DB->FetchRows();
                foreach ($Rows as $Row)
                {
                    $CurId = $Row["ClassificationId"];
                    while ($CurId !== FALSE)
                    {
                        $SearchClasses[$CurId][]=$Row["ResourceId"] ;
                        $CurId = self::FindParentClass($CurId);
                    }
                }

                # also pull out controlled names
                $DB->Query("SELECT ResourceId,ControlledNameId FROM ResourceNameInts "
                           ."WHERE ResourceId in "
                           ."(".implode(",", array_keys($Chunk)).")");
                $Rows = $DB->FetchRows();
                foreach ($Rows as $Row)
                {
                    $SearchNames[$Row["ControlledNameId"]][]= $Row["ResourceId"];
                }
            }

            # make sure we haven't double-counted resources that have
            # a classification and some of its children assigned
            $TmpClasses = array();
            foreach ($SearchClasses as $ClassId => $Resources)
            {
                $TmpClasses[$ClassId] = array_unique($Resources);
            }
            $SearchClasses = $TmpClasses;
        }

        # generate a map of FieldId -> Field Names for all of the generated facets:
        $SuggestionsById = array();

        # pull relevant Classification names out of the DB
        if (count($SearchClasses)>0)
        {
            foreach (array_chunk($SearchClasses, $ChunkSize, TRUE) as $Chunk)
            {
                $DB->Query("SELECT FieldId,ClassificationId,ClassificationName"
                        ." FROM Classifications"
                        ." WHERE ClassificationId"
                        ." IN (".implode(",", array_keys($Chunk)).")");
                foreach ($DB->FetchRows() as $Row)
                {
                    $SuggestionsById[$Row["FieldId"]][]=
                        array("Id" => $Row["ClassificationId"],
                              "Name" => $Row["ClassificationName"],
                              "Count" => count(
                                  $SearchClasses[$Row["ClassificationId"]]));
                }
            }
        }

        # pull relevant ControlledNames out of the DB
        if (count($SearchNames)>0)
        {
            foreach (array_chunk($SearchNames, $ChunkSize, TRUE) as $Chunk)
            {
                $DB->Query("SELECT FieldId,ControlledNameId,ControlledName"
                        ." FROM ControlledNames"
                        ." WHERE ControlledNameId"
                        ." IN (".implode(",", array_keys($SearchNames)).")");
                foreach ($DB->FetchRows() as $Row)
                {
                    $SuggestionsById[$Row["FieldId"]][]=
                        array("Id" => $Row["ControlledNameId"],
                              "Name" => $Row["ControlledName"],
                              "Count" => count(
                                    $SearchNames[$Row["ControlledNameId"]]));
                }
            }
        }

        # translate the suggestions that we have in terms of the
        #  FieldIds to suggestions in terms of the field names
        $SuggestionsByFieldName = array();

        # if we have suggestions to offer
        if (count($SuggestionsById)>0)
        {
            # gill in an array that maps FieldNames to search links
            # which would be appropriate for that field
            foreach ($SuggestionsById as $FieldId => $FieldValues)
            {
                try
                {
                    $ThisField = new MetadataField($FieldId);
                }
                catch (Exception $Exception)
                {
                    $ThisField = NULL;
                }

                # bail on fields that didn't exist and on fields that the
                #       current user cannot view, and on fields that are
                #       disabled for advanced searching
                if (is_object($ThisField) &&
                    $ThisField->Status() == MetadataSchema::MDFSTAT_OK &&
                    $ThisField->IncludeInFacetedSearch() &&
                    $ThisField->Enabled() &&
                    $User->HasPriv($ThisField->ViewingPrivileges()))
                {
                    $SuggestionsByFieldName[$ThisField->Name()] = array();

                    foreach ($FieldValues as $Value)
                    {
                        $SuggestionsByFieldName[$ThisField->Name()][$Value["Id"]] =
                            array("Name" => $Value["Name"], "Count" => $Value["Count"] );
                    }
                }
            }
        }

        ksort($SuggestionsByFieldName);

        return $SuggestionsByFieldName;
    }

    /**
    * Set the default priority for background tasks.
    * @param mixed $NewPriority New task priority (one of
    *     ApplicationFramework::PRIORITY_*)
    */
    public static function SetUpdatePriority($NewPriority)
    {
        self::$TaskPriority = $NewPriority;
    }

    /**
    * Set the number of resources used for search facets.
    * @param int $NumToUse Updated value.
    */
    public static function SetNumResourcesForFacets($NumToUse)
    {
        self::$NumResourcesForFacets = $NumToUse;
    }

    # ---- BACKWARD COMPATIBILITY --------------------------------------------

    /**
    * Perform search with logical groups of fielded searches.  This method
    * is DEPRECATED -- please use SearchEngine::Search() with a SearchParameterSet
    * object instead.
    * @param mixed $SearchGroups Search parameters as SearchParameterSet
    *       object or legacy array format.
    * @param int $StartingResult Starting index into results.  (OPTIONAL,
    *       defaults to 0)
    * @param int $NumberOfResults Number of results to return.  (OPTIONAL,
    *       defaults to 10)
    * @param string $SortByField Name of field to sort results by.  (OPTIONAL,
    *       defaults to relevance score)
    * @param bool $SortDescending If TRUE, results will be sorted in
    *       descending order, otherwise results will be sorted in
    *       ascending order.  (OPTIONAL, defaults to TRUE)
    * @return array Array of search result scores, with the IDs of items
    *       found by search as the index.
    * @see SearchEngine::Search()
    */
    public function GroupedSearch(
            $SearchGroups, $StartingResult = 0, $NumberOfResults = 10,
            $SortByField = NULL, $SortDescending = TRUE)
    {
        if ($SearchGroups instanceof SearchParameterSet)
        {
            # if search parameter set was passed in, use it directly
            $SearchParams = $SearchGroups;
        }
        else
        {
            # otherwise, convert legacy array into SearchParameterSet
            $SearchParams = new SearchParameterSet();
            $SearchParams->SetFromLegacyArray($SearchGroups);
        }

        # perform search
        $Results = $this->Search(
            $SearchParams, $StartingResult, $NumberOfResults,
            $SortByField, $SortDescending);

        # pull out the resoults for the Resource schema
        if (isset($Results[MetadataSchema::SCHEMAID_DEFAULT]))
        {
            $Results = $Results[MetadataSchema::SCHEMAID_DEFAULT];
        }
        else
        {
            $Results = array();
        }

        # return the results
        return $Results;
    }

    /**
    * Get a simplified SearchParameterSet for display purposes.  When
    * searches are constructed using the faceting code, search groups
    * for "is 'X' OR begins with 'X -- ' are added to the search",
    * however these are less than idea for display, hence the
    * simplification used here.
    * For example, if a user searches for 'frogs', and then clicks
    * 'Science' under the 'GEM Subject' tree field facet, the
    * following search will be constructed:
    *  Searched for: frogs
    *  and (GEM Subject is Science OR
    *       GEM Subject begins with Science -- )
    * but the Display parameters will be:
    *  Searched for: frogs
    *  and GEM Subject is Science
    *
    * @param SearchParameterSet $SearchParams Parameters to format.
    * @return SearchParamterSet Formatted search paramters
    */
    public static function ConvertToDisplayParameters($SearchParams)
    {
        # create display parameters, used to make a more user-friendly
        # version of the search
        $DisplayParams = new SearchParameterSet();

        # copy keyword searches as is
        $DisplayParams->AddParameter(
            $SearchParams->GetKeywordSearchStrings() );

        # copy field searches as is
        $SearchStrings = $SearchParams->GetSearchStrings();
        foreach ($SearchStrings as $FieldId => $Params)
        {
            $DisplayParams->AddParameter($Params, $FieldId);
        }

        # iterate over the search groups, looking for the 'is or begins
        # with' group that we add when faceting and displaying them as
        # IS parameters rather than the literal subgroup that we
        # actually use
        $Groups = $SearchParams->GetSubgroups();
        foreach ($Groups as $Group)
        {
            # start off assuming that we'll just copy the group without conversion
            $CopyGroup = TRUE;

            # if this group uses OR logic for a single field, then it
            # might be one of the subgroups we want to match and will require further
            # investigation
            if ($Group->Logic() == "OR" &&
                count($Group->GetFields()) == 1)
            {
                # pull out the search strings for this field
                $SearchStrings = $Group->GetSearchStrings();
                $FieldId = key($SearchStrings);
                $Values = current($SearchStrings);

                # check if there are two search strings, one an 'is'
                # and the other a 'begins with' that both start with the
                # same prefix, as would be added by the search facet code
                if ( count($Values) == 2 &&
                     preg_match('/^=(.*)$/', $Values[0], $FirstMatch) &&
                     preg_match('/^\\^(.*) -- $/', $Values[1], $SecondMatch) &&
                     $FirstMatch[1] == $SecondMatch[1] )
                {
                    # check if this field is valid anywhere
                    if (MetadataSchema::FieldExistsInAnySchema($FieldId))
                    {
                        $Field = new MetadataField($FieldId);

                        # and check if this field is a tree field
                        if ($Field->Type() == MetadataSchema::MDFTYPE_TREE)
                        {
                            # okay, this group matches the form that
                            # the faceting code would add for an 'is or
                            # begins with' group; convert it to just an
                            # 'is' group for display
                            $DisplayParams->AddParameter("=".$FirstMatch[1], $FieldId);
                            $CopyGroup = FALSE;
                        }
                    }
                }
            }

            # if this group didn't require conversion, attempt to copy
            # it verbatim
            if ($CopyGroup)
            {
                try
                {
                    $DisplayParams->AddSet($Group);
                }
                catch (Exception $e)
                {
                    # if group could not be added for any reason, skip
                    # it and move on to the next group
                }
            }
        }

        return $DisplayParams;
    }

    # ---- PRIVATE INTERFACE -------------------------------------------------

    private $FieldTypes;
    private $Schemas;

    private static $TaskPriority = ApplicationFramework::PRIORITY_BACKGROUND;
    private static $NumResourcesForFacets = 500;

    /**
    * Get SQL for comparison against text field.
    * @param string $DBField Name of database field.
    * @param string $Operator Operator for comparison.
    * @param string $Value Value to compare against.
    * @param string $Prefix Table prefix to use (OPTIONAL)
    * @return string SQL comparison.
    */
    private function GetTextComparisonSql($DBField, $Operator, $Value, $Prefix="")
    {
        # if we were given a prefix, add the necessary period so we can use it
        if (strlen($Prefix))
        {
            $Prefix = $Prefix.".";
        }

        if ($Operator == "^")
        {
            $EscapedValue = str_replace(
                    array("%", "_"),
                    array("\%", "\_"),
                    addslashes($Value));
            $Comparison = $Prefix."`".$DBField."` LIKE '".$EscapedValue."%' ";
        }
        elseif ($Operator == '$')
        {
            $EscapedValue = str_replace(
                    array("%", "_"),
                    array("\%", "\_"),
                    addslashes($Value));
            $Comparison = $Prefix."`".$DBField."` LIKE '%".$EscapedValue."' ";
        }
        elseif ($Operator == '!=')
        {
            $Comparison =
                    "(".$Prefix."`".$DBField."` ".$Operator." '".addslashes($Value)."'"
                    ." AND ".$Prefix."`".$DBField."` IS NOT NULL)";
        }
        else
        {
            $Comparison = $Prefix."`".$DBField."` "
                    .$Operator." '".addslashes($Value)."' ";
        }
        return $Comparison;
    }

    /**
    * Get SQL conditional for comparison against time/date field.
    * @param object $Field Field to compare against.
    * @param string $Operator Operator for comparison.
    * @param string $Value Value to compare against.
    * @return string SQL conditional or NULL if no valid conditional
    *       could be generated.
    */
    private function GetTimeComparisonSql($Field, $Operator, $Value)
    {
        # check if this is a field modification comparison
        $ModificationComparison = ($Operator{0} == "@") ? TRUE : FALSE;

        # if value appears to have time component or text description
        if (strpos($Value, ":")
                || strstr($Value, "day")
                || strstr($Value, "week")
                || strstr($Value, "month")
                || strstr($Value, "year")
                || strstr($Value, "hour")
                || strstr($Value, "minute"))
        {
            # adjust operator if necessary
            if ($Operator == "@")
            {
                $Operator = ">=";
            }
            else
            {
                if ($ModificationComparison)
                {
                    $Operator = substr($Operator, 1);
                }

                if (strstr($Value, "ago"))
                {
                    $OperatorFlipMap = array(
                            "<" => ">=",
                            ">" => "<=",
                            "<=" => ">",
                            ">=" => "<",
                            );
                    $Operator = isset($OperatorFlipMap[$Operator])
                            ? $OperatorFlipMap[$Operator] : $Operator;
                }
            }

            # translate common words-to-numbers
            $WordsForNumbers = array(
                    '/^a /i'         => '1 ',
                    '/^an /i'        => '1 ',
                    '/^one /i'       => '1 ',
                    '/^two /i'       => '2 ',
                    '/^three /i'     => '3 ',
                    '/^four /i'      => '4 ',
                    '/^five /i'      => '5 ',
                    '/^six /i'       => '6 ',
                    '/^seven /i'     => '7 ',
                    '/^eight /i'     => '8 ',
                    '/^nine /i'      => '9 ',
                    '/^ten /i'       => '10 ',
                    '/^eleven /i'    => '11 ',
                    '/^twelve /i'    => '12 ',
                    '/^thirteen /i'  => '13 ',
                    '/^fourteen /i'  => '14 ',
                    '/^fifteen /i'   => '15 ',
                    '/^sixteen /i'   => '16 ',
                    '/^seventeen /i' => '17 ',
                    '/^eighteen /i'  => '18 ',
                    '/^nineteen /i'  => '19 ',
                    '/^twenty /i'    => '20 ',
                    '/^thirty /i'    => '30 ',
                    '/^forty /i'     => '40 ',
                    '/^fourty /i'    => '40 ',  # (common misspelling)
                    '/^fifty /i'     => '50 ',
                    '/^sixty /i'     => '60 ',
                    '/^seventy /i'   => '70 ',
                    '/^eighty /i'    => '80 ',
                    '/^ninety /i'    => '90 ');
            $Value = preg_replace(
                    array_keys($WordsForNumbers), $WordsForNumbers, $Value);

            # use strtotime method to build condition
            $TimestampValue = strtotime($Value);
            if (($TimestampValue !== FALSE) && ($TimestampValue != -1))
            {
                if ((date("H:i:s", $TimestampValue) == "00:00:00")
                        && (strpos($Value, "00:00") === FALSE)
                        && ($Operator == "<="))
                {
                    $NormalizedValue =
                            date("Y-m-d", $TimestampValue)." 23:59:59";
                }
                else
                {
                    $NormalizedValue = date(
                            "Y-m-d H:i:s", $TimestampValue);
                }
            }
            else
            {
                $NormalizedValue = addslashes($Value);
            }

            # build SQL conditional
            if ($ModificationComparison)
            {
                $Conditional = " ( FieldId = ".$Field->Id()
                        ." AND Timestamp ".$Operator
                                ." '".$NormalizedValue."' ) ";
            }
            else
            {
                $Conditional = " ( `".$Field->DBFieldName()."` "
                        .$Operator." '".$NormalizedValue."' ) ";
            }
        }
        else
        {
            # adjust operator if necessary
            if ($ModificationComparison)
            {
                $Operator = ($Operator == "@") ? ">="
                        : substr($Operator, 1);
            }

            # use Date object method to build conditional
            $Date = new Date($Value);
            if ($Date->Precision())
            {
                if ($ModificationComparison)
                {
                    $Conditional = " ( FieldId = ".$Field->Id()
                            ." AND ".$Date->SqlCondition(
                                    "Timestamp", NULL, $Operator)." ) ";
                }
                else
                {
                    $Conditional = " ( ".$Date->SqlCondition(
                            $Field->DBFieldName(), NULL, $Operator)." ) ";
                }
            }
        }

        # return assembled conditional to caller
        return $Conditional;
    }

    /**
    * Look up ParentIDs for classifications that are enabled for
    *   faceted searching and also assigned to any resources.
    * @param int $ClassId Classification to look up.
    * @return int ParentId or FALSE for non-existent, top-level, or unused
    *   classifications as well as those not enabled for faceted searching.
    */
    private static function FindParentClass($ClassId)
    {
        static $ParentMap;

        # first time through, fetch the mapping of parent values we need
        if (!isset($ParentMap))
        {
            $DB = new Database();

            # result here will be a parent/child mapping for all used
            # classifications; avoid caching it as it can be quite large
            $PreviousSetting = $DB->Caching();
            $DB->Caching(FALSE);
            $DB->Query(
                "SELECT ParentId, ClassificationId FROM Classifications "
                ."WHERE DEPTH > 0 AND FullResourceCount > 0 "
                ."AND FieldId IN (SELECT FieldId FROM MetadataFields "
                ."  WHERE IncludeInFacetedSearch=1)"
            );
            $DB->Caching($PreviousSetting);

            $ParentMap = $DB->FetchColumn("ParentId", "ClassificationId");
        }

        return isset($ParentMap[$ClassId]) ? $ParentMap[$ClassId] : FALSE;
    }
}
