/*
    This file is part of the KDE libraries
    SPDX-FileCopyrightText: 2000 Torben Weis <weis@kde.org>
    SPDX-FileCopyrightText: 2006-2013 David Faure <faure@kde.org>
    SPDX-FileCopyrightText: 2009 Michael Pyne <michael.pyne@kdemail.net>

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

#include "desktopexecparser.h"

#ifdef WITH_QTDBUS
#include "kiofuse_interface.h"
#endif

#include <KApplicationTrader>
#include <KConfigGroup>
#include <KDesktopFile>
#include <KLocalizedString>
#include <KMacroExpander>
#include <KService>
#include <KSharedConfig>
#include <KShell>
#include <kprotocolinfo.h> // KF6 TODO remove after moving hasSchemeHandler to OpenUrlJob

#ifdef WITH_QTDBUS
#include <QDBusConnection>
#include <QDBusReply>
#endif
#include <QDir>
#include <QFile>
#include <QProcessEnvironment>
#include <QStandardPaths>
#include <QUrl>

#include <config-kiocore.h> // KDE_INSTALL_FULL_LIBEXECDIR_KF

#include "kiocoredebug.h"

class KRunMX1 : public KMacroExpanderBase
{
public:
    explicit KRunMX1(const KService &_service)
        : KMacroExpanderBase(QLatin1Char('%'))
        , service(_service)
    {
    }

    bool hasUrls = false;
    bool hasSpec = false;
    bool hasError = false;

protected:
    int expandEscapedMacro(const QString &str, int pos, QStringList &ret) override;

private:
    const KService &service;
};

int KRunMX1::expandEscapedMacro(const QString &str, int pos, QStringList &ret)
{
    if (str.length() == pos + 1) {
        // Internally, the stack of KMacroExpanderBase is empty and thus it thinks everything is successfully parsed
        // This could be the case if the escape char "%" in this case, is at the end of the string
        // See BUG: 495606
        hasError = true;
        return 0;
    }
    uint option = str[pos + 1].unicode();
    switch (option) {
    case 'c':
        ret << service.name().replace(QLatin1Char('%'), QLatin1String("%%"));
        break;
    case 'k':
        ret << service.entryPath().replace(QLatin1Char('%'), QLatin1String("%%"));
        break;
    case 'i':
        ret << QStringLiteral("--icon") << service.icon().replace(QLatin1Char('%'), QLatin1String("%%"));
        break;
    case 'm':
        //       ret << "-miniicon" << service.icon().replace( '%', "%%" );
        qCWarning(KIO_CORE) << "-miniicon isn't supported anymore (service" << service.name() << ')';
        break;
    case 'u':
    case 'U':
        hasUrls = true;
        Q_FALLTHROUGH();
    /* fallthrough */
    case 'f':
    case 'F':
    case 'n':
    case 'N':
    case 'd':
    case 'D':
    case 'v':
        hasSpec = true;
        Q_FALLTHROUGH();
    /* fallthrough */
    default:
        return -2; // subst with same and skip
    }
    return 2;
}

class KRunMX2 : public KMacroExpanderBase
{
public:
    explicit KRunMX2(const QList<QUrl> &_urls)
        : KMacroExpanderBase(QLatin1Char('%'))
        , ignFile(false)
        , urls(_urls)
    {
    }

    bool ignFile;

protected:
    int expandEscapedMacro(const QString &str, int pos, QStringList &ret) override;

private:
    void subst(int option, const QUrl &url, QStringList &ret);

    const QList<QUrl> &urls;
};

void KRunMX2::subst(int option, const QUrl &url, QStringList &ret)
{
    switch (option) {
    case 'u':
        ret << ((url.isLocalFile() && url.fragment().isNull() && url.query().isNull()) ? QDir::toNativeSeparators(url.toLocalFile()) : url.toString());
        break;
    case 'd':
        ret << url.adjusted(QUrl::RemoveFilename).path();
        break;
    case 'f':
        ret << QDir::toNativeSeparators(url.toLocalFile());
        break;
    case 'n':
        ret << url.fileName();
        break;
    case 'v':
        if (url.isLocalFile() && QFile::exists(url.toLocalFile())) {
            ret << KDesktopFile(url.toLocalFile()).desktopGroup().readEntry("Dev");
        }
        break;
    }
    return;
}

int KRunMX2::expandEscapedMacro(const QString &str, int pos, QStringList &ret)
{
    uint option = str[pos + 1].unicode();
    switch (option) {
    case 'f':
    case 'u':
    case 'n':
    case 'd':
    case 'v':
        if (urls.isEmpty()) {
            if (!ignFile) {
                // qCDebug(KIO_CORE) << "No URLs supplied to single-URL service" << str;
            }
        } else if (urls.count() > 1) {
            qCWarning(KIO_CORE) << urls.count() << "URLs supplied to single-URL service" << str;
        } else {
            subst(option, urls.first(), ret);
        }
        break;
    case 'F':
    case 'U':
    case 'N':
    case 'D':
        option += 'a' - 'A';
        for (const QUrl &url : urls) {
            subst(option, url, ret);
        }
        break;
    case '%':
        ret = QStringList(QStringLiteral("%"));
        break;
    default:
        return -2; // subst with same and skip
    }
    return 2;
}

QStringList KIO::DesktopExecParser::supportedProtocols(const KService &service)
{
    QStringList supportedProtocols = service.supportedProtocols();

    KRunMX1 mx1(service);
    QString exec = service.exec();
    if (mx1.expandMacrosShellQuote(exec) && !mx1.hasUrls) {
        if (!supportedProtocols.isEmpty()) {
            qCWarning(KIO_CORE) << service.entryPath() << "contains supported protocols but doesn't use %u or %U in its Exec line! This is inconsistent.";
        }
        return QStringList();
    } else {
        if (supportedProtocols.isEmpty()) {
            // compat mode: assume KIO if not set and it's a KDE app (or a KDE service)
            const QStringList categories = service.property<QStringList>(QStringLiteral("Categories"));
            if (categories.contains(QLatin1String("KDE")) || !service.isApplication() || service.entryPath().isEmpty() /*temp service*/) {
                supportedProtocols.append(QStringLiteral("KIO"));
            } else { // if no KDE app, be a bit over-generic
                supportedProtocols.append(QStringLiteral("http"));
                supportedProtocols.append(QStringLiteral("https")); // #253294
                supportedProtocols.append(QStringLiteral("ftp"));
            }
        }
    }

    // qCDebug(KIO_CORE) << "supportedProtocols:" << supportedProtocols;
    return supportedProtocols;
}

bool KIO::DesktopExecParser::isProtocolInSupportedList(const QUrl &url, const QStringList &supportedProtocols)
{
    return url.isLocalFile() //
        || supportedProtocols.contains(QLatin1String("KIO")) //
        || supportedProtocols.contains(url.scheme(), Qt::CaseInsensitive);
}

// We have up to two sources of data, for protocols not handled by KIO workers (so called "helper") :
// 1) the exec line of the .protocol file, if there's one
// 2) the application associated with x-scheme-handler/<protocol> if there's one
bool KIO::DesktopExecParser::hasSchemeHandler(const QUrl &url) // KF6 TODO move to OpenUrlJob
{
    if (KProtocolInfo::isHelperProtocol(url)) {
        return true;
    }
    const KService::Ptr service = KApplicationTrader::preferredService(QLatin1String("x-scheme-handler/") + url.scheme());
    if (service) {
        qCDebug(KIO_CORE) << QLatin1String("preferred service for x-scheme-handler/") + url.scheme() << service->desktopEntryName();
    }
    return service;
}

class KIO::DesktopExecParserPrivate
{
public:
    DesktopExecParserPrivate(const KService &_service, const QList<QUrl> &_urls)
        : service(_service)
        , urls(_urls)
        , tempFiles(false)
    {
    }

    bool isUrlSupported(const QUrl &url, const QStringList &supportedProtocols);

    const KService &service;
    QList<QUrl> urls;
    bool tempFiles;
    QString suggestedFileName;
    QString m_errorString;
};

KIO::DesktopExecParser::DesktopExecParser(const KService &service, const QList<QUrl> &urls)
    : d(new DesktopExecParserPrivate(service, urls))
{
}

KIO::DesktopExecParser::~DesktopExecParser()
{
}

void KIO::DesktopExecParser::setUrlsAreTempFiles(bool tempFiles)
{
    d->tempFiles = tempFiles;
}

void KIO::DesktopExecParser::setSuggestedFileName(const QString &suggestedFileName)
{
    d->suggestedFileName = suggestedFileName;
}

static const QString kioexecPath()
{
    QString kioexec = QCoreApplication::applicationDirPath() + QLatin1String("/kioexec");
    if (!QFileInfo::exists(kioexec)) {
        kioexec = QStringLiteral(KDE_INSTALL_FULL_LIBEXECDIR_KF "/kioexec");
    }
    Q_ASSERT(QFileInfo::exists(kioexec));
    return kioexec;
}

static QString findNonExecutableProgram(const QString &executable)
{
    // Relative to current dir, or absolute path
    const QFileInfo fi(executable);
    if (fi.exists() && !fi.isExecutable()) {
        return executable;
    }

#ifdef Q_OS_UNIX
    // This is a *very* simplified version of QStandardPaths::findExecutable
    const QStringList searchPaths = QString::fromLocal8Bit(qgetenv("PATH")).split(QDir::listSeparator(), Qt::SkipEmptyParts);
    for (const QString &searchPath : searchPaths) {
        const QString candidate = searchPath + QLatin1Char('/') + executable;
        const QFileInfo fileInfo(candidate);
        if (fileInfo.exists()) {
            if (fileInfo.isExecutable()) {
                qWarning() << "Internal program error. QStandardPaths::findExecutable couldn't find" << executable << "but our own logic found it at"
                           << candidate << ". Please report a bug at https://bugs.kde.org";
            } else {
                return candidate;
            }
        }
    }
#endif
    return QString();
}

bool KIO::DesktopExecParserPrivate::isUrlSupported(const QUrl &url, const QStringList &protocols)
{
    if (KIO::DesktopExecParser::isProtocolInSupportedList(url, protocols)) {
        return true;
    }

    // supportedProtocols() only checks whether the .desktop file has MimeType=x-scheme-handler/xxx
    // We also want to check whether the app has been set as default/associated in mimeapps.list
    const auto handlers = KApplicationTrader::queryByMimeType(QLatin1String("x-scheme-handler/") + url.scheme());
    for (const KService::Ptr &handler : handlers) {
        if (handler->desktopEntryName() == service.desktopEntryName()) {
            return true;
        }
    }

    return false;
}

QStringList KIO::DesktopExecParser::resultingArguments() const
{
    QString exec = d->service.exec();
    if (exec.isEmpty()) {
        d->m_errorString = i18n("No Exec field in %1", d->service.entryPath());
        qCWarning(KIO_CORE) << "No Exec field in" << d->service.entryPath();
        return QStringList();
    }

    // Extract the name of the binary to execute from the full Exec line, to see if it exists
    const QString binary = executablePath(exec);
    QString executableFullPath;
    if (!binary.isEmpty()) { // skip all this if the Exec line is a complex shell command
        if (QDir::isRelativePath(binary)) {
            // Resolve the executable to ensure that helpers in libexec are found.
            // Too bad for commands that need a shell - they must reside in $PATH.
            executableFullPath = QStandardPaths::findExecutable(binary);
            if (executableFullPath.isEmpty()) {
                executableFullPath = QFile::decodeName(KDE_INSTALL_FULL_LIBEXECDIR_KF "/") + binary;
            }
        } else {
            executableFullPath = binary;
        }

        // Now check that the binary exists and has the executable flag
        if (!QFileInfo(executableFullPath).isExecutable()) {
            // Does it really not exist, or is it non-executable (on Unix)? (bug #415567)
            const QString nonExecutable = findNonExecutableProgram(binary);
            if (nonExecutable.isEmpty()) {
                d->m_errorString = i18n("Could not find the program '%1'", binary);
            } else {
                if (QDir::isRelativePath(binary)) {
                    d->m_errorString = i18n("The program '%1' was found at '%2' but it is missing executable permissions.", binary, nonExecutable);
                } else {
                    d->m_errorString = i18n("The program '%1' is missing executable permissions.", nonExecutable);
                }
            }
            return QStringList();
        }
    }

    QStringList result;
    bool appHasTempFileOption;

    KRunMX1 mx1(d->service);
    KRunMX2 mx2(d->urls);

    if (!mx1.expandMacrosShellQuote(exec) || mx1.hasError) { // Error in shell syntax
        d->m_errorString = i18n("Syntax error in command %1 coming from %2", exec, d->service.entryPath());
        qCWarning(KIO_CORE) << "Syntax error in command" << d->service.exec() << ", service" << d->service.name();
        return QStringList();
    }

    // FIXME: the current way of invoking kioexec disables term and su use

    // Check if we need "tempexec" (kioexec in fact)
    appHasTempFileOption = d->tempFiles && d->service.property<bool>(QStringLiteral("X-KDE-HasTempFileOption"));
    if (d->tempFiles && !appHasTempFileOption && d->urls.size()) {
        result << kioexecPath() << QStringLiteral("--tempfiles") << exec;
        if (!d->suggestedFileName.isEmpty()) {
            result << QStringLiteral("--suggestedfilename");
            result << d->suggestedFileName;
        }
        result += QUrl::toStringList(d->urls);
        return result;
    }

#ifdef WITH_QTDBUS
    // Return true for non-KIO desktop files with explicit X-KDE-Protocols list, like vlc, for the special case below
    auto isNonKIO = [this]() {
        const QStringList protocols = d->service.property<QStringList>(QStringLiteral("X-KDE-Protocols"));
        return !protocols.isEmpty() && !protocols.contains(QLatin1String("KIO"));
    };

    // Check if we need kioexec, or KIOFuse
    bool useKioexec = false;

    org::kde::KIOFuse::VFS kiofuse_iface(QStringLiteral("org.kde.KIOFuse"), QStringLiteral("/org/kde/KIOFuse"), QDBusConnection::sessionBus());
    struct MountRequest {
        QDBusPendingReply<QString> reply;
        int urlIndex;
    };
    QList<MountRequest> requests;
    requests.reserve(d->urls.count());

    const QStringList appSupportedProtocols = supportedProtocols(d->service);
    for (int i = 0; i < d->urls.count(); ++i) {
        const QUrl url = d->urls.at(i);
        const bool supported = mx1.hasUrls ? d->isUrlSupported(url, appSupportedProtocols) : url.isLocalFile();
        if (!supported) {
            // If FUSE fails, and there is no scheme handler, we'll have to fallback to kioexec
            useKioexec = true;
        }

        // NOTE: Some non-KIO apps may support the URLs (e.g. VLC supports smb://)
        // but will not have the password if they are not in the URL itself.
        // Hence convert URL to KIOFuse equivalent in case there is a password.
        // \sa https://pointieststick.com/2018/01/17/videos-on-samba-shares/
        // \sa https://bugs.kde.org/show_bug.cgi?id=330192
        if (!supported || (!url.userName().isEmpty() && url.password().isEmpty() && isNonKIO())) {
            requests.push_back({kiofuse_iface.mountUrl(url.toString()), i});
        }
    }

    for (auto &request : requests) {
        request.reply.waitForFinished();
    }
    const bool fuseError = std::any_of(requests.cbegin(), requests.cend(), [](const MountRequest &request) {
        return request.reply.isError();
    });

    if (fuseError && useKioexec) {
        // We need to run the app through kioexec
        result << kioexecPath();
        if (d->tempFiles) {
            result << QStringLiteral("--tempfiles");
        }
        if (!d->suggestedFileName.isEmpty()) {
            result << QStringLiteral("--suggestedfilename");
            result << d->suggestedFileName;
        }
        result << exec;
        result += QUrl::toStringList(d->urls);
        return result;
    }

    // At this point we know we're not using kioexec, so feel free to replace
    // KIO URLs with their KIOFuse local path.
    for (const auto &request : std::as_const(requests)) {
        if (!request.reply.isError()) {
            d->urls[request.urlIndex] = QUrl::fromLocalFile(request.reply.value());
        }
    }
#endif

    if (appHasTempFileOption) {
        exec += QLatin1String(" --tempfile");
    }

    // Did the user forget to append something like '%f'?
    // If so, then assume that '%f' is the right choice => the application
    // accepts only local files.
    if (!mx1.hasSpec) {
        exec += QLatin1String(" %f");
        mx2.ignFile = true;
    }

    mx2.expandMacrosShellQuote(exec); // syntax was already checked, so don't check return value

    /*
     1 = need_shell, 2 = terminal, 4 = su

     0                                                           << split(cmd)
     1                                                           << "sh" << "-c" << cmd
     2 << split(term) << "-e"                                    << split(cmd)
     3 << split(term) << "-e"                                    << "sh" << "-c" << cmd

     4                        << "kdesu" << "-u" << user << "-c" << cmd
     5                        << "kdesu" << "-u" << user << "-c" << ("sh -c " + quote(cmd))
     6 << split(term) << "-e" << "su"            << user << "-c" << cmd
     7 << split(term) << "-e" << "su"            << user << "-c" << ("sh -c " + quote(cmd))

     "sh -c" is needed in the "su" case, too, as su uses the user's login shell, not sh.
     this could be optimized with the -s switch of some su versions (e.g., debian linux).
    */

    if (d->service.terminal()) {
        KConfigGroup cg(KSharedConfig::openConfig(), QStringLiteral("General"));
        QString terminal = cg.readPathEntry("TerminalApplication", QStringLiteral("konsole"));

        const bool isKonsole = (terminal == QLatin1String("konsole"));
        QStringList terminalParts = KShell::splitArgs(terminal);
        QString terminalPath;
        if (!terminalParts.isEmpty()) {
            terminalPath = QStandardPaths::findExecutable(terminalParts.at(0));
        }

        if (terminalPath.isEmpty()) {
            d->m_errorString = i18n("Terminal %1 not found while trying to run %2", terminal, d->service.entryPath());
            qCWarning(KIO_CORE) << "Terminal" << terminal << "not found, service" << d->service.name();
            return QStringList();
        }
        terminalParts[0] = terminalPath;
        terminal = KShell::joinArgs(terminalParts);
        if (isKonsole) {
            if (!d->service.workingDirectory().isEmpty()) {
                terminal += QLatin1String(" --workdir ") + KShell::quoteArg(d->service.workingDirectory());
            }
            terminal += QLatin1String(" -qwindowtitle '%c'");
            if (!d->service.icon().isEmpty()) {
                terminal += QLatin1String(" -qwindowicon ") + KShell::quoteArg(d->service.icon().replace(QLatin1Char('%'), QLatin1String("%%")));
            }
        }
        terminal += QLatin1Char(' ') + d->service.terminalOptions();
        if (!mx1.expandMacrosShellQuote(terminal) || mx1.hasError) {
            d->m_errorString = i18n("Syntax error in command %1 while trying to run %2", terminal, d->service.entryPath());
            qCWarning(KIO_CORE) << "Syntax error in command" << terminal << ", service" << d->service.name();
            return QStringList();
        }
        mx2.expandMacrosShellQuote(terminal);
        result = KShell::splitArgs(terminal); // assuming that the term spec never needs a shell!
        result << QStringLiteral("-e");
    }

    KShell::Errors err;
    QStringList execlist = KShell::splitArgs(exec, KShell::AbortOnMeta | KShell::TildeExpand, &err);
    if (!executableFullPath.isEmpty()) {
        execlist[0] = executableFullPath;
    }

    if (d->service.substituteUid()) {
        if (d->service.terminal()) {
            result << QStringLiteral("su");
        } else {
            QString kdesu = QFile::decodeName(KDE_INSTALL_FULL_LIBEXECDIR_KF "/kdesu");
            if (!QFile::exists(kdesu)) {
                kdesu = QStandardPaths::findExecutable(QStringLiteral("kdesu"));
            }
            if (!QFile::exists(kdesu)) {
                // Insert kdesu as string so we show a nice warning: 'Could not launch kdesu'
                result << QStringLiteral("kdesu");
                return result;
            } else {
                result << kdesu << QStringLiteral("-u");
            }
        }

        result << d->service.username() << QStringLiteral("-c");
        if (err == KShell::FoundMeta) {
            exec = QLatin1String("/bin/sh -c ") + KShell::quoteArg(exec);
        } else {
            exec = KShell::joinArgs(execlist);
        }
        result << exec;
    } else {
        if (err == KShell::FoundMeta) {
            result << QStringLiteral("/bin/sh") << QStringLiteral("-c") << exec;
        } else {
            result += execlist;
        }
    }

    return result;
}

QString KIO::DesktopExecParser::errorMessage() const
{
    return d->m_errorString;
}

// static
QString KIO::DesktopExecParser::executableName(const QString &execLine)
{
    const QString bin = executablePath(execLine);
    return bin.mid(bin.lastIndexOf(QLatin1Char('/')) + 1);
}

// static
QString KIO::DesktopExecParser::executablePath(const QString &execLine)
{
    // Remove parameters and/or trailing spaces.
    const QStringList args = KShell::splitArgs(execLine, KShell::AbortOnMeta | KShell::TildeExpand);
    auto it = std::find_if(args.cbegin(), args.cend(), [](const QString &arg) {
        return !arg.contains(QLatin1Char('='));
    });
    return it != args.cend() ? *it : QString{};
}
