/*
 * Copyright (C) 2014 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.tools.lint.checks;

import static com.android.tools.lint.checks.PluralsDatabase.FLAG_FEW;
import static com.android.tools.lint.checks.PluralsDatabase.FLAG_MANY;
import static com.android.tools.lint.checks.PluralsDatabase.FLAG_MULTIPLE_ONE;
import static com.android.tools.lint.checks.PluralsDatabase.FLAG_MULTIPLE_TWO;
import static com.android.tools.lint.checks.PluralsDatabase.FLAG_MULTIPLE_ZERO;
import static com.android.tools.lint.checks.PluralsDatabase.FLAG_ONE;
import static com.android.tools.lint.checks.PluralsDatabase.FLAG_TWO;
import static com.android.tools.lint.checks.PluralsDatabase.FLAG_ZERO;
import static com.android.tools.lint.checks.PluralsDatabase.Quantity;
import static com.android.tools.lint.checks.PluralsDatabase.Quantity.few;
import static com.android.tools.lint.checks.PluralsDatabase.Quantity.many;
import static com.android.tools.lint.checks.PluralsDatabase.Quantity.one;
import static com.android.tools.lint.checks.PluralsDatabase.Quantity.two;
import static com.android.tools.lint.checks.PluralsDatabase.Quantity.zero;

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.resources.LocaleManager;
import com.google.common.base.Charsets;
import com.google.common.base.Objects;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.io.ByteStreams;

import junit.framework.TestCase;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

public class PluralsDatabaseTest extends TestCase {
    public void testGetRelevant() {
        PluralsDatabase db = PluralsDatabase.get();
        assertNull(db.getRelevant("unknown"));
        EnumSet<Quantity> relevant = db.getRelevant("en");
        assertNotNull(relevant);
        assertEquals(1, relevant.size());
        assertSame(Quantity.one, relevant.iterator().next());

        relevant = db.getRelevant("cs");
        assertNotNull(relevant);
        assertEquals(EnumSet.of(Quantity.few, Quantity.one), relevant);
    }

    public void testFindExamples() {
        PluralsDatabase db = PluralsDatabase.get();

        //noinspection ConstantConditions
        assertEquals("1, 101, 201, 301, 401, 501, 601, 701, 1001, \u2026",
                db.findIntegerExamples("sl", Quantity.one));

        assertEquals("1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, \u2026",
                db.findIntegerExamples("ru", Quantity.one));
    }

    public void testHasMultiValue() {
        PluralsDatabase db = PluralsDatabase.get();

        assertFalse(db.hasMultipleValuesForQuantity("en", Quantity.one));
        assertFalse(db.hasMultipleValuesForQuantity("en", Quantity.two));
        assertFalse(db.hasMultipleValuesForQuantity("en", Quantity.few));
        assertFalse(db.hasMultipleValuesForQuantity("en", Quantity.many));

        assertTrue(db.hasMultipleValuesForQuantity("br", Quantity.two));
        assertTrue(db.hasMultipleValuesForQuantity("mk", Quantity.one));
        assertTrue(db.hasMultipleValuesForQuantity("lv", Quantity.zero));
    }

    /**
     * If the lint unit test data/ folder contains a plurals.txt database file,
     * this test will parse that file and ensure that our current database produces
     * exactly the same results as those inferred from the file. If not, it will
     * dump out updated data structures for the database.
     */
    public void testDatabaseAccurate() {
        List<String> languages = new ArrayList<String>(LocaleManager.getLanguageCodes());
        Collections.sort(languages);
        PluralsTextDatabase db = PluralsTextDatabase.get();
        db.ensureInitialized();

        if (db.getSetName("en") == null) {
            // plurals.txt not found
            System.out.println("No plurals.txt database included; not checking consistency");
            return;
        }

        // Ensure that the two databases (the plurals.txt backed one and our actual
        // database) fully agree on everything
        PluralsDatabase pdb = PluralsDatabase.get();
        for (String language : languages) {
            if (!Objects.equal(pdb.getRelevant(language), db.getRelevant(language))) {
                dumpDatabaseTables();
                assertEquals(language, pdb.getRelevant(language), db.getRelevant(language));
            }
            if (db.getSetName(language) == null) {
                continue;
            }
            for (Quantity q : Quantity.values()) {
                boolean mv1 = pdb.hasMultipleValuesForQuantity(language, q); // binary database
                boolean mv2 = db.hasMultipleValuesForQuantity(language, q); // text database
                if (mv1 != mv2) {
                    dumpDatabaseTables();
                    assertEquals(language + " with quantity " + q, mv1, mv2);
                }
                if (mv2) {
                    String e1 = pdb.findIntegerExamples(language, q);
                    String e2 = db.findIntegerExamples(language, q);
                    if (!Objects.equal(e1, e2)) {
                        dumpDatabaseTables();
                        assertEquals(language, e1, e2);
                    }
                }
            }
        }
    }

    private static void dumpDatabaseTables() {
        List<String> languages = new ArrayList<String>(LocaleManager.getLanguageCodes());
        Collections.sort(languages);
        PluralsTextDatabase db = PluralsTextDatabase.get();
        db.ensureInitialized();

        db.getRelevant("en"); // ensure initialized
        Map<String,String> languageMap = Maps.newHashMap();
        Map<String,EnumSet<Quantity>> setMap = Maps.newHashMap();
        for (String language : languages) {
            String set = db.getSetName(language);
            if (set == null) {
                continue;
            }
            EnumSet<Quantity> quantitySet = db.getRelevant(language);
            if (quantitySet == null) {
                // No plurals data for this language. For example, in ICU 52, no
                // plurals data for the "nv" language (Navajo).
                continue;
            }
            assertNotNull(language, quantitySet);
            setMap.put(set, quantitySet);
            languageMap.put(set, language); // Could be multiple
        }

        List<String> setNames = Lists.newArrayList(setMap.keySet());
        Collections.sort(setNames);

        // Compute uniqueness
        Map<String,String> sameAs = Maps.newHashMap();
        for (int i = 0, n = setNames.size(); i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                String iSetName = setNames.get(i);
                String jSetName = setNames.get(j);
                assertNotNull(iSetName);
                assertNotNull(jSetName);
                EnumSet<Quantity> iSet = setMap.get(iSetName);
                EnumSet<Quantity> jSet = setMap.get(jSetName);
                assertNotNull(iSet);
                assertNotNull(jSet);
                if (iSet.equals(jSet)) {
                    String alias = sameAs.get(iSetName);
                    if (alias != null) {
                        iSetName = alias;
                    }
                    sameAs.put(jSetName, iSetName);
                    break;
                }
            }
        }

        final String indent = "    ";
        StringBuilder sb = new StringBuilder();

        // Multi Value Set names
        Set<String> sets = Sets.newHashSet();
        for (String language : languages) {
            String set = db.getSetName(language);
            sets.add(set);
            languageMap.put(set, language); // Could be multiple
        }

        Map<String,Integer> indices = Maps.newTreeMap();
        int index = 0;
        for (String set : setNames) {
            indices.put(set, index++);
        }

        // Language indices
        Map<String,Integer> languageIndices = Maps.newTreeMap();
        index = 0;
        for (String language : languages) {
            String set = db.getSetName(language);
            if (set == null) {
                continue;
            }

            languageIndices.put(language, index++);
        }

        Map<String, String> zero = computeExamples(db, Quantity.zero, sets, languageMap);
        Map<String, String> one = computeExamples(db, Quantity.one, sets, languageMap);
        Map<String, String> two = computeExamples(db, Quantity.two, sets, languageMap);

        // Language map
        sb.setLength(0);
        sb.append("/** Set of language codes relevant to plurals data */\n");
        sb.append("private static final String[] LANGUAGE_CODES = new String[] {\n");
        int column = 0;
        index = 0;
        sb.append(indent);
        for (String language : languages) {
            String set = db.getSetName(language);
            if (set == null) {
                continue;
            }
            sb.append('"').append(language).append("\", ");
            column++;
            if (column == 10) {
                sb.append("\n");
                sb.append(indent);
                column = 0;
            }
            assertEquals((int)languageIndices.get(language), index);
            index++;
        }
        stripLastComma(sb);
        sb.append("\n};\n");
        System.out.println(sb);

        // Quantity map
        sb.setLength(0);
        sb.append("/**\n"
                + " * Relevant flags for each language (corresponding to each language listed\n"
                + " * in the same position in {@link #LANGUAGE_CODES})\n"
                + " */\n");
        sb.append("private static final int[] FLAGS = new int[] {\n");
        column = 0;
        sb.append(indent);
        index = 0;
        for (String language : languages) {
            String setName = db.getSetName(language);
            if (setName == null) {
                continue;
            }
            assertEquals((int)languageIndices.get(language), index);

            // Compute flag
            int flag = 0;
            EnumSet<Quantity> relevant = db.getRelevant(language);
            assertNotNull(relevant);
            if (relevant.contains(Quantity.zero)) {
                flag |= FLAG_ZERO;
            }
            if (relevant.contains(Quantity.one)) {
                flag |= FLAG_ONE;
            }
            if (relevant.contains(Quantity.two)) {
                flag |= FLAG_TWO;
            }
            if (relevant.contains(Quantity.few)) {
                flag |= FLAG_FEW;
            }
            if (relevant.contains(Quantity.many)) {
                flag |= FLAG_MANY;
            }
            if (zero.containsKey(setName)) {
                flag |= FLAG_MULTIPLE_ZERO;
            }
            if (one.containsKey(setName)) {
                flag |= FLAG_MULTIPLE_ONE;
            }
            if (two.containsKey(setName)) {
                flag |= FLAG_MULTIPLE_TWO;
            }

            sb.append(String.format(Locale.US, "0x%04x, ", flag));
            column++;
            if (column == 8) {
                sb.append("\n");
                sb.append(indent);
                column = 0;
            }

            index++;
        }
        stripLastComma(sb);
        sb.append("\n};\n");
        System.out.println(sb);

        // Switch statement methods for examples
        printSwitch(db, Quantity.zero, languages, languageIndices, indices, zero);
        printSwitch(db, Quantity.one, languages, languageIndices, indices, one);
        printSwitch(db, Quantity.two, languages, languageIndices, indices, two);

    }

    private static String stripLastComma(String s) {
        StringBuilder stringBuilder = new StringBuilder(s);
        stripLastComma(stringBuilder);
        return stringBuilder.toString();
    }

    private static void stripLastComma(@NonNull StringBuilder sb) {
        for (int i = sb.length() - 1; i >= 1; i--) {
            char c = sb.charAt(i);
            if (!Character.isWhitespace(c)) {
                if (c == ',') {
                    sb.setLength(i);
                }
                break;
            }
        }
    }

    private static Map<String, String> computeExamples(PluralsTextDatabase db, Quantity quantity,
            Set<String> sets, Map<String, String> languageMap) {

        Map<String, String> setsWithExamples = Maps.newHashMap();
        for (String set : sets) {
            String language = languageMap.get(set);
            String examples = db.findIntegerExamples(language, quantity);
            if (examples != null && examples.indexOf(',') != -1) {
                setsWithExamples.put(set, examples);
            }
        }

        return setsWithExamples;
    }


    private static void printSwitch(
            PluralsTextDatabase db,
            Quantity quantity,
            List<String> languages,
            Map<String,Integer> languageIndices,
            Map<String, Integer> indices,
            Map<String, String> setsWithExamples) {

        List<String> sorted = new ArrayList<String>(setsWithExamples.keySet());
        Collections.sort(sorted);

        StringBuilder sb = new StringBuilder();
        String quantityName = quantity.name();
        quantityName = Character.toUpperCase(quantityName.charAt(0)) + quantityName.substring(1);
        sb.append("    @Nullable\n"
                + "    private static String getExampleForQuantity").append(quantityName)
                               .append("(@NonNull String language) {\n"
                                       + "        int index = getLanguageIndex(language);\n"
                                       + "        switch (index) {\n");

        for (Map.Entry<String, Integer> entry : indices.entrySet()) {
            String set = entry.getKey();
            if (!setsWithExamples.containsKey(set)) {
                continue;
            }
            String example = setsWithExamples.get(set);
            example = example.replace("…", "\\u2026");
            sb.append("            // ").append(set).append("\n");
            for (String language : languages) {
                String setName = db.getSetName(language);
                if (set.equals(setName)) {
                    int languageIndex = languageIndices.get(language);
                    sb.append("            case ");
                    sb.append(languageIndex).append(": // ").append(language).append("\n");
                }
            }

            sb.append("                return ");
            sb.append("\"").append(example).append("\"");
            sb.append(";\n");
        }

        sb.append("            case -1:\n"
                + "            default:\n"
                + "                return null;\n"
                + "        }\n"
                + "    }\n");

        System.out.println(sb);
    }

    /**
     * Plurals database backed by a plurals.txt file from ICU
     */
    private static class PluralsTextDatabase {
        private static final boolean DEBUG = false;
        private static final EnumSet<Quantity> NONE = EnumSet.noneOf(Quantity.class);

        private static final PluralsTextDatabase sInstance = new PluralsTextDatabase();

        private Map<String, EnumSet<Quantity>> mPlurals;
        private Map<Quantity, Set<String>> mMultiValueSetNames = Maps.newEnumMap(Quantity.class);
        private String mDescriptions;
        private int mRuleSetOffset;
        private Map<String,String> mSetNamePerLanguage;

        @NonNull
        public static PluralsTextDatabase get() {
            return sInstance;
        }

        @Nullable
        public EnumSet<Quantity> getRelevant(@NonNull String language) {
            ensureInitialized();
            EnumSet<Quantity> set = mPlurals.get(language);
            if (set == null) {
                String s = getLocaleData(language);
                if (s == null) {
                    mPlurals.put(language, NONE);
                    return null;
                }
                // Process each item and look for relevance

                set = EnumSet.noneOf(Quantity.class);
                int length = s.length();
                for (int offset = 0, end; offset < length; offset = end + 1) {
                    for (; offset < length; offset++) {
                        if (!Character.isWhitespace(s.charAt(offset))) {
                            break;
                        }
                    }

                    int begin = s.indexOf('{', offset);
                    if (begin == -1) {
                        break;
                    }
                    end = findBalancedEnd(s, begin);
                    if (end == -1) {
                        end = length;
                    }

                    if (s.startsWith("other{", offset)) {
                        // Not included
                        continue;
                    }

                    // Make sure the rule references applies to integers:
                    // Rule definition mentions n or i or @integer
                    //
                    //    n  absolute value of the source number (integer and decimals).
                    //    i  integer digits of n.
                    //    v  number of visible fraction digits in n, with trailing zeros.
                    //    w  number of visible fraction digits in n, without trailing zeros.
                    //    f  visible fractional digits in n, with trailing zeros.
                    //    t  visible fractional digits in n, without trailing zeros.
                    boolean appliesToIntegers = false;
                    boolean inQuotes = false;
                    for (int i = begin + 1; i < end - 1; i++) {
                        char c = s.charAt(i);
                        if (c == '"') {
                            inQuotes = !inQuotes;
                        } else if (inQuotes) {
                            if (c == '@') {
                                if (s.startsWith("@integer", i)) {
                                    appliesToIntegers = true;
                                    break;
                                } else {
                                    // @decimal always comes after @integer
                                    break;
                                }
                            } else if ((c == 'i' || c == 'n') && Character
                                    .isWhitespace(s.charAt(i + 1))) {
                                appliesToIntegers = true;
                                break;
                            }
                        }
                    }

                    if (!appliesToIntegers) {
                        if (DEBUG) {
                            System.out.println("Skipping quantity " + s.substring(offset, begin)
                                    + " in set for locale " + language + " (" + getSetName(language)
                                    + ")");
                        }
                        continue;
                    }

                    if (s.startsWith("one{", offset)) {
                        set.add(one);
                    } else if (s.startsWith("few{", offset)) {
                        set.add(few);
                    } else if (s.startsWith("many{", offset)) {
                        set.add(many);
                    } else if (s.startsWith("two{", offset)) {
                        set.add(two);
                    } else if (s.startsWith("zero{", offset)) {
                        set.add(zero);
                    } else {
                        // Unexpected quantity: ignore
                        if (DEBUG) {
                            assert false : s.substring(offset, Math.min(offset + 10, length));
                        }
                    }
                }

                mPlurals.put(language, set);
            }
            return set == NONE ? null : set;
        }

        public boolean hasMultipleValuesForQuantity(
                @NonNull String language,
                @NonNull Quantity quantity) {
            if (quantity == Quantity.one || quantity == Quantity.two || quantity == Quantity.zero) {
                ensureInitialized();
                String setName = getSetName(language);
                if (setName != null) {
                    Set<String> names = mMultiValueSetNames.get(quantity);
                    assert names != null : quantity;
                    return names.contains(setName);
                }
            }

            return false;
        }

        private void ensureInitialized() {
            if (mPlurals == null) {
                initialize();
            }
        }

        @SuppressWarnings({"UnnecessaryLocalVariable", "UnusedDeclaration"})
        private void initialize() {
            // Sets where more than a single integer maps to the quantity. Take for example
            // set 10:
            //    set10{
            //        one{
            //            "n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81,"
            //            " 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0"
            //            ", 101.0, 1001.0, …"
            //        }
            //    }
            // Here we see that both "1" and "21" will match the "one" category.
            // Note that this only applies to integers (since getQuantityString only takes integer)
            // whereas the plurals data also covers fractions. I was not sure what to do about
            // set17:
            //    set17{
            //        one{"i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6"}
            //    }
            // since it looks to me like this only differs from 1 in the fractional part.
            mSetNamePerLanguage = Maps.newHashMapWithExpectedSize(20);
            mMultiValueSetNames = Maps.newEnumMap(Quantity.class);
            Quantity[] quantities = new Quantity[] { Quantity.zero, Quantity.one, Quantity.two };
            for (Quantity quantity : quantities) {
                mMultiValueSetNames.put(quantity, Sets.<String>newHashSet());
                for (String language : LocaleManager.getLanguageCodes()) {
                    String examples = findIntegerExamples(language, quantity);
                    if (examples != null && examples.indexOf(',') != -1) {
                        String setName = getSetName(language);
                        assertNotNull(setName);
                        Set<String> set = mMultiValueSetNames.get(quantity);
                        assertNotNull(set);
                        set.add(setName);
                    }
                }
            }

            mPlurals = Maps.newHashMapWithExpectedSize(20);
        }

        @Nullable
        public String findIntegerExamples(@NonNull String language, @NonNull Quantity quantity) {
            String data = getQuantityData(language, quantity);
            if (data != null) {
                int index = data.indexOf("@integer");
                if (index == -1) {
                    return null;
                }
                int start = index + "@integer".length();
                int end = data.indexOf('@', start);
                if (end == -1) {
                    end = data.length();
                }
                return data.substring(start, end).trim();
            }

            return null;
        }

        @NonNull
        private String getPluralsDescriptions() {
            if (mDescriptions == null) {
                InputStream stream = PluralsDatabaseTest.class.getResourceAsStream("data/plurals.txt");
                if (stream != null) {
                    try {
                        byte[] bytes = ByteStreams.toByteArray(stream);
                        mDescriptions = new String(bytes, Charsets.UTF_8);
                        mRuleSetOffset = mDescriptions.indexOf("rules{");
                        if (mRuleSetOffset == -1) {
                            if (DEBUG) {
                                assert false;
                            }
                            mDescriptions = "";
                            mRuleSetOffset = 0;
                        }

                    } catch (IOException e) {
                        try {
                            stream.close();
                        } catch (IOException e1) {
                            // Stupid API.
                        }
                    }
                }
                if (mDescriptions == null) {
                    mDescriptions = "";
                }
            }
            return mDescriptions;
        }

        @Nullable
        public String getQuantityData(@NonNull String language, @NonNull Quantity quantity) {
            String data = getLocaleData(language);
            if (data == null) {
                return null;
            }
            String quantityDeclaration = quantity.name() + "{";
            int quantityStart = data.indexOf(quantityDeclaration);
            if (quantityStart == -1) {
                return null;
            }
            int quantityEnd = findBalancedEnd(data, quantityStart);
            if (quantityEnd == -1) {
                return null;
            }
            //String s = data.substring(quantityStart + quantityDeclaration.length(), quantityEnd);
            StringBuilder sb = new StringBuilder();
            boolean inString = false;
            for (int i = quantityStart + quantityDeclaration.length(); i < quantityEnd; i++) {
                char c = data.charAt(i);
                if (c == '"') {
                    inString = !inString;
                } else if (inString) {
                    sb.append(c);
                }
            }
            return sb.toString();
        }

        @Nullable
        public String getSetName(@NonNull String language) {
            String name = mSetNamePerLanguage.get(language);
            if (name == null) {
                name = findSetName(language);
                if (name == null) {
                    name = ""; // Store "" instead of null so we remember search result
                }
                mSetNamePerLanguage.put(language, name);
            }

            return name.isEmpty() ? null : name;
        }

        @Nullable
        private String findSetName(@NonNull String language) {
            String data = getPluralsDescriptions();
            int index = data.indexOf("locales{");
            if (index == -1) {
                return null;
            }
            int end = data.indexOf("locales_ordinals{", index + 1);
            if (end == -1) {
                return null;
            }
            String languageDeclaration = " " + language + "{\"";
            index = data.indexOf(languageDeclaration);
            if (index == -1 || index >= end) {
                return null;
            }
            int setEnd = data.indexOf('\"', index + languageDeclaration.length());
            if (setEnd == -1) {
                return null;
            }
            return data.substring(index + languageDeclaration.length(), setEnd).trim();
        }

        @Nullable
        public String getLocaleData(@NonNull String language) {
            String set = getSetName(language);
            if (set == null) {
                return null;
            }
            String data = getPluralsDescriptions();
            int setStart = data.indexOf(set + "{", mRuleSetOffset);
            if (setStart == -1) {
                return null;
            }
            int setEnd = findBalancedEnd(data, setStart);
            if (setEnd == -1) {
                return null;
            }
            return data.substring(setStart + set.length() + 1, setEnd);
        }

        private static int findBalancedEnd(String data, int offset) {
            int balance = 0;
            int length = data.length();
            for (; offset < length; offset++) {
                char c = data.charAt(offset);
                if (c == '{') {
                    balance++;
                } else if (c == '}') {
                    balance--;
                    if (balance == 0) {
                        return offset;
                    }
                }
            }

            return -1;
        }
    }
}