package com.atlassian.bitbucket.io;

import com.atlassian.utils.process.LineReadable;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.*;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

public abstract class LineReader implements LineReadable {

    public static final int NEWLINE = 0x0a;
    public static final int CARRIAGERETURN = 0x0d;
    public static final int NULL = 0x00;

    private final StringBuilder buffer;
    private final Deque<String> lineBuffer;

    private Mode fileMode;
    private int readAhead;

    protected LineReader(Mode fileMode) {
        this.fileMode = fileMode;

        buffer = new StringBuilder();
        lineBuffer = new LinkedList<>();
        readAhead = -1;
    }

    protected LineReader() {
        this(Mode.MODE_DETECT);
    }

    private boolean isEOL(int i) throws IOException {
        boolean isEOL;

        switch (fileMode) {
            case MODE_UNIX:
                isEOL = i == NEWLINE;
                break;
            case MODE_MAC:
                isEOL = i == CARRIAGERETURN;
                break;
            case MODE_PC:
                if (i == CARRIAGERETURN) {
                    // is an eol only if followed by a LF
                    int i2 = readNext();
                    if (i2 == NEWLINE) {
                        isEOL = true;
                    } else {
                        pushBack(i2);
                        isEOL = false;
                    }
                } else {
                    isEOL = false;
                }
                break;
            case MODE_DETECT:
                if (i == NEWLINE) {
                    fileMode = Mode.MODE_UNIX;
                    isEOL = true;
                } else if (i == CARRIAGERETURN) {
                    int i2 = readNext();
                    if (i2 == NEWLINE) {
                        fileMode = Mode.MODE_PC;
                        isEOL = true;
                    } else {
                        pushBack(i2);
                        fileMode = Mode.MODE_MAC;
                        isEOL = true;
                    }
                } else {
                    isEOL = false;
                }
                break;
            case MODE_MIXED:
                if (i == NEWLINE) {
                    isEOL = true;
                } else if (i == CARRIAGERETURN) {
                    int i2 = readNext();
                    if (i2 != NEWLINE) {
                        pushBack(i2);
                    }
                    isEOL = true;
                } else {
                    isEOL = false;
                }
                break;
            case MODE_PC_UNIX_MIXED:
                if (i == NEWLINE) {
                    isEOL = true;
                } else if (i == CARRIAGERETURN) {
                    int i2 = readNext();
                    if (i2 == NEWLINE) {
                        isEOL = true;
                    } else {
                        pushBack(i2);
                        isEOL = false;
                    }
                } else {
                    isEOL = false;
                }
                break;
            case MODE_NULL:
                isEOL = i == NULL;
                break;
            default:
                isEOL = false;
                break;
        }
        return isEOL;
    }

    /**
     * reads a line, <b>including</b> the terminator, or null if eof
     * This method returns a string, even if it is just really reading bytes
     */
    public String readLine() throws IOException {
        if (lineBuffer.size() > 0) {
            return lineBuffer.pop();
        }
        buffer.setLength(0);
        int i = readNext();
        if (i == -1) {
            return null;
        }
        while (i != -1) {
            if (isEOL(i)) {
                break;
            }
            buffer.append((char) i);
            i = readNext();
        }

        return buffer.toString();
    }

    public String readLine(int maxLength) throws IOException {
        if (lineBuffer.size() > 0) {
            String line = lineBuffer.pop();
            if (line.length() > maxLength) {
                return line.substring(0, maxLength);
            }
            return line;
        }
        buffer.setLength(0);
        int i = readNext();
        if (i == -1) {
            return null;
        }

        while (i != -1) {
            if (isEOL(i)) {
                break;
            }
            if (buffer.length() < maxLength) {
                buffer.append((char) i);
            }
            i = readNext();
        }

        return buffer.toString();
    }

    /**
     * Pushes the provided line of output back onto the reader.
     * <p>
     * Note: Pushing lines back has stack (last-in, first-out) semantics. Consider the following code:
     * <pre><code>
     *     LineReader reader = ...;
     *     String line = reader.readLine();
     *     reader.pushLineBack(line);
     *     if (line == reader.readLine()) {
     *         //This will always be true
     *     }
     * </code></pre>
     * If multiple lines are pushed back, they will be returned by {@link #readLine()} in reverse order, with the last
     * line to be pushed back returned first.
     *
     * @param line the line of input to push back
     */
    public void pushLineBack(String line) {
        lineBuffer.push(line);
    }

    /**
     * @return a stream over this reader's lines
     * @since 4.6
     */
    @Nonnull
    public Stream<String> stream() {
        return makeStream(this::readLine);
    }

    /**
     * @param maxLength the maximum length for any line; lines which are longer than this will be truncated and
     *                  characters past the limit will be discarded
     * @return a stream over this reader's lines which will truncate lines at {@code maxLength} characters
     * @since 4.6
     */
    @Nonnull
    public Stream<String> stream(int maxLength) {
        return makeStream(() -> readLine(maxLength));
    }

    //This implementation draws heavily from BufferedReader's lines() implementation
    private Stream<String> makeStream(LineSupplier supplier) {
        Iterator<String> iterator = new Iterator<String>() {

            private String next;

            @Override
            public boolean hasNext() {
                if (next != null) {
                    return true;
                }

                try {
                    next = supplier.get();

                    return next != null;
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            }

            @Override
            public String next() {
                if (hasNext()) {
                    String line = next;
                    next = null;

                    return line;
                }

                throw new NoSuchElementException();
            }
        };

        return StreamSupport.stream(
                Spliterators.spliteratorUnknownSize(iterator, Spliterator.NONNULL | Spliterator.ORDERED),
                false);
    }

    private int readNext() throws IOException {
        int ch;
        if (readAhead == -1) {
            ch = read();
        } else {
            ch = readAhead;
            readAhead = -1;
        }
        return ch;
    }

    private void pushBack(int ch) {
        readAhead = ch;
    }

    protected abstract int read() throws IOException;

    /**
     * Enumerates the line break modes supported by {@link LineReader}.
     */
    public enum Mode {
        /**
         * Automatically detects the line break mode to use based on the first EOL type detected. This mode
         * may select any of the following modes:
         * <ul>
         *     <li>{@link #MODE_MAC}</li>
         *     <li>{@link #MODE_PC}</li>
         *     <li>{@link #MODE_UNIX}</li>
         * </ul>
         * Mixed-mode endings <i>will not</i> be detected. The first mode will be used. This may result in
         * incorrect parsing behavior.
         * <p>
         * {@link #MODE_NULL Null} line breaks also <i>will not</i> be detected, even if the line contains
         * {@code NUL} characters.
         */
        MODE_DETECT,
        /**
         * {@link #NEWLINE LF} characters are used for line breaks.
         */
        MODE_UNIX,
        /**
         * {@link #CARRIAGERETURN CR} characters are used for line breaks.
         */
        MODE_MAC,
        /**
         * {@code CRLF} characters are used for line breaks. {@code CR} or {@code LF} characters by themselves
         * <i>are not</i> considered line breaks.
         */
        MODE_PC,
        /**
         * {@code CR}, {@code LF} <i>or</i> {@code CRLF} characters are used for line breaks. This is
         * essentially the combination of {@link #MODE_MAC}, {@link #MODE_PC} and {@link #MODE_UNIX}.
         */
        MODE_MIXED,
        /**
         * {@code LF} <i>or</i> {@code CRLF} characters are used for line breaks. This is essentially
         * the combination of {@link #MODE_PC} and {@link #MODE_UNIX}.
         */
        MODE_PC_UNIX_MIXED,
        /**
         * {@link #NULL NUL} characters are used for line breaks.
         *
         * @since 4.2
         */
        MODE_NULL,
    }

    /**
     * A {@code Supplier}-like interface which allows propagating {@code IOException}s.
     *
     * @since 4.6
     */
    @FunctionalInterface
    private interface LineSupplier {

        @Nullable
        String get() throws IOException;
    }
}
