/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF 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
 *
 *   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.apache.syncope.core.persistence.jpa.entity;

import java.time.OffsetDateTime;
import java.util.Base64;
import java.util.Optional;
import java.util.regex.Pattern;
import javax.persistence.Lob;
import javax.persistence.MappedSuperclass;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.syncope.common.lib.SyncopeConstants;
import org.apache.syncope.common.lib.types.AttrSchemaType;
import org.apache.syncope.core.persistence.api.attrvalue.validation.ParsingValidationException;
import org.apache.syncope.core.persistence.api.entity.PlainAttrValue;
import org.apache.syncope.core.persistence.api.entity.PlainSchema;
import org.apache.syncope.core.persistence.jpa.validation.entity.PlainAttrValueCheck;
import org.apache.syncope.core.provisioning.api.utils.FormatUtils;
import org.apache.syncope.core.spring.ApplicationContextProvider;
import org.apache.syncope.core.spring.security.Encryptor;

@MappedSuperclass
@PlainAttrValueCheck
public abstract class AbstractPlainAttrValue extends AbstractGeneratedKeyEntity implements PlainAttrValue {

    private static final long serialVersionUID = -9141923816611244785L;

    private static final Pattern SPRING_ENV_PROPERTY = Pattern.compile("^\\$\\{.*\\}$");

    private String stringValue;

    private OffsetDateTime dateValue;

    private Boolean booleanValue;

    private Long longValue;

    private Double doubleValue;

    @Lob
    private byte[] binaryValue;

    @Override
    public Boolean getBooleanValue() {
        return booleanValue;
    }

    @Override
    public void setBooleanValue(final Boolean booleanValue) {
        this.booleanValue = booleanValue;
    }

    @Override
    public OffsetDateTime getDateValue() {
        return dateValue;
    }

    @Override
    public void setDateValue(final OffsetDateTime dateValue) {
        this.dateValue = dateValue;
    }

    @Override
    public Double getDoubleValue() {
        return doubleValue;
    }

    @Override
    public void setDoubleValue(final Double doubleValue) {
        this.doubleValue = doubleValue;
    }

    @Override
    public Long getLongValue() {
        return longValue;
    }

    @Override
    public void setLongValue(final Long longValue) {
        this.longValue = longValue;
    }

    @Override
    public String getStringValue() {
        // workaround for Oracle DB considering empty string values as NULL (SYNCOPE-664)
        return dateValue == null
                && booleanValue == null
                && longValue == null
                && doubleValue == null
                && binaryValue == null
                && stringValue == null
                        ? StringUtils.EMPTY
                        : stringValue;
    }

    @Override
    public void setStringValue(final String stringValue) {
        this.stringValue = stringValue;
    }

    @Override
    public byte[] getBinaryValue() {
        return binaryValue;
    }

    @Override
    public void setBinaryValue(final byte[] binaryValue) {
        this.binaryValue = ArrayUtils.clone(binaryValue);
    }

    protected String getSecretKey(final PlainSchema schema) {
        return SPRING_ENV_PROPERTY.matcher(schema.getSecretKey()).matches()
                ? ApplicationContextProvider.getApplicationContext().getEnvironment().
                        getProperty(StringUtils.substringBetween(schema.getSecretKey(), "${", "}"))
                : schema.getSecretKey();
    }

    @Override
    public void parseValue(final PlainSchema schema, final String value) {
        Exception exception = null;

        switch (schema.getType()) {

            case Boolean:
                this.setBooleanValue(Boolean.valueOf(value));
                break;

            case Long:
                try {
                this.setLongValue(schema.getConversionPattern() == null
                        ? Long.valueOf(value)
                        : FormatUtils.parseNumber(value, schema.getConversionPattern()).longValue());
            } catch (Exception pe) {
                exception = pe;
            }
            break;

            case Double:
                try {
                this.setDoubleValue(schema.getConversionPattern() == null
                        ? Double.valueOf(value)
                        : FormatUtils.parseNumber(value, schema.getConversionPattern()).doubleValue());
            } catch (Exception pe) {
                exception = pe;
            }
            break;

            case Date:
                try {
                this.setDateValue(schema.getConversionPattern() == null
                        ? FormatUtils.parseDate(value)
                        : FormatUtils.parseDate(value, schema.getConversionPattern()));
            } catch (Exception pe) {
                exception = pe;
            }
            break;

            case Encrypted:
                try {
                this.setStringValue(Encryptor.getInstance(getSecretKey(schema)).
                        encode(value, schema.getCipherAlgorithm()));
            } catch (Exception pe) {
                exception = pe;
            }
            break;

            case Binary:
                this.setBinaryValue(Base64.getDecoder().decode(value));
                break;

            case String:
            case Enum:
            default:
                this.setStringValue(value);
        }

        if (exception != null) {
            throw new ParsingValidationException(
                    "While trying to parse '" + value + "' as " + schema.getKey(), exception);
        }
    }

    @SuppressWarnings("unchecked")
    @Override
    public <T> T getValue() {
        return (T) (booleanValue != null
                ? getBooleanValue()
                : dateValue != null
                        ? getDateValue()
                        : doubleValue != null
                                ? getDoubleValue()
                                : longValue != null
                                        ? getLongValue()
                                        : binaryValue != null
                                                ? getBinaryValue()
                                                : getStringValue());
    }

    private Object getValue(final AttrSchemaType type) {
        Object value;
        switch (type) {

            case Boolean:
                value = getBooleanValue();
                break;

            case Long:
                value = getLongValue();
                break;

            case Double:
                value = getDoubleValue();
                break;

            case Date:
                value = getDateValue();
                break;

            case Binary:
                value = getBinaryValue();
                break;

            case String:
            case Enum:
            case Encrypted:
                value = getStringValue();
                break;

            default:
                value = null;
        }

        return value;
    }

    private String getValueAsString(final AttrSchemaType type, final PlainSchema schema) {
        if (getValue(type) == null) {
            LOG.warn("Could not find expected value for type {} in {}, reverting to getValue().toString()", type, this);

            Object value = getValue();
            return Optional.ofNullable(value).map(Object::toString).orElse(null);
        }

        String result;
        switch (type) {

            case Boolean:
                result = getBooleanValue().toString();
                break;

            case Long:
                result = schema == null || schema.getConversionPattern() == null
                        ? getLongValue().toString()
                        : FormatUtils.format(getLongValue(), schema.getConversionPattern());
                break;

            case Double:
                result = schema == null || schema.getConversionPattern() == null
                        ? getDoubleValue().toString()
                        : FormatUtils.format(getDoubleValue(), schema.getConversionPattern());
                break;

            case Date:
                result = schema == null || schema.getConversionPattern() == null
                        ? FormatUtils.format(getDateValue())
                        : FormatUtils.format(getDateValue(), schema.getConversionPattern());
                break;

            case Binary:
                result = Base64.getEncoder().encodeToString(getBinaryValue());
                break;

            case Encrypted:
                if (schema == null
                        || !SyncopeConstants.ENCRYPTED_DECODE_CONVERSION_PATTERN.equals(schema.getConversionPattern())
                        || !schema.getCipherAlgorithm().isInvertible()) {

                    result = getStringValue();
                } else {
                    try {
                        result = Encryptor.getInstance(getSecretKey(schema)).
                                decode(getStringValue(), schema.getCipherAlgorithm());
                    } catch (Exception e) {
                        LOG.error("Could not decode encrypted value {} for schema {}", getStringValue(), schema, e);
                        result = getStringValue();
                    }
                }
                break;

            case String:
            case Enum:
            default:
                result = getStringValue();
        }

        return result;
    }

    @Override
    public String getValueAsString() {
        PlainSchema schema = getAttr() == null || getAttr().getSchema() == null
                ? null
                : getAttr().getSchema();
        AttrSchemaType type = schema == null || schema.getType() == null
                ? AttrSchemaType.String
                : getAttr().getSchema().getType();

        return getValueAsString(type, schema);
    }

    @Override
    public String getValueAsString(final AttrSchemaType type) {
        return getValueAsString(
                type,
                getAttr() == null || getAttr().getSchema() == null ? null : getAttr().getSchema());
    }

    @Override
    public String getValueAsString(final PlainSchema schema) {
        return getValueAsString(schema.getType(), schema);
    }

    @Override
    public String toString() {
        return new ToStringBuilder(this).
                append(getKey()).
                append(stringValue).
                append(dateValue).
                append(booleanValue).
                append(longValue).
                append(doubleValue).
                append(binaryValue).
                build();
    }
}
