/**
 * SPDX-FileCopyrightText: 2020 Tobias Fella <tobias.fella@kde.org>
 * SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
 *
 * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
 */

#include "enclosure.h"
#include "enclosurelogging.h"

#include <KLocalizedString>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QImage>
#include <QMimeDatabase>
#include <QNetworkReply>
#include <QSqlQuery>

#include <attachedpictureframe.h>
#include <fileref.h>
#include <id3v2frame.h>
#include <id3v2tag.h>
#include <mpegfile.h>

#include "audiomanager.h"
#include "database.h"
#include "datamanager.h"
#include "entry.h"
#include "error.h"
#include "fetcher.h"
#include "models/downloadmodel.h"
#include "models/errorlogmodel.h"
#include "objectslogging.h"
#include "settingsmanager.h"
#include "utils/enclosuredownloadjob.h"
#include "utils/networkconnectionmanager.h"
#include "utils/storagemanager.h"

Enclosure::Enclosure(Entry *entry)
    : QObject(entry)
    , m_entry(entry)
{
    connect(this, &Enclosure::playPositionChanged, this, &Enclosure::leftDurationChanged);
    connect(this, &Enclosure::statusChanged, &DownloadModel::instance(), &DownloadModel::monitorDownloadStatus);
    connect(this, &Enclosure::downloadError, &ErrorLogModel::instance(), &ErrorLogModel::monitorErrorMessages);
    connect(&Fetcher::instance(), &Fetcher::entryUpdated, this, [this](const qint64 entryuid) {
        if (m_entry->entryuid() == entryuid) {
            updateFromDb();
        }
    });
    connect(&AudioManager::instance(), &AudioManager::playbackRateChanged, this, &Enclosure::leftDurationChanged);
    connect(&DataManager::instance(), &DataManager::entryPlayPositionsChanged, this, [this](const QList<qint64> &positions, const QList<qint64> &entryuids) {
        if (entryuids.contains(m_entryuid)) {
            qint64 index = entryuids.indexOf(m_entryuid);
            Q_ASSERT(index > -1);
            m_playposition = positions[index];
            Q_EMIT playPositionChanged();
        }
    });
    connect(&DataManager::instance(), &DataManager::enclosureDurationsChanged, this, [this](const QList<qint64> &durations, const QList<qint64> &entryuids) {
        if (entryuids.contains(m_entryuid)) {
            qint64 index = entryuids.indexOf(m_entryuid);
            Q_ASSERT(index > -1);
            m_duration = durations[index];
            Q_EMIT durationChanged();
        }
    });
    connect(&DataManager::instance(), &DataManager::enclosureSizesChanged, this, [this](const QList<qint64> &sizes, const QList<qint64> &entryuids) {
        if (entryuids.contains(m_entryuid)) {
            qint64 index = entryuids.indexOf(m_entryuid);
            Q_ASSERT(index > -1);
            m_size = sizes[index];
            Q_EMIT sizeChanged();
        }
    });
    connect(&DataManager::instance(), &DataManager::enclosureStatusesChanged, this, [this](const QList<Status> &statuses, const QList<qint64> &entryuids) {
        if (entryuids.contains(m_entryuid)) {
            qint64 index = entryuids.indexOf(m_entryuid);
            Q_ASSERT(index > -1);
            m_status = statuses[index];
            m_downloadProgress = 0;
            m_downloadSize = 0;
            Q_EMIT statusChanged(m_entry, m_status);
        }
    });
    // This connection is there to keep other objects of the currently playing
    // enclosure in sync without writing the position to the DB (yet)
    connect(&AudioManager::instance(), &AudioManager::positionChanged, this, [this](const qint64 position, const qint64 entryuid) {
        if (entryuid == m_entryuid) {
            m_playposition = position;
            Q_EMIT playPositionChanged();
        }
    });

    // TODO: this will just take the first enclosure found; we should handle
    // multiple ones
    QSqlQuery query;
    query.prepare(QStringLiteral("SELECT * FROM Enclosures WHERE entryuid=:entryuid"));
    query.bindValue(QStringLiteral(":entryuid"), entry->entryuid());
    Database::instance().execute(query);

    if (!query.next()) {
        return;
    }

    m_entryuid = query.value(QStringLiteral("entryuid")).toLongLong();
    m_enclosureuid = query.value(QStringLiteral("enclosureuid")).toLongLong();
    m_duration = query.value(QStringLiteral("duration")).toInt();
    m_size = query.value(QStringLiteral("size")).toInt();
    m_type = query.value(QStringLiteral("type")).toString();
    m_url = query.value(QStringLiteral("url")).toString();
    m_playposition = query.value(QStringLiteral("playposition")).toLongLong();
    m_status = dbToStatus(query.value(QStringLiteral("downloaded")).toInt());
    m_playposition_dbsave = m_playposition;

    // using qtimer to do this update after the constructor so the signals can be picked up correctly
    QTimer::singleShot(0, this, &Enclosure::checkSizeOnDisk);

    qCDebug(kastsObjects) << "Enclosure object" << m_enclosureuid << "constructed (corresponding entryuid is" << m_entryuid << ")";
}

Enclosure::~Enclosure()
{
    qCDebug(kastsObjects) << "Enclosure object" << m_enclosureuid << "destructed (corresponding entryuid is" << m_entryuid << ")";
}

void Enclosure::updateFromDb()
{
    // This method is used to update the most relevant fields from the RSS feed,
    // most notably the download URL.  It's deliberatly only updating the
    // duration and size if the URL has changed, since these values are
    // notably untrustworthy.  We generally get them from the files themselves
    // at the time they are downloaded.
    QSqlQuery query;
    query.prepare(QStringLiteral("SELECT * FROM Enclosures WHERE enclosureuid=:enclosureuid"));
    query.bindValue(QStringLiteral(":enclosureuid"), m_enclosureuid);
    Database::instance().execute(query);

    if (!query.next()) {
        return;
    }

    if (m_url != query.value(QStringLiteral("url")).toString() && m_status != Downloaded) {
        // this means that the audio file has changed, or at least its location
        // let's only do something if the file isn't downloaded.
        // try to delete the file first (it actually shouldn't exist)
        deleteFile();

        m_url = query.value(QStringLiteral("url")).toString();
        Q_EMIT urlChanged(m_url);
        Q_EMIT pathChanged(path());

        if (m_duration != query.value(QStringLiteral("duration")).toInt()) {
            m_duration = query.value(QStringLiteral("duration")).toInt();
            Q_EMIT durationChanged();
        }
        if (m_size != query.value(QStringLiteral("size")).toInt()) {
            m_size = query.value(QStringLiteral("size")).toInt();
            Q_EMIT sizeChanged();
        }
        if (m_type != query.value(QStringLiteral("type")).toString()) {
            m_type = query.value(QStringLiteral("type")).toString();
            Q_EMIT typeChanged(m_type);
        }
    }
}

int Enclosure::statusToDb(Enclosure::Status status)
{
    return static_cast<int>(status);
}

Enclosure::Status Enclosure::dbToStatus(int value)
{
    return Enclosure::Status(value);
}

void Enclosure::download()
{
    if (m_status == Downloaded) {
        return;
    }

    // TODO: move this check to fetcher; needs error refactoring to use uids
    if (!NetworkConnectionManager::instance().episodeDownloadsAllowed()) {
        if (NetworkConnectionManager::instance().networkReachable()) {
            Q_EMIT downloadError(Error::Type::MeteredNotAllowed,
                                 m_entry->feed()->url(),
                                 m_entry->id(),
                                 0,
                                 i18n("Podcast downloads not allowed on metered connection"),
                                 QString());
            return;
        } else {
            Q_EMIT downloadError(Error::Type::NoNetwork, m_entry->feed()->url(), m_entry->id(), 0, i18n("No network connection"), QString());
            return;
        }
    }

    checkSizeOnDisk();
    EnclosureDownloadJob *downloadJob = Fetcher::instance().enqueueEnclosureDownload(m_entryuid, m_url, path(), m_entry->title());

    qint64 resumedAt = m_sizeOnDisk;
    m_downloadProgress = 0;
    m_downloadSize = 0;
    Q_EMIT downloadProgressChanged();

    m_entry->feed()->setErrorId(0);
    m_entry->feed()->setErrorString(QString());

    connect(downloadJob, &KJob::result, this, [this, downloadJob]() {
        checkSizeOnDisk();
        if (downloadJob->error() == 0) {
            processDownloadedFile();
        } else {
            QFile file(path());
            if (file.exists() && file.size() > 0) {
                setStatus(PartiallyDownloaded);
            } else {
                setStatus(Downloadable);
            }
            if (downloadJob->error() != QNetworkReply::OperationCanceledError) {
                m_entry->feed()->setErrorId(downloadJob->error());
                m_entry->feed()->setErrorString(downloadJob->errorString());
                Q_EMIT downloadError(Error::Type::MediaDownload,
                                     m_entry->feed()->url(),
                                     m_entry->id(),
                                     downloadJob->error(),
                                     downloadJob->errorString(),
                                     QString());
            }
        }
        disconnect(this, &Enclosure::cancelDownload, this, nullptr);
        Q_EMIT statusChanged(m_entry, m_status);
    });

    connect(this, &Enclosure::cancelDownload, this, [this, downloadJob]() {
        downloadJob->doKill();
        checkSizeOnDisk();
        QFile file(path());
        if (file.exists() && file.size() > 0) {
            setStatus(PartiallyDownloaded);
        } else {
            setStatus(Downloadable);
        }
        disconnect(this, &Enclosure::cancelDownload, this, nullptr);
    });

    connect(downloadJob, &KJob::processedAmountChanged, this, [this, resumedAt](KJob *kjob, KJob::Unit unit, qulonglong amount) {
        Q_ASSERT(unit == KJob::Unit::Bytes);

        setStatus(Status::Downloading);

        qint64 totalSize = static_cast<qint64>(kjob->totalAmount(unit));
        qint64 currentSize = static_cast<qint64>(amount);

        if ((totalSize > 0) && (m_size != totalSize + resumedAt)) {
            qCDebug(kastsEnclosure) << "Correct filesize for enclosure" << m_entry->title() << "from" << m_size << "to" << totalSize + resumedAt;
            setSize(totalSize + resumedAt);
        }

        m_downloadSize = currentSize + resumedAt;
        m_downloadProgress = static_cast<double>(m_downloadSize) / static_cast<double>(m_size);
        Q_EMIT downloadProgressChanged();

        qCDebug(kastsEnclosure) << "m_downloadSize" << m_downloadSize;
        qCDebug(kastsEnclosure) << "m_downloadProgress" << m_downloadProgress;
        qCDebug(kastsEnclosure) << "m_size" << m_size;
    });

    setStatus(Queued);
}

void Enclosure::processDownloadedFile()
{
    // This will be run if the enclosure has been downloaded successfully

    // First check if file size is larger than 0; otherwise something unexpected
    // must have happened
    checkSizeOnDisk();
    if (m_sizeOnDisk == 0) {
        deleteFile();
        return;
    }

    // Check if reported filesize in rss feed corresponds to real file size
    // if not, correct the filesize in the database
    // otherwise the file will get deleted because of mismatch in signature
    if (m_sizeOnDisk != size()) {
        qCDebug(kastsEnclosure) << "Correcting enclosure file size mismatch for" << m_entry->title() << "from" << size() << "to" << m_sizeOnDisk;
        setSize(m_sizeOnDisk);
        setStatus(Downloaded);
    }

    // Check the duration inside the tag, it should be more accurate than the
    // value from the feed entry
    TagLib::FileRef f(path().toStdString().data());
    if (!f.isNull() && f.audioProperties()) {
        int fileduration = f.audioProperties()->lengthInSeconds();
        if (fileduration > 0 && fileduration != duration()) {
            qCDebug(kastsEnclosure) << "Correcting enclosure duration mismatch for" << m_entry->title() << "from" << duration() << "to" << fileduration;
            setDuration(fileduration);
        }
    }

    // Unset "new" status of item
    if (m_entry->getNew()) {
        m_entry->setNew(false);
    }

    // Trigger update of image since the downloaded file can have an embedded image
    Q_EMIT m_entry->imageChanged(m_entry->image());
    Q_EMIT m_entry->cachedImageChanged(m_entry->cachedImage());
}

void Enclosure::deleteFile()
{
    qCDebug(kastsEnclosure) << "Trying to delete enclosure file" << path();
    if (m_entryuid == AudioManager::instance().entryuid()) {
        qCDebug(kastsEnclosure) << "Track is still playing; let's unload it before deleting";
        AudioManager::instance().setEntryuid(0);
    }

    // First check if file still exists; you never know what has happened
    if (QFile(path()).exists()) {
        QFile(path()).remove();
    }

    // If file disappeared unexpectedly, then still change status to downloadable
    setStatus(Downloadable);
    m_sizeOnDisk = 0;
    Q_EMIT sizeOnDiskChanged();
    Q_EMIT downloadProgressChanged();
}

qint64 Enclosure::enclosureuid() const
{
    return m_enclosureuid;
}

QString Enclosure::url() const
{
    return m_url;
}

QString Enclosure::path() const
{
    return StorageManager::instance().enclosurePath(m_entry->title(), m_url, m_entry->feed()->dirname());
}

Enclosure::Status Enclosure::status() const
{
    return m_status;
}

QString Enclosure::cachedEmbeddedImage() const
{
    // if image is already cached, then return the path
    QString cachedpath = StorageManager::instance().imagePath(m_url);
    if (QFileInfo::exists(cachedpath)) {
        if (QFileInfo(cachedpath).size() != 0) {
            return QUrl::fromLocalFile(cachedpath).toString();
        }
    }

    if (m_status != Downloaded || path().isEmpty()) {
        return QLatin1String("");
    }

    const auto mime = QMimeDatabase().mimeTypeForFile(path()).name();
    if (mime != QStringLiteral("audio/mpeg")) {
        return QLatin1String("");
    }

    TagLib::MPEG::File f(path().toStdString().data());
    if (!f.isValid() || !f.hasID3v2Tag()) {
        return QLatin1String("");
    }

    bool imageFound = false;
    for (const auto &frame : f.ID3v2Tag()->frameListMap()["APIC"]) {
        auto pictureFrame = dynamic_cast<TagLib::ID3v2::AttachedPictureFrame *>(frame);
        QByteArray data(pictureFrame->picture().data(), pictureFrame->picture().size());
        if (!data.isEmpty() && QImage().loadFromData(data)) {
            QFile file(cachedpath);
            if (file.open(QIODevice::WriteOnly)) {
                file.write(data);
                file.close();
                imageFound = true;
            }
        }
    }

    if (imageFound) {
        return cachedpath;
    } else {
        return QLatin1String("");
    }
}

qint64 Enclosure::playPosition() const
{
    return m_playposition;
}

qint64 Enclosure::duration() const
{
    return m_duration;
}

qint64 Enclosure::size() const
{
    return m_size;
}

qint64 Enclosure::sizeOnDisk() const
{
    return m_sizeOnDisk;
}

void Enclosure::setStatus(Enclosure::Status status)
{
    if (m_status != status) {
        m_status = status;
        DataManager::instance().bulkSetEnclosureStatuses((QList<Status>{status}), QList<qint64>({m_entryuid}));
        qCDebug(kastsEnclosure) << "updating enclosure status" << status << m_entry->title();
    }
}

void Enclosure::setPlayPosition(const qint64 &position)
{
    if (m_playposition != position) {
        m_playposition = position;
        DataManager::instance().bulkSetPlayPositions(QList<qint64>({position}), QList<qint64>({m_entryuid}));
        qCDebug(kastsEnclosure) << "save playPosition" << position << m_entry->title();
    }
}

void Enclosure::setDuration(const qint64 &duration)
{
    if (m_duration != duration) {
        m_duration = duration;
        DataManager::instance().bulkSetEnclosureDurations(QList<qint64>({duration}), QList<qint64>({m_entryuid}));
        qCDebug(kastsEnclosure) << "updating entry duration" << duration << m_entry->title();
    }
}

void Enclosure::setSize(const qint64 &size)
{
    if (m_size != size) {
        m_size = size;
        DataManager::instance().bulkSetEnclosureSizes(QList<qint64>({size}), QList<qint64>({m_entryuid}));
        qCDebug(kastsEnclosure) << "updating entry enclosure size" << size << m_entry->title();
    }
}

void Enclosure::checkSizeOnDisk()
{
    // In principle the database contains this status, we check anyway in case
    // something changed on disk
    QFile file(path());
    if (file.exists()) {
        if (file.size() == m_size && file.size() > 0) {
            // file is on disk and has correct size, write to database if it
            // wasn't already registered so
            // this should, in principle, never happen unless the db was deleted
            setStatus(Downloaded);
        } else if (file.size() > 0) {
            // file was downloaded, but there is a size mismatch
            // set to PartiallyDownloaded such that download can be resumed
            setStatus(PartiallyDownloaded);
        } else {
            // file is empty
            setStatus(Downloadable);
        }
        if (file.size() != m_sizeOnDisk) {
            m_sizeOnDisk = file.size();
            m_downloadSize = m_sizeOnDisk;
            m_downloadProgress = (m_size == 0) ? 0.0 : static_cast<double>(m_sizeOnDisk) / static_cast<double>(m_size);
            Q_EMIT sizeOnDiskChanged();
        }
    } else {
        // file does not exist
        setStatus(Downloadable);
        if (m_sizeOnDisk != 0) {
            m_sizeOnDisk = 0;
            m_downloadSize = 0;
            m_downloadProgress = 0.0;
            Q_EMIT sizeOnDiskChanged();
        }
    }
}

QString Enclosure::formattedSize() const
{
    return m_kformat.formatByteSize(m_size);
}

QString Enclosure::formattedDownloadSize() const
{
    return m_kformat.formatByteSize(m_downloadSize);
}

QString Enclosure::formattedDuration() const
{
    return m_kformat.formatDuration(m_duration * 1000);
}

QString Enclosure::formattedLeftDuration() const
{
    qreal rate = 1.0;
    if (SettingsManager::self()->adjustTimeLeft()) {
        rate = AudioManager::instance().playbackRate();
        rate = (rate > 0.0) ? rate : 1.0;
    }
    qint64 diff = duration() * 1000 - playPosition();
    return m_kformat.formatDuration(diff / rate);
}

QString Enclosure::formattedPlayPosition() const
{
    return m_kformat.formatDuration(m_playposition);
}
