/*
 *   SPDX-FileCopyrightText: 2010 Peter Penz <peter.penz19@gmail.com>
 *   SPDX-FileCopyrightText: 2023 Harald Sitter <sitter@kde.org>
 *
 *   SPDX-License-Identifier: GPL-2.0-or-later
 */

#include "kio_filenamesearch.h"
#include "kio_filenamesearch_p.h"

#include "kio_filenamesearch_debug.h"

#include <KFileItem>
#include <KIO/FileCopyJob>
#include <KIO/ListJob>
#include <KLocalizedString>

#include <QCoreApplication>
#include <QDBusInterface>
#include <QDir>
#include <QMimeDatabase>
#include <QProcess>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QTemporaryFile>
#include <QUrl>
#include <QUrlQuery>

namespace
{

QString ensureTrailingSlash(const QString &str)
{
    QString path = str;
    if (!path.endsWith(QLatin1Char('/'))) {
        path += QLatin1Char('/');
    }
    return path;
}

bool hasBeenVisited(const QString &path, std::set<QString> &seenDirs)
{
    // If the file or folder is within any of the seenDirs, it has already
    // been visited, skip the entry.

    for (auto iterator : seenDirs) {
        if (path.startsWith(iterator)) {
            return true;
        }
    }
    return false;
}

bool isDotOrDotDot(const QString &fileName)
{
    return fileName == QLatin1String(".") || fileName == QLatin1String("..");
}

//  Returns a regex with special characters escaped while still allowing flexible
//  matches of whitespace.

QString escapePhrase(const QString &regex)
{
    QString escapedRegex = regex;

    escapedRegex.replace(QRegularExpression(QStringLiteral("(\\s|_)+")), QStringLiteral(" "));
    escapedRegex = QRegularExpression::escape(escapedRegex);
    escapedRegex.replace(QRegularExpression(QStringLiteral("\\\\ ")), QStringLiteral("\\s+"));

    return escapedRegex;
}
}

namespace FileNameSearch
{
using namespace FileNameSearch;

// Pseudo plugin class to embed meta data
class KIOPluginForMetaData : public QObject
{
    Q_OBJECT
    Q_PLUGIN_METADATA(IID "org.kde.kio.worker.filenamesearch" FILE "filenamesearch.json")
};

FileNameSearchProtocol::FileNameSearchProtocol(const QByteArray &pool, const QByteArray &app)
    : QObject()
    , WorkerBase("search", pool, app)
{
    QDBusInterface kded(QStringLiteral("org.kde.kded6"), QStringLiteral("/kded"), QStringLiteral("org.kde.kded6"));
    kded.call(QStringLiteral("loadModule"), QStringLiteral("filenamesearchmodule"));
}

FileNameSearchProtocol::~FileNameSearchProtocol() = default;

KIO::WorkerResult FileNameSearchProtocol::stat(const QUrl &url)
{
    KIO::UDSEntry uds;
    uds.reserve(9);
    uds.fastInsert(KIO::UDSEntry::UDS_ACCESS, 0700);
    uds.fastInsert(KIO::UDSEntry::UDS_FILE_TYPE, S_IFDIR);
    uds.fastInsert(KIO::UDSEntry::UDS_MIME_TYPE, QStringLiteral("inode/directory"));
    uds.fastInsert(KIO::UDSEntry::UDS_ICON_OVERLAY_NAMES, QStringLiteral("baloo"));
    uds.fastInsert(KIO::UDSEntry::UDS_DISPLAY_TYPE, i18n("Search Folder"));
    uds.fastInsert(KIO::UDSEntry::UDS_URL, url.url());

    QUrlQuery query(url);
    QString title = query.queryItemValue(QStringLiteral("title"), QUrl::FullyDecoded);
    if (!title.isEmpty()) {
        uds.fastInsert(KIO::UDSEntry::UDS_NAME, title);
        uds.fastInsert(KIO::UDSEntry::UDS_DISPLAY_NAME, title);
    }

    statEntry(uds);
    return KIO::WorkerResult::pass();
}

// Create a UDSEntry for "."
void FileNameSearchProtocol::listRootEntry()
{
    KIO::UDSEntry entry;
    entry.reserve(4);
    entry.fastInsert(KIO::UDSEntry::UDS_NAME, QStringLiteral("."));
    entry.fastInsert(KIO::UDSEntry::UDS_FILE_TYPE, S_IFDIR);
    entry.fastInsert(KIO::UDSEntry::UDS_SIZE, 0);
    entry.fastInsert(KIO::UDSEntry::UDS_ACCESS, S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IWGRP | S_IXGRP | S_IROTH | S_IXOTH);
    listEntry(entry);
}

FileNameSearchProtocol::SearchOptions
FileNameSearchProtocol::parseSearchOptions(const QString optionContent, const QString optionHidden, const SearchOption defaultOptions)
{
    //  If SearchOption::SearchFileName
    //     Match the search query in the filename
    //  If SearchOption::SearchContent
    //     Match the search query in the filename and the content (of text/plain files)
    //  If SearchOption::IncludeHidden
    //     Search hidden files and within hidden folders
    //  If SearchOption::IncludeHiddenFiles
    //     Search hidden files (but don't navigate down into hidden folders)
    //  If SearchOption::IncludeHiddenFolders
    //     Recurively search hidden folders (without reading hidden files)

    SearchOptions options(defaultOptions);

    if (QString::compare(optionContent, QLatin1String("no"), Qt::CaseInsensitive) == 0) {
        options.setFlag(SearchOption::SearchFileName);
    } else if (QString::compare(optionContent, QLatin1String("yes"), Qt::CaseInsensitive) == 0) {
        options.setFlag(SearchOption::SearchContent);
    }

    if (QString::compare(optionHidden, QLatin1String("yes"), Qt::CaseInsensitive) == 0) {
        options.setFlag(SearchOption::IncludeHidden);
    } else if (QString::compare(optionHidden, QLatin1String("filesandfolders"), Qt::CaseInsensitive) == 0) {
        options.setFlag(SearchOption::IncludeHidden);
    } else if (QString::compare(optionHidden, QLatin1String("folders"), Qt::CaseInsensitive) == 0) {
        options.setFlag(SearchOption::IncludeHiddenFolders);
    } else if (QString::compare(optionHidden, QLatin1String("files"), Qt::CaseInsensitive) == 0) {
        options.setFlag(SearchOption::IncludeHiddenFiles);
    }
    return options;
}

FileNameSearchProtocol::SearchSyntax FileNameSearchProtocol::parseSearchSyntax(const QString optionSyntax, const SearchSyntax defaultSyntax)
{
    //  If SearchSyntax::Regex
    //     Pass the query expression "as is"
    //  If SearchSyntax::Phrase
    //     Escape Regex 'special characters' but allow flexible matching of whitespace
    //  If SearchSyntax::WordList
    //     Treat the pattern as a space separated list of words where the aim is to find
    //     a match for all of them.
    //  The external script can match phrases but not lists of words, in the case of an
    //  explicit syntax=wordlist use the internal code.

    SearchSyntax searchSyntax = defaultSyntax;

    if (QString::compare(optionSyntax, QLatin1String("regex"), Qt::CaseInsensitive) == 0) {
        searchSyntax = SearchSyntax::Regex;
    } else if (QString::compare(optionSyntax, QLatin1String("phrase"), Qt::CaseInsensitive) == 0) {
        searchSyntax = SearchSyntax::Phrase;
    } else if (QString::compare(optionSyntax, QLatin1String("wordlist"), Qt::CaseInsensitive) == 0) {
        searchSyntax = SearchSyntax::WordList;
    }
    return searchSyntax;
}

FileNameSearchProtocol::SearchSrcs FileNameSearchProtocol::parseSearchSrc(const QString optionSrc, const SearchSrc defaultSrcs)
{
    //  If SearchSrc::Internal
    //     Use the internal code
    //  If SearchSrc::External
    //     Call the external script
    //  If SearchSrc::ExternalThenInternal
    //     Use the external script and fall back to using the internal search if it fails.

    SearchSrcs srcs(defaultSrcs);

    if (QString::compare(optionSrc, QLatin1String("internal"), Qt::CaseInsensitive) == 0) {
        srcs = SearchSrc::Internal;
    } else if (QString::compare(optionSrc, QLatin1String("external"), Qt::CaseInsensitive) == 0) {
        srcs = SearchSrc::External;
    }
    return srcs;
}

//  Search for terms in the filename and then, if SearchContent set, in the body of the file
//  Return true if all the terms match
//  Potentially possible to use a single match with regex lookaheads here, as in (?=.*two)(?=.*one),
//  but the need to look in the filename and the content causes too much trouble.

static bool filenameContainsPattern(const KIO::UDSEntry &entry, QHash<QRegularExpression, int> &regexHash)
{
    QHash<QRegularExpression, int>::const_iterator i;
    bool matchedEverything = true;

    for (i = regexHash.constBegin(); i != regexHash.constEnd(); ++i) {
        if (regexHash.value(i.key()) == 0) {
            if (i.key().match(entry.stringValue(KIO::UDSEntry::UDS_NAME)).hasMatch()) {
                regexHash[i.key()]++;
            } else {
                matchedEverything = false;
            }
        }
    }

    return matchedEverything;
}

static bool contentContainsPattern(const QUrl &url, QHash<QRegularExpression, int> &regexHash)
{
    auto fileContainsPattern = [&regexHash](const QString &path) {
        QFile file(path);
        if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
            return false;
        }

        QHashIterator<QRegularExpression, int> regex(regexHash);
        QTextStream in(&file);
        QString line;
        bool matchedEverything = false;

        //  Need to concatenate lines to support multiline phrase searches.
        //  Just search across line boundaries, not paragraph boundaries.
        //  Surely not the best way of doing it, longer term should probably
        //  use the plaintextextractor as this copes with legacy (non unicode)
        //  encodings.

        while (!in.atEnd()) {
            const QString line2 = in.readLine();
            line.append(line2);
            regex.toFront();
            matchedEverything = true;

            while (regex.hasNext()) {
                regex.next();
                if (regexHash.value(regex.key()) == 0) {
                    if (regex.key().match(line).hasMatch()) {
                        regexHash[regex.key()]++;
                    } else {
                        matchedEverything = false;
                    }
                }
            }
            if (matchedEverything) {
                break;
            }
            line = line2;
            line.append(QLatin1Char('\n'));
        }

        return matchedEverything;
    };

    if (url.isLocalFile()) {
        return fileContainsPattern(url.toLocalFile());
    } else {
        QTemporaryFile tempFile;
        if (tempFile.open()) {
            const QString tempName = tempFile.fileName();
            KIO::Job *getJob = KIO::file_copy(url, QUrl::fromLocalFile(tempName), -1, KIO::Overwrite | KIO::HideProgressInfo);
            if (getJob->exec()) {
                // The non-local file was downloaded successfully.
                return fileContainsPattern(tempName);
            }
        }
    }

    return false;
}

bool FileNameSearchProtocol::match(const KIO::UDSEntry &entry, QHash<QRegularExpression, int> regexHash, const SearchOptions options)
{
    bool matchedEverything = filenameContainsPattern(entry, regexHash);

    if (options.testFlag(SearchOption::SearchContent) && !matchedEverything) {
        const QUrl entryUrl(entry.stringValue(KIO::UDSEntry::UDS_URL));
        QMimeDatabase mdb;
        QMimeType mimetype = mdb.mimeTypeForUrl(entryUrl);
        if (mimetype.inherits(QStringLiteral("text/plain"))) {
            matchedEverything = contentContainsPattern(entryUrl, regexHash);
        }
    }

    return matchedEverything;
}

//  Scan a pattern looking for single quotes. A quote within a word,
//  as in "xxx'x", is assumed to be an apostrope and backslash escaped

QString FileNameSearchProtocol::escapeApostrophes(const QString pattern)
{
    bool escaped = false;
    bool seenSpace = true;
    bool seenApostrophe = false;
    QString escapedPattern = QString();

    //  A rough heuristic, if we have a quote at the end of a phrase and it would be the only
    //  quote, escape it so it's treated as an apostrophe.

    int countSingleQuotes = 0;

    auto isEvenNumberOfQuotes = [&countSingleQuotes]() {
        return (countSingleQuotes % 2 == 0);
    };

    auto treatQuoteAsQuote = [&seenApostrophe, &countSingleQuotes, &escapedPattern](const QChar c) {
        if (seenApostrophe) {
            escapedPattern.append(QLatin1Char('\''));
            countSingleQuotes++;
            seenApostrophe = false;
        }
        escapedPattern.append(c);
    };

    auto treatQuoteAsApostrophe = [&seenApostrophe, &escapedPattern](const QChar c) {
        if (seenApostrophe) {
            escapedPattern.append(QLatin1String("\\'"));
            seenApostrophe = false;
        }
        escapedPattern.append(c);
    };

    for (const auto &c : pattern) {
        if (escaped) {
            escapedPattern.append(c);
            escaped = false;
            seenSpace = (c.isSpace() || c == QLatin1Char('_'));
            seenApostrophe = false;
        } else if (c == QLatin1Char('\\')) {
            treatQuoteAsApostrophe(c);
            escaped = true;
        } else if (c == QLatin1Char('\'')) {
            if (!seenSpace && !seenApostrophe) {
                seenApostrophe = true;
            } else {
                escapedPattern.append(QLatin1Char('\''));
                countSingleQuotes++;
            }
        } else if (c.isSpace() || c == QLatin1Char('_')) {
            if (isEvenNumberOfQuotes()) {
                treatQuoteAsApostrophe(c);
            } else {
                treatQuoteAsQuote(c);
            }
            seenSpace = true;
        } else {
            treatQuoteAsApostrophe(c);
            seenSpace = false;
        }
    }

    if (seenApostrophe) {
        if (isEvenNumberOfQuotes()) {
            escapedPattern.append(QLatin1String("\\'"));
        } else {
            escapedPattern.append(QLatin1Char('\''));
        }
        countSingleQuotes++;
    }

    return escapedPattern;
}

//  Split the search string on spaces into a list of terms, returning the terms
//  as keys in a QHash. Used with the SearchSyntax=WordList option.

QStringList FileNameSearchProtocol::splitWordList(const QString pattern)
{
    //  Handle quoted phrases, accepting single or double quotes.
    //  Backslashed quotes are treated as normal characters.
    //  Spaces are replaced by the regex "\s+" when within quoted phrases,
    //  and as separators otherwise.

    //  The handling of underscores mimics Baloo, where they are treated
    //  as word separators. A search for Adventures_in_Wonderland works as
    //  if it is a quoted phrase "Adventures in Wonderland".

    QStringList wordList;
    QString escapedPattern = escapeApostrophes(pattern);

    bool inSingleQuotes = false;
    bool inDoubleQuotes = false;
    bool escaped = false;
    QString splitTerm = QString();

    auto addTerm = [&wordList, &splitTerm](const QString term) {
        if (!term.isEmpty()) {
            wordList.append(term);
            splitTerm = QString();
        }
    };

    for (const auto &c : escapedPattern) {
        if (escaped) {
            splitTerm.append(c);
            escaped = false;
        } else if (c == QLatin1Char('\\')) {
            escaped = true;
        } else if (c == QLatin1Char('\'') && !inDoubleQuotes) {
            if (inSingleQuotes) {
                addTerm(escapePhrase(splitTerm));
            }
            inSingleQuotes = !inSingleQuotes;
        } else if (c == QLatin1Char('"') && !inSingleQuotes) {
            if (inDoubleQuotes) {
                addTerm(escapePhrase(splitTerm));
            }
            inDoubleQuotes = !inDoubleQuotes;
        } else if (c.isSpace() && !inSingleQuotes && !inDoubleQuotes) {
            if (!splitTerm.isEmpty()) {
                splitTerm = escapePhrase(splitTerm);
                addTerm(splitTerm);
            }
        } else {
            splitTerm.append(c);
        }
    }

    if (!splitTerm.isEmpty()) {
        addTerm(escapePhrase(splitTerm));
    }

    return wordList;
}

KIO::WorkerResult FileNameSearchProtocol::listDir(const QUrl &url)
{
    listRootEntry();

    const QUrlQuery urlQuery(url);
    QString search = urlQuery.queryItemValue(QStringLiteral("search"), QUrl::FullyDecoded).trimmed();
    if (search.isEmpty()) {
        return KIO::WorkerResult::pass();
    }

    QUrl dirUrl = QUrl(urlQuery.queryItemValue(QStringLiteral("url"), QUrl::FullyDecoded));

    // Canonicalise the search base directory
    if (dirUrl.isLocalFile()) {
        const QString canonicalPath = QFileInfo(dirUrl.toLocalFile()).canonicalFilePath();
        if (!canonicalPath.isEmpty()) {
            dirUrl = QUrl::fromLocalFile(canonicalPath);
        }
    }

    // Don't try to iterate the /proc directory of Linux
    if (dirUrl.isLocalFile() && dirUrl.toLocalFile() == QLatin1String("/proc")) {
        return KIO::WorkerResult::fail(KIO::ERR_WORKER_DEFINED, i18nc("@info:status", "Invalid search location\nTrying to search the /proc directory"));
    }

    const QString optionContent = urlQuery.queryItemValue(QStringLiteral("checkContent"));
    const QString optionHidden = urlQuery.queryItemValue(QStringLiteral("includeHidden"));
    const QString optionSyntax = urlQuery.queryItemValue(QStringLiteral("syntax"));
    const QString optionSrc = urlQuery.queryItemValue(QStringLiteral("src"));

    SearchSrcs srcs = parseSearchSrc(optionSrc, SearchSrc::ExternalThenInternal);
    SearchOptions options = parseSearchOptions(optionContent, optionHidden, SearchOption::SearchFileName);
    SearchSyntax syntax = parseSearchSyntax(optionSyntax, SearchSyntax::Regex);

    QStringList termList;
    switch (syntax) {
    case SearchSyntax::Regex:
        termList = {search};
        break;
    case SearchSyntax::Phrase:
        search = escapePhrase(search);
        termList = {search};
        break;
    case SearchSyntax::WordList:
        termList = splitWordList(search);
        break;
    }
    int numberOfTerms = termList.count();

    //  Check the split up list of search terms or expressions to see whether they are valid.

    auto invalidRegex = [](const QString &search, QString errorString) {
        qCWarning(KIO_FILENAMESEARCH) << "Invalid QRegularExpression/PCRE search pattern:" << search;
        errorString[0] = errorString[0].toUpper();
        return KIO::WorkerResult::fail(KIO::ERR_WORKER_DEFINED,
                                       i18nc("@info:status", "Invalid search query: '%1'\nExpected a regular expression: %2", search, errorString));
    };

    for (auto term : termList) {
        const QRegularExpression regex(term, QRegularExpression::CaseInsensitiveOption);
        if (!regex.isValid()) {
            return invalidRegex(regex.pattern(), regex.errorString());
        }
    }

#if !defined(Q_OS_WIN32)
    // Prefer using external tools if available
    if (srcs.testFlag(SearchSrc::External) && options.testFlag(SearchOption::SearchContent) && (syntax == SearchSyntax::Phrase || syntax == SearchSyntax::Regex)
        && dirUrl.isLocalFile()) {
        const QRegularExpression regex(search, QRegularExpression::CaseInsensitiveOption);
        if (regex.isValid()) {
            KIO::WorkerResult result = searchDirWithExternalTool(dirUrl, regex);
            if (result.error() == KIO::ERR_UNSUPPORTED_ACTION) {
                if (srcs.testFlag(SearchSrc::Internal)) {
                    qCDebug(KIO_FILENAMESEARCH) << "External tool not available. Fall back to KIO.";
                } else {
                    qCDebug(KIO_FILENAMESEARCH) << "External tool not available. Test fails.";
                    return KIO::WorkerResult::fail(KIO::ERR_CANNOT_LAUNCH_PROCESS, QStringLiteral("External tool not available"));
                }
            } else {
                return result;
            }
        }
    }
#endif

    if (srcs.testFlag(SearchSrc::Internal)) {
        std::set<QString> iteratedDirs;
        std::queue<QUrl> pendingDirs;
        QHash<QRegularExpression, int> regexHash;

        for (auto term : termList) {
            const QRegularExpression regex(term, QRegularExpression::CaseInsensitiveOption);
            if (regex.isValid()) {
                regexHash[regex] = 0;
            }
        }

        searchDir(dirUrl, regexHash, options, iteratedDirs, pendingDirs);

        while (!pendingDirs.empty()) {
            const QUrl pendingUrl = pendingDirs.front();
            pendingDirs.pop();
            searchDir(pendingUrl, regexHash, options, iteratedDirs, pendingDirs);
        }
    }

    return KIO::WorkerResult::pass();
}

void FileNameSearchProtocol::searchDir(const QUrl &dirUrl,
                                       const QHash<QRegularExpression, int> &regexHash,
                                       const SearchOptions options,
                                       std::set<QString> &iteratedDirs,
                                       std::queue<QUrl> &pendingDirs)
{
    //  If the directory is flagged in the iteratedDirs set, it has already been searched.
    //  Return to avoid circular recursion into symlinks.

    const QString dirPath = ensureTrailingSlash(QUrl(dirUrl).path());

    if (iteratedDirs.contains(dirPath)) {
        return;
    }

    KIO::ListJob::ListFlags listFlags = {};
    if (options.testAnyFlags(SearchOption::IncludeHidden)) {
        listFlags = KIO::ListJob::ListFlag::IncludeHidden;
    }
    KIO::ListJob *listJob = KIO::listRecursive(dirUrl, KIO::HideProgressInfo, listFlags);

    connect(this, &QObject::destroyed, listJob, [listJob]() {
        listJob->kill();
    });

    connect(listJob, &KIO::ListJob::entries, this, [&](KJob *, const KIO::UDSEntryList &list) {
        if (listJob->error()) {
            qCWarning(KIO_FILENAMESEARCH) << "Searching failed:" << listJob->errorText();
            return;
        }

        QUrl entryUrl(dirUrl);
        const QString path = ensureTrailingSlash(entryUrl.path());

        for (auto entry : list) {
            if (wasKilled()) { // File-by-file searches may take some time, call wasKilled before each file
                listJob->kill();
                return;
            }

            // Create a KFileItem that assumes dirUrl is a folder and delays mimetype resolution.
            const KFileItem item(entry, dirUrl, true, true);

            // UDS_NAME is e.g. "foo/bar/somefile.txt"
            const QString leaf = path + item.name();

            //  .. Avoid dot and dotdot

            if (!isDotOrDotDot(entry.stringValue(KIO::UDSEntry::UDS_NAME)) && !hasBeenVisited(leaf, iteratedDirs)) {
                entryUrl.setPath(leaf);

                const QString urlStr = entryUrl.toDisplayString();
                entry.replace(KIO::UDSEntry::UDS_URL, urlStr);

                const QString fileName = entryUrl.fileName();
                entry.replace(KIO::UDSEntry::UDS_NAME, fileName);

                if (item.isDir()) {
                    // Push the symlink destination into the queue, leaving the decision
                    // of whether to actually search the folder to later

                    // However only do this if the symlink is not hidden or IncludeHiddenFolders is set
                    // If the folder is hidden, add it to iteratedDirs to stop it being exlored
                    // (Assume ListRecursive returns the folder name before exploring the folder).

                    if ((!item.isHidden()) || options.testFlag(SearchOption::IncludeHiddenFolders)) {
                        const QString linkDest = entry.stringValue(KIO::UDSEntry::UDS_LINK_DEST);
                        if (!linkDest.isEmpty()) {
                            pendingDirs.push(entryUrl.resolved(QUrl(linkDest)));
                        }
                    } else {
                        iteratedDirs.insert(leaf);
                    }

                    //  There's no point in trying to search content if the entry is a folder so drop
                    //  the SearchContent flag.

                    SearchOptions folderOptions = options;
                    folderOptions.setFlag(SearchOption::SearchContent, false);

                    //  UDS_DISPLAY_NAME is e.g. "foo/bar/somefile.txt"

                    if ((!item.isHidden() || options.testFlag(SearchOption::IncludeHiddenFiles)) && match(entry, regexHash, folderOptions)) {
                        entry.replace(KIO::UDSEntry::UDS_DISPLAY_NAME, fileName);
                        listEntry(entry);
                    }

                } else if ((!item.isHidden() || options.testFlag(SearchOption::IncludeHiddenFiles)) && match(entry, regexHash, options)) {
                    entry.replace(KIO::UDSEntry::UDS_DISPLAY_NAME, fileName);
                    listEntry(entry);
                }
            }
        }
    });

    listJob->exec();
    // Mark the folder when finished
    iteratedDirs.insert(dirPath);
}

#if !defined(Q_OS_WIN32)

KIO::WorkerResult FileNameSearchProtocol::searchDirWithExternalTool(const QUrl &dirUrl, const QRegularExpression &regex)
{
    qCDebug(KIO_FILENAMESEARCH) << "searchDirWithExternalTool dir:" << dirUrl << "pattern:" << regex.pattern();

    const QString programName = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("kio_filenamesearch/kio-filenamesearch-grep"));
    if (programName.isEmpty()) {
        const QString message = QStringLiteral("kio_filenamesearch/kio-filenamesearch-grep not found in ")
            + QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation).join(QLatin1Char(':'));
        qCWarning(KIO_FILENAMESEARCH) << message;
        return KIO::WorkerResult::fail(KIO::ERR_CANNOT_LAUNCH_PROCESS, message);
    }

    QProcess process;
    process.setProgram(programName);
    process.setWorkingDirectory(dirUrl.toLocalFile());
    process.setArguments({QStringLiteral("--run"), regex.pattern()});

    qCDebug(KIO_FILENAMESEARCH) << "Start" << process.program() << "args:" << process.arguments() << "in:" << process.workingDirectory();
    process.start(QIODeviceBase::ReadWrite | QIODeviceBase::Unbuffered);
    if (!process.waitForStarted()) {
        qCWarning(KIO_FILENAMESEARCH) << programName << "failed to start:" << process.errorString();
        return KIO::WorkerResult::fail(KIO::ERR_CANNOT_LAUNCH_PROCESS, QStringLiteral("%1: %2").arg(programName, process.errorString()));
    }
    // Explicitly close the write channel, to avoid some tools waiting for input (e.g. ripgrep, when no path is given on cmdline)
    process.closeWriteChannel();
    qCDebug(KIO_FILENAMESEARCH) << "Close STDIN.";

    QDir rootDir(dirUrl.path());
    QUrl url(dirUrl);
    QByteArray output;
    const char sep = '\0';

    auto sendMatch = [this, &rootDir, &url](const QString &result) {
        qCDebug(KIO_FILENAMESEARCH) << "RESULT:" << result;
        QString relativePath = rootDir.cleanPath(result);
        QString fullPath = rootDir.filePath(relativePath);
        url.setPath(fullPath);
        KIO::UDSEntry uds;
        uds.reserve(4);
        uds.fastInsert(KIO::UDSEntry::UDS_NAME, url.fileName());
        uds.fastInsert(KIO::UDSEntry::UDS_DISPLAY_NAME, url.fileName());
        uds.fastInsert(KIO::UDSEntry::UDS_URL, url.url());
        uds.fastInsert(KIO::UDSEntry::UDS_LOCAL_PATH, fullPath);
        listEntry(uds);
    };

    do {
        if (!process.waitForReadyRead()) {
            continue;
        }
        output.append(process.readAllStandardOutput());
        qCDebug(KIO_FILENAMESEARCH) << "STDOUT:" << output;
        int begin = 0;
        while (begin < output.size()) {
            const int end = output.indexOf(sep, begin);
            if (end < 0) {
                // incomplete output, wait for more
                break;
            }

            if (end > begin) {
                QString s = QString::fromUtf8(output.mid(begin, end - begin));
                sendMatch(s);
            }

            begin = end + 1;
        }
        if (begin < output.size()) {
            output = output.mid(begin);
        } else {
            output.clear();
        }
    } while (process.state() == QProcess::Running);

    if (!output.isEmpty()) {
        qCDebug(KIO_FILENAMESEARCH) << "STDOUT:" << output;
        QString s = QString::fromUtf8(output);
        sendMatch(s);
    }

    const QString errors = QString::fromLocal8Bit(process.readAllStandardError()).trimmed();
    if (!errors.isEmpty()) {
        qCWarning(KIO_FILENAMESEARCH) << "STDERR:" << errors;
    }

    const int code = process.exitCode();
    qCDebug(KIO_FILENAMESEARCH) << programName << "stopped. Exit code:" << code;

    if (process.exitStatus() == QProcess::CrashExit) {
        qCWarning(KIO_FILENAMESEARCH) << "Crash exit:" << process.errorString();
        return KIO::WorkerResult::fail(KIO::ERR_UNKNOWN, QStringLiteral("%1: %2").arg(programName, process.errorString()));
    } else {
        if (code == 127) {
            qCDebug(KIO_FILENAMESEARCH) << "Search tool not found.";
            return KIO::WorkerResult::fail(KIO::ERR_UNSUPPORTED_ACTION);
        }
        if (code == 0 || errors.isEmpty()) {
            // `rg` returns 1 when no match, and 2 when it encounters broken links or no permission to read
            // a file, even if we suppressed the error message with `--no-messages`. We don't want to fail
            // in these cases.
            qCDebug(KIO_FILENAMESEARCH) << "Search success.";
            return KIO::WorkerResult::pass();
        } else {
            qCWarning(KIO_FILENAMESEARCH) << "Search failed. " << process.errorString();
            return KIO::WorkerResult::fail(
                KIO::ERR_UNKNOWN,
                i18nc("@info:%1 is the program used to do the search", "%1 failed, exit code: %2, error messages: %3", programName, code, errors));
        }
    }
}

#endif // !defined(Q_OS_WIN32)

extern "C" int Q_DECL_EXPORT kdemain(int argc, char **argv)
{
    QCoreApplication app(argc, argv);

    if (argc != 4) {
        qCDebug(KIO_FILENAMESEARCH) << "Usage: kio_filenamesearch protocol domain-socket1 domain-socket2";
        return -1;
    }

    FileNameSearchProtocol worker(argv[2], argv[3]);
    worker.dispatchLoop();

    return 0;
}

} // namespace FileNameSearch

#include "kio_filenamesearch.moc"
#include "moc_kio_filenamesearch.cpp"
