package com.atlassian.bitbucket.io;

import com.atlassian.utils.process.BaseOutputHandler;
import com.atlassian.utils.process.ProcessException;
import org.apache.commons.lang3.StringUtils;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.charset.UnsupportedCharsetException;

public abstract class TruncatedLineOutputHandler extends BaseOutputHandler {

    /**
     * Defines the size of the {@code BufferedInputStream}'s buffer. This is also used to limit how many bytes are
     * checked when doing {@link ContentDetectionUtils#isBinary(BufferedInputStream, int) binary file detection}.
     */
    private static final int BUFFER_SIZE = 8192;
    /**
     * Limits the number of bytes that are considered while doing
     * {@link ContentDetectionUtils#detectEncoding(BufferedInputStream, int) encoding detection}.
     * <p>
     * This value is larger than {@link #BUFFER_SIZE} to increase the odds that the right encoding will be chosen
     * by giving the heuristic-based detection more characters to consider. In cases where the encoding is "easy",
     * the detection logic may not read up to the limit. This limit just bounds the worst-case scenario where the
     * detection logic is not able to make a choice.
     */
    private static final int ENCODING_BUFFER_SIZE = 32768;

    private final Charset charset;
    private final String encoding;
    private final int maxLineLength;

    private int counter;

    protected TruncatedLineOutputHandler(int maxLineLength) {
        this(maxLineLength, (Charset) null); // If no charset is specified, detect it from the file
    }

    /**
     * @param maxLineLength the maximum line length to read; lines which exceed this length will be truncated in the
     *                      reader to avoid excessive memory consumption
     * @param charset       the character set to use when processing the input stream, or {@code null} to automatically
     *                      detect the character set
     * @since 4.5
     */
    protected TruncatedLineOutputHandler(int maxLineLength, @Nullable Charset charset) {
        this.charset = charset;
        this.maxLineLength = maxLineLength;

        encoding = null;
    }

    /**
     * @param maxLineLength the maximum line length to read; lines which exceed this length will be truncated in the
     *                      reader to avoid excessive memory consumption
     * @param encoding      the encoding (character set) to use when processing the input stream, or {@code null} to
     *                      automatically detect the encoding
     * @deprecated in 4.5 for removal in 5.0. Use {@link #TruncatedLineOutputHandler(int, Charset)} instead.
     */
    @Deprecated
    protected TruncatedLineOutputHandler(int maxLineLength, @Nullable String encoding) {
        this.encoding = encoding;
        this.maxLineLength = maxLineLength;

        charset = null;
    }

    public int getLineNumber() {
        return counter;
    }

    public void process(InputStream output) throws ProcessException {
        BufferedInputStream bufferedIn = new BufferedInputStream(output, BUFFER_SIZE);
        try {
            if (ContentDetectionUtils.isBinary(bufferedIn, BUFFER_SIZE)) {
                try {
                    handleBinary();
                    return;
                } finally {
                    cancelProcess();
                }
            }
        } catch (IOException e) {
            throw new ProcessException(e);
        }
        process(bufferedIn);
    }

    protected void process(BufferedInputStream in) throws ProcessException {
        try (LineReader reader = createReader(in, chooseCharset(in))) {
            String line;
            while ((line = reader.readLine(maxLineLength + 3)) != null) { // extra 3 characters so that we will know if it has been truncated (\r, \n, +1 character)
                resetWatchdog();

                boolean truncated = false;
                if (line.length() > maxLineLength) {
                    truncated = true;
                    line = line.substring(0, maxLineLength);
                }
                processLine(counter++, line, truncated);
            }
            processInputEnd(counter);
        } catch (InterruptedIOException e) {
            // This means the process was asked to stop which can be normal so we just finish
            processEndByException();
        } catch (IOException e) {
            processEndByException();
            throw new ProcessException(e);
        } catch (UnsupportedCharsetException e) {
            throw new ProcessException("The [" + e.getCharsetName() + "] character set is not supported", e);
        }
    }

    /**
     * Creates a {@link LineReader} over the provided input stream using the specified encoding.
     * <p>
     * The default implementation creates a {@link ReaderLineReader} using {@link ReaderLineReader.Mode#MODE_DETECT
     * automatic line ending detection}. For derived classes which are used to parse output that has a known set of
     * line endings, it may be desirable to override this method and create a reader with a more explicit mode.
     *
     * @param inputStream the stream to read from
     * @param charset     the character set to use when decoding the input stream
     * @return a {@link LineReader} over the provided input stream which uses the specified character set
     * @throws IOException if there is an error creating the {@link LineReader}
     * @since 4.5
     */
    protected LineReader createReader(@Nonnull BufferedInputStream inputStream, @Nonnull Charset charset)
            throws IOException {
        return new ReaderLineReader(new InputStreamReader(inputStream, charset));
    }

    /**
     * Called to signal {@link #process(InputStream)} has received a binary file <i>and no lines will be
     * {@link #processLine(int, String, boolean) processed}</i>.
     *
     * @throws ProcessException if handling for receiving a binary file fails
     */
    protected abstract void handleBinary() throws ProcessException;

    /**
     * Called to signal {@link #process(BufferedInputStream)} has encountered an exception and no more input will be
     * processed.
     */
    protected void processEndByException() {
        // do nothing by default
    }

    /**
     * Called to signal {@link #process(BufferedInputStream)} has read and {@link #processLine(int, String, boolean)
     * processed} all of the available lines.
     *
     * @param lineCount total number of lines processed
     * @throws ProcessException if wrapping up processing fails
     */
    protected void processInputEnd(int lineCount) throws ProcessException {
        // do nothing by default
    }

    /**
     * Process the given line
     *
     * @param lineNum   The line number of the line being processed
     * @param line      the content of the line
     * @param truncated true if the line has been truncated
     * @throws ProcessException if line processing fails
     */
    protected abstract void processLine(int lineNum, String line, boolean truncated) throws ProcessException;

    /**
     * @param in the input stream to detect a {@code Charset} for, if one hasn't been set explicitly
     * @return the chosen {@code Charset}, which will never be {@code null}
     * @throws IOException if neither {@link #charset} nor {@link #encoding} was set and automatic detection fails
     * @throws UnsupportedCharsetException if an {@link #encoding} was specified and is not supported, or if automatic
     *                                     detection chooses and unsupported encoding
     */
    private Charset chooseCharset(BufferedInputStream in) throws IOException {
        if (charset != null) {
            // if an explicit Charset was set in the constructor, use it
            return charset;
        }

        // otherwise, check for an encoding (until 5.0)
        String charsetName;
        if (StringUtils.isEmpty(encoding)) {
            // if an encoding wasn't set either, try to detect it automatically from the stream data
            charsetName = ContentDetectionUtils.detectEncoding(in, ENCODING_BUFFER_SIZE);
            if (StringUtils.isBlank(charsetName)) {
                // if the detection heuristics all fail, default to UTF-8
                return StandardCharsets.UTF_8;
            }
        } else {
            charsetName = encoding;
        }

        // if an unsupported encoding was provided in the constructor, or detected by ContentDetectionUtils,
        // this may throw an UnsupportedCharsetException
        return Charset.forName(charsetName);
    }
}