/*
 * Copyright (c) 2016 The original author or authors
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * and Apache License v2.0 which accompanies this distribution.
 *
 *      The Eclipse Public License is available at
 *      http://www.eclipse.org/legal/epl-v10.html
 *
 *      The Apache License v2.0 is available at
 *      http://www.opensource.org/licenses/apache2.0.php
 *
 * You may elect to redistribute this code under either of these licenses.
 */

package io.engagingspaces.graphql.query;

import io.vertx.codegen.annotations.DataObject;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;

import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

/**
 * Immutable data object that holds the execution result of a GraphQL query.
 * <p>
 * If the query was successful the response is available as {@link JsonObject}. Otherwise a list of
 * {@link QueryError} provides more detail on the failure.
 *
 * @author <a href="https://github.com/aschrijver/">Arnold Schrijver</a>
 */
@DataObject
public class QueryResult {

    private final JsonObject data;
    private final boolean succeeded;
    private final List<QueryError> errors;

    private volatile int hashCode;

    public QueryResult(JsonObject data, boolean succeeded, List<QueryError> errors) {
        this.data = data;
        this.succeeded = succeeded;
        this.errors = errors == null ? Collections.emptyList() : Collections.unmodifiableList(errors);
    }

    /**
     * Creates a new {@link QueryResult} from
     * its json representation.
     *
     * @param json the json object
     */
    public QueryResult(JsonObject json) {
        this.data = json.getJsonObject("data", new JsonObject());
        this.succeeded = json.getBoolean("succeeded", false);
        List<QueryError> queryErrors = json.getJsonArray("errors", new JsonArray()).stream()
                .map(error -> new QueryError((JsonObject) error)).collect(Collectors.toList());
        this.errors = queryErrors == null ? Collections.emptyList() : Collections.unmodifiableList(queryErrors);
    }

    /**
     * Creates a new {@link QueryResult} by copying
     * the values from another {@link QueryResult}.
     *
     * @param other the query result to copy
     */
    public QueryResult(QueryResult other) {
        this.data = other.data;
        this.succeeded = other.succeeded;
        this.errors = other.errors;
    }

    /**
     * @return the JSON representation of the current
     * {@link QueryResult}.
     */
    public JsonObject toJson() {
        return new JsonObject()
                .put("data", data)
                .put("succeeded", succeeded)
                .put("errors", new JsonArray(errors.stream().map(QueryError::toJson).collect(Collectors.toList())));
    }

    /**
     * Gets the {@link JsonObject} response of a successful GraphQL query. If the query wasn't
     * successful an empty json object is returned.
     *
     * @return the query response
     */
    public JsonObject getData() {
        return data;
    }

    /**
     * @return {@code true} when the query was successful, {@code false} otherwise
     */
    public boolean isSucceeded() {
        return succeeded;
    }

    /**
     * Gets the errors that occurred on query execution, if the GraphQL query was not successful.
     *
     * @return the query errors that occurred, or an empty list
     */
    public List<QueryError> getErrors() {
        return errors;
    }

    /**
     * Determine object equality of this query result with another object.
     * <p>
     * Use this method judiciously, as it uses deep comparison, which can become costly when
     * comparing to a successful query result holding a large json payload.
     *
     * @param other the object to compare
     * @return {@code true} when equal, {@code false} otherwise
     */
    @Override
    public boolean equals(Object other) {
        if (other == this) {
            return true;
        }
        if (!(other instanceof QueryResult)) {
            return false;
        }
        QueryResult test = (QueryResult) other;
        return succeeded == test.succeeded && fieldEquals(errors, test.errors) && fieldEquals(data, test.data);
    }

    private static boolean fieldEquals(Object value1, Object value2) {
        return value1 == null ? value2 == null : value1.equals(value2);
    }

    /**
     * The hash code of the query result.
     * <p>
     * Result is lazy-loaded on the first call, then cached.
     *
     * @return the hash code
     */
    @Override
    public int hashCode() {
        int result = hashCode;
        if (result == 0) {
            result = 17;
            result = 31 * result + (data == null ? 0 : data.hashCode());
            result = 31 * result + (succeeded ? 1 : 0);
            result = 31 * result + (errors == null ? 0 : errors.hashCode());
            hashCode = result;
        }
        return result;
    }

    /**
     * Data object that represents an error that occurred upon execution of a GraphQL query.
     */
    @DataObject
    public static class QueryError {

        private final String errorType;
        private final String message;
        private final List<ErrorLocation> locations;

        public QueryError(String errorType, String message, List<ErrorLocation> locations) {
            this.errorType = errorType;
            this.message = message;
            this.locations = locations == null ? Collections.emptyList() : Collections.unmodifiableList(locations);
        }

        /**
         * Creates a new {@link QueryError} from
         * its json representation.
         *
         * @param json the json object
         */
        public QueryError(JsonObject json) {
            this.errorType = json.getString("errorType");
            this.message = json.getString("message");
            List<ErrorLocation> errorLocations = json.getJsonArray("locations", new JsonArray()).stream()
                    .map(location -> new ErrorLocation((JsonObject) location)).collect(Collectors.toList());
            this.locations = errorLocations == null ?
                    Collections.emptyList() : Collections.unmodifiableList(errorLocations);
        }

        /**
         * Creates a new {@link QueryError} by copying
         * the values from another {@link QueryError}.
         *
         * @param other the query error to copy
         */
        public QueryError(QueryError other) {
            this.errorType = other.errorType;
            this.message = other.message;
            this.locations = other.locations;
        }

        /**
         * @return the JSON representation of the current
         * {@link QueryError}.
         */
        public JsonObject toJson() {
            return new JsonObject()
                    .put("errorType", errorType)
                    .put("message", message)
                    .put("locations", new JsonArray(locations.stream()
                            .map(ErrorLocation::toJson).collect(Collectors.toList())));
        }

        /**
         * Gets the type of the graphql execution error.
         * <p>
         * The possible types are determined by the `ValidationErrorType` enum defined in the GraphQL implementation
         * (refer to: https://github.com/graphql-java/graphql-java).
         *
         * @return the error type
         */
        public String getErrorType() {
            return errorType;
        }

        /**
         * @return the error message
         */
        public String getMessage() {
            return message;
        }

        /**
         * @return the location(s) where the error occurred (if applicable)
         */
        public List<ErrorLocation> getLocations() {
            return locations;
        }

        /**
         * @param other the object to compare to this query error
         * @return {@code true} when equal, {@code false} otherwise
         */
        @Override
        public boolean equals(Object other) {
            if (other == this) {
                return true;
            }
            if (!(other instanceof QueryError)) {
                return false;
            }
            QueryError test = (QueryError) other;
            return fieldEquals(errorType, test.errorType) && fieldEquals(message, test.message) &&
                    fieldEquals(locations, test.locations);
        }

        private boolean fieldEquals(Object value1, Object value2) {
            return value1 == null ? value2 == null : value1.equals(value2);
        }

        /**
         * @return the hash code of the query error
         */
        @Override
        public int hashCode() {
            int result = 17;
            result = 31 * result + (errorType == null ? 0 : errorType.hashCode());
            result = 31 * result + (message == null ? 0 : message.hashCode());
            result = 31 * result + (locations == null ? 0: locations.hashCode());
            return result;
        }
    }

    /**
     * Data object that holds the location of a graphql query error.
     */
    @DataObject
    public static class ErrorLocation {

        private final int line;
        private final int column;

        public ErrorLocation(int line, int column) {
            this.line = line;
            this.column = column;
        }

        public ErrorLocation(JsonObject json) {
            this.line = json.getInteger("line", 0);
            this.column = json.getInteger("column", 0);
        }

        public ErrorLocation(ErrorLocation other) {
            this.line = other.line;
            this.column = other.column;
        }

        public JsonObject toJson() {
            return new JsonObject().put("line", line).put("column", column);
        }

        /**
         * @return the line where the error occurred
         */
        public int getLine() {
            return line;
        }

        /**
         * @return the column where the error occurred
         */
        public int getColumn() {
            return column;
        }

        /**
         * @param other the object to compare to this error location
         * @return {@code true} when equal, {@code false} otherwise
         */
        @Override
        public boolean equals(Object other) {
            if (other == this) {
                return true;
            }
            if (!(other instanceof ErrorLocation)) {
                return false;
            }
            ErrorLocation test = (ErrorLocation) other;
            return this.line == test.line && this.column == test.column;
        }

        /**
         * @return the hash code of the error location
         */
        @Override
        public int hashCode() {
            int result = 17;
            result = 31 * result + line;
            result = 31 * result + column;
            return result;
        }
    }
}