<?PHP
#
#   FILE:  SPTSearchEngine.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2011-2013 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)
        {
            $Operator = $Operators[$Index];
            $Value = $Values[$Index];

            if (MetadataSchema::FieldExistsInAnySchema($FieldId) )
            {
                $Field = new MetadataField($FieldId);
                switch ($Field->Type())
                {
                    case MetadataSchema::MDFTYPE_TEXT:
                    case MetadataSchema::MDFTYPE_PARAGRAPH:
                    case MetadataSchema::MDFTYPE_NUMBER:
                    case MetadataSchema::MDFTYPE_FLAG:
                    case MetadataSchema::MDFTYPE_URL:
                        if (isset($Queries["Resources"]))
                        {
                            $Queries["Resources"] .= $CombineWord;
                        }
                        else
                        {
                            $Queries["Resources"] = "SELECT DISTINCT ResourceId"
                                    ." FROM Resources WHERE ";
                        }
                        $Queries["Resources"] .= $this->GetTextComparisonSql(
                                $Field->DBFieldName(), $Operator, $Value);
                        break;

                    case MetadataSchema::MDFTYPE_USER:
                        $QueryIndex = "ResourceUserInts".$FieldId;

                        if (isset($Queries[$QueryIndex]))
                        {
                            $Queries[$QueryIndex] .= $CombineWord;
                        }
                        else
                        {
                            $Queries[$QueryIndex] = "SELECT DISTINCT ResourceId"
                                ." FROM ResourceUserInts WHERE ";
                        }

                        $User = new CWUser($Value);

                        $Queries[$QueryIndex] .=
                            "(UserId = ".$User->Id()." AND FieldId = "
                                    .intval($FieldId).")";
                        break;

                    case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
                        $QueryIndex = "ResourceNameInts".$Field->Id();
                        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($Field->Id())
                                    ." 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 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"))
                        {
                            if (isset($Queries["Resources"]))
                            {
                                $Queries["Resources"] .= $CombineWord;
                            }
                            else
                            {
                                $Queries["Resources"] = "SELECT DISTINCT ResourceId"
                                        ." FROM Resources WHERE ";
                            }

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

                            # 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);
                            }
                            $Queries["Resources"] .=
                                    " ( `".$Field->DBFieldName()."` "
                                    .$Operator
                                    ." '".$NormalizedValue."' ) ";
                        }
                        else
                        {
                            # use Date object method to build condition
                            $Date = new Date($Value);
                            if ($Date->Precision())
                            {
                                if (isset($Queries["Resources"]))
                                {
                                    $Queries["Resources"] .= $CombineWord;
                                }
                                else
                                {
                                    $Queries["Resources"] = "SELECT DISTINCT ResourceId"
                                            ." FROM Resources WHERE ";
                                }
                                $Queries["Resources"] .= " ( ".$Date->SqlCondition(
                                        $Field->DBFieldName(), NULL, $Operator)." ) ";
                            }
                        }
                        break;

                    case MetadataSchema::MDFTYPE_DATE:
                        $Date = new Date($Value);
                        if ($Date->Precision())
                        {
                            if (isset($Queries["Resources"]))
                            {
                                $Queries["Resources"] .= $CombineWord;
                            }
                            else
                            {
                                $Queries["Resources"] = "SELECT DISTINCT ResourceId"
                                        ." FROM Resources WHERE ";
                            }
                            $Queries["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 MetadataSchema::MDFTYPE_IMAGE:
                    case MetadataSchema::MDFTYPE_FILE:
                        # (these types not yet handled by search engine for comparisons)
                        break;
                }
            }
        }

        # 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);
        if ($Resource->Status() == -1) {  return;  }

        # 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;
    }

    /**
    * Compat function for old SPT code to update resources.
    * @param int $ItemId Item to update.
    */
    public function UpdateForResource($ItemId)
    {
        $this->UpdateForItem($ItemId);
    }

    /**
    * 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.
    * @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
        $Groups = $SearchParams->GetSubgroups();
        foreach ($Groups as $Group)
        {
            # if this group uses OR logic for a single field
            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 this field is valid anywhere
                if (MetadataSchema::FieldExistsInAnySchema($FieldId))
                {
                    $Field = new MetadataField($FieldId);
                    # if this is a tree field, and we're doing a prefix match
                    # on it, we'll just want to display this subgroup as an "IS"
                    # otherwise display the group unmodified
                    if ($Field->Type() == MetadataSchema::MDFTYPE_TREE &&
                        preg_match('/^=(.*)$/', $Values[0], $FirstMatch) &&
                        preg_match('/^\\^(.*) -- $/', $Values[1], $SecondMatch) &&
                        $FirstMatch[1] == $SecondMatch[1] )
                    {
                        $DisplayParams->AddParameter("=".$FirstMatch[1], $FieldId);
                    }
                    else
                    {
                        $DisplayParams->AddSet($Group);
                    }
                }
            }
            else
            {
                # otherwise, just attempt to include the group as-is
                try
                {
                    $DisplayParams->AddSet($Group);
                }
                catch (Exception $e)
                {
                    # if group could not be added for any reason, continue
                }
            }
        }

        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;
    }

    /**
    * 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;
    }
}
