/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF 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.apache.jackrabbit.server.remoting.davex;

import java.io.IOException;
import java.io.Writer;
import java.util.Collection;
import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.Property;
import javax.jcr.PropertyIterator;
import javax.jcr.PropertyType;
import javax.jcr.RepositoryException;
import javax.jcr.Value;

import org.apache.jackrabbit.JcrConstants;
import org.apache.jackrabbit.commons.json.JsonUtil;

/**
 * <code>JsonWriter</code> traverses a tree of JCR items and writes a JSON object
 * exposing nodes as JSON object members and properties as JSON pairs.
 * <p>
 * <strong>Note</strong>: Using JSON.org library is deliberately avoided for the
 * following reasons.
 * <ul>
 * <li>JSONObject does not preserve the order of members added, which is required
 * for JCR remoting.</li>
 * <li>JSONObject#numberToString:
 * Double numbers get their trailing '.0' stripped away, which removes
 * the ability to distinguish between JCR values of type {@link PropertyType#DOUBLE}
 * and {@link PropertyType#LONG}.</li>
 * </ul>
 */
class JsonWriter {

    private final Writer writer;

    /**
     * Create a new JsonItemWriter
     *
     * @param writer Writer to which the generated JSON string is written.
     */
    JsonWriter(Writer writer) {
        this.writer = writer;
    }

    /**
     * 
     * @param node
     * @param maxLevels
     * @throws RepositoryException
     * @throws IOException
     */
    void write(Node node, int maxLevels) throws RepositoryException, IOException {
        write(node, 0, maxLevels);
    }

    void write(Collection<Node> nodes, int maxLevels)
            throws RepositoryException, IOException {
        writer.write('{');
        writeKey("nodes");
        writer.write('{');
        boolean first = true;
        for (Node node : nodes) {
            if (first) {
                first = false;
            } else {
                writer.write(',');
            }
            writeKey(node.getPath());
            write(node, maxLevels);
        }
        writer.write('}');
        writer.write('}');
    }

    private void write(Node node, int currentLevel, int maxLevels)
            throws RepositoryException, IOException {
        // start of node info
        writer.write('{');

        // append the jcr properties as JSON pairs.
        PropertyIterator props = node.getProperties();
        while (props.hasNext()) {
            Property prop = props.nextProperty();
            writeProperty(prop);
            // add separator: next json pair/member is either a property or
            // a childnode or the special no-children-present pair.
            writer.write(',');
        }

        // for jcr child nodes include member unless the max-depths is reached.
        // in case there are no children at all, append a special pair.
        final NodeIterator children = node.getNodes();
        if (!children.hasNext()) {
            // no child present at all -> add special property.
            writeKeyValue("::NodeIteratorSize", 0);
        } else {
            // the child nodes
            while (children.hasNext()) {
                final Node n = children.nextNode();
                String name = n.getName();
                int index = n.getIndex();
                if (index > 1) {
                    writeKey(name + "[" + index + "]");
                } else {
                    writeKey(name);
                }
                if (maxLevels < 0 || currentLevel < maxLevels) {
                    write(n, currentLevel + 1, maxLevels);
                } else {
                    /**
                     * In order to be able to compute the set of child-node entries
                     * upon Node creation -> add incomplete "node info" JSON
                     * object for the child node omitting properties and child
                     * information except for the jcr:uuid property (if present
                     * at all).
                     * the latter is required in order to build the correct SPI
                     * ChildInfo for Node n.
                     */
                    writeChildInfo(n);
                }
                if (children.hasNext()) {
                    writer.write(',');
                }
            }
        }

        // end of node info
        writer.write('}');
    }

    /**
     * Write child info without including the complete node info.
     *
     * @param n
     * @throws RepositoryException
     * @throws IOException
     */
    private void writeChildInfo(Node n) throws RepositoryException, IOException {
        // start child info
        writer.write('{');

        // make sure the SPI childInfo can be built correctly on the
        // client side -> pass uuid if present.
        if (n.isNodeType(JcrConstants.MIX_REFERENCEABLE) &&
                n.hasProperty(JcrConstants.JCR_UUID)) {
            writeProperty(n.getProperty(JcrConstants.JCR_UUID));
        }

        // end child info
        writer.write('}');
    }

    /**
     * Write a single property
     *
     * @param p
     * @throws javax.jcr.RepositoryException
     * @throws java.io.IOException
     */
    private void writeProperty(Property p) throws RepositoryException, IOException {
        // special handling for binaries: we dump the length and not the length
        int type = p.getType();
        if (type == PropertyType.BINARY) {
            // mark binary properties with a leading ':'
            // the value(s) reflect the jcr-values length instead of the binary data.
            String key = ":" + p.getName();
            if (p.isMultiple()) {
                long[] binLengths = p.getLengths();
                writeKeyArray(key, binLengths);
            } else {
                writeKeyValue(key, p.getLength());
            }
        } else {
            boolean isMultiple = p.isMultiple();
            if (requiresTypeInfo(p) || (isMultiple && p.getValues().length == 0)) {
                /* special property types that have no correspondence in JSON
                   are transported as String. the type is transported with an
                   extra key-value pair, the key having a leading ':' the value
                   reflects the type.
                   the same applies for multivalued properties consisting of an
                   empty array -> property type guessing would not be possible.
                 */
                writeKeyValue(":" +  p.getName(), PropertyType.nameFromValue(type), true);
            }
            /* append key-value pair containing the jcr value(s).
               for String, Boolean, Double, Long -> types in json available */
            if (isMultiple) {
                writeKeyArray(p.getName(), p.getValues());
            } else {
                writeKeyValue(p.getName(), p.getValue());
            }
        }
    }

    private static boolean requiresTypeInfo(Property p) throws RepositoryException {
        switch (p.getType()) {
            case PropertyType.NAME:
            case PropertyType.PATH:
            case PropertyType.REFERENCE:
            case PropertyType.DATE:
            case PropertyType.WEAKREFERENCE:
            case PropertyType.URI:
            case PropertyType.DECIMAL:
                return true;
            case PropertyType.DOUBLE:
                /* Double.NaN, Double.POSITIVE_INFINITY and Double.NEGATIVE_INFINITY
                   are not defined in JSON, so they have to be serialized as type-hinted strings.
                 */
                if (p.isMultiple()) {
                    for (Value val : p.getValues()) {
                        double doubleValue = val.getDouble();
                        if (Double.isInfinite(doubleValue) || Double.isNaN(doubleValue)) {
                            return true;
                        }
                    }
                    return false;
                } else {
                    double doubleValue = p.getDouble();
                    return Double.isInfinite(doubleValue) || Double.isNaN(doubleValue);
                }
            default:
                // any other property type
                return false;
        }
    }

    private void writeKeyValue(String key, String value, boolean hasNext) throws IOException {
        writeKey(key);
        writer.write(JsonUtil.getJsonString(value));
        if (hasNext) {
            writer.write(',');
        }
    }

    private void writeKeyValue(String key, Value value) throws RepositoryException, IOException {
        writeKey(key);
        writeJsonValue(value);
    }

    private void writeKeyArray(String key, Value[] values) throws RepositoryException, IOException {
        writeKey(key);
        writer.write('[');
        for (int i = 0; i < values.length; i++) {
            if (i > 0) {
                writer.write(',');
            }
            writeJsonValue(values[i]);
        }
        writer.write(']');
    }

    private void writeKeyValue(String key, long binLength) throws IOException {
        writeKey(key);
        writer.write(String.valueOf(binLength));
    }

    private void writeKeyArray(String key, long[] binLengths) throws RepositoryException, IOException {
        writeKey(key);
        writer.write('[');
        for (int i = 0; i < binLengths.length; i++) {
            if (i > 0) {
                writer.write(',');
            }
            writer.write(String.valueOf(binLengths[i]));
        }
        writer.write(']');
    }

    /**
     *
     * @param key
     * @throws IOException
     */
   private void writeKey(String key) throws IOException {
        writer.write(JsonUtil.getJsonString(key));
        writer.write(':');
    }

    /**
     * @param v
     * @throws RepositoryException
     * @throws IOException
     */
    private void writeJsonValue(Value v) throws RepositoryException, IOException {

        switch (v.getType()) {
            case PropertyType.BINARY:
                // should never get here
                throw new IllegalArgumentException();

            case PropertyType.BOOLEAN:
            case PropertyType.LONG:
                writer.write(v.getString());
                break;
            case PropertyType.DOUBLE:
                double d = v.getDouble();
                String str = v.getString();
                if (Double.isNaN(d) || Double.isInfinite(d)) {
                    str = JsonUtil.getJsonString(str);
                }
                writer.write(str);
                break;

            default:
                writer.write(JsonUtil.getJsonString(v.getString()));
        }
    }
}
