package com.github.jsonldjava.core;

import static com.github.jsonldjava.core.JsonLdConsts.RDF_FIRST;
import static com.github.jsonldjava.core.JsonLdConsts.RDF_LANGSTRING;
import static com.github.jsonldjava.core.JsonLdConsts.RDF_NIL;
import static com.github.jsonldjava.core.JsonLdConsts.RDF_REST;
import static com.github.jsonldjava.core.JsonLdConsts.RDF_TYPE;
import static com.github.jsonldjava.core.JsonLdConsts.XSD_BOOLEAN;
import static com.github.jsonldjava.core.JsonLdConsts.XSD_DECIMAL;
import static com.github.jsonldjava.core.JsonLdConsts.XSD_DOUBLE;
import static com.github.jsonldjava.core.JsonLdConsts.XSD_INTEGER;
import static com.github.jsonldjava.core.JsonLdConsts.XSD_STRING;
import static com.github.jsonldjava.core.JsonLdUtils.isKeyword;
import static com.github.jsonldjava.core.JsonLdUtils.isList;
import static com.github.jsonldjava.core.JsonLdUtils.isObject;
import static com.github.jsonldjava.core.JsonLdUtils.isString;
import static com.github.jsonldjava.core.JsonLdUtils.isValue;
import static com.github.jsonldjava.utils.Obj.newMap;

import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * Starting to migrate away from using plain java Maps as the internal RDF
 * dataset store. Currently each item just wraps a Map based on the old format
 * so everything doesn't break. Will phase this out once everything is using the
 * new format.
 *
 * @author Tristan
 *
 */
public class RDFDataset extends LinkedHashMap<String, Object> {
    private static final long serialVersionUID = 2796344994239879165L;

    private static final Pattern PATTERN_INTEGER = Pattern.compile("^[\\-+]?[0-9]+$");
    private static final Pattern PATTERN_DOUBLE = Pattern
            .compile("^(\\+|-)?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([Ee](\\+|-)?[0-9]+)?$");

    public static class Quad extends LinkedHashMap<String, Object> implements Comparable<Quad> {
        private static final long serialVersionUID = -7021918051975883082L;

        public Quad(final String subject, final String predicate, final String object,
                final String graph) {
            this(subject, predicate,
                    object.startsWith("_:") ? new BlankNode(object) : new IRI(object), graph);
        };

        public Quad(final String subject, final String predicate, final String value,
                final String datatype, final String language, final String graph) {
            this(subject, predicate, new Literal(value, datatype, language), graph);
        };

        private Quad(final String subject, final String predicate, final Node object,
                final String graph) {
            this(subject.startsWith("_:") ? new BlankNode(subject) : new IRI(subject),
                    new IRI(predicate), object, graph);
        };

        public Quad(final Node subject, final Node predicate, final Node object,
                final String graph) {
            super();
            put("subject", subject);
            put("predicate", predicate);
            put("object", object);
            if (graph != null && !"@default".equals(graph)) {
                // TODO: i'm not yet sure if this should be added or if the
                // graph should only be represented by the keys in the dataset
                put("name", graph.startsWith("_:") ? new BlankNode(graph) : new IRI(graph));
            }
        }

        public Node getSubject() {
            return (Node) get("subject");
        }

        public Node getPredicate() {
            return (Node) get("predicate");
        }

        public Node getObject() {
            return (Node) get("object");
        }

        public Node getGraph() {
            return (Node) get("name");
        }

        @Override
        public int compareTo(Quad o) {
            if (o == null) {
                return 1;
            }
            int rval = getGraph().compareTo(o.getGraph());
            if (rval != 0) {
                return rval;
            }
            rval = getSubject().compareTo(o.getSubject());
            if (rval != 0) {
                return rval;
            }
            rval = getPredicate().compareTo(o.getPredicate());
            if (rval != 0) {
                return rval;
            }
            return getObject().compareTo(o.getObject());
        }
    }

    public static abstract class Node extends LinkedHashMap<String, Object>
            implements Comparable<Node> {
        private static final long serialVersionUID = 1460990331795672793L;

        public abstract boolean isLiteral();

        public abstract boolean isIRI();

        public abstract boolean isBlankNode();

        public String getValue() {
            return (String) get("value");
        }

        public String getDatatype() {
            return (String) get("datatype");
        }

        public String getLanguage() {
            return (String) get("language");
        }

        @Override
        public int compareTo(Node o) {
            if (o == null) {
                // valid nodes are > null nodes
                return 1;
            }
            if (this.isIRI()) {
                if (!o.isIRI()) {
                    // IRIs > everything
                    return 1;
                }
            } else if (this.isBlankNode()) {
                if (o.isIRI()) {
                    // IRI > blank node
                    return -1;
                } else if (o.isLiteral()) {
                    // blank node > literal
                    return 1;
                }
            } else if (this.isLiteral()) {
                if (o.isIRI() || o.isBlankNode()) {
                    return -1; // literals < blanknode < IRI
                }
            }
            // NOTE: Literal will also need to compare
            // language and datatype
            return this.getValue().compareTo(o.getValue());
        }

        /**
         * Converts an RDF triple object to a JSON-LD object.
         *
         * @param o
         *            the RDF triple object to convert.
         * @param useNativeTypes
         *            true to output native types, false not to.
         *
         * @return the JSON-LD object.
         * @throws JsonLdError
         */
        Map<String, Object> toObject(Boolean useNativeTypes) throws JsonLdError {
            // If value is an an IRI or a blank node identifier, return a new
            // JSON object consisting
            // of a single member @id whose value is set to value.
            if (isIRI() || isBlankNode()) {
                return newMap("@id", getValue());
            }

            // convert literal object to JSON-LD
            final Map<String, Object> rval = newMap("@value", getValue());

            // add language
            if (getLanguage() != null) {
                rval.put("@language", getLanguage());
            }
            // add datatype
            else {
                final String type = getDatatype();
                final String value = getValue();
                if (useNativeTypes) {
                    // use native datatypes for certain xsd types
                    if (XSD_STRING.equals(type)) {
                        // don't add xsd:string
                    } else if (XSD_BOOLEAN.equals(type)) {
                        if ("true".equals(value)) {
                            rval.put("@value", Boolean.TRUE);
                        } else if ("false".equals(value)) {
                            rval.put("@value", Boolean.FALSE);
                        } else {
                            // Else do not replace the value, and add the
                            // boolean type in
                            rval.put("@type", type);
                        }
                    } else if (
                    // http://www.w3.org/TR/xmlschema11-2/#integer
                    (XSD_INTEGER.equals(type) && PATTERN_INTEGER.matcher(value).matches())
                            // http://www.w3.org/TR/xmlschema11-2/#nt-doubleRep
                            || (XSD_DOUBLE.equals(type)
                                    && PATTERN_DOUBLE.matcher(value).matches())) {
                        try {
                            final Double d = Double.parseDouble(value);
                            if (!Double.isNaN(d) && !Double.isInfinite(d)) {
                                if (XSD_INTEGER.equals(type)) {
                                    final Integer i = d.intValue();
                                    if (i.toString().equals(value)) {
                                        rval.put("@value", i);
                                    }
                                } else if (XSD_DOUBLE.equals(type)) {
                                    rval.put("@value", d);
                                } else {
                                    throw new RuntimeException(
                                            "This should never happen as we checked the type was either integer or double");
                                }
                            }
                        } catch (final NumberFormatException e) {
                            // TODO: This should never happen since we match the
                            // value with regex!
                            throw new RuntimeException(e);
                        }
                    }
                    // do not add xsd:string type
                    else {
                        rval.put("@type", type);
                    }
                } else if (!XSD_STRING.equals(type)) {
                    rval.put("@type", type);
                }
            }
            return rval;
        }
    }

    public static class Literal extends Node {
        private static final long serialVersionUID = 8124736271571220251L;

        public Literal(String value, String datatype, String language) {
            super();
            put("type", "literal");
            put("value", value);
            put("datatype", datatype != null ? datatype : XSD_STRING);
            if (language != null) {
                put("language", language);
            }
        }

        @Override
        public boolean isLiteral() {
            return true;
        }

        @Override
        public boolean isIRI() {
            return false;
        }

        @Override
        public boolean isBlankNode() {
            return false;
        }

        private static int nullSafeCompare(String a, String b) {
            if (a == null && b == null) {
                return 0;
            }
            if (a == null) {
                return 1;
            }
            if (b == null) {
                return -1;
            }
            return a.compareTo(b);
        }

        @Override
        public int compareTo(Node o) {
            // NOTE: this will also compare getValue() early!
            final int nodeCompare = super.compareTo(o);
            if (nodeCompare != 0) {
                // null, different type or different value
                return nodeCompare;
            }
            if (this.getLanguage() != null || o.getLanguage() != null) {
                // We'll ignore type-checking if either has language tag
                // as language tagged literals should always have the type
                // rdf:langString in RDF 1.1
                return nullSafeCompare(this.getLanguage(), o.getLanguage());
            } else {
                return nullSafeCompare(this.getDatatype(), o.getDatatype());
            }
            // NOTE: getValue() already compared by super.compareTo()
        }
    }

    public static class IRI extends Node {
        private static final long serialVersionUID = 1540232072155490782L;

        public IRI(String iri) {
            super();
            put("type", "IRI");
            put("value", iri);
        }

        @Override
        public boolean isLiteral() {
            return false;
        }

        @Override
        public boolean isIRI() {
            return true;
        }

        @Override
        public boolean isBlankNode() {
            return false;
        }
    }

    public static class BlankNode extends Node {
        private static final long serialVersionUID = -2842402820440697318L;

        public BlankNode(String attribute) {
            super();
            put("type", "blank node");
            put("value", attribute);
        }

        @Override
        public boolean isLiteral() {
            return false;
        }

        @Override
        public boolean isIRI() {
            return false;
        }

        @Override
        public boolean isBlankNode() {
            return true;
        }
    }

    private static final Node first = new IRI(RDF_FIRST);
    private static final Node rest = new IRI(RDF_REST);
    private static final Node nil = new IRI(RDF_NIL);

    private final Map<String, String> context;

    // private UniqueNamer namer;
    private JsonLdApi api;

    public RDFDataset() {
        super();
        put("@default", new ArrayList<Quad>());
        context = new LinkedHashMap<String, String>();
        // put("@context", context);
    }

    /*
     * public RDFDataset(String blankNodePrefix) { this(new
     * UniqueNamer(blankNodePrefix)); }
     *
     * public RDFDataset(UniqueNamer namer) { this(); this.namer = namer; }
     */
    public RDFDataset(JsonLdApi jsonLdApi) {
        this();
        this.api = jsonLdApi;
    }

    public void setNamespace(String ns, String prefix) {
        context.put(ns, prefix);
    }

    public String getNamespace(String ns) {
        return context.get(ns);
    }

    /**
     * clears all the namespaces in this dataset
     */
    public void clearNamespaces() {
        context.clear();
    }

    public Map<String, String> getNamespaces() {
        return context;
    }

    /**
     * Returns a valid context containing any namespaces set
     *
     * @return The context map
     */
    public Map<String, Object> getContext() {
        final Map<String, Object> rval = newMap();
        rval.putAll(context);
        // replace "" with "@vocab"
        if (rval.containsKey("")) {
            rval.put("@vocab", rval.remove(""));
        }
        return rval;
    }

    /**
     * parses a context object and sets any namespaces found within it
     *
     * @param contextLike
     *            The context to parse
     * @throws JsonLdError
     *             If the context can't be parsed
     */
    public void parseContext(Object contextLike) throws JsonLdError {
        Context context;
        if (api != null) {
            context = new Context(api.opts);
        } else {
            context = new Context();
        }
        // Context will do our recursive parsing and initial IRI resolution
        context = context.parse(contextLike);
        // And then leak to us the potential 'prefixes'
        final Map<String, String> prefixes = context.getPrefixes(true);

        for (final String key : prefixes.keySet()) {
            final String val = prefixes.get(key);
            if ("@vocab".equals(key)) {
                if (val == null || isString(val)) {
                    setNamespace("", val);
                } else {
                }
            } else if (!isKeyword(key)) {
                setNamespace(key, val);
                // TODO: should we make sure val is a valid URI prefix (i.e. it
                // ends with /# or ?)
                // or is it ok that full URIs for terms are used?
            }
        }
    }

    /**
     * Adds a triple to the @default graph of this dataset
     *
     * @param subject
     *            the subject for the triple
     * @param predicate
     *            the predicate for the triple
     * @param value
     *            the value of the literal object for the triple
     * @param datatype
     *            the datatype of the literal object for the triple (null values
     *            will default to xsd:string)
     * @param language
     *            the language of the literal object for the triple (or null)
     */
    public void addTriple(final String subject, final String predicate, final String value,
            final String datatype, final String language) {
        addQuad(subject, predicate, value, datatype, language, "@default");
    }

    /**
     * Adds a triple to the specified graph of this dataset
     *
     * @param s
     *            the subject for the triple
     * @param p
     *            the predicate for the triple
     * @param value
     *            the value of the literal object for the triple
     * @param datatype
     *            the datatype of the literal object for the triple (null values
     *            will default to xsd:string)
     * @param graph
     *            the graph to add this triple to
     * @param language
     *            the language of the literal object for the triple (or null)
     */
    public void addQuad(final String s, final String p, final String value, final String datatype,
            final String language, String graph) {
        if (graph == null) {
            graph = "@default";
        }
        if (!containsKey(graph)) {
            put(graph, new ArrayList<Quad>());
        }
        ((ArrayList<Quad>) get(graph)).add(new Quad(s, p, value, datatype, language, graph));
    }

    /**
     * Adds a triple to the default graph of this dataset
     *
     * @param subject
     *            the subject for the triple
     * @param predicate
     *            the predicate for the triple
     * @param object
     *            the object for the triple
     */
    public void addTriple(final String subject, final String predicate, final String object) {
        addQuad(subject, predicate, object, "@default");
    }

    /**
     * Adds a triple to the specified graph of this dataset
     *
     * @param subject
     *            the subject for the triple
     * @param predicate
     *            the predicate for the triple
     * @param object
     *            the object for the triple
     * @param graph
     *            the graph to add this triple to
     */
    public void addQuad(final String subject, final String predicate, final String object,
            String graph) {
        if (graph == null) {
            graph = "@default";
        }
        if (!containsKey(graph)) {
            put(graph, new ArrayList<Quad>());
        }
        ((ArrayList<Quad>) get(graph)).add(new Quad(subject, predicate, object, graph));
    }

    /**
     * Creates an array of RDF triples for the given graph.
     *
     * @param graphName
     *            The graph URI
     * @param graph
     *            the graph to create RDF triples for.
     */
    void graphToRDF(String graphName, Map<String, Object> graph) {
        // 4.2)
        final List<Quad> triples = new ArrayList<Quad>();
        // 4.3)
        final List<String> subjects = new ArrayList<String>(graph.keySet());
        // Collections.sort(subjects);
        for (final String id : subjects) {
            if (JsonLdUtils.isRelativeIri(id)) {
                continue;
            }
            final Map<String, Object> node = (Map<String, Object>) graph.get(id);
            final List<String> properties = new ArrayList<String>(node.keySet());
            Collections.sort(properties);
            for (String property : properties) {
                final List<Object> values;
                // 4.3.2.1)
                if ("@type".equals(property)) {
                    values = (List<Object>) node.get("@type");
                    property = RDF_TYPE;
                }
                // 4.3.2.2)
                else if (isKeyword(property)) {
                    continue;
                }
                // 4.3.2.3)
                else if (property.startsWith("_:") && !api.opts.getProduceGeneralizedRdf()) {
                    continue;
                }
                // 4.3.2.4)
                else if (JsonLdUtils.isRelativeIri(property)) {
                    continue;
                } else {
                    values = (List<Object>) node.get(property);
                }

                Node subject;
                if (id.indexOf("_:") == 0) {
                    // NOTE: don't rename, just set it as a blank node
                    subject = new BlankNode(id);
                } else {
                    subject = new IRI(id);
                }

                // RDF predicates
                Node predicate;
                if (property.startsWith("_:")) {
                    predicate = new BlankNode(property);
                } else {
                    predicate = new IRI(property);
                }

                for (final Object item : values) {
                    // convert @list to triples
                    if (isList(item)) {
                        final List<Object> list = (List<Object>) ((Map<String, Object>) item)
                                .get("@list");
                        Node last = null;
                        Node firstBNode = nil;
                        if (!list.isEmpty()) {
                            last = objectToRDF(list.get(list.size() - 1));
                            firstBNode = new BlankNode(api.generateBlankNodeIdentifier());
                        }
                        triples.add(new Quad(subject, predicate, firstBNode, graphName));
                        for (int i = 0; i < list.size() - 1; i++) {
                            final Node object = objectToRDF(list.get(i));
                            triples.add(new Quad(firstBNode, first, object, graphName));
                            final Node restBNode = new BlankNode(api.generateBlankNodeIdentifier());
                            triples.add(new Quad(firstBNode, rest, restBNode, graphName));
                            firstBNode = restBNode;
                        }
                        if (last != null) {
                            triples.add(new Quad(firstBNode, first, last, graphName));
                            triples.add(new Quad(firstBNode, rest, nil, graphName));
                        }
                    }
                    // convert value or node object to triple
                    else {
                        final Node object = objectToRDF(item);
                        if (object != null) {
                            triples.add(new Quad(subject, predicate, object, graphName));
                        }
                    }
                }
            }
        }
        put(graphName, triples);
    }

    /**
     * Converts a JSON-LD value object to an RDF literal or a JSON-LD string or
     * node object to an RDF resource.
     *
     * @param item
     *            the JSON-LD value or node object.
     * @return the RDF literal or RDF resource.
     */
    private Node objectToRDF(Object item) {
        // convert value object to RDF
        if (isValue(item)) {
            final Object value = ((Map<String, Object>) item).get("@value");
            final Object datatype = ((Map<String, Object>) item).get("@type");

            // convert to XSD datatypes as appropriate
            if (value instanceof Boolean || value instanceof Number) {
                // convert to XSD datatype
                if (value instanceof Boolean) {
                    return new Literal(value.toString(),
                            datatype == null ? XSD_BOOLEAN : (String) datatype, null);
                } else if (value instanceof Double || value instanceof Float
                        || XSD_DOUBLE.equals(datatype)) {
                    if (value instanceof Double && !Double.isFinite((double) value)) {
                        return new Literal(Double.toString((double) value),
                                datatype == null ? XSD_DOUBLE : (String) datatype, null);
                    } else if (value instanceof Float && !Float.isFinite((float) value)) {
                        return new Literal(Float.toString((float) value),
                                datatype == null ? XSD_DOUBLE : (String) datatype, null);
                    } else {
                        // Only canonicalize representation if datatype is not XSD_DECIMAL
                        if (XSD_DECIMAL.equals(datatype)) {
                            return new Literal(value.toString(), XSD_DECIMAL, null);
                        }
                        final DecimalFormat df = new DecimalFormat("0.0###############E0");
                        df.setDecimalFormatSymbols(DecimalFormatSymbols.getInstance(Locale.US));
                        return new Literal(df.format(value),
                                datatype == null ? XSD_DOUBLE : (String) datatype, null);
                    }
                } else {
                    final DecimalFormat df = new DecimalFormat("0");
                    return new Literal(df.format(value),
                            datatype == null ? XSD_INTEGER : (String) datatype, null);
                }
            } else if (((Map<String, Object>) item).containsKey("@language")) {
                return new Literal((String) value,
                        datatype == null ? RDF_LANGSTRING : (String) datatype,
                        (String) ((Map<String, Object>) item).get("@language"));
            } else {
                return new Literal((String) value,
                        datatype == null ? XSD_STRING : (String) datatype, null);
            }
        }
        // convert string/node object to RDF
        else {
            final String id;
            if (isObject(item)) {
                id = (String) ((Map<String, Object>) item).get("@id");
                if (JsonLdUtils.isRelativeIri(id)) {
                    return null;
                }
            } else {
                id = (String) item;
            }
            if (id.indexOf("_:") == 0) {
                // NOTE: once again no need to rename existing blank nodes
                return new BlankNode(id);
            } else {
                return new IRI(id);
            }
        }
    }

    public Set<String> graphNames() {
        // TODO Auto-generated method stub
        return keySet();
    }

    public List<Quad> getQuads(String graphName) {
        return (List<Quad>) get(graphName);
    }
}
