// +-------------------------------------------------------------------------
// | Copyright (C) 2016 Yunify, Inc.
// +-------------------------------------------------------------------------
// | Licensed under the Apache License, Version 2.0 (the "License");
// | you may not use this work except in compliance with the License.
// | You may obtain a copy of the License in the LICENSE file, or at:
// |
// | http://www.apache.org/licenses/LICENSE-2.0
// |
// | Unless required by applicable law or agreed to in writing, software
// | distributed under the License is distributed on an "AS IS" BASIS,
// | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// | See the License for the specific language governing permissions and
// | limitations under the License.
// +-------------------------------------------------------------------------

package com.qingstor.sdk.utils;

import com.qingstor.sdk.config.EnvContext;
import com.qingstor.sdk.constants.QSConstant;
import com.qingstor.sdk.exception.QSException;
import com.qingstor.sdk.model.RequestInputModel;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class QSSignatureUtil {
    private static Logger logger = QSLoggerUtil.setLoggerHanlder(QSSignatureUtil.class.getName());

    private static final String ENCODING = "UTF-8";
    private static final String ALGORITHM = "HmacSHA256";
    private static final String GMT_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss z";
    private static Map keysMap;

    /**
     * @param parameters parameters to sign
     * @param requestUrl base request url
     * @return generated url
     * @throws QSException UnsupportedEncodingException
     */
    public static String generateQSURL(Map<String, String> parameters, String requestUrl)
            throws QSException {

        parameters = QSParamInvokeUtil.serializeParams(parameters);
        StringBuilder sbStringToSign = new StringBuilder();

        String[] sortedKeys = parameters.keySet().toArray(new String[]{});
        Arrays.sort(sortedKeys);
        int count = 0;

        try {
            for (String key : sortedKeys) {
                if (count != 0) {
                    sbStringToSign.append("&");
                }
                sbStringToSign
                        .append(QSStringUtil.percentEncode(
                                key, QSConstant.ENCODING_UTF8))
                        .append("=")
                        .append(QSStringUtil.percentEncode(
                                parameters.get(key), QSConstant.ENCODING_UTF8));
                count++;
            }
        } catch (UnsupportedEncodingException e) {
            logger.log(Level.SEVERE, e.getMessage());
            throw new QSException("generateQSURL error", e);
        }

        if (sbStringToSign.length() > 0) {

            if (requestUrl.indexOf("?") > 0) {
                return String.format("%s&%s", requestUrl, sbStringToSign.toString());
            } else {
                return String.format("%s?%s", requestUrl, sbStringToSign.toString());
            }
        }
        return requestUrl;
    }

    /**
     * Generate signature for request against QingStor.
     *
     * @param accessKey:  API access key ID
     * @param secretKey:  API secret access key ID
     * @param method:     HTTP method
     * @param requestURI:
     * @param params:     HTTP request parameters
     * @param headers:    HTTP request headers
     * @return a string which can be used as value of HTTP request header field "Authorization"
     * directly.
     *
See https://docs.qingcloud.com/qingstor/api/common/signature.html for more details
     * about how to do signature of request against QingStor.
     */
    public static String generateAuthorization(
            String accessKey,
            String secretKey,
            String method,
            String requestURI,
            Map<String, String> params,
            Map<String, String> headers) {
        String signature = generateSignature(secretKey, method, requestURI, params, headers);
        return String.format("QS %s:%s", accessKey, signature);
    }

    /**
     * Generate signature for request against QingStor.
     *
     * @param accessKey: API access key ID
     * @param secretKey: API secret access key ID
     * @param strToSign: strToSign
     * @return a string which can be used as value of HTTP request header field "Authorization"
     * directly.
     *
See https://docs.qingcloud.com/qingstor/api/common/signature.html for more details
     * about how to do signature of request against QingStor.
     */
    public static String generateAuthorization(
            String accessKey,
            String secretKey,
            String strToSign) {
        String signature = generateSignature(secretKey, strToSign);
        return String.format("QS %s:%s", accessKey, signature);
    }

    /**
     * Generate signature for request against QingStor.
     *
     * @param secretKey:  API secret access key ID
     * @param method:     HTTP method
     * @param requestURI:
     * @param params:     HTTP request parameters
     * @param headers:    HTTP request headers
     * @return a string which can be used as value of HTTP request header field "Authorization"
     * directly.
     *
See https://docs.qingcloud.com/qingstor/api/common/signature.html for more details
     * about how to do signature of request against QingStor.
     */
    public static String generateSignature(
            String secretKey,
            String method,
            String requestURI,
            Map<String, String> params,
            Map<String, String> headers) {
        String signature = "";
        String strToSign = getStringToSignature(method, requestURI, params, headers);
        logger.log(Level.INFO, "== String to sign ==\n" + strToSign + "\n");
        signature = generateSignature(secretKey, strToSign);
        return signature;
    }

    public static String getStringToSignature(
            String method,
            String authPath,
            Map<String, String> params,
            Map<String, String> headers) {
        final String SEPARATOR = "&";
        String strToSign = "";

        strToSign += method.toUpperCase() + "\n";

        String contentMD5 = "";
        String contentType = "";
        if (headers != null) {
            if (headers.containsKey("content-md5")) contentMD5 = headers.get("content-md5");
            if (headers.containsKey("content-type")) contentType = headers.get("content-type");
        }
        strToSign += contentMD5 + "\n";
        strToSign += contentType;

        // Append request time as string
        String dateStr = "";
        if (headers != null) {
            if (headers.containsKey(QSConstant.HEADER_PARAM_KEY_DATE)) {
                dateStr = headers.get(QSConstant.HEADER_PARAM_KEY_DATE);
            }
            if (headers.containsKey(QSConstant.HEADER_PARAM_KEY_EXPIRES)) {
                dateStr = headers.get(QSConstant.HEADER_PARAM_KEY_EXPIRES);
            }
        }
        strToSign += "\n" + dateStr;

        // Generate signed headers.
        if (headers != null) {
            String[] sortedHeadersKeys = headers.keySet().toArray(new String[]{});
            Arrays.sort(sortedHeadersKeys);
            for (String key : sortedHeadersKeys) {
                if (!key.startsWith("x-qs-") && !key.startsWith("X-QS-")) continue;
                strToSign += String.format("\n%s:%s", key.toLowerCase(), headers.get(key));
            }
        }

        // Generate canonicalized query string.
        String canonicalized_query = "";
        if (params != null) {
            String[] sortedParamsKeys = params.keySet().toArray(new String[]{});
            Arrays.sort(sortedParamsKeys);
            for (String key : sortedParamsKeys) {

                if (!isSubKey(key)) {
                    continue;
                }

                if (!canonicalized_query.isEmpty()) {
                    canonicalized_query += SEPARATOR;
                }

                try {
                    canonicalized_query += key;
                    String value = String.valueOf(params.get(key));
                    if (!value.isEmpty()) {
                        canonicalized_query += "=" + value;
                    }
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }
        }

        // Generate canonicalized resource.
        String canonicalized_resource = authPath;
        if (!canonicalized_query.isEmpty()) {
            canonicalized_resource += "?" + canonicalized_query;
        }
        strToSign += String.format("\n%s", canonicalized_resource);

        logger.log(Level.INFO, "== String to sign ==\n" + strToSign + "\n");

        return strToSign;
    }

    public static String generateSignature(String secretKey, String strToSign) {
        byte[] signData = null;
        try {
            Mac mac = Mac.getInstance(ALGORITHM);
            mac.init(new SecretKeySpec(secretKey.getBytes(ENCODING), ALGORITHM));
            signData = mac.doFinal(strToSign.getBytes(ENCODING));
            // return new String(Base64.encodeBase64(signData));
        } catch (InvalidKeyException e) {
            throw new RuntimeException(e);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        } catch (IllegalStateException e) {
            throw new RuntimeException(e);
        }

        // Base64 encoder = Base64.getEncoder();
        return new String(Base64.encode(signData));
    }

    public static boolean isSubKey(String key) {
        if (keysMap == null) {
            keysMap =
                    new HashMap() {
                        {
                            put("acl", "acl");
                            put("cors", "cors");
                            put("delete", "delete");
                            put("mirror", "mirror");
                            put("part_number", "part_number");
                            put("policy", "policy");
                            put("stats", "stats");
                            put("upload_id", "upload_id");

                            put("response-expires", "response-expires");
                            put("response-cache-control", "response-cache-control");
                            put("response-content-type", "response-content-type");
                            put("response-content-language", "response-content-language");
                            put("response-content-encoding", "response-content-encoding");
                            put("response-content-disposition", "response-content-disposition");
                        }
                    };
        }
        return keysMap.containsKey(key);
    }

    public static String formatGmtDate(Date date) {
        SimpleDateFormat df = new SimpleDateFormat(GMT_DATE_FORMAT, Locale.US);
        df.setTimeZone(TimeZone.getTimeZone("GMT"));
        String dateStr = df.format(date);
        if (dateStr.indexOf("+") > 0) {
            return dateStr.substring(0, dateStr.indexOf("+"));
        }
        return dateStr;
    }

    public static String getObjectAuthRequestUrl(
            EnvContext envContext,
            String zone,
            String bucketName,
            String objectName,
            int expiresSecond)
            throws QSException {
        Map context = new HashMap();
        try {
            objectName = QSStringUtil.asciiCharactersEncoding(objectName);
            context.put(QSConstant.PARAM_KEY_REQUEST_ZONE, zone);
            context.put(QSConstant.EVN_CONTEXT_KEY, envContext);
            context.put("OperationName", "GetObject");
            context.put("APIName", "GetObject");
            context.put("ServiceName", "QingStor");
            context.put("RequestMethod", "GET");
            context.put("RequestURI", "/<bucket-name>/<object-key>");
            context.put("bucketNameInput", bucketName);
            context.put("objectNameInput", objectName);
            long expiresTime = (new Date().getTime() / 1000 + expiresSecond);
            String expireAuth = getExpireAuth(context, expiresTime, new RequestInputModel());
            String serviceUrl = envContext.getRequestUrl();
            String storRequestUrl = serviceUrl.replace("://", "://%s." + zone + ".");
            if (objectName != null && objectName.indexOf("?") > 0) {
                return String.format(
                        storRequestUrl + "/%s&access_key_id=%s&expires=%s&signature=%s",
                        bucketName,
                        objectName,
                        envContext.getAccessKey(),
                        expiresTime + "",
                        expireAuth);
            } else {
                return String.format(
                        storRequestUrl + "/%s?access_key_id=%s&expires=%s&signature=%s",
                        bucketName,
                        objectName,
                        envContext.getAccessKey(),
                        expiresTime + "",
                        expireAuth);
            }
        } catch (UnsupportedEncodingException e) {
            throw new QSException("Auth signature error", e);
        }
    }

    public static String getExpireAuth(Map context, long expiresSecond, RequestInputModel params)
            throws UnsupportedEncodingException {

        EnvContext envContext = (EnvContext) context.get(QSConstant.EVN_CONTEXT_KEY);

        Map paramsQuery = QSParamInvokeUtil.getRequestParams(params, QSConstant.PARAM_TYPE_QUERY);
        Map paramsHeaders =
                QSParamInvokeUtil.getRequestParams(params, QSConstant.PARAM_TYPE_HEADER);
        paramsHeaders.remove(QSConstant.HEADER_PARAM_KEY_DATE);
        paramsHeaders.clear();
        paramsHeaders.put(QSConstant.HEADER_PARAM_KEY_EXPIRES, expiresSecond + "");

        String method = (String) context.get(QSConstant.PARAM_KEY_REQUEST_METHOD);
        String bucketName = (String) context.get(QSConstant.PARAM_KEY_BUCKET_NAME);
        String requestPath = (String) context.get(QSConstant.PARAM_KEY_REQUEST_PATH);
        requestPath = requestPath.replace(QSConstant.BUCKET_NAME_REPLACE, bucketName);
        if (context.containsKey(QSConstant.PARAM_KEY_OBJECT_NAME)) {
            requestPath =
                    requestPath.replace(
                            QSConstant.OBJECT_NAME_REPLACE,
                            (String) context.get(QSConstant.PARAM_KEY_OBJECT_NAME));
        }
        String authSign =
                generateSignature(
                        envContext.getAccessSecret(),
                        method,
                        requestPath,
                        paramsQuery,
                        paramsHeaders);

        return URLEncoder.encode(authSign, QSConstant.ENCODING_UTF8);
    }
}


// +-------------------------------------------------------------------------
// | Copyright (C) 2016 Yunify, Inc.
// +-------------------------------------------------------------------------
// | Licensed under the Apache License, Version 2.0 (the "License");
// | you may not use this work except in compliance with the License.
// | You may obtain a copy of the License in the LICENSE file, or at:
// |
// | http://www.apache.org/licenses/LICENSE-2.0
// |
// | Unless required by applicable law or agreed to in writing, software
// | distributed under the License is distributed on an "AS IS" BASIS,
// | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// | See the License for the specific language governing permissions and
// | limitations under the License.
// +-------------------------------------------------------------------------

package com.qingstor.sdk.utils;

public class QSServerSignatureUtil {

    /**
     * Generate signature for request against QingStor.
     *
     * @param accessKey: API access key ID
     * @param secretKey: API secret access key ID
     * @param strToSign: strToSign
     * @return a string which can be used as value of HTTP request header field "Authorization"
     * directly.
     *
See https://docs.qingcloud.com/qingstor/api/common/signature.html for more details
     * about how to do signature of request against QingStor.
     */
    public static String generateAuthorization(
            String accessKey,
            String secretKey,
            String strToSign) {
        return QSSignatureUtil.generateAuthorization(accessKey, secretKey, strToSign);
    }

    /**
     * Generate signature for request against QingStor.
     *
     * @param secretKey API secret access key ID
     * @param strToSign string to sign
     * @return signature
     *
     *
See https://docs.qingcloud.com/qingstor/api/common/signature.html for more details

     */
    public static String generateSignature(String secretKey, String strToSign) {
        return QSSignatureUtil.generateSignature(secretKey, strToSign);
    }
}


// +-------------------------------------------------------------------------
// | Copyright (C) 2016 Yunify, Inc.
// +-------------------------------------------------------------------------
// | Licensed under the Apache License, Version 2.0 (the "License");
// | you may not use this work except in compliance with the License.
// | You may obtain a copy of the License in the LICENSE file, or at:
// |
// | http://www.apache.org/licenses/LICENSE-2.0
// |
// | Unless required by applicable law or agreed to in writing, software
// | distributed under the License is distributed on an "AS IS" BASIS,
// | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// | See the License for the specific language governing permissions and
// | limitations under the License.
// +-------------------------------------------------------------------------

package com.qingstor.sdk.request;

import com.qingstor.sdk.annotation.ParamAnnotation;
import com.qingstor.sdk.config.EnvContext;
import com.qingstor.sdk.constants.QSConstant;
import com.qingstor.sdk.exception.QSException;
import com.qingstor.sdk.model.OutputModel;
import com.qingstor.sdk.model.RequestInputModel;
import com.qingstor.sdk.request.impl.ProgressRequestBody;
import com.qingstor.sdk.utils.QSLoggerUtil;
import com.qingstor.sdk.utils.QSParamInvokeUtil;
import com.qingstor.sdk.utils.QSStringUtil;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

import okhttp3.Request;
import okhttp3.RequestBody;

public class RequestHandler {

    private static Logger logger = QSLoggerUtil.setLoggerHanlder(RequestHandler.class.getName());

    private Map contextParam;

    private RequestInputModel paramBean;

    private Class outputClass;

    private ResponseCallBack asyncCallback;

    private QSBuilder builder;

    private BodyProgressListener progressListener;

    private CancellationHandler cancellationHandler;

    private QSRequestBody qsRequestBody;

    public RequestHandler(Map context, RequestInputModel paramBean, Class outputClass) throws QSException {
        this.contextParam = context;
        this.paramBean = paramBean;
        this.outputClass = outputClass;
        this.builder = new QSBuilder(context, paramBean);
    }

    public RequestHandler(Map context, RequestInputModel paramBean, ResponseCallBack asyncCallback) throws QSException {
        this.contextParam = context;
        this.paramBean = paramBean;
        this.asyncCallback = asyncCallback;
        this.builder = new QSBuilder(context, paramBean);
    }

    public void sendAsync() throws QSException {
        String validate = this.check();
        if (!QSStringUtil.isEmpty(validate)) {
            OutputModel out = QSParamInvokeUtil.getOutputModel(this.asyncCallback);
            QSOkHttpRequestClient.fillResponseCallbackModel(
                    QSConstant.REQUEST_ERROR_CODE, validate, out);
            this.asyncCallback.onAPIResponse(out);
        } else {
            EnvContext envContext = (EnvContext) this.contextParam.get(QSConstant.EVN_CONTEXT_KEY);
            Request request = this.getRequest();
            QSOkHttpRequestClient.getInstance()
                    .requestActionAsync(request, envContext.isSafeOkHttp(), this.asyncCallback);
        }
    }

    public OutputModel send() throws QSException {

        String validate = this.check();
        if (!QSStringUtil.isEmpty(validate)) {
            try {
                OutputModel model = (OutputModel) outputClass.newInstance();
                QSOkHttpRequestClient.fillResponseCallbackModel(
                        QSConstant.REQUEST_ERROR_CODE, validate, model);
                return model;
            } catch (Exception e) {
                logger.log(Level.SEVERE, e.getMessage());
                throw new QSException(e.getMessage());
            }
        } else {
            EnvContext envContext = (EnvContext) this.contextParam.get(QSConstant.EVN_CONTEXT_KEY);

            Request request = this.getRequest();

            return QSOkHttpRequestClient.getInstance()
                    .requestAction(request, envContext.isSafeOkHttp(), outputClass);
        }
    }

    private Request getRequest() throws QSException{
        checkDownloadRequest();
        RequestBody  body = this.builder.getRequestBody(this.getQsRequestBody());
    	if(this.getProgressListener() != null){
    		return this.builder.getRequest(new ProgressRequestBody(body,this.progressListener, getCancellationHandler()));
    	}
    	return this.builder.getRequest(body);
    }

    /**
     *  <p> OkHttp will use "Accept-Encoding: gzip" as default header,
     *  which may not get Content-Length form server when download. </p>
     */
    private void checkDownloadRequest() {
        if (outputClass == null) return;
        boolean isDownloadRequest = false;
        Field[] declaredField = outputClass.getDeclaredFields();
        for (Field field : declaredField) {
            String methodName = "get" + QSParamInvokeUtil.capitalize(field.getName());
            Method[] methods = outputClass.getDeclaredMethods();
            for (Method m : methods) {
                if (m.getName().equalsIgnoreCase(methodName)) {
                    ParamAnnotation annotation = m.getAnnotation(ParamAnnotation.class);
                    if (annotation == null) continue;
                    if ("BodyInputStream".equals(annotation.paramName())) {
                        isDownloadRequest = true;
                        break;
                    }
                }
            }
        }
        if (isDownloadRequest) {
            getBuilder().setHeader("Accept-Encoding", "identity");
        }
    }

    public String getStringToSignature() throws QSException {
        return this.builder.getStringToSignature();
    }

    public void setSignature(String accessKey, String signature) throws QSException {
        this.builder.setSignature(accessKey, signature);
    }

    /**
     * Set signature and server time.
     * @param accessKey accessKey
     * @param signature signature
     * @param gmtTime time format with GMT
     * @throws QSException exception
     */
    public void setSignature(String accessKey, String signature, String gmtTime) throws QSException {
        builder.setHeader(QSConstant.HEADER_PARAM_KEY_DATE, gmtTime);
        setSignature(accessKey, signature);
    }

    public String getExpiresRequestUrl() throws QSException {
        return this.builder.getExpiresRequestUrl();
    }

    public String check() {
        String validate = this.paramBean != null ? paramBean.validateParam() : "";
        EnvContext envContext = (EnvContext) this.contextParam.get(QSConstant.EVN_CONTEXT_KEY);
        String envValidate = envContext.validateParam();
        if (!QSStringUtil.isEmpty(validate) || !QSStringUtil.isEmpty(envValidate)) {
            if (QSStringUtil.isEmpty(validate)) {
                validate = envValidate;
            }
            return validate;
        }
        return null;
    }

    public QSBuilder getBuilder() {
        return this.builder;
    }

	/**
	 * @return the progressListener
	 */
	public BodyProgressListener getProgressListener() {
		return progressListener;
	}

	/**
	 * @param progressListener the progressListener to set
	 */
	public void setProgressListener(BodyProgressListener progressListener) {
		this.progressListener = progressListener;
	}

	/**
	 * @return the qsRequestBody
	 */
	public QSRequestBody getQsRequestBody() {
		return qsRequestBody;
	}

	/**
	 * @param qsRequestBody the qsRequestBody to set
	 */
	public void setQsRequestBody(QSRequestBody qsRequestBody) {
		this.qsRequestBody = qsRequestBody;
	}

    public CancellationHandler getCancellationHandler() {
        return cancellationHandler;
    }

    public void setCancellationHandler(CancellationHandler cancellationHandler) {
        this.cancellationHandler = cancellationHandler;
    }
}


// +-------------------------------------------------------------------------
// | Copyright (C) 2016 Yunify, Inc.
// +-------------------------------------------------------------------------
// | Licensed under the Apache License, Version 2.0 (the "License");
// | you may not use this work except in compliance with the License.
// | You may obtain a copy of the License in the LICENSE file, or at:
// |
// | http://www.apache.org/licenses/LICENSE-2.0
// |
// | Unless required by applicable law or agreed to in writing, software
// | distributed under the License is distributed on an "AS IS" BASIS,
// | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// | See the License for the specific language governing permissions and
// | limitations under the License.
// +-------------------------------------------------------------------------

package com.qingstor.sdk.utils;

public final class Base64 {

    private static final int BASELENGTH = 128;
    private static final int LOOKUPLENGTH = 64;
    private static final int TWENTYFOURBITGROUP = 24;
    private static final int EIGHTBIT = 8;
    private static final int SIXTEENBIT = 16;
    private static final int FOURBYTE = 4;
    private static final int SIGN = -128;
    private static char PAD = '=';
    private static byte[] base64Alphabet = new byte[BASELENGTH];
    private static char[] lookUpBase64Alphabet = new char[LOOKUPLENGTH];

    static {
        for (int i = 0; i < BASELENGTH; ++i) {
            base64Alphabet[i] = -1;
        }
        for (int i = 'Z'; i >= 'A'; i--) {
            base64Alphabet[i] = (byte) (i - 'A');
        }
        for (int i = 'z'; i >= 'a'; i--) {
            base64Alphabet[i] = (byte) (i - 'a' + 26);
        }

        for (int i = '9'; i >= '0'; i--) {
            base64Alphabet[i] = (byte) (i - '0' + 52);
        }

        base64Alphabet['+'] = 62;
        base64Alphabet['/'] = 63;

        for (int i = 0; i <= 25; i++) {
            lookUpBase64Alphabet[i] = (char) ('A' + i);
        }

        for (int i = 26, j = 0; i <= 51; i++, j++) {
            lookUpBase64Alphabet[i] = (char) ('a' + j);
        }

        for (int i = 52, j = 0; i <= 61; i++, j++) {
            lookUpBase64Alphabet[i] = (char) ('0' + j);
        }
        lookUpBase64Alphabet[62] = (char) '+';
        lookUpBase64Alphabet[63] = (char) '/';
    }

    private static boolean isWhiteSpace(char octect) {
        return (octect == 0x20 || octect == 0xd || octect == 0xa || octect == 0x9);
    }

    private static boolean isPad(char octect) {
        return (octect == PAD);
    }

    private static boolean isData(char octect) {
        return (octect < BASELENGTH && base64Alphabet[octect] != -1);
    }

    /**
     * Encodes hex octects into Base64
     *
     * @param binaryData Array containing binaryData
     * @return Encoded Base64 array
     */
    public static String encode(byte[] binaryData) {

        if (binaryData == null) {
            return null;
        }

        int lengthDataBits = binaryData.length * EIGHTBIT;
        if (lengthDataBits == 0) {
            return "";
        }

        int fewerThan24bits = lengthDataBits % TWENTYFOURBITGROUP;
        int numberTriplets = lengthDataBits / TWENTYFOURBITGROUP;
        int numberQuartet = fewerThan24bits != 0 ? numberTriplets + 1 : numberTriplets;
        char encodedData[] = null;

        encodedData = new char[numberQuartet * 4];

        byte k = 0, l = 0, b1 = 0, b2 = 0, b3 = 0;

        int encodedIndex = 0;
        int dataIndex = 0;

        for (int i = 0; i < numberTriplets; i++) {
            b1 = binaryData[dataIndex++];
            b2 = binaryData[dataIndex++];
            b3 = binaryData[dataIndex++];

            l = (byte) (b2 & 0x0f);
            k = (byte) (b1 & 0x03);

            byte val1 = ((b1 & SIGN) == 0) ? (byte) (b1 >> 2) : (byte) ((b1) >> 2 ^ 0xc0);
            byte val2 = ((b2 & SIGN) == 0) ? (byte) (b2 >> 4) : (byte) ((b2) >> 4 ^ 0xf0);
            byte val3 = ((b3 & SIGN) == 0) ? (byte) (b3 >> 6) : (byte) ((b3) >> 6 ^ 0xfc);

            encodedData[encodedIndex++] = lookUpBase64Alphabet[val1];
            encodedData[encodedIndex++] = lookUpBase64Alphabet[val2 | (k << 4)];
            encodedData[encodedIndex++] = lookUpBase64Alphabet[(l << 2) | val3];
            encodedData[encodedIndex++] = lookUpBase64Alphabet[b3 & 0x3f];
        }

        // form integral number of 6-bit groups
        if (fewerThan24bits == EIGHTBIT) {
            b1 = binaryData[dataIndex];
            k = (byte) (b1 & 0x03);

            byte val1 = ((b1 & SIGN) == 0) ? (byte) (b1 >> 2) : (byte) ((b1) >> 2 ^ 0xc0);
            encodedData[encodedIndex++] = lookUpBase64Alphabet[val1];
            encodedData[encodedIndex++] = lookUpBase64Alphabet[k << 4];
            encodedData[encodedIndex++] = PAD;
            encodedData[encodedIndex++] = PAD;
        } else if (fewerThan24bits == SIXTEENBIT) {
            b1 = binaryData[dataIndex];
            b2 = binaryData[dataIndex + 1];
            l = (byte) (b2 & 0x0f);
            k = (byte) (b1 & 0x03);

            byte val1 = ((b1 & SIGN) == 0) ? (byte) (b1 >> 2) : (byte) ((b1) >> 2 ^ 0xc0);
            byte val2 = ((b2 & SIGN) == 0) ? (byte) (b2 >> 4) : (byte) ((b2) >> 4 ^ 0xf0);

            encodedData[encodedIndex++] = lookUpBase64Alphabet[val1];
            encodedData[encodedIndex++] = lookUpBase64Alphabet[val2 | (k << 4)];
            encodedData[encodedIndex++] = lookUpBase64Alphabet[l << 2];
            encodedData[encodedIndex++] = PAD;
        }

        return new String(encodedData);
    }

    /**
     * Decodes Base64 data into octects
     *
     * @param encoded string containing Base64 data
     * @return Array containind decoded data.
     */
    public static byte[] decode(String encoded) {

        if (encoded == null) {
            return null;
        }

        char[] base64Data = encoded.toCharArray();
        // remove white spaces
        int len = removeWhiteSpace(base64Data);

        if (len % FOURBYTE != 0) {
            return null; // should be divisible by four
        }

        int numberQuadruple = (len / FOURBYTE);

        if (numberQuadruple == 0) {
            return new byte[0];
        }

        byte decodedData[] = null;
        byte b1 = 0, b2 = 0, b3 = 0, b4 = 0;
        char d1 = 0, d2 = 0, d3 = 0, d4 = 0;

        int i = 0;
        int encodedIndex = 0;
        int dataIndex = 0;
        decodedData = new byte[(numberQuadruple) * 3];

        for (; i < numberQuadruple - 1; i++) {

            if (!isData((d1 = base64Data[dataIndex++]))
                    || !isData((d2 = base64Data[dataIndex++]))
                    || !isData((d3 = base64Data[dataIndex++]))
                    || !isData((d4 = base64Data[dataIndex++]))) {
                return null;
            } // if found "no data" just return null

            b1 = base64Alphabet[d1];
            b2 = base64Alphabet[d2];
            b3 = base64Alphabet[d3];
            b4 = base64Alphabet[d4];

            decodedData[encodedIndex++] = (byte) (b1 << 2 | b2 >> 4);
            decodedData[encodedIndex++] = (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf));
            decodedData[encodedIndex++] = (byte) (b3 << 6 | b4);
        }

        if (!isData((d1 = base64Data[dataIndex++])) || !isData((d2 = base64Data[dataIndex++]))) {
            return null; // if found "no data" just return null
        }

        b1 = base64Alphabet[d1];
        b2 = base64Alphabet[d2];

        d3 = base64Data[dataIndex++];
        d4 = base64Data[dataIndex++];
        if (!isData((d3)) || !isData((d4))) { // Check if they are PAD characters
            if (isPad(d3) && isPad(d4)) {
                if ((b2 & 0xf) != 0) // last 4 bits should be zero
                {
                    return null;
                }
                byte[] tmp = new byte[i * 3 + 1];
                System.arraycopy(decodedData, 0, tmp, 0, i * 3);
                tmp[encodedIndex] = (byte) (b1 << 2 | b2 >> 4);
                return tmp;
            } else if (!isPad(d3) && isPad(d4)) {
                b3 = base64Alphabet[d3];
                if ((b3 & 0x3) != 0) // last 2 bits should be zero
                {
                    return null;
                }
                byte[] tmp = new byte[i * 3 + 2];
                System.arraycopy(decodedData, 0, tmp, 0, i * 3);
                tmp[encodedIndex++] = (byte) (b1 << 2 | b2 >> 4);
                tmp[encodedIndex] = (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf));
                return tmp;
            } else {
                return null;
            }
        } else { // No PAD e.g 3cQl
            b3 = base64Alphabet[d3];
            b4 = base64Alphabet[d4];
            decodedData[encodedIndex++] = (byte) (b1 << 2 | b2 >> 4);
            decodedData[encodedIndex++] = (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf));
            decodedData[encodedIndex++] = (byte) (b3 << 6 | b4);
        }

        return decodedData;
    }

    /**
     * remove WhiteSpace from MIME containing encoded Base64 data.
     *
     * @param data the byte array of base64 data (with WS)
     * @return the new length
     */
    private static int removeWhiteSpace(char[] data) {
        if (data == null) {
            return 0;
        }

        // count characters that's not whitespace
        int newSize = 0;
        int len = data.length;
        for (int i = 0; i < len; i++) {
            if (!isWhiteSpace(data[i])) {
                data[newSize++] = data[i];
            }
        }
        return newSize;
    }
}


// +-------------------------------------------------------------------------
// | Copyright (C) 2016 Yunify, Inc.
// +-------------------------------------------------------------------------
// | Licensed under the Apache License, Version 2.0 (the "License");
// | you may not use this work except in compliance with the License.
// | You may obtain a copy of the License in the LICENSE file, or at:
// |
// | http://www.apache.org/licenses/LICENSE-2.0
// |
// | Unless required by applicable law or agreed to in writing, software
// | distributed under the License is distributed on an "AS IS" BASIS,
// | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// | See the License for the specific language governing permissions and
// | limitations under the License.
// +-------------------------------------------------------------------------
package com.qingstor.sdk.upload;

import com.google.gson.Gson;
import com.qingstor.sdk.annotation.ParamAnnotation;
import com.qingstor.sdk.constants.QSConstant;
import com.qingstor.sdk.exception.QSException;
import com.qingstor.sdk.model.OutputModel;
import com.qingstor.sdk.model.UploadModel;
import com.qingstor.sdk.request.BodyProgressListener;
import com.qingstor.sdk.request.CancellationHandler;
import com.qingstor.sdk.request.RequestHandler;
import com.qingstor.sdk.service.Bucket;
import com.qingstor.sdk.utils.QSStringUtil;

import java.io.File;
import java.io.UnsupportedEncodingException;

/**
 * A manager of uploading.

 * Created by chengww on 2018/1/23.
 */
public class UploadManager {

    private long partSize = 4 * 1024 * 1024L;
    private static final int MAX_PART_COUNTS = 10000; // Max part counts.
    private int partCounts;

    private Recorder recorder;
    private Bucket bucket;
    private UploadProgressListener progressListener;
    private CancellationHandler cancellationHandler;
    private UploadModel uploadModel;
    private UploadManagerCallback callBack;

    public UploadManager(Bucket bucket, Recorder recorder,
                         UploadProgressListener progressListener,
                         CancellationHandler cancellationHandler,
                         UploadManagerCallback callBack) {
        this.recorder = recorder;
        this.bucket = bucket;
        this.progressListener = progressListener;
        this.cancellationHandler = cancellationHandler;
        this.callBack = callBack;
    }

    public UploadManager(Bucket bucket, UploadManagerCallback callBack) {
        this(bucket, null, null, null, callBack);
    }

    /**
     * Upload a file with a sync request.
     * @param file file
     * @throws QSException exception
     */
    public void put(File file) throws QSException {

        if (!file.exists() || file.isDirectory())
            throw new QSException("File does not exist or it is a directory.");

        put(file, file.getName(), null, "");
    }

    /**
     * Upload a file with a sync request.
     * @param file file
     * @param objectKey key of the object in the bucket
     * @param fileName file name of response(content-disposition) when downloaded
     * @param eTag MD5 info
     * @throws QSException exception
     */
    public void put(File file, String objectKey, String fileName, String eTag) throws QSException {
        // Check the file does exist or not.
        if (!file.exists() || file.isDirectory())
            throw new QSException("File does not exist or it is a directory.");

        // Check file's length.
        long length = file.length();
        if (length < 1) throw new QSException("The size of file cannot be smaller than 1 byte.");
        if (length <= partSize) {
            partCounts = 1;
            putFile(file, objectKey, fileName, length);
        } else { // Do multi uploads.
            // Calculate part counts.
            if (length / partSize > MAX_PART_COUNTS) {
                partSize = length / MAX_PART_COUNTS;
                partCounts = MAX_PART_COUNTS;
                // Check every part's size(max 5GB).
                if (partSize > 5 * 1024 * 1024 * 1024L)
                    throw new QSException("The size of file is too large.");
            } else {
                partCounts = (int) (length / partSize);
                if (length % partSize > 0) partCounts += 1;
            }
            putFileMulti(file, objectKey, fileName, eTag, length);
        }
    }

    /**
     * Upload a file with a multi upload as a sync request.
     * @param file file
     * @param objectKey key of the object in the bucket
     * @param fileName file name of response(content-disposition) when downloaded
     * @param eTag MD5 info
     * @param length length of the file.
     * @throws QSException exception
     */
    public void putFileMulti(File file, final String objectKey, String fileName, String eTag, final long length) throws QSException {

        if (partSize < 4 * 1024 * 1024) {
            throw new QSException("Every part of the file can not smaller than 4 MB.");
        }

        if (recorder == null || recorder.get(objectKey) == null) {
            Bucket.InitiateMultipartUploadInput inputInit = new Bucket.InitiateMultipartUploadInput();
            Bucket.InitiateMultipartUploadOutput initOutput = bucket.initiateMultipartUpload(objectKey, inputInit);
            int code = initOutput.getStatueCode();
            // Initiate occurs an error.
            if (code < 200 || code >= 300) {
                if (callBack != null) {
                    OutputModel outputModel = new OutputModel();
                    outputModel.setStatueCode(code);
                    outputModel.setMessage(initOutput.getMessage());
                    callBack.onAPIResponse(objectKey, outputModel);
                }
                return;
            }
            uploadModel = new UploadModel();
            uploadModel.setTotalSize(length);
            uploadModel.setUploadID(initOutput.getUploadID());
        } else {
            byte[] bytes = recorder.get(objectKey);
            String json = new String(bytes);
            uploadModel = new Gson().fromJson(json, UploadModel.class);
            // Check status of the task.
            if (uploadModel.isUploadComplete()) {
                if (callBack != null) {
                    OutputModel outputModel = new OutputModel();
                    outputModel.setStatueCode(201);
                    outputModel.setMessage("This task has been uploaded.");
                    callBack.onAPIResponse(objectKey, outputModel);
                }
                return;
            }
        }

        uploadModel.setTotalSize(length);

        // Check if all parts have been completely uploaded.
        if (uploadModel.isFileComplete()) {
            completeMultiUpload(objectKey, fileName, eTag, uploadModel.getUploadID(), length);
        } else {
            for (int i = uploadModel.getCurrentPart(); i < partCounts; i++) {
                // Make records when a new part starts to upload.
                uploadModel.setCurrentPart(i);
                uploadModel.setBytesWritten(i * partSize);
                setData(objectKey, recorder);
                // Request cancelled. Stop upload other parts.
                if (cancellationHandler != null && cancellationHandler.isCancelled()) {
                    // Keep parts data into the upload.
                    setData(objectKey, recorder);
                    break;
                }

                long contentLength = Math.min(partSize, (file.length() - uploadModel.getCurrentPart() * partSize));
                Bucket.UploadMultipartInput input = new Bucket.UploadMultipartInput();
                input.setBodyInputFilePart(file);
                input.setFileOffset(i * partSize);
                input.setContentLength(contentLength);
                input.setPartNumber(i);
                input.setUploadID(uploadModel.getUploadID());

                // Create request to upload a part.
                RequestHandler requestHandler = bucket.uploadMultipartRequest(objectKey, input);

                // Progress listener
                if (progressListener != null) {
                    requestHandler.setProgressListener(new BodyProgressListener() {
                        @Override
                        public void onProgress(long len, long size) {
                            long bytesWritten = uploadModel.getCurrentPart() * partSize + len;
                            progressListener.onProgress(objectKey, bytesWritten, length);
                        }
                    });
                }

                // Cancellation handler.
                requestHandler.setCancellationHandler(cancellationHandler);

                // Sign with server if needed.
                sign(requestHandler);

                // Send the request.
                OutputModel send = requestHandler.send();

                // Check response.
                if (send.getStatueCode() != 200 && send.getStatueCode() != 201) { // Failed.
                    setData(objectKey, recorder);
                    // On upload failed
                    if (callBack != null)
                        callBack.onAPIResponse(objectKey, send);

                    // Once failed, break the circle.
                    break;
                } else if (i == partCounts - 1) { // Success and it is the last part of th file.
                    // Make a record in the upload.
                    uploadModel.setBytesWritten(length);
                    uploadModel.setFileComplete(true);
                    setData(objectKey, recorder);
                }
            }

            // Finally check.
            if (uploadModel.isFileComplete()) {
                completeMultiUpload(objectKey, fileName, eTag, uploadModel.getUploadID(), length);
            }
        }

    }

    /**
     * When sending a request will call this method to sign with server.
     * @param requestHandler handler of the request.
     * @throws QSException exception
     */
    private void sign(RequestHandler requestHandler) throws QSException {
        if (callBack != null) {
            String signed = callBack.onSignature(requestHandler.getStringToSignature());
            if (!QSStringUtil.isEmpty(signed))
                requestHandler.setSignature(callBack.onAccessKey(), signed);
            String correctTime = callBack.onCorrectTime(requestHandler.getStringToSignature());
            if (correctTime != null && correctTime.trim().length() > 0)
                requestHandler.getBuilder().setHeader(QSConstant.HEADER_PARAM_KEY_DATE, correctTime);
        }
    }

    /**
     * Set data in the upload with a recorder.
     * @param objectKey key
     * @param recorder recorder
     */
    private void setData(String objectKey, Recorder recorder) {
        if (recorder == null) return;
        String upload = new Gson().toJson(uploadModel);
        recorder.set(objectKey, upload.getBytes());
    }

    /**
     * Complete the multi upload.
     * @param objectKey key of the object in the bucket
     * @param fileName file name of response(content-disposition) when downloaded
     * @param eTag MD5 info
     * @param uploadID multi upload id
     * @param length length of the file
     * @throws QSException exception
     */
    private void completeMultiUpload(String objectKey, String fileName, String eTag, String uploadID, long length) throws QSException {
        CompleteMultipartUploadInput completeMultipartUploadInput =
                new CompleteMultipartUploadInput(uploadID, partCounts, 0);

        completeMultipartUploadInput.setContentLength(length);

        // Set content disposition to the object.
        if (!QSStringUtil.isEmpty(fileName)) {
            try {
                String keyName = QSStringUtil.percentEncode(fileName, "UTF-8");
                completeMultipartUploadInput.setContentDisposition(String.format(
                        "attachment; filename=\"%s\"; filename*=utf-8''%s", keyName, keyName));
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
        }

        // Set the Md5 info to the object.
        if (!QSStringUtil.isEmpty(eTag)) {
            completeMultipartUploadInput.setETag(eTag);
        }

        RequestHandler requestHandler =
                bucket.completeMultipartUploadRequest(objectKey, completeMultipartUploadInput);

        sign(requestHandler);

        Bucket.CompleteMultipartUploadOutput send =
                (Bucket.CompleteMultipartUploadOutput) requestHandler.send();

        if (send.getStatueCode() == 200 || send.getStatueCode() == 201) {
            uploadModel.setUploadComplete(true);
            setData(objectKey, recorder);
        }

        // Response callback.
        if (callBack != null)
            callBack.onAPIResponse(objectKey, send);
    }

    /**
     * Upload a file with a simple put object upload as a sync request.

     * If a file's size is less than {@link UploadManager#partSize}, there will call this method.
     * @param file file
     * @param objectKey key of the object in the bucket
     * @param fileName file name of response(content-disposition) when downloaded
     * @param length length of the file
     * @throws QSException exception
     */
    public void putFile(File file, final String objectKey,
                        String fileName, long length) throws QSException {
        PutObjectInput input = new PutObjectInput();
        input.setContentLength(length);
        input.setBodyInputFile(file);
        // Set content disposition to the object.
        if (!QSStringUtil.isEmpty(fileName)) {
            try {
                String keyName = QSStringUtil.percentEncode(fileName, "UTF-8");
                input.setContentDisposition(String.format(
                        "attachment; filename=\"%s\"; filename*=utf-8''%s", keyName, keyName));
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
        }

        RequestHandler requestHandler = bucket.putObjectRequest(objectKey, input);

        // Set progress listener.
        if (progressListener != null) {
            requestHandler.setProgressListener(new BodyProgressListener() {
                @Override
                public void onProgress(long len, long size) {
                    progressListener.onProgress(objectKey, len, size);
                }
            });
        }

        // Cancellation handler.
        requestHandler.setCancellationHandler(cancellationHandler);

        // Sign if needed.
        sign(requestHandler);

        OutputModel outputModel = requestHandler.send();
        if (callBack != null)
            callBack.onAPIResponse(objectKey, outputModel);
    }

    public static class CompleteMultipartUploadInput
            extends Bucket.CompleteMultipartUploadInput {
        private String contentDisposition;

        private Long contentLength;

        @ParamAnnotation(paramType = "header", paramName = "Content-Length")
        public Long getContentLength() {
            return contentLength;
        }

        public void setContentLength(Long contentLength) {
            this.contentLength = contentLength;
        }

        public void setContentDisposition(String contentDisposition) {
            this.contentDisposition = contentDisposition;
        }

        @ParamAnnotation(paramType = "header", paramName = "content-disposition")
        public String getContentDisposition() {
            return this.contentDisposition;
        }

        public CompleteMultipartUploadInput(String multipart_upload_id, int partsCount, int startIndex) {
            super(multipart_upload_id, partsCount, startIndex);
        }
    }

    public static class PutObjectInput extends Bucket.PutObjectInput {
        private String contentDisposition;

        public void setContentDisposition(String contentDisposition) {
            this.contentDisposition = contentDisposition;
        }

        @ParamAnnotation(paramType = "header", paramName = "content-disposition")
        public String getContentDisposition() {
            return this.contentDisposition;
        }

    }

    // Getter and setter methods.

    public long getPartSize() {
        return partSize;
    }

    public void setPartSize(long partSize) {
        this.partSize = partSize;
    }

    public Recorder getRecorder() {
        return recorder;
    }

    public void setRecorder(Recorder recorder) {
        this.recorder = recorder;
    }

    public Bucket getBucket() {
        return bucket;
    }

    public void setBucket(Bucket bucket) {
        this.bucket = bucket;
    }

    public UploadProgressListener getProgressListener() {
        return progressListener;
    }

    public void setProgressListener(UploadProgressListener progressListener) {
        this.progressListener = progressListener;
    }

    public CancellationHandler getCancellationHandler() {
        return cancellationHandler;
    }

    public void setCancellationHandler(CancellationHandler cancellationHandler) {
        this.cancellationHandler = cancellationHandler;
    }

    public UploadManagerCallback getCallBack() {
        return callBack;
    }

    public void setCallBack(UploadManagerCallback callBack) {
        this.callBack = callBack;
    }
}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License.  You may obtain a
 * copy of the License at the following location:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.jasig.portlet.notice.action.favorite;

import java.io.IOException;
import java.util.HashSet;
import java.util.Set;

import javax.portlet.ActionRequest;
import javax.portlet.ActionResponse;
import javax.portlet.PortletPreferences;
import javax.portlet.PortletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.jasig.portlet.notice.NotificationAction;
import org.jasig.portlet.notice.NotificationEntry;

public class FavoriteAction extends NotificationAction {

    /**
     * This INSTANCE is only for convenience -- FAVORITE and UNFAVORITE not singletons.
     * There may be situations where de-serialization will create additional
     * instances, and that's okay.
     */
    public static final FavoriteAction FAVORITE = new FavoriteAction();

    /**
     * Stores the Ids of notices marked as favorite.
     */
    private static final String FAVORITE_NOTIFICATION_IDS_PREFERENCE =
            FavoriteAction.class.getName() + ".FAVORITE_NOTIFICATION_IDS_PREFERENCE";

    private static final long serialVersionUID = 1L;

    /**
     * Must remain public, no-arg for de-serialization.
     */
    public FavoriteAction() {
        // Set a default label;  most use cases will use the setter and override
        setLabel("FAVORITE");
    }

    public FavoriteAction(String label) {
        setLabel(label);
    }

    public static final FavoriteAction createFavoriteInstance() {
        return new FavoriteAction();
    }

    public static final FavoriteAction createUnfavoriteInstance() {
        return new FavoriteAction("UNFAVORITE");
    }

    /**
     * Invoking a FavoriteAction toggles it.
     */
    @Override
    public void invoke(final ActionRequest req, final ActionResponse res) throws IOException {
        final NotificationEntry entry = getTarget();
        final String notificationId = entry.getId();
        final Set<String> favoriteNotices = this.getFavoriteNotices(req);
        if (favoriteNotices.contains(notificationId)) {
            favoriteNotices.remove(notificationId);
        } else {
            favoriteNotices.add(notificationId);
        }
        setFavoriteNotices(req, favoriteNotices);
    }

    @Override
    public void invoke(HttpServletRequest request, HttpServletResponse response) {
        throw new UnsupportedOperationException("TODO:  Implement!");
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        final String id = getId();
        result = prime * result + ((id == null) ? 0 : id.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        // At present, any instance FavoriteAction is equal to another
        return true;
    }

    public void removeFavoriteNotices (final PortletRequest req, Set<String> idsToRemove) {
        Set<String> currentIds = getFavoriteNotices(req);
        currentIds.removeAll(idsToRemove);
        setFavoriteNotices(req, currentIds);
    }

    /*
     * Non-public API
     */

    /* package-private */ Set<String> getFavoriteNotices(final PortletRequest req) {

        final HashSet<String> rslt = new HashSet<String>();

        final PortletPreferences prefs = req.getPreferences();
        final String[] ids = prefs.getValues(FAVORITE_NOTIFICATION_IDS_PREFERENCE, new String[0]);

        for (int i=0; i < ids.length; i++) {
            rslt.add(ids[i]);
        }
        return rslt;

    }

    /* package-private */ void setFavoriteNotices(final PortletRequest req, final Set<String> favoriteNotices) {
        final String[] ids = favoriteNotices.toArray(new String[favoriteNotices.size()]);
        final PortletPreferences prefs = req.getPreferences();
        try {
            prefs.setValues(FAVORITE_NOTIFICATION_IDS_PREFERENCE, ids);
            prefs.store();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License.  You may obtain a
 * copy of the License at the following location:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.jasig.portlet.notice.util.sort;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

import javax.portlet.PortletPreferences;
import javax.portlet.PortletRequest;
import javax.servlet.http.HttpServletRequest;

import org.apache.commons.lang3.StringUtils;
import org.jasig.portlet.notice.NotificationCategory;
import org.jasig.portlet.notice.NotificationEntry;
import org.jasig.portlet.notice.NotificationResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Utility for sorting {@link NotificationEntry} objects, either within a List
 * or within their {@link NotificationCategory} containers.
 *
 * @author drewwills
 */
public final class Sorting {

    public static final String SORT_STRATEGY_PARAMETER_NAME = "sort";
    public static final String SORT_ORDER_PARAMETER_NAME = "order";

    /**
     * Used to define a portlet preference that indicates the way
     * {@link NotificationEntry} objects should be sorted within their
     * {@link NotificationCategory} containers.  Using a {@link SortStrategy} is
     * completely optional;  if none is specified, no sorting will be applied.
     *
     * @deprecated Part of the legacy, Portlet-based API.
     */
    @Deprecated
    public static final String SORT_STRATEGY_PREFERENCE = "Sorting.sortStrategy";

    /**
     * Either ASCENDING (default) or DECENDING.
     *
     * @deprecated Part of the legacy, Portlet-based API.
     */
    @Deprecated
    public static final String SORT_ORDER_PREFERENCE = "Sorting.sortOrder";

    public static final String SORT_ORDER_DEFAULT = SortOrder.ASCENDING.name();

    private static final Logger LOGGER = LoggerFactory.getLogger(Sorting.class);

    public static NotificationResponse sort(HttpServletRequest req, NotificationResponse data) {

        final Comparator<NotificationEntry> comparator = chooseConfiguredComparator(req);
        if (comparator == null) {
            // No sorting;  we're done...
            return data;
        }

        return sortNotificationResponse(comparator, data);

    }

    public static List<NotificationEntry> sort(HttpServletRequest req, List<NotificationEntry> entries) {

        final Comparator<NotificationEntry> comparator = chooseConfiguredComparator(req);
        if (comparator == null) {
            // No sorting;  we're done...
            return entries;
        }

        final List<NotificationEntry> rslt = new ArrayList<>(entries);  // defensive copy
        rslt.sort(comparator);

        return rslt;

    }

    /**
     * @deprecated Part of the legacy, Portlet-based API.
     */
    @Deprecated
    public static NotificationResponse sort(PortletRequest req, NotificationResponse data) {

        final Comparator<NotificationEntry> comparator = chooseConfiguredComparator(req);
        if (comparator == null) {
            // No sorting;  we're done...
            return data;
        }

        return sortNotificationResponse(comparator, data);

    }

    /**
     * @deprecated Part of the legacy, Portlet-based API.
     */
    @Deprecated
    public static List<NotificationEntry> sort(PortletRequest req, List<NotificationEntry> entries) {

        final Comparator<NotificationEntry> comparator = chooseConfiguredComparator(req);
        if (comparator == null) {
            // No sorting;  we're done...
            return entries;
        }

        final List<NotificationEntry> rslt = new ArrayList<>(entries);  // defensive copy
        rslt.sort(comparator);

        return rslt;

    }

    /*
     * Implementation
     */

    private static Comparator<NotificationEntry> chooseConfiguredComparator(HttpServletRequest req) {

        final String strategyName = req.getParameter(SORT_STRATEGY_PARAMETER_NAME);
        if (strategyName == null) {
            // No strategy means "natural" ordering;  we won't be sorting...
            return null;
        }

        // We WILL be sorting;  work out the details...
        try {
            final SortStrategy strategy = SortStrategy.valueOf(strategyName.toUpperCase()); // tolerant of case mismatch
            final String orderName = req.getParameter(SORT_ORDER_PARAMETER_NAME);
            final SortOrder order = StringUtils.isNotBlank(orderName)
                    ? SortOrder.valueOf(orderName.toUpperCase()) // tolerant of case mismatch
                    : SortOrder.valueOf(SORT_ORDER_DEFAULT);

            return order.equals(SortOrder.ASCENDING)
                    ? strategy.getComparator()                             // Default/ascending order
                    : Collections.reverseOrder(strategy.getComparator());  // Descending order
        } catch (IllegalArgumentException e) {
            LOGGER.warn("Unable to sort based on parameters {}='{}' and {}='{}'",
                    SORT_STRATEGY_PARAMETER_NAME,
                    strategyName,
                    SORT_ORDER_PARAMETER_NAME,
                    req.getParameter(SORT_ORDER_PARAMETER_NAME));
        }

        // We didn't succeed in selecting a strategy & order
        return null;

    }

    /**
     * @deprecated Part of the legacy, Portlet-based API.
     */
    @Deprecated
    private static Comparator<NotificationEntry> chooseConfiguredComparator(PortletRequest req) {

        final PortletPreferences prefs = req.getPreferences();
        final String strategyName = prefs.getValue(SORT_STRATEGY_PREFERENCE, null);
        if (strategyName == null) {
            // No strategy means "natural" ordering;  we won't be sorting...
            return null;
        }

        // We WILL be sorting;  work out the details...
        try {
            final SortStrategy strategy = SortStrategy.valueOf(strategyName.toUpperCase()); // tolerant of case mismatch
            final String orderName = req.getParameter(SORT_ORDER_PARAMETER_NAME);
            final SortOrder order = StringUtils.isNotBlank(orderName)
                    ? SortOrder.valueOf(orderName.toUpperCase()) // tolerant of case mismatch
                    : SortOrder.valueOf(SORT_ORDER_DEFAULT);

            return order.equals(SortOrder.ASCENDING)
                    ? strategy.getComparator()                             // Default/ascending order
                    : Collections.reverseOrder(strategy.getComparator());  // Descending order
        } catch (IllegalArgumentException e) {
            LOGGER.warn("Unable to sort based on parameters {}='{}' and {}='{}'",
                    SORT_STRATEGY_PARAMETER_NAME,
                    strategyName,
                    SORT_ORDER_PARAMETER_NAME,
                    req.getParameter(SORT_ORDER_PARAMETER_NAME));
        }

        // We didn't succeed in selecting a strategy & order
        return null;

    }

    private static NotificationResponse sortNotificationResponse(Comparator<NotificationEntry> comparator, NotificationResponse data) {
        // Sort each category...
        final List<NotificationCategory> copies = new ArrayList<>();
        for (NotificationCategory category : data.getCategories()) {
            final List<NotificationEntry> entries = new ArrayList<>(category.getEntries());  // defensive copy
            entries.sort(comparator);
            copies.add(new NotificationCategory(category.getTitle(), entries));
        }

        return new NotificationResponse(copies, data.getErrors());
    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License.  You may obtain a
 * copy of the License at the following location:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.jasig.portlet.notice.service.jpa;

import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import javax.portlet.PortletPreferences;
import javax.portlet.PortletRequest;
import javax.servlet.http.HttpServletRequest;

import org.apache.commons.lang.StringUtils;
import org.jasig.portlet.notice.NotificationAction;
import org.jasig.portlet.notice.NotificationAttribute;
import org.jasig.portlet.notice.NotificationCategory;
import org.jasig.portlet.notice.NotificationEntry;
import org.jasig.portlet.notice.NotificationError;
import org.jasig.portlet.notice.NotificationResponse;
import org.jasig.portlet.notice.NotificationState;
import org.jasig.portlet.notice.service.AbstractNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;

/**
 * Implementation of {@code INotificationService} backed by a Spring-/JPA-managed
 * relational database schema.
 *
 * @since 3.0
 * @author drewwills
 */
public class JpaNotificationService extends AbstractNotificationService {

    /**
     * This prefix helps to keep the Notification table together (in an
     * alphabetized list) and provides clarity to their origin and purpose.
     */
    /* package-private */ static final String TABLENAME_PREFIX = "NOTICE_";

    public static final String ID_PREFIX = "jpa_";

    private static final String UNCATEGORIZED_MESSAGE_CODE = "uncategorized";
    private static final String UNCATEGORIZED_DEFAULT_MESSAGE = "(Uncategorized)";
    private static final String PREFS_ENABLED = "JpaNotificationService.enabled";

    @Autowired
    private INotificationDao notificationDao;

    @Autowired
    private MessageSource messages;

    private final Logger log = LoggerFactory.getLogger(getClass());

    @Override
    public NotificationResponse fetch(PortletRequest req) {

        NotificationResponse rslt = NotificationResponse.EMPTY_RESPONSE;  // default

        PortletPreferences prefs = req.getPreferences();

        /*
         * We do not perform a check for unauthenticated users (though this is an assumption that
         * may need revisiting in the future).
         */
        if (usernameFinder.isAuthenticated(req) && Boolean.parseBoolean(prefs.getValue(PREFS_ENABLED, "false"))) {
            final String username = usernameFinder.findUsername(req);

            log.debug("Fetching notifications for user:  {}", username);

            final Set<JpaEntry> entries = notificationDao.getEntriesByRecipient(username);

            log.debug("Found the following notifications for user '{}':  {}",
                                                username, entries.toString());

            rslt = prepareResponse(entries, username);
        }

        return rslt;

    }

    @Override
    public NotificationResponse fetch(HttpServletRequest request) {

        NotificationResponse rslt = NotificationResponse.EMPTY_RESPONSE;  // default

        /*
         * We do not perform a check for unauthenticated users (though this is an assumption that
         * may need revisiting in the future).
         */
        if (usernameFinder.isAuthenticated(request)) {
            final String username = usernameFinder.findUsername(request);

            log.debug("Fetching notifications for user:  {}", username);

            final Set<JpaEntry> entries = notificationDao.getEntriesByRecipient(username);

            log.debug("Found the following notifications for user '{}':  {}",
                    username, entries.toString());

            rslt = prepareResponse(entries, username);
        }

        return rslt;

    }

    /**
     * Caller must insure that the state being set has not already been added to the entry
     * to avoid multiple events with the same state.
     */
    public void addEntryState(PortletRequest req, String entryId, NotificationState state) {
        if (usernameFinder.isAuthenticated(req)) {

            final String username = usernameFinder.findUsername(req);

            String idStr = entryId.replaceAll(ID_PREFIX, ""); // remove the prefix

            JpaEntry jpaEntry = notificationDao.getEntry(Long.parseLong(idStr));
            if (jpaEntry != null) {
                JpaEvent event = new JpaEvent();
                event.setEntry(jpaEntry);
                event.setState(state);
                event.setTimestamp(new Timestamp(new Date().getTime()));
                event.setUsername(username);

                notificationDao.createOrUpdateEvent(event);
            }
            else {
                throw new IllegalArgumentException("JpaEntry not found");
            }
        }
    }

    /*
     * Implementation
     */

    private NotificationResponse prepareResponse(Set<JpaEntry> entries, String username) {

        Map<String,NotificationCategory> categories = new HashMap<>();
        for (JpaEntry entry : entries) {

            // Choose a category title
            final String categryTitle = !StringUtils.isBlank(entry.getCategory())
                    ? entry.getCategory()
                    : messages.getMessage(UNCATEGORIZED_MESSAGE_CODE, null,
                            UNCATEGORIZED_DEFAULT_MESSAGE, Locale.getDefault());

            // Obtain the category object matching the title
            NotificationCategory category = categories.get(categryTitle);
            if (category == null) {
                category = new NotificationCategory();
                category.setTitle(categryTitle);
                categories.put(categryTitle, category);
            }

            // Prepare a NotificationEntry
            NotificationEntry y = prepareEntry(entry, username);
            if (y != null) {
                category.addEntries(Collections.singletonList(y));
            }

        }

        // Create & load the response
        final List<NotificationCategory> cList = new ArrayList<>(categories.values());
        final List<NotificationError> eList = Collections.emptyList();  // Anything here?

        return new NotificationResponse(cList, eList);

    }

    /**
     * Creates a {@link NotificationEntry} from a {@link JpaEntry}, but will
     * return null if that process cannot be performed in a valid way.
     *
     * @return A fully-constituted {@link NotificationEntry} or null
     */
    private NotificationEntry prepareEntry(JpaEntry entry, String username) {

        /*
         * Implementation Note:  Most notification fields are optional.  This
         * method impl avoids setting fields within the NotificationEntry when
         * those fields are (essentially) unspecified on the JpaEntry in order
         * not to run afoul of any logic (present or future) that may trigger
         * when a field is set (e.g. validation, virtual members, change
         * tracking).
         */

        NotificationEntry rslt = new NotificationEntry();

        // Id & title will always be present
        rslt.setId(ID_PREFIX + entry.getId());
        final String title = entry.getTitle();
        if (StringUtils.isBlank(title)) {
            log.warn("User '" + username + "' had a notification with an empty title:  " + entry.toString());
            return null;
        }
        rslt.setTitle(title);

        // But these fields are optional
        if (!StringUtils.isBlank(entry.getBody())) {  // Body
            rslt.setBody(entry.getBody());
        }
        if (entry.getDueDate() != null) {  // Due date
            rslt.setDueDate(entry.getDueDate());
        }
        if (!StringUtils.isBlank(entry.getImage())) {  // Image
            rslt.setImage(entry.getImage());
        }
        if (!StringUtils.isBlank(entry.getLinkText())) {  // Link text
            rslt.setLinkText(entry.getLinkText());
        }
        if (entry.getPriority() != 0) {  // Priority
            rslt.setPriority(entry.getPriority());
        }
        if (!StringUtils.isBlank(entry.getSource())) {  // Source
            rslt.setSource(entry.getSource());
        }
        if (!StringUtils.isBlank(entry.getUrl())) {  // Url
            rslt.setUrl(entry.getUrl());
        }

        // States (transaction log)
        Map<NotificationState,Date> states = prepareStates(entry, username);
        rslt.setStates(states);

        // Collections of items...
        if (!entry.getAttributes().isEmpty()) {  // Attributes
            List<NotificationAttribute> attributes = prepareAttributes(entry.getAttributes());
            rslt.setAttributes(attributes);
        }
        if (!entry.getActions().isEmpty()) {  // Actions
            List<NotificationAction> actions = prepareActions(entry.getActions(),
                    username);
            rslt.setAvailableActions(actions);
        }

        return rslt;

    }

    private Map<NotificationState, Date> prepareStates(JpaEntry entry, String username) {
        Map<NotificationState, Date> rslt = new HashMap<>();
        List<JpaEvent> events = notificationDao.getEvents(entry.getId(), username);
        Collections.reverse(events);  // Process in reverse-chronological order
        for (JpaEvent e : events) {
            // NOTE:  We're obligated to filter out states
            // that are "canceled out" by subsequent events
            Set<NotificationState> subsequentHistory = rslt.keySet();
            if (e.getState().isActive(e.getTimestamp(), subsequentHistory)) {
                rslt.put(e.getState(), e.getTimestamp());
            }
        }
        return rslt;
    }

    private List<NotificationAttribute> prepareAttributes(Set<JpaAttribute> attributes) {
        List<NotificationAttribute> rslt = new ArrayList<>();
        for (JpaAttribute a : attributes) {
            NotificationAttribute n = new NotificationAttribute(a.getName(), a.getValues());
            rslt.add(n);
        }
        return rslt;
    }

    private List<NotificationAction> prepareActions(Set<JpaAction> actions, String username) {

        List<NotificationAction> rslt = new ArrayList<>();

        String className = null;
        try {
            // NB:  Do we need to filter out actions that are not currently
            // applicable?  (Or is that better-handled downstream?)
            for (JpaAction a : actions) {
                className = a.getClazz();
                @SuppressWarnings("unchecked")
                final Class<? extends NotificationAction> clazz = (Class<? extends NotificationAction>) Class.forName(className);
                NotificationAction n = clazz.newInstance();
                n.setLabel(a.getLabel());
                rslt.add(n);
            }
        } catch (Exception e) {
            log.warn("User '" + username + "' had an action of an unknown className:  " + className);
        }

        return rslt;

    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License.  You may obtain a
 * copy of the License at the following location:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.jasig.portlet.notice.service.ssp;

import com.fasterxml.jackson.annotation.JsonProperty;

/**
 * Entity to hold SSP authentication information.
 *
 * @author Josh Helmer, jhelmer.unicon.net
 */
public class SSPToken {
    private static final long TIMEOUT_BUFFER = 5000;
    private long created = System.currentTimeMillis();
    private long expiresIn = 0;
    private String tokenType;
    private String accessToken;

    /**
     * Helper method to determine if an SSPToken has expired.
     */
    public boolean hasExpired() {
        long now = System.currentTimeMillis();
        if (created + (expiresIn * 1000) + TIMEOUT_BUFFER > now) {
            return false;
        }

        return true;
    }

    @JsonProperty("expires_in")
    public long getExpiresIn() {
        return expiresIn;
    }

    public void setExpiresIn(long expiresIn) {
        this.expiresIn = expiresIn;
    }

    @JsonProperty("token_type")
    public String getTokenType() {
        return tokenType;
    }

    public void setTokenType(String tokenType) {
        this.tokenType = tokenType;
    }

    @JsonProperty("access_token")
    public String getAccessToken() {
        return accessToken;
    }

    public void setAccessToken(String accessToken) {
        this.accessToken = accessToken;
    }
}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License.  You may obtain a
 * copy of the License at the following location:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.jasig.portlet.notice.util;

import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.apache.commons.lang3.StringUtils;
import org.jasig.portlet.notice.NotificationEntry;
import org.jasig.portlet.notice.NotificationState;
import org.jasig.portlet.notice.action.hide.HideNotificationServiceDecorator;
import org.jasig.portlet.notice.rest.AttributeDTO;
import org.jasig.portlet.notice.rest.EntryDTO;
import org.jasig.portlet.notice.rest.EventDTO;
import org.jasig.portlet.notice.service.jpa.IJpaNotificationRESTService;
import org.jasig.portlet.notice.service.jpa.JpaNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * This bean allows features and data sources outside of the JPA service
 * implementation to "piggyback" on some of the persistent capabilities of
 * the JPA service.
 *
 * @author drewwills
 */
@Service("jpaServices")
public class JpaServices implements IJpaServices {

    /**
     * The {@link HideNotificationServiceDecorator} uses this value as the
     * source string when masquerading as an external data source
     * in the JPA system.
     */
    private static final String PROXY_SOURCE_NAME = JpaServices.class.getName() + ".PROXY_SOURCE_NAME";

    /**
     * The {@link HideNotificationServiceDecorator} uses this value to store an
     * entry's original id as a custom attribute within the JPA
     * system.
     */
    private static final String PROXY_ID_ATTRIBUTE = JpaServices.class.getName() + ".PROXY_ID_ATTRIBUTE";

    /**
     * Short message WRT what this object is.
     */
    private static final String PROXY_BODY_CONTENT = "This is a proxy notification representing "
                + "one from an external data source;  this strategy supports status changes.";

    @Autowired
    private IJpaNotificationRESTService jpaNotificationRestService;

    private Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * Is the {@link NotificationEntry} object owned by the JPA service?
     */
    public boolean contains(NotificationEntry entry) {
        return StringUtils.isNotBlank(entry.getId())
                && entry.getId().startsWith(JpaNotificationService.ID_PREFIX);
    }

    /**
     * Provides the known history of status changes for the specified user and
     * notification in chronological order.
     */
    @Override
    public List<EventDTO> getHistory(NotificationEntry entry, String username) {

        List<EventDTO> rslt = Collections.emptyList();  // default

        /*
         * The JPA system owns status tracking, but it only tracks status for
         * entries that it owns.  If the entry is not already a JPA-backed
         * entry, we would use a JPA-side "proxy."
         */
        final EntryDTO entryDto = contains(entry)
                ? jpaNotificationRestService.getNotification(entry, false)
                : fetchJpaProxyIfAvailable(entry);

        // There can't be history if there isn't (yet) a proxy
        if (entryDto != null) {
            rslt = jpaNotificationRestService.getEventsByNotificationAndUser(entryDto.getId(), username);
        }

        return rslt;

    }

    @Override
    public void applyState(NotificationEntry entry, String username, NotificationState state) {

        /*
         * The JPA system owns status tracking, but it only tracks status for
         * entries that it owns.  If the entry is not already a JPA-backed
         * entry, we need to create a JPA-side "proxy."
         */
        final EntryDTO entryDto = contains(entry)
                ? jpaNotificationRestService.getNotification(entry, false)
                : fetchOrCreateJpaProxy(entry);

        final EventDTO event = new EventDTO();
        event.setState(state);
        event.setTimestamp(new Timestamp(System.currentTimeMillis()));
        event.setUsername(username);

        jpaNotificationRestService.createEvent(entryDto.getId(), event);

    }

    /*
     * Implementation
     */

    private EntryDTO fetchJpaProxyIfAvailable(NotificationEntry entry) {
        EntryDTO rslt = null;  // default
        final List<EntryDTO> list = jpaNotificationRestService.getNotificationsBySourceAndCustomAttribute(
                PROXY_SOURCE_NAME,
                PROXY_ID_ATTRIBUTE,
                entry.getId());
        logger.debug("Search for JPA-backed entry with id='{}' returned the following:  {}", entry.getId(), list);
        switch (list.size()) {
            case 1:
                // Cool;  we have one...
                rslt = list.get(0);
                break;
            case 0:
                // Also cool;  we don't have one...
                break;
            default:
                // Not cool...
                throw new IllegalStateException("More than one JPA-back entry exists for id=" + entry.getId());
        }
        return rslt;
    }

    private EntryDTO fetchOrCreateJpaProxy(NotificationEntry entry) {

        // Try fetch first...
        EntryDTO rslt = fetchJpaProxyIfAvailable(entry);

        // Otherwise create...
        if (rslt == null) {
            final EntryDTO newEntry = new EntryDTO();

            // Important stuff that allows us to identify and retrieve this entry
            final List<String> values = new ArrayList<>();
            values.add(entry.getId());
            final AttributeDTO idAttribute = new AttributeDTO();
            idAttribute.setName(PROXY_ID_ATTRIBUTE);
            idAttribute.setValues(values);
            final Set<AttributeDTO> attributes = new HashSet<>();
            attributes.add(idAttribute);
            newEntry.setAttributes(attributes);
            newEntry.setSource(PROXY_SOURCE_NAME);

            // Just fluff
            newEntry.setTitle(entry.getTitle());
            newEntry.setBody(PROXY_BODY_CONTENT);

            rslt = jpaNotificationRestService.createNotification(newEntry);
        }

        return rslt;

    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License.  You may obtain a
 * copy of the License at the following location:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.jasig.portlet.notice.util;

import org.jasig.portlet.notice.*;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * {@link INotificationService} beans return {@link NotificationResponse} objects, which are tree
 * structures wherein notices are grouped by category.  Most APIs and clients probably prefer
 * notices in a list (often sorted).  This utility provides a standard way to flatten a
 * NotificationResponse.
 */
@Component
public class NotificationResponseFlattener {

    public List<NotificationEntry> flatten(NotificationResponse response) {

        // We will be modifying the entries to add the category since it will not be represented in the uncategorized list, so create a
        // copy of the data if it is not already cloned.
        final NotificationResponse clone = response.cloneIfNotCloned();

        // Combine all categories into one list and create a category list.  The category list will include categories that have no elements so
        // it can be used for a consistent filtering interface if the data source provides a full list.  (This is helpful for an interface such as
        // student jobs where you always want the user to see a consistent list of all the categories for a category filter).
        List<NotificationEntry> rslt = new ArrayList<>();
        final Set<String> categoryList = new HashSet<>();
        for (final NotificationCategory notificationCategory : clone.getCategories()) {
            categoryList.add(notificationCategory.getTitle());
            addAndCategorizeEntries(rslt, notificationCategory);
        }

        return rslt;

    }

    /*
     * Implementation
     */

    /**
     * Add all entries from the notification category to the allEntries list after adding an attribute 'category' that contains
     * the category.  That allows UIs that want the convenience of an uncategorized list, such as dataTables, to obtain the data in a simple
     * format that requires no additional processing but maintains the knowledge of the category of the entries.
     *
     * @param allEntries List of all entries
     * @param notificationCategory NotificationCategory to add its entries to the allEntries list
     */
    private void addAndCategorizeEntries(List<NotificationEntry> allEntries, NotificationCategory notificationCategory) {
        for (NotificationEntry entry : notificationCategory.getEntries()) {
            List<NotificationAttribute> attrs = new ArrayList<>(entry.getAttributes());
            attrs.add(new NotificationAttribute("category", notificationCategory.getTitle()));
            entry.setAttributes(attrs);
            allEntries.add(entry);
        }
    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License.  You may obtain a
 * copy of the License at the following location:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.jasig.portlet.notice.controller.rest;

import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.jasig.portlet.notice.rest.AddresseeDTO;
import org.jasig.portlet.notice.rest.EntryDTO;
import org.jasig.portlet.notice.rest.EventDTO;
import org.jasig.portlet.notice.service.jpa.IJpaNotificationRESTService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

/**
 * Controller for the REST endpoints exposed to manage
 * JPA notifications.
 *
 * @author Josh Helmer, jhelmer.unicon.net
 * @since 3.0
 */
@Controller
@RequestMapping(JPANotificationRESTController.REQUEST_ROOT)
public class JPANotificationRESTController {

    public static final String REQUEST_ROOT = "/api/v1/notifications";

    private final Logger log = LoggerFactory.getLogger(getClass());

    @Autowired
    private IJpaNotificationRESTService restService;

    /**
     * Get the list of notifications.  Supports optional (and recommended!) paging
     *
     * @param page The 0 based page number
     * @param pageSize the page size
     * @return the list of matching notifications.
     */
    @RequestMapping(method = RequestMethod.GET)
    @ResponseBody
    public List<EntryDTO> getNotifications(
            @RequestParam(value = "page", required = false) Integer page,
            @RequestParam(value = "pageSize", required = false) Integer pageSize) {

        return restService.getNotifications(page, pageSize);
    }

    /**
     * Create a notification.
     *
     * @param req the Http request
     * @param response the Http response
     * @param entry The Entry
     * @return The persisted entry
     */
    @RequestMapping(method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.CREATED)
    @ResponseBody
    public EntryDTO createNotification(HttpServletRequest req, HttpServletResponse response, @RequestBody EntryDTO entry) {
        EntryDTO persisted = restService.createNotification(entry);

        String url = getSingleNotificationRESTUrl(req, persisted.getId());
        response.addHeader("Location", url);

        return persisted;
    }

    /**
     * Get 1 notification by id.
     *
     * @param response the Http response
     * @param id the id of the notification
     * @param full optionally fetch addressee info.  If false, addressee info will be omitted.
     * @return the notification
     */
    @RequestMapping(value = "/{notificationId}", method = RequestMethod.GET)
    @ResponseBody
    public EntryDTO getNotification(HttpServletResponse response, @PathVariable("notificationId") long id,
            @RequestParam(value = "full", required = false, defaultValue = "false") boolean full) {
        EntryDTO notification = restService.getNotification(id, full);
        if (notification == null) {
            response.setStatus(HttpStatus.NOT_FOUND.value());
            return null;
        }

        return notification;
    }

    /**
     * Get the set of addressees for a notification.
     *
     * @param id the notification id
     * @return the set of addressees
     */
    @RequestMapping(value = "/{notificationId}/addressees", method = RequestMethod.GET)
    @ResponseBody
    public Set<AddresseeDTO> getAddressees(@PathVariable("notificationId") long id) {
        return restService.getAddressees(id);
    }

    /**
     * Create a new addressee for a notification.
     *
     * @param req the Http request
     * @param resp the Http response
     * @param id the notification id
     * @param addressee the list of addressees
     * @return the persisted addressee
     */
    @RequestMapping(value = "/{notificationId}/addressees", method = RequestMethod.POST)
    @ResponseBody
    @ResponseStatus(HttpStatus.CREATED)
    public AddresseeDTO addAddressee(HttpServletRequest req, HttpServletResponse resp,
                @PathVariable("notificationId") long id,
                @RequestBody AddresseeDTO addressee) {
        AddresseeDTO dto = restService.createAddressee(id, addressee);
        if (dto == null) {
            resp.setStatus(HttpStatus.NOT_FOUND.value());
            return null;
        }

        String url = getSingleNotificationRESTUrl(req, id) + "/addressee/" + dto.getId();
        resp.addHeader("Location", url);

        return dto;
    }

    /**
     * Get a specific addressee
     *
     * @param resp the Http Response
     * @param notificationId the notification id
     * @param addresseeId the addressee id
     * @return the matching addressee if available
     */
    @RequestMapping(value = "/{notificationId}/addressees/{addresseeId}", method = RequestMethod.GET)
    @ResponseBody
    public AddresseeDTO getAddressee(HttpServletResponse resp,
            @PathVariable("notificationId") long notificationId,
            @PathVariable("addresseeId") long addresseeId) {
        AddresseeDTO dto = restService.getAddressee(addresseeId);
        if (dto == null) {
            resp.setStatus(HttpStatus.NOT_FOUND.value());
            return null;
        }

        return dto;
    }

    /**
     * Get the list of events for a notification.
     *
     * @param id the notification id
     * @return the list of events
     */
    @RequestMapping(value = "/{notificationId}/events", method = RequestMethod.GET)
    @ResponseBody
    public List<EventDTO> getEventsByNotification(@PathVariable("notificationId") long id) {
        return restService.getEventsByNotification(id);
    }

    /**
     * Get a specific event.
     *
     * @param response The Http response
     * @param notificationId the notification id
     * @param eventId the event id
     * @return the matching event, if found
     */
    @RequestMapping(value = "/{notificationId}/events/{eventId}", method = RequestMethod.GET)
    @ResponseBody
    public EventDTO getEvent(HttpServletResponse response, @PathVariable("notificationId") long notificationId,
            @PathVariable("eventId") long eventId) {
        EventDTO event = restService.getEvent(eventId);
        if (event == null) {
            response.setStatus(HttpStatus.NOT_FOUND.value());
            return null;
        }

        return event;
    }

    /**
     * Create a new event.
     *
     * @param req the Http request
     * @param resp the Http response
     * @param notificationId the notification id
     * @param event the event id
     * @return The newly persisted object
     */
    @RequestMapping(value = "/{notificationId}/events", method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.CREATED)
    @ResponseBody
    public EventDTO createEvent(HttpServletRequest req, HttpServletResponse resp,
            @PathVariable("notificationId") long notificationId,
            @RequestBody EventDTO event) {
        EventDTO dto = restService.createEvent(notificationId, event);
        if (dto == null) {
            resp.setStatus(HttpStatus.NOT_FOUND.value());
            return null;
        }

        String url = getSingleNotificationRESTUrl(req, notificationId) + "/state/" + dto.getId();
        resp.addHeader("Location", url);

        return dto;
    }

    /**
     * Build the URL for a specific notification.
     *
     * @param request the Http request
     * @param id the notification id
     * @return the URL to hit that specific id
     */
    private String getSingleNotificationRESTUrl(HttpServletRequest request, long id) {
        String path = request.getContextPath() + REQUEST_ROOT + id;
        try {
            URL url = new URL(request.getScheme(), request.getServerName(), request.getServerPort(), path);
            return url.toExternalForm();

        } catch (MalformedURLException e) {
            // if it fails, just return a relative path.  Not ideal, but better than nothing...
            log.warn("Error building Location header", e);
            return path;
        }
    }
}