// Copyright (C) 2016 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0

#include "outputformatter.h"

#include "algorithm.h"
#include "ansiescapecodehandler.h"
#include "fileinprojectfinder.h"
#include "link.h"
#include "qtcassert.h"
#include "stringutils.h"
#include "stylehelper.h"
#include "theme/theme.h"

#include <QDir>
#include <QPair>
#include <QPlainTextEdit>
#include <QPointer>
#include <QRegularExpressionMatch>
#include <QTextCursor>

#ifdef WITH_TESTS
#include <QTest>
#endif

#include <numeric>

namespace Utils {

class OutputLineParser::Private
{
public:
    FilePaths searchDirs;
    QPointer<const OutputLineParser> redirectionDetector;
    bool skipFileExistsCheck = false;
    bool demoteErrorsToWarnings = false;
    FileInProjectFinder *fileFinder = nullptr;
};

OutputLineParser::OutputLineParser() : d(new Private) { }

OutputLineParser::~OutputLineParser() { delete d; }

Q_GLOBAL_STATIC_WITH_ARGS(QString, linkPrefix, {"olpfile://"})
Q_GLOBAL_STATIC_WITH_ARGS(QString, linkSep, {"::"})

QString OutputLineParser::createLinkTarget(const FilePath &filePath, int line = -1, int column = -1)
{
    return *linkPrefix() + filePath.toUrlishString() + *linkSep() + QString::number(line)
            + *linkSep() + QString::number(column);
}

bool OutputLineParser::isLinkTarget(const QString &target)
{
    // TODO: Can we use the generic file scheme for our internal links as well?
    return target.startsWith(*linkPrefix()) || target.startsWith("file://");
}

Link OutputLineParser::parseLinkTarget(const QString &target)
{
    const QStringList parts = target.startsWith(*linkPrefix)
        ? target.mid(linkPrefix()->size()).split(*linkSep())
        : target.mid(7).split(':');
    if (parts.isEmpty())
        return {};
    return Link(
        FilePath::fromString(parts.first()),
        parts.length() > 1 ? parts.at(1).toInt() : 0,
        parts.length() > 2 ? parts.at(2).toInt() - 1 : 0);
}

// The redirection mechanism is needed for broken build tools (e.g. xcodebuild) that get invoked
// indirectly as part of the build process and redirect their child processes' stderr output
// to stdout. A parser might be able to detect this condition and inform interested
// other parsers that they need to interpret stdout data as stderr.
void OutputLineParser::setRedirectionDetector(const OutputLineParser *detector)
{
    d->redirectionDetector = detector;
}

bool OutputLineParser::needsRedirection() const
{
    return d->redirectionDetector && (d->redirectionDetector->hasDetectedRedirection()
                                      || d->redirectionDetector->needsRedirection());
}

void OutputLineParser::addSearchDir(const FilePath &dir)
{
    d->searchDirs << dir;
}

void OutputLineParser::dropSearchDir(const FilePath &dir)
{
    const int idx = d->searchDirs.lastIndexOf(dir);

    // TODO: This apparently triggers. Find out why and either remove the assertion (if it's legit)
    //       or fix the culprit.
    QTC_ASSERT(idx != -1, return);

    d->searchDirs.removeAt(idx);
}

const FilePaths OutputLineParser::searchDirectories() const
{
    return d->searchDirs;
}

void OutputLineParser::setFileFinder(FileInProjectFinder *finder)
{
    d->fileFinder = finder;
}

void OutputLineParser::setDemoteErrorsToWarnings(bool demote)
{
    d->demoteErrorsToWarnings = demote;
}

bool OutputLineParser::demoteErrorsToWarnings() const
{
    return d->demoteErrorsToWarnings;
}

FilePath OutputLineParser::absoluteFilePath(const FilePath &filePath) const
{
    if (filePath.isEmpty())
        return filePath;
    if (filePath.isAbsolutePath())
        return filePath.cleanPath();
    FilePaths candidates;
    for (const FilePath &dir : searchDirectories()) {
        FilePath candidate = dir.resolvePath(filePath);
        if (candidate.exists() || d->skipFileExistsCheck) {
            candidate = candidate.cleanPath();
            if (!candidates.contains(candidate))
                candidates << candidate;
        }
    }
    if (candidates.count() == 1)
        return candidates.first();

    QString fp = filePath.toUrlishString();
    while (fp.startsWith("../"))
        fp.remove(0, 3);
    bool found = false;
    candidates = d->fileFinder->findFile(QUrl::fromLocalFile(fp), &found);
    if (found && candidates.size() == 1)
        return candidates.first();

    return filePath;
}

void OutputLineParser::addLinkSpecForAbsoluteFilePath(
    OutputLineParser::LinkSpecs &linkSpecs,
    const FilePath &filePath,
    int lineNo,
    int column,
    int pos,
    int len)
{
    if (filePath.isAbsolutePath())
        linkSpecs.append({pos, len, createLinkTarget(filePath, lineNo, column)});
}

void OutputLineParser::addLinkSpecForAbsoluteFilePath(
    OutputLineParser::LinkSpecs &linkSpecs,
    const FilePath &filePath,
    int lineNo,
    int column,
    const QRegularExpressionMatch &match,
    int capIndex)
{
    addLinkSpecForAbsoluteFilePath(linkSpecs, filePath, lineNo, column,
                                   match.capturedStart(capIndex), match.capturedLength(capIndex));
}
void OutputLineParser::addLinkSpecForAbsoluteFilePath(
    OutputLineParser::LinkSpecs &linkSpecs,
    const FilePath &filePath,
    int lineNo,
    int column,
    const QRegularExpressionMatch &match,
    const QString &capName)
{
    addLinkSpecForAbsoluteFilePath(linkSpecs, filePath, lineNo, column,
                                   match.capturedStart(capName), match.capturedLength(capName));
}

bool Utils::OutputLineParser::fileExists(const FilePath &fp) const
{
#ifdef WITH_TESTS
    if (d->skipFileExistsCheck)
        return !fp.isEmpty();
#endif
    return fp.exists();
}

QString OutputLineParser::rightTrimmed(const QString &in)
{
    int pos = in.size();
    for (; pos > 0; --pos) {
        if (!in.at(pos - 1).isSpace())
            break;
    }
    return in.mid(0, pos);
}

#ifdef WITH_TESTS
void OutputLineParser::skipFileExistsCheck()
{
    d->skipFileExistsCheck = true;
}
#endif


class OutputFormatter::Private
{
public:
    QPlainTextEdit *plainTextEdit = nullptr;
    QTextCharFormat formats[NumberOfFormats];
    QTextCursor cursor;
    AnsiEscapeCodeHandler escapeCodeHandler;
    QList<FormattedText> incompleteLine;
    OutputFormat lastFormat = NumberOfFormats;
    std::optional<QTextCharFormat> formatOverride;
    QList<OutputLineParser *> lineParsers;
    OutputLineParser *nextParser = nullptr;
    FileInProjectFinder fileFinder;
    PostPrintAction postPrintAction;
    bool boldFontEnabled = true;
    bool prependCarriageReturn = false;
    bool forwardStdOutToStdError = false;
    QColor explicitBackground;
};

OutputFormatter::OutputFormatter() : d(new Private) { }

OutputFormatter::~OutputFormatter()
{
    qDeleteAll(d->lineParsers);
    delete d;
}

QPlainTextEdit *OutputFormatter::plainTextEdit() const
{
    return d->plainTextEdit;
}

void OutputFormatter::setPlainTextEdit(QPlainTextEdit *plainText)
{
    d->plainTextEdit = plainText;
    d->cursor = plainText ? plainText->textCursor() : QTextCursor();
    d->cursor.movePosition(QTextCursor::End);
    initFormats();
}

void OutputFormatter::setLineParsers(const QList<OutputLineParser *> &parsers)
{
    flush();
    qDeleteAll(d->lineParsers);
    d->lineParsers.clear();
    d->nextParser = nullptr;
    addLineParsers(parsers);
}

void OutputFormatter::addLineParsers(const QList<OutputLineParser *> &parsers)
{
    for (OutputLineParser * const p : std::as_const(parsers))
        addLineParser(p);
}

void OutputFormatter::addLineParser(OutputLineParser *parser)
{
    setupLineParser(parser);
    d->lineParsers << parser;
}

void OutputFormatter::setupLineParser(OutputLineParser *parser)
{
    parser->setFileFinder(&d->fileFinder);
    connect(parser, &OutputLineParser::newSearchDirFound, this, &OutputFormatter::addSearchDir);
    connect(parser, &OutputLineParser::searchDirExpired, this, &OutputFormatter::dropSearchDir);
}

void OutputFormatter::setFileFinder(const FileInProjectFinder &finder)
{
    d->fileFinder = finder;
}

void OutputFormatter::setDemoteErrorsToWarnings(bool demote)
{
    for (OutputLineParser * const p : std::as_const(d->lineParsers))
        p->setDemoteErrorsToWarnings(demote);
}

void OutputFormatter::overridePostPrintAction(const PostPrintAction &postPrintAction)
{
    d->postPrintAction = postPrintAction;
}

static void checkAndFineTuneColors(QTextCharFormat *format, const QColor &background)
{
    QTC_ASSERT(format, return);
    const QColor bgColor = background.isValid()
            ? background
            : (format->hasProperty(QTextCharFormat::BackgroundBrush)
               ? format->background().color()
               : Utils::creatorColor(Theme::PaletteBase));
    const QColor fgColor = StyleHelper::ensureReadableOn(bgColor,
                                                         format->foreground().color());
    format->setForeground(fgColor);
}

void OutputFormatter::doAppendMessage(const QString &text, OutputFormat format, LineStatus lineStatus)
{
    QTextCharFormat charFmt = lineStatus == LineStatus::Incomplete ? d->incompleteLine.last().format
                                                                   : charFormat(format);
    const auto addNewlineIfApplicable = [&] {
        if (lineStatus == LineStatus::Complete)
            append("\n", charFmt);
    };

    if (!d->incompleteLine.isEmpty())
        clearLastLine();
    QList<FormattedText> formattedText = d->incompleteLine + parseAnsi(text, charFmt);
    d->incompleteLine.clear();

    const QString cleanLine = std::accumulate(formattedText.begin(), formattedText.end(), QString(),
            [](const FormattedText &t1, const FormattedText &t2) -> QString
            { return t1.text + t2.text; });
    QList<OutputLineParser *> involvedParsers;
    const OutputLineParser::Result res = handleMessage(cleanLine, format, involvedParsers);

    // If the line was recognized by a parser and a redirection was detected for that parser,
    // then our formatting should reflect that redirection as well, i.e. print in red
    // even if the nominal format is stdout.
    if (!involvedParsers.isEmpty()) {
        const OutputFormat formatForParser = res.formatOverride
                ? *res.formatOverride : outputTypeForParser(involvedParsers.last(), format);
        if (formatForParser != format && cleanLine == text && formattedText.length() == 1) {
            charFmt = charFormat(formatForParser);
            checkAndFineTuneColors(&charFmt, d->explicitBackground);
            formattedText.first().format = charFmt;
        }
    }

    if (res.newContent) {
        append(*res.newContent, charFmt);
        addNewlineIfApplicable();
        d->escapeCodeHandler = {};
        return;
    }

    const QList<FormattedText> linkified = linkifiedText(formattedText, res.linkSpecs);
    for (FormattedText output : linkified) {
        checkAndFineTuneColors(&output.format, d->explicitBackground);
        append(output.text, output.format);
        charFmt = output.format;
    }
    addNewlineIfApplicable();

    for (OutputLineParser * const p : std::as_const(involvedParsers)) {
        if (d->postPrintAction)
            d->postPrintAction(p);
        else
            p->runPostPrintActions(plainTextEdit());
    }
}

OutputLineParser::Result OutputFormatter::handleMessage(const QString &text, OutputFormat format,
                                                        QList<OutputLineParser *> &involvedParsers)
{
    // We only invoke the line parsers for stdout and stderr
    // Bad: on Windows we may get stdout and stdErr only as DebugFormat as e.g. GUI applications
    // print them Windows-internal and we retrieve this separately
    if (format != StdOutFormat && format != StdErrFormat && format != DebugFormat)
        return OutputLineParser::Status::NotHandled;
    const OutputLineParser * const oldNextParser = d->nextParser;
    if (d->nextParser) {
        involvedParsers << d->nextParser;
        const OutputLineParser::Result res
                = d->nextParser->handleLine(text, outputTypeForParser(d->nextParser, format));
        switch (res.status) {
        case OutputLineParser::Status::Done:
            d->nextParser->flush();
            d->nextParser = nullptr;
            return res;
        case OutputLineParser::Status::InProgress:
            return res;
        case OutputLineParser::Status::NotHandled:
            d->nextParser = nullptr;
            break;
        }
    }
    QTC_CHECK(!d->nextParser);
    for (OutputLineParser * const parser : std::as_const(d->lineParsers)) {
        if (parser == oldNextParser) // We tried that one already.
            continue;
        const OutputLineParser::Result res
                = parser->handleLine(text, outputTypeForParser(parser, format));
        switch (res.status) {
        case OutputLineParser::Status::Done:
            parser->flush();
            involvedParsers << parser;
            return res;
        case OutputLineParser::Status::InProgress:
            involvedParsers << parser;
            d->nextParser = parser;
            return res;
        case OutputLineParser::Status::NotHandled:
            break;
        }
    }
    return OutputLineParser::Status::NotHandled;
}

QTextCharFormat OutputFormatter::charFormat(OutputFormat format) const
{
    return d->formatOverride ? d->formatOverride.value() : d->formats[format];
}

QList<FormattedText> OutputFormatter::parseAnsi(const QString &text, const QTextCharFormat &format)
{
    return d->escapeCodeHandler.parseText(FormattedText(text, format));
}

const QList<FormattedText> OutputFormatter::linkifiedText(
        const QList<FormattedText> &text, const OutputLineParser::LinkSpecs &linkSpecs)
{
    if (linkSpecs.isEmpty())
        return text;

    QList<FormattedText> linkified;
    int totalTextLengthSoFar = 0;
    int nextLinkSpecIndex = 0;

    for (const FormattedText &t : text) {
        const int totalPreviousTextLength = totalTextLengthSoFar;

        // There is no more linkification work to be done. Just copy the text as-is.
        if (nextLinkSpecIndex >= linkSpecs.size()) {
            linkified << t;
            continue;
        }

        for (int nextLocalTextPos = 0; nextLocalTextPos < t.text.size(); ) {
            const auto copyRestOfSegmentAsIs = [&] {
                linkified << FormattedText(t.text.mid(nextLocalTextPos), t.format);
                totalTextLengthSoFar += t.text.size() - nextLocalTextPos;
            };

            // We are out of links.
            if (nextLinkSpecIndex >= linkSpecs.size()) {
                copyRestOfSegmentAsIs();
                break;
            }

            const OutputLineParser::LinkSpec &linkSpec = linkSpecs.at(nextLinkSpecIndex);
            const int localLinkStartPos = linkSpec.startPos - totalPreviousTextLength;

            // There are more links, but not in this segment.
            if (localLinkStartPos >= t.text.size()) {
                copyRestOfSegmentAsIs();
                break;
            }

            ++nextLinkSpecIndex;

            // We ignore links that would cross format boundaries.
            if (localLinkStartPos < nextLocalTextPos
                    || localLinkStartPos + linkSpec.length > t.text.size()) {
                copyRestOfSegmentAsIs();
                break;
            }

            // Now we know we have a link that is fully inside this part of the text.
            // Split the text so that the link part gets the appropriate format.
            const int prefixLength = localLinkStartPos - nextLocalTextPos;
            const QString textBeforeLink = t.text.mid(nextLocalTextPos, prefixLength);
            linkified << FormattedText(textBeforeLink, t.format);
            const QString linkedText = t.text.mid(localLinkStartPos, linkSpec.length);
            linkified << FormattedText(linkedText, linkFormat(t.format, linkSpec.target));
            nextLocalTextPos = localLinkStartPos + linkSpec.length;
            totalTextLengthSoFar += prefixLength + linkSpec.length;
        }
    }
    return linkified;
}

void OutputFormatter::append(const QString &text, const QTextCharFormat &format)
{
    if (!plainTextEdit())
        return;
    int startPos = 0;
    int crPos = -1;
    while ((crPos = text.indexOf('\r', startPos)) >= 0)  {
        d->cursor.insertText(text.mid(startPos, crPos - startPos), format);
        d->cursor.clearSelection();
        d->cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor);
        startPos = crPos + 1;
    }
    if (startPos < text.size())
        d->cursor.insertText(text.mid(startPos), format);
}

QTextCharFormat OutputFormatter::linkFormat(const QTextCharFormat &inputFormat, const QString &href)
{
    QTextCharFormat result = inputFormat;
    result.setForeground(creatorColor(Theme::TextColorLink));
    result.setUnderlineStyle(QTextCharFormat::SingleUnderline);
    result.setAnchor(true);
    result.setAnchorHref(href);
    return result;
}

void OutputFormatter::clearLastLine()
{
    // Note that this approach will fail if the text edit is not read-only and users
    // have messed with the last line between programmatic inputs.
    // We live with this risk, as all the alternatives are worse.
    if (!d->cursor.atEnd())
        d->cursor.movePosition(QTextCursor::End);
    d->cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor);
    d->cursor.removeSelectedText();
}

void OutputFormatter::initFormats()
{
    if (!plainTextEdit())
        return;

    d->formats[NormalMessageFormat].setForeground(creatorColor(Theme::OutputPanes_NormalMessageTextColor));
    d->formats[ErrorMessageFormat].setForeground(creatorColor(Theme::OutputPanes_ErrorMessageTextColor));
    d->formats[LogMessageFormat].setForeground(creatorColor(Theme::OutputPanes_WarningMessageTextColor));
    d->formats[StdOutFormat].setForeground(creatorColor(Theme::OutputPanes_StdOutTextColor));
    d->formats[StdErrFormat].setForeground(creatorColor(Theme::OutputPanes_StdErrTextColor));
    d->formats[DebugFormat].setForeground(creatorColor(Theme::OutputPanes_DebugTextColor));
    d->formats[GeneralMessageFormat].setForeground(creatorColor(Theme::OutputPanes_DebugTextColor));
    setBoldFontEnabled(d->boldFontEnabled);
}

void OutputFormatter::flushIncompleteLine()
{
    doAppendMessage({}, d->lastFormat, LineStatus::Incomplete);
}

void OutputFormatter::dumpIncompleteLine(const QString &line, OutputFormat format)
{
    if (line.isEmpty())
        return;

    const QList<FormattedText> formattedText = parseAnsi(line, charFormat(format));
    for (FormattedText output : formattedText) {
        checkAndFineTuneColors(&output.format, d->explicitBackground);
        append(output.text, output.format);
    }
    d->incompleteLine << formattedText;
    d->lastFormat = format;
}

bool OutputFormatter::handleFileLink(const QString &href)
{
    if (!OutputLineParser::isLinkTarget(href))
        return false;

    Link link = OutputLineParser::parseLinkTarget(href);
    QTC_ASSERT(!link.targetFilePath.isEmpty(), return false);
    emit openInEditorRequested(link);
    return true;
}

void OutputFormatter::handleLink(const QString &href)
{
    QTC_ASSERT(!href.isEmpty(), return);
    // We can handle absolute file paths ourselves. Other types of references are forwarded
    // to the line parsers.
    if (handleFileLink(href))
        return;
    for (OutputLineParser * const f : std::as_const(d->lineParsers)) {
        if (f->handleLink(href))
            return;
    }
}

void OutputFormatter::clear()
{
    if (plainTextEdit())
        plainTextEdit()->clear();
}

void OutputFormatter::reset()
{
    d->prependCarriageReturn = false;
    d->incompleteLine.clear();
    d->nextParser = nullptr;
    qDeleteAll(d->lineParsers);
    d->lineParsers.clear();
    d->fileFinder = FileInProjectFinder();
    d->formatOverride.reset();
    d->escapeCodeHandler = AnsiEscapeCodeHandler();
}

void OutputFormatter::setBoldFontEnabled(bool enabled)
{
    d->boldFontEnabled = enabled;
    const QFont::Weight fontWeight = enabled ? QFont::Bold : QFont::Normal;
    d->formats[NormalMessageFormat].setFontWeight(fontWeight);
    d->formats[ErrorMessageFormat].setFontWeight(fontWeight);
}

void OutputFormatter::setForwardStdOutToStdError(bool enabled)
{
    d->forwardStdOutToStdError = enabled;
}

void Utils::OutputFormatter::setExplicitBackgroundColor(const QColor &color)
{
    d->explicitBackground = color;
}

void OutputFormatter::flush()
{
    if (!d->incompleteLine.isEmpty())
        flushIncompleteLine();
    d->escapeCodeHandler = {};
    for (OutputLineParser * const p : std::as_const(d->lineParsers))
        p->flush();
    if (d->nextParser)
        d->nextParser->runPostPrintActions(plainTextEdit());
}

bool OutputFormatter::hasFatalErrors() const
{
    return anyOf(d->lineParsers, [](const OutputLineParser *p) {
        return p->hasFatalErrors();
    });
}

void OutputFormatter::addSearchDir(const FilePath &dir)
{
    for (OutputLineParser * const p : std::as_const(d->lineParsers))
        p->addSearchDir(dir);
}

void OutputFormatter::dropSearchDir(const FilePath &dir)
{
    for (OutputLineParser * const p : std::as_const(d->lineParsers))
        p->dropSearchDir(dir);
}

OutputFormat OutputFormatter::outputTypeForParser(const OutputLineParser *parser,
                                                  OutputFormat type) const
{
    if (type == StdOutFormat && (parser->needsRedirection() || d->forwardStdOutToStdError))
        return StdErrFormat;
    return type;
}

void OutputFormatter::appendMessage(const QString &text, OutputFormat format)
{
    if (text.isEmpty())
        return;

    // If we have an existing incomplete line and its format is different from this one,
    // then we consider the two messages unrelated. We re-insert the previous incomplete line
    // and start from scratch with the new input.
    if (!d->incompleteLine.isEmpty() && d->lastFormat != format)
        flushIncompleteLine();

    QString out = text;
    if (d->prependCarriageReturn) {
        d->prependCarriageReturn = false;
        out.prepend('\r');
    }
    out = Utils::normalizeNewlines(out);
    if (out.endsWith('\r')) {
        d->prependCarriageReturn = true;
        out.chop(1);
    }

    // If the input is a single incomplete line, we do not forward it to the specialized
    // formatting code, but simply dump it as-is (including escape code handling).
    // Once it becomes complete or it needs to be flushed for other reasons, we remove the
    // current content and re-insert it, this time with full formatting.
    if (!out.contains('\n')) {
        dumpIncompleteLine(out, format);
        return;
    }

    // Forward all complete lines to the specialized formatting code, and handle a
    // potential trailing incomplete line the same way as above.
    d->cursor.beginEditBlock();
    for (int startPos = 0; ;) {
        const int eolPos = out.indexOf('\n', startPos);
        if (eolPos == -1) {
            dumpIncompleteLine(out.mid(startPos), format);
            break;
        }
        doAppendMessage(out.mid(startPos, eolPos - startPos), format, LineStatus::Complete);
        startPos = eolPos + 1;
    }
    d->cursor.endEditBlock();
}

#ifdef WITH_TESTS

void OutputFormatter::overrideTextCharFormat(const QTextCharFormat &fmt)
{
    d->formatOverride = fmt;
}

QList<OutputLineParser *> OutputFormatter::lineParsers() const
{
    return d->lineParsers;
}

// Handles all lines starting with "A" and the following ones up to and including the next
// one starting with "A".
class TestFormatterA : public OutputLineParser
{
private:
    Result handleLine(const QString &text, OutputFormat) override
    {
        static const QString replacement = "handled by A";
        if (m_handling) {
            if (text.startsWith("A")) {
                m_handling = false;
                return {Status::Done, {}, replacement};
            }
            return {Status::InProgress, {}, replacement};
        }
        if (text.startsWith("A")) {
            m_handling = true;
            return {Status::InProgress, {}, replacement};
        }
        return Status::NotHandled;
    }

    bool m_handling = false;
};

// Handles all lines starting with "B". No continuation logic.
class TestFormatterB : public OutputLineParser
{
private:
    Result handleLine(const QString &text, OutputFormat) override
    {
        if (text.startsWith("B"))
            return {Status::Done, {}, QString("handled by B")};
        return Status::NotHandled;
    }
};

class OutputFormatterTest final : public QObject
{
    Q_OBJECT

private slots:
    void testOutputFormatter()
    {
        const QString input =
            "B to be handled by B\r\r\n"
            "not to be handled\n\n\n\n"
            "A to be handled by A\n"
            "continuation for A\r\n"
            "B looks like B, but still continuation for A\r\n"
            "A end of A\n"
            "A next A\n"
            "A end of next A\n"
            " A trick\r\n"
            "line with \r embedded carriage return\n"
            "B to be handled by B\n";
        const QString output =
            "handled by B\n"
            "not to be handled\n\n\n\n"
            "handled by A\n"
            "handled by A\n"
            "handled by A\n"
            "handled by A\n"
            "handled by A\n"
            "handled by A\n"
            " A trick\n"
            " embedded carriage return\n"
            "handled by B\n";

        // Stress-test the implementation by providing the input in chunks, splitting at all possible
        // offsets.
        for (int i = 0; i < input.size(); ++i) {
            OutputFormatter formatter;
            QPlainTextEdit textEdit;
            formatter.setPlainTextEdit(&textEdit);
            formatter.setLineParsers({new TestFormatterB, new TestFormatterA});
            formatter.appendMessage(input.left(i), StdOutFormat);
            formatter.appendMessage(input.mid(i), StdOutFormat);
            formatter.flush();
            QCOMPARE(textEdit.toPlainText(), output);
        }
    }

    static QString input() { return "A line with some \x1b[31mred\x1b[0m output\n"; };
    void testEscapeCodeHandling_data()
    {
        QTest::addColumn<int>("offset");
        QTest::addColumn<int>("expectedCharCount");

        QTest::addRow("plain prefix") << 17 << 17;
        QTest::addRow("incomplete escape code") << 18 << 17;
        QTest::addRow("incomplete escape code 2") << 19 << 17;
        QTest::addRow("incomplete escape code 3") << 20 << 17;
        QTest::addRow("incomplete escape code 4") << 21 << 17;
        QTest::addRow("complete escape code with no text following") << 22 << 17;
        QTest::addRow("complete escape code with text following") << 23 << 18;
        QTest::addRow("complete line") << input().size() << 27;
    }

    void testEscapeCodeHandling()
    {
        QFETCH(int, offset);
        QFETCH(int, expectedCharCount);

        OutputFormatter formatter;
        QPlainTextEdit textEdit;
        QTextDocument * const doc = textEdit.document();
        formatter.setPlainTextEdit(&textEdit);

        const auto compareFormats = [&](int run) {
            QTextCursor cursor(doc);
            const QTextCharFormat defaultFormat = cursor.charFormat();
            const std::pair<int, int> redRegion(17, 20);
            for (int i = 0; i < doc->characterCount(); ++i) {
                cursor.setPosition(i);
                const bool hasExpectedColor = (cursor.charFormat() != defaultFormat) ==
                                              (i > redRegion.first && i <= redRegion.second);
                if (!hasExpectedColor)
                    qDebug() << run << i;
                QVERIFY(hasExpectedColor);
            }
        };
        const auto getActualCharCount = [&] {
            // Strips trailing character separators. There's always at least one.
            int count = doc->characterCount();
            for (int i = count - 1; i >= 0; --i) {
                if (doc->characterAt(i) != QChar::ParagraphSeparator)
                    break;
                --count;
            }
            return count;
        };

        // Write out the first chunk (until the position specified by the test data).
        // Make sure that the escape codes are either not there (if they are incomplete)
        // or they have been properly handled, resulting in the respective color change.
        formatter.appendMessage(input().left(offset), StdOutFormat);
        QCOMPARE(getActualCharCount(), expectedCharCount);
        compareFormats(1);

        // Now write out the rest of the text and check that the line is complete
        // and properly formatted.
        formatter.appendMessage(input().mid(offset), StdOutFormat);
        QCOMPARE(getActualCharCount(), 27);
        compareFormats(2);
    }
};

QObject *createOutputFormatterTest()
{
    return new OutputFormatterTest;
}

#endif // WITH_TESTS

} // namespace Utils

#include "outputformatter.moc"
