/*
 *  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.providers;

import com.steelbridgelabs.oss.neo4j.structure.Neo4JElementIdProvider;
import org.neo4j.driver.Driver;
import org.neo4j.driver.Record;
import org.neo4j.driver.Result;
import org.neo4j.driver.Session;
import org.neo4j.driver.Transaction;
import org.neo4j.driver.types.Entity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Collections;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;

/**
 * {@link Neo4JElementIdProvider} implementation based on a sequence generator stored in a Neo4J database Node.
 */
public class DatabaseSequenceElementIdProvider implements Neo4JElementIdProvider<Long> {

    private static final Logger logger = LoggerFactory.getLogger(DatabaseSequenceElementIdProvider.class);

    public static final String DefaultIdFieldName = "id";
    public static final String DefaultSequenceNodeLabel = "UniqueIdentifierGenerator";
    public static final long DefaultPoolSize = 1000;

    private final Driver driver;
    private final String idFieldName;
    private final String sequenceNodeLabel;
    private final long poolSize;
    private final AtomicLong atomicLong = new AtomicLong(0L);
    private final Object monitor = new Object();

    private AtomicLong maximum = new AtomicLong(0L);

    public DatabaseSequenceElementIdProvider(Driver driver) {
        Objects.requireNonNull(driver, "driver cannot be null");
        // initialize fields
        this.driver = driver;
        this.poolSize = DefaultPoolSize;
        this.idFieldName = DefaultIdFieldName;
        this.sequenceNodeLabel = DefaultSequenceNodeLabel;
    }

    public DatabaseSequenceElementIdProvider(Driver driver, long poolSize, String idFieldName, String sequenceNodeLabel) {
        Objects.requireNonNull(driver, "driver cannot be null");
        Objects.requireNonNull(idFieldName, "idFieldName cannot be null");
        Objects.requireNonNull(sequenceNodeLabel, "sequenceNodeLabel cannot be null");
        // initialize fields
        this.driver = driver;
        this.poolSize = poolSize;
        this.idFieldName = idFieldName;
        this.sequenceNodeLabel = sequenceNodeLabel;
    }

    /**
     * Gets the field name used for {@link Entity} identifier.
     *
     * @return The field name used for {@link Entity} identifier or <code>null</code> if not using field for identifier.
     */
    @Override
    public String fieldName() {
        return idFieldName;
    }

    /**
     * Gets the identifier value from a neo4j {@link Entity}.
     *
     * @param entity The neo4j {@link Entity}.
     * @return The neo4j {@link Entity} identifier.
     */
    @Override
    public Long get(Entity entity) {
        Objects.requireNonNull(entity, "entity cannot be null");
        // return property value
        return entity.get(idFieldName).asLong();
    }

    /**
     * Generates a new identifier value. This {@link Neo4JElementIdProvider} will fetch a pool of identifiers
     * from a Neo4J database Node.
     *
     * @return A unique identifier within the database sequence generator.
     */
    @Override
    public Long generate() {
        // get maximum identifier we can use (before obtaining new identifier to make sure it is in the current pool)
        long max = maximum.get();
        // generate new identifier
        long identifier = atomicLong.incrementAndGet();
        // check we need to obtain new identifier pool (identifier is out of range for current pool)
        if (identifier > max) {
            // loop until we get an identifier value
            do {
                // log information
                logger.debug("About to request a pool of identifiers from database, maximum id: {}", max);
                // make sure only one thread gets a new range of identifiers
                synchronized (monitor) {
                    // update maximum number in pool, do not switch the next two statements (in case another thread was executing the synchronized block while the current thread was waiting)
                    max = maximum.get();
                    identifier = atomicLong.incrementAndGet();
                    // verify a new identifier is needed (compare it with current maximum)
                    if (identifier >= max) {
                        // create database session
                        try (Session session = driver.session()) {
                            // create transaction
                            try (Transaction transaction = session.beginTransaction()) {
                                // execute statement
                                Result result = transaction.run("MERGE (g:`" + sequenceNodeLabel + "`) ON CREATE SET g.nextId = 1 ON MATCH SET g.nextId = g.nextId + $poolSize RETURN g.nextId", Collections.singletonMap("poolSize", poolSize));
                                // process result
                                if (result.hasNext()) {
                                    // get record
                                    Record record = result.next();
                                    // get nextId value
                                    long nextId = record.get(0).asLong();
                                    // set value for next identifier (do not switch the next two statements!)
                                    atomicLong.set(nextId - poolSize);
                                    maximum.set(nextId);
                                }
                                // commit
                                transaction.commit();
                            }
                        }
                        // update maximum number in pool
                        max = maximum.get();
                        // get a new identifier
                        identifier = atomicLong.incrementAndGet();
                        // log information
                        if (logger.isDebugEnabled())
                            logger.debug("Requested new pool of identifiers from database, current id: {}, maximum id: {}", identifier, max);
                    }
                    else if (logger.isDebugEnabled())
                        logger.debug("No need to request pool of identifiers, current id: {}, maximum id: {}", identifier, max);
                }
            }
            while (identifier > max);
        }
        else if (logger.isDebugEnabled())
            logger.debug("Current identifier: {}", identifier);
        // return identifier
        return identifier;
    }

    /**
     * Process the given identifier converting it to the correct type if necessary.
     *
     * @param id The {@link org.apache.tinkerpop.gremlin.structure.Element} identifier.
     * @return The {@link org.apache.tinkerpop.gremlin.structure.Element} identifier converted to the correct type if necessary.
     */
    @Override
    public Long processIdentifier(Object id) {
        Objects.requireNonNull(id, "Element identifier cannot be null");
        // check for Long
        if (id instanceof Long)
            return (Long)id;
        // check for numeric types
        if (id instanceof Number)
            return ((Number)id).longValue();
        // check for string
        if (id instanceof String)
            return Long.valueOf((String)id);
        // error
        throw new IllegalArgumentException(String.format("Expected an id that is convertible to Long but received %s", id.getClass()));
    }

    /**
     * Gets the MATCH WHERE predicate operand.
     *
     * @param alias The neo4j {@link Entity} alias in a MATCH statement.
     * @return The MATCH WHERE predicate operand.
     */
    @Override
    public String matchPredicateOperand(String alias) {
        Objects.requireNonNull(alias, "alias cannot be null");
        // alias.identifier
        return alias + "." + idFieldName;
    }
}