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

class MetadataSchema extends ItemFactory {

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

    # types of field ordering
    const MDFORDER_DISPLAY =  1;
    const MDFORDER_EDITING =  2;
    const MDFORDER_ALPHABETICAL =  3;

    # metadata field types
    # (must parallel MetadataFields.FieldType declaration in install/CreateTables.sql
    #        and MetadataField::$FieldTypeDBEnums declaration below)
    const MDFTYPE_TEXT =            1;
    const MDFTYPE_PARAGRAPH =       2;
    const MDFTYPE_NUMBER =          4;
    const MDFTYPE_DATE =            8;
    const MDFTYPE_TIMESTAMP =       16;
    const MDFTYPE_FLAG =            32;
    const MDFTYPE_TREE =            64;
    const MDFTYPE_CONTROLLEDNAME =  128;
    const MDFTYPE_OPTION =          256;
    const MDFTYPE_USER =            512;
    const MDFTYPE_IMAGE =           1024;
    const MDFTYPE_FILE =            2048;
    const MDFTYPE_URL =             4096;
    const MDFTYPE_POINT =           8192;

    # error status codes
    const MDFSTAT_OK =                 1;
    const MDFSTAT_ERROR =              2;
    const MDFSTAT_DUPLICATENAME =      4;
    const MDFSTAT_DUPLICATEDBCOLUMN =  8;
    const MDFSTAT_FIELDDOESNOTEXIST =  16;
    const MDFSTAT_ILLEGALNAME =        32;
    const MDFSTAT_DUPLICATELABEL =     64;
    const MDFSTAT_ILLEGALLABEL =       128;

    # object constructor
    function MetadataSchema()
    {
        # set up item factory base class
        $this->ItemFactory(
                "MetadataField", "MetadataFields", "FieldId", "FieldName");

        # start with field info caching enabled
        $this->CachingOn = TRUE;
    }

    # turn internal caching of field info on or off
    function CacheData($NewValue)
    {
        $this->CachingOn = $NewValue;
    }

    # add new metadata field
    function AddField($FieldName, $FieldType, $Optional = TRUE, $DefaultValue = NULL)
    {
        # create new field
        $Field = new MetadataField(NULL, $FieldName, $FieldType, $Optional, $DefaultValue);

        # save error code if create failed and return NULL
        if ($Field->Status() != MetadataSchema::MDFSTAT_OK)
        {
            $this->ErrorStatus = $Field->Status();
            $Field = NULL;
        }

        # return new field to caller
        return $Field;
    }

    /**
     * Add new metadata field based on supplied XML.  The XML elements are method
     * names from the MetadataField object, with the values being passed in as the
     * parameter to that method.  The <i>FieldName</i> and <i>FieldType</i>
     * elements are required.  Values for elements/methods that would normally be
     * called with constants in PHP can be constant names.
     * @param Xml Block of XML containing field description.
     * @return New MetadataField object or MDFSTAT_ error code if addition failed.
     */
    function AddFieldFromXml($Xml)
    {
        # assume field addition will fail
        $Field = self::MDFSTAT_ERROR;

        # add XML prefixes if needed
        $Xml = trim($Xml);
        if (!preg_match("/^<\?xml/i", $Xml))
        {
            if (!preg_match("/^<document>/i", $Xml))
            {
                $Xml = "<document>".$Xml."</document>";
            }
            $Xml = "<?xml version='1.0'?>".$Xml;
        }

        # parse XML
        $XmlData = simplexml_load_string($Xml);

         # if required values are present
        if (is_object($XmlData)
                && isset($XmlData->Name)
                && isset($XmlData->Type)
                && constant("MetadataSchema::".$XmlData->Type))
        {
            # create the metadata field
            $Field = new MetadataField(NULL, $XmlData->Name,
                    constant("MetadataSchema::".$XmlData->Type));

            # if field creation failed
            if ($Field->Status() !== self::MDFSTAT_OK)
            {
                # reset field value to error code
                $Field = $Field->Status();
            }
            else
            {
                # for other field attributes
                foreach ($XmlData as $MethodName => $Value)
                {
                    # if they look valid and have not already been set
                    if (method_exists($Field, $MethodName)
                            && ($MethodName != "Name")
                            && ($MethodName != "Type"))
                    {
                        # condense down any extraneous whitespace
                        $Value = preg_replace("/\s+/", " ", trim($Value));

                        # set value for field
                        $Field->$MethodName($Value);
                    }
                }

                # make new field permanent
                $Field->IsTempItem(FALSE);
            }
        }

        # return new field (if any) to caller
        return $Field;
    }

    # delete metadata field
    function DropField($FieldId)
    {
        $Field = new MetadataField($FieldId);
        $Field->Drop();
    }

    # retrieve field by ID
    function GetField($FieldId)
    {
        static $Fields;

        # if caching is off or field is already loaded
        if (($this->CachingOn != TRUE) || !isset($Fields[$FieldId]))
        {
            # retrieve field
            $Fields[$FieldId] = new MetadataField($FieldId);
        }

        # return field to caller
        return $Fields[$FieldId];
    }

    /**
     * Retrieve metadata field by name.
     * @param FieldName Field name.
     * @param IgnoreCase If TRUE, case is ignore when matching field names.
     * @return Requested MetadataField or NULL if no field found with specified name.
     */
    function GetFieldByName($FieldName, $IgnoreCase = FALSE)
    {
        $FieldId = $this->GetFieldIdByName($FieldName, $IgnoreCase);
        return ($FieldId === NULL) ? NULL : $this->GetField($FieldId);
    }

    /**
     * Retrieve metadata field by label.
     * @param FieldLabel Field label.
     * @param IgnoreCase If TRUE, case is ignore when matching field labels.
     * @return Requested MetadataField or NULL if no field found with specified label.
     */
    function GetFieldByLabel($FieldLabel, $IgnoreCase = FALSE)
    {
        $FieldId = $this->GetFieldIdByLabel($FieldLabel, $IgnoreCase);
        return ($FieldId === NULL) ? NULL : $this->GetField($FieldId);
    }

    /**
     * Retrieve metadata field ID by name.
     * @param FieldName Field name.
     * @param IgnoreCase If TRUE, case is ignore when matching field names.
     * @return ID of requested MetadataField or FALSE if no field found with
     *       specified name.
     */
    function GetFieldIdByName($FieldName, $IgnoreCase = FALSE)
    {
        static $FieldIdsByName;

        # if caching is off or field ID is already loaded
        if (($this->CachingOn != TRUE) || !isset($FieldIdsByName[$FieldName]))
        {
            # retrieve field ID from DB
            $Condition = $IgnoreCase
                    ? "WHERE LOWER(FieldName) = '".addslashes(strtolower($FieldName))."'"
                    : "WHERE FieldName = '".addslashes($FieldName)."'";
            $FieldIdsByName[$FieldName] = $this->DB->Query(
                    "SELECT FieldId FROM MetadataFields ".$Condition, "FieldId");
        }

        return $FieldIdsByName[$FieldName];
    }

    /**
     * Retrieve metadata field ID by label.
     * @param FieldLabel Field label.
     * @param IgnoreCase If TRUE, case is ignore when matching field labels.
     * @return ID of requested MetadataField or FALSE if no field found with
     *       specified label.
     */
    function GetFieldIdByLabel($FieldLabel, $IgnoreCase = FALSE)
    {
        static $FieldIdsByLabel;

        # if caching is off or field ID is already loaded
        if (($this->CachingOn != TRUE) || !isset($FieldIdsByLabel[$FieldLabel]))
        {
            # retrieve field ID from DB
            $Condition = $IgnoreCase
                    ? "WHERE LOWER(Label) = '".addslashes(strtolower($FieldLabel))."'"
                    : "WHERE Label = '".addslashes($FieldLabel)."'";
            $FieldIdsByLabel[$FieldLabel] = $this->DB->Query(
                    "SELECT FieldId FROM MetadataFields ".$Condition, "FieldId");
        }

        return $FieldIdsByLabel[$FieldLabel];
    }

    # check whether field with specified name exists
    function FieldExists($FieldName) {  return $this->NameIsInUse($FieldName);  }

    # retrieve array of fields
    function GetFields($FieldTypes = NULL, $OrderType = NULL,
            $IncludeDisabledFields = FALSE, $IncludeTempFields = FALSE)
    {
        # create empty array to pass back
        $Fields = array();

        # for each field type in database
        if ($IncludeTempFields && $IncludeDisabledFields)
        {
            $this->DB->Query("SELECT FieldId, FieldType FROM MetadataFields");
        }
        else
        {
            if ($IncludeTempFields)
            {
                $this->DB->Query("SELECT FieldId, FieldType FROM MetadataFields WHERE Enabled != 0");
            }
            elseif ($IncludeDisabledFields)
            {
                $this->DB->Query("SELECT FieldId, FieldType FROM MetadataFields WHERE FieldId >= 0");
            }
            else
            {
                $this->DB->Query("SELECT FieldId, FieldType FROM MetadataFields WHERE FieldId >= 0 AND Enabled != 0");
            }
        }
        while ($Record = $this->DB->FetchRow())
        {
            # if no specific type requested or if field is of requested type
            if (($FieldTypes == NULL)
                || (MetadataField::$FieldTypePHPEnums[$Record["FieldType"]] & $FieldTypes))
            {
                # create field object and add to array to be passed back
                $Fields[$Record["FieldId"]] = $this->GetField($Record["FieldId"]);
            }
        }

        # if field sorting requested
        if ($OrderType !== NULL)
        {
            # sort field array by requested order type
            $this->FieldCompareType = $OrderType;
            $this->FieldOrderError = FALSE;
            uasort($Fields, array($this, "CompareFieldOrder"));

            # if field order error detected
            if ($this->FieldOrderError)
            {
                # repair (reset) field order
                $OrderIndex = 1;
                foreach ($Fields as $Field)
                {
                    $Field->OrderPosition($OrderType, $OrderIndex);
                    $OrderIndex++;
                }
            }
        }

        # return array of field objects to caller
        return $Fields;
    }

    # callback function for sorting fields
    function CompareFieldOrder($FieldA, $FieldB)
    {
        if ($this->FieldCompareType == MetadataSchema::MDFORDER_ALPHABETICAL)
        {
            return ($FieldA->GetDisplayName() < $FieldB->GetDisplayName()) ? -1 : 1;
        }
        else
        {
            if ($FieldA->OrderPosition($this->FieldCompareType)
                    == $FieldB->OrderPosition($this->FieldCompareType))
            {
                $this->FieldOrderError = TRUE;
                return 0;
            }
            else
            {
                return ($FieldA->OrderPosition($this->FieldCompareType)
                        < $FieldB->OrderPosition($this->FieldCompareType)) ? -1 : 1;
            }
        }
    }

    function GetFieldNames($FieldTypes = NULL, $OrderType = NULL,
                            $IncludeDisabledFields = FALSE, $IncludeTempFields = FALSE)
    {
        global $DB;

        $FieldNames=array();
        $Fields = $this->GetFields($FieldTypes, $OrderType, $IncludeDisabledFields, $IncludeTempFields);

        foreach($Fields as $Field)
        {
            $DB->Query("SELECT FieldName FROM MetadataFields WHERE FieldId=".$Field->Id());
            $FieldNames[ $Field->Id() ] = $DB->FetchField("FieldName");
        }

        return $FieldNames;
    }

    /**
     * Retrieve fields of specified type as HTML option list with field names
     * as labels and field IDs as value attributes.  The first element on the list
     * will have a label of "--" and an ID of -1 to indicate no field selected.
     * @param OptionListName Value of option list "name" and "id" attributes.
     * @param FieldTypes Types of fields to return.  (OPTIONAL - use NULL for all types)
     * @param SelectedFieldId ID or array of IDs of the currently-selected field(s).  (OPTIONAL)
     * @param IncludeNullOption Whether to include "no selection" (-1) option.
     *       (OPTIONAL - defaults to TRUE)
     * @param AddEntries An array of additional entries to include at the end of
     *       the option list, with option list values for the indexes and option list
     *       labels for the values.  (OPTIONAL)
     * @param AllowMultiple TRUE to allow multiple field selections
     * @return HTML for option list.
     */
    function GetFieldsAsOptionList($OptionListName, $FieldTypes = NULL,
            $SelectedFieldId = NULL, $IncludeNullOption = TRUE,
            $AddEntries = NULL, $AllowMultiple = FALSE)
    {
        # retrieve requested fields
        $FieldNames = $this->GetFieldNames($FieldTypes);

        # transform field names to labels
        foreach ($FieldNames as $FieldId => $FieldName)
        {
            $FieldNames[$FieldId] = $this->GetField($FieldId)->GetDisplayName();
        }

        # begin HTML option list
        $Html = "<select id=\"".$OptionListName."\" name=\"".$OptionListName."\"";

        # if multiple selections should be allowed
        if ($AllowMultiple)
        {
            $Html .= " multiple=\"multiple\"";
        }

        $Html .= ">\n";

        if ($IncludeNullOption)
        {
            $Html .= "<option value=\"\">--</option>\n";
        }

        # make checking for IDs simpler
        if (!is_array($SelectedFieldId))
        {
            $SelectedFieldId = array($SelectedFieldId);
        }

        # for each metadata field
        foreach ($FieldNames as $Id => $Name)
        {
            # add entry for field to option list
            $Html .= "<option value=\"".$Id."\"";
            if (in_array($Id, $SelectedFieldId)) {  $Html .= " selected";  }
            $Html .= ">".htmlspecialchars($Name)."</option>\n";
        }

        # if additional entries were requested
        if ($AddEntries)
        {
            foreach ($AddEntries as $Value => $Label)
            {
                $Html .= "<option value=\"".$Value."\"";
                if (in_array($Value,$SelectedFieldId)) {  $Html .= " selected";  }
                $Html .= ">".htmlspecialchars($Label)."</option>\n";
            }
        }

        # end HTML option list
        $Html .= "</select>\n";

        # return constructed HTML to caller
        return $Html;
    }

    # retrieve array of field types (enumerated type => field name)
    function GetFieldTypes()
    {
        return MetadataField::$FieldTypeDBEnums;
    }

    # retrieve array of field types that user can create (enumerated type => field name)
    function GetAllowedFieldTypes()
    {
        return MetadataField::$FieldTypeDBAllowedEnums;
    }

    # remove all metadata field associations for a given qualifier
    function RemoveQualifierAssociations($QualifierIdOrObject)
    {
        # sanitize qualifier ID or grab it from object
        $QualifierIdOrObject = is_object($QualifierIdOrObject)
                ? $QualifierIdOrObject->Id() : intval($QualifierIdOrObject);

        # delete intersection records from database
        $this->DB->Query("DELETE FROM FieldQualifierInts WHERE QualifierId = "
                         .$QualifierIdOrObject);
    }

    # return whether qualifier is in use by metadata field
    function QualifierIsInUse($QualifierIdOrObject)
    {
        # sanitize qualifier ID or grab it from object
        $QualifierIdOrObject = is_object($QualifierIdOrObject)
                ? $QualifierIdOrObject->Id() : intval($QualifierIdOrObject);

        # determine whether any fields use qualifier as default
        $DefaultCount = $this->DB->Query("SELECT COUNT(*) AS RecordCount FROM MetadataFields"
                                         ." WHERE DefaultQualifier = ".$QualifierIdOrObject,
                                         "RecordCount");

        # determine whether any fields are associated with qualifier
        $AssociationCount = $this->DB->Query("SELECT COUNT(*) AS RecordCount FROM FieldQualifierInts"
                                         ." WHERE QualifierId = ".$QualifierIdOrObject,
                                         "RecordCount");

        # report whether qualifier is in use based on defaults and associations
        return (($DefaultCount + $AssociationCount) > 0) ? TRUE : FALSE;
    }

    # move fields up or down in field order
    function MoveUpInOrder($FieldIdOrObj, $OrderType)
    {
        $this->MoveFieldInOrder($FieldIdOrObj, $OrderType, FALSE);
    }
    function MoveDownInOrder($FieldIdOrObj, $OrderType)
    {
        $this->MoveFieldInOrder($FieldIdOrObj, $OrderType, TRUE);
    }

    # return highest field ID currently in use
    function GetHighestFieldId() {  return $this->GetHighestItemId();  }

    /**
     * Get/set mapping of standard field name to specific field.
     * @param MappedName Standard field name.
     * @param FieldId ID of field to map to. (OPTIONAL)
     * @return ID of field to which standard field name is mapped or NULL if
     *   specified standard field name is not currently mapped.
     */
    static function StdNameToFieldMapping($MappedName, $FieldId = NULL)
    {
        if ($FieldId !== NULL)
        {
            self::$FieldMappings[$MappedName] = $FieldId;
        }
        return isset(self::$FieldMappings[$MappedName])
                ? self::$FieldMappings[$MappedName] : NULL;
    }

    /**
     * Get mapping of field ID to standard field name.
     * @param FieldId Field ID.
     * @return Standard field name to which specified field is mapped, or
     *   NULL if field is not currently mapped.
     */
    static function FieldToStdNameMapping($FieldId)
    {
        if ($FieldId != -1)
        {
            foreach (self::$FieldMappings as $MappedName => $MappedFieldId)
            {
                if ($MappedFieldId == $FieldId)
                {
                    return $MappedName;
                }
            }
        }
        return NULL;
    }

    /**
     * Get field by standard field name.
     * @param MappedName Standard field name.
     * @return MetadataField to which standard field name is mapped or NULL if
     *   specified standard field name is not currently mapped or mapped field
     *   does not exist.
     */
    function GetFieldByMappedName($MappedName)
    {
        return ($this->StdNameToFieldMapping($MappedName) == NULL) ? NULL
                : $this->GetField($this->StdNameToFieldMapping($MappedName));
    }

    /**
     * Get fields that have an owner associated with them
     * @return an array of fields that have an owner associated with them
     */
    public function GetOwnedFields()
    {
        $Fields = array();

        $this->DB->Query("
            SELECT * FROM MetadataFields
            WHERE Owner IS NOT NULL AND LENGTH(Owner) > 0");

        while (FALSE !== ($Row = $this->DB->FetchRow()))
        {
            $FieldId = $Row["FieldId"];
            $Fields[$FieldId] = $this->GetField($FieldId);
        }

        return $Fields;
    }

    /**
     * Allow external dependencies, i.e., the current list of owners that are
     * available, to be injected.
     * @param $Callback retrieval callback
     */
    public static function SetOwnerListRetrievalFunction($Callback)
    {
        if (is_callable($Callback))
        {
            self::$OwnerListRetrievalFunction = $Callback;
        }
    }

    /**
     * Disable owned fields that have an owner that is unavailable and
     * re-enable fields if an owner has returned and the field was flagged to
     * be re-enabled.
     */
    public static function NormalizeOwnedFields()
    {
        # if an owner list retrieval function exists
        if (self::$OwnerListRetrievalFunction)
        {
            # retrieve the list of owners that currently exist
            $OwnerList = call_user_func(self::$OwnerListRetrievalFunction);

            # an array is expected
            if (is_array($OwnerList))
            {
                $Schema = new MetadataSchema();

                # get each metadata field that is owned by a plugin
                $OwnedFields = $Schema->GetOwnedFields();

                # loop through each owned field
                foreach ($OwnedFields as $OwnedField)
                {
                    # the owner of the current field
                    $Owner = $OwnedField->Owner();

                    # if the owner of the field is in the list of owners that
                    # currently exist, i.e., available plugins
                    if (in_array($Owner, $OwnerList))
                    {
                        # enable the field and reset its "enable on owner return"
                        # flag if the "enable on owner return" flag is currently
                        # set to true. in other words, re-enable the field since
                        # the owner has returned to the list of existing owners
                        if ($OwnedField->EnableOnOwnerReturn())
                        {
                            $OwnedField->Enabled(TRUE);
                            $OwnedField->EnableOnOwnerReturn(FALSE);
                        }
                    }

                    # if the owner of the field is *not* in the list of owners
                    # that currently exist, i.e., available plugins
                    else
                    {
                        # first, see if the field is currently enabled since it
                        # will determine whether the field is re-enabled when
                        # the owner becomes available again
                        $Enabled = $OwnedField->Enabled();

                        # if the field is enabled, set its "enable on owner
                        # return" flag to true and disable the field. nothing
                        # needs to be done if the field is already disabled
                        if ($Enabled)
                        {
                            $OwnedField->EnableOnOwnerReturn($Enabled);
                            $OwnedField->Enabled(FALSE);
                        }
                    }
                }
            }
        }
    }

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

    private $FieldCompareType;
    private $FieldOrderError;
    private $CachingOn;
    private static $FieldMappings;
    protected static $OwnerListRetrievalFunction;

    private function MoveFieldInOrder($FieldIdOrObj, $OrderType, $MoveFieldDown)
    {
        # grab field ID
        $FieldId = is_object($FieldIdOrObj) ? $Field->Id() : $FieldIdOrObj;

        # retrieve array of fields
        $Fields = $this->GetFields(NULL, $OrderType);

        # reverse array of fields if we are moving field down
        if ($MoveFieldDown)
        {
            $Fields = array_reverse($Fields);
        }

        # for each field in order
        $PreviousField = NULL;
        foreach ($Fields as $Field)
        {
            # if field is the field to be moved
            if ($Field->Id() == $FieldId)
            {
                # if we have a previous field
                if ($PreviousField !== NULL)
                {
                    # swap field with previous field according to order type
                    $TempVal = $Field->OrderPosition($OrderType);
                    $Field->OrderPosition($OrderType, $PreviousField->OrderPosition($OrderType));
                    $PreviousField->OrderPosition($OrderType, $TempVal);
                }
            }

            # save field for next iteration
            $PreviousField = $Field;
        }
    }
}
