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

/**
* Folder object used to create and manage groups of items.  Items are identified
* and manipulated within folders by a positive integer "ID" value.  For folders
* intended to contain multiple types of item types, item types are arbitrary
* string values.
* \nosubgrouping
*/
class Folder
{

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

    /** @name Setup/Initialization */ /*@(*/

    /**
    * Object constructor -- load an existing folder.  New folders must be created
    * with FolderFactory::CreateFolder() or FolderFactory::CreateMixedFolder().
    * @param int $FolderId ID of folder.
    */
    public function __construct($FolderId)
    {
        # create our own DB handle
        $this->DB = new Database();

        # store folder ID
        $this->Id = intval($FolderId);

        # attempt to load in folder info
        $this->DB->Query("SELECT * FROM Folders WHERE FolderId = ".$this->Id);
        $Record = $this->DB->FetchRow();

        # if folder was not found
        if ($Record === FALSE)
        {
            # bail out with exception
            throw new Exception("Unknown Folder ID (".$FolderId.").");
        }

        # save folder info
        $this->OwnerId          = $Record["OwnerId"];
        $this->FolderName       = $Record["FolderName"];
        $this->NormalizedName   = $Record["NormalizedName"];
        $this->FolderNote       = $Record["FolderNote"];
        $this->IsShared         = $Record["IsShared"];
        $this->ContentType      = $Record["ContentType"];
        $this->UpdateValueCache = $Record;

        # load list of resources in folder from database
        $this->DB->Query("SELECT ItemId, ItemTypeId, ItemNote FROM FolderItemInts"
                ." WHERE FolderId = ".$this->Id);

        # create internal cache for item notes
        $this->ItemNoteCache = array();
        while ($Record = $this->DB->FetchRow())
        {
            $Index = self::GetCacheIndex($Record["ItemId"], $Record["ItemTypeId"]);
            $this->ItemNoteCache[$Index] = $Record["ItemNote"];
        }

        # load item ordering
        if ($this->ContentType == self::MIXEDCONTENT)
        {
            $this->OrderList = new PersistentDoublyLinkedList(
                    "FolderItemInts", "ItemId", "FolderId = ".$this->Id, "ItemTypeId");
        }
        else
        {
            $this->OrderList = new PersistentDoublyLinkedList(
                    "FolderItemInts", "ItemId", "FolderId = ".$this->Id);
        }

        # be sure that a NormalizedName is set
        $this->NormalizedName();
    }

    /**
    * Delete folder.  (Object is no longer usable after calling this method.)
    */
    public function Delete()
    {
        # take folder out of global folder order
        $Factory = new FolderFactory();
        $Factory->RemoveItemFromOrder($this->Id);

        # remove resource listings from DB
        $this->DB->Query("DELETE FROM FolderItemInts WHERE FolderId = ".$this->Id);

        # remove folder listing from DB
        $this->DB->Query("DELETE FROM Folders WHERE FolderId = ".$this->Id);
    }

    /*@)*/ /* Setup/Initialization */
    # ------------------------------------------------------------------------
    /** @name Attribute Setting/Retrieval */ /*@(*/

    /**
    * Get folder ID.
    * @return Numerical folder ID.
    */
    public function Id()
    {
        return $this->Id;
    }

    /**
    * Get/set folder name.  When used to set the folder name this also
    * generates and sets a new normalized folder name.
    * @param string $NewValue New name.  (OPTIONAL)
    * @return String containing folder name.
    */
    public function Name($NewValue = DB_NOVALUE)
    {
        if ($NewValue != DB_NOVALUE)
        {
            $this->NormalizedName(self::NormalizeFolderName($NewValue));
        }
        return $this->UpdateValue("FolderName", $NewValue);
    }

    /**
    * Get/set normalized version of folder name.  This method can be used
    * to override the normalized name autogenerated when the folder name is
    * set with Folder::Name().
    * @param string $NewValue New normalized version of folder name.  (OPTIONAL)
    * @return Current normalized version of folder name.
    */
    public function NormalizedName($NewValue = DB_NOVALUE)
    {
        $Name = $this->UpdateValue("NormalizedName", $NewValue);
        # attempt to generate and set new normalized name if none found
        if (!strlen($Name))
        {
            $Name = $this->UpdateValue("NormalizedName",
                    self::NormalizeFolderName($this->Name()));
        }
        return $Name;
    }

    /**
    * Convert folder name to normalized form (lower-case alphanumeric only).
    * @param string $Name Folder name to normalize.
    * @return String containing normalized name.
    */
    public static function NormalizeFolderName($Name)
    {
        return preg_replace("/[^a-z0-9]/", "", strtolower($Name));
    }

    /**
    * Get/set whether folder is publically-viewable.  (This flag is not used at
    * all by the Folder object, but rather provided for use in interface code.)
    * @param bool $NewValue Boolean value.  (OPTIONAL)
    * @return Boolean flag indicating whether or not folder is publically-viewable
    */
    public function IsShared($NewValue = DB_NOVALUE)
    {
        return $this->UpdateValue("IsShared", $NewValue);
    }

    /**
    * Get/set user ID of folder owner.
    * @param int $NewValue Numerical user ID.  (OPTIONAL)
    * @return ID of current folder owner.
    */
    public function OwnerId($NewValue = DB_NOVALUE)
    {
        if ($NewValue !== DB_NOVALUE) {  unset($this->Owner);  }
        return intval($this->UpdateValue("OwnerId", $NewValue));
    }

    /**
    * Get/set note text for folder.
    * @param string $NewValue New note text.  (OPTIONAL)
    * @return String containing current note for folder.
    */
    public function Note($NewValue = DB_NOVALUE)
    {
        return $this->UpdateValue("FolderNote", $NewValue);
    }

    /*@)*/ /* Attribute Setting/Retrieval */
    # ------------------------------------------------------------------------
    /** @name Item Operations */ /*@(*/

    /**
    * Insert item into folder before specified item.  If the item is already
    * present in the folder, it is moved to the new location.  If the target
    * item is not found in the folder, the new item is added to the folder as
    * the first item.
    * @param mixed $TargetItemOrItemId Item to insert before.
    * @param mixed $NewItemOrItemId Item to insert.
    * @param mixed $TargetItemType Type of item to insert before.  (OPTIONAL,
    *       for mixed-item-type folders)
    * @param mixed $NewItemType Type of item to insert.  (OPTIONAL, for
    *       mixed-item-type folders)
    */
    public function InsertItemBefore($TargetItemOrItemId, $NewItemOrItemId,
            $TargetItemType = NULL, $NewItemType = NULL)
    {
        $this->AddItem($NewItemOrItemId, $NewItemType);
        $this->OrderList->InsertBefore($TargetItemOrItemId, $NewItemOrItemId,
                self::GetItemTypeId($TargetItemType),
                self::GetItemTypeId($NewItemType));
    }

    /**
    * Insert item into folder after specified item.  If the item is already
    * present in the folder, it is moved to the new location.  If the target
    * item is not found, the new item is added to the folder as the last item.
    * @param mixed $TargetItemOrItemId Item to insert after.
    * @param mixed $NewItemOrItemId Item to insert.
    * @param mixed $TargetItemType Type of item to insert after.  (OPTIONAL, for
    *       mixed-item-type folders)
    * @param mixed $NewItemType Type of item to insert.  (OPTIONAL, for
    *       mixed-item-type folders)
    */
    public function InsertItemAfter($TargetItemOrItemId, $NewItemOrItemId,
            $TargetItemType = NULL, $NewItemType = NULL)
    {
        $this->AddItem($NewItemOrItemId, $NewItemType);
        $this->OrderList->InsertAfter($TargetItemOrItemId, $NewItemOrItemId,
                self::GetItemTypeId($TargetItemType),
                self::GetItemTypeId($NewItemType));
    }

    /**
    * Add item to folder as the first item.  If the item is already present
    * in the folder, it is moved to be the first item.
    * @param mixed $ItemOrItemId Item to add.
    * @param mixed $ItemType Type of item to add.  (OPTIONAL, for
    *       mixed-item-type folders)
    */
    public function PrependItem($ItemOrItemId, $ItemType = NULL)
    {
        $this->AddItem($ItemOrItemId, $ItemType);
        $this->OrderList->Prepend($ItemOrItemId, self::GetItemTypeId($ItemType));
    }

    /**
    * Add item to folder as the last item.  If the item is already present
    * in the folder, it is moved to be the last item.
    * @param mixed $ItemOrItemId Item to add.
    * @param mixed $ItemType Type of item to add.  (OPTIONAL, for mixed-item-type
    *       folders)
    */
    public function AppendItem($ItemOrItemId, $ItemType = NULL)
    {
        $this->AddItem($ItemOrItemId, $ItemType);
        $this->OrderList->Append($ItemOrItemId, self::GetItemTypeId($ItemType));
    }

    /**
    * Add multiple items to the folder at the end.
    * @param array $ItemsOrItemIds ItemIds or Item objects to add.
    * @param mixed $ItemTypes Item types corresponding to the Items,
    *    or a single item type that applies to all Items.
    */
    public function AppendItems($ItemsOrItemIds, $ItemTypes = NULL)
    {
        # convert ItemTypes to an array if it wasn't one
        if (!is_array($ItemTypes))
        {
            $NewItemTypes = array();
            foreach ($ItemsOrItemIds as $Id)
            {
                $NewItemTypes[] = $ItemTypes;
            }
            $ItemTypes = $NewItemTypes;
        }

        # get items ids
        $ItemIds = array();
        foreach ($ItemsOrItemIds as $ItemOrId)
        {
            $ItemIds[] = is_object($ItemOrId) ? $ItemOrId->Id() : $ItemOrId;
        }

        # add all the itmes to our folder
        foreach ($ItemIds as $Index => $ItemId)
        {
            $this->AddItem($ItemId, $ItemTypes[$Index]);
        }

        # and add them to our ordering list
        if ($this->ContentType == self::MIXEDCONTENT)
        {
            $ItemTypeIds = array();
            foreach ($ItemTypes as $ItemType)
            {
                $ItemTypeIds[] = self::GetItemTypeId($ItemType);
            }

            $this->OrderList->Append($ItemIds, $ItemTypeIds);
        }
        else
        {
            $this->OrderList->Append($ItemIds);
        }
    }

    /**
    * Retrieve array of IDs of items in folder, in the order that they appear
    * in the folder.
    * @param int $Offset Offset into results, to request a subset.  If negative,
    *       the subset will start that far from the end.  (OPTIONAL)
    * @param int $Length Number of results to return, when requesting a subset.
    *       If negative, the subset will stop that many elements from the end.
    *       (OPTIONAL - if not specified, all results to end are returned)
    * @return Array of item IDs or (if mixed-item-type list) item IDs and types.
    *       When returning IDs and types, each element in the returned array is
    *       an associative array, with the indexes "ID" and "Type".
    */
    public function GetItemIds($Offset = NULL, $Length = NULL)
    {
        # retrieve item ordered list of type IDs
        $ItemIds = $this->OrderList->GetIds();

        # if this is a mixed-item-type folder
        if ($this->ContentType == self::MIXEDCONTENT)
        {
            # convert item type IDs to corresponding type names
            $NewItemIds = array();
            foreach ($ItemIds as $ItemInfo)
            {
                $NewItemIds[] = array(
                        "ID" => $ItemInfo["ID"],
                        "Type" => self::GetItemTypeName($ItemInfo["Type"]),
                        );
            }
            $ItemIds = $NewItemIds;
        }

        # prune results to subset if requested
        if ($Offset !== NULL)
        {
            if ($Length !== NULL)
            {
                $ItemIds = array_slice($ItemIds, $Offset, $Length);
            }
            else
            {
                $ItemIds = array_slice($ItemIds, $Offset);
            }
        }

        # return list of item type IDs (and possibly types) to caller
        return $ItemIds;
    }

    /**
    * Get number of items in folder.  This does not included nested items,
    * so if a folder contains another folder that contains items, the inner
    * folder is counted as only one item.
    * @return int Number of items.
    */
    public function GetItemCount()
    {
        return $this->OrderList->GetCount();
    }

    /**
    * Remove item from folder, if present.
    * @param int $ItemId ID of item to remove.
    * @param mixed $ItemType Type of item to be removed.  (OPTIONAL, for
    *       mixed-item-type folders)
    */
    public function RemoveItem($ItemId, $ItemType = NULL)
    {
        # if resource is in folder
        if ($this->ContainsItem($ItemId, $ItemType))
        {
            # remove item from item order
            $ItemTypeId = self::GetItemTypeId($ItemType);
            $this->OrderList->Remove($ItemId, $ItemTypeId);

            # remove resource from folder locally
            unset($this->ItemNoteCache[self::GetCacheIndex($ItemId, $ItemTypeId)]);

            # remove resource from folder in DB
            $this->DB->Query("DELETE FROM FolderItemInts"
                    ." WHERE FolderId = ".intval($this->Id)
                    ." AND ItemId = ".intval($ItemId)
                    ." AND ItemTypeId = "
                            .($ItemTypeId === NULL ? -1 : intval($ItemTypeId)));
        }
    }

    /**
    * Get/set note text for specific item within folder.
    * @param int $ItemId ID of item.
    * @param string $NewValue New note text for item.  (OPTIONAL)
    * @param mixed $ItemType Type of item.  (OPTIONAL, for mixed-item-type folders)
    * @return String containing current note text for specified item.
    */
    public function NoteForItem($ItemId, $NewValue = DB_NOVALUE, $ItemType = NULL)
    {
        $ItemTypeId = self::GetItemTypeId($ItemType);
        $Index = self::GetCacheIndex($ItemId, $ItemTypeId);
        $DummyCache = array("ItemNote" => $this->ItemNoteCache[$Index]);

        $Value = $this->DB->UpdateValue("FolderItemInts", "ItemNote", $NewValue,
                "FolderId = ".intval($this->Id)
                        ." AND ItemId = ".intval($ItemId)
                        ." AND ItemTypeId = "
                            .($ItemTypeId === NULL ? -1 : intval($ItemTypeId)),
                $DummyCache);

        $this->ItemNoteCache[self::GetCacheIndex($ItemId, $ItemTypeId)] = $Value;

        return $Value;
    }

    /**
    * Check whether specified item is contained in folder.
    * @param int $ItemId ID of item.
    * @param mixed $ItemType Type of item.  (OPTIONAL, for use with
    *       mixed-item-type folders)
    * @return TRUE if item is in folder, otherwise FALSE.
    */
    public function ContainsItem($ItemId, $ItemType = NULL)
    {
        $ItemTypeId = self::GetItemTypeId($ItemType);
        return array_key_exists(self::GetCacheIndex($ItemId, $ItemTypeId),
                $this->ItemNoteCache) ? TRUE : FALSE;
    }

    /*@)*/ /* Item Operations */

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

    private $DB;
    private $Id;

    # folder attributes - these much match field names in Folders DB table
    private $OwnerId;
    private $FolderName;
    private $NormalizedName;
    private $FolderNote;
    private $IsShared;
    private $ContentType;

    private $ItemNoteCache;
    private $OrderList;
    private $UpdateValueCache;

    # item type IDs (indexed by normalized type name)
    static private $ItemTypeIds;
    # item type names (indexed by type ID)
    static private $ItemTypeNames;

    # content type that indicates folder contains mixed content types
    const MIXEDCONTENT = -1;

    /** @cond */
    /**
    * Map item type string to numerical value.  (This method is not private
    * because FolderFactory needs it.)
    * @param string $TypeName Item type name.
    * @return int Item type ID or NULL if TypeName was NULL.
    */
    public static function GetItemTypeId($TypeName)
    {
        # return NULL if TypeName is NULL.
        if ($TypeName === NULL) {  return NULL;  }

        # make sure item type map is loaded
        self::LoadItemTypeMap();

        # normalize item type name
        $NormalizedTypeName = strtoupper(
                preg_replace("/[^a-zA-Z0-9]/", "", $TypeName));

        # if name not already mapped
        if (!array_key_exists($NormalizedTypeName, self::$ItemTypeIds))
        {
            # add name to database
            static $DB;
            if (!isset($DB)) {  $DB = new Database();  }
            $DB->Query("INSERT INTO FolderContentTypes SET"
                    ." TypeName = '".addslashes($TypeName)."',"
                    ." NormalizedTypeName = '".addslashes($NormalizedTypeName)."'");

            # add name to cached mappings
            $NewTypeId = $DB->LastInsertId();
            self::$ItemTypeIds[$NormalizedTypeName] = $NewTypeId;
            self::$ItemTypeNames[$NewTypeId] = $TypeName;
        }

        # return item type ID to caller
        return self::$ItemTypeIds[$NormalizedTypeName];
    }
    /** @endcond */

    /**
    * Map item type ID value to item type name.
    * @param int $TypeId Item type ID.
    * @return string Item type name.
    */
    private static function GetItemTypeName($TypeId)
    {
        # make sure item type map is loaded
        self::LoadItemTypeMap();

        # if ID not present in mappings
        if (!array_key_exists($TypeId, self::$ItemTypeNames))
        {
            # return null value
            return NULL;
        }
        else
        {
            # return item type name to caller
            return self::$ItemTypeNames[$TypeId];
        }
    }

    /**
    * Load item type map (self::$ItemTypeIds) from database.  May be called
    * repeatedly with no additional significant overhead.
    */
    private static function LoadItemTypeMap()
    {
        # if name-to-number item type map not already loaded
        if (!isset(self::$ItemTypeIds))
        {
            # load item type map from database
            $DB = new Database();
            $DB->Query("SELECT * FROM FolderContentTypes");
            self::$ItemTypeIds = array();
            self::$ItemTypeNames = array();
            while ($Row = $DB->FetchRow())
            {
                self::$ItemTypeIds[$Row["NormalizedTypeName"]] = $Row["TypeId"];
                self::$ItemTypeNames[$Row["TypeId"]] = $Row["TypeName"];
            }
        }
    }

    /**
    * Add resource to folder (does not add to ordered list).
    * @param mixed $ItemOrItemId Item object or item ID.
    * @param string $ItemType Item type name.
    */
    private function AddItem($ItemOrItemId, $ItemType)
    {
        # convert item to ID if necessary
        $ItemId = is_object($ItemOrItemId)
                ? $ItemOrItemId->Id() : $ItemOrItemId;

        # convert item type to item type ID
        $ItemTypeId = self::GetItemTypeId($ItemType);

        # convert null item type to "no type" value used in database
        if ($ItemTypeId === NULL) {  $ItemTypeId = -1;  }

        # if resource is not already in folder
        if (!$this->ContainsItem($ItemId, $ItemType))
        {
            # add resource to folder locally
            $this->ItemNoteCache[self::GetCacheIndex($ItemId, $ItemTypeId)] = NULL;

            # add resource to folder in DB
            $this->DB->Query("INSERT INTO FolderItemInts SET"
                    ." FolderId = ".intval($this->Id)
                    .", ItemId = ".intval($ItemId)
                    .", ItemTypeId = "
                            .($ItemTypeId === NULL ? -1 : intval($ItemTypeId)));
        }
    }

    /**
    * Generate index to be used with item note cache array.
    * @param int $ItemId Numerical item ID.
    * @param int $ItemTypeId Numerical item type ID.
    * @return string Index string to be used with associative cache array.
    */
    private static function GetCacheIndex($ItemId, $ItemTypeId)
    {
        $ItemTypeId = ($ItemTypeId === NULL) ? -1 : $ItemTypeId;
        return intval($ItemTypeId).":".intval($ItemId);
    }

    /**
    * Get/set value in database.  (Convenience wrapper for Database method.)
    * @param string $FieldName Name of database field to update.
    * @param mixed $NewValue Value to save.  (Use DB_NOVALUE to not update value.)
    * @return mixed Current value.
    * @see Database::UpdateValue()
    */
    private function UpdateValue($FieldName, $NewValue)
    {
        $this->$FieldName = $this->DB->UpdateValue("Folders", $FieldName, $NewValue,
                "FolderId = ".$this->Id, $this->UpdateValueCache);
        return $this->$FieldName;
    }
}
