/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch licenses this file to you 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.
 */

package org.elasticsearch.script;

import org.elasticsearch.Version;
import org.elasticsearch.action.admin.cluster.storedscripts.GetStoredScriptResponse;
import org.elasticsearch.cluster.AbstractDiffable;
import org.elasticsearch.cluster.ClusterState;
import org.elasticsearch.cluster.Diff;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.ParsingException;
import org.elasticsearch.common.bytes.BytesArray;
import org.elasticsearch.common.bytes.BytesReference;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.io.stream.Writeable;
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
import org.elasticsearch.common.xcontent.ObjectParser;
import org.elasticsearch.common.xcontent.ObjectParser.ValueType;
import org.elasticsearch.common.xcontent.ToXContentObject;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.XContentParser.Token;
import org.elasticsearch.common.xcontent.XContentType;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
 * {@link StoredScriptSource} represents user-defined parameters for a script
 * saved in the {@link ClusterState}.
 */
public class StoredScriptSource extends AbstractDiffable<StoredScriptSource> implements Writeable, ToXContentObject {

    /**
     * Standard {@link ParseField} for outer level of stored script source.
     */
    public static final ParseField SCRIPT_PARSE_FIELD = new ParseField("script");

    /**
     * Standard {@link ParseField} for outer level of stored script source.
     */
    public static final ParseField TEMPLATE_PARSE_FIELD = new ParseField("template");

    /**
     * Standard {@link ParseField} for lang on the inner level.
     */
    public static final ParseField LANG_PARSE_FIELD = new ParseField("lang");

    /**
     * Standard {@link ParseField} for code on the inner level.
     */
    public static final ParseField CODE_PARSE_FIELD = new ParseField("code");

    /**
     * Standard {@link ParseField} for options on the inner level.
     */
    public static final ParseField OPTIONS_PARSE_FIELD = new ParseField("options");

    /**
     * Helper class used by {@link ObjectParser} to store mutable {@link StoredScriptSource} variables and then
     * construct an immutable {@link StoredScriptSource} object based on parsed XContent.
     */
    private static final class Builder {
        private String lang;
        private String code;
        private Map<String, String> options;

        private Builder() {
            // This cannot default to an empty map because options are potentially added at multiple points.
            this.options = new HashMap<>();
        }

        private void setLang(String lang) {
            this.lang = lang;
        }

        /**
         * Since stored scripts can accept templates rather than just scripts, they must also be able
         * to handle template parsing, hence the need for custom parsing code.  Templates can
         * consist of either an {@link String} or a JSON object.  If a JSON object is discovered
         * then the content type option must also be saved as a compiler option.
         */
        private void setCode(XContentParser parser) {
            try {
                if (parser.currentToken() == Token.START_OBJECT) {
                    //this is really for search templates, that need to be converted to json format
                    XContentBuilder builder = XContentFactory.jsonBuilder();
                    code = builder.copyCurrentStructure(parser).string();
                    options.put(Script.CONTENT_TYPE_OPTION, XContentType.JSON.mediaType());
                } else {
                    code = parser.text();
                }
            } catch (IOException exception) {
                throw new UncheckedIOException(exception);
            }
        }

        /**
         * Options may have already been added if a template was specified.
         * Appends the user-defined compiler options with the internal compiler options.
         */
        private void setOptions(Map<String, String> options) {
            this.options.putAll(options);
        }

        /**
         * Validates the parameters and creates an {@link StoredScriptSource}.
         */
        private StoredScriptSource build() {
            if (lang == null) {
                throw new IllegalArgumentException("must specify lang for stored script");
            } else if (lang.isEmpty()) {
                throw new IllegalArgumentException("lang cannot be empty");
            }

            if (code == null) {
                throw new IllegalArgumentException("must specify code for stored script");
            } else if (code.isEmpty()) {
                throw new IllegalArgumentException("code cannot be empty");
            }

            if (options.size() > 1 || options.size() == 1 && options.get(Script.CONTENT_TYPE_OPTION) == null) {
                throw new IllegalArgumentException("illegal compiler options [" + options + "] specified");
            }

            return new StoredScriptSource(lang, code, options);
        }
    }

    private static final ObjectParser<Builder, Void> PARSER = new ObjectParser<>("stored script source", Builder::new);

    static {
        // Defines the fields necessary to parse a Script as XContent using an ObjectParser.
        PARSER.declareString(Builder::setLang, LANG_PARSE_FIELD);
        PARSER.declareField(Builder::setCode, parser -> parser, CODE_PARSE_FIELD, ValueType.OBJECT_OR_STRING);
        PARSER.declareField(Builder::setOptions, XContentParser::mapStrings, OPTIONS_PARSE_FIELD, ValueType.OBJECT);
    }

    /**
     * This will parse XContent into a {@link StoredScriptSource}.  The following formats can be parsed:
     *
     * The simple script format with no compiler options or user-defined params:
     *
     * Example:
     * {@code
     * {"script": "return Math.log(doc.popularity) * 100;"}
     * }
     *
     * The above format requires the lang to be specified using the deprecated stored script namespace
     * (as a url parameter during a put request).  See {@link ScriptMetaData} for more information about
     * the stored script namespaces.
     *
     * The complex script format using the new stored script namespace
     * where lang and code are required but options is optional:
     *
     * {@code
     * {
     *     "script" : {
     *         "lang" : "<lang>",
     *         "code" : "<code>",
     *         "options" : {
     *             "option0" : "<option0>",
     *             "option1" : "<option1>",
     *             ...
     *         }
     *     }
     * }
     * }
     *
     * Example:
     * {@code
     * {
     *     "script": {
     *         "lang" : "painless",
     *         "code" : "return Math.log(doc.popularity) * params.multiplier"
     *     }
     * }
     * }
     *
     * The simple template format:
     *
     * {@code
     * {
     *     "query" : ...
     * }
     * }
     *
     * The complex template format:
     *
     * {@code
     * {
     *     "template": {
     *         "query" : ...
     *     }
     * }
     * }
     *
     * Note that templates can be handled as both strings and complex JSON objects.
     * Also templates may be part of the 'code' parameter in a script.  The Parser
     * can handle this case as well.
     *
     * @param lang    An optional parameter to allow for use of the deprecated stored
     *                script namespace.  This will be used to specify the language
     *                coming in as a url parameter from a request or for stored templates.
     * @param content The content from the request to be parsed as described above.
     * @return        The parsed {@link StoredScriptSource}.
     */
    public static StoredScriptSource parse(String lang, BytesReference content, XContentType xContentType) {
        try (XContentParser parser = xContentType.xContent().createParser(NamedXContentRegistry.EMPTY, content)) {
            Token token = parser.nextToken();

            if (token != Token.START_OBJECT) {
                throw new ParsingException(parser.getTokenLocation(), "unexpected token [" + token + "], expected [{]");
            }

            token = parser.nextToken();

            if (token != Token.FIELD_NAME) {
                throw new ParsingException(parser.getTokenLocation(), "unexpected token [" + token + ", expected [" +
                    SCRIPT_PARSE_FIELD.getPreferredName() + ", " + TEMPLATE_PARSE_FIELD.getPreferredName());
            }

            String name = parser.currentName();

            if (SCRIPT_PARSE_FIELD.getPreferredName().equals(name)) {
                token = parser.nextToken();

                if (token == Token.VALUE_STRING) {
                    if (lang == null) {
                        throw new IllegalArgumentException(
                            "must specify lang as a url parameter when using the deprecated stored script namespace");
                    }

                    return new StoredScriptSource(lang, parser.text(), Collections.emptyMap());
                } else if (token == Token.START_OBJECT) {
                    if (lang == null) {
                        return PARSER.apply(parser, null).build();
                    } else {
                        //this is really for search templates, that need to be converted to json format
                        try (XContentBuilder builder = XContentFactory.jsonBuilder()) {
                            builder.copyCurrentStructure(parser);
                            return new StoredScriptSource(lang, builder.string(), Collections.emptyMap());
                        }
                    }

                } else {
                    throw new ParsingException(parser.getTokenLocation(), "unexpected token [" + token + "], expected [{, <code>]");
                }
            } else {
                if (lang == null) {
                    throw new IllegalArgumentException("unexpected stored script format");
                }

                if (TEMPLATE_PARSE_FIELD.getPreferredName().equals(name)) {
                    token = parser.nextToken();

                    if (token == Token.VALUE_STRING) {
                        return new StoredScriptSource(lang, parser.text(), Collections.emptyMap());
                    }
                }

                try (XContentBuilder builder = XContentFactory.jsonBuilder()) {
                    if (token != Token.START_OBJECT) {
                        builder.startObject();
                        builder.copyCurrentStructure(parser);
                        builder.endObject();
                    } else {
                        builder.copyCurrentStructure(parser);
                    }

                    return new StoredScriptSource(lang, builder.string(), Collections.emptyMap());
                }
            }
        } catch (IOException ioe) {
            throw new UncheckedIOException(ioe);
        }
    }

    /**
     * This will parse XContent into a {@link StoredScriptSource}. The following format is what will be parsed:
     *
     * {@code
     * {
     *     "script" : {
     *         "lang" : "<lang>",
     *         "code" : "<code>",
     *         "options" : {
     *             "option0" : "<option0>",
     *             "option1" : "<option1>",
     *             ...
     *         }
     *     }
     * }
     * }
     *
     * Note that the "code" parameter can also handle template parsing including from
     * a complex JSON object.
     */
    public static StoredScriptSource fromXContent(XContentParser parser) throws IOException {
        return PARSER.apply(parser, null).build();
    }

    /**
     * Required for {@link ScriptMetaData.ScriptMetadataDiff}.  Uses
     * the {@link StoredScriptSource#StoredScriptSource(StreamInput)}
     * constructor.
     */
    public static Diff<StoredScriptSource> readDiffFrom(StreamInput in) throws IOException {
        return readDiffFrom(StoredScriptSource::new, in);
    }

    private final String lang;
    private final String code;
    private final Map<String, String> options;

    /**
     * Constructor for use with {@link GetStoredScriptResponse}
     * to support the deprecated stored script namespace.
     */
    public StoredScriptSource(String code) {
        this.lang = null;
        this.code = Objects.requireNonNull(code);
        this.options = null;
    }

    /**
     * Standard StoredScriptSource constructor.
     * @param lang    The language to compile the script with.  Must not be {@code null}.
     * @param code    The source code to compile with.  Must not be {@code null}.
     * @param options Compiler options to be compiled with.  Must not be {@code null},
     *                use an empty {@link Map} to represent no options.
     */
    public StoredScriptSource(String lang, String code, Map<String, String> options) {
        this.lang = Objects.requireNonNull(lang);
        this.code = Objects.requireNonNull(code);
        this.options = Collections.unmodifiableMap(Objects.requireNonNull(options));
    }

    /**
     * Reads a {@link StoredScriptSource} from a stream.  Version 5.3+ will read
     * all of the lang, code, and options parameters.  For versions prior to 5.3,
     * only the code parameter will be read in as a bytes reference.
     */
    public StoredScriptSource(StreamInput in) throws IOException {
        if (in.getVersion().onOrAfter(Version.V_5_3_0_UNRELEASED)) {
            this.lang = in.readString();
            this.code = in.readString();
            @SuppressWarnings("unchecked")
            Map<String, String> options = (Map<String, String>)(Map)in.readMap();
            this.options = options;
        } else {
            this.lang = null;
            this.code = in.readBytesReference().utf8ToString();
            this.options = null;
        }
    }

    /**
     * Writes a {@link StoredScriptSource} to a stream.  Version 5.3+ will write
     * all of the lang, code, and options parameters.  For versions prior to 5.3,
     * only the code parameter will be read in as a bytes reference.
     */
    @Override
    public void writeTo(StreamOutput out) throws IOException {
        if (out.getVersion().onOrAfter(Version.V_5_3_0_UNRELEASED)) {
            out.writeString(lang);
            out.writeString(code);
            @SuppressWarnings("unchecked")
            Map<String, Object> options = (Map<String, Object>)(Map)this.options;
            out.writeMap(options);
        } else {
            out.writeBytesReference(new BytesArray(code));
        }
    }

    /**
     * This will write XContent from a {@link StoredScriptSource}. The following format will be written:
     *
     * {@code
     * {
     *     "script" : {
     *         "lang" : "<lang>",
     *         "code" : "<code>",
     *         "options" : {
     *             "option0" : "<option0>",
     *             "option1" : "<option1>",
     *             ...
     *         }
     *     }
     * }
     * }
     *
     * Note that the 'code' parameter can also handle templates written as complex JSON.
     */
    @Override
    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
        builder.startObject();
        builder.field(LANG_PARSE_FIELD.getPreferredName(), lang);
        builder.field(CODE_PARSE_FIELD.getPreferredName(), code);
        builder.field(OPTIONS_PARSE_FIELD.getPreferredName(), options);
        builder.endObject();

        return builder;
    }

    /**
     * @return The language used for compiling this script.
     */
    public String getLang() {
        return lang;
    }

    /**
     * @return The code used for compiling this script.
     */
    public String getCode() {
        return code;
    }

    /**
     * @return The compiler options used for this script.
     */
    public Map<String, String> getOptions() {
        return options;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        StoredScriptSource that = (StoredScriptSource)o;

        if (lang != null ? !lang.equals(that.lang) : that.lang != null) return false;
        if (code != null ? !code.equals(that.code) : that.code != null) return false;
        return options != null ? options.equals(that.options) : that.options == null;

    }

    @Override
    public int hashCode() {
        int result = lang != null ? lang.hashCode() : 0;
        result = 31 * result + (code != null ? code.hashCode() : 0);
        result = 31 * result + (options != null ? options.hashCode() : 0);
        return result;
    }

    @Override
    public String toString() {
        return "StoredScriptSource{" +
            "lang='" + lang + '\'' +
            ", code='" + code + '\'' +
            ", options=" + options +
            '}';
    }
}
