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

/**
* Represents a "resource" in CWIS.
*/
class Resource
{

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

    /**
    * Object constructor for loading an existing resource.  (To create a new
    * resource, use Resource::Create().)
    * @param int $ResourceId ID of resource to load.
    * @see Create()
    */
    public function __construct($ResourceId)
    {
        $this->DB = new Database();

        # save resource ID
        $this->Id = intval($ResourceId);

        # locate resource in database
        $this->DB->Query("SELECT * FROM Resources WHERE ResourceId = ".$this->Id);

        # if unable to locate resource
        $Record = $this->DB->FetchRow();
        if ($Record == FALSE)
        {
            # set status to -1 to indicate that creation failed
            $this->LastStatus = -1;
        }
        else
        {
            # load in attributes from database
            $this->DBFields = $Record;
            $this->CumulativeRating = $Record["CumulativeRating"];

            # load our local metadata schema
            $this->SchemaId = $this->DBFields["SchemaId"];
            if (!isset(self::$Schemas[$this->SchemaId]))
            {
                self::$Schemas[$this->SchemaId] =
                        new MetadataSchema($this->SchemaId);
            }

            # set status to 1 to indicate that creation succeeded
            $this->LastStatus = 1;
        }
    }

    /**
    * Create a new resource.
    * @param int $SchemaId ID of metadata schema for new resource.
    * @return object Resource object.
    * @throws Exception If resource creation failed.
    */
    public static function Create($SchemaId)
    {
        # clean out any temp resource records more than three days old
        $RFactory = new ResourceFactory();
        $RFactory->CleanOutStaleTempItems(60 * 24 * 3);

        # lock DB tables to prevent next ID from being grabbed
        $DB = new Database;
        $DB->Query("LOCK TABLES Resources WRITE");

        # find next temp resource ID
        $Id = $RFactory->GetNextTempItemId();

        # write out new resource record with temp resource ID
        #  Set DateLastModified = NOW() to avoid being pruned as a
        #  stale temp resource.
        $DB->Query(
        "INSERT INTO Resources
            SET `ResourceId` = '".intval($Id)."',
            `SchemaId` = '".intval($SchemaId)."',
            `DateLastModified` = NOW() " );

        # release DB tables
        $DB->Query("UNLOCK TABLES");

        # create new Resource object
        $Resource = new Resource($Id);

        if ($Resource->Status() == -1)
        {
            throw new Exception("Resource creation failed");
        }

        # set some additional fields for default resources
        if ($SchemaId == MetadataSchema::SCHEMAID_DEFAULT)
        {
            $Resource->Set("Added By Id", $GLOBALS["G_User"]->Id());
            $Resource->Set("Last Modified By Id", $GLOBALS["G_User"]->Id());
            $Resource->Set("Date Of Record Creation", date("Y-m-d H:i:s"));
            $Resource->Set("Date Last Modified", date("Y-m-d H:i:s"));
        }

        # for each field that can have a default value
        $Schema = new MetadataSchema($SchemaId);
        $Fields = $Schema->GetFields(MetadataSchema::MDFTYPE_OPTION
                |MetadataSchema::MDFTYPE_TEXT
                |MetadataSchema::MDFTYPE_FLAG
                |MetadataSchema::MDFTYPE_NUMBER
                |MetadataSchema::MDFTYPE_POINT);
        foreach ($Fields as $Field)
        {
            # if there is a default value available
            $DefaultValue = $Field->DefaultValue();
            if ($DefaultValue !== NULL)
            {
                # if the default value is an array
                if (is_array($DefaultValue))
                {
                    # if there are values in the array
                    if (!empty($DefaultValue))
                    {
                        # flip values for Set() if necessary
                        if ($Field->Type() == MetadataSchema::MDFTYPE_OPTION)
                        {
                            $DefaultValue = array_flip($DefaultValue);
                        }

                        # set default value
                        $Resource->Set($Field, $DefaultValue);
                    }
                }
                else
                {
                    # set default value
                    $Resource->Set($Field, $DefaultValue);
                }
            }
        }

        # update timestamps as required
        $TimestampFields = $Schema->GetFields(MetadataSchema::MDFTYPE_TIMESTAMP);
        foreach ($TimestampFields as $Field)
        {
            if ($Field->UpdateMethod() ==
                MetadataField::UPDATEMETHOD_ONRECORDCREATE)
            {
                $Resource->SetByField($Field, "now");
            }
        }

        # signal resource creation
        $GLOBALS["AF"]->SignalEvent("EVENT_RESOURCE_CREATE", array(
                "Resource" => $Resource,
                ));

        # return new Resource object to caller
        return $Resource;
    }

    /**
    * Remove resource (and accompanying associations) from database and
    * delete any associated files.
    */
    public function Delete()
    {
        global $SysConfig;

        # signal that resource deletion is about to occur
        global $AF;
        $AF->SignalEvent("EVENT_RESOURCE_DELETE", array(
                "Resource" => $this,
                ));

        # grab list of classifications
        $Classifications = $this->Classifications();

        # delete resource/classification intersections
        $DB = $this->DB;
        $DB->Query("DELETE FROM ResourceClassInts WHERE ResourceId = ".$this->Id());

        # for each classification type
        foreach ($Classifications as $ClassType => $ClassesOfType)
        {
            # for each classification of that type
            foreach ($ClassesOfType as $ClassId => $ClassName)
            {
                # recalculate resource count for classification
                $Class = new Classification($ClassId);
                $Class->RecalcResourceCount();
            }
        }

        # delete resource references
        $DB->Query("
            DELETE FROM ReferenceInts
            WHERE SrcResourceId = '".addslashes($this->Id())."'
            OR DstResourceId = '".addslashes($this->Id())."'");

        # delete resource/name intersections
        $DB->Query("DELETE FROM ResourceNameInts WHERE ResourceId = ".$this->Id());

        # delete resource/user intersections
        $DB->Query("DELETE FROM ResourceUserInts WHERE ResourceId = ".$this->Id());

        # get the list of all images associated with this resource
        $DB->Query("SELECT ImageId FROM ResourceImageInts"
                ." WHERE ResourceId = ".intval($this->Id()));
        $ImageIds = $DB->FetchColumn("ImageId");

        # disassociate this resource from all images
        $DB->Query("DELETE FROM ResourceImageInts"
            ." WHERE ResourceId = ".intval($this->Id()));

        # delete any images that no longer belong to any resources
        foreach ($ImageIds as $ImageId)
        {
            $DB->Query("SELECT ResourceId FROM ResourceImageInts"
                ." WHERE ImageId = ".intval($ImageId) );
            if ($DB->NumRowsSelected() == 0)
            {
                $Image = new SPTImage($ImageId);
                $Image->Delete();
            }
        }

        # delete any associated files
        $Factory = new FileFactory(NULL);
        $Files = $Factory->GetFilesForResource($this->Id());
        foreach ($Files as $File)
        {
            $File->Delete();
        }

        # delete resource record from database
        $DB->Query("DELETE FROM Resources WHERE ResourceId = ".$this->Id());

        # drop item from search engine and recommender system
        if ($SysConfig->SearchDBEnabled())
        {
            $SearchEngine = new SPTSearchEngine();
            $SearchEngine->DropItem($this->Id());
        }
        if ($SysConfig->RecommenderDBEnabled())
        {
            $Recommender = new SPTRecommender();
            $Recommender->DropItem($this->Id());
        }

        # get the folders containing the resource
        $FolderFactory = new FolderFactory();
        $Folders = $FolderFactory->GetFoldersContainingItem(
            $this->Id,
            "Resource");

        # drop the resource from each folder it belongs to
        foreach ($Folders as $Folder)
        {
            # mixed item type folder
            if ($Folder->ContainsItem($this->Id, "Resource"))
            {
                $Folder->RemoveItem($this->Id, "Resource");
            }

            # single item type folder
            else
            {
                $Folder->RemoveItem($this->Id);
            }
        }

        # delete any resource comments
        $DB->Query("DELETE FROM Messages WHERE ParentId = ".$this->Id);
    }

    /**
    * Retrieve result of last operation if available.
    * @return Result of last operation (if available).
    */
    public function Status()
    {
        return $this->LastStatus;
    }

    /**
    * Retrieve numerical resource ID.
    * @return Resource ID.
    */
    public function Id()
    {
        return $this->Id;
    }

    /**
    * Retrieve ID of schema for resource.
    * @return int Schema ID.
    */
    public function SchemaId()
    {
        return $this->SchemaId;
    }

    /**
    * Get MetadataSchema for resource.
    * @return object Our schema.
    */
    public function Schema()
    {
        return self::$Schemas[$this->SchemaId];
    }

    /**
    * Get/set whether resource is a temporary record.
    * @param bool $NewSetting TRUE/FALSE setting for whether resource is
    *       temporary. (OPTIONAL)
    * @return TRUE if resource is temporary record, or FALSE otherwise.
    */
    public function IsTempResource($NewSetting = NULL)
    {
        # if new temp resource setting supplied
        if (!is_null($NewSetting))
        {
            # if caller requested to switch
            $DB = $this->DB;
            if ((($this->Id() < 0) && ($NewSetting == FALSE))
                    || (($this->Id() >= 0) && ($NewSetting == TRUE)))
            {
                # lock DB tables to prevent next ID from being grabbed
                $DB->Query("LOCK TABLES Resources write");

                # get next resource ID as appropriate
                $OldResourceId = $this->Id;
                $Factory = new ResourceFactory($this->SchemaId);
                if ($NewSetting == TRUE)
                {
                    $this->Id = $Factory->GetNextTempItemId();
                }
                else
                {
                    $this->Id = $Factory->GetNextItemId();
                }

                # change resource ID
                $DB->Query("UPDATE Resources SET ResourceId = ".
                    $this->Id.  " WHERE ResourceId = ".$OldResourceId);

                # release DB tables
                $DB->Query("UNLOCK TABLES");

                # change associations
                unset($this->ClassificationCache);
                $DB->Query("UPDATE ResourceClassInts SET ResourceId = ".
                    $this->Id.  " WHERE ResourceId = ".$OldResourceId);
                unset($this->ControlledNameCache);
                unset($this->ControlledNameVariantCache);
                $DB->Query("UPDATE ResourceNameInts SET ResourceId = ".
                    $this->Id.  " WHERE ResourceId = ".$OldResourceId);
                $DB->Query("UPDATE Files SET ResourceId = ".
                    $this->Id.  " WHERE ResourceId = ".$OldResourceId);
                $DB->Query("UPDATE ReferenceInts SET SrcResourceId = ".
                    $this->Id.  " WHERE SrcResourceId = ".$OldResourceId);
                $DB->Query("UPDATE ResourceImageInts SET ResourceId = ".
                    $this->Id.  " WHERE ResourceId = ".$OldResourceId);
                $DB->Query("UPDATE ResourceUserInts SET ResourceId = ".
                    $this->Id.  " WHERE ResourceId = ".$OldResourceId);

                # signal event as appropriate
                if ($NewSetting === FALSE)
                {
                    $GLOBALS["AF"]->SignalEvent("EVENT_RESOURCE_ADD", array(
                            "Resource" => $this,
                            ));
                }
            }
        }

        # report to caller whether we are a temp resource
        return ($this->Id() < 0) ? TRUE : FALSE;
    }


    # --- Generic Attribute Retrieval Methods -------------------------------

    /**
    * Retrieve view page URL for this resource.
    * @return string view page url
    */
    public function GetViewPageUrl()
    {
        # put our Id into the ViewPage from our schema
        $Url = str_replace(
            "\$ID", $this->Id(),
            $this->Schema()->ViewPage());

        # return clean url, if one is available
        return $GLOBALS["AF"]->GetCleanUrlForPath($Url);
    }

    /**
    * Retrieve value using field name or field object.
    * @param mixed $Field Field ID or full name of field or a Field object.
    * @param bool $ReturnObject For field types that can return multiple values, if
    *       TRUE, returns array of objects, else returns array of values.
    *       Defaults to FALSE.
    * @param bool $IncludeVariants If TRUE, includes variants in return value.
    *       Only applicable for ControlledName fields.
    * @return Requested object(s) or value(s).  Returns empty array (for field
    *       types that allow multiple values) or NULL (for field types that do
    *       not allow multiple values) if no values found.  Returns NULL if
    *       field does not exist or was otherwise invalid.
    */
    public function Get($Field, $ReturnObject = FALSE, $IncludeVariants = FALSE)
    {
        # load field object if not already supplied
        $Field = is_object($Field) ? $Field
                : (is_numeric($Field) ? $this->Schema()->GetField($Field)
                        : $this->Schema()->GetFieldByName($Field));

        # return no value found if we don't have a valid field
        if (!($Field instanceof MetadataField)) {  return NULL;  }

        if ($Field->SchemaId() != $this->SchemaId())
        {
            throw new Exception("Attempt to get a value for a field"
                    ." from a different schema."
                    ." (Field: ".$Field->Name()." [".$Field->Id()
                    ."], Field Schema: ".$Field->SchemaId()
                    .", Resource Schema: ".$this->SchemaId()
                    .")");
        }

        # grab database field name
        $DBFieldName = $Field->DBFieldName();

        # format return value based on field type
        switch ($Field->Type())
        {
            case MetadataSchema::MDFTYPE_TEXT:
            case MetadataSchema::MDFTYPE_PARAGRAPH:
            case MetadataSchema::MDFTYPE_URL:
                $ReturnValue = isset($this->DBFields[$DBFieldName])
                        ? (string)$this->DBFields[$DBFieldName] : NULL;
                break;

            case MetadataSchema::MDFTYPE_NUMBER:
                $ReturnValue = isset($this->DBFields[$DBFieldName])
                        ? (int)$this->DBFields[$DBFieldName] : NULL;
                break;

            case MetadataSchema::MDFTYPE_FLAG:
                $ReturnValue = isset($this->DBFields[$DBFieldName])
                        ? (bool)$this->DBFields[$DBFieldName] : NULL;
                break;

            case MetadataSchema::MDFTYPE_POINT:
                $ReturnValue = array("X" => (float)$this->DBFields[$DBFieldName."X"],
                                     "Y" => (float)$this->DBFields[$DBFieldName."Y"]);
                break;

            case MetadataSchema::MDFTYPE_DATE:
                $Date = new Date($this->DBFields[$DBFieldName."Begin"],
                                    $this->DBFields[$DBFieldName."End"],
                                    $this->DBFields[$DBFieldName."Precision"]);
                if ($ReturnObject)
                {
                    $ReturnValue = $Date;
                }
                else
                {
                    $ReturnValue = $Date->Formatted();
                }
                break;

            case MetadataSchema::MDFTYPE_TIMESTAMP:
                $ReturnValue = $this->DBFields[$DBFieldName];
                break;

            case MetadataSchema::MDFTYPE_TREE:
                # start with empty array
                $ReturnValue = array();

                # if classification cache has not been loaded
                if (!isset($this->ClassificationCache))
                {
                    # load all classifications associated with this resource into cache
                    $this->ClassificationCache = array();
                    $this->DB->Query(
                            "SELECT Classifications.ClassificationId,"
                                    ." Classifications.FieldId,ClassificationName"
                            ." FROM ResourceClassInts, Classifications"
                            ." WHERE ResourceClassInts.ResourceId = ".$this->Id
                            ." AND ResourceClassInts.ClassificationId"
                                    ." = Classifications.ClassificationId");
                    while ($Record = $this->DB->FetchRow())
                    {
                        $ClassId = $Record["ClassificationId"];
                        $this->ClassificationCache[$ClassId]["Name"]
                                = $Record["ClassificationName"];
                        $this->ClassificationCache[$ClassId]["FieldId"]
                                = $Record["FieldId"];
                    }
                }
                # for each entry in classification cache
                foreach ($this->ClassificationCache as
                        $ClassificationId => $ClassificationInfo)
                {
                    # if classification ID matches field we are looking for
                    if ($ClassificationInfo["FieldId"] == $Field->Id())
                    {
                        # add field to result
                        if ($ReturnObject)
                        {
                            $ReturnValue[$ClassificationId] =
                                    new Classification($ClassificationId);
                        }
                        else
                        {
                            $ReturnValue[$ClassificationId] = $ClassificationInfo["Name"];
                        }
                    }
                }
                break;

            case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
            case MetadataSchema::MDFTYPE_OPTION:
                # start with empty array
                $ReturnValue = array();

                # if controlled name cache has not been loaded
                if (!isset($this->ControlledNameCache))
                {
                    # load all controlled names associated with this resource into cache
                    $this->ControlledNameCache = array();
                    $this->DB->Query(
                            "SELECT ControlledNames.ControlledNameId,"
                                    ." ControlledNames.FieldId,ControlledName"
                            ." FROM ResourceNameInts, ControlledNames"
                            ." WHERE ResourceNameInts.ResourceId = ".$this->Id
                            ." AND ResourceNameInts.ControlledNameId"
                                    ." = ControlledNames.ControlledNameId"
                            ." ORDER BY ControlledNames.ControlledName ASC");
                    while ($Record = $this->DB->FetchRow())
                    {
                        $CNameId = $Record["ControlledNameId"];
                        $this->ControlledNameCache[$CNameId]["Name"]
                                = $Record["ControlledName"];
                        $this->ControlledNameCache[$CNameId]["FieldId"]
                                = $Record["FieldId"];
                    }
                }

                # if variant names requested and variant name cache has not been loaded
                if ($IncludeVariants && !isset($this->ControlledNameVariantCache))
                {
                    # load all controlled names associated with this resource into cache
                    $this->ControlledNameVariantCache = array();
                    $this->DB->Query("SELECT ControlledNames.ControlledNameId,"
                                    ." ControlledNames.FieldId,"
                                    ." ControlledName, VariantName"
                            ." FROM ResourceNameInts, ControlledNames, VariantNames"
                            ." WHERE ResourceNameInts.ResourceId = ".$this->Id
                            ." AND ResourceNameInts.ControlledNameId"
                                    ." = ControlledNames.ControlledNameId"
                            ." AND VariantNames.ControlledNameId"
                                    ." = ControlledNames.ControlledNameId");
                    while ($Record = $this->DB->FetchRow())
                    {
                        $this->ControlledNameVariantCache[$Record["ControlledNameId"]][]
                                = $Record["VariantName"];
                    }
                }

                # for each entry in controlled name cache
                foreach ($this->ControlledNameCache as
                        $CNameId => $ControlledNameInfo)
                {
                    # if controlled name type matches field we are looking for
                    if ($ControlledNameInfo["FieldId"] == $Field->Id())
                    {
                        # if objects requested
                        if ($ReturnObject)
                        {
                            $ReturnValue[$CNameId] =
                                    new ControlledName($CNameId);
                        }
                        else
                        {
                            # if variant names requested
                            if ($IncludeVariants)
                            {
                                # add field to result
                                $ReturnValue[] = $ControlledNameInfo["Name"];

                                # add any variant names to result
                                if (isset($this->ControlledNameVariantCache[$CNameId]))
                                {
                                    $ReturnValue = array_merge(
                                            $ReturnValue,
                                            $this->ControlledNameVariantCache[$CNameId]);
                                }
                            }
                            else
                            {
                                # add field with index to result
                                $ReturnValue[$CNameId] =
                                        $ControlledNameInfo["Name"];
                            }
                        }
                    }
                }
                break;

            case MetadataSchema::MDFTYPE_USER:
                # start out assuming no associated users
                $ReturnValue = array();

                # query the database to get the associated userids
                $this->DB->Query(
                    "SELECT UserId FROM ResourceUserInts WHERE ".
                    "ResourceId=".intval($this->Id).
                        " AND FieldId=".intval($Field->Id()));
                $UserIds = $this->DB->FetchColumn("UserId");

                # convert each userid to either a name or a CWUser object
                foreach ($UserIds as $UserId)
                {
                    $User = new CWUser(intval($UserId));
                    if ($ReturnObject)
                    {
                        $ReturnValue[$UserId] = $User;
                    }
                    else
                    {
                        $ReturnValue[$UserId] = $User->Get("UserName");
                    }
                }
                break;

            case MetadataSchema::MDFTYPE_IMAGE:
                # start out assuming no images will be found
                $ReturnValue = array();

                # find all images associated with this resource
                $this->DB->Query("SELECT ImageId FROM ResourceImageInts"
                        ." WHERE ResourceId = ".intval($this->Id())
                        ." AND FieldId = ".intval($Field->Id()));

                # if images were found
                if ($this->DB->NumRowsSelected())
                {
                    # if we are to return an object
                    $ImageIds = $this->DB->FetchColumn("ImageId");
                    if ($ReturnObject)
                    {
                        # load array of Image objects for return value
                        foreach ($ImageIds as $ImageId)
                        {
                            $ReturnValue[$ImageId] = new SPTImage($ImageId);
                        }
                    }
                    else
                    {
                        # load array of Image ids for return value
                        $ReturnValue = $ImageIds;
                    }
                }
                break;

            case MetadataSchema::MDFTYPE_FILE:
                # retrieve files using factory
                $Factory = new FileFactory($Field->Id());
                $ReturnValue = $Factory->GetFilesForResource(
                        $this->Id, $ReturnObject);
                break;

            case MetadataSchema::MDFTYPE_REFERENCE:
                # query for resource references
                $this->DB->Query("
                    SELECT * FROM ReferenceInts
                    WHERE FieldId = '".addslashes($Field->Id())."'
                    AND SrcResourceId = '".addslashes($this->Id())."'");

                $ReturnValue = array();

                # return each reference as a Resource object
                if ($ReturnObject)
                {
                    $FoundErrors = FALSE;

                    while (FALSE !== ($Record = $this->DB->FetchRow()))
                    {
                        $ReferenceId = $Record["DstResourceId"];
                        $Reference = new Resource($ReferenceId);

                        # the reference is bad, so flag that there were errors
                        if ($Reference->Status() != 1)
                        {
                            $FoundErrors = TRUE;
                        }

                        else
                        {
                            $ReturnValue[$ReferenceId] = $Reference;
                        }
                    }

                    # try to fix the errors by removing any references to
                    # resources that were bad
                    if ($FoundErrors)
                    {
                        $this->Set($Field, $ReturnValue);
                    }
                }

                # return each reference as a resource ID
                else
                {
                    while (FALSE !== ($Record = $this->DB->FetchRow()))
                    {
                        $ReferenceId = $Record["DstResourceId"];
                        $ReturnValue[$ReferenceId] = $ReferenceId;
                    }
                }
                break;

            default:
                # ERROR OUT
                exit("<br>SPT - ERROR: attempt to retrieve "
                        ."unknown resource field type (".$Field->Type().")<br>\n");
                break;
        }

        # return formatted value to caller
        return $ReturnValue;
    }

    /**
    * Retrieve value using field name or field object, signaling
    * EVENT_FIELD_DISPLAY_FILTER to allow other code to possibly modify the
    * value before it's returned.  Note that the default for the $ReturnObject
    * parameter is TRUE, which is the opposite of the default for the same
    * parameter to all other Resource::Get*() methods.
    * @param mixed $FieldNameOrObject Full name of field or a Field object.
    * @param bool $ReturnObject For field types that can return multiple values, if
    *       TRUE, returns array of objects, else returns array of values.
    *       (OPTIONAL, defaults to TRUE)
    * @param bool $IncludeVariants If TRUE, includes variants in return value.
    *       Only applicable for ControlledName fields.  (OPTIONAL, defaults
    *       to FALSE)
    * @return Requested object(s) or value(s).  Returns empty array (for field
    *       types that allow multiple values) or NULL (for field types that do
    *       not allow multiple values) if no values found.  Returns NULL if
    *       field does not exist or was otherwise invalid.
    * @see Resource::Get()
    */
    public function GetForDisplay(
            $FieldNameOrObject, $ReturnObject = TRUE, $IncludeVariants = FALSE)
    {
        # normalize metadata field for use by any hooked code
        $Field = is_object($FieldNameOrObject) ? $FieldNameOrObject
                : $this->Schema()->GetFieldByName($FieldNameOrObject);

        # retrieve value
        $Value = $this->Get($Field, $ReturnObject, $IncludeVariants);

        # signal event to allowed hooked code to modify value
        $SignalResult = $GLOBALS["AF"]->SignalEvent(
                "EVENT_FIELD_DISPLAY_FILTER", array(
                        "Field" => $Field,
                        "Resource" => $this,
                        "Value" => $Value));

        # return possibly modified value to caller
        return $SignalResult["Value"];
    }

    /**
    * Old method for retrieving values, deprecated in favor of Get().
    * @param mixed $FieldNameOrObject Full name of field or a Field object.
    * @param bool $ReturnObject For field types that can return multiple values, if
    *       TRUE, returns array of objects, else returns array of values.
    *       Defaults to FALSE.
    * @param bool $IncludeVariants If TRUE, includes variants in return value.
    *       Only applicable for ControlledName fields.
    * @return Requested object(s) or value(s).  Returns empty array (for field
    *       types that allow multiple values) or NULL (for field types that do
    *       not allow multiple values) if no values found.  Returns NULL if
    *       field does not exist or was otherwise invalid.
    * @see Resource::Get()
    * @deprecated
    */
    public function GetByField($FieldNameOrObject,
            $ReturnObject = FALSE, $IncludeVariants = FALSE)
    {
        return $this->Get($FieldNameOrObject, $ReturnObject, $IncludeVariants);
    }

    /**
    * Retrieve value using field ID.
    * @param int $FieldId ID of field.
    * @param bool $ReturnObject For field types that can return multiple values,
    *   if TRUE, returns array of objects, else returns array of values.
    *   Defaults to FALSE.
    * @param bool $IncludeVariants If TRUE, includes variants in return value.
    *   Only applicable for ControlledName fields.
    * @return Requested object(s) or value(s).  Returns empty array (for field
    *   types that allow multiple values) or NULL (for field types that do not
    *   allow multiple values) if no values found.
    * @see Resource::Get()
    * @deprecated
    */
    public function GetByFieldId(
            $FieldId, $ReturnObject = FALSE, $IncludeVariants = FALSE)
    {
        return $this->Get($FieldId, $ReturnObject, $IncludeVariants);
    }

    /**
    * Retrieve all resource values as an array.
    * @param bool $IncludeDisabledFields Include values for disabled fields.
    *       (OPTIONAL, defaults to FALSE)
    * @param bool $ReturnObjects If TRUE, an object is returned for field types
    *       where appropriate, in the same fashion as Resource::Get()
    *       (OPTIONAL, defaults to TRUE)
    * @return Array of values with field names for array indices.  Qualifiers
    *       (where available) are returned with an index of the field name
    *       with " Qualifier" appended.
    * @see Resource::Get()
    */
    public function GetAsArray($IncludeDisabledFields = FALSE, $ReturnObjects = TRUE)
    {
        # retrieve field info
        $Fields = $this->Schema()->GetFields();

        # for each field
        foreach ($Fields as $Field)
        {
            # if field is enabled or caller requested disabled fields
            if ($Field->Enabled() || $IncludeDisabledFields)
            {
                # retrieve info and add it to the array
                $FieldStrings[$Field->Name()] = $this->Get($Field, $ReturnObjects);

                # if field uses qualifiers
                if ($Field->UsesQualifiers())
                {
                    # get qualifier attributes and add to the array
                    $FieldStrings[$Field->Name()." Qualifier"] =
                            $this->GetQualifierByField($Field, $ReturnObjects);
                }
            }
        }

        # add in internal values
        $FieldStrings["ResourceId"] = $this->Id();
        $FieldStrings["CumulativeRating"] = $this->CumulativeRating();

        # return array to caller
        return $FieldStrings;
    }

    /**
    * Retrieve value using standard (mapped) field name.
    * @param string $MappedName Standard field name.
    * @param bool $ReturnObject For field types that can return multiple values, if
    *       TRUE, returns array of objects, else returns array of values.
    *       Defaults to FALSE.
    * @param bool $IncludeVariants If TRUE, includes variants in return value.  Only
    *       applicable for ControlledName fields.  Defaults to FALSE.
    * @return Requested object(s) or value(s), or NULL if no mapping found.
    *       Returns empty array (for field types that allow multiple values) or
    *       NULL (for field types that do not allow multiple values) if no
    *       values found.
    * @see Resource::Get()
    */
    public function GetMapped(
            $MappedName, $ReturnObject = FALSE, $IncludeVariants = FALSE)
    {
        $Schema = $this->Schema();
        $FieldId = $Schema->StdNameToFieldMapping($MappedName);
        return $FieldId
                ? $this->Get($FieldId, $ReturnObject, $IncludeVariants)
                : NULL;
    }

    /**
    * Retrieve qualifier by field name.
    * @param string $FieldName Full name of field.
    * @param bool $ReturnObject If TRUE, return Qualifier objects, else return
    *       qualifier IDs.  Defaults to TRUE.
    * @return Array of qualifiers if field supports qualifiers, or NULL if
    *       field does not support qualifiers.
    */
    public function GetQualifier($FieldName, $ReturnObject = TRUE)
    {
        $Field = $this->Schema()->GetFieldByName($FieldName);
        return $this->GetQualifierByField($Field, $ReturnObject);
    }

    /**
    * Retrieve qualifier by field ID.
    * @param int $FieldId ID of field.
    * @param int $ReturnObject If TRUE, return Qualifier objects, else return
    *       qualifier IDs.  Defaults to TRUE.
    * @return Array of qualifiers if field supports qualifiers, or NULL
    *       if field does not support qualifiers or field is invalid.
    */
    public function GetQualifierByFieldId($FieldId, $ReturnObject = TRUE)
    {
        $Field = $this->Schema()->GetField($FieldId);
        return ($Field) ? $this->GetQualifierByField($Field, $ReturnObject) : NULL;
    }

    /**
    * Retrieve qualifier by Field object.
    * @param MetadataField $Field Field object.
    * @param bool $ReturnObject If TRUE, return Qualifier objects, else return
    *       qualifier IDs.  Defaults to TRUE.
    * @return Array of qualifiers if field supports qualifiers, or NULL if
    *       field does not support qualifiers or field is invalid.
    */
    public function GetQualifierByField($Field, $ReturnObject = TRUE)
    {
        # return NULL if field is invalid
        if (!($Field instanceof MetadataField)) {  return NULL;  }

        # assume no qualifiers if not otherwise determined
        $ReturnValue = NULL;

        # if field uses qualifiers
        if ($Field->UsesQualifiers())
        {
            # retrieve qualifiers based on field type
            switch ($Field->Type())
            {
                case MetadataSchema::MDFTYPE_TREE:
                case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
                case MetadataSchema::MDFTYPE_OPTION:
                    # retrieve list of items
                    $Items = $this->Get($Field);

                    # if field uses item-level qualifiers
                    if ($Field->HasItemLevelQualifiers())
                    {
                        # determine general item name in DB
                        $TableName = ($Field->Type() == MetadataSchema::MDFTYPE_TREE)
                                ? "Classification" : "ControlledName";

                        # for each item
                        foreach ($Items as $ItemId => $ItemName)
                        {
                            # look up qualifier for item
                            $QualId = $this->DB->Query(
                                    "SELECT * FROM ".$TableName."s"
                                    ." WHERE ".$TableName."Id = ".$ItemId,
                                    "QualifierId");


                            if ($QualId > 0)
                            {
                                # if object was requested by caller
                                if ($ReturnObject)
                                {
                                    # load qualifier and add to return value array
                                    $ReturnValue[$ItemId] = new Qualifier($QualId);
                                }
                                else
                                {
                                    # add qualifier ID to return value array
                                    $ReturnValue[$ItemId] = $QualId;
                                }
                            }
                            else
                            {
                                # add NULL to return value array for this item
                                $ReturnValue[$ItemId] = NULL;
                            }
                        }
                    }
                    else
                    {
                        # for each item
                        foreach ($Items as $ItemId => $ItemName)
                        {
                            # if object was requested by caller
                            if ($ReturnObject)
                            {
                                # load default qualifier and add to return value array
                                $ReturnValue[$ItemId] = new Qualifier(
                                        $Field->DefaultQualifier());
                            }
                            else
                            {
                                # add default qualifier ID to return value array
                                $ReturnValue[$ItemId] = $Field->DefaultQualifier();
                            }
                        }
                    }
                    break;

                default:
                    # if field uses item-level qualifiers
                    if ($Field->HasItemLevelQualifiers())
                    {
                        # if qualifier available
                        if ($this->DBFields[$Field->DBFieldName()."Qualifier"] > 0)
                        {
                            # if object was requested by caller
                            $QFieldName = $Field->DBFieldName()."Qualifier";
                            if ($ReturnObject)
                            {
                                # return qualifier for field
                                $ReturnValue = new Qualifier(
                                        $this->DBFields[$QFieldName]);
                            }
                            else
                            {
                                # return qualifier ID for field
                                $ReturnValue = $this->DBFields[$QFieldName];
                            }
                        }
                    }
                    else
                    {
                        # if default qualifier available
                        if ($Field->DefaultQualifier() > 0)
                        {
                            # if object was requested by caller
                            if ($ReturnObject)
                            {
                                # return default qualifier
                                $ReturnValue = new Qualifier($Field->DefaultQualifier());
                            }
                            else
                            {
                                # return default qualifier ID
                                $ReturnValue = $Field->DefaultQualifier();
                            }
                        }
                    }
                    break;
            }
        }

        # return qualifier object or ID (or array of same) to caller
        return $ReturnValue;
    }

    /**
    * Determine if the value for a field is set.
    * @param mixed $FieldNameOrObject Full name of field or a Field object.
    * @param bool $IgnorePadding Optional flag for ignoring whitespace padding
    *      for text, paragraph, number, and URL fields.
    * @return Returns TRUE if the value is set or FALSE otherwise.
    */
    public function FieldIsSet($FieldNameOrObject, $IgnorePadding=FALSE)
    {
        # load field object if needed
        $Field = is_object($FieldNameOrObject) ? $FieldNameOrObject
                : $this->Schema()->GetFieldByName($FieldNameOrObject);

        # return no value found if we don't have a valid field
        if (!($Field instanceof MetadataField)) {  return FALSE;  }

        # get the value
        $Value = $this->Get($Field);

        # checks depend on the field type
        switch ($Field->Type())
        {
            case MetadataSchema::MDFTYPE_TEXT:
            case MetadataSchema::MDFTYPE_PARAGRAPH:
            case MetadataSchema::MDFTYPE_NUMBER:
            case MetadataSchema::MDFTYPE_URL:
                return isset($Value)
                    && strlen($Value)
                    && (!$IgnorePadding || ($IgnorePadding && strlen(trim($Value))));

            case MetadataSchema::MDFTYPE_FLAG:
                return isset($Value)
                    && strlen($Value);

            case MetadataSchema::MDFTYPE_POINT:
                return isset($Value["X"])
                    && isset($Value["Y"])
                    && strlen(trim($Value["X"]))
                    && strlen(trim($Value["Y"]));

            case MetadataSchema::MDFTYPE_DATE:
                return isset($Value)
                    && strlen(trim($Value))
                    && $Value != "0000-00-00";

            case MetadataSchema::MDFTYPE_TIMESTAMP:
                return isset($Value)
                    && strlen(trim($Value))
                    && $Value != "0000-00-00 00:00:00";

            case MetadataSchema::MDFTYPE_TREE:
            case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
            case MetadataSchema::MDFTYPE_OPTION:
            case MetadataSchema::MDFTYPE_FILE:
            case MetadataSchema::MDFTYPE_IMAGE:
            case MetadataSchema::MDFTYPE_REFERENCE:
                return count($Value) > 0;

            case MetadataSchema::MDFTYPE_USER:
                $Factory = new CWUserFactory();
                return isset($Value)
                    && strlen($Value)
                    && $Factory->UserNameExists($Value);

            default:
               return FALSE;
        }
    }

    /**
    * Get URLs for images, returning CleanURLs when possible and
    *     direct paths to image files otherwise.
    * @param mixed $FieldNameOrObject Full field name, FieldId, or a
    *     MetadataField object.
    * @param mixed $ImageSize Desired size as an SPTImage::SIZE_ constant.
    * @return array of image URLs, keyed by ImageId
    */
    public function GetImageUrls($FieldNameOrObject, $ImageSize=SPTImage::SIZE_FULL)
    {
        $Result = array();

        # get our target field and extract its values
        $Field = is_object($FieldNameOrObject) ? $FieldNameOrObject
            : $this->Schema()->GetField($FieldNameOrObject);
        $Images = $this->Get($Field, TRUE);

        # iterate over our images getting URLs for each
        $Index = 0;
        foreach ($Images as $Image)
        {
            $Result[$Image->Id()] = $Image->GetImageUrlForResource(
                $this->Id(), $Field->Id(), $Index, $ImageSize);
            $Index++;
        }

        return $Result;
    }

    # --- Generic Attribute Setting Methods ---------------------------------

    /**
    * Set value using field name or field object.
    * @param mixed $Field Field ID or full name of field or a Field object.
    * @param mixed $NewValue New value for field.
    * @param bool $Reset When TRUE Controlled Names, Classifications,
    *       and Options will be set to contain *ONLY* the contents of
    *       NewValue, rather than appending $NewValue to the current value.
    * @throws Exception When attempting to set a value for a field that is
    *       part of a different schema than the resource.
    * @throws InvalidArgumentException When attempting to set a controlled
    *       name with an invalid ID.
    */
    public function Set($Field, $NewValue, $Reset=FALSE)
    {
        # load field object if not already supplied
        $Field = is_object($Field) ? $Field
                : (is_numeric($Field) ? $this->Schema()->GetField($Field)
                        : $this->Schema()->GetFieldByName($Field));

        # return if we don't have a valid field
        if (!($Field instanceof MetadataField)) {  return;  }

        if ($Field->SchemaId() != $this->SchemaId())
        {
            throw new Exception("Attempt to set a value for a field "
                    ."from a different schema.");
        }

        # grab commonly-used values for local use
        $DB = $this->DB;
        $ResourceId = $this->Id;

        # grab database field name
        $DBFieldName = $Field->DBFieldName();

        # Flag to deterimine if we've actually changed anything.
        $UpdateModTime = FALSE;

        # store value in DB based on field type
        switch ($Field->Type())
        {
            case MetadataSchema::MDFTYPE_TEXT:
            case MetadataSchema::MDFTYPE_PARAGRAPH:
            case MetadataSchema::MDFTYPE_URL:
                if ($this->DBFields[$DBFieldName] != $NewValue)
                {
                    # save value directly to DB
                    $DB->Query("UPDATE Resources SET `"
                               .$DBFieldName."` = '".addslashes($NewValue)."' "
                               ."WHERE ResourceId = ".$ResourceId);

                    # save value locally
                    $this->DBFields[$DBFieldName] = $NewValue;
                    $UpdateModTime=TRUE;
                }
                break;

            case MetadataSchema::MDFTYPE_NUMBER:
                if ( $this->DBFields[$DBFieldName] != $NewValue )
                {
                    # save value directly to DB
                    if (is_null($NewValue))
                    {
                        $DB->Query("UPDATE Resources SET `"
                                   .$DBFieldName."` = NULL"
                                   ." WHERE ResourceId = ".$ResourceId);
                    }
                    else
                    {
                        $DB->Query("UPDATE Resources SET `"
                                   .$DBFieldName."` = ".intval($NewValue)
                                   ." WHERE ResourceId = ".$ResourceId);
                    }

                    # save value locally
                    $this->DBFields[$DBFieldName] = $NewValue;
                    $UpdateModTime = TRUE;
                }
                break;


            case MetadataSchema::MDFTYPE_POINT:
                if ($this->DBFields[$DBFieldName."X"] != $NewValue["X"] ||
                    $this->DBFields[$DBFieldName."Y"] != $NewValue["Y"] )
                {
                    if (is_null($NewValue))
                    {
                        $DB->Query("UPDATE Resources SET "
                                   ."`".$DBFieldName."X` = NULL, "
                                   ."`".$DBFieldName."Y` = NULL "
                                   ."WHERE ResourceId = ".$ResourceId);
                        $this->DBFields[$DBFieldName."X"] = NULL;
                        $this->DBFields[$DBFieldName."Y"] = NULL;
                    }
                    else
                    {
                        $DB->Query("UPDATE Resources SET "
                                ."`".$DBFieldName."X` = " .(strlen($NewValue["X"])
                                        ? "'".$NewValue["X"]."'" : "NULL").", "
                               ."`".$DBFieldName."Y` = ".(strlen($NewValue["Y"])
                                        ? "'".$NewValue["Y"]."'" : "NULL")
                               ." WHERE ResourceId = ".$ResourceId);

                        $Digits = $Field->PointDecimalDigits();

                        $this->DBFields[$DBFieldName."X"] =
                                strlen($NewValue["X"]) ?
                                round($NewValue["X"], $Digits) : NULL;
                        $this->DBFields[$DBFieldName."Y"] =
                                strlen($NewValue["Y"]) ?
                                round($NewValue["Y"], $Digits) : NULL;
                    }
                    $UpdateModTime = TRUE;
                }
                break;

            case MetadataSchema::MDFTYPE_FLAG:
                if ($this->DBFields[$DBFieldName] != $NewValue)
                {
                    # save value directly to DB
                    if (is_null($NewValue))
                    {
                        $DB->Query("UPDATE Resources SET `"
                                   .$DBFieldName."` = NULL"
                                   ." WHERE ResourceId = ".$ResourceId);
                    }
                    else
                    {
                        $NewValue = $NewValue ? "1" : "0";
                        $DB->Query("UPDATE Resources SET `"
                                   .$DBFieldName."` = ".$NewValue
                                   ." WHERE ResourceId = ".$ResourceId);
                    }

                    $this->DBFields[$DBFieldName] = $NewValue;

                    # recalculate counts for any associated classifications if necessary
                    if ($DBFieldName == "ReleaseFlag")
                    {
                        $DB->Query("SELECT ClassificationId FROM ResourceClassInts"
                                ." WHERE ResourceId = ".$ResourceId);
                        while ($ClassId = $DB->FetchField("ClassificationId"))
                        {
                            $Class = new Classification($ClassId);
                            $Class->RecalcResourceCount();
                        }
                    }
                    $UpdateModTime = TRUE;
                }
                break;

            case MetadataSchema::MDFTYPE_USER:
                $OldValue = $this->Get($Field);
                # value comes back as array (UserId => UserName), just get the Ids
                $OldValue = array_keys($OldValue);

                # input to Set() for these fields is one of
                # 1. an int specifying a UserId
                if (is_numeric($NewValue))
                {
                    $NewValue = array($NewValue);
                }
                # 2. a CWUser object
                elseif ($NewValue instanceof CWUser)
                {
                    $NewValue = array($NewValue->Id());
                }
                # 3. an array keyed by UserId (don't care about the values)
                elseif (is_array($NewValue))
                {
                    $NewValue = array_keys($NewValue);
                }
                else
                {
                    throw new Exception("Unknown format for NewValue in a User field");
                }

                # if this is a unique field, only accept the first of the options given
                if ($Field->AllowMultiple() == FALSE && count($NewValue) > 1)
                {
                    $NewValue = array_slice($NewValue, 0, 1, TRUE);
                }

                # sort new and old values so we can directly compare
                sort($OldValue);
                sort($NewValue);

                # if the value has changed
                if ($OldValue != $NewValue)
                {
                    if ($Reset || $Field->AllowMultiple() == FALSE )
                    {
                        $ToRemove = array_diff($OldValue, $NewValue);
                        $this->RemoveAssociation(
                            "ResourceUserInts", "UserId", $ToRemove, $Field);
                    }

                    # associate with resource if not already associated
                    $this->AddAssociation("ResourceUserInts",
                                          "UserId",
                                          $NewValue, $Field);

                    $UpdateModTime=TRUE;
                }
                break;

            case MetadataSchema::MDFTYPE_DATE:
                # if we were given a date object
                if (is_object($NewValue))
                {
                    # use supplied date object
                    $Date = $NewValue;
                }
                else
                {
                    # create date object
                    $Date = new Date($NewValue);
                }

                $OldDate = new Date(
                    $this->DBFields[$DBFieldName."Begin"],
                    $this->DBFields[$DBFieldName."End"]);

                if ($OldDate->BeginDate() != $Date->BeginDate() ||
                    $OldDate->EndDate()   != $Date->EndDate()   ||
                    $OldDate->Precision() != $Date->Precision() )
                {
                    # extract values from date object and store in DB
                    $BeginDate = "'".$Date->BeginDate()."'";
                    if (strlen($BeginDate) < 3) {  $BeginDate = "NULL";  }
                    $EndDate = "'".$Date->EndDate()."'";
                    if (strlen($EndDate) < 3) {  $EndDate = "NULL";  }

                    $DB->Query("UPDATE Resources SET "
                               .$DBFieldName."Begin = ".$BeginDate.", "
                               .$DBFieldName."End = ".$EndDate.", "
                               .$DBFieldName."Precision = '".$Date->Precision()."' "
                               ."WHERE ResourceId = ".$ResourceId);

                    # save values locally
                    $this->DBFields[$DBFieldName."Begin"] = $Date->BeginDate();
                    $this->DBFields[$DBFieldName."End"] = $Date->EndDate();
                    $this->DBFields[$DBFieldName."Precision"] = $Date->Precision();
                    $UpdateModTime=TRUE;
                }
                break;

            case MetadataSchema::MDFTYPE_TIMESTAMP:
                if (is_null($NewValue) || !strlen(trim($NewValue)))
                {
                    $DateValue = $NewValue;

                    if (!is_null($this->DBFields[$DBFieldName]))
                    {
                        # save value directly to DB
                        $DB->Query("UPDATE Resources SET "
                                   ."`".$DBFieldName."` = NULL "
                                   ."WHERE ResourceId = ".$ResourceId);
                        $UpdateModTime = TRUE;
                    }
                }
                else
                {
                    # assume value is date and use directly
                    $TimestampValue = strtotime($NewValue);

                    # use the new value if the date is valid
                    if ($TimestampValue !== FALSE && $TimestampValue >= 0)
                    {
                        $DateValue = date("Y-m-d H:i:s", $TimestampValue);

                        if ($this->DBFields[$DBFieldName] != $DateValue)
                        {
                            # save value directly to DB
                            $DB->Query("UPDATE Resources SET "
                                    ."`".$DBFieldName."` = '".addslashes($DateValue)."' "
                                    ."WHERE ResourceId = ".$ResourceId);
                            $UpdateModTime=TRUE;
                        }
                    }

                    # continue using the old value if invalid
                    else
                    {
                        $DateValue = $this->Get($Field);
                    }
                }

                # save value locally
                $this->DBFields[$DBFieldName] = $DateValue;
                break;

            case MetadataSchema::MDFTYPE_TREE:
                $OldValue = $this->Get($Field);

                # if incoming value is array
                if (is_array($NewValue))
                {
                    if ($OldValue != $NewValue)
                    {
                        if ($Reset)
                        {
                            # remove values that were in the old value
                            # but not the new one
                            $ToRemove = array_diff(array_keys($OldValue),
                                    array_keys($NewValue));
                            foreach ($ToRemove as $ClassificationId)
                            {
                                $this->RemoveAssociation("ResourceClassInts",
                                                         "ClassificationId",
                                                         $ClassificationId);
                                $Class = new Classification($ClassificationId);
                                if ($Class->Status() == Classification::CLASSSTAT_OK)
                                {
                                    $Class->RecalcResourceCount();
                                }
                            }
                        }

                        # for each element of array
                        foreach ($NewValue as
                                 $ClassificationId => $ClassificationName)
                        {
                            $Class = new Classification($ClassificationId);
                            if ($Class->FieldId() == $Field->Id())
                            {
                                if ($Class->Status() == Classification::CLASSSTAT_OK)
                                {
                                    # associate with resource if not already associated
                                    if ($this->AddAssociation("ResourceClassInts",
                                                              "ClassificationId",
                                                              $ClassificationId) )
                                    {
                                        $Class->UpdateLastAssigned();
                                        $Class->RecalcResourceCount();
                                    }
                                }
                            }
                            else
                            {
                                throw new Exception(
                                    "Attempting to store classification from "
                                    ."Field ".$Class->FieldId()." into Field "
                                    .$Field->Id() );
                            }

                        }

                        $UpdateModTime=TRUE;
                    }
                }
                else
                {
                    # associate with resource if not already associated
                    if (is_object($NewValue))
                    {
                        $Class = $NewValue;
                        $NewValue = $Class->Id();
                    }
                    else
                    {
                        $Class = new Classification($NewValue);
                    }

                    if (!array_key_exists($Class->Id(), $OldValue))
                    {

                        $this->AddAssociation("ResourceClassInts",
                                              "ClassificationId",
                                              $NewValue);
                        $Class->UpdateLastAssigned();
                        $Class->RecalcResourceCount();
                        $UpdateModTime=TRUE;
                    }
                }

                # clear our classification cache
                if ($UpdateModTime)
                {
                    unset($this->ClassificationCache);
                }
                break;

            case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
            case MetadataSchema::MDFTYPE_OPTION:
                $OldValue = $this->Get($Field);

                # input to Set() for these fields is one of
                # 1. an int specifying a ControlledNameId
                # 2. a ControlledName object
                # 3. an array with keys giving Ids and values giving ControlledNames
                #
                # normalize 1 and 2 into 3 for simplicity of processing
                if (is_object($NewValue) || !is_array($NewValue) )
                {
                    if (!is_object($NewValue))
                    {
                        $NewValue = new ControlledName($NewValue);
                    }

                    $TmpValue = array();
                    $TmpValue[$NewValue->Id()] = $NewValue->Name();

                    $NewValue = $TmpValue;
                }

                # if this is a unique field, only accept the first of the options given
                #  NB: all ControlledNames implicitly AllowMultiple
                if ($Field->Type() == MetadataSchema::MDFTYPE_OPTION &&
                    $Field->AllowMultiple() == FALSE && count($NewValue) > 1)
                {
                    $NewValue = array_slice($NewValue, 0, 1, TRUE);
                }

                # if the value has changed
                if ($OldValue != $NewValue)
                {
                    if ($Reset || ($Field->Type() == MetadataSchema::MDFTYPE_OPTION
                                    && $Field->AllowMultiple() == FALSE ) )
                    {
                        $ToRemove = array_diff(array_keys($OldValue),
                                array_keys($NewValue));
                        foreach ($ToRemove as $CNId)
                        {
                            $this->RemoveAssociation("ResourceNameInts",
                                                     "ControlledNameId",
                                                     $CNId);
                        }
                    }

                    # for each element of array
                    foreach ($NewValue as $ControlledNameId => $ControlledName)
                    {
                        # associate with resource if not already associated
                        if ($this->AddAssociation("ResourceNameInts",
                                                  "ControlledNameId",
                                                  $ControlledNameId))
                        {
                            $CN = new ControlledName($ControlledNameId);
                            if ($CN->Status() != ControlledName::STATUS_OK)
                            {
                                $this->RemoveAssociation("ResourceNameInts",
                                        "ControlledNameId", $ControlledNameId);
                                throw new InvalidArgumentException(
                                        "Attempt to set controlled name with"
                                        ." invalid ID (".$ControlledNameId.").");
                            }
                            $CN->UpdateLastAssigned();
                        }
                    }
                    $UpdateModTime = TRUE;
                }

                if ($UpdateModTime)
                {
                    # clear our controlled name cache
                    unset($this->ControlledNameCache);
                    unset($this->ControlledNameVariantCache);

                    # clear visible count cache for any affected resources
                    $ValueIds = array_keys($OldValue) + array_keys($NewValue);
                    $DB->Query(
                        "DELETE FROM VisibleResourceCounts WHERE "
                        ."SchemaId=".intval($this->SchemaId)." AND "
                        ."ValueId IN (".implode(",", $ValueIds).")");
                }

                break;

            case MetadataSchema::MDFTYPE_IMAGE:
                # associate value(s) with resource
                $this->AddAssociation(
                        "ResourceImageInts", "ImageId", $NewValue, $Field);
                # clear cached image mappings
                SPTImage::ClearImageSymlinksForResource($this->Id(), $Field->Id());
                break;

            case MetadataSchema::MDFTYPE_FILE:
                # convert incoming value to array if necessary
                if (!is_array($NewValue)) {  $NewValue = array($NewValue);  }

                # for each incoming file
                $Factory = new FileFactory($Field->Id());
                foreach ($NewValue as $File)
                {
                    # make copy of file
                    $NewFile = $Factory->Copy($File);

                    # associate copy with this resource and field
                    $NewFile->ResourceId($this->Id);
                    $NewFile->FieldId($Field->Id());
                }
                # Since we make a fresh copy of the File whenever Set is called,
                # we'll always update the modification time for this field.
                $UpdateModTime = TRUE;
                break;

            case MetadataSchema::MDFTYPE_REFERENCE:
                # convert incoming value to array to simplify the workflow
                if (is_scalar($NewValue) || $NewValue instanceof Resource)
                {
                    $NewValue = array($NewValue);
                }

                # delete existing resource references
                $this->ClearByField($Field);

                # add each reference
                foreach ($NewValue as $ReferenceOrId)
                {
                    # initially issume it's a reference ID and not an object...
                    $ReferenceId = $ReferenceOrId;

                    # ... but get the reference ID if it's an object
                    if ($ReferenceOrId instanceof Resource)
                    {
                        $ReferenceId = $ReferenceOrId->Id();
                    }

                    # skip blank reference IDs
                    if (strlen(trim($ReferenceId)) < 1)
                    {
                        continue;
                    }

                    # skip reference IDs that don't look right
                    if (!is_numeric($ReferenceId))
                    {
                        continue;
                    }

                    # skip references to the current resource
                    if ($ReferenceId == $this->Id())
                    {
                        continue;
                    }

                    # add the reference to the references table
                    $DB->Query("
                        INSERT INTO ReferenceInts (
                            FieldId,
                            SrcResourceId,
                            DstResourceId)
                        VALUES (
                            ".addslashes($Field->Id()).",
                            ".addslashes($this->Id()).",
                            ".addslashes($ReferenceId).")");
                }
                break;

            default:
                # ERROR OUT
                exit("<br>SPT - ERROR: attempt to set unknown resource field type<br>\n");
                break;
        }

        if ($UpdateModTime && !$this->IsTempResource())
        {
            # update modification timestamps
            global $G_User;
            $UserId = $G_User->IsLoggedIn() ? $G_User->Get("UserId") : -1;
            $DB->Query("DELETE FROM ResourceFieldTimestamps "
                       ."WHERE ResourceId=".$this->Id." AND "
                       ."FieldId=".$Field->Id() );
            $DB->Query("INSERT INTO ResourceFieldTimestamps "
                       ."(ResourceId,FieldId,ModifiedBy,Timestamp) VALUES ("
                       .$this->Id.",".$Field->Id().","
                       .$UserId.",NOW())");

            # on resource modification, clear the UserPermsCache entry
            #   so that stale permissions checks are not cached
            $DB->Query("DELETE FROM UserPermsCache WHERE ResourceId=".$this->Id);
        }
    }

    /**
    * Method replaced by Resource::Set(), preserved for backward compatibility.
    * @param object $Field MetadataField object.
    * @param mixed $NewValue New value for field.
    * @see Resource::Set()
    * @deprecated
    */
    public function SetByField($Field, $NewValue)
    {
        $this->Set($Field, $NewValue);
    }

    /**
    * Set field value using field ID.
    * @param int $FieldId Metadata field ID.
    * @param mixed $NewValue Value to set.
    * @see Resource::Set()
    * @deprecated
    */
    public function SetByFieldId($FieldId, $NewValue)
    {
        $this->Set($FieldId, $NewValue);
    }

    /**
    * Set qualifier using field name.
    * @param string $FieldName Metadata field name.
    * @param mixed $NewValue Qualifier object or ID.
    */
    public function SetQualifier($FieldName, $NewValue)
    {
        $Field = $this->Schema()->GetFieldByName($FieldName);
        $this->SetQualifierByField($Field, $NewValue);
    }

    /**
    * Set qualifier using field ID.
    * @param int $FieldId Metadata field ID.
    * @param mixed $NewValue Qualifier object or ID.
    */
    public function SetQualifierByFieldId($FieldId, $NewValue)
    {
        $Field = $this->Schema()->GetField($FieldId);
        $this->SetQualifierByField($Field, $NewValue);
    }

    /**
    * Set qualifier using field object.
    * @param object $Field Metadata field.
    * @param mixed $NewValue Qualifier object or ID.
    */
    public function SetQualifierByField($Field, $NewValue)
    {
        # if field uses qualifiers and uses item-level qualifiers
        if ($Field->UsesQualifiers() && $Field->HasItemLevelQualifiers())
        {
            # if qualifier object passed in
            if (is_object($NewValue))
            {
                # grab qualifier ID from object
                $QualifierId = $NewValue->Id();
            }
            else
            {
                # assume value passed in is qualifier ID
                $QualifierId = $NewValue;
            }

            # update qualifier value in database
            $DBFieldName = $Field->DBFieldName();
            $this->DB->Query("UPDATE Resources SET "
                     .$DBFieldName."Qualifier = '".$QualifierId."' "
                     ."WHERE ResourceId = ".$this->Id);

            # update local qualifier value
            $this->DBFields[$DBFieldName."Qualifier"] = $QualifierId;
        }
    }

    /**
    * Clear field value specified by field ID.
    * @param int $FieldId Metadata field ID.
    * @param mixed $ValueToClear Specific value to clear (for fields that
    *       support multiple values).  (OPTIONAL)
    */
    public function ClearByFieldId($FieldId, $ValueToClear = NULL)
    {
        $Field = $this->Schema()->GetField($FieldId);
        $this->Clear($Field, $ValueToClear);
    }

    /**
    * Clear field value.
    * @param object $Field Metadata field.
    * @param mixed $ValueToClear Specific value to clear (for fields that
    *       support multiple values).  (OPTIONAL)
    */
    public function Clear($Field, $ValueToClear = NULL)
    {
        # convert field name to object if necessary
        if (!is_object($Field))
        {
            $Field = $this->Schema()->GetFieldByName($Field);
        }

        # grab commonly-used values for local use
        $DB = $this->DB;
        $ResourceId = $this->Id;

        # grab database field name
        $DBFieldName = $Field->DBFieldName();

        $UpdateModTime=FALSE;

        # store value in DB based on field type
        switch ($Field->Type())
        {
            case MetadataSchema::MDFTYPE_TEXT:
            case MetadataSchema::MDFTYPE_PARAGRAPH:
            case MetadataSchema::MDFTYPE_NUMBER:
            case MetadataSchema::MDFTYPE_FLAG:
            case MetadataSchema::MDFTYPE_TIMESTAMP:
            case MetadataSchema::MDFTYPE_URL:
                if (strlen($this->DBFields[$DBFieldName])>0)
                {
                    # clear value in DB
                    $DB->Query("UPDATE Resources SET `"
                               .$DBFieldName."` = NULL "
                               ."WHERE ResourceId = ".$ResourceId);

                    # clear value locally
                    $this->DBFields[$DBFieldName] = NULL;
                    $UpdateModTime=TRUE;
                }
                break;

            case MetadataSchema::MDFTYPE_POINT:
                if (!is_null($this->DBFields[$DBFieldName."X"]) ||
                    !is_null($this->DBFields[$DBFieldName."Y"]) )
                {
                    # Clear DB Values
                    $DB->Query("UPDATE Resources SET "
                               ."`".$DBFieldName."X` = NULL ,"
                               ."`".$DBFieldName."Y` = NULL "
                               ."WHERE ResourceId = ".$ResourceId);

                    # Clear local values
                    $this->DBFields[$DBFieldName."X"] = NULL;
                    $this->DBFields[$DBFieldName."Y"] = NULL;
                    $UpdateModTime=TRUE;
                }
                break;

            case MetadataSchema::MDFTYPE_DATE:
                if (!is_null($this->DBFields[$DBFieldName."Begin"]) ||
                    !is_null($this->DBFields[$DBFieldName."End"])   ||
                    !is_null($this->DBFields[$DBFieldName."Precision"]))
                {
                    # clear date object values in DB
                    $DB->Query("UPDATE Resources SET "
                               .$DBFieldName."Begin = '', "
                               .$DBFieldName."End = '', "
                               .$DBFieldName."Precision = '' "
                               ."WHERE ResourceId = ".$ResourceId);

                    # clear value locally
                    $this->DBFields[$DBFieldName."Begin"] = NULL;
                    $this->DBFields[$DBFieldName."End"] = NULL;
                    $this->DBFields[$DBFieldName."Precision"] = NULL;
                    $UpdateModTime=TRUE;
                }
                break;

            case MetadataSchema::MDFTYPE_TREE:
                $OldValue = $this->Get($Field);

                # if value to clear supplied
                if ($ValueToClear !== NULL)
                {
                    # if supplied value is array
                    if (is_array($ValueToClear))
                    {
                        # for each element of array
                        foreach ($ValueToClear as $ClassificationId => $Dummy)
                        {
                            if (array_key_exists($ClassificationId, $OldValue))
                            {
                                # remove association with resource (if any)
                                $this->RemoveAssociation("ResourceClassInts",
                                                         "ClassificationId",
                                                         $ClassificationId);
                                $Class = new Classification($ClassificationId);
                                $Class->RecalcResourceCount();
                                $UpdateModTime=TRUE;
                            }
                        }
                    }
                    else
                    {
                        if (array_key_exists($ValueToClear, $OldValue))
                        {
                            # remove association with resource (if any)
                            $this->RemoveAssociation("ResourceClassInts",
                                                     "ClassificationId",
                                                     $ValueToClear);
                            $Class = new Classification($ValueToClear);
                            $Class->RecalcResourceCount();
                            $UpdateModTime=TRUE;
                        }
                    }
                }
                else
                {
                    if (count($OldValue)>0)
                    {
                        # remove all associations for resource and field
                        $this->RemoveAllAssociations(
                                "ResourceClassInts", "ClassificationId", $Field);

                        # recompute resource count
                        $Values = $this->Get($Field);
                        foreach ($Values as $ClassificationId => $Dummy)
                        {
                            $Class = new Classification($ClassificationId);
                            $Class->RecalcResourceCount();
                        }
                        $UpdateModTime=TRUE;
                    }
                }

                # clear our classification cache
                if ($UpdateModTime)
                {
                    unset($this->ClassificationCache);
                }
                break;

            case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
            case MetadataSchema::MDFTYPE_OPTION:
                $OldValue = $this->Get($Field);
                # if value to clear supplied
                if ($ValueToClear !== NULL)
                {
                    # if incoming value is array
                    if (is_array($ValueToClear))
                    {
                        # for each element of array
                        foreach ($ValueToClear as $ControlledNameId =>
                            $ControlledName)
                        {
                            if (array_key_exists($ControlledNameId, $OldValue))
                            {
                                # remove association with resource (if any)
                                $this->RemoveAssociation("ResourceNameInts",
                                                         "ControlledNameId",
                                                         $ControlledNameId);
                                $UpdateModTime=TRUE;
                            }
                        }
                    }
                    else
                    {
                        if (array_key_exists($ValueToClear, $OldValue))
                        {
                            # remove association with resource (if any)
                            $this->RemoveAssociation("ResourceNameInts",
                                                     "ControlledNameId",
                                                     $ValueToClear);
                            $UpdateModTime=TRUE;
                        }
                    }
                }
                else
                {
                    if (count($OldValue)>0)
                    {
                        # remove all associations for resource and field
                        $this->RemoveAllAssociations(
                                "ResourceNameInts", "ControlledNameId", $Field);
                        $UpdateModTime=TRUE;
                    }
                }

                if ($UpdateModTime)
                {
                    # clear our controlled name cache
                    unset($this->ControlledNameCache);
                    unset($this->ControlledNameVariantCache);
                }
                break;

            case MetadataSchema::MDFTYPE_USER:
                $OldValue = $this->Get($Field);

                # if value to clear supplied
                if ($ValueToClear !== NULL)
                {
                    # if incoming value is array
                    if (is_array($ValueToClear))
                    {
                        # for each element of array
                        foreach ($ValueToClear as $UserId => $User)
                        {
                            if (array_key_exists($UserId, $OldValue))
                            {
                                # remove association with resource (if any)
                                $this->RemoveAssociation("ResourceUserInts",
                                                         "UserId",
                                                         $UserId,
                                                         $Field);
                                $UpdateModTime=TRUE;
                            }
                        }
                    }
                    else
                    {
                        if (array_key_exists($ValueToClear, $OldValue))
                        {
                            # remove association with resource (if any)
                            $this->RemoveAssociation("ResourceUserInts",
                                                     "UserId",
                                                     $UserId,
                                                     $Field);
                            $UpdateModTime=TRUE;
                        }
                    }
                }
                else
                {
                    if (count($OldValue)>0)
                    {
                        # remove all associations for resource and field
                        $this->RemoveAllAssociations(
                                "ResourceUserInts", "UserId", $Field);
                        $UpdateModTime=TRUE;
                    }
                }

                break;

            case MetadataSchema::MDFTYPE_FILE:
                # if value to clear supplied
                if ($ValueToClear !== NULL)
                {
                    # convert value to array if necessary
                    $Files = $ValueToClear;
                    if (!is_array($Files)) {  $Files = array($Files);  }

                    # convert values to objects if necessary
                    foreach ($Files as $Index => $File)
                    {
                        if (!is_object($File))
                        {
                            $Files[$Index] = new File($File);
                        }
                    }
                }
                else
                {
                    # use all files associated with resource
                    $Files = $this->Get($Field, TRUE);
                }

                # delete files
                foreach ($Files as $File) {  $File->Delete();  }
                break;

            case MetadataSchema::MDFTYPE_IMAGE:
                # if value to clear supplied
                if ($ValueToClear !== NULL)
                {
                    # convert value to array if necessary
                    $Images = $ValueToClear;
                    if (!is_array($Images)) {  $Images = array($Images);  }

                    # convert values to objects if necessary
                    foreach ($Images as $Index => $Image)
                    {
                        if (!is_object($Image))
                        {
                            $Images[$Index] = new SPTImage($Image);
                        }
                    }
                }
                else
                {
                    # use all images associated with resource
                    $Images = $this->Get($Field, TRUE);
                }

                # delete images if we are the last resource referencing
                #  a particular image.
                foreach ($Images as $Image)
                {
                    $Cnt = $this->DB->Query(
                        "SELECT COUNT(*) AS Cnt FROM ResourceImageInts WHERE ".
                            "ImageId=".$Image->Id(), "Cnt");
                    if ($Cnt==1)
                    {
                        $Image->Delete();
                    }
                }

                # clear cached image mappings
                SPTImage::ClearImageSymlinksForResource($this->Id(), $Field->Id());

                # remove connections to images
                $UpdateModTime = $this->RemoveAssociation(
                        "ResourceImageInts", "ImageId", $Images, $Field);
                break;

            case MetadataSchema::MDFTYPE_REFERENCE:
                # remove references from the references table
                $DB->Query("
                    DELETE FROM ReferenceInts
                    WHERE FieldId = '".addslashes($Field->Id())."'
                    AND SrcResourceId = '".addslashes($this->Id())."'");
                break;

            default:
                # ERROR OUT
                exit("<br>SPT - ERROR: attempt to clear "
                     ."unknown resource field type<br>\n");
                break;
        }

        if ($UpdateModTime && !$this->IsTempResource())
        {
            # update modification timestamps
            global $G_User;
            $UserId = $G_User->IsLoggedIn() ? $G_User->Get("UserId") : -1;
            $DB->Query("DELETE FROM ResourceFieldTimestamps "
                       ."WHERE ResourceId=".$this->Id." AND "
                       ."FieldId=".$Field->Id() );
            $DB->Query("INSERT INTO ResourceFieldTimestamps "
                       ."(ResourceId,FieldId,ModifiedBy,Timestamp) VALUES ("
                       .$this->Id.",".$Field->Id().","
                       .$UserId.",NOW())");
        }
    }

    /**
    * Clear field value.  DEPRECATED in favor of Clear().
    * @param object $Field Metadata field.
    * @param mixed $ValueToClear Specific value to clear (for fields that
    *       support multiple values).  (OPTIONAL)
    * @see Resource::Clear()
    * @deprecated
    */
    public function ClearByField($Field, $ValueToClear = NULL)
    {
        $this->Clear($Field, $ValueToClear);
    }

    # --- Field-Specific or Type-Specific Attribute Retrieval Methods -------

    /**
    * Get 2D array of classifications associated with resource.
    * @return array Array where first index is classification (field) name,
    *       second index is classification ID.
    */
    public function Classifications()
    {
        $DB = $this->DB;

        # start with empty array
        $Names = array();

        # for each controlled name
        $DB->Query("SELECT ClassificationName, MetadataFields.FieldName, "
                ."ResourceClassInts.ClassificationId FROM ResourceClassInts, "
                ."Classifications, MetadataFields "
                ."WHERE ResourceClassInts.ResourceId = ".$this->Id." "
                ."AND ResourceClassInts.ClassificationId = "
                        ."Classifications.ClassificationId "
                ."AND Classifications.FieldId = MetadataFields.FieldId ");
        while ($Record = $DB->FetchRow())
        {
            # add name to array
            $Names[$Record["FieldName"]][$Record["ClassificationId"]] =
                    $Record["ClassificationName"];
        }

        # return array to caller
        return $Names;
    }


    # --- Ratings Methods ---------------------------------------------------

    /**
    * Get cumulative rating  (range is usually 0-100)
    * @return int Rating value.
    */
    public function CumulativeRating()
    {
        return $this->CumulativeRating;
    }

    /**
    * Return cumulative rating scaled to 1/10th.  (Range is usually 0-10.)
    * @return int Scaled rating value or NULL if no cumulative rating available.
    */
    public function ScaledCumulativeRating()
    {
        if ($this->CumulativeRating == NULL)
        {
            return NULL;
        }
        else
        {
            return intval(($this->CumulativeRating + 5) / 10);
        }
    }

    /**
    * Get current number of ratings for resource.
    * @return int Ratings count.
    */
    public function NumberOfRatings()
    {
        # if number of ratings not already set
        if (!isset($this->NumberOfRatings))
        {
            # obtain number of ratings
            $this->NumberOfRatings =
                    $this->DB->Query("SELECT Count(*) AS NumberOfRatings "
                            ."FROM ResourceRatings "
                            ."WHERE ResourceId = ".$this->Id,
                    "NumberOfRatings"
                    );

            # recalculate cumulative rating if it looks erroneous
            if (($this->NumberOfRatings > 0) && !$this->CumulativeRating())
            {
                $this->UpdateCumulativeRating();
            }
        }

        # return number of ratings to caller
        return $this->NumberOfRatings;
    }

    /**
    * Get/set rating by a specific user for resource.
    * @param int $NewRating New rating value.
    * @param int $UserId ID of user rating resource.
    * @return int Current rating value of resource by user or NULL if user
    *       has not rated resource..
    */
    public function Rating($NewRating = NULL, $UserId = NULL)
    {
        $DB = $this->DB;

        # if user ID not supplied
        if ($UserId == NULL)
        {
            # if user is logged in
            global $User;
            if ($User->IsLoggedIn())
            {
                # use ID of current user
                $UserId = $User->Get("UserId");
            }
            else
            {
                # return NULL to caller
                return NULL;
            }
        }

        # sanitize $NewRating
        if (!is_null($NewRating))
        {
            $NewRating = intval($NewRating);
        }

        # if there is a rating for resource and user
        $DB->Query("SELECT Rating FROM ResourceRatings "
                ."WHERE UserId = ${UserId} AND ResourceId = ".$this->Id);
        if ($Record = $DB->FetchRow())
        {
            # if new rating was supplied
            if ($NewRating != NULL)
            {
                # update existing rating
                $DB->Query("UPDATE ResourceRatings "
                        ."SET Rating = ${NewRating}, DateRated = NOW() "
                        ."WHERE UserId = ${UserId} AND ResourceId = ".$this->Id);

                # update cumulative rating value
                $this->UpdateCumulativeRating();

                # return value is new rating
                $Rating = $NewRating;
            }
            else
            {
                # get rating value to return to caller
                $Rating = $Record["Rating"];
            }
        }
        else
        {
            # if new rating was supplied
            if ($NewRating != NULL)
            {
                # add new rating
                $DB->Query("INSERT INTO ResourceRatings "
                        ."(ResourceId, UserId, DateRated, Rating) "
                        ."VALUES ("
                                .$this->Id.", "
                                ."${UserId}, "
                                ."NOW(), "
                                ."${NewRating})");

                # update cumulative rating value
                $this->UpdateCumulativeRating();

                # return value is new rating
                $Rating = $NewRating;
            }
            else
            {
                # return value is NULL
                $Rating = NULL;
            }
        }

        # return rating value to caller
        return $Rating;
    }


    # --- Resource Comment Methods ------------------------------------------

    /**
    * Get comments for resource.
    * @return array Comments as Message objects.
    */
    public function Comments()
    {
        # read in comments if not already loaded
        if (!isset($this->Comments))
        {
            $this->DB->Query("SELECT MessageId FROM Messages "
                    ."WHERE ParentId = ".$this->Id
                    ." AND ParentType = 2 "
                    ."ORDER BY DatePosted DESC");
            while ($MessageId = $this->DB->FetchField("MessageId"))
            {
                $this->Comments[] = new Message($MessageId);
            }
        }

        # return array of comments to caller
        return $this->Comments;
    }

    /**
    * Get current number of comments for resource.
    * @return int Number of comments.
    */
    public function NumberOfComments()
    {
        # obtain number of comments if not already set
        if (!isset($this->NumberOfComments))
        {
            $this->NumberOfComments =
                    $this->DB->Query("SELECT Count(*) AS NumberOfComments "
                            ."FROM Messages "
                            ."WHERE ParentId = ".$this->Id
                            ." AND ParentType = 2",
                    "NumberOfComments"
                    );
        }

        # return number of comments to caller
        return $this->NumberOfComments;
    }


    # --- Permission Methods -------------------------------------------------

    /**
    * Determine if the given user can view the resource, e.g., on the full
    * record page. The result of this method can be modified via the
    * EVENT_RESOURCE_VIEW_PERMISSION_CHECK event.
    * @param User $User User to check against.
    * @param bool $AllowHooksToModify TRUE if hook functions should be
    *     allowed to modify the return value (OPTIONAL default TRUE).
    * @return bool TRUE if the user can view the resource and FALSE otherwise
    */
    public function UserCanView(User $User, $AllowHooksToModify=TRUE)
    {
        return $this->CheckSchemaPermissions($User, "View", $AllowHooksToModify);
    }

    /**
    * Determine if the given user can edit the resource.  The result of this
    * method can be modified via the EVENT_RESOURCE_EDIT_PERMISSION_CHECK event.
    * @param User $User User to check against.
    * @return bool TRUE if the user can edit the resource and FALSE otherwise
    */
    public function UserCanEdit($User)
    {
        return $this->CheckSchemaPermissions($User, "Edit");
    }

    /**
    * Determine if the given user can edit the resource.  The result of this
    * method can be modified via the EVENT_RESOURCE_EDIT_PERMISSION_CHECK event.
    * @param User $User User to check against.
    * @return bool TRUE if the user can edit the resource and FALSE otherwise
    */
    public function UserCanAuthor($User)
    {
        return $this->CheckSchemaPermissions($User, "Author");
    }

    /**
    * Check if the user is allowed to modify (Edit for perm resources,
    * Author for temp) a specified resources.
    * @param User $User User to check.
    * @return bool TRUE if the user can modify the resource, FALSE otherwise
    */
    public function UserCanModify($User)
    {
        $CheckFn = "UserCan".(($this->Id()<0) ? "Author" : "Edit");
        return $this->$CheckFn($User);
    }

    /**
    * Check whether user is allowed to view specified metadata field.
    * @param User $User User to check.
    * @param mixed $FieldOrFieldName Field name or object.
    * @return TRUE if user can view field, otherwise FALSE.
    */
    public function UserCanViewField($User, $FieldOrFieldName)
    {
        return $this->CheckFieldPermissions( $User, $FieldOrFieldName, "View" );
    }

    /**
    * Check whether user is allowed to edit specified metadata field.
    * @param User $User User to check.
    * @param mixed $FieldOrFieldName Field name or object.
    * @return TRUE if user can edit field, otherwise FALSE.
    */
    public function UserCanEditField($User, $FieldOrFieldName)
    {
        return $this->CheckFieldPermissions( $User, $FieldOrFieldName, "Edit" );
    }

    /**
    * Check whether user is allowed to author specified metadata field.
    * @param User $User User to check.
    * @param mixed $FieldOrFieldName Field name or object.
    * @return TRUE if user can author field, otherwise FALSE.
    */
    public function UserCanAuthorField($User, $FieldOrFieldName)
    {
        return $this->CheckFieldPermissions( $User, $FieldOrFieldName, "Author" );
    }

    /**
    * Check whether user is allowed to modify (Edit for perm
    * resources, Author for temp) specified metadata field.
    * @param User $User User to check.
    * @param mixed $FieldOrFieldName Field name or object.
    * @return TRUE if user can modify field, otherwise FALSE.
    */
    public function UserCanModifyField($User, $FieldOrFieldName)
    {
        $CheckFn = "UserCan".(($this->Id()<0) ? "Author" : "Edit")."Field";

        return $this->$CheckFn($User, $FieldOrFieldName);
    }

    # --- Utility Methods ----------------------------------------------------

    /**
    * Update search and recommender system DBs.
    */
    public function QueueSearchAndRecommenderUpdate()
    {
        if (!$this->IsTempResource())
        {
            $SearchEngine = new SPTSearchEngine();
            $SearchEngine->QueueUpdateForItem($this);

            $Recommender = new SPTRecommender();
            $Recommender->QueueUpdateForItem($this);
        }
    }

    /**
    * Get schema ID for specified resource(s).
    * @param int $ResourceId ID of resource or array of IDs of resources.
    * @return int Schema ID or array of schema IDs indexed by resource ID.
    */
    public static function GetSchemaForResource($ResourceId)
    {
        # if schema IDs are not loaded
        if (!isset(self::$SchemaIdCache))
        {
            # load schema IDs
            $DB = new Database();
            $DB->Query("SELECT ResourceId, SchemaId FROM Resources");
            self::$SchemaIdCache = $DB->FetchColumn("SchemaId", "ResourceId");
        }

        # if multiple resources specified
        if (is_array($ResourceId))
        {
            # find schema IDs for specified resources
            $SchemaIds = array_intersect_key(self::$SchemaIdCache,
                    array_flip($ResourceId));

            # check that specified resource IDs were all valid
            if (count($SchemaIds) < count($ResourceId))
            {
                $BadIds = array_diff($ResourceId, array_keys($SchemaIds));
                throw new InvalidArgumentException("Unknown resource IDs ("
                        .implode(", ", $BadIds).").");
            }

            # return schema IDs to caller
            return $SchemaIds;
        }
        else
        {
            # check that specified resource was valid
            if (!isset(self::$SchemaIdCache[$ResourceId]))
            {
                throw new InvalidArgumentException("Unknown resource ID ("
                        .$ResourceId.").");
            }

            # return schema IDs for specified resource
            return self::$SchemaIdCache[$ResourceId];
        }
    }


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

    protected $DB;

    private $ClassificationCache;
    private $Comments;
    private $ControlledNameCache;
    private $ControlledNameVariantCache;
    private $CumulativeRating;
    private $DBFields;
    private $LastStatus;
    private $NumberOfComments;
    private $NumberOfRatings;
    private $PermissionCache;
    private $SchemaId;

    static private $Schemas;
    static private $SchemaIdCache;

    /**
    * Check schema permissions to see if user is allowed to
    *         View/Edit/Author this resource.
    * @param User $User User to check.
    * @param string $CheckType Type of check to perform (one of View,
    *       Author, or Edit).
    * @param bool $AllowHooksToModify TRUE if hook functions should be
    *       allowed to modify the return value (OPTIONAL default TRUE).
    * @return bool TRUE if user is allowed, FALSE otherwise
    */
    private function CheckSchemaPermissions($User, $CheckType, $AllowHooksToModify=TRUE)
    {
        # checks against invalid resources should always fail
        if ($this->Status() !== 1) {  return FALSE;  }

        # construct a key to use for our permissions cache
        $CacheKey = "UserCan".$CheckType.$User->Id();

        # if we don't have a cached value for this perm, compute one
        if (!isset($this->PermissionCache[$CacheKey]))
        {
            # get privileges for schema
            $PermsFn = $CheckType."ingPrivileges";
            $SchemaPrivs = $this->Schema()->$PermsFn();

            # check passes if user privileges are greater than resource set
            $CheckResult = $SchemaPrivs->MeetsRequirements($User, $this);

            # save the result of this check in our cache
            $this->PermissionCache[$CacheKey] = $CheckResult;
        }

        $Value = $this->PermissionCache[$CacheKey];

        if ($AllowHooksToModify)
        {
            $SignalResult = $GLOBALS["AF"]->SignalEvent(
            "EVENT_RESOURCE_".strtoupper($CheckType)."_PERMISSION_CHECK",
             array(
                 "Resource" => $this,
                 "User" => $User,
                 "Can".$CheckType => $Value,
                 "Schema" => $this->Schema(), ));

            $Value =  $SignalResult["Can".$CheckType];
        }

        return $Value;
    }

    /**
    * Check field permissions to see if user is allowed to
    *         View/Author/Edit a specified field.
    * @param object $User User to check.
    * @param mixed $Field Metadata field or field ID or field name.
    * @param string $CheckType Type of check to perform (View, Author, or Edit).
    * @return bool TRUE if user is allowed, FALSE otherwise.
    */
    private function CheckFieldPermissions($User, $Field, $CheckType)
    {
        # checks against invalid resources should always fail
        if ($this->Status() !== 1) {  return FALSE;  }

        # get field object (if not supplied)
        if (!($Field instanceof MetadataField))
        {
            try
            {
                $Field = $this->Schema()->GetField($Field);
            }
            catch (InvalidArgumentException $Exception)
            {
                # (user cannot view/author/edit if field was invalid)
                return FALSE;
            }
        }

        # construct a key to use for our permissions cache
        $CacheKey = "UserCan".$CheckType."Field".$Field->Id()."-".$User->Id();

        # if we don't have a cahced value, compute one
        if (!isset($this->PermissionCache[$CacheKey]))
        {
            # if field is enabled and editable, do permission check
            if ($Field->Enabled() &&
                ($CheckType == "View" || $Field->Editable()))
            {
                # be sure schema privs allow View/Edit/Author for this resource
                $SchemaCheckFn = "UserCan".$CheckType;
                if ($this->$SchemaCheckFn($User))
                {
                    # get appropriate privilege set for field
                    $PermsFn = $CheckType."ingPrivileges";
                    $FieldPrivs = $Field->$PermsFn();

                    # user can View/Edit/Author if privileges are greater than field set
                    $CheckResult = $FieldPrivs->MeetsRequirements($User, $this);
                }
                else
                {
                    $CheckResult = FALSE;
                }
            }
            else
            {
                $CheckResult = FALSE;
            }

            # allow plugins to modify result of permission check
            $SignalResult = $GLOBALS["AF"]->SignalEvent(
                    "EVENT_FIELD_".strtoupper($CheckType)."_PERMISSION_CHECK", array(
                        "Field" => $Field,
                        "Resource" => $this,
                        "User" => $User,
                        "Can".$CheckType => $CheckResult));
            $CheckResult = $SignalResult["Can".$CheckType];

            # save the result of this check in our cache
            $this->PermissionCache[$CacheKey] = $CheckResult;
        }

        # return cached permission value
        return $this->PermissionCache[$CacheKey];
    }

    /**
    * Recalculate and save cumulative rating value for resource.
    */
    private function UpdateCumulativeRating()
    {
        # grab totals from DB
        $this->DB->Query("SELECT COUNT(Rating) AS Count, "
                ."SUM(Rating) AS Total FROM ResourceRatings "
                ."WHERE ResourceId = ".$this->Id);
        $Record = $this->DB->FetchRow();

        # calculate new cumulative rating
        $this->CumulativeRating = round($Record["Total"] / $Record["Count"]);

        # save new cumulative rating in DB
        $this->DB->Query("UPDATE Resources "
                ."SET CumulativeRating = ".$this->CumulativeRating." "
                ."WHERE ResourceId = ".$this->Id);
    }

    /**
    * Associate specified value(s) with resource (by adding an entry into
    * the specified intersection table if necessary).  If an object or array
    * of objects are passed in, they must support an Id() method to retrieve
    * the object ID.
    * @param string $TableName Name of database intersection table.
    * @param string $FieldName Name of column in database table.
    * @param mixed $Value ID or object or array of IDs or objects to associate.
    * @param object $Field Metadata field.  (OPTIONAL)
    * @return bool TRUE if new value was associated, otherwise FALSE.
    */
    private function AddAssociation($TableName, $FieldName, $Value, $Field = NULL)
    {
        # We should ignore duplicate key errors when doing inserts:
        $this->DB->SetQueryErrorsToIgnore( array(
                    "/INSERT INTO ".$TableName."/" =>
                        "/Duplicate entry '-?[0-9]+-[0-9]+(-[0-9]+)?' for key/"));

        # start out assuming no association will be added
        $AssociationAdded = FALSE;

        # convert new value to array if necessary
        $Values = is_array($Value) ? $Value : array($Value);

        # for each new value
        foreach ($Values as $Value)
        {
            # retrieve ID from value if necessary
            if (is_object($Value)) {  $Value = $Value->Id();  }

            # Try to insert a new entry for this association.
            $this->DB->Query("INSERT INTO ".$TableName." SET"
                        ." ResourceId = ".intval($this->Id)
                        .", ".$FieldName." = ".intval($Value)
                        .($Field ? ", FieldId = ".intval($Field->Id()) : ""));

            # If the insert ran without a duplicate key error,
            #  then we added an assocation:
            if ($this->DB->IgnoredError() === FALSE)
            {
                $AssociationAdded = TRUE;
            }
        }

        # Clear ignored errors:
        $this->DB->SetQueryErrorsToIgnore( NULL );

        # report to caller whether association was added
        return $AssociationAdded;
    }

    /**
    * Disassociate specified value(s) with resource (by removing entries in
    * the specified intersection table as necessary).  If an object or array
    * of objects are passed in, they must support an Id() method to retrieve
    * the object ID.
    * @param string $TableName Name of database intersection table.
    * @param string $FieldName Name of column in database table.
    * @param mixed $Value ID or object or array of IDs or objects to disassociate.
    * @param object $Field Metadata field.  (OPTIONAL)
    * @return bool TRUE if value was disassociated, otherwise FALSE.
    */
    private function RemoveAssociation($TableName, $FieldName, $Value, $Field = NULL)
    {
        # start out assuming no association will be removed
        $AssociationRemoved = FALSE;

        # convert value to array if necessary
        $Values = is_array($Value) ? $Value : array($Value);

        # for each value
        foreach ($Values as $Value)
        {
            # retrieve ID from value if necessary
            if (is_object($Value)) {  $Value = $Value->Id();  }

            # remove any intersections with target ID from DB
            $this->DB->Query("DELETE FROM ".$TableName
                    ." WHERE ResourceId = ".intval($this->Id)
                    .($Field ? " AND FieldId = ".intval($Field->Id()) : "")
                    ." AND ".$FieldName." = ".intval($Value));
            if ($this->DB->NumRowsAffected()) {  $AssociationRemoved = TRUE;  }
        }

        # report to caller whether association was added
        return $AssociationRemoved;
    }

    /**
    * Remove all intersections for resource and field (if any).
    * @param string $TableName DB table with associations.
    * @param string $TargetFieldName Field name.
    * @param object $Field Metadata field.
    */
    private function RemoveAllAssociations($TableName, $TargetFieldName, $Field)
    {
        # retrieve list of entries for this field and resource
        $Entries = $this->Get($Field);

        # divide them into chunks of not more than 100
        foreach (array_chunk($Entries, 100, TRUE) as $Chunk)
        {
            # remove assocations from this chunk
            $this->DB->Query("DELETE FROM ".$TableName
                    ." WHERE ResourceId = ".intval($this->Id)
                    ." AND ".$TargetFieldName." IN "
                    ."(".implode(",", array_keys($Chunk)).")");
        }
    }
}
