/*
 * Copyright (C) 2009 The Android Open Source Project
 *
 * Licensed 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 com.android.internal.util;

import java.io.IOException;
import java.io.Reader;
import java.io.StreamTokenizer;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;

/**
 * A {@code Map} that publishes a set of typed properties, defined by
 * zero or more {@code Reader}s containing textual definitions and assignments.
 */
public class TypedProperties extends HashMap<String, Object> {
    /**
     * Instantiates a {@link java.io.StreamTokenizer} and sets its syntax tables
     * appropriately for the {@code TypedProperties} file format.
     *
     * @param r The {@code Reader} that the {@code StreamTokenizer} will read from
     * @return a newly-created and initialized {@code StreamTokenizer}
     */
    static StreamTokenizer initTokenizer(Reader r) {
        StreamTokenizer st = new StreamTokenizer(r);

        // Treat everything we don't specify as "ordinary".
        st.resetSyntax();

        /* The only non-quoted-string words we'll be reading are:
         * - property names: [._$a-zA-Z0-9]
         * - type names (case insensitive): [a-zA-Z]
         * - number literals: [-0-9.eExXA-Za-z]  ('x' for 0xNNN hex literals. "NaN", "Infinity")
         * - "true" or "false" (case insensitive): [a-zA-Z]
         */
        st.wordChars('0', '9');
        st.wordChars('A', 'Z');
        st.wordChars('a', 'z');
        st.wordChars('_', '_');
        st.wordChars('$', '$');
        st.wordChars('.', '.');
        st.wordChars('-', '-');
        st.wordChars('+', '+');

        // Single-character tokens
        st.ordinaryChar('=');

        // Other special characters
        st.whitespaceChars(' ', ' ');
        st.whitespaceChars('\t', '\t');

        st.quoteChar('"');

        st.commentChar('#');

        st.eolIsSignificant(true);

        return st;
    }


    /**
     * An unchecked exception that is thrown when encountering a syntax
     * or semantic error in the input.
     */
    public static class ParseException extends IllegalArgumentException {
        ParseException(StreamTokenizer state, String expected) {
            super("expected " + expected + ", saw " + state.toString());
        }
    }

    // A sentinel instance used to indicate a null string.
    static final String NULL_STRING = new String("<TypedProperties:NULL_STRING>");

    // Constants used to represent the supported types.
    static final int TYPE_UNSET = 'x';
    static final int TYPE_BOOLEAN = 'Z';
    static final int TYPE_BYTE = 'I' | 1 << 8;
    // TYPE_CHAR: character literal syntax not supported; use short.
    static final int TYPE_SHORT = 'I' | 2 << 8;
    static final int TYPE_INT = 'I' | 4 << 8;
    static final int TYPE_LONG = 'I' | 8 << 8;
    static final int TYPE_FLOAT = 'F' | 4 << 8;
    static final int TYPE_DOUBLE = 'F' | 8 << 8;
    static final int TYPE_STRING = 'L' | 's' << 8;
    static final int TYPE_ERROR = -1;

    /**
     * Converts a case-insensitive string to an internal type constant.
     *
     * @param typeName the type name to convert
     * @return the type constant that corresponds to {@code typeName},
     *         or {@code TYPE_ERROR} if the type is unknown
     */
    static int interpretType(String typeName) {
        if ("unset".equalsIgnoreCase(typeName)) {
            return TYPE_UNSET;
        } else if ("boolean".equalsIgnoreCase(typeName)) {
            return TYPE_BOOLEAN;
        } else if ("byte".equalsIgnoreCase(typeName)) {
            return TYPE_BYTE;
        } else if ("short".equalsIgnoreCase(typeName)) {
            return TYPE_SHORT;
        } else if ("int".equalsIgnoreCase(typeName)) {
            return TYPE_INT;
        } else if ("long".equalsIgnoreCase(typeName)) {
            return TYPE_LONG;
        } else if ("float".equalsIgnoreCase(typeName)) {
            return TYPE_FLOAT;
        } else if ("double".equalsIgnoreCase(typeName)) {
            return TYPE_DOUBLE;
        } else if ("string".equalsIgnoreCase(typeName)) {
            return TYPE_STRING;
        }
        return TYPE_ERROR;
    }

    /**
     * Consumes EOL tokens.
     * Returns when a non-EOL token is found.
     *
     * @param st The {@code StreamTokenizer} to read tokens from
     * @return &gt; 0 if an EOL token was seen, &lt; 0 if EOF was seen,
     *         0 if no tokens were consumed
     */
    static int eatEols(StreamTokenizer st) throws IOException {
        int token;
        boolean eolSeen = false;
        do {
            token = st.nextToken();
            if (token == StreamTokenizer.TT_EOF) {
                return -1;
            } else if (token == StreamTokenizer.TT_EOL) {
                eolSeen = true;
            }
        } while (token == StreamTokenizer.TT_EOL);
        st.pushBack();
        return eolSeen ? 1 : 0;
    }

    /**
     * Parses the data in the reader.
     *
     * @param r The {@code Reader} containing input data to parse
     * @param map The {@code Map} to insert parameter values into
     * @throws ParseException if the input data is malformed
     * @throws IOException if there is a problem reading from the {@code Reader}
     */
    static void parse(Reader r, Map<String, Object> map) throws ParseException, IOException {
        final StreamTokenizer st = initTokenizer(r);

        /* A property name must be a valid fully-qualified class + package name.
         * We don't support Unicode, though.
         */
        final String identifierPattern = "[a-zA-Z_$][0-9a-zA-Z_$]*";
        final Pattern propertyNamePattern =
            Pattern.compile("(" + identifierPattern + "\\.)*" + identifierPattern);


        boolean eolNeeded = false;
        while (true) {
            int token;

            // Eat one or more EOL, or quit on EOF.
            int eolStatus = eatEols(st);
            if (eolStatus < 0) {
                // EOF occurred.
                break;
            } else if (eolNeeded && eolStatus == 0) {
                throw new ParseException(st, "end of line or end of file");
            }

            // Read the property name.
            token = st.nextToken();
            if (token != StreamTokenizer.TT_WORD) {
                throw new ParseException(st, "property name");
            }
            final String propertyName = st.sval;
            if (!propertyNamePattern.matcher(propertyName).matches()) {
                throw new ParseException(st, "valid property name");
            }
            st.sval = null;

            // Read the type.
            token = st.nextToken();
            if (token != StreamTokenizer.TT_WORD) {
                throw new ParseException(st, "type name");
            }
            final int type = interpretType(st.sval);
            if (type == TYPE_ERROR) {
                throw new ParseException(st, "valid type name");
            }
            st.sval = null;

            if (type == TYPE_UNSET) {
                map.remove(propertyName);
            } else {
                // Expect '='.
                token = st.nextToken();
                if (token != '=') {
                    throw new ParseException(st, "'='");
                }

                // Read a value of the appropriate type, and insert into the map.
                final Object value = parseValue(st, type);
                final Object oldValue = map.remove(propertyName);
                if (oldValue != null) {
                    // TODO: catch the case where a string is set to null and then
                    //       the same property is defined with a different type.
                    if (value.getClass() != oldValue.getClass()) {
                        throw new ParseException(st,
                            "(property previously declared as a different type)");
                    }
                }
                map.put(propertyName, value);
            }

            // Require that we see at least one EOL before the next token.
            eolNeeded = true;
        }
    }

    /**
     * Parses the next token in the StreamTokenizer as the specified type.
     *
     * @param st The token source
     * @param type The type to interpret next token as
     * @return a Boolean, Number subclass, or String representing the value.
     *         Null strings are represented by the String instance NULL_STRING
     * @throws IOException if there is a problem reading from the {@code StreamTokenizer}
     */
    static Object parseValue(StreamTokenizer st, final int type) throws IOException {
        final int token = st.nextToken();

        if (type == TYPE_BOOLEAN) {
            if (token != StreamTokenizer.TT_WORD) {
                throw new ParseException(st, "boolean constant");
            }

            if ("true".equalsIgnoreCase(st.sval)) {
                return Boolean.TRUE;
            } else if ("false".equalsIgnoreCase(st.sval)) {
                return Boolean.FALSE;
            }

            throw new ParseException(st, "boolean constant");
        } else if ((type & 0xff) == 'I') {
            if (token != StreamTokenizer.TT_WORD) {
                throw new ParseException(st, "integer constant");
            }

            /* Parse the string.  Long.decode() handles C-style integer constants
             * ("0x" -> hex, "0" -> octal).  It also treats numbers with a prefix of "#" as
             * hex, but our syntax intentionally does not list '#' as a word character.
             */
            long value;
            try {
                value = Long.decode(st.sval);
            } catch (NumberFormatException ex) {
                throw new ParseException(st, "integer constant");
            }

            // Ensure that the type can hold this value, and return.
            int width = (type >> 8) & 0xff;
            switch (width) {
            case 1:
                if (value < Byte.MIN_VALUE || value > Byte.MAX_VALUE) {
                    throw new ParseException(st, "8-bit integer constant");
                }
                return new Byte((byte)value);
            case 2:
                if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) {
                    throw new ParseException(st, "16-bit integer constant");
                }
                return new Short((short)value);
            case 4:
                if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
                    throw new ParseException(st, "32-bit integer constant");
                }
                return new Integer((int)value);
            case 8:
                if (value < Long.MIN_VALUE || value > Long.MAX_VALUE) {
                    throw new ParseException(st, "64-bit integer constant");
                }
                return new Long(value);
            default:
                throw new IllegalStateException(
                    "Internal error; unexpected integer type width " + width);
            }
        } else if ((type & 0xff) == 'F') {
            if (token != StreamTokenizer.TT_WORD) {
                throw new ParseException(st, "float constant");
            }

            // Parse the string.
            /* TODO: Maybe just parse as float or double, losing precision if necessary.
             *       Parsing as double and converting to float can change the value
             *       compared to just parsing as float.
             */
            double value;
            try {
                /* TODO: detect if the string representation loses precision
                 *       when being converted to a double.
                 */
                value = Double.parseDouble(st.sval);
            } catch (NumberFormatException ex) {
                throw new ParseException(st, "float constant");
            }

            // Ensure that the type can hold this value, and return.
            if (((type >> 8) & 0xff) == 4) {
                // This property is a float; make sure the value fits.
                double absValue = Math.abs(value);
                if (absValue != 0.0 && !Double.isInfinite(value) && !Double.isNaN(value)) {
                    if (absValue < Float.MIN_VALUE || absValue > Float.MAX_VALUE) {
                        throw new ParseException(st, "32-bit float constant");
                    }
                }
                return new Float((float)value);
            } else {
                // This property is a double; no need to truncate.
                return new Double(value);
            }
        } else if (type == TYPE_STRING) {
            // Expect a quoted string or the word "null".
            if (token == '"') {
                return st.sval;
            } else if (token == StreamTokenizer.TT_WORD && "null".equalsIgnoreCase(st.sval)) {
                return NULL_STRING;
            }
            throw new ParseException(st, "double-quoted string or 'null'");
        }

        throw new IllegalStateException("Internal error; unknown type " + type);
    }


    /**
     * Creates an empty TypedProperties instance.
     */
    public TypedProperties() {
        super();
    }

    /**
     * Loads zero or more properties from the specified Reader.
     * Properties that have already been loaded are preserved unless
     * the new Reader overrides or unsets earlier values for the
     * same properties.
     *
     * File syntax:
     *
     *     &lt;property-name&gt; &lt;type&gt; = &lt;value&gt;
     *     &lt;property-name&gt; unset
     *
     *     '#' is a comment character; it and anything appearing after it
     *     on the line is ignored.
     *
     *     Blank lines are ignored.
     *
     *     The only required whitespace is between the property name
     *     and the type.
     *
     *     Property assignments may not be split across multiple lines.
     *
     *     &lt;property-name&gt; is a valid fully-qualified class name
     *     (one or more valid identifiers separated by dot characters).
     *
     *     &lt;type&gt; is one of {boolean, byte, short, int, long,
     *     float, double, string}, and is case-insensitive.
     *
     *     &lt;value&gt; depends on the type:
     *     - boolean: one of {true, false} (case-insensitive)
     *     - byte, short, int, long: a valid Java integer constant
     *       (including non-base-10 constants like 0xabc and 074)
     *       whose value does not overflow the type.  NOTE: these are
     *       interpreted as Java integer values, so they are all signed.
     *     - float, double: a valid Java floating-point constant.
     *       If the type is float, the value must fit in 32 bits.
     *     - string: a double-quoted string value, or the word {@code null}.
     *       NOTE: the contents of the string must be 7-bit clean ASCII;
     *       C-style octal escapes are recognized, but Unicode escapes are not.
     *
     *
     * @param r The Reader to load properties from
     * @throws IOException if an error occurs when reading the data
     * @throws IllegalArgumentException if the data is malformed
     */
    public void load(Reader r) throws IOException {
        parse(r, this);
    }

    @Override
    public Object get(Object key) {
        Object value = super.get(key);
        if (value == NULL_STRING) {
            return null;
        }
        return value;
    }

    /*
     * Getters with explicit defaults
     */

    /**
     * An unchecked exception that is thrown if a {@code get&lt;TYPE&gt;()} method
     * is used to retrieve a parameter whose type does not match the method name.
     */
    public static class TypeException extends IllegalArgumentException {
        TypeException(String property, Object value, String requestedType) {
            super(property + " has type " + value.getClass().getName() +
                ", not " + requestedType);
        }
    }

    /**
     * Returns the value of a boolean property, or the default if the property
     * has not been defined.
     *
     * @param property The name of the property to return
     * @param def The default value to return if the property is not set
     * @return the value of the property
     * @throws TypeException if the property is set and is not a boolean
     */
    public boolean getBoolean(String property, boolean def) {
        Object value = super.get(property);
        if (value == null) {
            return def;
        }
        if (value instanceof Boolean) {
            return ((Boolean)value).booleanValue();
        }
        throw new TypeException(property, value, "boolean");
    }

    /**
     * Returns the value of a byte property, or the default if the property
     * has not been defined.
     *
     * @param property The name of the property to return
     * @param def The default value to return if the property is not set
     * @return the value of the property
     * @throws TypeException if the property is set and is not a byte
     */
    public byte getByte(String property, byte def) {
        Object value = super.get(property);
        if (value == null) {
            return def;
        }
        if (value instanceof Byte) {
            return ((Byte)value).byteValue();
        }
        throw new TypeException(property, value, "byte");
    }

    /**
     * Returns the value of a short property, or the default if the property
     * has not been defined.
     *
     * @param property The name of the property to return
     * @param def The default value to return if the property is not set
     * @return the value of the property
     * @throws TypeException if the property is set and is not a short
     */
    public short getShort(String property, short def) {
        Object value = super.get(property);
        if (value == null) {
            return def;
        }
        if (value instanceof Short) {
            return ((Short)value).shortValue();
        }
        throw new TypeException(property, value, "short");
    }

    /**
     * Returns the value of an integer property, or the default if the property
     * has not been defined.
     *
     * @param property The name of the property to return
     * @param def The default value to return if the property is not set
     * @return the value of the property
     * @throws TypeException if the property is set and is not an integer
     */
    public int getInt(String property, int def) {
        Object value = super.get(property);
        if (value == null) {
            return def;
        }
        if (value instanceof Integer) {
            return ((Integer)value).intValue();
        }
        throw new TypeException(property, value, "int");
    }

    /**
     * Returns the value of a long property, or the default if the property
     * has not been defined.
     *
     * @param property The name of the property to return
     * @param def The default value to return if the property is not set
     * @return the value of the property
     * @throws TypeException if the property is set and is not a long
     */
    public long getLong(String property, long def) {
        Object value = super.get(property);
        if (value == null) {
            return def;
        }
        if (value instanceof Long) {
            return ((Long)value).longValue();
        }
        throw new TypeException(property, value, "long");
    }

    /**
     * Returns the value of a float property, or the default if the property
     * has not been defined.
     *
     * @param property The name of the property to return
     * @param def The default value to return if the property is not set
     * @return the value of the property
     * @throws TypeException if the property is set and is not a float
     */
    public float getFloat(String property, float def) {
        Object value = super.get(property);
        if (value == null) {
            return def;
        }
        if (value instanceof Float) {
            return ((Float)value).floatValue();
        }
        throw new TypeException(property, value, "float");
    }

    /**
     * Returns the value of a double property, or the default if the property
     * has not been defined.
     *
     * @param property The name of the property to return
     * @param def The default value to return if the property is not set
     * @return the value of the property
     * @throws TypeException if the property is set and is not a double
     */
    public double getDouble(String property, double def) {
        Object value = super.get(property);
        if (value == null) {
            return def;
        }
        if (value instanceof Double) {
            return ((Double)value).doubleValue();
        }
        throw new TypeException(property, value, "double");
    }

    /**
     * Returns the value of a string property, or the default if the property
     * has not been defined.
     *
     * @param property The name of the property to return
     * @param def The default value to return if the property is not set
     * @return the value of the property
     * @throws TypeException if the property is set and is not a string
     */
    public String getString(String property, String def) {
        Object value = super.get(property);
        if (value == null) {
            return def;
        }
        if (value == NULL_STRING) {
            return null;
        } else if (value instanceof String) {
            return (String)value;
        }
        throw new TypeException(property, value, "string");
    }

    /*
     * Getters with implicit defaults
     */

    /**
     * Returns the value of a boolean property, or false
     * if the property has not been defined.
     *
     * @param property The name of the property to return
     * @return the value of the property
     * @throws TypeException if the property is set and is not a boolean
     */
    public boolean getBoolean(String property) {
        return getBoolean(property, false);
    }

    /**
     * Returns the value of a byte property, or 0
     * if the property has not been defined.
     *
     * @param property The name of the property to return
     * @return the value of the property
     * @throws TypeException if the property is set and is not a byte
     */
    public byte getByte(String property) {
        return getByte(property, (byte)0);
    }

    /**
     * Returns the value of a short property, or 0
     * if the property has not been defined.
     *
     * @param property The name of the property to return
     * @return the value of the property
     * @throws TypeException if the property is set and is not a short
     */
    public short getShort(String property) {
        return getShort(property, (short)0);
    }

    /**
     * Returns the value of an integer property, or 0
     * if the property has not been defined.
     *
     * @param property The name of the property to return
     * @return the value of the property
     * @throws TypeException if the property is set and is not an integer
     */
    public int getInt(String property) {
        return getInt(property, 0);
    }

    /**
     * Returns the value of a long property, or 0
     * if the property has not been defined.
     *
     * @param property The name of the property to return
     * @return the value of the property
     * @throws TypeException if the property is set and is not a long
     */
    public long getLong(String property) {
        return getLong(property, 0L);
    }

    /**
     * Returns the value of a float property, or 0.0
     * if the property has not been defined.
     *
     * @param property The name of the property to return
     * @return the value of the property
     * @throws TypeException if the property is set and is not a float
     */
    public float getFloat(String property) {
        return getFloat(property, 0.0f);
    }

    /**
     * Returns the value of a double property, or 0.0
     * if the property has not been defined.
     *
     * @param property The name of the property to return
     * @return the value of the property
     * @throws TypeException if the property is set and is not a double
     */
    public double getDouble(String property) {
        return getDouble(property, 0.0);
    }

    /**
     * Returns the value of a String property, or ""
     * if the property has not been defined.
     *
     * @param property The name of the property to return
     * @return the value of the property
     * @throws TypeException if the property is set and is not a string
     */
    public String getString(String property) {
        return getString(property, "");
    }
}
