// 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 "externaleditors.h"

#include <coreplugin/editormanager/ieditorfactory.h>

#include <projectexplorer/kitmanager.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectexplorer.h>
#include <projectexplorer/projectexplorerconstants.h>
#include <projectexplorer/projectmanager.h>
#include <projectexplorer/target.h>

#include <qtsupport/qtkitaspect.h>

#include <utils/algorithm.h>
#include <utils/environment.h>
#include <utils/filepath.h>
#include <utils/hostosinfo.h>
#include <utils/mimeconstants.h>
#include <utils/qtcassert.h>
#include <utils/qtcprocess.h>

#include <QCoreApplication>
#include <QDebug>
#include <QMap>
#include <QStringList>
#include <QTcpServer>
#include <QTcpSocket>

#include <functional>

using namespace ProjectExplorer;
using namespace Utils;

enum { debug = 0 };

namespace QtSupport::Internal {

struct Tr {
    Q_DECLARE_TR_FUNCTIONS(QtC::QmakeProjectManager)
};

// Locate a binary in a directory, applying all kinds of
// extensions the operating system supports.
static QString checkBinary(const QDir &dir, const QString &binary)
{
    // naive UNIX approach
    const QFileInfo info(dir.filePath(binary));
    if (info.isFile() && info.isExecutable())
        return info.absoluteFilePath();

    // Does the OS have some weird extension concept or does the
    // binary have a 3 letter extension?
    if (HostOsInfo::isAnyUnixHost() && !HostOsInfo::isMacHost())
        return {};
    const int dotIndex = binary.lastIndexOf(QLatin1Char('.'));
    if (dotIndex != -1 && dotIndex == binary.size() - 4)
        return {};

    switch (HostOsInfo::hostOs()) {
    case OsTypeLinux:
    case OsTypeOtherUnix:
    case OsTypeOther:
        break;
    case OsTypeWindows: {
            static const char *windowsExtensions[] = {".cmd", ".bat", ".exe", ".com"};
            // Check the Windows extensions using the order
            for (const char * const ext : windowsExtensions) {
                const QFileInfo windowsBinary(dir.filePath(binary + QLatin1String(ext)));
                if (windowsBinary.isFile() && windowsBinary.isExecutable())
                    return windowsBinary.absoluteFilePath();
            }
        }
        break;
    case OsTypeMac: {
            // Check for Mac app folders
            const QFileInfo appFolder(dir.filePath(binary + QLatin1String(".app")));
            if (appFolder.isDir()) {
                QString macBinaryPath = appFolder.absoluteFilePath();
                macBinaryPath += QLatin1String("/Contents/MacOS/");
                macBinaryPath += binary;
                const QFileInfo macBinary(macBinaryPath);
                if (macBinary.isFile() && macBinary.isExecutable())
                    return macBinary.absoluteFilePath();
            }
        }
        break;
    }
    return {};
}

static QString locateBinary(const QString &path, const QString &binary)
{
    // Absolute file?
    const QFileInfo absInfo(binary);
    if (absInfo.isAbsolute())
        return checkBinary(absInfo.dir(), absInfo.fileName());

    // Windows finds binaries  in the current directory
    if (HostOsInfo::isWindowsHost()) {
        const QString currentDirBinary = checkBinary(QDir::current(), binary);
        if (!currentDirBinary.isEmpty())
            return currentDirBinary;
    }

    const QStringList paths = path.split(HostOsInfo::pathListSeparator());
    if (paths.empty())
        return {};
    const QStringList::const_iterator cend = paths.constEnd();
    for (QStringList::const_iterator it = paths.constBegin(); it != cend; ++it) {
        const QDir dir(*it);
        const QString rc = checkBinary(dir, binary);
        if (!rc.isEmpty())
            return rc;
    }
    return {};
}

static QString designerBinary(const QtSupport::QtVersion *qtVersion)
{
    if (qtVersion)
        return qtVersion->designerFilePath().toUrlishString();
    return QLatin1String(HostOsInfo::isMacHost() ? "Designer" : "designer");
}

// Data required to launch the editor
struct LaunchData
{
    QString binary;
    QStringList arguments;
    FilePath workingDirectory;
};

// Mac: Change the call 'Foo.app/Contents/MacOS/Foo <filelist>' to
// 'open -a Foo.app <filelist>'. doesn't support generic command line arguments
static LaunchData createMacOpenCommand(const LaunchData &data)
{
    LaunchData openData = data;
    const int appFolderIndex = data.binary.lastIndexOf(QLatin1String("/Contents/MacOS/"));
    if (appFolderIndex != -1) {
        openData.binary = "open";
        openData.arguments = QStringList({QString("-a"), data.binary.left(appFolderIndex)})
                + data.arguments;
    }
    return openData;
}

using CommandForQtVersion = std::function<QString(const QtSupport::QtVersion *)>;

static QString findFirstCommand(const QList<QtSupport::QtVersion *> &qtVersions,
                                CommandForQtVersion command)
{
    for (QtSupport::QtVersion *qt : qtVersions) {
        if (qt) {
            const QString binary = command(qt);
            if (!binary.isEmpty())
                return binary;
        }
    }
    return QString();
}

static Result<> getEditorLaunchData(const CommandForQtVersion &commandForQtVersion,
                                    const FilePath &filePath,
                                    LaunchData *data)
{
    // Check in order for Qt version with the binary:
    // - active kit of project
    // - any other of the project
    // - default kit
    // - any other kit
    // As fallback check PATH
    if (!KitManager::waitForLoaded())
        return ResultError(Tr::tr("Could not load kits in a reasonable amount of time."));

    data->workingDirectory.clear();
    QList<QtSupport::QtVersion *> qtVersionsToCheck; // deduplicated after being filled
    if (const Project *project = ProjectManager::projectForFile(filePath)) {
        data->workingDirectory = project->projectDirectory();
        // active kit
        qtVersionsToCheck << QtSupport::QtKitAspect::qtVersion(project->activeKit());
        // all kits of project
        qtVersionsToCheck += Utils::transform<QList>(project->targets(), [](Target *t) {
            return QTC_GUARD(t) ? QtSupport::QtKitAspect::qtVersion(t->kit()) : nullptr;
        });
    }
    // default kit
    qtVersionsToCheck << QtSupport::QtKitAspect::qtVersion(KitManager::defaultKit());
    // all kits
    qtVersionsToCheck += Utils::transform<QList>(KitManager::kits(), QtSupport::QtKitAspect::qtVersion);
    qtVersionsToCheck = Utils::filteredUnique(qtVersionsToCheck); // can still contain nullptr
    data->binary = findFirstCommand(qtVersionsToCheck, commandForQtVersion);
    // fallback
    if (data->binary.isEmpty()) {
        const QString path = qtcEnvironmentVariable("PATH");
        data->binary = locateBinary(path, commandForQtVersion(nullptr));
    }

    if (data->binary.isEmpty()) {
        return ResultError(Tr::tr("The application \"%1\" could not be found.")
            .arg(filePath.toUserOutput()));
    }

    // Setup binary + arguments, use Mac Open if appropriate
    data->arguments.push_back(filePath.toUrlishString());
    if (HostOsInfo::isMacHost())
        *data = createMacOpenCommand(*data);
    if (debug)
        qDebug() << Q_FUNC_INFO << '\n' << data->binary << data->arguments;
    return ResultOk;
}

Result<> startEditorProcess(const LaunchData &data)
{
    if (debug)
        qDebug() << Q_FUNC_INFO << '\n' << data.binary << data.arguments << data.workingDirectory;
    const CommandLine cmd{FilePath::fromString(data.binary), data.arguments};
    if (!Process::startDetached(cmd, data.workingDirectory))
        return  ResultError(Tr::tr("Unable to start \"%1\".").arg(cmd.toUserOutput()));
    return ResultOk;
}

// ExternalDesignerEditorFactory with Designer Tcp remote control.

// A per-binary entry containing the socket
using ProcessCache = QMap<QString, QTcpSocket*>;

static ProcessCache m_processCache;

static void processTerminated(const QString &binary)
{
    const ProcessCache::iterator it = m_processCache.find(binary);
    if (it == m_processCache.end())
        return;
    // Make sure socket is closed and cleaned, remove from cache
    QTcpSocket *socket = it.value();
    m_processCache.erase(it); // Note that closing will cause the slot to be retriggered
    if (debug)
        qDebug() << Q_FUNC_INFO << '\n' << binary << socket->state();
    if (socket->state() == QAbstractSocket::ConnectedState)
        socket->close();
    socket->deleteLater();
}

class ExternalDesignerFactory final : public Core::IEditorFactory
{
public:
    explicit ExternalDesignerFactory(QObject *guard)
    {
        setId("Qt.Designer");
        setDisplayName(Tr::tr("Qt Widgets Designer"));
        setMimeTypes({Utils::Constants::FORM_MIMETYPE});

        setEditorStarter([guard](const FilePath &filePath) -> Result<> {
            LaunchData data;

            // Find the editor binary
            if (const Result<> res = getEditorLaunchData(designerBinary, filePath, &data); !res)
                return res;

            if (HostOsInfo::isMacHost())
                return startEditorProcess(data);

            /* Qt Widgets Designer on the remaining platforms: Uses Designer's own
            * Tcp-based communication mechanism to ensure all files are opened
            * in one instance (per version). */

            // Known one?
            const ProcessCache::iterator it = m_processCache.find(data.binary);
            if (it != m_processCache.end()) {
                // Process is known, write to its socket to cause it to open the file
                if (debug)
                    qDebug() << Q_FUNC_INFO << "\nWriting to socket:" << data.binary << filePath;
                QTcpSocket *socket = it.value();
                if (!socket->write(filePath.toUrlishString().toUtf8() + '\n')) {
                    return ResultError(Tr::tr("Qt Widgets Designer is not responding (%1).")
                                        .arg(socket->errorString()));
                }
                return ResultOk;
            }
            // No process yet. Create socket & launch the process
            QTcpServer server;
            if (!server.listen(QHostAddress::LocalHost))
                return ResultError(Tr::tr("Unable to create server socket: %1").arg(server.errorString()));

            const quint16 port = server.serverPort();
            if (debug)
                qDebug() << Q_FUNC_INFO << "\nLaunching server:" << port << data.binary << filePath;
            // Start first one with file and socket as '-client port file'
            // Wait for the socket listening
            data.arguments.push_front(QString::number(port));
            data.arguments.push_front(QLatin1String("-client"));

            if (const Result<> res = startEditorProcess(data); !res)
                return res;

            // Insert into cache if socket is created, else try again next time
            if (server.waitForNewConnection(3000)) {
                QTcpSocket *socket = server.nextPendingConnection();
                socket->setParent(guard);
                const QString binary = data.binary;
                m_processCache.insert(binary, socket);
                auto mapSlot = [binary] { processTerminated(binary); };
                QObject::connect(socket, &QAbstractSocket::disconnected, guard, mapSlot);
                QObject::connect(socket, &QAbstractSocket::errorOccurred, guard, mapSlot);
            }
            return ResultOk;
        });
    }
};

void setupExternalDesigner(QObject *guard)
{
    static ExternalDesignerFactory theExternalDesignerFactory(guard);
}

// Linguist

static QString linguistBinary(const QtSupport::QtVersion *qtVersion)
{
    if (qtVersion)
        return qtVersion->linguistFilePath().toUrlishString();
    return QLatin1String(HostOsInfo::isMacHost() ? "Linguist" : "linguist");
}

class ExternalLinguistFactory : public Core::IEditorFactory
{
public:
    ExternalLinguistFactory()
    {
        setId("Qt.Linguist");
        setDisplayName(Tr::tr("Qt Linguist"));
        setMimeTypes({Utils::Constants::LINGUIST_MIMETYPE});
        setEditorStarter([](const FilePath &filePath) {
            LaunchData data;
            if (const Result<> res = getEditorLaunchData(linguistBinary, filePath, &data); !res)
                return res;
            return startEditorProcess(data);
        });
    }
};

void setupExternalLinguist()
{
    static ExternalLinguistFactory theExternalDesignerFactory;
}

} // QtSupport::Internal
