/*
 *   SPDX-FileCopyrightText: 2020 Alexey Minnekhanov <alexey.min@gmail.com>
 *
 *   SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
 */

#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QStandardPaths>
#include <QUrl>

// KF5
#include <KIO/TransferJob>
#include <KUiServerJobTracker>

#include <utility>

#include "AppstreamDataDownloader.h"
#include "alpineapk_backend_logging.h"

namespace DiscoverVersion
{
// contains static QLatin1String version("5.20.5"); definition
// autogenerated from top CMakeLists.txt
#include "../../../DiscoverVersion.h"
}

QString AppstreamDataDownloader::appStreamCacheDir()
{
    const QString cachePath = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QLatin1String("/external_appstream_data");
    // ^^ "~/.cache/discover/external_appstream_data"
    QDir(cachePath).mkpath(QStringLiteral("."));
    return cachePath;
}

AppstreamDataDownloader::AppstreamDataDownloader(QObject *parent)
    : QObject(parent)
{
    m_jobTracker = new KUiServerJobTracker(this);
}

void AppstreamDataDownloader::setCacheExpirePeriodSecs(qint64 secs)
{
    m_cacheExpireSeconds = secs;
}

void AppstreamDataDownloader::loadUrlsJson(const QString &jsonPath)
{
    const QString jsonBaseName = QFileInfo(jsonPath).baseName();
    QFile jsonFile(jsonPath);
    if (!jsonFile.open(QIODevice::ReadOnly)) {
        qCWarning(LOG_ALPINEAPK) << "Failed to open JSON:" << jsonPath << "for reading!";
        Q_EMIT downloadFinished();
        return;
    }
    const QByteArray jsonBa = jsonFile.readAll();
    jsonFile.close();

    QJsonParseError jsonError;
    const QJsonDocument jDoc = QJsonDocument::fromJson(jsonBa, &jsonError);
    if (jDoc.isNull()) {
        qCWarning(LOG_ALPINEAPK) << "Failed to parse JSON:" << jsonPath << "!";
        qCWarning(LOG_ALPINEAPK) << jsonError.errorString();
        Q_EMIT downloadFinished();
        return;
    }
    // JSON structure:
    // {
    //    "urls": [
    //        "https://...",  "https://...",  "https://..."
    //    ]
    // }
    const QJsonObject rootObj = jDoc.object();
    const QJsonArray urls = rootObj.value(QLatin1String("urls")).toArray();
    for (const QJsonValue &urlValue : urls) {
        const QString url = urlValue.toString();
        m_urls.append(url);
        // prefixes are used to avoid name clashes with potential similar
        //    URL paths from other JSON files. Json file basename is used
        //    as prefix
        m_urlPrefixes.insert(url, jsonBaseName);
    }
}

QString AppstreamDataDownloader::calcLocalFileSavePath(const QUrl &urlToDownload)
{
    // we are adding a prefix here to local file name to avoid possible
    //    file name clashes with files from other JSONs
    // urlToDownload looks like:
    //    "https://appstream.alpinelinux.org/data/edge/main/Components-main-aarch64.xml.gz"
    //    "https://appstream.alpinelinux.org/data/edge/community/Components-community-aarch64.xml.gz"
    // future update will change them to this form:
    //   "https://appstream.alpinelinux.org/data/edge/main/Components-aarch64.xml.gz"
    //   "https://appstream.alpinelinux.org/data/edge/community/Components-aarch64.xml.gz"
    // so, file names will clash. We also need to have full URL to affect local file name
    //   to avoid name clashes.
    const QString urlPrefix = m_urlPrefixes.value(urlToDownload.toString(), QString());
    const QString urlPathHash = urlToDownload.path().replace(QLatin1Char('/'), QLatin1Char('_'));
    const QString localCacheFile = AppstreamDataDownloader::appStreamCacheDir() + QDir::separator() + urlPrefix + QLatin1Char('_') + urlPathHash;

    // new "~/.cache/discover/external_appstream_data/alpine-appstream-data__data_edge_main_Components-aarch64.xml.gz"

    return localCacheFile;
}

QString AppstreamDataDownloader::calcLocalFileSavePathOld(const QUrl &urlToDownload)
{
    // Calculate what file name was there for old format.
    // We keep a list of "old" file names so we can delete them.
    // "~/.cache/discover/external_appstream_data/alpine-appstream-data_Components-main-aarch64.xml.gz"
    // ^                                        ^/|                   ^ ^
    // | appstream cache dir -------------------|/|--- urlPrefix -----|_|-- file name
    const QString urlPrefix = m_urlPrefixes.value(urlToDownload.toString(), QString());
    const QString urlFileName = QFileInfo(urlToDownload.path()).fileName();
    const QString oldFormatFileName = AppstreamDataDownloader::appStreamCacheDir() + QDir::separator() + urlPrefix + QLatin1Char('_') + urlFileName;
    return oldFormatFileName;
}

void AppstreamDataDownloader::cleanupOldCachedFiles()
{
    if (m_oldFormatFileNames.isEmpty()) {
        return;
    }
    qCDebug(LOG_ALPINEAPK) << "appstream_downloader: removing old files:";
    for (const QString &oldFn : m_oldFormatFileNames) {
        bool ok = QFile::remove(oldFn);
        qCDebug(LOG_ALPINEAPK) << "    " << oldFn << (ok ? "OK" : "Fail");
    }
}

void AppstreamDataDownloader::start()
{
    m_urls.clear();
    // load json files with appdata URLs configuration
    const QString path =
        QStandardPaths::locate(QStandardPaths::GenericDataLocation, QLatin1String("libdiscover/external-appstream-urls"), QStandardPaths::LocateDirectory);
    if (path.isEmpty()) {
        qCWarning(LOG_ALPINEAPK) << "external-appstream-urls directory does not exist.";
        return;
    }

    QDir jsonsDir(path);
    // search for all JSON files in that directory and load each one
    QFileInfoList fileList = jsonsDir.entryInfoList({QStringLiteral("*.json")}, QDir::Files);
    for (const QFileInfo &fi : fileList) {
        qCDebug(LOG_ALPINEAPK) << " reading URLs JSON: " << fi.absoluteFilePath();
        loadUrlsJson(fi.absoluteFilePath());
    }

    qCDebug(LOG_ALPINEAPK) << "appstream_downloader: urls:" << m_urls;

    // check if download is needed at all, maybe all files are already up to date?

    appStreamCacheDir(); // can create a cache dir if not exists

    const QDateTime dtNow = QDateTime::currentDateTime();

    m_urlsToDownload.clear();
    m_oldFormatFileNames.clear();

    for (const QString &url : m_urls) {
        const QUrl urlToDownload(url, QUrl::TolerantMode);
        const QString localCacheFile = calcLocalFileSavePath(urlToDownload);
        const QFileInfo localFi(localCacheFile);
        if (localFi.exists()) {
            int modifiedSecsAgo = localFi.lastModified().secsTo(dtNow);
            if (modifiedSecsAgo >= m_cacheExpireSeconds) {
                qCDebug(LOG_ALPINEAPK) << " appstream metadata file: " << localFi.fileName() << " was last modified " << modifiedSecsAgo
                                       << " seconds ago, need to download";
                m_urlsToDownload.append(url);
            }
        } else {
            // locally downloaded file does not even exist, we need to download it
            m_urlsToDownload.append(url);
            qCDebug(LOG_ALPINEAPK) << " appstream metadata file: " << localFi.fileName() << " does not exist, queued for downloading";
        }

        // create a set of possible cached files with old name format
        const QString localCacheFileOld = calcLocalFileSavePathOld(urlToDownload);
        m_oldFormatFileNames.insert(localCacheFileOld);
    }

    if (m_urlsToDownload.isEmpty()) {
        // no need to download anything
        qCDebug(LOG_ALPINEAPK) << "appstream_downloader: All appstream data files "
                                  "are up to date, not downloading anything";
        cleanupOldCachedFiles();
        Q_EMIT downloadFinished();
        return;
    }

    // If we're here, some files are outdated; download is needed
    qCDebug(LOG_ALPINEAPK) << "appstream_downloader: We will need to download " << m_urlsToDownload.size() << " file(s)";

    const QString discoverVersion(QStringLiteral("plasma-discover %1").arg(DiscoverVersion::version));

    m_jobs.clear();
    for (const QString &sUrl : std::as_const(m_urlsToDownload)) {
        const QUrl url(sUrl, QUrl::TolerantMode);
        KIO::TransferJob *job = KIO::get(url, KIO::LoadType::Reload, KIO::JobFlag::HideProgressInfo);
        job->addMetaData(QLatin1String("UserAgent"), discoverVersion);

        m_jobTracker->registerJob(job);

        QObject::connect(job, &KJob::result, this, &AppstreamDataDownloader::onJobResult);
        QObject::connect(job, &KIO::TransferJob::data, this, &AppstreamDataDownloader::onJobData);

        m_jobs.push_back(job);
    }
}

void AppstreamDataDownloader::onJobData(KIO::Job *job, const QByteArray &data)
{
    KIO::TransferJob *tjob = qobject_cast<KIO::TransferJob *>(job);
    if (data.size() < 1) {
        return;
    }
    // while downloading, save data to temporary file
    const QString filePath = calcLocalFileSavePath(tjob->url()) + QLatin1String(".tmp");
    QFile fout(filePath);
    if (!fout.open(QIODevice::WriteOnly | QIODevice::Append)) {
        qCWarning(LOG_ALPINEAPK) << "appstream_downloader: failed to write: " << filePath;
        return;
    }
    fout.write(data);
    fout.close();
}

void AppstreamDataDownloader::onJobResult(KJob *job)
{
    KIO::TransferJob *tjob = qobject_cast<KIO::TransferJob *>(job);
    m_jobs.removeOne(tjob);
    m_jobTracker->unregisterJob(tjob);

    const QString localCacheFile = calcLocalFileSavePath(tjob->url());
    const QString localCacheFileTmp = localCacheFile + QLatin1String(".tmp");

    if (tjob->error()) {
        qCWarning(LOG_ALPINEAPK) << "appstream_downloader: failed to download: " << tjob->url();
        qCWarning(LOG_ALPINEAPK) << tjob->errorString();
        // error cleanup - remove temp file
        QFile::remove(localCacheFileTmp);
    } else {
        // success - rename tmp file to real
        QFile::remove(localCacheFile); // just in case, or QFile::rename() will fail
        QFile::rename(localCacheFileTmp, localCacheFile);
        m_cacheWasUpdated = true;
        qCDebug(LOG_ALPINEAPK) << "appstream_downloader: saved: " << localCacheFile;
    }

    tjob->deleteLater();

    qCDebug(LOG_ALPINEAPK).nospace() << "appstream_downloader: " << localCacheFile << " request finished (" << m_jobs.size() << " left)";

    if (m_jobs.isEmpty()) {
        qCDebug(LOG_ALPINEAPK) << "appstream_downloader: all downloads have finished!";
        cleanupOldCachedFiles();
        Q_EMIT downloadFinished();
    }
}
