/*
    SPDX-FileCopyrightText: 2007 Andreas Pakulat <apaku@gmx.de>

    SPDX-License-Identifier: LGPL-2.0-or-later
*/

#include "vcsdiff.h"

#include "debug.h"

#include <QString>
#include <QUrl>
#include <QSharedData>
#include <QRegularExpression>
#include <QtGlobal>

namespace KDevelop
{


/* A class representing a diff hunk (a collection of localized changes) */
class DiffHunk {
public:
    /* Metadata for the hunk */
    uint srcStart      /**< the 1-based (!) start line number of the range in the source file where the hunk applies */
       , srcCount      /**< the size of the range (in # of lines) in the source where the hunk applies  (i.e. ctx lines + deleted lines)*/
       , tgtStart      /**< the 1-based (!) start line number of the range in the target file where the hunk applies */
       , tgtCount      /**< the size of the range (in # of lines) in the target where the hunk applies  (i.e. ctx lines + deleted lines)*/
       , headingLineIdx  /**< The 0-based line number (in the whole diff) of the hunk header line (the one starting with `@@`) */
       ;
    QString srcFile    /**< The source filename */
          , tgtFile    /**< The target filename */
          , heading    /**< The heading of the hunk (the stuff in the header line after the position spec, i.e. after the second `@@`) */
          ;
    QStringList lines; /**< The lines comprising the hunk (excluding the header) */

    /**
     * @returns the 0-based line number (in the whole diff) of the last line contained in the hunk.
     */
    uint lastLineIdx() const { return headingLineIdx+lines.size(); }

    /**
     * @param lineIdx the 0-based line number of the tested line in the whole diff
     * @returns true if the line is part of the diff and false otherwise
     * @note: Returns true also for the header line (the one starting with `@@`)
     */
    bool containsDiffLine( uint lineIdx ) const {return headingLineIdx <= lineIdx && lineIdx <= lastLineIdx();}

    /**
     * Returns the index of the line within the hunk
     *
     * @param diffLineIdx the 0-based indes of the line in the diff
     *
     * @note assumes that the line is contained within the hunk
     * @note if the line is a header line, -1 is returned; otherwise the returned
     * number is the index of the line in the `lines` list
     */
    int diffLineToHunkLine(uint diffLineIdx) const {
        return diffLineIdx-(headingLineIdx+1);
    }

    /**
     * A helper method to construct a hunk header from the provided info
     *
     * A hunk header has the following form:
     *
     *      @@ oldStart,oldCount newStart,newCount @@ heading
     * e.g.
     *      @@ -36,14 +36,28 @@ public:
     *
     * @returns the hunk header
     */
    static QString formatHeader(uint oldStart, uint oldCount, uint newStart, uint newCount, QString head);

    /**
     * The following operators define a PARTIAL order on the hunks list.
     * A hunk H is strictly below a hunk K iff the endline of H is strictly below
     * the start line of K. In particular, the only non-overlapping hunks are
     * ordered.
     */
    bool operator<( const DiffHunk& b ) const {return lastLineIdx() < b.headingLineIdx;}
    bool operator<( uint line ) const  {return lastLineIdx() < line;}
    bool operator<=( const KDevelop::DiffHunk& b ) const {return lastLineIdx() <= b.headingLineIdx;}
    bool operator<=( uint line ) const {return lastLineIdx() <= line;}
    bool operator>( const KDevelop::DiffHunk& b ) const {return headingLineIdx > b.lastLineIdx();}
    bool operator>( uint line ) const {return headingLineIdx > line;}
    bool operator>= ( const KDevelop::DiffHunk& b ){return headingLineIdx >= b.lastLineIdx();}
    bool operator>=( uint line ) const {return headingLineIdx >= line;}
};

/* RegExp matching a hunk header line */
Q_GLOBAL_STATIC_WITH_ARGS(const QRegularExpression, HUNK_HEADER_RE, (QLatin1String("^@@ -([0-9,]+) \\+([0-9,]+) @@(.*)")))
// static const auto HUNK_HEADER_RE = QRegularExpression(QStringLiteral("^@@ -([0-9,]+) \\+([0-9,]+) @@(.*)"));

/* RegExp matching a meta line containing a source of target filename */
Q_GLOBAL_STATIC_WITH_ARGS(const QRegularExpression, DIFF_FILENAME_RE, (QLatin1String("^[-+]{3} [ab]/(.*)")))

/* RegExp matching a meta line (hunk header, filename, other info) */
Q_GLOBAL_STATIC_WITH_ARGS(const QRegularExpression, META_LINE_RE, (QLatin1String("(^[-+]{3} )|^[^-+ ]")))

/* RegExps matching conflict delimiting lines */
Q_GLOBAL_STATIC_WITH_ARGS(const QRegularExpression, CONFLICT_START_RE, (QLatin1String("^<<<<<<<")))
Q_GLOBAL_STATIC_WITH_ARGS(const QRegularExpression, CONFLICT_MID_RE, (QLatin1String("^=======")))
Q_GLOBAL_STATIC_WITH_ARGS(const QRegularExpression, CONFLICT_END_RE, (QLatin1String("^>>>>>>>")))
Q_GLOBAL_STATIC_WITH_ARGS(const QRegularExpression, CONFLICT_RE, (QLatin1String("(^>>>>>>>)|(^=======)|(^<<<<<<<)")))

QString formatRange(uint start, uint count) {
    if (count == 1) return QString().setNum(start);
    return QString().setNum(start)+QLatin1Char(',')+QString().setNum(count);
}
std::pair<uint, uint> parseRange(const QString& range) {
    int commaPos = range.indexOf(QLatin1Char(','));
    if (commaPos > -1) {
        return {range.midRef(0,commaPos).toInt(), range.midRef(commaPos+1).toInt()};
    }
    return {range.toInt(), 1};
}

/* Creates a hunk header line (starting with @@)
 *
 * Note: The line will not end with a newline */
QString DiffHunk::formatHeader(uint oldStart, uint oldCount, uint newStart, uint newCount, QString head) {
    return QLatin1String("@@ -")
         + formatRange(oldStart, oldCount)
         + QLatin1String(" +")
         + formatRange(newStart, newCount)
         + QLatin1String(" @@")
         + head;
}

/**
 * Parses a unified diff into a list of "diff hunks" (each hunk starts with a
 * line starting with @@ and represents a collection of localized changes).
 *
 * @param diff a diff in git's unified diff format
 * @returns a list of hunk structures
 *
 * The diff is assumed to be a collection of hunks, where each hunk has the
 * following structure:
 *
 *   METADATA
 *   --- a/SOURCE_PATH
 *   +++ b/TARGET_PATH
 *   HUNK HEADER
 *   HUNK CONTENT
 *
 * All metadata lines match the @ref:META_LINE_RE regexp (starts with anything
 * except for a '+', '-' and ' ') and these are discarded except for the hunk
 * header and source/target path specifications. The path specifications
 * are assumed to apply to all following hunks until a new path specification
 * is found and are stored in the srcFileName and tgtFileName attributes of
 * the hunk structure.
 *
 *
 * Hunk Header
 * -----------
 *
 * The hunk header has the following form
 *
 *   @@ -SRC_OFFSET[, SRC_CHANGES_COUNT] +TGT_OFFSET[, TGT_CHANGES_COUNT] @@ Heading
 *
 * where the SRC_OFFSET is a 1-based line index pointing to the source file where
 * the hunk applies and TGT_OFFSET is a 1-based line index pointing to the target
 * file where the hunk applies. These are parsed into the srcStart and tgtStart
 * attributes of the hunk structure.
 *
 * The optional SRC_CHANGES_COUNTS (assumed to be 1 if not present) specifies the
 * number of context lines (starting with ' ') plus the number of deleted lines
 * (starting with '-'). Similarly, the optional TGT_CHANGES_COUNT specifies the
 * number of context lines plus the number of added lines (starting with '+').
 * These are parsed and stored in the srcCount and tgtCount attributes of the hunk
 * structure, but not checked (!). I.e. if the diff hunk has more/less changes then
 * specified, the returned hunk structure will have invalid src & tgt counts.
 *
 * Finally the Heading, used as a visual aid for users, is supposed to show the line
 * where the nearest enclosing function scope of the hunk starts. It is parsed and
 * stored in the heading attribute.
 *
 * Hunk Content
 * ------------
 *
 * The hunk content is a collection of lines which
 *
 *   1) start with '+' (additions); or
 *   2) start with '-' (deletions); or
 *   3) start with ' ' (context lines); or
 *   4) are empty (context lines); or
 *   5) are within conflict markers
 *
 * These lines are parsed and stored in the lines attribute of the hunk structure.
 * The parsing of the hunk stops when a METADATA line (outside of conflict markers)
 * is encountered or the end of the file is reached.
 *
 * Conflict Markers
 * ----------------
 *
 * Conflict markers are collections of lines of the form:
 *
 *   >>>>>>> our ref
 *   our content
 *   ...
 *   =======
 *   their content
 *   ...
 *   <<<<<<< their ref
 *
 * And show content which a merge was not able to merge automatically.
 * Strictly speaking, these should not appear in diffs, but git diff
 * generates them anyway for files with unresolved conflicts.
 */
QList<KDevelop::DiffHunk *> parseHunks(VcsDiff& diff)
{
    QList<KDevelop::DiffHunk*> ret;
    int lineNo = -1;
    QString curSrcFileName, curTgtFileName;
    QStringListIterator lines(diff.diff().split(QLatin1Char('\n')));
    while( lines.hasNext() ) {
        lineNo++;
        auto curln = lines.next();
        auto m = DIFF_FILENAME_RE->match(curln);
        if (m.hasMatch()) {
            if (curln.startsWith(QLatin1Char('-')))
                curSrcFileName = m.captured(1);
            else if (curln.startsWith(QLatin1Char('+')))
                curTgtFileName = m.captured(1);
            continue;
        }
        m = HUNK_HEADER_RE->match(curln);
        if (! m.hasMatch() ) continue;
        auto [oldStart, oldCount] = parseRange(m.captured(1));
        auto [newStart, newCount] = parseRange(m.captured(2));
        auto heading = m.captured(3);
        uint firstLineIdx = lineNo;
        QStringList hunkLines;
        while( lines.hasNext() && (
             CONFLICT_START_RE->match(lines.peekNext()).hasMatch() ||
           ! META_LINE_RE->match(lines.peekNext()).hasMatch()
        )) {
            // Consume the conflict
            if (CONFLICT_START_RE->match(lines.peekNext()).hasMatch()) {
                lineNo ++;
                hunkLines << lines.next();
                while( lines.hasNext() && ! CONFLICT_END_RE->match(lines.peekNext()).hasMatch()) {
                    lineNo++;
                    hunkLines << lines.next();
                }
                if (! CONFLICT_END_RE->match(lines.peekNext()).hasMatch()) {
                    qCWarning(VCS) << "Invalid diff format, end of file reached before conflict finished";
                    qCDebug(VCS) << diff.diff();
                    break;
                }
            }
            lineNo ++;
            hunkLines << lines.next();
        }

        // The number of filenames present in the diff should match the number
        // of hunks
        ret << new DiffHunk{
            oldStart
          , oldCount
          , newStart
          , newCount
          , firstLineIdx
          , curSrcFileName
          , curTgtFileName
          , heading
          , hunkLines
        };
    }

    // If the diff ends with a newline, for the last hunk, when splitting into lines above
    // we will always get an empty string at the end, which we now remove
    if (diff.diff().endsWith(QLatin1Char('\n'))) {
        if (ret.size() > 0 && ret.back()->lines.size() > 0) {
            ret.back()->lines.pop_back();
        } else {
            qCWarning(VCS) << "Failed to parse a diff, produced no hunks";
            qCDebug(VCS) << "Failed diff:" << diff.diff();
        }
    }

    return ret;
}

class VcsDiffPrivate : public QSharedData
{
public:
    QUrl baseDiff;
    QString diff;
    uint depth = 0;
    QList<DiffHunk*> hunks;

    enum Dest {
        SRC = '-',
        TGT = '+',
    };

    /**
     * Maps a line position in the diff to a corresponding line position in the destination file.
     *
     * @param line a 0-based line position in the diff
     * @param dest specifies the destination file to map to:
     *             either SRC (the source file, '-') or TGT (the target file, '+')
     * @returns a @ref VcsDiff::SourceLocation whose path is the (relative to diff root)
     *          destination file path and line the 0-based line position in the
     *          destination file or {"", -1} if no such position exists.
     */
    VcsDiff::SourceLocation mapDiffLine ( const uint line, const Dest dest ) const
    {
        const QLatin1Char skipChar = (dest == SRC) ? QLatin1Char(TGT) : QLatin1Char(SRC);
        for(auto h: hunks) {
            if (h->containsDiffLine(line)) {
                int hunkPos = h->diffLineToHunkLine(line);

                // The line refers to the heading line
                if (hunkPos < 0)
                    return {};

                // Any lines in the diff hunk which come before line and come from the opposite
                // of dest should not be counted (they are not present in the dest)
                int skipCount = 0;
                for(int i=0; i<hunkPos; i++) {
                    if (h->lines.at(i).startsWith(skipChar)) skipCount++;
                }

                // Any lines in the diff hunk which come from the second part (src)/ first part (tgt)
                // of a conflict should not be counted either
                bool inConflict = false; // This is set so that a line inside a conflict is recognized as a valid line
                for(int i=0; i<hunkPos; i++) {
                    if (CONFLICT_START_RE->match(h->lines.at(i)).hasMatch()) {
                        skipCount++; // skip the conflict marker line
                        if (dest == TGT) {
                            while((++i)<hunkPos && !CONFLICT_MID_RE->match(h->lines.at(i)).hasMatch()) {
                                skipCount++;
                            }
                        } else {
                            inConflict = true;
                        }
                    }
                    if (CONFLICT_MID_RE->match(h->lines.at(i)).hasMatch()) {
                        skipCount++; // skip the conflict marker line
                        if (dest == SRC) {
                            while((++i)<hunkPos && !CONFLICT_END_RE->match(h->lines.at(i)).hasMatch())
                                skipCount++;
                        } else {
                            inConflict = true;
                        }
                    }
                    if (CONFLICT_END_RE->match(h->lines.at(i)).hasMatch()) {
                        skipCount++; // skip the conflict marker line
                        inConflict = false;
                    }
                }

                auto ln = h->lines[hunkPos];

                // This works around the fact that inConflict is set even if hunkPos
                // ends up hitting a conflict marker
                if (CONFLICT_RE->match(ln).hasMatch())
                    return {};

                if (ln.startsWith(dest) || ln.startsWith(QLatin1Char(' ')) || ln.isEmpty() || inConflict) {
                    if (dest == SRC)
                        // The -1 accounts for the fact that srcStart is 1-based
                        // but we need to return 0-based line numbers
                        return {h->srcFile, static_cast<int>(h->srcStart-1+hunkPos-skipCount)};
                    else
                        // The -1 accounts for the fact that srcStart is 1-based
                        // but we need to return 0-based line numbers
                        return {h->tgtFile, static_cast<int>(h->tgtStart-1+hunkPos-skipCount)};
                } else return {};
            }
        }
        return {};
    }
};

KDevelop::VcsDiff VcsDiff::subDiffHunk(const uint line, DiffDirection dir) const
{
    auto hunks = d->hunks;
    for(const auto* hunk: hunks) {
        if (hunk->containsDiffLine(line)) {
            return subDiff(hunk->headingLineIdx, hunk->lastLineIdx(), dir);
        }
    }

    VcsDiff emptyDiff;
    emptyDiff.setBaseDiff(d->baseDiff);
    emptyDiff.setDepth(d->depth);
    emptyDiff.setDiff(d->diff.mid(0,d->diff.indexOf(QStringLiteral("@@"))));
    return emptyDiff;
}

KDevelop::VcsDiff VcsDiff::subDiff(const uint startLine, const uint endLine, DiffDirection dir) const
{
    // Code adapted from cola/diffparse.py
    enum LineType {
        ADD = '+',
        DEL = '-',
        CTX = ' ',
        NO_NEWLINE = '\\'
    };

    VcsDiff ret;
    ret.setBaseDiff(baseDiff());
    ret.setDepth(depth());


    auto hunks = d->hunks;
    QStringList lines;
    for(const auto* hunk: hunks) {
        // Skip hunks before the first line
        if (*hunk < startLine)
            continue;

        // Skip hunks after the last line
        if (*hunk > endLine)
            break;

        std::map<LineType, int> counts = {
            {ADD, 0},
            {DEL, 0},
            {CTX, 0},
            {NO_NEWLINE, 0}
        };
        QStringList filteredLines;

        // Will be set if the previous line in a hunk was
        // skipped because it was not in the selected range
        bool prevSkipped = false;

        uint lnIdx = hunk->headingLineIdx;

        // Store the number of skipped lines which start the hunk
        // (i.e. lines before a first deletion (addition in case of reverse)
        // so that we can adjust the start appropriately
        int startOffset = 0;
        const auto _lines = QStringList(hunk->lines.constBegin(), hunk->lines.constEnd());
        for(const auto& line: _lines) {
            lnIdx++;
            LineType tp = line.length() > 0 ? (LineType) line[0].toLatin1() : (LineType) 0;
            QString content = line.mid(1);

            if (dir == Reverse) {
                if (tp == ADD) tp = DEL;
                else if (tp == DEL) tp = ADD;
            }

            if (lnIdx < startLine || endLine < lnIdx) {
                // skip additions (or deletions if reverse) that are not in range
                if (tp == ADD) {
                    prevSkipped=true;
                    // If we are before the range and
                    // so far we only encountered ADD (or DEL, in case of reverse) lines
                    // these will not be included in the subdiff hunk so we increase the startOffset
                    if (lnIdx < startLine && counts[CTX] == 0) startOffset++;
                    continue;
                }
                // change deletions (or additions if reverse) that are not in range into context
                if (tp == DEL) tp=CTX;
            }

            // If the line immediately before a "No newline" line was
            // skipped (because it was an unselected addition) skip
            // the "No newline" line as well.
            if (tp == NO_NEWLINE && prevSkipped ) {
                if (lnIdx <= endLine ) startOffset++;
                continue;
            }

            // Empty lines are context lines and we
            // preserve them
            if ((int)tp == 0) {
                filteredLines << content;
                tp = CTX;
            } else {
                filteredLines << QLatin1Char(tp)+content;
            }
            counts[tp]++;
            prevSkipped = false;
        }

        // Skip hunks which have only context lines
        if (counts[ADD] + counts[DEL] == 0)
            continue;


        // Compute the start & counts of the hunks
        uint subSrcStart, subTgtStart;
        if (dir == Reverse) {
            subSrcStart = hunk->tgtStart + startOffset;
            subTgtStart = hunk->srcStart + startOffset;
        } else {
            subSrcStart = hunk->srcStart + startOffset;
            subTgtStart = hunk->tgtStart + startOffset;
        }
        uint subSrcCount = counts[CTX] + counts[DEL];
        uint subTgtCount = counts[CTX] + counts[ADD];

        // Prepend lines identifying the source files
        lines << QStringLiteral("--- a/") + ((dir == Reverse) ? hunk->tgtFile : hunk->srcFile);
        lines << QStringLiteral("+++ b/") + ((dir == Reverse) ? hunk->srcFile : hunk->tgtFile);

        lines << DiffHunk::formatHeader(
            subSrcStart, subSrcCount,
            subTgtStart, subTgtCount,
            hunk->heading
        );
        lines += filteredLines;
    }
    if (lines.size() > 2)
        ret.setDiff(lines.join(QLatin1Char('\n'))+QLatin1Char('\n'));
    return ret;
}


const QVector<VcsDiff::FilePair> VcsDiff::fileNames() const
{
    QVector<VcsDiff::FilePair> ret;
    VcsDiff::FilePair current;
    for(auto h: d->hunks) {
        // List each pair only once
        if (h->srcFile == current.source && h->tgtFile == current.target)
            continue;
        current = {h->srcFile, h->tgtFile};
        ret.push_back(current);
    }
    return ret;
}


VcsDiff::SourceLocation VcsDiff::diffLineToSource ( const uint line ) const
{
    return d->mapDiffLine(line, VcsDiffPrivate::SRC);
}

VcsDiff::SourceLocation VcsDiff::diffLineToTarget ( const uint line ) const
{
    return d->mapDiffLine(line, VcsDiffPrivate::TGT);
}

VcsDiff::VcsDiff()
    : d(new VcsDiffPrivate)
{
}

VcsDiff::~VcsDiff() = default;

VcsDiff::VcsDiff( const VcsDiff& rhs )
    : d(rhs.d)
{
}

bool VcsDiff::isEmpty() const
{
    return d->diff.isEmpty();
}

QString VcsDiff::diff() const
{
    return d->diff;
}


void VcsDiff::setDiff( const QString& s )
{
    d->diff = s;
    d->hunks = parseHunks(*this);
}

VcsDiff& VcsDiff::operator=( const VcsDiff& rhs)
{
    d = rhs.d;
    return *this;
}

QUrl VcsDiff::baseDiff() const
{
    return d->baseDiff;
}

uint VcsDiff::depth() const
{
    return d->depth;
}

void VcsDiff::setBaseDiff(const QUrl& url)
{
    d->baseDiff=url;
}

void VcsDiff::setDepth(const uint depth)
{
    d->depth = depth;
}


}

