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

/**
* SearchFacetUI supports the generation of a user interface for faceted
* search, by taking the search parameters and search results and generating
* the data needed to lay out the HTML.
*/
class SearchFacetUI
{

    # ---- PUBLIC INTERFACE --------------------------------------------------

    /**
    * Constructor, that accepts the search parameters and results and
    * prepares the facet UI data to be retrieved.
    * @param string $BaseLink Base URL for search results, without any search
    *       parameter values.
    * @param object $SearchParams Current search parameter set.
    * @param array $SearchResults Search result array, with resource IDs for the
    *       index and result scores for the values.
    * @param object $User User (if any) who will be using the interface.
    * @param int $MaxFacetsPerField Maximum number of
    * @param int $SchemaId ID of metadata schema for resources.
    */
    public function __construct($BaseLink, $SearchParams, $SearchResults,
            $User, $MaxFacetsPerField, $SchemaId)
    {
        # save base URL, search parameters, and max facets for later use
        $this->BaseLink = $BaseLink;
        $this->SearchParams = $SearchParams;
        $this->MaxFacetsPerField = $MaxFacetsPerField;

        # facets which should be displayed because they were explicitly
        #       included in the search terms
        $this->FieldsOpenByDefault = array();

        # suggestions for Option and ControlledName fields
        #   array( FieldName => array(ModifiedSearchUrls)
        $this->SuggestionsByFieldName = array();

        # suggestions for Tree fields
        #   when nothing is selected,
        #     array( FieldName => array(ModifiedSearchUrls)) {exactly as above}
        #   when something is selected and we should display its children
        #     array( FieldName => array( Selection => array(ModifiedSearchURLs)))
        #       where the last Modified URL is always a link to remove the
        #       current selection
        $this->TreeSuggestionsByFieldName = array();

        # iterrate over the suggested result facets, bulding up a list of
        # those we care to display.  this is necessary because GetResultFacets
        # just gives back a list of all Tree, Option, and ControlledNames that
        # occur in the result set.  For Trees in particular, some processing
        # is necessary as we don't want to just display a value six layers deep
        # when our current search has selected something at the second layer of
        # the tree.
        $Schema = new MetadataSchema($SchemaId);
        $ResultFacets = SPTSearchEngine::GetResultFacets($SearchResults, $User);
        foreach ($ResultFacets as $FieldName => $Suggestions)
        {
            if (!$Schema->FieldExists($FieldName))
            {
                continue;
            }

            $Field = $Schema->GetField($FieldName);
            $FieldId = $Field->Id();

            # foreach field, generate a list of suggested facets
            # and determine if the field should be open by default
            if ($Field->Type() == MetadataSchema::MDFTYPE_TREE)
            {
                $Suggestions = $this->GenerateFacetsForTree($Field, $Suggestions);
            }
            else
            {
                $this->GenerateFacetsForName($Field, $Suggestions);
            }
        }

        # Make sure that we've got a facet displayed for each selected search option
        #  (including those that were not suggested as facets)

        $TreeFieldsToCheck = array();
        # for each field in search parameters
        foreach ($this->SearchParams->GetSearchStrings(TRUE)
                as $FieldId => $Values)
        {
            # if field is valid and viewable
            $Field = $Schema->GetField($FieldId);
            if (is_object($Field)
                    && ($Field->Status() === MetadataSchema::MDFSTAT_OK)
                    && $Field->UserCanView($User))
            {
                # for each value for field
                $FieldName = $Field->Name();
                foreach ($Values as $Value)
                {
                    # for Tree fields
                    if ($Field->Type() == MetadataSchema::MDFTYPE_TREE)
                    {
                        # if value is not already in list
                        if (!isset($this->DisplayedValues[$Field->Name()][$Value]))
                        {
                            $TreeFieldsToCheck[$Field->Name()][]= $Value;
                            # allow removing the current search terms
                            $this->FieldsOpenByDefault[$FieldName] = TRUE;
                            $RemovalLink = $this->RemoveTermFromSearchURL(
                                    $Field, $Value);
                            $this->TreeSuggestionsByFieldName[$FieldName][$Value][]
                                    = array(
                                            "Name" => $Value,
                                            "RemoveLink" => $RemovalLink);
                        }
                    }
                    # for Option and Controlled Name fields
                    elseif ($Field->Type() == MetadataSchema::MDFTYPE_OPTION
                            || $Field->Type() == MetadataSchema::MDFTYPE_CONTROLLEDNAME)
                    {
                        # if this is not a "contains" search parameter
                        if ($Value[0] == "=")
                        {
                            # if value is not already in list
                            if (!isset($this->DisplayedValues[$Field->Name(
                                    )][substr($Value, 1)]))
                            {
                                # note that this field should be open
                                $this->FieldsOpenByDefault[$FieldName] = TRUE;

                                # mark as a facet that can be removed
                                $RemovalLink = $this->RemoveTermFromSearchURL(
                                        $Field, $Value);
                                $this->SuggestionsByFieldName[$Field->Name()][] = array(
                                        "Name" => substr($Value, 1),
                                        "RemoveLink" => $RemovalLink);
                            }
                        }
                    }
                }
            }
        }

        # next, iterate over tree fields and check to see if we need
        # to clean up the link suggestions
        foreach ($TreeFieldsToCheck as $FieldName => $Values)
        {
            # if we have two conditions for this field, the first one
            # is an exact match, the second one is a prefix match, and
            # they refer to the same string then we need to clean this
            # one up
            if (count($Values) == 2 &&
                preg_match('/^=(.*)$/', $Values[0], $FirstMatch) &&
                preg_match('/^\\^(.*) -- $/', $Values[1], $SecondMatch) &&
                $FirstMatch[1] == $SecondMatch[1] )
            {
                # erase the two separate remove links
                unset ($this->TreeSuggestionsByFieldName[$FieldName][$Values[0]]);
                unset ($this->TreeSuggestionsByFieldName[$FieldName][$Values[1]]);

                # pull out our field
                $Field = $Schema->GetField($FieldName);

                # generate a remove link that covers both conditions
                $RemovalLink = $this->RemoveTermFromSearchUrl(
                    $Field, array($Values[0], $Values[1]) );

                # add that link in to our suggestions
                $this->TreeSuggestionsByFieldName[$FieldName][$FirstMatch[1]][]=
                        array("Name" => $FirstMatch[1],
                              "RemoveLink" => $RemovalLink);
            }
        }

        # within each field, sort the suggestions alphabetically
        foreach ($this->TreeSuggestionsByFieldName as &$Suggestion)
        {
            uasort($Suggestion, array($this, "FacetSuggestionSortCallback"));
        }
        foreach ($this->SuggestionsByFieldName as &$Suggestion)
        {
            uasort($Suggestion, array($this, "FacetSuggestionSortCallback"));
        }
    }

    /**
    * Retrieve facet UI data for non-tree metadata fields.
    * @return array Facet data.
    */
    public function GetSuggestionsByFieldName()
    {
        return $this->SuggestionsByFieldName;
    }

    /**
    * Retrieve facet UI data for tree metadata fields.
    * @return array Facet data.
    */
    public function GetTreeSuggestionsByFieldName()
    {
        return $this->TreeSuggestionsByFieldName;
    }

    /**
    * Retrieve which fields should be initially open in facet UI.
    * @return array Indexed by field name.
    */
    public function GetFieldsOpenByDefault()
    {
        return $this->FieldsOpenByDefault;
    }


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

    private $BaseLink;
    private $DisplayedValues;
    private $FieldsOpenByDefault;
    private $MaxFacetsPerField;
    private $SearchParams;
    private $SuggestionsByFieldName;
    private $TreeSuggestionsByFieldName;

    /**
    * Generate a list of desired search facets for a tree field.
    * @param MetadataField $Field Metadata field to process
    * @param array $Suggestions Search suggestions from SPTSearchEngine::GetSearchFacets()
    */
    private function GenerateFacetsForTree($Field, $Suggestions)
    {
        # if we already have selections for this field, dig out the children of that
        #  selection and present those as options:
        $CurrentValues = $this->SearchParams->GetSearchStringsForField($Field);
        if (count($CurrentValues))
        {
            $this->FieldsOpenByDefault[$Field->Name()] = TRUE;
            $ParentValue = $CurrentValues[0];

            # if this was an exact match, rather than a 'contains'
            if (strpos($ParentValue, "=") === 0)
            {
                # cut off the leading '='
                $ParentValue = substr($ParentValue, 1);
            }

            $FacetsForThisTree = $this->GenerateFacetsForSelectedTree(
                    $Field, $Suggestions, $ParentValue);

            # if we have facets to display, but not too many of them
            if ((count($FacetsForThisTree) > 0)
                    && (count($FacetsForThisTree) <= $this->MaxFacetsPerField))
            {
                # and append the required 'remove current' link
                $RemovalLink = $this->RemoveTermFromSearchURL($Field, $CurrentValues);
                $FacetsForThisTree[] = array(
                                "Name" => $ParentValue,
                                "RemoveLink" => $RemovalLink);

                # mark each value for this field displayed
                foreach ($CurrentValues as $Val)
                {
                    $this->DisplayedValues[$Field->Name()][$Val] = TRUE;
                }

                $this->TreeSuggestionsByFieldName[$Field->Name()] =
                        array($FacetsForThisTree);
            }
        }
        else
        {
            # otherwise, list the toplevel options for this field
            $FacetsForThisTree = $this->GenerateFacetsForUnselectedTree(
                    $Field, $Suggestions);

            # if we have facets to display, but not too many add the toplevel options
            #  to the suggestions for this field
            if ((count($FacetsForThisTree) > 0)
                    && (count($FacetsForThisTree) <= $this->MaxFacetsPerField))
            {
                $this->TreeSuggestionsByFieldName[$Field->Name()] =
                        $FacetsForThisTree;
            }
        }
    }

    /**
    * Generate a list of desired search facets for a tree that is selected
    *   (i.e. one or more values appear in the current search)
    * @param MetadataField $Field Field to process
    * @param array $Suggestions Search suggestions from SPTSearchEngine::GetSearchFacets()
    * @param string $ParentValue Current setting for this field
    * @return array of search facets formatted for PrintSearchFacets()
    */
    private function GenerateFacetsForSelectedTree($Field, $Suggestions, $ParentValue)
    {
        $ParentSegments = explode(" -- ", $ParentValue);

        # keep track of what has been shown
        $DisplayedSegments = array();

        $FacetsForThisTree = array();
        # pull out those classifications which were explicitly assigned to something.
        #  those are the ones we can get counts for:
        $FieldName = $Field->Name();
        foreach ($Suggestions as $ValueId => $ValueData)
        {
            $ValueName = $ValueData["Name"];
            $ValueCount = $ValueData["Count"];
            $FieldSegments = explode(" -- ", $ValueName);

            # print any children of the current field value
            #  (determined based on a prefix match
            if ( preg_match("/^".preg_quote($ParentValue." -- ", "/")."/", $ValueName)
                 && (count($FieldSegments) == (count($ParentSegments) + 1)))
            {
                # note that we've already displayed this segment because it was selected
                #  allowing us to avoid displaying it twice
                $DisplayedSegments[$ValueName]=TRUE;
                $LeafValue = array_pop($FieldSegments);

                # add the modified search URL to our list of facets for this field
                $AdditionLink = $this->AddTermToSearchURL($Field, $ValueName);

                $FacetsForThisTree[] = array(
                                "Name" => $LeafValue,
                                "AddLink" => $AdditionLink,
                                "Count" => $ValueCount);

                # record value as having been displayed
                $this->DisplayedValues[$FieldName][$LeafValue] = TRUE;
            }
        }

        # now, we'll need to iterate over the suggestions again and make sure that we've
        #  displayed proper suggestions even for fields where only the lowest level
        #  elements are displayed
        foreach ($Suggestions as $ValueId => $ValueData)
        {
            $ValueName = $ValueData["Name"];
            $ValueCount = $ValueData["Count"];

            # get an array of the segments for this suggestion
            $FieldSegments = explode(" -- ", $ValueName);

            # skip segments that are along some other branch of this tree
            if (!preg_match("/^".preg_quote($ParentValue." -- ", "/")
                    ."/", $ValueName))
            {
                continue;
            }

            # skip segments that aren't (grand)* children of the current parent.
            if (count($FieldSegments) < count($ParentSegments))
            {
                continue;
            }

            # truncate our array of segments to include only the
            # level below our currently selected level
            $TargetSegments = array_slice($FieldSegments, 0,
                    (count($ParentSegments) + 1));

            # reassemble this into a Classification string
            $TargetName = implode(" -- ", $TargetSegments);

            # if we haven't already displayed this string, either
            # because it was in an earlier suggestion or was
            # actually included in the search, add it to our list
            # now
            if (!isset($DisplayedSegments[$TargetName]))
            {
                # as above, note that we've displayed this segment
                # and add a modified search URL to our list of
                # facets to display
                $DisplayedSegments[$TargetName] = TRUE;
                $LeafValue = array_pop($TargetSegments);
                $AdditionLink = $this->AddTermToSearchURL($Field, $TargetName);
                $FacetsForThisTree[] = array(
                        "Name" => $LeafValue,
                        "AddLink" => $AdditionLink);

                # record value as having been displayed
                $this->DisplayedValues[$FieldName][$LeafValue] = TRUE;
            }
        }

        return $FacetsForThisTree;
    }

    /**
    * Generate a list of desired search facets for a tree that does not
    *   appear in the current search
    * @param MetadataField $Field Field to process
    * @param array $Suggestions Search suggestions from SPTSearchEngine::GetSearchFacets()
    * @return array of search facets formatted for PrintSearchFacets()
    */
    private function GenerateFacetsForUnselectedTree($Field, $Suggestions)
    {
        $FacetsForThisTree = array();

        # keep track of what has been already shown
        $DisplayedSegments = array();

        # first, pull out those which are assigned to some reasources and for which
        # we can compute counts (those with the top-level directly assigned)
        $FieldName = $Field->Name();
        foreach ($Suggestions as $ValueId => $ValueData )
        {
            $ValueName = $ValueData["Name"];
            $ValueCount = $ValueData["Count"];
            $FieldSegments = explode(" -- ", $ValueName);

            # if this is a top level field
            if (count($FieldSegments) == 1)
            {
                # add it to our list of facets for this tree
                $DisplayedSegments[$ValueName] = TRUE;
                $AdditionLink = $this->AddTermToSearchURL($Field, $ValueName);
                $FacetsForThisTree[] = array(
                        "Name" => $ValueName,
                        "AddLink" => $AdditionLink,
                        "Count" => $ValueCount);

                # record value as having been displayed
                $this->DisplayedValues[$FieldName][$ValueName] = TRUE;
            }
        }

        # scan through the suggestions again, and verify that
        # we've at least offered the top-level
        # option for each entry (necessary for cases where no
        # resources in the results set have the top-level value,
        # e.g. they all have Science -- Technology but none have
        # Science.  in this case we can't display counts, but
        # should at least suggest Science as an option)
        foreach ($Suggestions as $ValueId => $ValueData)
        {
            $ValueName = $ValueData["Name"];
            $FieldSegments = explode(" -- ", $ValueName);

            # if none of our previous efforts have displayed this segment
            if (!isset($DisplayedSegments[$FieldSegments[0]]))
            {
                # add it to our list
                $DisplayedSegments[$FieldSegments[0]] = TRUE;
                $AdditionLink = $this->AddTermToSearchURL(
                        $Field, $FieldSegments[0]);
                $FacetsForThisTree[] = array(
                        "Name" => $FieldSegments[0],
                        "AddLink" => $AdditionLink,
                        "Count" => $ValueData["Count"]);

                # record value as having been displayed
                $this->DisplayedValues[$FieldName][$FieldSegments[0]] = TRUE;
            }
        }

        return $FacetsForThisTree;
    }

    /**
    * Generate a list of desired search facets for a ControlledName or Option field.
    * @param MetadataField $Field Metadata field to process
    * @param array $Suggestions Search suggestions from
    *       SPTSearchEngine::GetSearchFacets()
    */
    private function GenerateFacetsForName($Field, $Suggestions)
    {
        # for option fields, bail when there are too many suggestions for this option
        if (count($Suggestions) > $this->MaxFacetsPerField)
        {
            return;
        }

        # retrieve current search parameter values for field
        $CurrentValues = $this->SearchParams->GetSearchStringsForField($Field);

        # if a field is required, and we have only one suggestion and
        # there is no current value, then we don't want to display this
        # facet because the search results will be identical
        if (($Field->Optional() == FALSE)
                && (count($Suggestions) == 1)
                && !count($CurrentValues))
        {
            return;
        }

        # for each suggested value
        $FieldName = $Field->Name();
        foreach ($Suggestions as $ValueId => $ValueData)
        {
            $ValueName = $ValueData["Name"];

            # if we have a current value that is selected for this search
            if (in_array("=".$ValueName, $CurrentValues))
            {
                # note that this facet should be displayed and add a
                #  remove link for it
                $this->FieldsOpenByDefault[$FieldName] = TRUE;
                $RemovalLink = $this->RemoveTermFromSearchURL(
                        $Field, "=".$ValueName);
                $this->SuggestionsByFieldName[$FieldName][] = array(
                        "Name" => $ValueName,
                        "RemoveLink" => $RemovalLink,
                        "Count" => $ValueData["Count"]);
            }
            else
            {
                # otherwise, put an 'add' link in our suggestions,
                #       displaying counts if we can
                $AdditionLink = $this->AddTermToSearchURL(
                        $Field, "=".$ValueName);
                $this->SuggestionsByFieldName[$FieldName][] = array(
                        "Name" => $ValueName,
                        "AddLink" => $AdditionLink,
                        "Count" => $ValueData["Count"]);
            }

            # record value as having been displayed
            $this->DisplayedValues[$FieldName][$ValueName] = TRUE;
        }
    }

    /**
    * Add a specified search to the URL of the current page, assumed to be
    * a search URL.
    * @param MetadataField $Field Metadata field for this term.
    * @param mixed $Term Search term to add.
    * @return string URL with search parameters including new term.
    */
    private function AddTermToSearchURL($Field, $Term)
    {
        # create our own copy of search parameters
        $OurSearchParams = clone $this->SearchParams;

        # if this is not a tree field type
        if ($Field->Type() != MetadataSchema::MDFTYPE_TREE)
        {
            # retrieve subgroups from search parameters
            $Subgroups = $OurSearchParams->GetSubgroups();

            # find subgroup for this field
            foreach ($Subgroups as $Group)
            {
                if (in_array($Field->Id(), $Group->GetFields()))
                {
                    $Subgroup = $Group;
                    break;
                }
            }

            # if subgroup found
            if (isset($Subgroup))
            {
                # add term to subgroup
                $Subgroup->AddParameter($Term, $Field);
            }
            else
            {
                # create new subgroup
                $Subgroup = new SearchParameterSet();

                # set logic for new subgroup
                $Subgroup->Logic($Field->SearchGroupLogic());

                # add term to subgroup
                $Subgroup->AddParameter($Term, $Field);

                # add subgroup to search parameters
                $OurSearchParams->AddSet($Subgroup);
            }
        }
        else
        {
            # add specified term to search parameters
            $OurSearchParams->RemoveParameter(NULL, $Field);
            $Subgroup = new SearchParameterSet();
            $Subgroup->Logic("OR");
            $Subgroup->AddParameter(array(
                    "=".$Term, "^".$Term." -- "), $Field);
            $OurSearchParams->AddSet($Subgroup);
        }

        # build new URL with revised search parameters
        $Url = implode("&amp;", array_filter(array(
                $this->BaseLink, $OurSearchParams->UrlParameterString())));

        # return new URL to caller
        return $Url;
    }

    /**
    * Remove a specified search term to the URL of the current page, which
    * is assumed to be a search URL.
    * @param MetadataField $Field Metadata field for this term
    * @param mixed $Terms Term or array of Terms to remove
    * @return string URL with search parameters with term removed.
    */
    private function RemoveTermFromSearchURL($Field, $Terms)
    {
        # create our own copy of search parameters with specified parameter removed
        $OurSearchParams = clone $this->SearchParams;

        if ($Field->Type() == MetadataSchema::MDFTYPE_TREE)
        {
            # pull out the old setting
            $OldSetting = $OurSearchParams->GetSearchStringsForField($Field);
            $OurSearchParams->RemoveParameter($Terms, $Field);

            # if we had an exact + prefix match for a specified level of the tree,
            # move up one level
            if (count($OldSetting) == 2 &&
                preg_match('/^=(.*)$/', $OldSetting[0], $FirstMatch) &&
                preg_match('/^\\^(.*) -- $/', $OldSetting[1], $SecondMatch) &&
                $FirstMatch[1] == $SecondMatch[1] )
            {
                # explode out the segments
                $FieldSegments = explode(" -- ", $FirstMatch[1]);

                # remove the last one
                array_pop($FieldSegments);

                # if we have any segments left
                if (count($FieldSegments))
                {
                    # construct revised setting
                    $NewSetting = implode(" -- ", $FieldSegments);
                    $Subset = new SearchParameterSet();
                    $Subset->Logic("OR");
                    $Subset->AddParameter(
                        array("=".$NewSetting, "^".$NewSetting." -- "),
                        $Field);
                    $OurSearchParams->AddSet($Subset);
                }
            }
        }
        else
        {
            $OurSearchParams->RemoveParameter($Terms, $Field);
        }

        # build new URL with revised search parameters
        $Url = implode("&amp;", array_filter(array(
                $this->BaseLink, $OurSearchParams->UrlParameterString())));

        # return new URL to caller
        return $Url;
    }

    /**
    * Callback method for sorting facet entries.
    * @param array $A First entry for sort.
    * @param array $B Second entry for sort.
    * @return int 0/1/-1 as appropriate for sort callbacks.
    */
    private function FacetSuggestionSortCallback($A, $B)
    {
        if (isset($A["Name"]))
        {
            return strcmp($A["Name"], $B["Name"]);
        }
        else
        {
            $CountA = count($A);
            $CountB = count($B);
            return ($CountA == $CountB) ? 0
                    : (($CountA < $CountB) ? -1 : 1);
        }
    }
}
