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

import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;

import javax.jcr.Binary;
import javax.jcr.Node;
import javax.jcr.Property;
import javax.jcr.PropertyType;
import javax.jcr.RepositoryException;
import javax.jcr.Value;
import javax.jcr.ValueFormatException;

import org.apache.jackrabbit.api.ReferenceBinary;
import org.apache.jackrabbit.commons.jackrabbit.SimpleReferenceBinary;
import org.apache.jackrabbit.util.XMLChar;
import org.apache.jackrabbit.value.ValueHelper;
import org.apache.jackrabbit.util.Text;

/**
 * Helper class that represents a (jcr) property in the document view format.
 * It contains formatting and parsing methods for writing/reading enhanced
 * docview properties.
 *
 * {@code prop:= [ "{" type "}" ] ( value | "[" [ value { "," value } ] "]" )}
 */
public class DocViewProperty {

    private static final String BINARY_REF = "BinaryRef";

    /**
     * name of the property
     */
    public final String name;

    /**
     * value(s) of the property. always contains at least one value if this is
     * not a mv property.
     */
    public final String[] values;

    /**
     * indicates a MV property
     */
    public final boolean isMulti;

    /**
     * type of this property (can be undefined)
     */
    public final int type;

    /**
     * indicates a binary ref property
     */
    public final boolean isReferenceProperty;

    /**
     * set of unambigous property names
     */
    private static final Set<String> UNAMBIGOUS = new HashSet<String>();
    static {
        UNAMBIGOUS.add("jcr:primaryType");
        UNAMBIGOUS.add("jcr:mixinTypes");
    }

    /**
     * Creates a new property.
     * @param name name of the property
     * @param values values.
     * @param multi multiple flag
     * @param type type of the property
     * @throws IllegalArgumentException if single value property and not exactly 1 value is given.
     */
    public DocViewProperty(String name, String[] values, boolean multi, int type) {
        this(name, values, multi, type, false);
    }

    /**
     * Creates a new property.
     * @param name name of the property
     * @param values values.
     * @param multi multiple flag
     * @param type type of the property
     * @param isRef {@code true} to indicated that this is a binary reference property
     * @throws IllegalArgumentException if single value property and not exactly 1 value is given.
     */
    public DocViewProperty(String name, String[] values, boolean multi, int type, boolean isRef) {
        this.name = name;
        this.values = values;
        isMulti = multi;
        // validate type
        if (type == PropertyType.UNDEFINED) {
            if ("jcr:primaryType".equals(name) || "jcr:mixinTypes".equals(name)) {
                type = PropertyType.NAME;
            }
        }
        this.type = type;
        if (!isMulti && values.length != 1) {
            throw new IllegalArgumentException("Single value property needs exactly 1 value.");
        }
        this.isReferenceProperty = isRef;
    }

    /**
     * Parses a enhanced docview property string and returns the property.
     * @param name name of the property
     * @param value (attribute) value
     * @return a property
     */
    public static DocViewProperty parse(String name, String value) {
        boolean isMulti = false;
        boolean isBinaryRef = false;
        int type = PropertyType.UNDEFINED;
        int pos = 0;
        char state = 'b';
        List<String> vals = null;
        StringBuffer tmp = new StringBuffer();
        int unicode = 0;
        int unicodePos = 0;
        while (pos < value.length()) {
            char c = value.charAt(pos++);
            switch (state) {
                case 'b': // begin (type or array or value)
                    if (c == '{') {
                        state = 't';
                    } else if (c == '[') {
                        isMulti = true;
                        state = 'v';
                    } else if (c == '\\') {
                        state = 'e';
                    } else {
                        tmp.append(c);
                        state = 'v';
                    }
                    break;
                case 'a': // array (array or value)
                    if (c == '[') {
                        isMulti = true;
                        state = 'v';
                    } else if (c == '\\') {
                        state = 'e';
                    } else {
                        tmp.append(c);
                        state = 'v';
                    }
                    break;
                case 't':
                    if (c == '}') {
                        if (BINARY_REF.equals(tmp.toString())) {
                            type = PropertyType.BINARY;
                            isBinaryRef = true;
                        } else {
                            type = PropertyType.valueFromName(tmp.toString());
                        }
                        tmp.setLength(0);
                        state = 'a';
                    } else {
                        tmp.append(c);
                    }
                    break;
                case 'v': // value
                    if (c == '\\') {
                        state = 'e';
                    } else if (c == ',' && isMulti) {
                        if (vals == null) {
                            vals = new LinkedList<String>();
                        }
                        vals.add(tmp.toString());
                        tmp.setLength(0);
                    } else if (c == ']' && isMulti && pos == value.length()) {
                        if (tmp.length() > 0 || vals != null) {
                            if (vals == null) {
                                vals = new LinkedList<String>();
                            }
                            vals.add(tmp.toString());
                            tmp.setLength(0);
                        }
                    } else {
                        tmp.append(c);
                    }
                    break;
                case 'e': // escaped
                    if (c == 'u') {
                        state = 'u';
                        unicode = 0;
                        unicodePos = 0;
                    } else if (c == '0') {
                        // special case to treat empty values. see JCR-3661
                        state = 'v';
                        if (vals == null) {
                            vals = new LinkedList<String>();
                        }
                    } else {
                        state = 'v';
                        tmp.append(c);
                    }
                    break;
                case 'u': // unicode escaped
                    unicode = (unicode << 4) + Character.digit(c, 16);
                    if (++unicodePos == 4) {
                        tmp.appendCodePoint(unicode);
                        state = 'v';
                    }
                    break;

            }
        }
        String[] values;
        if (isMulti) {
            // add value if missing ']'
            if (tmp.length() > 0) {
                if (vals == null) {
                    vals = new LinkedList<String>();
                }
                vals.add(tmp.toString());
            }
            if (vals == null) {
                values = Constants.EMPTY_STRING_ARRAY;
            } else {
                values = vals.toArray(new String[vals.size()]);
            }
        } else {
            values = new String[]{tmp.toString()};
        }
        return new DocViewProperty(name, values, isMulti, type, isBinaryRef);
    }
    /**
     * Formats the given jcr property to the enhanced docview syntax.
     * @param prop the jcr property
     * @return the formatted string
     * @throws RepositoryException if a repository error occurs
     */
    public static String format(Property prop) throws RepositoryException {
        return format(prop, false, false);
    }
    
    /**
     * Formats the given jcr property to the enhanced docview syntax.
     * @param prop the jcr property
     * @param sort if {@code true} multivalue properties are sorted
     * @param useBinaryReferences {@code true} to use binary references
     * @return the formatted string
     * @throws RepositoryException if a repository error occurs
     */
    public static String format(Property prop, boolean sort, boolean useBinaryReferences)
            throws RepositoryException {
        StringBuffer attrValue = new StringBuffer();
        int type = prop.getType();
        if (type == PropertyType.BINARY || isAmbiguous(prop)) {
            String referenceBinary = null;
            if (useBinaryReferences && type == PropertyType.BINARY) {
                Binary bin = prop.getBinary();
                if (bin != null && bin instanceof ReferenceBinary) {
                    referenceBinary = ((ReferenceBinary) bin).getReference();
                }
            }

            if (referenceBinary == null) {
                attrValue.append("{");
                attrValue.append(PropertyType.nameFromValue(prop.getType()));
                attrValue.append("}");
            } else {
                attrValue.append("{");
                attrValue.append(BINARY_REF);
                attrValue.append("}");
                attrValue.append(referenceBinary);
            }
        }
        // only write values for non binaries
        if (prop.getType() != PropertyType.BINARY) {
            if (prop.getDefinition().isMultiple()) {
                attrValue.append('[');
                Value[] values = prop.getValues();
                if (sort) {
                    Arrays.sort(values, ValueComparator.getInstance());
                }
                for (int i = 0; i < values.length; i++) {
                    if (i > 0) {
                        attrValue.append(',');
                    }
                    String strValue = ValueHelper.serialize(values[i], false);
                    if (values.length == 1 && strValue.length() == 0) {
                        // special case for empty string MV value (JCR-3661)
                        attrValue.append("\\0");
                    } else {
                        switch (prop.getType()) {
                            case PropertyType.STRING:
                            case PropertyType.NAME:
                            case PropertyType.PATH:
                                escape(attrValue, strValue, true);
                                break;
                            default:
                                attrValue.append(strValue);
                        }
                    }
                }
                attrValue.append(']');
            } else {
                String strValue = ValueHelper.serialize(prop.getValue(), false);
                escape(attrValue, strValue, false);
            }
        }
        return attrValue.toString();
    }

    /**
     * Escapes the value
     * @param buf buffer to append to
     * @param value value to escape
     * @param isMulti indicates multi value property
     */
    protected static void escape(StringBuffer buf, String value, boolean isMulti) {
        for (int i=0; i<value.length(); i++) {
            char c = value.charAt(i);
            if (c == '\\') {
                buf.append("\\\\");
            } else if (c == ',' && isMulti) {
                buf.append("\\,");
            } else if (i == 0 && !isMulti && (c == '[' || c == '{')) {
                buf.append('\\').append(c);
            } else if ( XMLChar.isInvalid(c)) {
                buf.append("\\u");
                buf.append(Text.hexTable[(c >> 12) & 15]);
                buf.append(Text.hexTable[(c >> 8) & 15]);
                buf.append(Text.hexTable[(c >> 4) & 15]);
                buf.append(Text.hexTable[c & 15]);
            } else {
                buf.append(c);
            }
        }
    }
    
    /**
     * Checks if the type of the given property is ambiguous in respect to it's
     * property definition. the current implementation just checks some well
     * known properties.
     *
     * @param prop the property
     * @return type
     * @throws RepositoryException if a repository error occurs
     */
    public static boolean isAmbiguous(Property prop) throws RepositoryException {
        return prop.getType() != PropertyType.STRING && !UNAMBIGOUS.contains(prop.getName());
    }

    /**
     * Sets this property on the given node
     *
     * @param node the node
     * @return {@code true} if the value was modified.
     * @throws RepositoryException if a repository error occurs
     */
    public boolean apply(Node node) throws RepositoryException {
        Property prop = node.hasProperty(name) ? node.getProperty(name) : null;
        // check if multiple flags are equal
        if (prop != null && isMulti != prop.getDefinition().isMultiple()) {
            prop.remove();
            prop = null;
        }
        if (prop != null) {
            int propType = prop.getType();
            if (propType != type && (propType != PropertyType.STRING || type != PropertyType.UNDEFINED)) {
                // never compare if types differ
                prop = null;
            }
        }
        if (isMulti) {
            // todo: handle multivalue binaries and reference binaries
            Value[] vs = prop == null ? null : prop.getValues();
            if (vs != null && vs.length == values.length) {
                // quick check all values
                boolean modified = false;
                for (int i=0; i<vs.length; i++) {
                    if (!vs[i].getString().equals(values[i])) {
                        modified = true;
                    }
                }
                if (!modified) {
                    return false;
                }
            }
            if (type == PropertyType.UNDEFINED) {
                node.setProperty(name, values);
            } else {
                node.setProperty(name, values, type);
            }
            // assume modified
            return true;
        } else {
            Value v = prop == null ? null : prop.getValue();
            if (type == PropertyType.BINARY) {
                if (isReferenceProperty) {
                    ReferenceBinary ref = new SimpleReferenceBinary(values[0]);
                    Binary binary = node.getSession().getValueFactory().createValue(ref).getBinary();
                    if (v != null) {
                        Binary bin = v.getBinary();
                        if (bin.equals(binary)) {
                            return false;
                        }
                    }
                    node.setProperty(name, binary);
                }
                // the binary property is always modified (TODO: check if still correct with JCRVLT-110)
                return true;
            }
            if (v == null || !v.getString().equals(values[0])) {
                try {
                    if (type == PropertyType.UNDEFINED) {
                        node.setProperty(name, values[0]);
                    } else {
                        node.setProperty(name, values[0], type);
                    }
                } catch (ValueFormatException e) {
                    // forcing string
                    node.setProperty(name, values[0], PropertyType.STRING);
                }
                return true;
            }
        }
        return false;
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + (isMulti ? 1231 : 1237);
        result = prime * result + (isReferenceProperty ? 1231 : 1237);
        result = prime * result + ((name == null) ? 0 : name.hashCode());
        result = prime * result + type;
        result = prime * result + Arrays.hashCode(values);
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        DocViewProperty other = (DocViewProperty) obj;
        if (isMulti != other.isMulti)
            return false;
        if (isReferenceProperty != other.isReferenceProperty)
            return false;
        if (name == null) {
            if (other.name != null)
                return false;
        } else if (!name.equals(other.name))
            return false;
        if (type != other.type)
            return false;
        if (!Arrays.equals(values, other.values))
            return false;
        return true;
    }

    @Override
    public String toString() {
        return "DocViewProperty [name=" + name + ", values=" + Arrays.toString(values) + ", isMulti=" + isMulti + ", type=" + type
                + ", isReferenceProperty=" + isReferenceProperty + "]";
    }

}