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

/**
* Set of privileges used to access resource information or other parts of
* the system.  A privilege set is a combination of privileges (integers),
* MetadataFields (to check against a specified value), and
* privilege/MetadataField combinations.
*/
class PrivilegeSet
{

    # used as a field ID in conditions to test whether a resources is
    #       available as part of the privilege check
    const HAVE_RESOURCE = -1;

    /**
    * Class constructor, used to create a new set or reload an existing
    * set from previously-constructed data.
    * @param string $Data Existing privilege set data, previously
    *       retrieved with PrivilegeSet::Data().  (OPTIONAL)
    * @see PrivilegeSet::Data()
    */
    public function __construct($Data = NULL)
    {
        # if privilege data supplied
        if ($Data !== NULL)
        {
            # if data is in legacy form (an array of privileges)
            if (is_array($Data))
            {
                # set internal privilege set from array
                $this->Privileges = $Data;
            }
            else
            {
                # set internal values from data
                $this->LoadFromData($Data);
            }
        }
    }

    /**
    * Get/set privilege set data, in the form of an opaque string.  This
    * method can be used to retrieve an opaque string containing privilege
    * set data, which can then be saved (e.g. to a database) and later used
    * to reload a privilege set.  (Use instead of serialize() to avoid
    * future issues with internal class changes.)
    * @param string $NewValue New privilege set data.  (OPTIONAL)
    * @return string Current privilege set data (opaque value).
    */
    public function Data($NewValue = NULL)
    {
        # if new data supplied
        if ($NewValue !== NULL)
        {
            # unpack privilege data and load
            $this->LoadFromData($NewValue);
        }

        # serialize current data and return to caller
        $Data = array();
        if (count($this->Privileges))
        {
            foreach ($this->Privileges as $Priv)
            {
                $Data["Privileges"][] = is_object($Priv)
                        ? array("SUBSET" => $Priv->Data())
                        : $Priv;
            }
        }
        $Data["Logic"] = $this->Logic;
        return serialize($Data);
    }

    /**
    * Determine if a given user meets the requirements specified by
    * this PrivilegeSet.  Typically used to determine if a user should
    * be allowed access to a particular piece of data.
    * @param object $User CWUser object to use in comparisons.
    * @param object $Resource Resource object to used for comparison, for
    *       sets that include user conditions.  (OPTIONAL)
    * @return bool TRUE if privileges in set are greater than or equal to
    *       privileges in specified set, otherwise FALSE.
    */
    public function MeetsRequirements(CWUser $User, $Resource = self::NO_RESOURCE)
    {
        # when there are no requirements, then every user meets them
        $Satisfied = TRUE;

        # for each privilege requirement
        foreach ($this->Privileges as $Priv)
        {
            # if privilege is actually a privilege subgroup
            if (is_object($Priv))
            {
                # check if the subgroup is satisfied
                $Satisfied = $Priv->MeetsRequirements($User, $Resource);
            }
            # else if privilege is actually a condition
            elseif (is_array($Priv))
            {
                # check if condition is satisfied for the given resource
                $Satisfied = $this->MeetsCondition($Priv, $Resource, $User);
            }
            # else privilege is actually a privilege
            else
            {
                # check if user has the spcified privilege
                $Satisfied = $User->HasPriv( $Priv );
            }

            # for AND logic, we can bail as soon as the first
            # condition is not met
            if ($this->Logic == "AND")
            {
                if (!$Satisfied)
                {
                    break;
                }
            }
            # conversely, for OR logic, we can bail as soon as any
            # condition is met
            else
            {
                if ($Satisfied)
                {
                    break;
                }
            }
        }

        # report result of the test back to caller
        return $Satisfied;
    }

    /**
    * Add specified privilege to set.  If specified privilege is already
    * part of the set, no action is taken.
    * @param mixed $Privileges Privilege ID or object (or array of IDs or objects).
    * @see PrivilegeSet::RemovePrivilege()
    */
    public function AddPrivilege($Privileges)
    {
        # convert incoming value to array if needed
        if (!is_array($Privileges))
        {
            $Privileges = array($Privileges);
        }

        # for each privilege passed in
        foreach ($Privileges as $Privilege)
        {
            # add privilege if not currently in set
            if (!$this->IncludesPrivilege($Privilege))
            {
                if (is_object($Privilege)) {  $Privilege = $Privilege->Id();  }
                $this->Privileges[] = $Privilege;
            }
        }
    }

    /**
    * Remove specified privilege from set.  If specified privilege is not
    * currently in the set, no action is taken.
    * @param mixed $Privilege Privilege ID or object to remove from set.
    * @see PrivilegeSet::AddPrivilege()
    */
    public function RemovePrivilege($Privilege)
    {
        # remove privilege if currently in set
        if ($this->IncludesPrivilege($Privilege))
        {
            if (is_object($Privilege)) {  $Privilege = $Privilege->Id();  }
            $Index = array_search($Privilege, $this->Privileges);
            unset($this->Privileges[$Index]);
        }
    }

    /**
    * Check whether this privilege set includes the specified privilege.
    * @param mixed $Privilege Privilege ID or object to check.
    * @return bool TRUE if privilege is included, otherwise FALSE.
    */
    public function IncludesPrivilege($Privilege)
    {
        # check whether privilege is in our list and report to caller
        if (is_object($Privilege)) {  $Privilege = $Privilege->Id();  }
        return $this->IsInPrivilegeData($Privilege) ? TRUE : FALSE;
    }

    /**
    * Get privilege information as an array, with numerical indexes
    * except for the logic, which is contained in a element with the
    * index "Logic".  Values are either an associative array with
    * three elements, "FieldId", "Operator", and "Value", or a
    * PrivilegeSet object (for subsets).
    * @return array Array with privilege information.
    */
    public function GetPrivilegeInfo()
    {
        # grab privilege information and add logic
        $Info = $this->Privileges;
        $Info["Logic"] = $this->Logic;

        # return privilege info array to caller
        return $Info;
    }

    /**
    * Get list of privileges.  (Intended primarily for supporting legacy
    * privilege operations -- list contains privilege IDs only, and does
    * not include conditions.)
    * @return array Array of privilege IDs.
    */
    public function GetPrivilegeList()
    {
        # create list of privileges with conditions stripped out
        $List = array();
        foreach ($this->Privileges as $Priv)
        {
            if (!is_array($Priv)) {  $List[] = $Priv;  }
        }

        # return list of privileges to caller
        return $List;
    }

    /**
    * Add condition to privilege set.  If the condition is already present
    * in the set, no action is taken.  The $Field argument may also be
    * PrivilegeSet::HAVE_RESOURCE to test against whether a resource is
    * available for the privilege set check.
    * @param mixed $Field Metadata field object or ID to test against.
    * @param mixed $Value Value to test against.  (Specify NULL for User
    *       fields to test against current user and for Date/Timestamp fields
    *       to test against the current date and time.)
    * @param string $Operator String containing operator to used for
    *       condition.  (Standard PHP operators are used.)  (OPTIONAL,
    *       defaults to "==")
    * @return bool TRUE if condition was added, otherwise FALSE.
    */
    public function AddCondition($Field, $Value = NULL, $Operator = "==")
    {
        # get field ID
        $FieldId = is_object($Field) ? $Field->Id() : $Field;

        # set up condition array
        $Condition = array(
                "FieldId" => intval($FieldId),
                "Operator" => trim($Operator),
                "Value" => $Value);

        # if condition is not already in set
        if (!$this->IsInPrivilegeData($Condition))
        {
            # add condition to privilege set
            $this->Privileges[] = $Condition;
            return TRUE;
        }
        return FALSE;
    }

    /**
    * Remove condition from privilege set.  If condition was not present
    * in privilege set, no action is taken.
    * @param mixed $Field Metadata field object or ID to test against.
    * @param mixed $Value Value to test against.  (Specify NULL for User
    *       fields to test against current user.)
    * @param string $Operator String containing operator to used for
    *       condition.  (Standard PHP operators are used.)  (OPTIONAL,
    *       defaults to "==")
    * @return bool TRUE if condition was removed, otherwise FALSE.
    */
    public function RemoveCondition($Field, $Value = NULL, $Operator = "==")
    {
        # get field ID
        $FieldId = is_object($Field) ? $Field->Id() : $Field;

        # set up condition array
        $Condition = array(
                "FieldId" => intval($FieldId),
                "Operator" => trim($Operator),
                "Value" => $Value);

        # if condition is in set
        if ($this->IsInPrivilegeData($Condition))
        {
            # remove condition from privilege set
            $Index = array_search($Condition, $this->Privileges);
            unset($this->Privileges[$Index]);
            return TRUE;
        }
        return FALSE;
    }

    /**
    * Add subgroup of privileges/conditions to set.
    * @param PrivilegeSet $Set Subgroup to add.
    */
    public function AddSet(PrivilegeSet $Set)
    {
        # if subgroup is not already in set
        if (!$this->IsInPrivilegeData($Set))
        {
            # add subgroup to privilege set
            $this->Privileges[] = $Set;
        }
    }

    /**
    * Get/set whether all privileges/conditions in set are required (i.e.
    * "AND" logic), or only one privilege/condition needs to be met ("OR").
    * By default only one of the specified privilegs/conditions in a set
    * is required.
    * @param bool $NewValue Specify TRUE if all privileges are required,
    *       otherwise FALSE if only one privilege required.  (OPTIONAL)
    * @return bool TRUE if all privileges required, otherwise FALSE.
    */
    public function AllRequired($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            $this->Logic = $NewValue ? "AND" : "OR";
        }
        return ($this->Logic == "AND") ? TRUE : FALSE;
    }

    /**
    * List which privilege flags (e.g. PRIV_MYRESOURCEADMIN) are examined by
    *  this privset.
    * @return Array of privilege flags checked.
    */
    public function PrivilegeFlagsChecked()
    {
        $Info = $this->GetPrivilegeInfo();
        unset ($Info["Logic"]);

        $Result = array();
        foreach ($Info as $Item)
        {
            if (is_object($Item))
            {
                $Result = array_merge($Result, $Item->PrivilegeFlagsChecked() );
            }
            elseif (!is_array($Item))
            {
                $Result[]= $Item;
            }
        }
        return array_unique($Result);
    }

    /**
    * List which fields in this privset are involved in UserIs or UserIsNot
    *  comparisons for this privilege set.
    * @param string $ComparisonType Comparison Type (one of "==" or "!=")
    * @return Array of FieldIds that have a User comparison.
    */
    public function FieldsWithUserComparisons($ComparisonType)
    {
        $Info = $this->GetPrivilegeInfo();
        unset ($Info["Logic"]);

        $Result = array();
        foreach ($Info as $Item)
        {
            if (is_object($Item))
            {
                $Result = array_merge(
                    $Result,
                    $Item->FieldsWithUserComparisons( $ComparisonType ) );
            }
            elseif (is_array($Item))
            {
                if ($Item["Operator"] == $ComparisonType &&
                    $Item["FieldId"] > 0 )
                {
                    $Field = new MetadataField($Item["FieldId"]);

                    if ($Field->Type() == MetadataSchema::MDFTYPE_USER)
                    {
                        $Result[]= $Item["FieldId"];
                    }
                }
            }
        }

        return array_unique($Result);
    }

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

    private $Privileges = array();
    private $Logic = "OR";

    const NO_RESOURCE = "XXX NO RESOURCE XXX";

    /**
    * Load privileges from serialized data.
    * @param string $Serialized Privilege data.
    */
    private function LoadFromData($Serialized)
    {
        # save calling context in case load causes out-of-memory crash
        $GLOBALS["AF"]->RecordContextInCaseOfCrash();

        # unpack new data
        $Data = unserialize($Serialized);

        # unpack privilege data (if available) and load
        if (array_key_exists("Privileges", $Data))
        {
            $this->Privileges = array();
            foreach ($Data["Privileges"] as $Priv)
            {
                if (is_array($Priv) && array_key_exists("SUBSET", $Priv))
                {
                    $Subset = new PrivilegeSet();
                    $Subset->LoadFromData($Priv["SUBSET"]);
                    $this->Privileges[] = $Subset;
                }
                else
                {
                    $this->Privileges[] = $Priv;
                }
            }
        }

        # load logic if available
        if (array_key_exists("Logic", $Data))
        {
            $this->Logic = $Data["Logic"];
        }
    }

    /**
    * Check whether this privilege set meets the specified condition.
    * @param array $Condition Condition to check.
    * @param object $Resource Resource to use when checking.
    * @param CWUser $User User to use when checking.
    * @return bool TRUE if condition is met, otherwise FALSE.
    */
    private function MeetsCondition($Condition, $Resource, $User)
    {
        # if condition is a check for whether a resource is available
        if ($Condition["FieldId"] == self::HAVE_RESOURCE)
        {
            # return a result based on whether a resource is available
            return ((bool)($Resource == self::NO_RESOURCE)
                    != (bool)$Condition["Value"]) ? TRUE : FALSE;
        }
        # else if no resource is available
        elseif ($Resource == self::NO_RESOURCE)
        {
            # return a result that in effect ignores the condition
            return ($this->Logic == "AND") ? TRUE : FALSE;
        }
        # else if resource is valid
        elseif ($Resource instanceof Resource)
        {
            # pre-process condition parameters based on type of field
            try
            {
                $Field = new MetadataField($Condition["FieldId"]);
            }
            catch (Exception $e)
            {
                # if the field in a condition was invalid, the condition fails
                return FALSE;
            }

            $Operator = $Condition["Operator"];
            $Value = $Condition["Value"];
            $FieldValue  = $Resource->Get($Field, TRUE);
            switch ($Field->Type())
            {
                case MetadataSchema::MDFTYPE_USER:
                    # if supplied value is NULL
                    if ($Value === NULL)
                    {
                        $Value = $User->Id();
                    }

                    # get the UserIds contained in this field
                    $FieldValue = array_keys($FieldValue);
                    break;

                case MetadataSchema::MDFTYPE_DATE:
                case MetadataSchema::MDFTYPE_TIMESTAMP:
                    # date field values are Date objects, so handle those
                    if ($FieldValue instanceof Date)
                    {
                        $FieldValue = strtotime($FieldValue->Formatted());
                    }

                    # timestamp field values are just the date/time string
                    else
                    {
                        $FieldValue = strtotime($FieldValue);
                    }

                    # use the current time for the value if it's NULL
                    if ($Value === NULL)
                    {
                        $Value = time();
                    }

                    # otherwise, parse the value to get a numeric timestamp
                    else
                    {
                        $Value = strtotime($Value);
                    }
                    break;

                case MetadataSchema::MDFTYPE_NUMBER:
                case MetadataSchema::MDFTYPE_FLAG:
                    break;

                case MetadataSchema::MDFTYPE_OPTION:
                    # for options, construct a list of the CNIDs in this field
                    $NewValue = array();
                    foreach ($FieldValue as $CName)
                    {
                        $NewValue[]= $CName->Id();
                    }
                    $FieldValue = $NewValue;
                    break;

                default:
                    throw new Exception("Unsupported metadata field type ("
                            .print_r($Field->Type(), TRUE)
                            .") for condition in privilege set.");
                    break;
            }

            # compare field value and supplied value using specified operator
            switch ($Operator)
            {
                case "==":
                    if (is_array($FieldValue))
                    {
                        # equality against an option field is a 'contains' condition,
                        # true if the specified value is one of those set
                        $Result = FALSE;
                        foreach ($FieldValue as $FieldValue_i)
                        {
                            $Result |= ($FieldValue_i == $Value);
                        }
                    }
                    else
                    {
                        $Result = ($FieldValue == $Value);
                    }
                    break;

                case "!=":
                    if (is_array($FieldValue))
                    {
                        # not equal against an option field is 'does not contains',
                        # true as long as the spcified value is not one of those set
                        $Result = TRUE;
                        foreach ($FieldValue as $FieldValue_i)
                        {
                            $Result &= ($FieldValue_i != $Value);
                        }
                    }
                    else
                    {
                        $Result = ($FieldValue != $Value);
                    }
                    break;

                case "<":
                    $Result = ($FieldValue < $Value);
                    break;

                case ">":
                    $Result = ($FieldValue > $Value);
                    break;

                case "<=":
                    $Result = ($FieldValue <= $Value);
                    break;

                case ">=":
                    $Result = ($FieldValue >= $Value);
                    break;

                default:
                    throw new Exception("Unsupported condition operator ("
                            .print_r($Operator, TRUE).") in privilege set.");
                    break;
            }

            # report to caller whether condition was met
            return $Result ? TRUE : FALSE;
        }
        else
        {
            # error out because resource was illegal
            throw new Exception("Invalid Resource passed in for privilege"
                    ." set comparison.");
        }
    }

    /**
    * Check whether specified item (privilege, condition, or subgroup) is
    * currently in the list of privileges.  This is necessary instead of just
    * using in_array() because in_array() generates NOTICE messages if the
    * array contains mixed types.
    * @param mixed $Item Item to look for.
    * @return bool TRUE if item found in privilege list, otherwise FALSE.
    */
    private function IsInPrivilegeData($Item)
    {
        # step through privilege data
        foreach ($this->Privileges as $Priv)
        {
            # report to caller if item is found
            if (is_object($Item))
            {
                if (is_object($Priv) && ($Item == $Priv)) {  return TRUE;  }
            }
            elseif (is_array($Item))
            {
                if (is_array($Priv) && ($Item == $Priv)) {  return TRUE;  }
            }
            elseif ($Item == $Priv) {  return TRUE;  }
        }

        # report to caller that item is not in privilege data
        return FALSE;
    }
}
