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

/**
* Base class (covering non-presentation elements) supplying a standard user
* interface for presenting and working with HTML forms.
*/
abstract class FormUI_Base
{
    # ---- PUBLIC INTERFACE --------------------------------------------------

    /** Supported field types. */
    const FTYPE_FLAG = "Flag";
    const FTYPE_IMAGE = "Image";
    const FTYPE_METADATAFIELD = "MetadataField";
    const FTYPE_NUMBER = "Number";
    const FTYPE_OPTION = "Option";
    const FTYPE_PARAGRAPH = "Paragraph";
    const FTYPE_PRIVILEGES = "Privileges";
    const FTYPE_TEXT = "Text";
    const FTYPE_URL = "URL";
    const FTYPE_USER = "User";
    /** Supported field pseudo-types. */
    const FTYPE_HEADING = "Heading";

    /**
    * Class constructor.
    * @param array $FieldParams Associative array of associative arrays of
    *       form field parameters, with field names for the top index.
    * @param array $FieldValues Associative array of current values for
    *       form fields, with field names for the index.
    * @param string $UniqueKey Unique string to include in form field names
    *       to distinguish them from other fields in the form.  (OPTIONAL)
    */
    public function __construct($FieldParams, $FieldValues, $UniqueKey = NULL)
    {
        # make sure parameters are legal and complete
        $BooleanParams = array(
                "AllowMultiple",
                "ReadOnly",
                "Required",
                "UseWYSIWYG",
                );
        foreach ($FieldParams as $FieldName => $Params)
        {
            if (!isset($Params["Type"]))
            {
                $ErrMsgs[] = "Type missing for form field ".$FieldName.".";
            }
            if (!isset($Params["Label"]))
            {
                $ErrMsgs[] = "Label missing for form field ".$FieldName.".";
            }
            if (isset($Params["ValidateFunction"])
                    && !is_callable($Params["ValidateFunction"]))
            {
                $ErrMsgs[] = "Uncallable validation function for form field "
                        .$FieldName.".";
            }
            if (isset($Params["InsertIntoField"])
                    && !isset($FieldParams[$Params["InsertIntoField"]]))
            {
                $ErrMsgs[] = "Unknown insertion field (".$Params["InsertIntoField"]
                        .") found for form field ".$FieldName.".";
            }
            foreach ($BooleanParams as $ParamName)
            {
                if (!isset($Params[$ParamName]))
                {
                    $FieldParams[$FieldName][$ParamName] = FALSE;
                }
            }
        }
        if (isset($ErrMsgs))
        {
            $ErrMsgString = implode("  ", $ErrMsgs);
            throw new InvalidArgumentException($ErrMsgString);
        }

        # save form parameters and values
        $this->FieldParams = $FieldParams;
        $this->FieldValues = $FieldValues;
        $this->UniqueKey = $UniqueKey;
    }

    /**
    * Display HTML table with settings parameters.
    * @param string $TableId CSS ID for table element.  (OPTIONAL)
    * @param string $TableStyle CSS styles for table element.  (OPTIONAL)
    */
    abstract public function DisplayFormTable($TableId = NULL, $TableStyle = NULL);

    /**
    * Log error message for later display.
    * @param string $Msg Error message.
    * @param string $Field Field associated with error.  (OPTIONAL, defaults
    *       to no field association)
    */
    public static function LogError($Msg, $Field = NULL)
    {
        self::$ErrorMessages[$Field][] = $Msg;
    }

    /**
    * Get logged errors.
    * @return array Logged errors, with associated fields for the index (NULL
    *       for errors with no association) and an array of error messages for
    *       each value.
    */
    public static function GetLoggedErrors()
    {
        return self::$ErrorMessages;
    }

    /**
    * Report whether errors have been logged.
    * @param string $Field Field to check -- specify NULL to check for any
    *       errors with no field associated.  (OPTIONAL)
    * @return bool TRUE if errors have been logged, otherwise FALSE.
    */
    public static function ErrorsLogged($Field = FALSE)
    {
        if ($Field === FALSE)
        {
            return count(self::$ErrorMessages) ? TRUE : FALSE;
        }
        else
        {
            return isset(self::$ErrorMessages[$Field]) ? TRUE : FALSE;
        }
    }

    /**
    * Clear logged errors.
    * @param string $Field Clear only errors for specified field.  (OPTIONAL)
    */
    public static function ClearLoggedErrors($Field = FALSE)
    {
        if ($Field === FALSE)
        {
            self::$ErrorMessages = array();
        }
        else
        {
            unset(self::$ErrorMessages[$Field]);
        }
    }

    /**
    * Validate field values on submitted form.  Validation functions (specified
    * via the "ValidateFunction" parameter) should take a field name and value
    * as parameters, and return NULL if the field validates successfully, or
    * an error message if it does not.
    * @return int Number of fields with invalid values found.
    */
    public function ValidateFieldInput()
    {
        # retrieve field values
        $Values = $this->GetNewValuesFromForm();

        # for each field
        $ErrorsFound = 0;
        foreach ($this->FieldParams as $Name => $Params)
        {
            # determine if field has a value set
            if (is_array($Values[$Name]))
            {
                $IsEmpty = !count($Values[$Name]);
            }
            else
            {
                $IsEmpty = !strlen(trim($Values[$Name]));
            }

            # if field has validation function
            if (isset($Params["ValidateFunction"]))
            {
                # call validation function for value
                $Args = array_merge(array($Name, $Values[$Name]),
                        $this->ExtraValidationParams);
                $ErrMsg = call_user_func_array(
                        $Params["ValidateFunction"], $Args);
                if ($ErrMsg === FALSE)
                {
                    throw new Exception("Calling validation function for"
                            ." parameter \"".$Name."\" failed.");
                }

                # log any resulting error
                if ($ErrMsg !== NULL)
                {
                    self::LogError($ErrMsg, $Name);
                    $ErrorsFound++;
                }
            }

            # if field is required and empty
            if ($IsEmpty && isset($Params["Required"]) && $Params["Required"])
            {
                # log error to indicate required value is missing
                self::LogError("<i>".$Params["Label"]."</i> is required.", $Name);
                $ErrorsFound++;
            }
            # else validate based on field type
            else
            {
                switch ($Params["Type"])
                {
                    case self::FTYPE_URL:
                        # make sure URL entered looks valid
                        if (!$IsEmpty && (filter_var(
                                $Values[$Name], FILTER_VALIDATE_URL) === FALSE))
                        {
                            self::LogError("Value \"".$Values[$Name]
                                    ."\" does not appear to be a valid URL for <i>"
                                    .$Params["Label"]."</i>.", $Name);
                            $ErrorsFound++;
                        }
                        break;

                    case self::FTYPE_USER:
                        # make sure user name entered is valid
                        $UFactory = new CWUserFactory();
                        if (!$UFactory->UserNameExists($Values[$Name]))
                        {
                            self::LogError("User name \"".$Values[$Name]
                                    ."\" not found for <i>"
                                    .$Params["Label"]."</i>.", $Name);
                            $ErrorsFound++;
                        }
                        break;
                }
            }
        }

        # report number of fields with invalid values found to caller
        return $ErrorsFound;
    }

    /**
    * Add values to be passed to input validation functions, in addition
    * to field name and value.
    * @see FormUI_Base::ValidateFieldInput()
    */
    public function AddValidationParameters()
    {
        $this->ExtraValidationParams = func_get_args();
    }

    /**
    * Retrieve values set by form.
    * @return array Array of configuration settings, with setting names
    *       for the index, and new setting values for the values.
    */
    public function GetNewValuesFromForm()
    {
        # for each configuration setting
        $NewSettings = array();
        foreach ($this->FieldParams as $Name => $Params)
        {
            # determine form field name (matches mechanism in HTML)
            $FieldName = $this->GetFormFieldName($Name);

            # assume the plugin value will not change
            $DidValueChange = FALSE;
            $OldValue = isset($this->FieldValues[$Name])
                    ? $this->FieldValues[$Name] : NULL;
            $NewSettings[$Name] = $OldValue;

            # retrieve value based on configuration parameter type
            switch ($Params["Type"])
            {
                case self::FTYPE_FLAG:
                    # if radio buttons were used
                    if (array_key_exists("OnLabel", $Params)
                            && array_key_exists("OffLabel", $Params))
                    {
                        if (isset($_POST[$FieldName]))
                        {
                            $NewValue = ($_POST[$FieldName] == "1") ? TRUE : FALSE;

                            # flag that the values changed if they did
                            $DidValueChange = self::DidValueChange(
                                    $OldValue, $NewValue);

                            $NewSettings[$Name] = $NewValue;
                        }
                    }
                    # else checkbox was used
                    else
                    {
                        $NewValue = isset($_POST[$FieldName]) ? TRUE : FALSE;

                        # flag that the values changed if they did
                        $DidValueChange = self::DidValueChange($OldValue, $NewValue);

                        $NewSettings[$Name] = $NewValue;
                    }
                    break;

                case self::FTYPE_OPTION:
                    $NewValue = GetArrayValue($_POST, $FieldName, array());

                    # flag that the values changed if they did
                    $DidValueChange = self::DidValueChange($OldValue, $NewValue);

                    $NewSettings[$Name] = $NewValue;
                    break;

                case self::FTYPE_PRIVILEGES:
                case self::FTYPE_METADATAFIELD:
                    $NewValue = GetArrayValue($_POST, $FieldName, array());
                    if ($NewValue == "-1") {  $NewValue = array();  }

                    # flag that the values changed if they did
                    $DidValueChange = self::DidValueChange($OldValue, $NewValue);

                    $NewSettings[$Name] = $NewValue;
                    break;

                case self::FTYPE_IMAGE:
                    $NewSettings[$Name] = GetArrayValue(
                            $_POST, $FieldName."_ID", array());
                    foreach ($NewSettings[$Name] as $Index => $ImageId)
                    {
                        if ($ImageId == self::NO_VALUE_FOR_FIELD)
                        {
                            unset($NewSettings[$Name][$Index]);
                        }
                        if (isset($_POST[$FieldName."_AltText_".$ImageId]))
                        {
                            $Image = new SPTImage($ImageId);
                            $Image->AltText($_POST[$FieldName."_AltText_".$ImageId]);
                        }
                    }
                    break;

                default:
                    if (isset($_POST[$FieldName]))
                    {
                        $NewValue = $_POST[$FieldName];

                        # flag that the values changed if they did
                        $DidValueChange = self::DidValueChange($OldValue, $NewValue);

                        $NewSettings[$Name] = $NewValue;
                    }
                    break;
            }

            # if value changed and there is an event to signal for changes
            if ($DidValueChange && $this->SettingChangeEventName)
            {
                # set info about changed value in event parameters if appropriate
                $EventParams = $this->SettingChangeEventParams;
                foreach ($EventParams as $ParamName => $ParamValue)
                {
                    switch ($ParamName)
                    {
                        case "SettingName":
                            $EventParams[$ParamName] = $Name;
                            break;

                        case "OldValue":
                            $EventParams[$ParamName] = $OldValue;
                            break;

                        case "NewValue":
                            $EventParams[$ParamName] = $NewValue;
                            break;
                    }
                }

                # signal event
                $GLOBALS["AF"]->SignalEvent(
                        $this->SettingChangeEventName, $EventParams);
            }
        }

        # return updated setting values to caller
        return $NewSettings;
    }

    /**
    * Get value for form field.
    * @param string $FieldName Canonical field name.
    * @return mixed Value or array of values for field.
    */
    public function GetFieldValue($FieldName)
    {
        # get base form field name
        $FormFieldName = $this->GetFormFieldName($FieldName);

        switch ($this->FieldParams[$FieldName]["Type"])
        {
            case self::FTYPE_IMAGE:
                # get name of image ID form field
                $ImgIdFieldName = $FormFieldName."_ID";

                # use an updated value for this field if available
                if (isset($this->HiddenFields[$ImgIdFieldName]))
                {
                    $Value = $this->HiddenFields[$ImgIdFieldName];
                }
                # else use a value from form if available
                elseif (isset($_POST[$ImgIdFieldName]))
                {
                    $Value = $_POST[$ImgIdFieldName];
                }
                # else use a stored value if available
                elseif (isset($this->FieldValues[$FieldName]))
                {
                    $Value = $this->FieldValues[$FieldName];
                }
                # else assume no value set for field
                else
                {
                    $Value = array();
                }

                # add in any previously-set extra values
                if (isset($this->ExtraValues[$ImgIdFieldName])
                        && count($this->ExtraValues[$ImgIdFieldName]))
                {
                    if (!is_array($Value))
                    {
                        $Value = array($Value);
                    }
                    $Value = array_merge($Value,
                            $this->ExtraValues[$ImgIdFieldName]);
                }
                break;

            default:
                # use incoming form value if available
                if (isset($_POST[$FormFieldName]))
                {
                    # use incoming form value
                    $Value = $_POST[$FormFieldName];
                }
                # else use current value for field if available
                elseif (isset($this->FieldValues[$FieldName]))
                {
                    $Value = $this->FieldValues[$FieldName];
                }
                # else use empty value
                else
                {
                    $Value = NULL;
                }
                break;
        }

        # return value found to caller
        return $Value;
    }

    /**
    * Handle image and file uploads.
    */
    public function HandleUploads()
    {
        # for each form field
        foreach ($this->FieldParams as $FieldName => $FieldParams)
        {
            # move on to next field if this field does not allow uploads
            if ($FieldParams["Type"] != self::FTYPE_IMAGE)
            {
                continue;
            }

            # move on to next field if this field does not have an uploaded file
            $FormFieldName = $this->GetFormFieldName($FieldName);
            if (!isset($_FILES[$FormFieldName]["name"]))
            {
                continue;
            }
            $UploadedFileName = $_FILES[$FormFieldName]["name"];
            if (!strlen($UploadedFileName))
            {
                continue;
            }

            # create temp copy of file with correct name
            $TmpFile = "tmp/".$UploadedFileName;
            copy($_FILES[$FormFieldName]["tmp_name"], $TmpFile);

            switch ($FieldParams["Type"])
            {
                case self::FTYPE_IMAGE:
                    # create new image object from uploaded file
                    $Image = new SPTImage($TmpFile,
                            $FieldParams["MaxWidth"],
                            $FieldParams["MaxHeight"],
                            $FieldParams["MaxPreviewWidth"],
                            $FieldParams["MaxPreviewHeight"],
                            $FieldParams["MaxThumbnailWidth"],
                            $FieldParams["MaxThumbnailHeight"]);

                    # check for errors during image object creation
                    if ($Image->Status() != AI_OKAY)
                    {
                        switch ($Image->Status())
                        {
                            case AI_UNKNOWNTYPE:
                                $this->LogError("Unknown format for file "
                                        .$UploadedFileName.".", $FieldName);
                                break;

                            case AI_UNSUPPORTEDFORMAT:
                                $this->LogError("Unsupported format for file "
                                        .$UploadedFileName." ("
                                        .$Image->Format().").", $FieldName);
                                break;

                            default:
                                $this->LogError("Error encountered when"
                                        ." processing uploaded image file "
                                        .$UploadedFileName.".", $FieldName);
                                break;
                        }
                        unlink($TmpFile);
                        continue;
                    }

                    # set image object alternate text
                    $Image->AltText($_POST[$FormFieldName."_AltText_NEW"]);

                    # add image ID to extra values
                    $this->ExtraValues[$FormFieldName."_ID"][] = $Image->Id();
                    break;
            }
        }
    }

    /**
    * Handle image and file deletions.
    */
    public function HandleDeletes()
    {
        # if deleted image ID is available
        $IncomingValue = GetFormValue("F_ImageToDelete");
        if (is_numeric($IncomingValue)
                || (is_array($IncomingValue) && count($IncomingValue)))
        {
            # retrieve ID of image
            $ImageId = is_array($IncomingValue)
                    ? array_shift($IncomingValue)
                    : $IncomingValue;

            # add ID to deleted images list
            $this->DeletedImages[] = $ImageId;
        }
    }

    /**
    * Set event to signal when retrieving values from form when settings
    * have changed.  If the supplied event parameters include parameter
    * names (indexes) of "SettingName", "OldValue", or "NewValue", the
    * parameter value will be replaced with an appropriate value before
    * the event is signaled.
    * @param string $EventName Name of event to signal.
    * @param array $EventParams Array of event parameters, with CamelCase
    *       parameter names for index.  (OPTIONAL)
    * @see FormUI_Base::GetNewsettingsFromForm()
    */
    public function SetEventToSignalOnChange($EventName, $EventParams = array())
    {
        $this->SettingChangeEventName = $EventName;
        $this->SettingChangeEventParams = $EventParams;
    }

    /**
    * Determine if a new form field value is different from an old one.
    * @param mixed $OldValue Old field value.
    * @param mixed $NewValue New field value.
    * @return Returns TRUE if the values are different and FALSE otherwise.
    */
    public static function DidValueChange($OldValue, $NewValue)
    {
        # didn't change if they are identical
        if ($OldValue === $NewValue)
        {
            return FALSE;
        }

        # need special cases from this point because PHP returns some odd results
        # when performing loose equality comparisons:
        # http://php.net/manual/en/types.comparisons.php#types.comparisions-loose

        # consider NULL and an empty string to be the same. this is in case a field
        # is currently set to NULL and receives an empty value from the form.
        # $_POST values are always strings
        if ((is_null($OldValue) && is_string($NewValue) && !strlen($NewValue))
            || (is_null($NewValue) && is_string($OldValue) && !strlen($OldValue)))
        {
            return FALSE;
        }

        # if they both appear to be numbers and are equal
        if (is_numeric($OldValue) && is_numeric($NewValue) && $OldValue == $NewValue)
        {
            return FALSE;
        }

        # true-like values
        if (($OldValue === TRUE && ($NewValue === 1 || $NewValue === "1"))
            || ($NewValue === TRUE && ($OldValue === 1 || $OldValue === "1")))
        {
            return FALSE;
        }

        # false-like values
        if (($OldValue === FALSE && ($NewValue === 0 || $NewValue === "0"))
            || ($NewValue === FALSE && ($OldValue === 0 || $OldValue === "0")))
        {
            return FALSE;
        }

        # arrays
        if (is_array($OldValue) && is_array($NewValue))
        {
            # they certainly changed if the counts are different
            if (count($OldValue) != count($NewValue))
            {
                return TRUE;
            }

            # the algorithm for associative arrays is slightly different from
            # sequential ones. the values for associative arrays must match the keys
            if (count(array_filter(array_keys($OldValue), "is_string")))
            {
                foreach ($OldValue as $Key => $Value)
                {
                    # it changed if the keys don't match
                    if (!array_key_exists($Key, $NewValue))
                    {
                        return TRUE;
                    }

                    # the arrays changed if a value changed
                    if (self::DidValueChange($Value, $NewValue[$Key]))
                    {
                        return TRUE;
                    }
                }
            }

            # sequential values don't have to have the same keys, just the same
            # values
            else
            {
                # sort them so all the values match up if they're equal
                sort($OldValue);
                sort($NewValue);

                foreach ($OldValue as $Key => $Value)
                {
                    # the arrays changed if a value changed
                    if (self::DidValueChange($Value, $NewValue[$Key]))
                    {
                        return TRUE;
                    }
                }
            }

            # the arrays are equal
            return FALSE;
        }

        # they changed
        return TRUE;
    }


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

    protected $DeletedImages = array();
    protected $ExtraValidationParams = array();
    protected $ExtraValues = array();
    protected $FieldParams;
    protected $FieldValues;
    protected $HiddenFields = array();
    protected $SettingChangeEventName = NULL;
    protected $SettingChangeEventParams = array();

    protected static $ErrorMessages = array();

    /** Marker used to indicate currently no value for field. */
    const NO_VALUE_FOR_FIELD = "NO VALUE";

    /**
    * Display HTML form field for specified field.
    * @param string $Name Field name.
    * @param mixed $Value Current value for field.
    * @param array $Params Field parameters.
    */
    abstract protected function DisplayFormField($Name, $Value, $Params);

    /**
    * Get HTML form field name for specified field.
    * @param string $FieldName Field name.
    * @return string Form field name.
    */
    protected function GetFormFieldName($FieldName)
    {
        return "F_"
                .($this->UniqueKey ? $this->UniqueKey."_" : "")
                .preg_replace("/[^a-zA-Z0-9]/", "", $FieldName);
    }

    /**
    * Get HTML for hidden form fields associated with form processing.
    */
    protected function GetHiddenFieldsHtml()
    {
        $Html = "";
        if (count($this->HiddenFields))
        {
            foreach ($this->HiddenFields as $FieldName => $Value)
            {
                if (is_array($Value))
                {
                    foreach ($Value as $EachValue)
                    {
                        $Html .= '<input type="hidden" name="'.$FieldName
                                .'[]" value="'.htmlspecialchars($EachValue).'">';
                    }
                }
                else
                {
                    $Html .= '<input type="hidden" name="'.$FieldName
                        .'[]" id="'.$FieldName.'" value="'
                        .htmlspecialchars($Value).'">';
                }
            }
        }
        return $Html;
    }
}
