/*
 * 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.any23.encoding;

import org.apache.tika.detect.TextStatistics;
import org.apache.tika.utils.CharsetUtils;
import org.jsoup.nodes.Element;
import org.jsoup.select.Evaluator;
import org.jsoup.select.QueryParser;
import org.jsoup.select.Selector;
import org.rypt.f8.Utf8Statistics;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * @author Hans Brende
 */
class EncodingUtils {

    /**
     * Very efficient method to convert an input stream directly to an ISO-8859-1 encoded string
     */
    static String iso_8859_1(InputStream is) throws IOException {
        StringBuilder chars = new StringBuilder(Math.max(is.available(), 8192));
        byte[] buffer = new byte[8192];
        int n;
        while ((n = is.read(buffer)) != -1) {
            for (int i = 0; i < n; i++) {
                chars.append((char)(buffer[i] & 0xFF));
            }
        }
        return chars.toString();
    }


    //get correct variant, or null if charset is incompatible with stats
    static Charset correctVariant(TextStatistics stats, Charset charset) {
        if (charset == null) {
            return null;
        }
        switch (charset.name()) {
            //ISO-8859-1 variants
            case "ISO-8859-1":
                //Take a hint from icu4j's CharsetRecog_8859_1 and Tika's UniversalEncodingListener:
                // return windows-1252 before ISO-8859-1 if:
                // (1) C1 ctrl chars are used (as in icu4j), or
                // (2) '\r' is used (as in Tika)
                if ((stats.count('\r') != 0 || hasC1Control(stats)) && hasNoneOf(stats, windows1252Illegals)) {
                    try {
                        return forName("windows-1252");
                    } catch (Exception e) {
                        //ignore
                    }
                }
                return iso_8859_1_or_15(stats);
            case "windows-1252":
                return hasNoneOf(stats, windows1252Illegals) ? charset : iso_8859_1_or_15(stats);

            //ISO-8859-2 variants
            case "ISO-8859-2":
                //Take a hint from icu4j's CharsetRecog_8859_2 class:
                // return windows-1250 before ISO-8859-2 if has valid C1 chars
                if (hasC1Control(stats) && hasNoneOf(stats, windows1250Illegals)) {
                    try {
                        return forName("windows-1250");
                    } catch (Exception e) {
                        //ignore
                    }
                }
                return charset;
            case "windows-1250":
                return hasNoneOf(stats, windows1250Illegals) ? charset : charset("ISO-8859-2");

            //ISO-8859-7 variants
            case "ISO-8859-7":
                //Take a hint from icu4j's CharsetRecog_8859_7 class:
                // return windows-1253 before ISO-8859-7 if has valid C1 chars
                if (hasC1Control(stats) && hasNoneOf(stats, windows1253Illegals)) {
                    try {
                        return forName("windows-1253");
                    } catch (Exception e) {
                        //ignore
                    }
                }
                return hasNoneOf(stats, iso_8859_7Illegals) ? charset : null;
            case "windows-1253":
                return hasNoneOf(stats, windows1253Illegals) ? charset :
                        hasNoneOf(stats, iso_8859_7Illegals) ? charset("ISO-8859-7") : null;

            //ISO-8859-8 variants
            case "ISO-8859-8":
            case "ISO-8859-8-I":
                //Take a hint from icu4j's CharsetRecog_8859_8 class:
                // return windows-1255 before ISO-8859-8 if has valid C1 chars
                if (hasC1Control(stats) && hasNoneOf(stats, windows1255Illegals)) {
                    try {
                        return forName("windows-1255");
                    } catch (Exception e) {
                        //ignore
                    }
                }
                return hasNoneOf(stats, iso_8859_8Illegals) ? charset : null;
            case "windows-1255":
                return hasNoneOf(stats, windows1255Illegals) ? charset :
                        hasNoneOf(stats, iso_8859_8Illegals) ? charset("ISO-8859-8") : null;

            //ISO-8859-9 variants
            case "ISO-8859-9":
                //Take a hint from icu4j's CharsetRecog_8859_9 class:
                // return windows-1254 before ISO-8859-9 if has valid C1 chars
                if (hasC1Control(stats) && hasNoneOf(stats, windows1254Illegals)) {
                    try {
                        return forName("windows-1254");
                    } catch (Exception e) {
                        //ignore
                    }
                }
                return charset;
            case "windows-1254":
                return hasNoneOf(stats, windows1254Illegals) ? charset : charset("ISO-8859-9");

            //Others: just make sure no illegal characters are present
            case "windows-1251":
                return hasNoneOf(stats, windows1251Illegals) ? charset : null;
            case "ISO-8859-6":
                return hasNoneOf(stats, iso_8859_6Illegals) ? charset : null;
            default:
                return charset;
        }
    }

    private static Charset iso_8859_1_or_15(TextStatistics stats) {
        //Take a hint from Tika's UniversalEncodingListener:
        // return ISO-8859-15 before ISO-8859-1 if currency/euro symbol is used
        if (stats.count(0xa4) != 0) {
            try {
                return forName("ISO-8859-15");
            } catch (Exception e) {
                //ignore
            }
        }
        return StandardCharsets.ISO_8859_1;
    }

    private static final int[] windows1252Illegals = {0x81, 0x8D, 0x8F, 0x90, 0x9D};
    private static final int[] windows1250Illegals = {0x81, 0x83, 0x88, 0x90, 0x98};
    private static final int[] iso_8859_7Illegals = {0xAE, 0xD2, 0xFF};
    private static final int[] windows1253Illegals = {0x81, 0x88, 0x8A, 0x8C, 0x8D,
            0x8E, 0x8F, 0x90, 0x98, 0x9A, 0x9C, 0x9D, 0x9E, 0x9F, 0xAA, 0xD2, 0xFF};

    private static final int[] windows1255Illegals = {0x81, 0x8A, 0x8C, 0x8D, 0x8E, 0x8F, 0x90, 0x9A,
            0x9C, 0x9D, 0x9E, 0x9F, 0xCA, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF, 0xFB, 0xFC, 0xFF};

    private static final int[] iso_8859_8Illegals = {0xA1, 0xBF, 0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5,
            0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4,
            0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xFB, 0xFC, 0xFF};

    private static final int[] windows1254Illegals = {0x81, 0x8D, 0x8E, 0x8F, 0x90, 0x9D, 0x9E};

    private static final int[] windows1251Illegals = {0x98};

    private static final int[] iso_8859_6Illegals = {0xA1, 0xA2, 0xA3, 0xA5, 0xA6, 0xA7,
            0xA8, 0xA9, 0xAA, 0xAB, 0xAE, 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6,
            0xB7, 0xB8, 0xB9, 0xBA, 0xBC, 0xBD, 0xBE, 0xC0, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF,
            0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF};


    private static boolean hasNoneOf(TextStatistics stats, int[] illegals) {
        for (int i : illegals) {
            if (stats.count(i) != 0) {
                return false;
            }
        }
        return true;
    }

    private static boolean hasC1Control(TextStatistics ts) {
        for (int i = 0x80; i < 0xA0; i++) {
            if (ts.count(i) != 0) {
                return true;
            }
        }
        return false;
    }

    private static class TextStatisticsOptimizedForUtf8 extends TextStatistics {

        private final Utf8Statistics utf8Stats = new Utf8Statistics();

        @Override
        public void addData(byte[] buffer, int offset, int length) {
            super.addData(buffer, offset, length);
            utf8Stats.write(buffer, offset, length);
        }

        @Override
        public boolean looksLikeUTF8() {
            return utf8Stats.looksLikeUtf8();
        }
    }

    /*
     * Returns a custom implementation of Tika's TextStatistics class for an input stream
     */
    static TextStatistics stats(InputStream stream) throws IOException {
        TextStatisticsOptimizedForUtf8 stats = new TextStatisticsOptimizedForUtf8();
        byte[] buffer = new byte[8192];
        int n;
        while ((n = stream.read(buffer)) != -1) {
            stats.addData(buffer, 0, n);
        }
        return stats;
    }

    static Charset forName(String charset) throws Exception {
        try {
            return CharsetUtils.forName(charset);
        } catch (Exception e) {
            //ICU4j sometimes returns 'ISO-8859-8-I', which is unsupported!
            // Cf. https://en.wikipedia.org/wiki/ISO/IEC_8859-8
            // "Nominally ISO-8859-8 (code page 28598) is for 'visual order',
            // and ISO-8859-8-I (code page 38598) is for logical order.
            // But usually in practice, and required for HTML and XML
            // documents, ISO-8859-8 also stands for logical order text."
            charset = charset.replaceAll("(?i)-I\\b", "");
            try {
                return CharsetUtils.forName(charset);
            } catch (Exception ignored) {
                throw e;
            }
        }
    }

    private static Charset charset(String charset) {
        try {
            return forName(charset);
        } catch (Exception e) {
            return null;
        }
    }

    private static final Evaluator charsetMetas = QueryParser
            .parse("meta[http-equiv=content-type], meta[charset]");

    static Charset htmlCharset(TextStatistics stats, Element root) {
        for (Element meta : Selector.select(charsetMetas, root)) {
            Charset foundCharset = correctVariant(stats, charset(meta.attr("charset")));
            if (foundCharset != null) {
                return foundCharset;
            }
            foundCharset = correctVariant(stats, contentTypeCharset(meta.attr("content")));
            if (foundCharset != null) {
                return foundCharset;
            }
        }
        return null;
    }


    private static final Pattern contentTypeCharsetPattern =
            Pattern.compile("(?i)\\bcharset\\s*=[\\s\"']*([^\\s,;\"']+)");

    static Charset contentTypeCharset(CharSequence contentType) {
        if (contentType == null)
            return null;
        Matcher m = contentTypeCharsetPattern.matcher(contentType);
        if (m.find()) {
            try {
                return forName(m.group(1));
            } catch (Exception e) {
                return null;
            }
        }
        return null;
    }

    private static final Pattern xmlEncoding = Pattern.compile(
            "(?is)\\A\\s*<\\?\\s*xml\\s+[^<>]*encoding\\s*=\\s*(?:['\"]\\s*)?([-_:.a-z0-9]+)");

    static Charset xmlCharset(TextStatistics stats, CharSequence str) {
        Matcher matcher = xmlEncoding.matcher(str);
        if (matcher.find()) {
            return correctVariant(stats, charset(matcher.group(1)));
        } else {
            return null;
        }
    }


    //uncomment this handy function to print out invalid bytes for a charset
//    public static void main(String[] args) throws Exception {
//        String[] cs = {
//                "ISO-8859-15",
//                "windows-1252", "ISO-8859-1",
//                "windows-1250", "ISO-8859-2",
//                "windows-1253", "ISO-8859-7",
//                "windows-1255", "ISO-8859-8",
//                "windows-1254", "ISO-8859-9",
//                "windows-1251",
//                "windows-1256",
//                "ISO-8859-5",
//                "ISO-8859-6"
//        };
//
//        for (String name : cs) {
//            Charset c = EncodingUtils.forName(name);
//            if (c.newEncoder().maxBytesPerChar() > 1) {
//                throw new IllegalArgumentException("this method doesn't support " + c);
//            }
//            String line = java.util.stream.IntStream
//                    .range(0, 256)
//                    .filter(i -> new String(new byte[]{(byte) i}, c).getBytes(c)[0] != (byte)i)
//                    .mapToObj(i -> "0x" + Integer.toHexString(i).toUpperCase())
//                    .collect(java.util.stream.Collectors.joining(", ", "undefined " + name + " = {", "};"));
//
//            System.out.println(line);
//        }
//    }

}
