/*
 * Copyright (c) 2004-2022, 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 lc.esp.eo.sdk;

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

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 Cloneable {
    private static final MechaLogger logger = MechaLoggerFactory.getLogger(EO.class);
    private static final long serialVersionUID = 20;
    private String type;
    private final HashMap<String, Object> data = new HashMap<>();
    private final EOId id;

    private final Instant ctime;

    public EO(String apiType, EOId id, Instant ctime) {
        this.type = apiType;
        this.id = id;
        this.ctime = ctime;
    }

    public EO(String apiType, EOId id) {
        this(apiType, id, Instant.now());
    }

    public EO(EOId id) {
        this(null, id);
    }

    public EO() {
        this(null, null);
    }

    public EOId getId() {
        return id;
    }

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

    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(type, 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 Object getMatchingType(final String key, final Object obj) {
        final Object 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, Object> 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();
    }

    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 Object 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();
    }

    public HashMap<String, Object> getData() {
        return data;
    }

    public Object remove(final String 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));
    }

    // Be a wolf

    /**
     * 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) {
        Object 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.setParent(this, key);
            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 key, final String text) {
        throw new NotImplementedException();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        EO eo = (EO) o;
        return id.equals(eo.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    /**
     * Set the given value into this EO.
     * <p>
     * Although this method takes {@link Object} for a value, this is not a guarantee that any Object
     * class is compatible with any use-case, such as loading/storing via database adapters, serializing/deserializing
     * to various encodings such as JSON, etc.
     * <p>
     * The value may not be an object, natural array or other structure.
     *
     * @param key      The key to set.
     * @param value    The value to set the key to.
     * @return
     */
    public EO setValue(final String key, final Object value) {
        if (value instanceof EO)
            throw new IllegalArgumentException("EO cannot be set as a value in an EO");

        Object oldValue = data.get(key);

        if (value == null) {
            data.remove(key);
            return this;
        }

        if (value instanceof String) {
            data.put(key, value);
            return this;
        }

        if (value instanceof EOLoop) {
            ((EOLoop) value).setParent(this, key);
        }

        data.put(key, value);

        return this;
    }

    /**
     * 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));
        }

        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 key, final Object value) {
        if (data.containsKey(key)) return false;
        setValue(key, 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 Object 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 key) {
        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 Object getValue(final String key) {
        return data.get(key);
    }

    public boolean isEmpty(final String key) {
        final Object o = data.get(key);
        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 key) {
        return Snavig.convertToLocale(data.get(key));
    }

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

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

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

    /**
     * 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 Object getFloat(final String key) {
        return Snavig.convertToFloat(key);
    }

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

    public String getType() {
        return type;
    }

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

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

    @Override
    public String toString() {
        return "EO{" +
                "type='" + type + '\'' +
                ", data=" + data +
                ", id=" + id +
                ", ctime=" + ctime +
                '}';
    }

    public Instant getCtime() {
        return ctime;
    }

    /**
     * Returns true if the given attribute contains an {@link EOLoop}.
     */
    public boolean isValueLoop(String key) {
        Object s = data.get(key);
        if (s != null && s instanceof EOLoop) return true;
        return false;
    }
}