/*
 * Copyright (c) 2004-2017, by Alex Leigh.
 * All rights reserved.
 *
 * THIS IS UNPUBLISHED PROPRIETARY SOURCE CODE
 * The copyright notice above does not evidence any
 * actual or intended publication of such source code.
 */

package leigh.eo;

import leigh.mecha.lang.NotImplementedException;
import leigh.mecha.log.MechaLogger;
import leigh.mecha.log.MechaLoggerFactory;
import leigh.mecha.math.IntIntervals;
import leigh.mecha.util.Snavig;
import org.apache.commons.lang.StringUtils;

import java.io.Serializable;
import java.math.BigDecimal;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.regex.Pattern;

/**
 * Design Considerations:
 * <p>
 * An important philosophy of the EO is that the internal data-type (returned from <code>getValue()</code>) should
 * not be relied on. For example, serializing an EO to a store and then retrieving it may change the internal
 * data-types. Code that requires a given data-type should always call the type-specific getter so that (if necessary)
 * type conversion can automatically be performed. For example, <code>getValueInstant()</code> instead of
 * <code>getValue()</code>.
 * <p>
 * When a <code>String</code> representation of a value is required, call <code>getValueString()</code> instead of
 * <code>getValue().toString()</code> as the EO implementation has specific string conversion mechanics to ensure
 * the returned string can be properly type-converted by the EO class in the future.
 * <p>
 * The EO intentionally has no understanding of the meaning of the data it stores, and this extends to the content-types
 * of that data. For example, if a given attribute contains a UUID, there is no intrinsic way to know whether that UUID
 * refers, to a UUID or a binary reference of that ID, or to a value that should have been a string but the caller
 * provided a UUID anyways.
 * <p>
 * It is an exercise for the caller to keep track of their business data meaning; this can either be done through
 * schemas (as provided by ESP) or through other techniques such as encoding data-types or flags into the EO itself.
 *
 * <i>We took from our environment and made from it something more.</i>
 * <p>
 * It is a requirement that hashCode() and equals() be the same regardless of the contents.
 *
 * @author Alex Leigh
 */

// TODO: Use loss of precision calculations from TypeConverter to improve class mechanics
public class EO implements Serializable, Cloneable {
    private static final MechaLogger logger = MechaLoggerFactory.getLogger(EO.class);
    private static final long serialVersionUID = 1;
    private static final String WEAK_META_PREFIX = "_";
    private static final String STRONG_META_PREFIX = "#";
    private static final String META_LABEL_KEY = "label";
    private static final String META_UOM_KEY = "uom";
    private static String pathPattern = Pattern.quote("/");
    private String typeName;
    private final HashMap<String, Serializable> data = new HashMap<>();
    private transient EditTracker editTracker;
    private HashMap<String, HashMap<String, Serializable>> meta;

    /**
     * Initialize a new element map with the given type name. The type name has no particular
     * meaning to the EO implementation itself, but, maps the definition of the EO to an external
     * schema.
     */
    public EO(final String apiType) {
        this.typeName = apiType;
    }

    public EO() {
        typeName = null;
    }

    public EditTracker getEditTracker() {
        return editTracker;
    }

    /**
     * Set the edit tracker for this EO. This will have the side-effect of setting the edit tracker for any
     * loops contained within the EO.
     */
    public EO setEditTracker(EditTracker editTracker) {
        this.editTracker = editTracker;
        for (Map.Entry<String, Serializable> val : data.entrySet()) {
            if (val instanceof EOLoop) {
                ((EOLoop) val).setEditTracker(editTracker);
            }
        }
        return this;
    }

    public void setApiType(String type) {
        this.typeName = type;
    }

    /**
     * Return the meta map for the given apiName, if it exists, otherwise, return <code>null</code>>.
     * If <code>create</code> is specified as <code>true</code> then the meta map will be created
     * if it does not already exist.
     */
    private HashMap<String, Serializable> getMetaMap(String key, boolean create) {
        if (meta == null) {
            if (create == true) {
                meta = new HashMap<>();
            } else {
                return null;
            }
        }

        HashMap<String, Serializable> attrMeta = meta.get(key);
        if (attrMeta != null) {
            return attrMeta;
        }

        if (create) {
            attrMeta = new HashMap<>();
            meta.put(key, attrMeta);
            return attrMeta;
        }

        return null;
    }

    private HashMap<String, Serializable> getMetaMap(String key) {
        return getMetaMap(key, false);
    }

    /**
     * Set a meta value for the given attribute. If the given attribute is removed from the EO, then all meta
     * values for that attribute are also removed.
     * <p>
     * If the isWeak flag is set to true, then the meta value has special properties. If the attribute to which this
     * meta value belongs is changed, the weak value will be removed.
     */
    public void setMetaValue(String apiName, String metaKey, Serializable value, boolean isWeak) {
        logger.debug("setMetaValue(): [apiName: {}] [metaKey: {}] [value: {}] [isWeak: {}]",
                apiName, metaKey, value, isWeak);
        if (meta == null) meta = new HashMap<>();
        HashMap<String, Serializable> attMeta = meta.get(apiName);
        if (attMeta == null) {
            attMeta = new HashMap<>();
            meta.put(apiName, attMeta);
        }

        if (isWeak) {
            attMeta.remove(STRONG_META_PREFIX + metaKey);
            attMeta.put(WEAK_META_PREFIX + metaKey, value);
        } else {
            attMeta.remove(WEAK_META_PREFIX + metaKey);
            attMeta.put(STRONG_META_PREFIX + metaKey, value);
        }
    }

    public Map<String, HashMap<String, Serializable>> getMeta() {
        return meta;
    }

    /**
     * Return the meta value, if any, for the given attribute. Returns <code>null</code> if there is no such
     * meta value.
     */
    public Serializable getMetaValue(String apiName, String metaKey) {
        HashMap<String, Serializable> attMeta = getMetaMap(apiName);
        if (attMeta == null) return null;
        Serializable s = attMeta.get(STRONG_META_PREFIX + metaKey);
        if (s != null) return s;
        return attMeta.get(WEAK_META_PREFIX + metaKey);
    }

    /**
     * Remove all meta values for the given attribute.
     */
    public void removeAllMeta(String apiName) {
        if (meta != null) {
            meta.remove(apiName);
        }
    }

    /**
     * Add all the provides <code>metaValues</code> values to the meta values already stored
     * in this EO for the given attribute. The keys for the provided meta values are expected
     * to have already been prefixed for weak status.
     */
    public void setAllMeta(String apiName, Map<String, Serializable> metaValues) {
        HashMap<String, Serializable> attrMeta = getMetaMap(apiName, true);
        attrMeta.putAll(metaValues);
    }

    /**
     * Remove all weak meta values for the given attribute.
     */
    public void removeWeakMeta(String apiName) {
        HashMap<String, Serializable> attMeta = getMetaMap(apiName);
        if (attMeta == null) return;

        // We want to remove every key that begins with the weak prefix
        ArrayList<String> toRemove = new ArrayList<>();
        for (String metaKey : attMeta.keySet()) {
            if (metaKey.startsWith(WEAK_META_PREFIX)) toRemove.add(metaKey);
        }
        attMeta.keySet().removeAll(toRemove);
    }

    public boolean containsKey(String key) {
        return data.containsKey(key);
    }

    /**
     * Returns <code>true</code> if this EO is - or is compatible with - the provided type.
     */
    public boolean isType(String apiName) {
        return StringUtils.equals(typeName, apiName);
    }

    /**
     * Provides a deep-copy of the given EO.
     *
     * @return
     */
    public Object clone() {
        throw new NotImplementedException();
    }

    /**
     * Return a value from the EO while attempting to match the type of another object. This performs necessary
     * type-conversions on the object stored internally to the EO.
     * <p>
     * If <code>obj</code> is <code>null</code>, the natural value from the EO will be returned.
     */
    public Serializable getMatchingType(final String key, final Serializable obj) {
        final Serializable v = getValue(key);

        // If we don't have a value, we can't type-convert it
        if (v == null)
            return null;

        // If they did not provide a value, we can't type-convert ours
        if (obj == null)
            return v;

        // Maybe they already match.
        if (v.getClass().isAssignableFrom(obj.getClass())) {
            return v;
        }

        if (obj instanceof String) {
            return getValueString(key);
        }

        if (obj instanceof UUID) {
            return getValueUUID(key);
        }

        if (obj instanceof Instant) {
            return getValueInstant(key);
        }

        if (obj instanceof Date) {
            return getValueDate(key);
        }

        throw new IllegalArgumentException("Unable to match types. " + v.getClass() + " -> " + obj.getClass());
    }

    /**
     * Returns <code>true</code> if the EO is multi-dimensional. The EO is considered multi-dimensional if it contains
     * a loop.
     *
     * @return
     */
    public boolean isMultiDimensional() {
        for (final Map.Entry<String, Serializable> entry : data.entrySet()) {
            final Object v = entry.getValue();
            if (v instanceof EOLoop) return true;
        }

        return false;
    }

    /**
     * Clear all the values stored in this EO, including errors and global errors. The schema type
     * for the EO will be preserved.
     */
    public void clear() {
        data.clear();
        if (meta != null) meta.clear();
    }

    public String getValueString(final String key, final String def) {
        String x = getValueString(key);
        if (StringUtils.isEmpty(x)) return def;
        return x;
    }

    /**
     * Return the value in string form.
     * <p>
     * If the value is null, <code>null</code> is returned.
     * <p>
     * If the value is a <code>Date</code>, the time is returned in ISO format (rather than Date format).
     */
    public String getValueString(final String key) {
        return Snavig.convertToString(data.get(key));
    }

    public String getValueStringLowercase(final String key) {
        return Snavig.convertToLowercaseString(data.get(key));
    }

    public Pattern getValuePattern(final String key) {
        return Snavig.convertToPattern(data.get(key));
    }

    /**
     * Identical to <code>getValue()</code> and provided for compatibility with 3rd party libraries, such as Velocity.
     */
    public Serializable get(String key) {
        return getValue(key);
    }

    /**
     * Returns <code>null</code> if there are no global errors.
     *
     * @return
     */
    public List<String> getGlobalErrors() {
        // TODO: Implement
        throw new NotImplementedException();
    }

    protected HashMap<String, Serializable> getData() {
        return data;
    }

    public Serializable remove(final String key) {
        if (meta != null) meta.remove(key);
        return data.remove(key);
    }

    /**
     * Returns <code>true</code> if the property exists and contains a positive boolean. Otherwise,
     * returns negative, including when the property is unset in the object.
     */
    public boolean getValueBoolean(final String key) {
        return Snavig.convertToBoolean(data.get(key));
    }

    public Integer getValueInteger(final String key) {
        return Snavig.convertToInteger(data.get(key));
    }

    public Double getValueDouble(final String key) {
        return Snavig.convertToDouble(data.get(key));
    }

    public Float getValueFloat(final String key) {
        return Snavig.convertToFloat(data.get(key));
    }

    public Instant getValueInstant(final String key) {
        return Snavig.convertToInstant(data.get(key));
    }

    public BigDecimal getNumber(final String key) {
        return Snavig.convertToBigDecimal(data.get(key));
    }

    public Duration getValueDuration(final String key) {
        return Snavig.convertToDuration(data.get(key));
    }

    public UUID getValueUUID(final String key) {
        return Snavig.convertToUUID(data.get(key));
    }

    public IntIntervals getValueIntIntervals(final String key) {
        return Snavig.convertToIntIntervals(data.get(key));
    }

    public Date getValueDate(final String key) {
        return Snavig.convertToDate(data.get(key));
    }

    /**
     * For the given apiName, this method returns the associated label as stored in the "label" meta attribute. If
     * a label is not available, then the string form for the value is returned instead.
     */
    public String getLabelOrValue(final String apiKey) {
        String label = getMetaValueString(apiKey, META_LABEL_KEY);
        return label!=null ? label : getValueString(apiKey);
    }

    public void setLabel(final String apiKey, final String label) {
        setMetaValue(apiKey, META_LABEL_KEY, label, true);
    }

    public EODataType getValueEspType(final String key) {
        final Object value = data.get(key);
        if (value == null) return null;
        if (value instanceof EODataType) return (EODataType) value;
        return EODataType.valueOf(String.valueOf(value));
    }

    // Be a wolf

    public void recurse(EOLoop dst, final String path) {
        recurse(dst, path.split(pathPattern), 0);
    }

    private void recurse(EOLoop dst, final String[] path, final int index) {
        final Object value = data.get(path[index]);

        logger.debug("recurse(). [idx: {}] [path: {}] [value: {}]", index, path, value);

        if (!(value instanceof EOLoop)) return;

        int next = index + 1;

        if (next == path.length) {
            dst.addAll((EOLoop) value);
            return;
        }

        for (EO eo : (EOLoop) value) {
            eo.recurse(dst, path, next);
        }
    }

    /**
     * Returns the value as a loop. If the value is null, a new loop will be created. If a value is set,
     * but is not a loop, a {@link ClassCastException} will be thrown.
     */
    public EOLoop getValueLoop(final String key) {
        Serializable debug = data.get(key);
        if (debug != null && !(debug instanceof EOLoop)) {
            logger.warn("found bad value. [key: {}] [data: {}]", key, debug);
        }

        EOLoop value = (EOLoop) data.get(key);
        if (value == null) {
            value = new EOLoop();
            value.setEditTracker(editTracker);
            data.put(key, value);
        }
        return value;
    }

    public EO addAll(EO src) {
        for (String key : src.getKeys()) {
            setValue(key, src.getValue(key));
        }
        return this;
    }

    public void addGlobalError(final String msg) {
        throw new NotImplementedException();
    }

    /**
     * Set an error message into the EO for the given key. The error message is presumed to be locale-specific.
     * The same EO is returned, allowing chaining of put operations.
     */
    public EO putError(final String apiName, final String text) {
        throw new NotImplementedException();
    }

    /**
     * Indicate that this value is changing to the newly provided value. This method will only call the event
     * listener if the value actually changes.
     */
    private void dirty(String key, Serializable oldValue, Serializable newValue, EOListener listener) {
        if (editTracker != null) {
            // We only dirty the attribute if it is actually changing here.
            if (!Objects.equals(oldValue, newValue)) {
                editTracker.dirty(this, key, oldValue, newValue, listener);
            }
        }
    }

    public EO setValue(final String apiName, final Serializable value, EOListener listener) {
        if (value instanceof EO)
            throw new IllegalArgumentException("EO cannot be set as a value in an EO");

        Serializable oldValue = data.get(apiName);

        if (value == null) {
            data.remove(apiName);
            dirty(apiName, oldValue, value, listener);
            return this;
        }

        if (value instanceof String) {
            data.put(apiName, value);
            dirty(apiName, oldValue, value, listener);
            return this;
        }

        if (value instanceof EOLoop) {
            ((EOLoop) value).setEditTracker(editTracker);
        }

        data.put(apiName, value);
        removeWeakMeta(apiName);
        dirty(apiName, oldValue, value, listener);

        return this;
    }

    /**
     * Set a value into the EO for the given key. The same EO is returned allowing changing of put operations.
     * <p>
     * It is illegal to set an EO into an EO as a value, and attempting to do so will result in an
     * <code>IllegalArgumentException</code> being thrown. Only other serializable classes and
     * the <code>EOLoop</code> class are legal.
     * <p>
     * If a <code>String</code> is provided, and the string is empty, then this method will not add the
     * value to the EO. If the value is null, then the value will be removed from the EO. Empty could include
     * a string <code>""</code> or a string consisting entirely of whitespace characters.
     */
    public EO setValue(final String apiName, final Serializable value) {
        return setValue(apiName, value, null);
    }

    /**
     * Set all the data elements in the provided object into this object. Returns this EO to the caller
     * for the purpose of method chaining. This method does not copy the meta vvalues from the provided
     * EO, to do that, use <code>merge()</code> instead.
     */
    public EO setAll(EO obj) {
        // TODO: This also needs to set the meta.
        for (String key : obj.getKeys()) {
            data.put(key, obj.get(key));
        }
        return this;
    }

    /**
     * <code>merge()</code> works similarly to <code>setAll()</code> with the added mechanic that it copies
     * the metadata from the provided <code>obj</code> as well. Returns this EO to the caller for the
     * purpose of method chaining.
     */
    public EO merge(EO obj) {
        for (String key : obj.getKeys()) {
            data.put(key, obj.get(key));
            HashMap<String, Serializable> metaAttr = obj.getMetaMap(key);
            if (metaAttr != null) setAllMeta(key, metaAttr);
        }

        logger.info("Merge: {} -> {}", obj, this);
        return this;
    }

    /**
     * Set the provided value only if the key did not already exist in the EO. This is useful for fixing up
     * default values.
     */
    public boolean setValueIfUnset(final String apiName, final Serializable value) {
        if (data.containsKey(apiName)) return false;
        setValue(apiName, value);
        return true;

    }

    /**
     * Returns <code>true</code> if the given key is set in this EO and has a non-null value.
     */
    public boolean contains(final String key) {
        final Serializable o = data.get(key);
        return o != null;
    }

    /**
     * Return the error string (if any is set) for the given key, or <code>null</code> if no error
     * string has been set.
     */
    public String getError(final String apiName) {
        throw new NotImplementedException();
    }

    /**
     * Return the value (if any is set) for the given key, or <code>null</code> if no value has been set.
     */
    public Serializable getValue(final String apiName) {
        return data.get(apiName);
    }

    public boolean isEmpty(final String apiName) {
        final Object o = data.get(apiName);
        if (o == null) return true;
        if (o instanceof String) {
            return StringUtils.isEmpty((String) o);
        }
        return false;
    }

    /**
     * Return the value in the form of a <code>Locale</code>. If the value is provided as a <code>String</code>
     * it should be in IETF BCP 47 format. Parsing the string is in accordance with the specification of
     * <code>Locale.forLanguageTag()</code>.
     */
    public Locale getValueLocale(final String apiName) {
        return Snavig.convertToLocale(data.get(apiName));
    }

    public Inet4Address getInet4Address(final String apiName) {
        return Snavig.convertToInet4Address(data.get(apiName));
    }

    public Inet6Address getInet6Address(final String apiName) {
        return Snavig.convertToInet6Address(data.get(apiName));
    }

    public BigDecimal getValueBigDecimal(final String apiName) {
        return Snavig.convertToBigDecimal(data.get(apiName));
    }

    /**
     * Return the value in floating point format. If the value is not already a float, it will be converted
     * using <code>Float.parseFloat()</code>.
     */
    public Serializable getFloat(final String apiName) {
        return Snavig.convertToFloat(apiName);
    }

    /**
     * Return the type name associated with this EO.
     */

    public String getApiType() {
        return typeName;
    }

    @Override
    public String toString() {
        return "EO{" +
                "typeName='" + typeName + '\'' +
                ", data=" + data +
                ", meta=" + meta +
                '}';
    }

    public Set<String> getKeys() {
        return data.keySet();
    }

    public int size() {
        return data.size();
    }

    public boolean isValueLoop(String apiName) {
        Serializable s = data.get(apiName);
        if (s != null && s instanceof EOLoop) return true;
        return false;
    }

    public String getMetaValueString(String key, String keyDisplayValue) {
        return Snavig.convertToString(getMetaValue(key, keyDisplayValue));
    }
}