/*
 * Copyright 2018 Scytl Secure Electronic Voting SA
 *
 * All rights reserved
 *
 * See our extended copyright notice in *file 'Copyright.txt' which is part of this source code package
 */
package com.scytl.products.ov.sdm.infrastructure;

import com.orientechnologies.common.exception.OException;
import com.orientechnologies.orient.core.db.document.ODatabaseDocument;
import com.orientechnologies.orient.core.metadata.schema.OClass;
import com.orientechnologies.orient.core.metadata.schema.OSchema;
import com.orientechnologies.orient.core.metadata.schema.OType;
import com.orientechnologies.orient.core.record.impl.ODocument;
import com.orientechnologies.orient.core.sql.OCommandSQL;
import com.orientechnologies.orient.core.sql.query.OSQLSynchQuery;

import javax.json.Json;
import javax.json.JsonArrayBuilder;
import javax.json.JsonObjectBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.StringJoiner;

import com.scytl.products.ov.sdm.application.exception.DatabaseException;
import com.scytl.products.ov.sdm.domain.model.EntityRepository;
import static com.scytl.products.ov.sdm.infrastructure.JsonConstants.JSON_ATTRIBUTE_NAME_ID;
import static com.scytl.products.ov.sdm.infrastructure.JsonConstants.JSON_ATTRIBUTE_NAME_RESULT;
import static com.scytl.products.ov.sdm.infrastructure.JsonConstants.JSON_EMPTY_OBJECT;
import static java.text.MessageFormat.format;
import static java.util.Collections.emptyMap;
import static java.util.Collections.singletonMap;
import static java.util.Objects.requireNonNull;

/**
 * Basic abstract implementation of {@link EntityRepository}.
 * <p>
 * Subclasses can use the following methods for manipulating {@link ODocument}
 * <ul>
 * <li>{@link #newDocument()}. Creates new empty {@link ODocument}.</li>
 * <li>{@link #newDocument(String)}. Creates new {@link ODocument} from given
 * JSON.</li>
 * <li>{@link #saveDocument(ODocument)}. Saves a given {@link ODocument} in the
 * database.</li>
 * <li>{@link #selectDocuments(String, Map, int)}. Runs an arbitrary SELECT
 * query.</li>
 * <li>{@link #findDocument(Map)}. Finds a {@link ODocument} matching a given
 * criteria.</li>
 * <li>{@link #listDocuments(Map)}. Lists {@link ODocument} matching a given
 * criteria.</li>
 * <li>{@link #deleteDocuments(String, Map)}. Runs an arbitrary delete query.
 * </li>
 * </ul>
 * For more low level manipulation of {@link ODocument} subclasses can use
 * {@link ODatabaseDocument} returned by {@link #openDatabase()} method.
 * <p>
 * Instances of concrete subclasses must be initialized using
 * {@link #initialize()} method before they are published for general access.
 */
public abstract class AbstractEntityRepository
        implements EntityRepository {

    private final DatabaseManager manager;

    /**
     * Constructor.
     *
     * @param manager
     */
    public AbstractEntityRepository(final DatabaseManager manager) {
        this.manager = manager;
    }

    @Override
    public final void delete(final Map<String, Object> criteria)
            throws DatabaseException {
        Map<String, Object> parameters = new HashMap<>();
        String sql = buildDeleteSQL(criteria, parameters);
        try {
            deleteDocuments(sql, parameters);
        } catch (OException e) {
            throw new DatabaseException("Failed to delete.", e);
        }
    }

    @Override
    public final void delete(final String id) throws DatabaseException {
        delete(singletonMap(JSON_ATTRIBUTE_NAME_ID, id));
    }

    @Override
    public final String find(final Map<String, Object> criteria)
            throws DatabaseException {
        ODocument document;
        try {
            document = findDocument(criteria);
        } catch (OException e) {
            throw new DatabaseException("Failed to find.", e);
        }
        return document != null ? document.toJSON("") : JSON_EMPTY_OBJECT;
    }

    @Override
    public final String find(final String id) throws DatabaseException {
        return find(singletonMap(JSON_ATTRIBUTE_NAME_ID, id));
    }

    /**
     * Initializes the repository. This method must be called before the
     * repository instance is published.
     *
     * @throws DatabaseException
     *             failed to initialize the repository.
     */
    public void initialize() throws DatabaseException {
        try (ODatabaseDocument database = openDatabase()) {
            OSchema schema = database.getMetadata().getSchema();
            if (!schema.existsClass(entityName())) {
                OClass entity = schema.createClass(entityName());
                if (!entity.existsProperty(JSON_ATTRIBUTE_NAME_ID)) {
                    entity
                        .createProperty(JSON_ATTRIBUTE_NAME_ID,
                            OType.STRING)
                        .setMandatory(true).setNotNull(true);
                }
                if (!entity.areIndexed(JSON_ATTRIBUTE_NAME_ID)) {
                    entity.createIndex(entityName() + "_Unique",
                        OClass.INDEX_TYPE.UNIQUE, JSON_ATTRIBUTE_NAME_ID);
                }
            }
        } catch (OException e) {
            throw new DatabaseException("Failed to initialize.", e);
        }
    }

    @Override
    public final String list() {
        return list(emptyMap());
    }

    @Override
    public final String list(final Map<String, Object> criteria)
            throws DatabaseException {
        List<ODocument> documents;
        try {
            documents = listDocuments(criteria);
        } catch (OException e) {
            throw new DatabaseException("Failed to list.", e);
        }
        String prefix = "{\"" + JSON_ATTRIBUTE_NAME_RESULT + "\":[";
        String suffix = "]}";
        StringJoiner joiner = new StringJoiner(",", prefix, suffix);
        for (ODocument document : documents) {
            joiner.add(document.toJSON(""));
        }
        return joiner.toString();
    }

    @Override
    public final String save(final String json) throws DatabaseException {
        try {
            saveDocument(newDocument(json));
        } catch (OException e) {
            throw new DatabaseException("Failed to save.", e);
        }
        return json;
    }

    @Override
    public final String update(final String json) {
        ODocument newDocument = newDocument(json);
        String id =
            newDocument.field(JSON_ATTRIBUTE_NAME_ID, String.class);
        String[] dirtyFields;
        try {
            ODocument oldDocument =
                findDocument(singletonMap(JSON_ATTRIBUTE_NAME_ID, id));
            if (oldDocument == null) {
                throw new DatabaseException(
                    format("Entity ''{0}'' does not exist.", id));
            }
            oldDocument.merge(newDocument, true, true);
            dirtyFields = oldDocument.getDirtyFields();
            saveDocument(oldDocument);
        } catch (OException e) {
            throw new DatabaseException("Failed to update.", e);
        }
        JsonArrayBuilder arrayBuilder = Json.createArrayBuilder();
        for (String field : dirtyFields) {
            arrayBuilder.add(field);
        }
        JsonObjectBuilder builder = Json.createObjectBuilder();
        builder.add(JSON_ATTRIBUTE_NAME_RESULT, arrayBuilder.build());
        return builder.build().toString();
    }

    /**
     * Deletes the documents specified by given DELETE SQL and parameters.
     *
     * @param sql
     *            the SQL
     * @param parameters
     *            the parameters
     * @throws OException
     *             failed to delete documents.
     */
    protected final void deleteDocuments(final String sql,
            final Map<String, Object> parameters) {
        try (ODatabaseDocument database = openDatabase()) {
            OCommandSQL command = new OCommandSQL(sql);
            database.command(command).execute(parameters);
        }
    }

    /**
     * Returns the entity name.
     *
     * @return the entity name.
     */
    protected abstract String entityName();

    /**
     * Finds a document matching the specified criteria.
     *
     * @param criteria
     *            the criteria
     * @return the document or {@code null} if the document does not exist
     * @throws OException
     *             failed to find the document.
     */
    protected final ODocument findDocument(
            final Map<String, Object> criteria) throws OException {
        Map<String, Object> parameters = new HashMap<>();
        String sql = buildSelectSQL(criteria, parameters);
        List<ODocument> documents = selectDocuments(sql, parameters, 1);
        return documents.isEmpty() ? null : documents.get(0);
    }

    /**
     * Finds the document with the specified identifier. This is a shortcut for
     * {@code find(Collections.singletonMap("id", id))}.
     *
     * @param id
     *            the identifier
     * @return the document or {@code null} if the document does not exist.
     * @throws OException
     *             failed to find the document.
     */
    protected final ODocument findDocument(final String id)
            throws OException {
        return findDocument(singletonMap(JSON_ATTRIBUTE_NAME_ID, id));
    }

    /**
     * Returns the document with the specified identifier.
     *
     * @param id
     *            the identifier
     * @return the document
     * @throws OException
     *             the document does not exist or error occurred.
     */
    protected final ODocument getDocument(final String id)
            throws OException {
        ODocument document = findDocument(id);
        if (document == null) {
            throw new OException(
                format("Document ''{0}'' does not exist.", id));
        }
        return document;
    }

    /**
     * Lists the documents matching the specified criteria
     *
     * @param criteria
     *            the criteria
     * @return the documents
     * @throws OException
     *             failed to list the documents
     */
    protected final List<ODocument> listDocuments(
            final Map<String, Object> criteria) throws OException {
        Map<String, Object> parameters = new HashMap<>();
        String sql = buildSelectSQL(criteria, parameters);
        return selectDocuments(sql, parameters, -1);
    }

    /**
     * Creates a new empty document.
     *
     * @return the document.
     */
    protected final ODocument newDocument() {
        return new ODocument(entityName());
    }

    /**
     * Creates a new document from a given JSON.
     *
     * @return the document.
     */
    protected final ODocument newDocument(final String json) {
        requireNonNull(json, "JSON is null");
        ODocument document = newDocument();
        document.fromJSON(json);
        return document;
    }

    /**
     * Opens the database. Caller is responsible for closing the returned
     * database.
     *
     * @return the database
     * @throws OException
     *             failed to open the database.
     */
    protected final ODatabaseDocument openDatabase() throws OException {
        return manager.openDatabase();
    }

    /**
     * Saves a given document.
     *
     * @param document
     *            the document
     * @throws OException
     *             failed to save the document.
     */
    protected final void saveDocument(final ODocument document)
            throws OException {
        requireNonNull(document, "Document is null.");
        try (ODatabaseDocument database = openDatabase()) {
            database.save(document);
        }
    }

    /**
     * Selects documents specified by given SELECT SQL, parameters and limit.
     *
     * @param sql
     *            the SQL
     * @param parameters
     *            the parameters
     * @param limit
     *            the limit or {@code -1} to retrieve all
     * @return the documents
     * @throws OException
     *             failed to select the documents.
     */
    protected final List<ODocument> selectDocuments(final String sql,
            final Map<String, Object> parameters, final int limit)
            throws OException {
        List<ODocument> documents;
        try (ODatabaseDocument database = openDatabase()) {
            OSQLSynchQuery<ODocument> query =
                new OSQLSynchQuery<>(sql, limit);
            documents = database.query(query, parameters);
        }
        return documents;
    }

    private String buildConditions(final Map<String, Object> criteria,
            final Map<String, Object> parameters) {
        StringJoiner conditions = new StringJoiner(" and ");
        int i = 0;
        for (Entry<String, Object> entry : criteria.entrySet()) {
            String attribute = entry.getKey();
            requireNonNull(attribute, "Attribute is null.");
            Object value = entry.getValue();
            if (value != null) {
                String parameter = "p" + i++;
                conditions.add(attribute + "=:" + parameter);
                parameters.put(parameter, value);
            } else {
                conditions.add(attribute + " is null");
            }
        }
        return conditions.toString();
    }

    private String buildDeleteSQL(final Map<String, Object> criteria,
            final Map<String, Object> parameters) {
        StringBuilder sql = new StringBuilder();
        sql.append("delete from ").append(entityName());
        if (!criteria.isEmpty()) {
            sql.append(" where ");
            sql.append(buildConditions(criteria, parameters));
        }
        return sql.toString();
    }

    private String buildSelectSQL(final Map<String, Object> criteria,
            final Map<String, Object> parameters) {
        StringBuilder sql = new StringBuilder();
        sql.append("select from ").append(entityName());
        if (!criteria.isEmpty()) {
            sql.append(" where ");
            sql.append(buildConditions(criteria, parameters));
        }
        return sql.toString();
    }
}
