// edbee - Copyright (c) 2012-2025 by Rick Blommers and contributors
// SPDX-License-Identifier: MIT

#include "commentcommand.h"

#include "edbee/edbee.h"
#include "edbee/models/dynamicvariables.h"
#include "edbee/models/textlexer.h"
#include "edbee/models/texteditorconfig.h"
#include "edbee/models/textbuffer.h"
#include "edbee/models/textdocument.h"
#include "edbee/models/textdocumentscopes.h"
#include "edbee/views/textselection.h"
#include "edbee/texteditorcontroller.h"
#include "edbee/util/rangelineiterator.h"
#include "edbee/util/regexp.h"

#include "edbee/debug.h"

namespace edbee {

// TODO, we need to rewrite this for improved testability
// TODO, this code can be much clearer, if we let go of the TM_ structure!!

/// A helper structures for storing comment start and end definitions
class CommentDefinitionItem
{
public:
    /// constructs the definition
    CommentDefinitionItem(const QString& startIn, const QString& endIn)
        : start_(startIn)
        , end_(endIn)
        , commentStartRegExp_(nullptr)
        , removeCommentStartRegeExp_(nullptr)
    {
    }

    /// the destructor
    virtual ~CommentDefinitionItem()
    {
        delete removeCommentStartRegeExp_;
        delete commentStartRegExp_;
    }

    /// returns the start regular expression
    const QString& start() { return start_;  }

    /// returns the end regular expression
    const QString& end() { return end_; }

    /// returns true if the definition is a block comment
    bool isBlockComment() { return !end_.isEmpty(); }

    /// creates the regexp for the start regular expression
    RegExp* commentStartRegExp()
    {
        if (!commentStartRegExp_) {
            commentStartRegExp_ = new RegExp(QStringLiteral("^\\s*%1").arg( RegExp::escape( start().trimmed())));
        }
        return commentStartRegExp_;
    }

    /// creates the regexp for removing a comment
    RegExp* removeCommentStartRegeExp()
    {
        if (!removeCommentStartRegeExp_) {
            removeCommentStartRegeExp_ = new RegExp(QStringLiteral("^[^\\S\n]*(%1[^\\S\n]?)").arg( RegExp::escape( start().trimmed())));
        }
        return removeCommentStartRegeExp_;
    }

private:
    QString start_;
    QString end_;
    RegExp* commentStartRegExp_;
    RegExp* removeCommentStartRegeExp_;
};


/// A class to temporary store all comment definitions for the current scope
/// placed in a class so we can make use of
class CommentDefinition
{
public:
    /// the destructor deletes all mail comment definitions
    ~CommentDefinition() { qDeleteAll(commentDefinitionList_); }

    /// returns a reference to the list for direct access
    QList<CommentDefinitionItem*>& list() { return commentDefinitionList_; }

    /// Returns the first comment definition with the given criteria
    /// It return 0 if no block definition has been found
    /// @param block should we find a block definition
    CommentDefinitionItem* findDefinition(bool block)
    {
        foreach (CommentDefinitionItem* item, commentDefinitionList_) {
            if (block == item->isBlockComment()) { return item; }
        }
        return 0;
    }

private:
    QList<CommentDefinitionItem*> commentDefinitionList_;
};


//--------------------------------


/// Checks if all lines are comment
/// @param doc the document to check
/// @param range the range for checking all commented lines
/// @param commentStart the text used as the line comment prefix
/// @return true if all lines are commented
static bool areAllLinesCommented(TextDocument* doc, TextRange& range, const QList<CommentDefinitionItem*>& definitions)
{
    // we directly check in the buffer for fast regexp without QString creation
    TextBuffer* buf = doc->buffer();

    // iterate over all lines
    RangeLineIterator itr(doc, range);
    while (itr.hasNext()) {
        size_t line = itr.next();
        size_t lineLength = doc->lineLengthWithoutNewline(line);

        // when it's the last line and its blank, we must skip it
        if (!itr.hasNext() && lineLength == 0) {
            continue;
        }

        // directly search in the raw data pointer buffer to prevent QString creation
        size_t offset = doc->offsetFromLine(line);

        // iterate over all line definitions
        bool found = false;
        foreach (CommentDefinitionItem* def, definitions) {

            // skip block comments
            if (def->isBlockComment()) { continue; }

            RegExp* commentStartRegExp = def->commentStartRegExp();

            /// toggle the found flag if a comment is found and break
            if (commentStartRegExp->indexIn(buf->rawDataPointer(), offset, offset+ doc->lineLength(line) - 1) != std::string::npos) {
                found = true;
                break;
            }
        }

        if (!found) {
            return false;
        }
    }
    return true;
}


/// removes all line comments from the iven line
/// @param doc the document to change
/// @param range the range to remove the comments
/// @param commentStart the text used as the line comment prefix
static void removeLineComment(TextDocument* doc, TextRange& range, const QList<CommentDefinitionItem*>& definitions)
{
//    RegExp regExp( QStringLiteral("^[^\\S\n]*(%1[^\\S\n]?)").arg( RegExp::escape(commentStart.trimmed() ) ) );
    TextBuffer* buf = doc->buffer();

    // iterate over all lines and build all ranges
    RangeLineIterator itr(doc, range);
    while (itr.hasNext()) {

        // directly search in the raw data pointer buffer to prevent QString creation
        size_t line = itr.next();
        size_t offset = doc->offsetFromLine(line);

        // iterate over alll definitions
        foreach (CommentDefinitionItem* def, definitions) {
            // skip block comments
            if (def->isBlockComment()) { continue; }

            RegExp* regExp = def->removeCommentStartRegeExp();

            // perform a regexp to extract the comment that needs to be removed
            if (regExp->indexIn(buf->rawDataPointer(), offset, offset + doc->lineLength(line)) != std::string::npos) {
                // remove the found regexp and goto the next line
                doc->replace(regExp->pos(1), regExp->len(1), "");
                break;
            }
        }
    }
}


/// Adds comments on all line starts
/// @param doc the document to insert the comment
/// @param range the textrange to insert comments
/// @param str the comment prefix
static void insertLineComments(TextDocument* doc, TextRange& range, const QString& str)
{
    TextBuffer* buf = doc->buffer();

    // iterate over all lines and build all ranges
    RangeLineIterator itr(doc, range);
    while (itr.hasNext()) {
        // directly search in the raw data pointer buffer to prevent QString creation
        size_t line = itr.next();
        size_t offset = doc->offsetFromLine(line);
        size_t lineLength = doc->lineLengthWithoutNewline(line);

        // when it's the last line and its blank, we must skip it
        if (!itr.hasNext() && lineLength == 0) {
            continue;
        }

        // when there's no comment found at this line return false
        size_t wordStart = buf->findCharPosWithinRangeOrClamp(offset, 1, doc->config()->whitespaces(), false, offset, offset + lineLength);
        doc->replace(wordStart, 0, str);

    }
}


/// Removes a block comment if possible
/// @param controller the controller to perform the operation on
/// @param range the current textrange
/// @param commentStart the start of the commment
/// @param commentEnd the end of the comment
/// @return this method returns true if the block comment was remoed
static bool removeBlockComment(TextEditorController* controller, TextRange& range, const QList<CommentDefinitionItem*>& definitions)
{
    TextDocument* doc = controller->textDocument();
    TextDocumentScopes* scopes = doc->scopes();

    // it seems that every language in sublime/textmate use the comment.block convention
    TextScope* blockCommentScope = Edbee::instance()->scopeManager()->refTextScope("comment.block");

    // we only fetch multi-line scoped textranges
    size_t middleRange= range.min() + (range.max() - range.min())/2;

    // With this uberdirty for loop, we also check if there's a commentscope
    // 1 character to the left. (this is required to fix the uncommnt block issue when the caret is next to the comment :)
    for (size_t i=0; middleRange && i < 2; ++i) {

        QVector<ScopedTextRange*> scopedRanges = scopes->createScopedRangesAtOffsetList(middleRange);

        foreach (ScopedTextRange* scopedRange, scopedRanges) {
            TextScope* scope = scopedRange->scope();

            // did we found a block comment
            if (scope->startsWith( blockCommentScope) ){
                size_t min = scopedRange->min();
                size_t max = scopedRange->max();

                // check all 'block' comments
                foreach (CommentDefinitionItem* def, definitions)  {

                    // skip if not a blockcomment
                    if (!def->isBlockComment()) { continue; }

                    const QString& commentStart = def->start();
                    const QString& commentEnd = def->end();
                    const size_t commentStartLength = static_cast<size_t>(commentStart.length());
                    const size_t commentEndLength = static_cast<size_t>(commentEnd.length());

                    // when the scope starts and ends with the comment start and end remove it
                    if (doc->textPart(min, commentStartLength) == commentStart  &&
                        doc->textPart(max - commentEndLength, commentEndLength ) == commentEnd) {

                        // * warning! * we cannot use the scoped rangeset directly
                        // when we replace a text, the scoped rangeset could be invalidated and destroyed!

                        // next remove the range
                        doc->replace(min, commentStartLength, "");
                        doc->replace(max - commentEndLength - commentStartLength, commentEndLength, "");

                        qDeleteAll(scopedRanges);
                        return true;
                    }
                }
            }
        }
        qDeleteAll(scopedRanges);

        // 1 char to the left
        --middleRange;
    }
    return false;
}


/// Insert a block comment
/// Adds a comment start and end around the current text range.
/// If the textrange is empty, it first is expanded to a full line
/// @param controller the controller to insert a comment for
/// @param range the range to insert a block comment for
/// @param commentStart the start of the comment
/// @param commentEnd the end of the comment
/// @return the last affected range
static size_t insertBlockComment(TextEditorController* controller, TextRange& range, const QString& commentStart, const QString& commentEnd)
{
    TextDocument* doc = controller->textDocument();

    // for safety I don't use the range anymore. It's adjusted automatically, but could in theory vanish
    size_t min = range.min();
    size_t max = range.max();

    // when the range is 'blank' expand it to a full line
    if (range.isEmpty()) {
        size_t line = doc->lineFromOffset(min);
        min = doc->offsetFromLine(line);
        max = min + doc->lineLengthWithoutNewline(line);
    }

    doc->replace(min, 0, commentStart);
    doc->replace(max + static_cast<size_t>(commentStart.length()), 0, commentEnd);

    return max + static_cast<size_t>(commentStart.length()) + static_cast<size_t>(commentEnd.length());
}



/// Collect all active comment structures
static bool getCommentDefinitions(TextEditorController* controller, TextRange& range, QList<CommentDefinitionItem*>& definitions)
{
    TextDocument* doc = controller->textDocument();
    TextDocumentScopes* scopes = doc->scopes();

    // retrieve the starting scope
    TextScopeList startScopeList = scopes->scopesAtOffset( range.min(), true );

    // next find all comment definitions
    QString padding;
    size_t index=1;
    do {
        // retrieve the comment start
        // when there's no comment start then we're done
        QString commentStart = controller->dynamicVariables()->value(QStringLiteral("TM_COMMENT_START%1").arg(padding), &startScopeList).toString();
        if (commentStart.isEmpty()) {  break; }

        // when there's a start there could be an ending (for block comments)
        QString commentEnd = controller->dynamicVariables()->value( QStringLiteral("TM_COMMENT_END%1").arg(padding), &startScopeList).toString();
        definitions.push_back(new CommentDefinitionItem(commentStart, commentEnd));

        // increase the index and change the padding
        ++index;
        padding = QStringLiteral("_%1").arg(index);
    }
    while (true);


    // return true if the comment isn't empty
    return !definitions.isEmpty();
}


/// Comments the given range
/// @param controller the controller that conatins the controller
/// @param range the range to comment
/// @return the last affected offset
static size_t commentRange(TextEditorController* controller, TextRange& range, bool block)
{
    CommentDefinition defs;
    // retrieve all comment definitions
    if (!getCommentDefinitions(controller, range, defs.list())) {
         return range.max();
    }

    // here we need to remove an active block comment, if there is one
    if (removeBlockComment(controller, range, defs.list())) {
        return range.max();
    }

    // find the first block definition
    CommentDefinitionItem* blockDef = defs.findDefinition(true);
    CommentDefinitionItem* lineDef = defs.findDefinition(false);

    // check if we need to do a block comment
    if (blockDef && (block || !lineDef)) {
        return insertBlockComment(controller, range, blockDef->start(), blockDef->end());

    // check if we need to perform a line comment
    } else if (lineDef) {
        TextDocument* doc = controller->textDocument();

        // we need to remove all comments
        if (areAllLinesCommented( doc, range, defs.list())) {
            removeLineComment(doc, range, defs.list());

        // add comments
        } else {
            insertLineComments(doc, range, lineDef->start());
        }
    }
    return range.max();
}


//---------------------------


/// constructs the comment comment
/// @param block should we try a block comment
CommentCommand::CommentCommand(bool block)
    : block_(block)
{
}


/// Executes the given comment
/// @param controller the controller this command is executed for
void CommentCommand::execute(TextEditorController* controller)
{
    // iterate over all ranges
    TextRangeSet* selection = new DynamicTextRangeSet(controller->textSelection());
    TextDocument* doc = controller->textDocument();

    // start changing the document
    // int lastOffset = -1;

    doc->beginChanges(controller);
    for (size_t i = 0, cnt = selection->rangeCount(); i < cnt; ++i) {
        TextRange range = selection->range(i);

        // assert the ranges are all lexed!
        controller->textDocument()->textLexer()->lexRange(range.min(), range.max());

        // comment the range and get the last offset
        // lastOffset = commentRange( controller, range, block_ );
        commentRange(controller, range, block_);
    }

    doc->giveSelection(controller, selection);
    doc->endChanges(0);

    // the following call shouldn't be necessary
    controller->update();
}


/// converts this command to a string
QString CommentCommand::toString()
{
    return "CommentCommand";
}


} // edbee
