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

/**
* Convenience class for QuickSearch responses, making it easy to share
* functions common to different types of QuickSearch objects.
*/
class QuickSearchHelper
{

    /**
    * Search a field for values matching a specified search string
    * @param MetadataField $Field Metadata field.
    * @param string $SearchString Search string.
    * @param array $IdExclusions Array of IDs for values to exclude.
    * @param array $ValueExclusions Array of values to exclude.
    * @return Returns an array containing the number of search results, the number
    *      of additional search results available, and the search results.
    */
    public static function SearchField(
        MetadataField $Field,
        $SearchString,
        array $IdExclusions=array(),
        array $ValueExclusions=array())
    {
        $MaxResults = $Field->NumAjaxResults();

        switch ($Field->Type())
        {
            case MetadataSchema::MDFTYPE_USER:
                return self::SearchForUsers(
                    $SearchString, $MaxResults, $IdExclusions, $ValueExclusions);

            case MetadataSchema::MDFTYPE_REFERENCE:
                if (count($ValueExclusions))
                {
                    throw new Exception(
                        "Cannot exclude resource by value. "
                        ."Did you want IdExclusions instead?");
                }

               return self::SearchForResources(
                   $Field, $SearchString, $MaxResults, $IdExclusions);

            default:
                return self::SearchForValues(
                    $Field, $SearchString, $MaxResults, $IdExclusions, $ValueExclusions);
        }
    }

    /**
    * Highlight all instances of the search string in the result label
    * @param string $SearchTerms The string(s) to highlight, optionally array of strings
    * @param string $LabelForFormatting The label in which to highlight the search string
    * @return Returns the formatted label string
    */
    public static function HighlightSearchString($SearchTerms, $LabelForFormatting)
    {
        if(!is_array($SearchTerms))
        {
            $SearchTerms = array($SearchTerms);
        }

        foreach ($SearchTerms as $SearchString)
        {
            $SearchString = trim($SearchString);
            $ExplodedSearch = preg_split('/\s+/', $SearchString);
            $Patterns = array();
            $Index = 0;
            $InQuote = FALSE;

            #Iterate through each term in the search string
            foreach ($ExplodedSearch as $Term)
            {
                #Handle quoted terms differently
                  #if the first character is a quote
                if ($Term[0] == '"')
                  {
                    $InQuote = TRUE;
                }

                if (substr($Term, -1) == '"')
                {
                    #last character is a quote means that we've found the end of the term.
                    $InQuote = FALSE;
                }

                #remove all of the quotes if we're matched
                $Term = str_replace('"', "", $Term);

                #Add the term to the list of patterns we'll be highlighting in the result
                # string at the current index (quoted terms will be appended to the index,
                # unquoted terms are added at a new index).
                $Patterns[$Index] = (isset($Patterns[$Index]) ?
                      $Patterns[$Index]." ":"").$Term;

                if (!$InQuote)
                {
                    # if we are not in a quoted term, the next term should go at
                    # a new index in the pattern array.
                    $Index++;
                }
            }

            # iterate over our terms, escaping them and including bolding
            # for segments two more ore characters longer
            $PregPatterns = array();
            foreach ($Patterns as $Term)
            {
                if (strlen($Term)>=2)
                {
                    $PregPatterns = "/".preg_quote($Term)."/i";
                }
            }

            # do the highlighting
            $LabelForFormatting = preg_replace(
                    $PregPatterns, "<b>$0</b>", $LabelForFormatting);

        }

        return $LabelForFormatting;
    }

    /**
    * Prepare a search string for use in an SQL statement.
    * @param string $SearchString Search string.
    * @return Returns the prepared search string.
    */
    private static function PrepareSearchString($SearchString)
    {
        # remove "--", which causes searches to fail and is often in classifications
        #  Also remove unwanted punctuation
        $SearchString = str_replace(
            array("--",",",".", ":"),
            " ", $SearchString);

        # split the search string into words
        $Words = preg_split('/\s+/', $SearchString, -1, PREG_SPLIT_NO_EMPTY);

        # the variable that holds the prepared search string
        $PreparedSearchString = "";

        foreach ($Words as $Word)
        {
            # Don't do one-character "words".
            if (strlen($Word)==1)
            {
                continue;
            }

            # just add the word if it's quoted or has an asterisk already
            if (preg_match('/\"$/', $Word) || preg_match('/\*$/', $Word))
            {
                $PreparedSearchString .= $Word . " ";
            }

            # add wildcard operator for stemming
            else
            {
                $PreparedSearchString .= $Word . "* ";
            }
        }

        # remove whitespace padding
        $PreparedSearchString = trim($PreparedSearchString);

        return $PreparedSearchString;
    }

    /**
    * Put search results from database queries that came out in
    * database-order (i.e. the order in which entries were created) into
    * an oredering designed to put the terms people likely wanted near the top.
    * Divides the results into seven bins based on the occurrence of
    * the search string.  Using regex notation (\W is a 'non-word
    * character'), the bins are:
    * 1) ^String$       (Exact match)
    * 2) String$        (Ends with String)
    * 3) ^String\W.*    (Starts with String as a word)
    * 4) ^String.*      (Starts with String as part of a word)
    * 5) .*String\W.*   (Contains String as a word somewhere in the middle)
    * 6) .*String.*     (Contains String somewhere in the middle)
    * 7) Everything else -- this comes up most often with multi-word
    *     strings where both words occur but are dispersed throughout
    *     the term
    *
    * Bins are returned in the above order, sorted alphabetically within a bin.
    * @param Array $Results Array( $ItemId => $ItemName ) as produced by the
    *   ItemFactory searching methods.
    * @param String $SearchString To use to decide the bins.
    * @param Integer $MaxResults To return
    * @return Array of Results
    */
    private static function SortSearchResults($Results, $SearchString, $MaxResults)
    {
        $Matches = array(
            "Exact" => array(),
            "End"   => array(),
            "BegSp" => array(),
            "Beg"   => array(),
            "MidSp" => array(),
            "Mid"   => array(),
            "Other" => array() );

        # escape regex characters
        $SafeStr = preg_quote( trim( preg_replace('/\s+/', " ",
                     str_replace( array("--",",",".", ":"), " ",
                                  $SearchString) )), '/');

        # iterate over search results, sorting them into bins
        foreach ($Results as $Key => $Val)
        {
            # apply the same normalization to our value as we did our search string
            $TestVal = preg_quote( trim( preg_replace('/\s+/', " ",
                     str_replace( array("--",",",".", ":"), " ",
                                  $Val) )), '/');

            if (preg_match('/^'.$SafeStr.'$/i', $TestVal))
            {
                $ix = "Exact";
            }
            elseif (preg_match('/^'.$SafeStr.'\\W/i', $TestVal))
            {
                $ix = "BegSp";
            }
            elseif (preg_match('/^'.$SafeStr.'/i', $TestVal))
            {
                $ix = "Beg";
            }
            elseif (preg_match('/'.$SafeStr.'$/i', $TestVal))
            {
                $ix = "End";
            }
            elseif (preg_match('/'.$SafeStr.'\\W/i', $TestVal))
            {
                $ix = "MidSp";
            }
            elseif (preg_match('/'.$SafeStr.'/i', $TestVal))
            {
                $ix = "Mid";
            }
            else
            {
                $ix = "Other";
            }

            $Matches[$ix][$Key] = $Val;
        }

        # assemble the sorted results
        $SortedResults = array();
        foreach (array("Exact", "BegSp", "Beg", "End", "MidSp", "Mid", "Other") as $ix)
        {
            asort( $Matches[$ix] );
            $SortedResults += $Matches[$ix];
        }

        # trim down the list to the requested number
        $SortedResults = array_slice($SortedResults, 0, $MaxResults, TRUE);

        return $SortedResults;
    }

    /**
    * Search for resources by keyword searching using a search string and returning
    * a maximum number of results.
    * @param MetadataField $DstField Metadata field we're requesting the search for.
    * @param string $SearchString Search string for a keyword search.
    * @param int $MaxResults Maximum number of results to return.
    * @param array $IdExclusions Array of resource IDs for resources
    *     to exclude (OPTIONAL).
    * @return Returns an array containing the number of search results, the number
    *      of additional search results available, and the search results
    */
    private static function SearchForResources(
        $DstField,
        $SearchString,
        $MaxResults,
        array $IdExclusions=array() )
    {
        # construct search groups based on the keyword
        $SearchParams = new SearchParameterSet();
        $SearchParams->AddParameter($SearchString);

        $SignalResult = $GLOBALS["AF"]->SignalEvent(
            "EVENT_FIELD_SEARCH_FILTER",
            array(
                "Search" => $SearchParams,
                "Field" => $DstField));
        $SearchParams = $SignalResult["Search"];

        # perform search
        $SearchEngine = new SPTSearchEngine();
        $SearchResults = $SearchEngine->Search($SearchParams);

        # get the list of referenceable schemas for this field
        $ReferenceableSchemaIds = $DstField->ReferenceableSchemaIds();

        # iterate over search results from desired schemas
        $SearchResultsNew = array();
        foreach ($SearchResults as $SchemaId => $SchemaResults)
        {
            if (in_array($SchemaId, $ReferenceableSchemaIds))
            {
                # filter resources the user cannot see
                $RFactory = new ResourceFactory($SchemaId);
                $ViewableIds = $RFactory->FilterNonViewableResources(
                    array_keys($SchemaResults), $GLOBALS["G_User"]);

                # add these results to our list of all search results
                $SearchResultsNew += array_intersect_key(
                        $SchemaResults, array_flip($ViewableIds));
            }
        }
        $SearchResults = $SearchResultsNew;

        # filter out excluded resource IDs if necessary
        if (count($IdExclusions))
        {
            $SearchResults = array_diff_key(
                $SearchResults, array_flip($IdExclusions));
        }

        # pull out mapped titles for all resources
        $ResourceData = array();
        foreach ($SearchResults as $ResourceId => $Score)
        {
            $Resource = new Resource($ResourceId);
            $ResourceData[$ResourceId] = $Resource->GetMapped("Title");
        }

        # determine how many results we had in total
        $TotalResults = count($ResourceData);

        # sort resources by title and subset if necessary
        $ResourceData = self::SortSearchResults(
            $ResourceData,
            $SearchString,
            $MaxResults);

        # compute the number of available and additional results
        $NumSearchResults = count($ResourceData);
        $NumAdditionalSearchResults = $TotalResults - count($ResourceData);

        return array($NumSearchResults, $NumAdditionalSearchResults, $ResourceData);
    }

    /**
    * Perform a search for users.
    * @param string $SearchString Search string.
    * @param int $MaxResults The maximum number of search results.
    * @param array $IdExclusions Array of user IDs for users to exclude.
    * @param array $ValueExclusions Array of values to exclude.
    * @return array giving the number of results displaye,d number of
    *   additional results available, and the results to display.
    */
    private static function SearchForUsers(
        $SearchString,
        $MaxResults = 15,
        array $IdExclusions=array(),
        array $ValueExclusions=array())
    {
        # the factory used for searching
        $UserFactory = new CWUserFactory();

        # get the minimum word length for fuzzy query matching
        $MysqlSysVars = new MysqlSystemVariables($GLOBALS["DB"]);
        $MinLen = intval($MysqlSysVars->Get("ft_min_word_len"));

        # initialize the result variables
        $SearchResults = array();
        $ResultsNeeded = $MaxResults;

        # if the search string is less than the minimum length, do exact query
        # matching first
        if (strlen($SearchString) < $MinLen)
        {
            $SearchResults = $UserFactory->FindUserNames(
                $SearchString,
                "UserName", "UserName", 0, # defaults
                PHP_INT_MAX,
                $IdExclusions,
                $ValueExclusions);

            # decrement the max results by how many were found
            $ResultsNeeded -= count($SearchResults);
        }

        # if there are still some results to fetch, perform fuzzy matching
        if ($ResultsNeeded > 0)
        {
            # prepare the search string
            $PreparedSearchString = self::PrepareSearchString($SearchString);

            # perform the search
            $SearchResults += $UserFactory->FindUserNames(
                $PreparedSearchString,
                "UserName", "UserName", 0, # defaults
                PHP_INT_MAX,
                $IdExclusions,
                $ValueExclusions);
        }

        # slice out just the results we want
        $TotalResults = count($SearchResults);
        $SearchResults = array_slice($SearchResults, 0, $MaxResults, TRUE);

        $NumResults = count($SearchResults);
        $NumAdditionalResults = $TotalResults - $NumResults;

        return array($NumResults, $NumAdditionalResults, $SearchResults);
    }

    /**
    * Search a non-User, non-Reference metadata field for values that
    * match a search string.
    * @param MetadataField $Field Metadata field to search
    * @param string $SearchString Search string.
    * @param int $MaxResults Max number of results to return.
    * @param array $IdExclusions IDs to exclude from search results.
    * @param array $ValueExclusions Values to exclude from results.
    * @return array giving the number of results displayed, number of
    *   additional results available, and the results to display.
    */
    private static function SearchForValues(
        MetadataField $Field,
        $SearchString,
        $MaxResults,
        array $IdExclusions,
        array $ValueExclusions)
    {
        $Factory = $Field->GetFactory();

        # get the minimum word length for fuzzy query matching
        $MysqlSysVars = new MysqlSystemVariables($GLOBALS["DB"]);
        $MinLen = intval($MysqlSysVars->Get("ft_min_word_len"));

        # initialize the result variables
        $Results = array();
        $Total = 0;

        $SignalResult = $GLOBALS["AF"]->SignalEvent(
            "EVENT_FIELD_SEARCH_FILTER",
            array(
                "Search" => $SearchString,
                "Field" => $Field));
        $SearchString = $SignalResult["Search"];

        # if the search string is less than the minimum length, do exact query
        # matching first
        if (strlen($SearchString) < $MinLen)
        {
            # search for results and get the total
            $Results += $Factory->SearchForItemNames(
                $SearchString,
                $MaxResults,
                FALSE, TRUE, 0, # defaults
                $IdExclusions,
                $ValueExclusions);
            $Total += $Factory->GetCountForItemNames(
                $SearchString,
                FALSE, TRUE, # defaults,
                $IdExclusions,
                $ValueExclusions);

            # decrement the max results by how many were returned when doing exact
            # matching
            $MaxResults -= count($Results);
        }

        # if more results should be fetched
        if ($MaxResults > 0)
        {
            $PreparedSearchString = self::PrepareSearchString($SearchString);

            if (strlen($SearchString) >= $MinLen)
            {
                $Results += $Factory->FindMatchingRecentlyUsedValues(
                    $PreparedSearchString, 5, $IdExclusions, $ValueExclusions);

                if (count($Results))
                {
                    $Results += array(-1 => "<hr>");
                }
            }

            # search for results and get the total
            $Results += self::SortSearchResults(
                $Factory->SearchForItemNames(
                    $PreparedSearchString,
                    2000,
                    FALSE, TRUE, 0, # defaults
                    $IdExclusions,
                    $ValueExclusions),
                $SearchString,
                $MaxResults);
            $Total += $Factory->GetCountForItemNames(
                $PreparedSearchString,
                FALSE, TRUE, # defaults,
                $IdExclusions,
                $ValueExclusions);
        }

        # get additional totals
        $NumSearchResults = count($Results);
        $NumAdditionalSearchResults = $Total - $NumSearchResults;

        return array($NumSearchResults, $NumAdditionalSearchResults, $Results);
    }
}
