/*
 *  Copyright 2016 SteelBridge Laboratories, LLC.
 *
 *  Licensed 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.
 *
 *  For more information: http://steelbridgelabs.com
 */

package com.steelbridgelabs.oss.neo4j.structure;

import org.apache.tinkerpop.gremlin.structure.Direction;
import org.apache.tinkerpop.gremlin.structure.Edge;
import org.apache.tinkerpop.gremlin.structure.Element;
import org.apache.tinkerpop.gremlin.structure.Graph;
import org.apache.tinkerpop.gremlin.structure.Property;
import org.apache.tinkerpop.gremlin.structure.Vertex;
import org.apache.tinkerpop.gremlin.structure.util.ElementHelper;
import org.apache.tinkerpop.gremlin.structure.util.StringFactory;
import org.neo4j.driver.Record;
import org.neo4j.driver.Value;
import org.neo4j.driver.types.Relationship;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

/**
 * @author Rogelio J. Baucells
 */
public class Neo4JEdge extends Neo4JElement implements Edge {

    private static class Neo4JEdgeProperty<T> implements Property<T> {

        private final Neo4JEdge edge;
        private final String name;
        private final T value;

        Neo4JEdgeProperty(Neo4JEdge edge, String name, T value) {
            Objects.requireNonNull(edge, "edge cannot be null");
            Objects.requireNonNull(name, "name cannot be null");
            Objects.requireNonNull(value, "value cannot be null");
            // store fields
            this.edge = edge;
            this.name = name;
            this.value = value;
        }

        @Override
        public String key() {
            return name;
        }

        @Override
        public T value() throws NoSuchElementException {
            return value;
        }

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

        @Override
        public Element element() {
            return edge;
        }

        @Override
        public void remove() {
            // remove from edge
            edge.properties.remove(name);
            // mark property as removed
            edge.removedProperties.add(name);
            // mark edge as dirty
            edge.dirty = true;
            // notify session
            edge.session.dirtyEdge(edge);
        }

        @Override
        public boolean equals(final Object object) {
            return object instanceof Property && ElementHelper.areEqual(this, object);
        }

        @Override
        public int hashCode() {
            return ElementHelper.hashCode(this);
        }

        @Override
        public String toString() {
            return StringFactory.propertyString(this);
        }
    }

    private final Object id;
    private final Neo4JGraph graph;
    private final Neo4JSession session;
    private final Neo4JElementIdProvider<?> edgeIdProvider;
    private final Map<String, Neo4JEdgeProperty> properties = new HashMap<>();
    private final String label;
    private final Neo4JVertex out;
    private final Neo4JVertex in;

    private Object generatedId = null;
    private boolean dirty = false;
    private boolean newEdge;
    private Set<String> removedProperties = new HashSet<>();
    private Map<String, Neo4JEdgeProperty> originalProperties;

    Neo4JEdge(Neo4JGraph graph, Neo4JSession session, Neo4JElementIdProvider<?> edgeIdProvider, String label, Neo4JVertex out, Neo4JVertex in) {
        Objects.requireNonNull(graph, "graph cannot be null");
        Objects.requireNonNull(session, "session cannot be null");
        Objects.requireNonNull(edgeIdProvider, "edgeIdProvider cannot be null");
        Objects.requireNonNull(label, "label cannot be null");
        Objects.requireNonNull(properties, "properties cannot be null");
        Objects.requireNonNull(out, "out cannot be null");
        Objects.requireNonNull(in, "in cannot be null");
        // store fields
        this.graph = graph;
        this.session = session;
        this.edgeIdProvider = edgeIdProvider;
        this.label = label;
        this.out = out;
        this.in = in;
        // generate id
        this.id = edgeIdProvider.generate();
        // initialize original properties
        originalProperties = new HashMap<>();
        // this is a new edge (transient)
        newEdge = true;
    }

    Neo4JEdge(Neo4JGraph graph, Neo4JSession session, Neo4JElementIdProvider<?> edgeIdProvider, Neo4JVertex out, Relationship relationship, Neo4JVertex in) {
        Objects.requireNonNull(graph, "graph cannot be null");
        Objects.requireNonNull(session, "session cannot be null");
        Objects.requireNonNull(edgeIdProvider, "edgeIdProvider cannot be null");
        Objects.requireNonNull(out, "out cannot be null");
        Objects.requireNonNull(relationship, "relationship cannot be null");
        Objects.requireNonNull(in, "in cannot be null");
        // store fields
        this.graph = graph;
        this.session = session;
        this.edgeIdProvider = edgeIdProvider;
        // from relationship
        this.id = edgeIdProvider.get(relationship);
        this.label = relationship.type();
        // id field name (if any)
        String idFieldName = edgeIdProvider.fieldName();
        // copy properties from relationship, remove idFieldName from map
        StreamSupport.stream(relationship.keys().spliterator(), false).filter(key -> !key.equals(idFieldName)).forEach(key -> {
            // value
            Value value = relationship.get(key);
            // add property value
            properties.put(key, new Neo4JEdgeProperty<>(this, key, value.asObject()));
        });
        // vertices
        this.out = out;
        this.in = in;
        // initialize original properties
        originalProperties = new HashMap<>(properties);
        // this is a persisted edge
        newEdge = false;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Iterator<Vertex> vertices(Direction direction) {
        // transaction should be ready for io operations
        graph.tx().readWrite();
        // out direction
        if (direction == Direction.OUT)
            return Stream.of((Vertex)out).iterator();
        // in direction
        if (direction == Direction.IN)
            return Stream.of((Vertex)in).iterator();
        // both
        return Stream.of((Vertex)out, in).iterator();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Object id() {
        return id != null ? id : generatedId;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String label() {
        return label;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Graph graph() {
        return graph;
    }

    @Override
    public boolean isDirty() {
        return dirty;
    }

    @Override
    public boolean isTransient() {
        return newEdge;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public <V> Property<V> property(String name, V value) {
        ElementHelper.validateProperty(name, value);
        // validate bolt support
        Neo4JBoltSupport.checkPropertyValue(value);
        // transaction should be ready for io operations
        graph.tx().readWrite();
        // property value for key
        Neo4JEdgeProperty<V> propertyValue = new Neo4JEdgeProperty<>(this, name, value);
        // update map
        properties.put(name, propertyValue);
        // set edge as dirty
        session.dirtyEdge(this);
        // update flag
        dirty = true;
        // return property
        return propertyValue;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @SuppressWarnings("unchecked")
    public <V> Property<V> property(String key) {
        Objects.requireNonNull(key, "key cannot be null");
        // property value
        Neo4JEdgeProperty propertyValue = properties.get(key);
        if (propertyValue != null)
            return (Property<V>)propertyValue;
        // empty property
        return Property.empty();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void remove() {
        // transaction should be ready for io operations
        graph.tx().readWrite();
        // remove edge on session
        session.removeEdge(this, true);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @SuppressWarnings("unchecked")
    public <V> Iterator<Property<V>> properties(String... propertyKeys) {
        Objects.requireNonNull(propertyKeys, "propertyKeys cannot be null");
        // check filter is a single property
        if (propertyKeys.length == 1) {
            // property value
            Property<V> propertyValue = properties.get(propertyKeys[0]);
            if (propertyValue != null) {
                // return iterator
                return Collections.singleton(propertyValue).iterator();
            }
            return Collections.emptyIterator();
        }
        // no properties in filter
        if (propertyKeys.length == 0) {
            // all properties (return a copy since properties iterator can be modified by calling remove())
            return properties.values().stream()
                .map(value -> (Property<V>)value)
                .collect(Collectors.toList())
                .iterator();
        }
        // filter properties (return a copy since properties iterator can be modified by calling remove())
        return Arrays.stream(propertyKeys)
            .map(key -> (Property<V>)properties.get(key))
            .filter(Objects::nonNull)
            .collect(Collectors.toList())
            .iterator();
    }

    private Map<String, Object> statementParameters() {
        // process properties
        Map<String, Object> parameters = properties.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().value()));
        // removed properties
        removedProperties.forEach(name -> parameters.put(name, null));
        // append id field if required
        String idFieldName = edgeIdProvider.fieldName();
        if (id != null && idFieldName != null)
            parameters.put(idFieldName, id);
        // return parameters
        return parameters;
    }

    @Override
    public Neo4JDatabaseCommand insertCommand() {
        // parameters
        Map<String, Object> parameters = new HashMap<>();
        parameters.put("oid", out.id());
        parameters.put("iid", in.id());
        parameters.put("ep", statementParameters());
        // check database side id generation is required
        if (id == null) {
            // create statement
            String statement = out.matchStatement("o", "oid") + " " + in.matchStatement("i", "iid") + " CREATE (o)-[r:`" + label + "`$ep]->(i) RETURN " + edgeIdProvider.matchPredicateOperand("r");
            // command statement
            return new Neo4JDatabaseCommand(statement, parameters, result -> {
                // check we received data
                if (result.hasNext()) {
                    // record
                    Record record = result.next();
                    // process node identifier
                    generatedId = edgeIdProvider.processIdentifier(record.get(0).asObject());
                }
            });
        }
        // create statement
        String statement = out.matchStatement("o", "oid") + " " + in.matchStatement("i", "iid") + " CREATE (o)-[:`" + label + "`$ep]->(i)";
        // command statement
        return new Neo4JDatabaseCommand(statement, parameters);
    }

    @Override
    public Neo4JDatabaseCommand updateCommand() {
        // check edge is dirty
        if (dirty) {
            // update statement
            String statement = out.matchStatement("o", "oid") + " " + in.matchStatement("i", "iid") + " MATCH (o)-[r:`" + label + "`]->(i)" + " WHERE " + edgeIdProvider.matchPredicateOperand("r") + " = $id SET r = $rp";
            // parameters
            Map<String, Object> parameters = new HashMap<>();
            parameters.put("oid", out.id());
            parameters.put("iid", in.id());
            parameters.put("id", id());
            parameters.put("rp", statementParameters());
            // command statement
            return new Neo4JDatabaseCommand(statement, parameters, result -> {
            });
        }
        return null;
    }

    @Override
    public Neo4JDatabaseCommand deleteCommand() {
        // delete statement
        String statement = out.matchStatement("o", "oid") + " " + in.matchStatement("i", "iid") + " MATCH (o)-[r:`" + label + "`]->(i)" + " WHERE " + edgeIdProvider.matchPredicateOperand("r") + " = $id DELETE r";
        // parameters
        Map<String, Object> parameters = new HashMap<>();
        parameters.put("oid", out.id());
        parameters.put("iid", in.id());
        parameters.put("id", id());
        // command statement
        return new Neo4JDatabaseCommand(statement, parameters, result -> {
        });
    }

    void commit() {
        // commit property values
        originalProperties = new HashMap<>(properties);
        // reset removed properties
        removedProperties.clear();
        // reset flags
        dirty = false;
        // this is no longer a transient edge
        newEdge = false;
    }

    void rollback() {
        // restore edge references
        out.addOutEdge(this);
        in.addInEdge(this);
        // restore property values
        properties.clear();
        properties.putAll(originalProperties);
        // reset removed properties
        removedProperties.clear();
        // reset flags
        dirty = false;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean equals(final Object object) {
        // ElementHelper.areEqual is implemented on this.id(), handle the case of generated ids
        return object instanceof Edge && (id != null ? ElementHelper.areEqual(this, object) : super.equals(object));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public int hashCode() {
        // ElementHelper.hashCode() is implemented on this.id(), handle the case of generated ids
        return id != null ? ElementHelper.hashCode(this) : super.hashCode();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {
        return StringFactory.edgeString(this);
    }
}