// Copyright (C) 2025 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only

#include "assetdownloader.h"

#include <QtCore/private/qzipreader_p.h>

#include <QtCore/QDir>
#include <QtCore/QFile>
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
#include <QtCore/QStandardPaths>
#include <QtCore/QTemporaryDir>
#include <QtCore/QTemporaryFile>

#include <QtTaskTree/QConcurrentCall>
#include <QtTaskTree/QNetworkReplyWrapper>
#include <QtTaskTree/QSingleTaskTreeRunner>

using namespace QtTaskTree;

QT_BEGIN_NAMESPACE

namespace Assets::Downloader {

struct DownloadableAssets
{
    QUrl remoteUrl;
    QList<QUrl> files;
};

class AssetDownloaderPrivate
{
public:
    AssetDownloaderPrivate(AssetDownloader *q) : m_q(q) {}
    AssetDownloader *m_q = nullptr;

    std::unique_ptr<QNetworkAccessManager> m_manager;
    std::unique_ptr<QTemporaryDir> m_temporaryDir;
    QSingleTaskTreeRunner m_taskTreeRunner;
    QString m_lastProgressText;
    QDir m_localDownloadDir;

    QString m_jsonFileName;
    QString m_zipFileName;
    QDir m_preferredLocalDownloadDir =
            QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
    QUrl m_offlineAssetsFilePath;
    QUrl m_downloadBase;
    QStringList m_networkErrors;
    QStringList m_sslErrors;

    void setLocalDownloadDir(const QDir &dir)
    {
        if (m_localDownloadDir != dir) {
            m_localDownloadDir = dir;
            emit m_q->localDownloadDirChanged(QUrl::fromLocalFile(m_localDownloadDir.absolutePath()));
        }
    }
    void setProgress(int progressValue, int progressMaximum, const QString &progressText)
    {
        m_lastProgressText = progressText;
        emit m_q->progressChanged(progressValue, progressMaximum, progressText);
    }
    void updateProgress(int progressValue, int progressMaximum)
    {
        setProgress(progressValue, progressMaximum, m_lastProgressText);
    }
    void clearProgress(const QString &progressText)
    {
        setProgress(0, 0, progressText);
    }

    void setupDownload(QNetworkReplyWrapper *query, const QString &progressText)
    {
        query->setNetworkAccessManager(m_manager.get());
        clearProgress(progressText);
        QObject::connect(query, &QNetworkReplyWrapper::downloadProgress,
                         query, [this](qint64 bytesReceived, qint64 totalBytes) {
            updateProgress((totalBytes > 0) ? 100.0 * bytesReceived / totalBytes : 0, 100);
        });
#if QT_CONFIG(ssl)
        QObject::connect(query, &QNetworkReplyWrapper::sslErrors,
                         query, [this](const QList<QSslError> &sslErrors) {
            for (const QSslError &sslError : sslErrors)
                m_sslErrors << sslError.errorString();
        });
#endif
        QObject::connect(query, &QNetworkReplyWrapper::started, query, [this, query] {
            QNetworkReply *reply = query->reply();
            QObject::connect(reply, &QNetworkReply::errorOccurred, reply, [this, reply] {
                m_networkErrors << reply->errorString();
            });
        });
    }
};

static bool isWritableDir(const QDir &dir)
{
    if (dir.exists()) {
        QTemporaryFile file(dir.filePath(QString::fromLatin1("tmp")));
        return file.open();
    }
    return false;
}

static bool sameFileContent(const QFileInfo &first, const QFileInfo &second)
{
    if (first.exists() ^ second.exists())
        return false;

    if (first.size() != second.size())
        return false;

    QFile firstFile(first.absoluteFilePath());
    QFile secondFile(second.absoluteFilePath());

    if (firstFile.open(QFile::ReadOnly) && secondFile.open(QFile::ReadOnly)) {
        char char1;
        char char2;
        int readBytes1 = 0;
        int readBytes2 = 0;
        while (!firstFile.atEnd()) {
            readBytes1 = firstFile.read(&char1, 1);
            readBytes2 = secondFile.read(&char2, 1);
            if (readBytes1 != readBytes2 || readBytes1 != 1)
                return false;
            if (char1 != char2)
                return false;
        }
        return true;
    }

    return false;
}

static bool createDirectory(const QDir &dir)
{
    if (dir.exists())
        return true;

    if (!createDirectory(dir.absoluteFilePath(QString::fromUtf8(".."))))
        return false;

    return dir.mkpath(QString::fromUtf8("."));
}

static bool canBeALocalBaseDir(const QDir &dir)
{
    if (dir.exists())
        return !dir.isEmpty() || isWritableDir(dir);
    return createDirectory(dir) && isWritableDir(dir);
}

static QDir baseLocalDir(const QDir &preferredLocalDir)
{
    if (canBeALocalBaseDir(preferredLocalDir))
        return preferredLocalDir;

    qWarning().noquote() << "AssetDownloader: Cannot set \"" << preferredLocalDir
                         << "\" as a local download directory!";
    return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
}

static QString pathFromUrl(const QUrl &url)
{
    if (url.isLocalFile())
        return url.toLocalFile();

    if (url.scheme() == u"qrc")
        return u":" + url.path();

    return url.toString();
}

static QList<QUrl> filterDownloadableAssets(const QList<QUrl> &assetFiles, const QDir &expectedDir)
{
    QList<QUrl> downloadList;
    std::copy_if(assetFiles.begin(), assetFiles.end(), std::back_inserter(downloadList),
                 [&](const QUrl &assetPath) {
        return !QFileInfo::exists(expectedDir.absoluteFilePath(assetPath.toString()));
    });
    return downloadList;
}

static bool allAssetsPresent(const QList<QUrl> &assetFiles, const QDir &expectedDir)
{
    return std::all_of(assetFiles.begin(), assetFiles.end(), [&](const QUrl &assetPath) {
        return QFileInfo::exists(expectedDir.absoluteFilePath(assetPath.toString()));
    });
}

AssetDownloader::AssetDownloader(QObject *parent)
    : QObject(parent)
    , d(new AssetDownloaderPrivate(this))
{}

AssetDownloader::~AssetDownloader() = default;

QUrl AssetDownloader::downloadBase() const
{
    return d->m_downloadBase;
}

void AssetDownloader::setDownloadBase(const QUrl &downloadBase)
{
    if (d->m_downloadBase != downloadBase) {
        d->m_downloadBase = downloadBase;
        emit downloadBaseChanged(d->m_downloadBase);
    }
}

QUrl AssetDownloader::preferredLocalDownloadDir() const
{
    return QUrl::fromLocalFile(d->m_preferredLocalDownloadDir.absolutePath());
}

void AssetDownloader::setPreferredLocalDownloadDir(const QUrl &localDir)
{
    if (localDir.scheme() == u"qrc") {
        qWarning() << "Cannot set a qrc as preferredLocalDownloadDir";
        return;
    }

    const QString path = pathFromUrl(localDir);
    if (d->m_preferredLocalDownloadDir != path) {
        d->m_preferredLocalDownloadDir.setPath(path);
        emit preferredLocalDownloadDirChanged(preferredLocalDownloadDir());
    }
}

QUrl AssetDownloader::offlineAssetsFilePath() const
{
    return d->m_offlineAssetsFilePath;
}

void AssetDownloader::setOfflineAssetsFilePath(const QUrl &offlineAssetsFilePath)
{
    if (d->m_offlineAssetsFilePath != offlineAssetsFilePath) {
        d->m_offlineAssetsFilePath = offlineAssetsFilePath;
        emit offlineAssetsFilePathChanged(d->m_offlineAssetsFilePath);
    }
}

QString AssetDownloader::jsonFileName() const
{
    return d->m_jsonFileName;
}

void AssetDownloader::setJsonFileName(const QString &jsonFileName)
{
    if (d->m_jsonFileName != jsonFileName) {
        d->m_jsonFileName = jsonFileName;
        emit jsonFileNameChanged(d->m_jsonFileName);
    }
}

QString AssetDownloader::zipFileName() const
{
    return d->m_zipFileName;
}

void AssetDownloader::setZipFileName(const QString &zipFileName)
{
    if (d->m_zipFileName != zipFileName) {
        d->m_zipFileName = zipFileName;
        emit zipFileNameChanged(d->m_zipFileName);
    }
}

QUrl AssetDownloader::localDownloadDir() const
{
    return QUrl::fromLocalFile(d->m_localDownloadDir.absolutePath());
}

QStringList AssetDownloader::networkErrors() const
{
    return d->m_networkErrors;
}

QStringList AssetDownloader::sslErrors() const
{
    return d->m_sslErrors;
}

static void precheckLocalFile(const QUrl &url)
{
    if (url.isEmpty())
        return;
    QFile file(pathFromUrl(url));
    if (!file.open(QIODevice::ReadOnly))
        qWarning() << "Cannot open local file" << url;
}

static void readAssetsFileContent(QPromise<DownloadableAssets> &promise, const QByteArray &content)
{
    const QJsonObject json = QJsonDocument::fromJson(content).object();
    const QJsonArray assetsArray = json[u"assets"].toArray();
    DownloadableAssets result;
    result.remoteUrl = json[u"url"].toString();
    for (const QJsonValue &asset : assetsArray) {
        if (promise.isCanceled())
            return;
        result.files.append(asset.toString());
    }

    if (result.files.isEmpty() || result.remoteUrl.isEmpty())
        promise.future().cancel();
    else
        promise.addResult(result);
}

static void unzip(QPromise<void> &promise, const QByteArray &content, const QDir &directory,
                  const QString &fileName)
{
    const QString zipFilePath = directory.absoluteFilePath(fileName);
    QFile zipFile(zipFilePath);
    if (!zipFile.open(QIODevice::WriteOnly)) {
        promise.future().cancel();
        return;
    }
    zipFile.write(content);
    zipFile.close();

    if (promise.isCanceled())
        return;

    QZipReader reader(zipFilePath);
    const bool extracted = reader.extractAll(directory.absolutePath());
    reader.close();
    if (extracted)
        QFile::remove(zipFilePath);
    else
        promise.future().cancel();
}

static void writeAsset(QPromise<void> &promise, const QByteArray &content, const QString &filePath)
{
    const QFileInfo fileInfo(filePath);
    QFile file(fileInfo.absoluteFilePath());
    if (!createDirectory(fileInfo.dir()) || !file.open(QFile::WriteOnly)) {
        promise.future().cancel();
        return;
    }

    if (promise.isCanceled())
        return;

    file.write(content);
    file.close();
}

static void copyAndCheck(QPromise<void> &promise, const QString &sourcePath, const QString &destPath)
{
    QFile sourceFile(sourcePath);
    QFile destFile(destPath);
    const QFileInfo sourceFileInfo(sourceFile.fileName());
    const QFileInfo destFileInfo(destFile.fileName());

    if (destFile.exists() && !destFile.remove()) {
        qWarning().noquote() << QString::fromLatin1("Unable to remove file \"%1\".")
                                        .arg(QFileInfo(destFile.fileName()).absoluteFilePath());
        promise.future().cancel();
        return;
    }

    if (!createDirectory(destFileInfo.absolutePath())) {
        qWarning().noquote() << QString::fromLatin1("Cannot create directory \"%1\".")
                                        .arg(destFileInfo.absolutePath());
        promise.future().cancel();
        return;
    }

    if (promise.isCanceled())
        return;

    if (!sourceFile.copy(destFile.fileName()) && !sameFileContent(sourceFileInfo, destFileInfo))
        promise.future().cancel();
}

void AssetDownloader::start()
{
    if (d->m_taskTreeRunner.isRunning())
        return;

    struct StorageData
    {
        QDir tempDir;
        QByteArray jsonContent;
        DownloadableAssets assets;
        QList<QUrl> assetsToDownload;
        QByteArray zipContent;
        int doneCount = 0;
    };

    const Storage<StorageData> storage;

    const auto onSetup = [this, storage] {
        if (!d->m_manager)
            d->m_manager = std::make_unique<QNetworkAccessManager>();
        if (!d->m_temporaryDir)
            d->m_temporaryDir = std::make_unique<QTemporaryDir>();
        if (!d->m_temporaryDir->isValid()) {
            qWarning() << "Cannot create a temporary directory.";
            return SetupResult::StopWithError;
        }
        storage->tempDir = d->m_temporaryDir->path();
        d->setLocalDownloadDir(baseLocalDir(d->m_preferredLocalDownloadDir));
        d->m_networkErrors.clear();
        d->m_sslErrors.clear();
        precheckLocalFile(resolvedUrl(d->m_offlineAssetsFilePath));
        return SetupResult::Continue;
    };

    const auto onJsonDownloadSetup = [this](QNetworkReplyWrapper &query) {
        query.setRequest(QNetworkRequest(d->m_downloadBase.resolved(d->m_jsonFileName)));
        d->setupDownload(&query, tr("Downloading JSON file..."));
    };
    const auto onJsonDownloadDone = [this, storage](const QNetworkReplyWrapper &query, DoneWith result) {
        if (result == DoneWith::Success) {
            storage->jsonContent = query.reply()->readAll();
            return DoneResult::Success;
        }
        qWarning() << "Cannot download" << d->m_downloadBase.resolved(d->m_jsonFileName)
                   << query.reply()->errorString();
        if (d->m_offlineAssetsFilePath.isEmpty()) {
            qWarning() << "Also there is no local file as a replacement";
            return DoneResult::Error;
        }

        QFile file(pathFromUrl(resolvedUrl(d->m_offlineAssetsFilePath)));
        if (!file.open(QIODevice::ReadOnly)) {
            qWarning() << "Also failed to open" << d->m_offlineAssetsFilePath;
            return DoneResult::Error;
        }

        storage->jsonContent = file.readAll();
        return DoneResult::Success;
    };

    const auto onReadAssetsFileSetup = [storage](QConcurrentCall<DownloadableAssets> &async) {
        async.setConcurrentCallData(readAssetsFileContent, storage->jsonContent);
    };
    const auto onReadAssetsFileDone = [storage](const QConcurrentCall<DownloadableAssets> &async) {
        storage->assets = async.result();
        storage->assetsToDownload = storage->assets.files;
    };

    const auto onSkipIfAllAssetsPresent = [this, storage] {
        return allAssetsPresent(storage->assets.files, d->m_localDownloadDir)
                ? SetupResult::StopWithSuccess : SetupResult::Continue;
    };

    const auto onZipDownloadSetup = [this, storage](QNetworkReplyWrapper &query) {
        if (d->m_zipFileName.isEmpty())
            return SetupResult::StopWithSuccess;

        query.setRequest(QNetworkRequest(d->m_downloadBase.resolved(d->m_zipFileName)));
        d->setupDownload(&query, tr("Downloading zip file..."));
        return SetupResult::Continue;
    };
    const auto onZipDownloadDone = [storage](const QNetworkReplyWrapper &query, DoneWith result) {
        if (result == DoneWith::Success)
            storage->zipContent = query.reply()->readAll();
        return DoneResult::Success; // Ignore zip download failure
    };

    const auto onUnzipSetup = [this, storage](QConcurrentCall<void> &async) {
        if (storage->zipContent.isEmpty())
            return SetupResult::StopWithSuccess;

        async.setConcurrentCallData(unzip, storage->zipContent, storage->tempDir, d->m_zipFileName);
        d->clearProgress(tr("Unzipping..."));
        return SetupResult::Continue;
    };
    const auto onUnzipDone = [storage](DoneWith result) {
        if (result == DoneWith::Success) {
            // Avoid downloading assets that are present in unzipped tree
            StorageData &storageData = *storage;
            storageData.assetsToDownload =
                    filterDownloadableAssets(storageData.assets.files, storageData.tempDir);
        } else {
            qWarning() << "ZipFile failed";
        }
        return DoneResult::Success; // Ignore unzip failure
    };

    const UntilIterator downloadIterator([storage](int iteration) {
        return iteration < storage->assetsToDownload.count();
    });

    const Storage<QByteArray> assetStorage;

    const auto onAssetsDownloadGroupSetup = [this, storage] {
        d->setProgress(0, storage->assetsToDownload.size(), tr("Downloading assets..."));
    };

    const auto onAssetDownloadSetup = [this, storage, downloadIterator](QNetworkReplyWrapper &query) {
        query.setNetworkAccessManager(d->m_manager.get());
        query.setRequest(QNetworkRequest(storage->assets.remoteUrl.resolved(
            storage->assetsToDownload.at(downloadIterator.iteration()))));
     };
    const auto onAssetDownloadDone = [assetStorage](const QNetworkReplyWrapper &query, DoneWith result) {
        if (result == DoneWith::Success)
            *assetStorage = query.reply()->readAll();
    };

    const auto onAssetWriteSetup = [storage, downloadIterator, assetStorage](
                                           QConcurrentCall<void> &async) {
        const QString filePath = storage->tempDir.absoluteFilePath(
            storage->assetsToDownload.at(downloadIterator.iteration()).toString());
        async.setConcurrentCallData(writeAsset, *assetStorage, filePath);
    };
    const auto onAssetWriteDone = [this, storage](DoneWith result) {
        if (result != DoneWith::Success) {
            qWarning() << "Asset write failed";
            return;
        }
        StorageData &storageData = *storage;
        ++storageData.doneCount;
        d->updateProgress(storageData.doneCount, storageData.assetsToDownload.size());
    };

    const UntilIterator copyIterator([storage](int iteration) {
        return iteration < storage->assets.files.count();
    });

    const auto onAssetsCopyGroupSetup = [this, storage] {
        storage->doneCount = 0;
        d->setProgress(0, storage->assets.files.size(), tr("Copying assets..."));
    };

    const auto onAssetCopySetup = [this, storage, copyIterator](QConcurrentCall<void> &async) {
        const QString fileName = storage->assets.files.at(copyIterator.iteration()).toString();
        const QString sourcePath = storage->tempDir.absoluteFilePath(fileName);
        const QString destPath = d->m_localDownloadDir.absoluteFilePath(fileName);
        async.setConcurrentCallData(copyAndCheck, sourcePath, destPath);
    };
    const auto onAssetCopyDone = [this, storage] {
        StorageData &storageData = *storage;
        ++storageData.doneCount;
        d->updateProgress(storageData.doneCount, storageData.assets.files.size());
    };

    const auto onAssetsCopyGroupDone = [this, storage](DoneWith result) {
        if (result != DoneWith::Success) {
            d->setLocalDownloadDir(storage->tempDir);
            qWarning() << "Asset copy failed";
            return;
        }
        d->m_temporaryDir.reset();
    };

    const Group recipe {
        storage,
        onGroupSetup(onSetup),
        QNetworkReplyWrapperTask(onJsonDownloadSetup, onJsonDownloadDone),
        QConcurrentCallTask<DownloadableAssets>(onReadAssetsFileSetup, onReadAssetsFileDone, CallDone::OnSuccess),
        Group {
            onGroupSetup(onSkipIfAllAssetsPresent),
            QNetworkReplyWrapperTask(onZipDownloadSetup, onZipDownloadDone),
            QConcurrentCallTask<void>(onUnzipSetup, onUnzipDone),
            For (downloadIterator) >> Do {
                parallelIdealThreadCountLimit,
                onGroupSetup(onAssetsDownloadGroupSetup),
                Group {
                    assetStorage,
                    QNetworkReplyWrapperTask(onAssetDownloadSetup, onAssetDownloadDone),
                    QConcurrentCallTask<void>(onAssetWriteSetup, onAssetWriteDone)
                }
            },
            For (copyIterator) >> Do {
                parallelIdealThreadCountLimit,
                onGroupSetup(onAssetsCopyGroupSetup),
                QConcurrentCallTask<void>(onAssetCopySetup, onAssetCopyDone, CallDone::OnSuccess),
                onGroupDone(onAssetsCopyGroupDone)
            }
        }
    };
    d->m_taskTreeRunner.start(recipe, [this] { emit started(); },
            [this](DoneWith result) { emit finished(result == DoneWith::Success); });
}

QUrl AssetDownloader::resolvedUrl(const QUrl &url) const
{
    return url;
}

} // namespace Assets::Downloader

QT_END_NAMESPACE
