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

# ----- LOCAL FUNCTIONS ------------------------------------------------------

/**
* Search for resources by keyword searching using a search string and returning
* a maximum number of results.
* @param string $SearchString Search string for a keyword search.
* @param int $MaxResults Maximum number of results to return.
* @param bool $ExportAllData TRUE to export all resource fields the user can view
* @param array $IdExclusions Array of resource IDs for resources to exclude.
* @return Returns an array containing the number of search results, the number
*      of additional search results available, and the search results
*/
function SearchForResources(
    $SearchString,
    $MaxResults,
    MetadataField $Field=NULL,
    $ExportAllData=FALSE,
    array $IdExclusions=array())
{
    $SearchEngine = new SPTSearchEngine();
    $ResourceData = array();

    # construct search groups based on the keyword
    $SearchGroups = array(
        "MAIN" => array(
            "SearchStrings" => array("XXXKeywordXXX" => $SearchString),
            "Logic" => SearchEngine::SEARCHLOGIC_AND));

    # add a condition to hide unreleased resources if necessary
    if (!$GLOBALS["User"]->HasPriv(PRIV_RELEASEADMIN, PRIV_RESOURCEADMIN)
        && (is_null($Field) || $Field->SchemaId() == MetadataSchema::SCHEMAID_DEFAULT))
    {
        $Schema = new MetadataSchema();
        $ReleaseField = $Schema->GetFieldByName("Release Flag");

        # if the release flag field is valid
        if ($ReleaseField)
        {
            $SearchGroup = array();

            # add condition to show only released resources
            $SearchGroup["SearchStrings"]["Release Flag"] = "=1";
            $SearchGroup["Logic"] = SearchEngine::LOGIC_AND;

            # show resources added by the user, even they they don't have
            # the release or resource admin privileges
            $AddedByField = $Schema->GetFieldByName("Added By Id");
            if ($AddedByField && $GLOBALS["User"]->HasPriv(PRIV_MYRESOURCEADMIN))
            {
                $SearchGroup["SearchStrings"]["Added By Id"] = "=".$GLOBALS["User"]->Name();
                $SearchGroup["Logic"] = SearchEngine::LOGIC_OR;
            }

            $SearchGroups[$ReleaseField->Id()] = $SearchGroup;
        }
    }

    # add a function to filter out excluded resource IDs if necessary
    if (count($IdExclusions))
    {
        # make the exclusions accessible to the filter function
        global $QUICKSEARCHCALLBACK_IdExclusions;
        $QUICKSEARCHCALLBACK_IdExclusions = $IdExclusions;

        $SearchEngine->AddResultFilterFunction("FilterResourceIdExclusions");
    }

    # allow the search groups to be modified if a specific field is being
    # searched
    if (!is_null($Field))
    {
        $SignalResult = $GLOBALS["AF"]->SignalEvent("EVENT_FIELD_SEARCH_FILTER", array(
            "Search" => $SearchGroups,
            "Field" => $Field));
        $SearchGroups = $SignalResult["Search"];
    }

    # perform search
    $SearchResults = $SearchEngine->GroupedSearch($SearchGroups, 0, $MaxResults);

    foreach ($SearchResults as $ResourceId => $Score)
    {
        $Resource = new Resource($ResourceId);

        # skip bad resources
        if ($Resource->Status() != 1)
        {
            continue;
        }

        $ResourceData[$ResourceId] = ExportResourceData($Resource, $ExportAllData);
    }

    $NumSearchResults = count($ResourceData);
    $Total = $SearchEngine->NumberOfResults();
    $NumAdditionalSearchResults = $Total - count($ResourceData);

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

/**
 * Export all the data associated with a resource. This will only export mapped
 * fields unless specified otherwise. This will only export fields the user is
 * allowed to view.
 * @param Resource $Resource resource
 * @param bool $ExportAll TRUE to export all fields the user can view
 * @return array of data that the user is allowed to view
 */
function ExportResourceData(Resource $Resource, $ExportAll=FALSE)
{
    $MetadataSchema = new MetadataSchema();
    $MappedFieldNames = array("Title", "Description", "Url");

    $Data = array(
        "_Id" => $Resource->Id(),
        "_URI" => OurBaseUrl()."index.php?P=FullRecord&ID=".$Resource->Id());

    foreach ($MappedFieldNames as $MappedFieldName)
    {
        $MappedField = $MetadataSchema->GetFieldByMappedName($MappedFieldName);

        # if the field is mapped and the user can view it
        if ($Resource->UserCanViewField($GLOBALS["User"], $MappedField))
        {
            $Data["_".$MappedFieldName] = $Resource->Get($MappedField);
        }

        # the user cannot view the field
        else
        {
            $Data["_".$MappedFieldName] = NULL;
        }
    }

    # add a special value for the title if necessary
    if (is_null($Data["_Title"]))
    {
      $Data["_Title"] = "You are not allowed to view the resource title.";
    }

    # if the mapped fields are the only ones that need to be exported
    if (!$ExportAll)
    {
        return $Data;
    }

    foreach ($MetadataSchema->GetFields() as $Field)
    {
        if ($Field->Enabled() && $Resource->UserCanViewField($GLOBALS["User"], $Field))
        {
            $Name = $Field->Name();
            $Value = $Resource->Get($Field);

            # change flags to booleans
            if ($Field->Type() == MetadataSchema::MDFTYPE_FLAG)
            {
                $Value = $Value == "1";
            }

            # change numbers to integers
            if ($Field->Type() == MetadataSchema::MDFTYPE_NUMBER)
            {
                $Value = intval($Value);
            }

            # image object to file location
            if ($Field->Type() == MetadataSchema::MDFTYPE_IMAGE)
            {
                $Image = $Resource->Get($Field, TRUE);
                $Value = NULL;

                if ($Image instanceof Image)
                {
                    $ThumbnailUrl = $Image->ThumbnailUrl();

                    if (!is_null($Image) && file_exists($ThumbnailUrl))
                    {
                        $Value = $ThumbnailUrl;
                    }
                }
            }

            # file object to file download location
            if ($Field->Type() == MetadataSchema::MDFTYPE_FILE)
            {
                $Files = $Resource->Get($Field, TRUE);
                $Value = array();

                foreach ($Files as $File)
                {
                    $Value[] = $File->GetLink();
                }
            }

            $Data[$Name] = $Value;
        }
    }

    return $Data;
}

/**
* Filter resources based on given resource ID exclusions
* @param int $ItemId Resource item ID.
* @return Returns TRUE if the item should be excluded and FALSE otherwise.
*/
function FilterResourceIdExclusions($ItemId)
{
    global $QUICKSEARCHCALLBACK_IdExclusions;

    # let the caller know if the item should be excluded
    if (in_array($ItemId, $QUICKSEARCHCALLBACK_IdExclusions))
    {
        return TRUE;
    }

    # the item shouldn't be excluded, so far as this filter knows
    return FALSE;
}

/**
* Search a field that has multiple possible values, that is, classifications,
* controlled names, etc., for values that match the search string.
* @param MetadataField $Field Metadata field.
* @param string $SearchString Search string.
* @param int $MaxResults Maximum number of results to return.
* @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.
*/
function SearchFieldForValues(
    MetadataField $Field,
    $SearchString,
    $MaxResults=NULL,
    array $IdExclusions=array(),
    array $ValueExclusions=array())
{
    $MaxResults = $MaxResults ? $MaxResults : $Field->NumAjaxResults();
    $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 = PrepareSearchString($SearchString);

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

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

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

/**
* Prepare a search string for use in an SQL statement.
* @param string $SearchString Search string.
* @return Returns the prepared search string.
*/
function PrepareSearchString($SearchString)
{
    # remove "--", which causes searches to fail and is often in classifications
    $SearchString = str_replace("--", "", $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)
    {
        # 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;
}

/**
* Perform a search for resources.
* @param JsonHelper $JsonHelper Object to help deal with returning JSON.
* @param string $SearchString Search string.
* @param array $IdExclusions Array of resource IDs for resources to exclude.
* @param array $ValueExclusions Array of values to exclude. This doesn't make
*      sense for this callback, so it's ignored.
*/
function PerformResourcesSearch(
    JsonHelper $JsonHelper,
    $SearchString,
    array $IdExclusions=array(),
    array $ValueExclusions=array())
{
    # additional parameters for this context
    $MaxResults = GetArrayValue($_GET, "MaxNumSearchResults", 10);
    $ExportAllData = GetArrayValue($_GET, "ExportAllData", FALSE);
    $FieldId = GetArrayValue($_GET, "FieldId");

    try
    {
        $Field = new MetadataField($FieldId);
    }

    catch (Exception $Exception)
    {
        $Field = NULL;
    }

    # perform the actual search
    list($NumSearchResults, $NumAdditionalSearchResults, $SearchResults) =
        SearchForResources($SearchString, $MaxResults, $Field, $ExportAllData, $IdExclusions);

    # add the data
    $JsonHelper->AddDatum("NumSearchResults", $NumSearchResults);
    $JsonHelper->AddDatum("NumAdditionalSearchResults", $NumAdditionalSearchResults);
    $JsonHelper->AddDatum("SearchResults", $SearchResults);
    $JsonHelper->Success();
}

/**
* Perform a search for field values from controlled names, etc.
* @param JsonHelper $JsonHelper Object to help deal with returning JSON.
* @param string $SearchString Search string.
* @param array $IdExclusions Array of IDs for values to exclude.
* @param array $ValueExclusions Array of values to exclude.
*/
function PerformMultipleValuesFieldSearch(
    JsonHelper $JsonHelper,
    $SearchString,
    array $IdExclusions=array(),
    array $ValueExclusions=array())
{
    # additional parameters for this context
    $MaxResults = GetArrayValue($_GET, "MaxNumSearchResults");
    $FieldId = GetArrayValue($_GET, "FieldId");

    # valid metadata field types (tree, controlled name, and option fields)
    $ValidFieldTypes = array(
        MetadataSchema::MDFTYPE_TREE,
        MetadataSchema::MDFTYPE_CONTROLLEDNAME,
        MetadataSchema::MDFTYPE_OPTION);

    # the field ID is required
    if (!$FieldId)
    {
        $JsonHelper->Error("The \"FieldId\" parameter is necessary.");
        return;
    }

    try
    {
        # get the field from the field ID
        $Field = new MetadataField($FieldId);
    }

    catch (Exception $Exception)
    {
        # the field ID is invalid
        $JsonHelper->Error("The \"FieldId\" parameter is invalid.");
        return;
    }

    # the field must be enabled
    if (!$Field->Enabled())
    {
        $JsonHelper->Error("The field is disabled.");
        return;
    }

    # must be a valid field type
    if (!in_array($Field->Type(), $ValidFieldTypes))
    {
        $JsonHelper->Error("This field does not use multiple, controlled values.");
        return;
    }

    # the user must be able to view the field
    if ($GLOBALS["User"]->HasPriv($Field->ViewingPrivilege()))
    {
        $JsonHelper->Error("You are not allowed to view this field.");
        return;
    }

    # perform the actual search
    list($NumSearchResults, $NumAdditionalSearchResults, $SearchResults) =
        SearchFieldForValues(
            $Field,
            $SearchString,
            $MaxResults,
            $IdExclusions,
            $ValueExclusions);

    # add the data
    $JsonHelper->AddDatum("NumSearchResults", $NumSearchResults);
    $JsonHelper->AddDatum("NumAdditionalSearchResults", $NumAdditionalSearchResults);
    $JsonHelper->AddDatum("SearchResults", $SearchResults);
    $JsonHelper->Success();
}

/**
* Perform a search for users.
* @param JsonHelper $JsonHelper Object to help deal with returning JSON.
* @param string $SearchString Search string.
* @param array $IdExclusions Array of user IDs for users to exclude.
* @param array $ValueExclusions Array of values to exclude.
*/
function PerformUserSearch(
    JsonHelper $JsonHelper,
    $SearchString,
    array $IdExclusions=array(),
    array $ValueExclusions=array())
{
    # additional parameters for this context
    $MaxResults = GetArrayValue($_GET, "MaxNumSearchResults", 15);

    # 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();

    # 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
            $MaxResults,
            $IdExclusions,
            $ValueExclusions);

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

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

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

    # add the data
    $JsonHelper->AddDatum("NumSearchResults", count($SearchResults));
    $JsonHelper->AddDatum("SearchResults", $SearchResults);
    $JsonHelper->Success();
}

/**
* Function used to delay the execution of the page to minimize problems stemming
* from calling session_write_close(). This could cause errors if there are any
* unbuffered callbacks registered with $AF that require write access to session
* variables. Currently, there are none. session_write_close() is used because
* PHP sessions normally cause AJAX calls to block, forcing them to be
* synchronous, which can cause considerable latency.
*/
function DelayCall()
{
    # how long until the cached results become stale (1800s = 30 minutes)
    $MaxAgeInSeconds = 1800;

    # set headers to control caching
    header("Expires: ".gmdate("D, d M Y H:i:s \G\M\T", time()+$MaxAgeInSeconds));
    header("Cache-Control: private, max-age=".$MaxAgeInSeconds);
    header("Pragma:");

    # PHP sessions cause AJAX calls to block, so make the session readonly from
    # this point on to avoid latency
    session_write_close();

    # create the object to help deal with returning JSON
    $JsonHelper = new JsonHelper();

    # extract some parameters
    $Version = GetArrayValue($_GET, "Version", 1);
    $Context = GetArrayValue($_GET, "Context");
    $SearchString = GetArrayValue($_GET, "SearchString");
    $IdExclusions = GetArrayValue($_GET, "IdExclusions", array());
    $ValueExclusions = GetArrayValue($_GET, "ValueExclusions", array());

    # map contexts to the functions that get results for that context
    $Contexts = array(
        "Resources" => "PerformResourcesSearch",
        "MultipleValuesField" => "PerformMultipleValuesFieldSearch",
        "Users" => "PerformUserSearch");

    # the context parameter is required
    if (is_null($Context) || !strlen(trim($Context)))
    {
        $JsonHelper->Error("The \"Context\" parameter is necessary.");
        return;
    }

    # the context parameter must be valid
    if (!isset($Contexts[$Context]))
    {
        $JsonHelper->Error("The \"Context\" parameter is invalid.");
        return;
    }

    # the search string parameter is required
    if (is_null($SearchString) || !strlen(trim($SearchString)))
    {
        $JsonHelper->Error("The \"SearchString\" parameter is necessary.");
        return;
    }

    # ID exclusions must be an array
    if (!is_array($IdExclusions))
    {
        $JsonHelper->Error("The \"IdExclusions\" parameter must be given as an array of values.");
        return;
    }

    # value exclusions must be an array
    if (!is_array($IdExclusions))
    {
        $JsonHelper->Error("The \"ValueExclusions\" parameter must be given as an array of values.");
        return;
    }

    # perform the search
    call_user_func($Contexts[$Context],
        $JsonHelper,
        $SearchString,
        $IdExclusions,
        $ValueExclusions);
}

# ----- MAIN -----------------------------------------------------------------

# don't output any HTML
$GLOBALS["AF"]->SuppressHTMLOutput();

# delay execution of the page. see the function documentation for more info
$GLOBALS["AF"]->AddUnbufferedCallback("DelayCall");
