package com.fsck.k9.preferences;


import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.TreeMap;

import com.fsck.k9.FontSizes;
import com.fsck.k9.K9;
import timber.log.Timber;

/*
 * TODO:
 * - use the default values defined in GlobalSettings and AccountSettings when creating new
 *   accounts
 * - think of a better way to validate enums than to use the resource arrays (i.e. get rid of
 *   ResourceArrayValidator); maybe even use the settings description for the settings UI
 * - add unit test that validates the default values are actually valid according to the validator
 */

class Settings {
    /**
     * Version number of global and account settings.
     *
     * <p>
     * This value is used as "version" attribute in the export file. It needs to be incremented
     * when a global or account setting is added or removed, or when the format of a setting
     * is changed (e.g. add a value to an enum).
     * </p>
     *
     * @see SettingsExporter
     */
    public static final int VERSION = 98;

    static Map<String, Object> validate(int version, Map<String, TreeMap<Integer, SettingsDescription<?>>> settings,
            Map<String, String> importedSettings, boolean useDefaultValues) {

        Map<String, Object> validatedSettings = new HashMap<>();
        for (Map.Entry<String, TreeMap<Integer, SettingsDescription<?>>> versionedSetting : settings.entrySet()) {

            // Get the setting description with the highest version lower than or equal to the
            // supplied content version.
            TreeMap<Integer, SettingsDescription<?>> versions = versionedSetting.getValue();
            SortedMap<Integer, SettingsDescription<?>> headMap = versions.headMap(version + 1);

            // Skip this setting if it was introduced after 'version'
            if (headMap.isEmpty()) {
                continue;
            }

            Integer settingVersion = headMap.lastKey();
            SettingsDescription<?> desc = versions.get(settingVersion);

            // Skip this setting if it is no longer used in 'version'
            if (desc == null) {
                continue;
            }

            String key = versionedSetting.getKey();

            boolean useDefaultValue;
            if (!importedSettings.containsKey(key)) {
                Timber.v("Key \"%s\" wasn't found in the imported file.%s",
                        key,
                        (useDefaultValues) ? " Using default value." : "");

                useDefaultValue = useDefaultValues;
            } else {
                String prettyValue = importedSettings.get(key);
                try {
                    Object internalValue = desc.fromPrettyString(prettyValue);
                    validatedSettings.put(key, internalValue);
                    useDefaultValue = false;
                } catch (InvalidSettingValueException e) {
                    Timber.v("Key \"%s\" has invalid value \"%s\" in imported file. %s",
                            key,
                            prettyValue,
                            (useDefaultValues) ? "Using default value." : "Skipping.");

                    useDefaultValue = useDefaultValues;
                }
            }

            if (useDefaultValue) {
                Object defaultValue = desc.getDefaultValue();
                validatedSettings.put(key, defaultValue);
            }
        }

        return validatedSettings;
    }

    /**
     * Convert settings from the internal representation to the string representation used in the
     * preference storage.
     *
     * @param settings
     *         The map of settings to convert.
     * @param settingDescriptions
     *         The structure containing the {@link SettingsDescription} objects that will be used
     *         to convert the setting values.
     *
     * @return The settings converted to the string representation used in the preference storage.
     */
    public static Map<String, String> convert(Map<String, Object> settings,
            Map<String, TreeMap<Integer, SettingsDescription<?>>> settingDescriptions) {
        Map<String, String> serializedSettings = new HashMap<>();

        for (Entry<String, Object> setting : settings.entrySet()) {
            String settingName = setting.getKey();
            Object internalValue = setting.getValue();

            TreeMap<Integer, SettingsDescription<?>> versionedSetting = settingDescriptions.get(settingName);
            Integer highestVersion = versionedSetting.lastKey();

            //noinspection unchecked
            SettingsDescription<Object> settingDesc =
                (SettingsDescription<Object>) versionedSetting.get(highestVersion);

            if (settingDesc != null) {
                String stringValue = settingDesc.toString(internalValue);

                serializedSettings.put(settingName, stringValue);
            } else {
                Timber.w("Settings.convert() called with a setting that should have been removed: %s", settingName);
            }
        }

        return serializedSettings;
    }

    /**
     * Creates a {@link TreeMap} linking version numbers to {@link SettingsDescription} instances.
     *
     * <p>
     * This {@code TreeMap} is used to quickly find the {@code SettingsDescription} belonging to a
     * content version as read by {@link SettingsImporter}. See e.g.
     * {@link Settings#validate(int, Map, Map, boolean)}.
     * </p>
     *
     * @param versionDescriptions
     *         A list of descriptions for a specific setting mapped to version numbers. Never
     *         {@code null}.
     *
     * @return A {@code TreeMap} using the version number as key, the {@code SettingsDescription}
     *         as value.
     */
    static TreeMap<Integer, SettingsDescription<?>> versions(V... versionDescriptions) {
        TreeMap<Integer, SettingsDescription<?>> map = new TreeMap<>();
        for (V v : versionDescriptions) {
            map.put(v.version, v.description);
        }
        return map;
    }


    static class InvalidSettingValueException extends Exception {
        private static final long serialVersionUID = 1L;

        public InvalidSettingValueException() {
        }

        public InvalidSettingValueException(String message) {
            super(message);
        }
    }

    /**
     * Describes a setting.
     *
     * <p>
     * Instances of this class are used to convert the string representations of setting values to
     * an internal representation (e.g. an integer) and back.
     * </p><p>
     * Currently we use two different string representations:
     * </p>
     * <ol>
     *   <li>
     *   The one that is used by the internal preference {@link Storage}. It is usually obtained by
     *   calling {@code toString()} on the internal representation of the setting value (see e.g.
     *   {@link K9#save(StorageEditor)}).
     *   </li>
     *   <li>
     *   The "pretty" version that is used by the import/export settings file (e.g. colors are
     *   exported in #rrggbb format instead of a integer string like "-8734021").
     *   </li>
     * </ol>
     * <p>
     * <strong>Note:</strong>
     * For the future we should aim to get rid of the "internal" string representation. The
     * "pretty" version makes reading a database dump easier and the performance impact should be
     * negligible.
     * </p>
     */
    abstract static class SettingsDescription<T> {
        /**
         * The setting's default value (internal representation).
         */
        T defaultValue;

        SettingsDescription(T defaultValue) {
            this.defaultValue = defaultValue;
        }

        /**
         * Get the default value.
         *
         * @return The internal representation of the default value.
         */
        public T getDefaultValue() {
            return defaultValue;
        }

        /**
         * Convert a setting's value to the string representation.
         *
         * @param value
         *         The internal representation of a setting's value.
         *
         * @return The string representation of {@code value}.
         */
        public String toString(T value) {
            return value.toString();
        }

        /**
         * Parse the string representation of a setting's value .
         *
         * @param value
         *         The string representation of a setting's value.
         *
         * @return The internal representation of the setting's value.
         *
         * @throws InvalidSettingValueException
         *         If {@code value} contains an invalid value.
         */
        public abstract T fromString(String value) throws InvalidSettingValueException;

        /**
         * Convert a setting value to the "pretty" string representation.
         *
         * @param value
         *         The setting's value.
         *
         * @return A pretty-printed version of the setting's value.
         */
        public String toPrettyString(T value) {
            return toString(value);
        }

        /**
         * Convert the pretty-printed version of a setting's value to the internal representation.
         *
         * @param value
         *         The pretty-printed version of the setting's value. See
         *         {@link #toPrettyString(Object)}.
         *
         * @return The internal representation of the setting's value.
         *
         * @throws InvalidSettingValueException
         *         If {@code value} contains an invalid value.
         */
        public T fromPrettyString(String value) throws InvalidSettingValueException {
            return fromString(value);
        }
    }

    static class V {
        public final Integer version;
        public final SettingsDescription<?> description;

        V(Integer version, SettingsDescription<?> description) {
            this.version = version;
            this.description = description;
        }
    }

    static class StringSetting extends SettingsDescription<String> {
        StringSetting(String defaultValue) {
            super(defaultValue);
        }

        @Override
        public String fromString(String value) {
            return value;
        }

        @Override
        public String toString(String value) {
            return value;
        }
    }

    static class BooleanSetting extends SettingsDescription<Boolean> {
        BooleanSetting(boolean defaultValue) {
            super(defaultValue);
        }

        @Override
        public Boolean fromString(String value) throws InvalidSettingValueException {
            if (Boolean.TRUE.toString().equals(value)) {
                return true;
            } else if (Boolean.FALSE.toString().equals(value)) {
                return false;
            }
            throw new InvalidSettingValueException();
        }
    }

    static class ColorSetting extends SettingsDescription<Integer> {
        ColorSetting(int defaultValue) {
            super(defaultValue);
        }

        @Override
        public Integer fromString(String value) throws InvalidSettingValueException {
            try {
                return Integer.parseInt(value);
            } catch (NumberFormatException e) {
                throw new InvalidSettingValueException();
            }
        }

        @Override
        public String toPrettyString(Integer value) {
            int color = value & 0x00FFFFFF;
            return String.format("#%06x", color);
        }

        @Override
        public Integer fromPrettyString(String value) throws InvalidSettingValueException {
            try {
                if (value.length() == 7) {
                    return Integer.parseInt(value.substring(1), 16) | 0xFF000000;
                }
            } catch (NumberFormatException e) { /* do nothing */ }

            throw new InvalidSettingValueException();
        }
    }

    static class EnumSetting<T extends Enum<T>> extends SettingsDescription<T> {
        private Class<T> enumClass;

        EnumSetting(Class<T> enumClass, T defaultValue) {
            super(defaultValue);
            this.enumClass = enumClass;
        }

        @Override
        public T fromString(String value) throws InvalidSettingValueException {
            try {
                return Enum.valueOf(enumClass, value);
            } catch (Exception e) {
                throw new InvalidSettingValueException();
            }
        }
    }

    /**
     * A setting that has multiple valid values but doesn't use an {@link Enum} internally.
     *
     * @param <T>
     *         The type of the internal representation (e.g. {@code Integer}).
     */
    abstract static class PseudoEnumSetting<T> extends SettingsDescription<T> {
        PseudoEnumSetting(T defaultValue) {
            super(defaultValue);
        }

        protected abstract Map<T, String> getMapping();

        @Override
        public String toPrettyString(T value) {
            return getMapping().get(value);
        }

        @Override
        public T fromPrettyString(String value) throws InvalidSettingValueException {
            for (Entry<T, String> entry : getMapping().entrySet()) {
                if (entry.getValue().equals(value)) {
                    return entry.getKey();
                }
            }

            throw new InvalidSettingValueException();
        }
    }

    static class FontSizeSetting extends PseudoEnumSetting<Integer> {
        private final Map<Integer, String> mapping;

        FontSizeSetting(int defaultValue) {
            super(defaultValue);

            Map<Integer, String> mapping = new HashMap<>();
            mapping.put(FontSizes.FONT_10SP, "tiniest");
            mapping.put(FontSizes.FONT_12SP, "tiny");
            mapping.put(FontSizes.SMALL, "smaller");
            mapping.put(FontSizes.FONT_16SP, "small");
            mapping.put(FontSizes.MEDIUM, "medium");
            mapping.put(FontSizes.FONT_20SP, "large");
            mapping.put(FontSizes.LARGE, "larger");
            this.mapping = Collections.unmodifiableMap(mapping);
        }

        @Override
        protected Map<Integer, String> getMapping() {
            return mapping;
        }

        @Override
        public Integer fromString(String value) throws InvalidSettingValueException {
            try {
                Integer fontSize = Integer.parseInt(value);
                if (mapping.containsKey(fontSize)) {
                    return fontSize;
                }
            } catch (NumberFormatException e) { /* do nothing */ }

            throw new InvalidSettingValueException();
        }
    }

    static class WebFontSizeSetting extends PseudoEnumSetting<Integer> {
        private final Map<Integer, String> mapping;

        WebFontSizeSetting(int defaultValue) {
            super(defaultValue);

            Map<Integer, String> mapping = new HashMap<>();
            mapping.put(1, "smallest");
            mapping.put(2, "smaller");
            mapping.put(3, "normal");
            mapping.put(4, "larger");
            mapping.put(5, "largest");
            this.mapping = Collections.unmodifiableMap(mapping);
        }

        @Override
        protected Map<Integer, String> getMapping() {
            return mapping;
        }

        @Override
        public Integer fromString(String value) throws InvalidSettingValueException {
            try {
                Integer fontSize = Integer.parseInt(value);
                if (mapping.containsKey(fontSize)) {
                    return fontSize;
                }
            } catch (NumberFormatException e) { /* do nothing */ }

            throw new InvalidSettingValueException();
        }
    }

    static class IntegerRangeSetting extends SettingsDescription<Integer> {
        private int start;
        private int end;

        IntegerRangeSetting(int start, int end, int defaultValue) {
            super(defaultValue);
            this.start = start;
            this.end = end;
        }

        @Override
        public Integer fromString(String value) throws InvalidSettingValueException {
            try {
                int intValue = Integer.parseInt(value);
                if (start <= intValue && intValue <= end) {
                    return intValue;
                }
            } catch (NumberFormatException e) { /* do nothing */ }

            throw new InvalidSettingValueException();
        }
    }
}
