/*
 * JBoss, Home of Professional Open Source.
 * Copyright 2014 Red Hat, Inc., and individual contributors
 * as indicated by the @author tags.
 *
 * 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 io.undertow.server.handlers.accesslog;

import io.undertow.UndertowLogger;
import io.undertow.Version;
import io.undertow.attribute.AuthenticationTypeExchangeAttribute;
import io.undertow.attribute.BytesSentAttribute;
import io.undertow.attribute.CompositeExchangeAttribute;
import io.undertow.attribute.ConstantExchangeAttribute;
import io.undertow.attribute.CookieAttribute;
import io.undertow.attribute.DateTimeAttribute;
import io.undertow.attribute.ExchangeAttribute;
import io.undertow.attribute.ExchangeAttributeParser;
import io.undertow.attribute.ExchangeAttributes;
import io.undertow.attribute.LocalIPAttribute;
import io.undertow.attribute.QueryStringAttribute;
import io.undertow.attribute.QuotingExchangeAttribute;
import io.undertow.attribute.ReadOnlyAttributeException;
import io.undertow.attribute.RemoteIPAttribute;
import io.undertow.attribute.RemoteUserAttribute;
import io.undertow.attribute.RequestHeaderAttribute;
import io.undertow.attribute.RequestMethodAttribute;
import io.undertow.attribute.RequestProtocolAttribute;
import io.undertow.attribute.RequestSchemeAttribute;
import io.undertow.attribute.RequestURLAttribute;
import io.undertow.attribute.ResponseCodeAttribute;
import io.undertow.attribute.ResponseHeaderAttribute;
import io.undertow.attribute.ResponseTimeAttribute;
import io.undertow.attribute.SecureExchangeAttribute;
import io.undertow.attribute.SubstituteEmptyWrapper;
import io.undertow.server.HttpServerExchange;
import io.undertow.util.HeaderValues;
import io.undertow.util.Headers;
import io.undertow.util.HttpString;

import java.io.IOException;
import java.io.StringReader;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * Parser that transforms an extended access log format string into a
 * Undertow access log format string.
 *
 * @author Stuart Douglas
 */
public class ExtendedAccessLogParser {

    /**
     * parser that is used to access servlet context attributes, to avoid bringing in servlet
     * context dependencies
     */
    private final ExchangeAttributeParser parser;

    public ExtendedAccessLogParser(ClassLoader classLoader) {
        this.parser = ExchangeAttributes.parser(classLoader, QuotingExchangeAttribute.WRAPPER);
    }

    private static class PatternTokenizer {
        private StringReader sr = null;
        private StringBuilder buf = new StringBuilder();
        private boolean ended = false;
        private boolean subToken;
        private boolean parameter;

        PatternTokenizer(String str) {
            sr = new StringReader(str);
        }

        public boolean hasSubToken() {
            return subToken;
        }

        public boolean hasParameter() {
            return parameter;
        }

        public String getToken() throws IOException {
            if (ended)
                return null;

            String result = null;
            subToken = false;
            parameter = false;

            int c = sr.read();
            while (c != -1) {
                switch (c) {
                    case ' ':
                        result = buf.toString();
                        buf = new StringBuilder();
                        buf.append((char) c);
                        return result;
                    case '-':
                        result = buf.toString();
                        buf = new StringBuilder();
                        subToken = true;
                        return result;
                    case '(':
                        result = buf.toString();
                        buf = new StringBuilder();
                        parameter = true;
                        return result;
                    case ')':
                        result = buf.toString();
                        buf = new StringBuilder();
                        break;
                    default:
                        buf.append((char) c);
                }
                c = sr.read();
            }
            ended = true;
            if (buf.length() != 0) {
                return buf.toString();
            } else {
                return null;
            }
        }

        public String getParameter() throws IOException {
            String result;
            if (!parameter) {
                return null;
            }
            parameter = false;
            int c = sr.read();
            while (c != -1) {
                if (c == ')') {
                    result = buf.toString();
                    buf = new StringBuilder();
                    return result;
                }
                buf.append((char) c);
                c = sr.read();
            }
            return null;
        }

        public String getWhiteSpaces() throws IOException {
            if (isEnded())
                return "";
            StringBuilder whiteSpaces = new StringBuilder();
            if (buf.length() > 0) {
                whiteSpaces.append(buf);
                buf = new StringBuilder();
            }
            int c = sr.read();
            while (Character.isWhitespace((char) c)) {
                whiteSpaces.append((char) c);
                c = sr.read();
            }
            if (c == -1) {
                ended = true;
            } else {
                buf.append((char) c);
            }
            return whiteSpaces.toString();
        }

        public boolean isEnded() {
            return ended;
        }

        public String getRemains() throws IOException {
            StringBuilder remains = new StringBuilder();
            for (int c = sr.read(); c != -1; c = sr.read()) {
                remains.append((char) c);
            }
            return remains.toString();
        }

    }

    public ExchangeAttribute parse(String pattern) {
        List<ExchangeAttribute> list = new ArrayList<>();

        PatternTokenizer tokenizer = new PatternTokenizer(pattern);
        try {

            // Ignore leading whitespace.
            tokenizer.getWhiteSpaces();

            if (tokenizer.isEnded()) {
                UndertowLogger.ROOT_LOGGER.extendedAccessLogEmptyPattern();
                return null;
            }

            String token = tokenizer.getToken();
            while (token != null) {
                if (UndertowLogger.ROOT_LOGGER.isDebugEnabled()) {
                    UndertowLogger.ROOT_LOGGER.debug("token = " + token);
                }
                ExchangeAttribute element = getLogElement(token, tokenizer);
                if (element == null) {
                    break;
                }
                list.add(element);
                String whiteSpaces = tokenizer.getWhiteSpaces();
                if (whiteSpaces.length() > 0) {
                    list.add(new ConstantExchangeAttribute(whiteSpaces));
                }
                if (tokenizer.isEnded()) {
                    break;
                }
                token = tokenizer.getToken();
            }
            if (UndertowLogger.ROOT_LOGGER.isDebugEnabled()) {
                UndertowLogger.ROOT_LOGGER.debug("finished decoding with element size of: " + list.size());
            }
            return new CompositeExchangeAttribute(list.toArray(new ExchangeAttribute[list.size()]));
        } catch (IOException e) {
            UndertowLogger.ROOT_LOGGER.extendedAccessLogPatternParseError(e);
            return null;
        }
    }

    protected ExchangeAttribute getLogElement(String token, PatternTokenizer tokenizer) throws IOException {
        if ("date".equals(token)) {
            return new DateTimeAttribute("yyyy-MM-dd", "GMT");
        } else if ("time".equals(token)) {
            if (tokenizer.hasSubToken()) {
                String nextToken = tokenizer.getToken();
                if ("taken".equals(nextToken)) {
                    //if response timing are not enabled we just print a '-'
                    return new SubstituteEmptyWrapper.SubstituteEmptyAttribute(new ResponseTimeAttribute(TimeUnit.SECONDS), "-");
                }
            } else {
                return new DateTimeAttribute("HH:mm:ss", "GMT");
            }
        } else if ("bytes".equals(token)) {
            return new BytesSentAttribute(true);
        } else if ("cached".equals(token)) {
            /* I don't know how to evaluate this! */
            return new ConstantExchangeAttribute("-");
        } else if ("c".equals(token)) {
            String nextToken = tokenizer.getToken();
            if ("ip".equals(nextToken)) {
                return RemoteIPAttribute.INSTANCE;
            } else if ("dns".equals(nextToken)) {
                return new ExchangeAttribute() {
                    @Override
                    public String readAttribute(HttpServerExchange exchange) {
                        final InetSocketAddress peerAddress = exchange.getConnection().getPeerAddress(InetSocketAddress.class);

                        try {
                            return peerAddress.getHostName();
                        } catch (Throwable e) {
                            return peerAddress.getHostString();
                        }
                    }

                    @Override
                    public void writeAttribute(HttpServerExchange exchange, String newValue) throws ReadOnlyAttributeException {
                        throw new ReadOnlyAttributeException();
                    }
                };
            }
        } else if ("s".equals(token)) {
            String nextToken = tokenizer.getToken();
            if ("ip".equals(nextToken)) {
                return LocalIPAttribute.INSTANCE;
            } else if ("dns".equals(nextToken)) {
                return new ExchangeAttribute() {
                    @Override
                    public String readAttribute(HttpServerExchange exchange) {
                        try {
                            return exchange.getConnection().getLocalAddress(InetSocketAddress.class).getHostName();
                        } catch (Throwable e) {
                            return "localhost";
                        }
                    }

                    @Override
                    public void writeAttribute(HttpServerExchange exchange, String newValue) throws ReadOnlyAttributeException {
                        throw new ReadOnlyAttributeException();
                    }

                };
            }
        } else if ("cs".equals(token)) {
            return getClientToServerElement(tokenizer);
        } else if ("sc".equals(token)) {
            return getServerToClientElement(tokenizer);
        } else if ("sr".equals(token) || "rs".equals(token)) {
            return getProxyElement(tokenizer);
        } else if ("x".equals(token)) {
            return getXParameterElement(tokenizer);
        }
        UndertowLogger.ROOT_LOGGER.extendedAccessLogUnknownToken(token);
        return null;
    }

    protected ExchangeAttribute getClientToServerElement(
            PatternTokenizer tokenizer) throws IOException {
        if (tokenizer.hasSubToken()) {
            String token = tokenizer.getToken();
            if ("method".equals(token)) {
                return RequestMethodAttribute.INSTANCE;
            } else if ("uri".equals(token)) {
                if (tokenizer.hasSubToken()) {
                    token = tokenizer.getToken();
                    if ("stem".equals(token)) {
                        return RequestURLAttribute.INSTANCE;
                    } else if ("query".equals(token)) {
                        return new SubstituteEmptyWrapper.SubstituteEmptyAttribute(QueryStringAttribute.BARE_INSTANCE, "-");
                    }
                } else {
                    return new ExchangeAttribute() {
                        @Override
                        public String readAttribute(HttpServerExchange exchange) {
                            String query = exchange.getDecodedQueryString();

                            if (query.isEmpty()) {
                                return exchange.getRequestURI();
                            } else {
                                StringBuilder buf = new StringBuilder();
                                buf.append(exchange.getRequestURI());
                                buf.append('?');
                                buf.append(exchange.getDecodedQueryString());
                                return buf.toString();
                            }
                        }

                        @Override
                        public void writeAttribute(HttpServerExchange exchange, String newValue) throws ReadOnlyAttributeException {
                            throw new ReadOnlyAttributeException();
                        }

                    };
                }
            }
        } else if (tokenizer.hasParameter()) {
            String parameter = tokenizer.getParameter();
            if (parameter == null) {
                UndertowLogger.ROOT_LOGGER.extendedAccessLogMissingClosing();
                return null;
            }
            return new QuotingExchangeAttribute(new RequestHeaderAttribute(new HttpString(parameter)));
        }
        UndertowLogger.ROOT_LOGGER.extendedAccessLogCannotDecode(tokenizer.getRemains());
        return null;
    }

    protected ExchangeAttribute getServerToClientElement(
            PatternTokenizer tokenizer) throws IOException {
        if (tokenizer.hasSubToken()) {
            String token = tokenizer.getToken();
            if ("status".equals(token)) {
                return ResponseCodeAttribute.INSTANCE;
            } else if ("comment".equals(token)) {
                return new ConstantExchangeAttribute("?");
            }
        } else if (tokenizer.hasParameter()) {
            String parameter = tokenizer.getParameter();
            if (parameter == null) {
                UndertowLogger.ROOT_LOGGER.extendedAccessLogMissingClosing();
                return null;
            }
            return new QuotingExchangeAttribute(new ResponseHeaderAttribute(new HttpString(parameter)));
        }
        UndertowLogger.ROOT_LOGGER.extendedAccessLogCannotDecode(tokenizer.getRemains());
        return null;
    }

    protected ExchangeAttribute getProxyElement(PatternTokenizer tokenizer)
            throws IOException {
        String token = null;
        if (tokenizer.hasSubToken()) {
            tokenizer.getToken();
            return new ConstantExchangeAttribute("-");
        } else if (tokenizer.hasParameter()) {
            tokenizer.getParameter();
            return new ConstantExchangeAttribute("-");
        }
        UndertowLogger.ROOT_LOGGER.extendedAccessLogCannotDecode(token);
        return null;
    }

    protected ExchangeAttribute getXParameterElement(PatternTokenizer tokenizer)
            throws IOException {
        if (!tokenizer.hasSubToken()) {
            UndertowLogger.ROOT_LOGGER.extendedAccessLogBadXParam();
            return null;
        }
        final String token = tokenizer.getToken();
        if (!tokenizer.hasParameter()) {
            UndertowLogger.ROOT_LOGGER.extendedAccessLogBadXParam();
            return null;
        }
        String parameter = tokenizer.getParameter();
        if (parameter == null) {
            UndertowLogger.ROOT_LOGGER.extendedAccessLogMissingClosing();
            return null;
        }
        if ("A".equals(token)) {
            return new SubstituteEmptyWrapper.SubstituteEmptyAttribute(parser.parse("%{sc," + parameter + "}"),"-");
        } else if ("C".equals(token)) {
            return new SubstituteEmptyWrapper.SubstituteEmptyAttribute(new CookieAttribute(parameter),"-");
        } else if ("R".equals(token)) {
            return parser.parse("%{r," + parameter + "}");
        } else if ("S".equals(token)) {
            return new SubstituteEmptyWrapper.SubstituteEmptyAttribute(parser.parse("%{s," + parameter + "}"),"-");
        } else if ("H".equals(token)) {
            return getServletRequestElement(parameter);
        } else if ("P".equals(token)) {
            return new SubstituteEmptyWrapper.SubstituteEmptyAttribute(parser.parse("%{rp," + parameter + "}"),"-");
        } else if ("O".equals(token)) {
            return new QuotingExchangeAttribute(new ExchangeAttribute() {
                @Override
                public String readAttribute(HttpServerExchange exchange) {
                    HeaderValues values = exchange.getResponseHeaders().get(parameter);
                    if (values != null && values.size() > 0) {
                        StringBuilder buffer = new StringBuilder();
                        for (int i = 0; i < values.size(); i++) {
                            String string = values.get(i);
                            buffer.append(string);
                            if (i + 1 < values.size())
                                buffer.append(",");
                        }
                        return buffer.toString();
                    }
                    return null;
                }

                @Override
                public void writeAttribute(HttpServerExchange exchange, String newValue) throws ReadOnlyAttributeException {
                    throw new ReadOnlyAttributeException();
                }
            });
        }
        UndertowLogger.ROOT_LOGGER.extendedAccessLogCannotDecodeXParamValue(token);
        return null;
    }

    protected ExchangeAttribute getServletRequestElement(String parameter) {
        if ("authType".equals(parameter)) {
            return new SubstituteEmptyWrapper.SubstituteEmptyAttribute(AuthenticationTypeExchangeAttribute.INSTANCE,"-");
        } else if ("remoteUser".equals(parameter)) {
            return new SubstituteEmptyWrapper.SubstituteEmptyAttribute(RemoteUserAttribute.INSTANCE,"-");
        } else if ("requestedSessionId".equals(parameter)) {
            return parser.parse("%{REQUESTED_SESSION_ID}");
        } else if ("requestedSessionIdFromCookie".equals(parameter)) {
            return parser.parse("%{REQUESTED_SESSION_ID_FROM_COOKIE}");
        } else if ("requestedSessionIdValid".equals(parameter)) {
            return parser.parse("%{REQUESTED_SESSION_ID_VALID}");
        } else if ("contentLength".equals(parameter)) {
            return new QuotingExchangeAttribute(new RequestHeaderAttribute(Headers.CONTENT_LENGTH));
        } else if ("characterEncoding".equals(parameter)) {
            return parser.parse("%{REQUEST_CHARACTER_ENCODING}");
        } else if ("locale".equals(parameter)) {
            return parser.parse("%{REQUEST_LOCALE}");
        } else if ("protocol".equals(parameter)) {
            return RequestProtocolAttribute.INSTANCE;
        } else if ("scheme".equals(parameter)) {
            return RequestSchemeAttribute.INSTANCE;
        } else if ("secure".equals(parameter)) {
            return SecureExchangeAttribute.INSTANCE;
        }
        UndertowLogger.ROOT_LOGGER.extendedAccessLogCannotDecodeXParamValue(parameter);
        return null;
    }

    public static class ExtendedAccessLogHeaderGenerator implements LogFileHeaderGenerator {

        private final String pattern;

        public ExtendedAccessLogHeaderGenerator(String pattern) {
            this.pattern = pattern;
        }

        @Override
        public String generateHeader() {
            StringBuilder sb = new StringBuilder();
            sb.append("#Fields: ");
            sb.append(pattern);
            sb.append(System.lineSeparator());
            sb.append("#Version: 2.0");
            sb.append(System.lineSeparator());
            sb.append("#Software: ");
            sb.append(Version.getFullVersionString());
            sb.append(System.lineSeparator());
            return sb.toString();
        }
    }

}
