//
//  ========================================================================
//  Copyright (c) 1995-2019 Mort Bay Consulting Pty. Ltd.
//  ------------------------------------------------------------------------
//  All rights reserved. This program and the accompanying materials
//  are made available under the terms of the Eclipse Public License v1.0
//  and Apache License v2.0 which accompanies this distribution.
//
//      The Eclipse Public License is available at
//      http://www.eclipse.org/legal/epl-v10.html
//
//      The Apache License v2.0 is available at
//      http://www.opensource.org/licenses/apache2.0.php
//
//  You may elect to redistribute this code under either of these licenses.
//  ========================================================================
//

package org.eclipse.jetty.http;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.Set;

import org.eclipse.jetty.util.ArrayTrie;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.StringUtil;
import org.eclipse.jetty.util.Trie;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;

/**
 * MIME Type enum and utilities
 */
public class MimeTypes
{

    private static final Logger LOG = Log.getLogger(MimeTypes.class);
    private static final Trie<ByteBuffer> TYPES = new ArrayTrie<ByteBuffer>(512);
    private static final Map<String, String> __dftMimeMap = new HashMap<String, String>();
    private static final Map<String, String> __inferredEncodings = new HashMap<String, String>();
    private static final Map<String, String> __assumedEncodings = new HashMap<String, String>();

    public enum Type
    {
        FORM_ENCODED("application/x-www-form-urlencoded"),
        MESSAGE_HTTP("message/http"),
        MULTIPART_BYTERANGES("multipart/byteranges"),
        MULTIPART_FORM_DATA("multipart/form-data"),

        TEXT_HTML("text/html"),
        TEXT_PLAIN("text/plain"),
        TEXT_XML("text/xml"),
        TEXT_JSON("text/json", StandardCharsets.UTF_8),
        APPLICATION_JSON("application/json", StandardCharsets.UTF_8),

        TEXT_HTML_8859_1("text/html;charset=iso-8859-1", TEXT_HTML),
        TEXT_HTML_UTF_8("text/html;charset=utf-8", TEXT_HTML),

        TEXT_PLAIN_8859_1("text/plain;charset=iso-8859-1", TEXT_PLAIN),
        TEXT_PLAIN_UTF_8("text/plain;charset=utf-8", TEXT_PLAIN),

        TEXT_XML_8859_1("text/xml;charset=iso-8859-1", TEXT_XML),
        TEXT_XML_UTF_8("text/xml;charset=utf-8", TEXT_XML),

        TEXT_JSON_8859_1("text/json;charset=iso-8859-1", TEXT_JSON),
        TEXT_JSON_UTF_8("text/json;charset=utf-8", TEXT_JSON),

        APPLICATION_JSON_8859_1("application/json;charset=iso-8859-1", APPLICATION_JSON),
        APPLICATION_JSON_UTF_8("application/json;charset=utf-8", APPLICATION_JSON);

        private final String _string;
        private final Type _base;
        private final ByteBuffer _buffer;
        private final Charset _charset;
        private final String _charsetString;
        private final boolean _assumedCharset;
        private final HttpField _field;

        Type(String s)
        {
            _string = s;
            _buffer = BufferUtil.toBuffer(s);
            _base = this;
            _charset = null;
            _charsetString = null;
            _assumedCharset = false;
            _field = new PreEncodedHttpField(HttpHeader.CONTENT_TYPE, _string);
        }

        Type(String s, Type base)
        {
            _string = s;
            _buffer = BufferUtil.toBuffer(s);
            _base = base;
            int i = s.indexOf(";charset=");
            _charset = Charset.forName(s.substring(i + 9));
            _charsetString = _charset.toString().toLowerCase(Locale.ENGLISH);
            _assumedCharset = false;
            _field = new PreEncodedHttpField(HttpHeader.CONTENT_TYPE, _string);
        }

        Type(String s, Charset cs)
        {
            _string = s;
            _base = this;
            _buffer = BufferUtil.toBuffer(s);
            _charset = cs;
            _charsetString = _charset == null ? null : _charset.toString().toLowerCase(Locale.ENGLISH);
            _assumedCharset = true;
            _field = new PreEncodedHttpField(HttpHeader.CONTENT_TYPE, _string);
        }

        public ByteBuffer asBuffer()
        {
            return _buffer.asReadOnlyBuffer();
        }

        public Charset getCharset()
        {
            return _charset;
        }

        public String getCharsetString()
        {
            return _charsetString;
        }

        public boolean is(String s)
        {
            return _string.equalsIgnoreCase(s);
        }

        public String asString()
        {
            return _string;
        }

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

        public boolean isCharsetAssumed()
        {
            return _assumedCharset;
        }

        public HttpField getContentTypeField()
        {
            return _field;
        }

        public Type getBaseType()
        {
            return _base;
        }
    }

    public static final Trie<MimeTypes.Type> CACHE = new ArrayTrie<>(512);

    static
    {
        for (MimeTypes.Type type : MimeTypes.Type.values())
        {
            CACHE.put(type.toString(), type);
            TYPES.put(type.toString(), type.asBuffer());

            int charset = type.toString().indexOf(";charset=");
            if (charset > 0)
            {
                String alt = StringUtil.replace(type.toString(), ";charset=", "; charset=");
                CACHE.put(alt, type);
                TYPES.put(alt, type.asBuffer());
            }

            if (type.isCharsetAssumed())
                __assumedEncodings.put(type.asString(), type.getCharsetString());
        }

        String resourceName = "org/eclipse/jetty/http/mime.properties";
        try (InputStream stream = MimeTypes.class.getClassLoader().getResourceAsStream(resourceName))
        {
            if (stream == null)
            {
                LOG.warn("Missing mime-type resource: {}", resourceName);
            }
            else
            {
                try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8))
                {
                    Properties props = new Properties();
                    props.load(reader);
                    props.stringPropertyNames().stream()
                        .filter(x -> x != null)
                        .forEach(x ->
                            __dftMimeMap.put(StringUtil.asciiToLowerCase(x), normalizeMimeType(props.getProperty(x))));

                    if (__dftMimeMap.isEmpty())
                    {
                        LOG.warn("Empty mime types at {}", resourceName);
                    }
                    else if (__dftMimeMap.size() < props.keySet().size())
                    {
                        LOG.warn("Duplicate or null mime-type extension in resource: {}", resourceName);
                    }
                }
                catch (IOException e)
                {
                    LOG.warn(e.toString());
                    LOG.debug(e);
                }
            }
        }
        catch (IOException e)
        {
            LOG.warn(e.toString());
            LOG.debug(e);
        }

        resourceName = "org/eclipse/jetty/http/encoding.properties";
        try (InputStream stream = MimeTypes.class.getClassLoader().getResourceAsStream(resourceName))
        {
            if (stream == null)
                LOG.warn("Missing encoding resource: {}", resourceName);
            else
            {
                try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8))
                {
                    Properties props = new Properties();
                    props.load(reader);
                    props.stringPropertyNames().stream()
                        .filter(t -> t != null)
                        .forEach(t ->
                        {
                            String charset = props.getProperty(t);
                            if (charset.startsWith("-"))
                                __assumedEncodings.put(t, charset.substring(1));
                            else
                                __inferredEncodings.put(t, props.getProperty(t));
                        });

                    if (__inferredEncodings.isEmpty())
                    {
                        LOG.warn("Empty encodings at {}", resourceName);
                    }
                    else if ((__inferredEncodings.size() + __assumedEncodings.size()) < props.keySet().size())
                    {
                        LOG.warn("Null or duplicate encodings in resource: {}", resourceName);
                    }
                }
                catch (IOException e)
                {
                    LOG.warn(e.toString());
                    LOG.debug(e);
                }
            }
        }
        catch (IOException e)
        {
            LOG.warn(e.toString());
            LOG.debug(e);
        }
    }

    private final Map<String, String> _mimeMap = new HashMap<String, String>();

    /**
     * Constructor.
     */
    public MimeTypes()
    {
    }

    public synchronized Map<String, String> getMimeMap()
    {
        return _mimeMap;
    }

    /**
     * @param mimeMap A Map of file extension to mime-type.
     */
    public void setMimeMap(Map<String, String> mimeMap)
    {
        _mimeMap.clear();
        if (mimeMap != null)
        {
            for (Entry<String, String> ext : mimeMap.entrySet())
            {
                _mimeMap.put(StringUtil.asciiToLowerCase(ext.getKey()), normalizeMimeType(ext.getValue()));
            }
        }
    }

    /**
     * Get the MIME type by filename extension.
     * Lookup only the static default mime map.
     *
     * @param filename A file name
     * @return MIME type matching the longest dot extension of the
     * file name.
     */
    public static String getDefaultMimeByExtension(String filename)
    {
        String type = null;

        if (filename != null)
        {
            int i = -1;
            while (type == null)
            {
                i = filename.indexOf(".", i + 1);

                if (i < 0 || i >= filename.length())
                    break;

                String ext = StringUtil.asciiToLowerCase(filename.substring(i + 1));
                if (type == null)
                    type = __dftMimeMap.get(ext);
            }
        }

        if (type == null)
        {
            type = __dftMimeMap.get("*");
        }

        return type;
    }

    /**
     * Get the MIME type by filename extension.
     * Lookup the content and static default mime maps.
     *
     * @param filename A file name
     * @return MIME type matching the longest dot extension of the
     * file name.
     */
    public String getMimeByExtension(String filename)
    {
        String type = null;

        if (filename != null)
        {
            int i = -1;
            while (type == null)
            {
                i = filename.indexOf(".", i + 1);

                if (i < 0 || i >= filename.length())
                    break;

                String ext = StringUtil.asciiToLowerCase(filename.substring(i + 1));
                if (_mimeMap != null)
                    type = _mimeMap.get(ext);
                if (type == null)
                    type = __dftMimeMap.get(ext);
            }
        }

        if (type == null)
        {
            if (_mimeMap != null)
                type = _mimeMap.get("*");
            if (type == null)
                type = __dftMimeMap.get("*");
        }

        return type;
    }

    /**
     * Set a mime mapping
     *
     * @param extension the extension
     * @param type the mime type
     */
    public void addMimeMapping(String extension, String type)
    {
        _mimeMap.put(StringUtil.asciiToLowerCase(extension), normalizeMimeType(type));
    }

    public static Set<String> getKnownMimeTypes()
    {
        return new HashSet<>(__dftMimeMap.values());
    }

    private static String normalizeMimeType(String type)
    {
        MimeTypes.Type t = CACHE.get(type);
        if (t != null)
            return t.asString();

        return StringUtil.asciiToLowerCase(type);
    }

    public static String getCharsetFromContentType(String value)
    {
        if (value == null)
            return null;
        int end = value.length();
        int state = 0;
        int start = 0;
        boolean quote = false;
        int i = 0;
        for (; i < end; i++)
        {
            char b = value.charAt(i);

            if (quote && state != 10)
            {
                if ('"' == b)
                    quote = false;
                continue;
            }

            if (';' == b && state <= 8)
            {
                state = 1;
                continue;
            }

            switch (state)
            {
                case 0:
                    if ('"' == b)
                    {
                        quote = true;
                        break;
                    }
                    break;

                case 1:
                    if ('c' == b)
                        state = 2;
                    else if (' ' != b)
                        state = 0;
                    break;
                case 2:
                    if ('h' == b)
                        state = 3;
                    else
                        state = 0;
                    break;
                case 3:
                    if ('a' == b)
                        state = 4;
                    else
                        state = 0;
                    break;
                case 4:
                    if ('r' == b)
                        state = 5;
                    else
                        state = 0;
                    break;
                case 5:
                    if ('s' == b)
                        state = 6;
                    else
                        state = 0;
                    break;
                case 6:
                    if ('e' == b)
                        state = 7;
                    else
                        state = 0;
                    break;
                case 7:
                    if ('t' == b)
                        state = 8;
                    else
                        state = 0;
                    break;

                case 8:
                    if ('=' == b)
                        state = 9;
                    else if (' ' != b)
                        state = 0;
                    break;

                case 9:
                    if (' ' == b)
                        break;
                    if ('"' == b)
                    {
                        quote = true;
                        start = i + 1;
                        state = 10;
                        break;
                    }
                    start = i;
                    state = 10;
                    break;

                case 10:
                    if (!quote && (';' == b || ' ' == b) ||
                        (quote && '"' == b))
                        return StringUtil.normalizeCharset(value, start, i - start);
            }
        }

        if (state == 10)
            return StringUtil.normalizeCharset(value, start, i - start);

        return null;
    }

    /**
     * Access a mutable map of mime type to the charset inferred from that content type.
     * An inferred encoding is used by when encoding/decoding a stream and is
     * explicitly set in any metadata (eg Content-Type).
     *
     * @return Map of mime type to charset
     */
    public static Map<String, String> getInferredEncodings()
    {
        return __inferredEncodings;
    }

    /**
     * Access a mutable map of mime type to the charset assumed for that content type.
     * An assumed encoding is used by when encoding/decoding a stream, but is not
     * explicitly set in any metadata (eg Content-Type).
     *
     * @return Map of mime type to charset
     */
    public static Map<String, String> getAssumedEncodings()
    {
        return __assumedEncodings;
    }

    @Deprecated
    public static String inferCharsetFromContentType(String contentType)
    {
        return getCharsetAssumedFromContentType(contentType);
    }

    public static String getCharsetInferredFromContentType(String contentType)
    {
        return __inferredEncodings.get(contentType);
    }

    public static String getCharsetAssumedFromContentType(String contentType)
    {
        return __assumedEncodings.get(contentType);
    }

    public static String getContentTypeWithoutCharset(String value)
    {
        int end = value.length();
        int state = 0;
        int start = 0;
        boolean quote = false;
        int i = 0;
        StringBuilder builder = null;
        for (; i < end; i++)
        {
            char b = value.charAt(i);

            if ('"' == b)
            {
                quote = !quote;

                switch (state)
                {
                    case 11:
                        builder.append(b);
                        break;
                    case 10:
                        break;
                    case 9:
                        builder = new StringBuilder();
                        builder.append(value, 0, start + 1);
                        state = 10;
                        break;
                    default:
                        start = i;
                        state = 0;
                }
                continue;
            }

            if (quote)
            {
                if (builder != null && state != 10)
                    builder.append(b);
                continue;
            }

            switch (state)
            {
                case 0:
                    if (';' == b)
                        state = 1;
                    else if (' ' != b)
                        start = i;
                    break;

                case 1:
                    if ('c' == b)
                        state = 2;
                    else if (' ' != b)
                        state = 0;
                    break;
                case 2:
                    if ('h' == b)
                        state = 3;
                    else
                        state = 0;
                    break;
                case 3:
                    if ('a' == b)
                        state = 4;
                    else
                        state = 0;
                    break;
                case 4:
                    if ('r' == b)
                        state = 5;
                    else
                        state = 0;
                    break;
                case 5:
                    if ('s' == b)
                        state = 6;
                    else
                        state = 0;
                    break;
                case 6:
                    if ('e' == b)
                        state = 7;
                    else
                        state = 0;
                    break;
                case 7:
                    if ('t' == b)
                        state = 8;
                    else
                        state = 0;
                    break;
                case 8:
                    if ('=' == b)
                        state = 9;
                    else if (' ' != b)
                        state = 0;
                    break;

                case 9:
                    if (' ' == b)
                        break;
                    builder = new StringBuilder();
                    builder.append(value, 0, start + 1);
                    state = 10;
                    break;

                case 10:
                    if (';' == b)
                    {
                        builder.append(b);
                        state = 11;
                    }
                    break;
                case 11:
                    if (' ' != b)
                        builder.append(b);
            }
        }
        if (builder == null)
            return value;
        return builder.toString();
    }
}
