package com.fsck.k9.mail.store.imap;


import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import com.fsck.k9.logging.Timber;
import com.fsck.k9.mail.K9MailLib;
import com.fsck.k9.mail.filter.FixedLengthInputStream;
import com.fsck.k9.mail.filter.PeekableInputStream;
import okio.Buffer;

import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_IMAP;


class ImapResponseParser {
    private PeekableInputStream inputStream;
    private ImapResponse response;
    private Exception exception;


    public ImapResponseParser(PeekableInputStream in) {
        this.inputStream = in;
    }

    public ImapResponse readResponse() throws IOException {
        return readResponse(null);
    }

    /**
     * Reads the next response available on the stream and returns an {@code ImapResponse} object that represents it.
     */
    public ImapResponse readResponse(ImapResponseCallback callback) throws IOException {
        try {
            int peek = inputStream.peek();
            if (peek == '+') {
                readContinuationRequest(callback);
            } else if (peek == '*') {
                readUntaggedResponse(callback);
            } else {
                readTaggedResponse(callback);
            }

            if (exception != null) {
                throw new ImapResponseParserException("readResponse(): Exception in callback method", exception);
            }

            return response;
        } finally {
            response = null;
            exception = null;
        }
    }

    private void readContinuationRequest(ImapResponseCallback callback) throws IOException {
        parseCommandContinuationRequest();
        response = ImapResponse.newContinuationRequest(callback);

        skipIfSpace();
        String rest = readStringUntilEndOfLine();
        response.add(rest);
    }

    private void readUntaggedResponse(ImapResponseCallback callback) throws IOException {
        parseUntaggedResponse();
        response = ImapResponse.newUntaggedResponse(callback);

        readTokens(response);
    }

    private void readTaggedResponse(ImapResponseCallback callback) throws IOException {
        String tag = parseTaggedResponse();
        response = ImapResponse.newTaggedResponse(callback, tag);

        readTokens(response);
    }

    List<ImapResponse> readStatusResponse(String tag, String commandToLog, String logId,
            UntaggedHandler untaggedHandler) throws IOException, NegativeImapResponseException {

        List<ImapResponse> responses = new ArrayList<>();

        ImapResponse response;
        do {
            response = readResponse();

            if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) {
                Timber.v("%s<<<%s", logId, response);
            }

            if (response.getTag() != null && !response.getTag().equalsIgnoreCase(tag)) {
                Timber.w("After sending tag %s, got tag response from previous command %s for %s", tag, response, logId);

                Iterator<ImapResponse> responseIterator = responses.iterator();

                while (responseIterator.hasNext()) {
                    ImapResponse delResponse = responseIterator.next();
                    if (delResponse.getTag() != null || delResponse.size() < 2 || (
                            !equalsIgnoreCase(delResponse.get(1), Responses.EXISTS) &&
                            !equalsIgnoreCase(delResponse.get(1), Responses.EXPUNGE))) {
                        responseIterator.remove();
                    }
                }
                response = null;
                continue;
            }

            if (response.getTag() == null && untaggedHandler != null) {
                untaggedHandler.handleAsyncUntaggedResponse(response);
            }

            responses.add(response);
        } while (response == null || response.getTag() == null);

        if (response.size() < 1 || !equalsIgnoreCase(response.get(0), Responses.OK)) {
            String message = "Command: " + commandToLog + "; response: " + response.toString();
            throw new NegativeImapResponseException(message, responses);
        }

        return responses;
    }

    private void readTokens(ImapResponse response) throws IOException {
        response.clear();

        Object firstToken = readToken(response);

        checkTokenIsString(firstToken);
        String symbol = (String) firstToken;

        response.add(symbol);

        if (isStatusResponse(symbol)) {
            parseResponseText(response);
        } else if (equalsIgnoreCase(symbol, Responses.LIST) || equalsIgnoreCase(symbol, Responses.LSUB)) {
            parseListResponse(response);
        } else {
            Object token;
            while ((token = readToken(response)) != null) {
                if (!(token instanceof ImapList)) {
                    response.add(token);
                }
            }
        }
    }

    /**
     * Parse {@code resp-text} tokens
     * <p>
     * Responses "OK", "PREAUTH", "BYE", "NO", "BAD", and continuation request responses can
     * contain {@code resp-text} tokens. We parse the {@code resp-text-code} part as tokens and
     * read the rest as sequence of characters to avoid the parser interpreting things like
     * "{123}" as start of a literal.
     * </p>
     * <p>Example:</p>
     * <p>
     * {@code * OK [UIDVALIDITY 3857529045] UIDs valid}
     * </p>
     * <p>
     * See RFC 3501, Section 9 Formal Syntax (resp-text)
     * </p>
     *
     * @param parent
     *         The {@link ImapResponse} instance that holds the parsed tokens of the response.
     *
     * @throws IOException
     *         If there's a network error.
     *
     * @see #isStatusResponse(String)
     */
    private void parseResponseText(ImapResponse parent) throws IOException {
        skipIfSpace();

        int next = inputStream.peek();
        if (next == '[') {
            parseList(parent, '[', ']');
            skipIfSpace();
        }

        String rest = readStringUntilEndOfLine();

        if (rest != null && !rest.isEmpty()) {
            // The rest is free-form text.
            parent.add(rest);
        }
    }

    private void parseListResponse(ImapResponse response) throws IOException {
        expect(' ');
        parseList(response, '(', ')');
        expect(' ');
        String delimiter = parseQuotedOrNil();
        response.add(delimiter);
        expect(' ');
        String name = parseString();
        response.add(name);
        expect('\r');
        expect('\n');
    }

    private void skipIfSpace() throws IOException {
        if (inputStream.peek() == ' ') {
            expect(' ');
        }
    }

    /**
     * Reads the next token of the response. The token can be one of: String -
     * for NIL, QUOTED, NUMBER, ATOM. Object - for LITERAL.
     * ImapList - for PARENTHESIZED LIST. Can contain any of the above
     * elements including List.
     *
     * @return The next token in the response or null if there are no more
     * tokens.
     */
    private Object readToken(ImapResponse response) throws IOException {
        while (true) {
            Object token = parseToken(response);
            if (token == null || !(token.equals(")") || token.equals("]"))) {
                return token;
            }
        }
    }

    private Object parseToken(ImapList parent) throws IOException {
        while (true) {
            int ch = inputStream.peek();

            if (ch == '(') {
                return parseList(parent, '(', ')');
            } else if (ch == '[') {
                return parseList(parent, '[', ']');
            } else if (ch == ')') {
                expect(')');
                return ")";
            } else if (ch == ']') {
                expect(']');
                return "]";
            } else if (ch == '"') {
                return parseQuoted();
            } else if (ch == '{') {
                return parseLiteral();
            } else if (ch == ' ') {
                expect(' ');
            } else if (ch == '\r') {
                expect('\r');
                expect('\n');
                return null;
            } else if (ch == '\n') {
                expect('\n');
                return null;
            } else if (ch == '\t') {
                expect('\t');
            } else {
                return parseBareString(true);
            }
        }
    }

    private String parseString() throws IOException {
        int ch = inputStream.peek();

        if (ch == '"') {
            return parseQuoted();
        } else if (ch == '{') {
            return (String) parseLiteral();
        } else {
            return parseBareString(false);
        }
    }

    private boolean parseCommandContinuationRequest() throws IOException {
        expect('+');
        return true;
    }

    private void parseUntaggedResponse() throws IOException {
        expect('*');
        expect(' ');
    }

    private String parseTaggedResponse() throws IOException {
        return readStringUntil(' ');
    }

    private ImapList parseList(ImapList parent, char start, char end) throws IOException {
        expect(start);

        ImapList list = new ImapList();
        parent.add(list);

        String endString = String.valueOf(end);

        Object token;
        while (true) {
            token = parseToken(list);
            if (token == null) {
                return null;
            } else if (token.equals(endString)) {
                break;
            } else if (!(token instanceof ImapList)) {
                list.add(token);
            }
        }

        return list;
    }

    private String parseBareString(boolean allowBrackets) throws IOException {
        StringBuilder sb = new StringBuilder();

        int ch;
        while (true) {
            ch = inputStream.peek();
            if (ch == -1) {
                throw new IOException("parseBareString(): end of stream reached");
            }

            if (ch == '(' || ch == ')' || (allowBrackets && (ch == '[' || ch == ']')) ||
                    ch == '{' || ch == ' ' || ch == '"' ||
                    (ch >= 0x00 && ch <= 0x1f) || ch == 0x7f) {

                if (sb.length() == 0) {
                    throw new IOException(String.format("parseBareString(): (%04x %c)", ch, ch));
                }

                return sb.toString();
            } else {
                sb.append((char) inputStream.read());
            }
        }
    }

    /**
     * A "{" has been read. Read the rest of the size string, the space and then notify the callback with an
     * {@code InputStream}.
     */
    private Object parseLiteral() throws IOException {
        expect('{');

        int size;
        try {
            size = Integer.parseInt(readStringUntil('}'));
        } catch (NumberFormatException e) {
            throw new ImapResponseParserException("Invalid value for size of literal string", e);
        }

        if (size < 0) {
            throw new ImapResponseParserException("Invalid value for size of literal string");
        }

        expect('\r');
        expect('\n');

        if (size == 0) {
            return "";
        }

        if (response.getCallback() != null) {
            FixedLengthInputStream fixed = new FixedLengthInputStream(inputStream, size);

            Exception callbackException = null;
            Object result = null;
            try {
                result = response.getCallback().foundLiteral(response, fixed);
            } catch (IOException e) {
                throw e;
            } catch (Exception e) {
                callbackException = e;
            }

            boolean someDataWasRead = fixed.available() != size;
            if (someDataWasRead) {
                if (result == null && callbackException == null) {
                    throw new AssertionError("Callback consumed some data but returned no result");
                }

                fixed.skipRemaining();
            }

            if (callbackException != null) {
                if (exception == null) {
                    exception = callbackException;
                }
                return "EXCEPTION";
            }
            
            if (result != null) {
                return result;
            }
        }

        byte[] data = new byte[size];
        int read = 0;
        while (read != size) {
            int count = inputStream.read(data, read, size - read);
            if (count == -1) {
                throw new IOException("parseLiteral(): end of stream reached");
            }
            read += count;
        }

        return new String(data, "US-ASCII");
    }

    private String parseQuoted() throws IOException {
        expect('"');

        Buffer buffer = new Buffer();
        int ch;
        boolean escape = false;
        while ((ch = inputStream.read()) != -1) {
            if (!escape && ch == '\\') {
                // Found the escape character
                escape = true;
            } else if (!escape && ch == '"') {
                return buffer.readUtf8();
            } else {
                buffer.writeByte(ch);
                escape = false;
            }
        }

        throw new IOException("parseQuoted(): end of stream reached");
    }

    private String parseQuotedOrNil() throws IOException {
        int peek = inputStream.peek();
        if (peek == '"') {
            return parseQuoted();
        } else {
            parseNil();
            return null;
        }
    }
    
    private void parseNil() throws IOException {
        expect('N');
        expect('I');
        expect('L');
    }

    private String readStringUntil(char end) throws IOException {
        StringBuilder sb = new StringBuilder();

        int ch;
        while ((ch = inputStream.read()) != -1) {
            if (ch == end) {
                return sb.toString();
            } else {
                sb.append((char) ch);
            }
        }

        throw new IOException("readStringUntil(): end of stream reached. " +
                "Read: \"" + sb.toString() + "\" while waiting for " + formatChar(end));
    }

    private String formatChar(char value) {
        return value < 32 ? "[" + Integer.toString(value) + "]" : "'" + value + "'";
    }

    private String readStringUntilEndOfLine() throws IOException {
        String rest = readStringUntil('\r');
        expect('\n');

        return rest;
    }

    private void expect(char expected) throws IOException {
        int readByte = inputStream.read();
        if (readByte != expected) {
            throw new IOException(String.format("Expected %04x (%c) but got %04x (%c)",
                    (int) expected, expected, readByte, (char) readByte));
        }
    }

    private boolean isStatusResponse(String symbol) {
        return symbol.equalsIgnoreCase(Responses.OK) ||
                symbol.equalsIgnoreCase(Responses.NO) ||
                symbol.equalsIgnoreCase(Responses.BAD) ||
                symbol.equalsIgnoreCase(Responses.PREAUTH) ||
                symbol.equalsIgnoreCase(Responses.BYE);
    }

    public static boolean equalsIgnoreCase(Object token, String symbol) {
        return token instanceof String && symbol.equalsIgnoreCase((String) token);

    }

    private void checkTokenIsString(Object token) {
        if (!(token instanceof String)) {
            throw new ImapResponseParserException("Unexpected non-string token");
        }
    }
}
