// Copyright (C) 2016 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 "qplaylistfileparser_p.h"
#include <qfileinfo.h>
#include <QtCore/QDebug>
#include <QtCore/qiodevice.h>
#include <QtCore/qpointer.h>
#include <QtNetwork/QNetworkReply>
#include <QtNetwork/QNetworkRequest>
#include "qmediaplayer.h"
#include "qmediametadata.h"

QT_BEGIN_NAMESPACE

namespace {

class ParserBase
{
public:
    explicit ParserBase(QPlaylistFileParser *parent)
        : m_parent(parent)
        , m_aborted(false)
    {
        Q_ASSERT(m_parent);
    }

    bool parseLine(int lineIndex, const QString& line, const QUrl& root)
    {
        if (m_aborted)
            return false;

        const bool ok = parseLineImpl(lineIndex, line, root);
        return ok && !m_aborted;
    }

    virtual void abort() { m_aborted = true; }
    virtual ~ParserBase() = default;

protected:
    virtual bool parseLineImpl(int lineIndex, const QString& line, const QUrl& root) = 0;

    static QUrl expandToFullPath(const QUrl &root, const QString &line)
    {
        // On Linux, backslashes are not converted to forward slashes :/
        if (line.startsWith(QLatin1String("//")) || line.startsWith(QLatin1String("\\\\"))) {
            // Network share paths are not resolved
            return QUrl::fromLocalFile(line);
        }

        QUrl url(line);
        if (url.scheme().isEmpty()) {
            // Resolve it relative to root
            if (root.isLocalFile())
                return QUrl::fromUserInput(line, root.adjusted(QUrl::RemoveFilename).toLocalFile(), QUrl::AssumeLocalFile);
            return root.resolved(url);
        }
        if (url.scheme().length() == 1)
            // Assume it's a drive letter for a Windows path
            url = QUrl::fromLocalFile(line);

        return url;
    }

    void newItemFound(const QVariant& content) { Q_EMIT m_parent->newItem(content); }


    QPlaylistFileParser *m_parent;
    bool m_aborted;
};

class M3UParser : public ParserBase
{
public:
    explicit M3UParser(QPlaylistFileParser *q)
        : ParserBase(q)
        , m_extendedFormat(false)
    {
    }

    /*
     *
    Extended M3U directives

    #EXTM3U - header - must be first line of file
    #EXTINF - extra info - length (seconds), title
    #EXTINF - extra info - length (seconds), artist '-' title

    Example

    #EXTM3U
    #EXTINF:123, Sample artist - Sample title
    C:\Documents and Settings\I\My Music\Sample.mp3
    #EXTINF:321,Example Artist - Example title
    C:\Documents and Settings\I\My Music\Greatest Hits\Example.ogg

     */
    bool parseLineImpl(int lineIndex, const QString& line, const QUrl& root) override
    {
        if (line[0] == u'#' ) {
            if (m_extendedFormat) {
                if (line.startsWith(QLatin1String("#EXTINF:"))) {
                    m_extraInfo.clear();
                    int artistStart = line.indexOf(QLatin1String(","), 8);
                    bool ok = false;
                    QStringView lineView { line };
                    int length = lineView.mid(8, artistStart < 8 ? -1 : artistStart - 8).trimmed().toInt(&ok);
                    if (ok && length > 0) {
                        //convert from second to milisecond
                        m_extraInfo[QMediaMetaData::Duration] = QVariant(length * 1000);
                    }
                    if (artistStart > 0) {
                        int titleStart = getSplitIndex(line, artistStart);
                        if (titleStart > artistStart) {
                            m_extraInfo[QMediaMetaData::Author] = lineView.mid(artistStart + 1,
                                                             titleStart - artistStart - 1).trimmed().toString().
                                                             replace(QLatin1String("--"), QLatin1String("-"));
                            m_extraInfo[QMediaMetaData::Title] = lineView.mid(titleStart + 1).trimmed().toString().
                                                   replace(QLatin1String("--"), QLatin1String("-"));
                        } else {
                            m_extraInfo[QMediaMetaData::Title] = lineView.mid(artistStart + 1).trimmed().toString().
                                                   replace(QLatin1String("--"), QLatin1String("-"));
                        }
                    }
                }
            } else if (lineIndex == 0 && line.startsWith(QLatin1String("#EXTM3U"))) {
                m_extendedFormat = true;
            }
        } else {
            QUrl url = expandToFullPath(root, line);
            m_extraInfo[QMediaMetaData::Url] = url;
            m_parent->playlist.append(url);
            newItemFound(QVariant::fromValue(m_extraInfo));
            m_extraInfo.clear();
        }

        return true;
    }

    int getSplitIndex(const QString& line, int startPos)
    {
        if (startPos < 0)
            startPos = 0;
        const QChar* buf = line.data();
        for (int i = startPos; i < line.length(); ++i) {
            if (buf[i] == u'-') {
                if (i == line.length() - 1)
                    return i;
                ++i;
                if (buf[i] != u'-')
                    return i - 1;
            }
        }
        return -1;
    }

private:
    QMediaMetaData  m_extraInfo;
    bool            m_extendedFormat;
};

class PLSParser : public ParserBase
{
public:
    explicit PLSParser(QPlaylistFileParser *q)
        : ParserBase(q)
    {
    }

/*
 *
The format is essentially that of an INI file structured as follows:

Header

    * [playlist] : This tag indicates that it is a Playlist File

Track Entry
Assuming track entry #X

    * FileX : Variable defining location of stream.
    * TitleX : Defines track title.
    * LengthX : Length in seconds of track. Value of -1 indicates indefinite.

Footer

    * NumberOfEntries : This variable indicates the number of tracks.
    * Version : Playlist version. Currently only a value of 2 is valid.

[playlist]

File1=Alternative\everclear - SMFTA.mp3

Title1=Everclear - So Much For The Afterglow

Length1=233

File2=http://www.site.com:8000/listen.pls

Title2=My Cool Stream

Length5=-1

NumberOfEntries=2

Version=2
*/
    bool parseLineImpl(int, const QString &line, const QUrl &root) override
    {
        // We ignore everything but 'File' entries, since that's the only thing we care about.
        if (!line.startsWith(QLatin1String("File")))
            return true;

        QString value = getValue(line);
        if (value.isEmpty())
            return true;

        QUrl path = expandToFullPath(root, value);
        m_parent->playlist.append(path);
        newItemFound(path);

        return true;
    }

    QString getValue(QStringView line) {
        int start = line.indexOf(u'=');
        if (start < 0)
            return QString();
        return line.mid(start + 1).trimmed().toString();
    }
};
}

/////////////////////////////////////////////////////////////////////////////////////////////////

class QPlaylistFileParserPrivate
{
    Q_DECLARE_PUBLIC(QPlaylistFileParser)
public:
    QPlaylistFileParserPrivate(QPlaylistFileParser *q)
        : q_ptr(q)
        , m_stream(nullptr)
        , m_type(QPlaylistFileParser::UNKNOWN)
        , m_scanIndex(0)
        , m_lineIndex(-1)
        , m_utf8(false)
        , m_aborted(false)
    {
    }

    void handleData();
    void handleParserFinished();
    void abort();
    void reset();

    QScopedPointer<QNetworkReply, QScopedPointerDeleteLater> m_source;
    QScopedPointer<ParserBase> m_currentParser;
    QByteArray      m_buffer;
    QUrl            m_root;
    QNetworkAccessManager m_mgr;
    QString m_mimeType;
    QPlaylistFileParser *q_ptr;
    QPointer<QIODevice> m_stream;
    QPlaylistFileParser::FileType m_type;
    struct ParserJob
    {
        QIODevice *m_stream;
        QUrl m_media;
        QString m_mimeType;
        [[nodiscard]] bool isValid() const { return m_stream || !m_media.isEmpty(); }
        void reset() { m_stream = nullptr; m_media = QUrl(); m_mimeType = QString(); }
    } m_pendingJob;
    int m_scanIndex;
    int m_lineIndex;
    bool m_utf8;
    bool m_aborted;

private:
    bool processLine(int startIndex, int length);
};

#define LINE_LIMIT  4096
#define READ_LIMIT  64

bool QPlaylistFileParserPrivate::processLine(int startIndex, int length)
{
    Q_Q(QPlaylistFileParser);
    m_lineIndex++;

    if (!m_currentParser) {
        const QString urlString = m_root.toString();
        const QString &suffix = !urlString.isEmpty() ? QFileInfo(urlString).suffix() : urlString;
        QString mimeType;
        if (m_source)
            mimeType = m_source->header(QNetworkRequest::ContentTypeHeader).toString();
        m_type = QPlaylistFileParser::findPlaylistType(suffix, !mimeType.isEmpty() ?  mimeType : m_mimeType, m_buffer.constData(), quint32(m_buffer.size()));

        switch (m_type) {
        case QPlaylistFileParser::UNKNOWN:
            emit q->error(QMediaPlaylist::FormatError,
                          QMediaPlaylist::tr("%1 playlist type is unknown").arg(m_root.toString()));
            q->abort();
            return false;
        case QPlaylistFileParser::M3U:
            m_currentParser.reset(new M3UParser(q));
            break;
        case QPlaylistFileParser::M3U8:
            m_currentParser.reset(new M3UParser(q));
            m_utf8 = true;
            break;
        case QPlaylistFileParser::PLS:
            m_currentParser.reset(new PLSParser(q));
            break;
        }

        Q_ASSERT(!m_currentParser.isNull());
    }

    QString line;

    if (m_utf8) {
        line = QString::fromUtf8(m_buffer.constData() + startIndex, length).trimmed();
    } else {
        line = QString::fromLatin1(m_buffer.constData() + startIndex, length).trimmed();
    }
    if (line.isEmpty())
        return true;

    Q_ASSERT(m_currentParser);
    return m_currentParser->parseLine(m_lineIndex, line, m_root);
}

void QPlaylistFileParserPrivate::handleData()
{
    Q_Q(QPlaylistFileParser);
    while (m_stream->bytesAvailable() && !m_aborted) {
        int expectedBytes = qMin(READ_LIMIT, int(qMin(m_stream->bytesAvailable(),
                                                      qint64(LINE_LIMIT - m_buffer.size()))));
        m_buffer.push_back(m_stream->read(expectedBytes));
        int processedBytes = 0;
        while (m_scanIndex < m_buffer.length() && !m_aborted) {
            char s = m_buffer[m_scanIndex];
            if (s == '\r' || s == '\n') {
                int l = m_scanIndex - processedBytes;
                if (l > 0) {
                    if (!processLine(processedBytes, l))
                        break;
                }
                processedBytes = m_scanIndex + 1;
                if (!m_stream) {
                    //some error happened, so exit parsing
                    return;
                }
            }
            m_scanIndex++;
        }

        if (m_aborted)
            break;

        if (m_buffer.length() - processedBytes >= LINE_LIMIT) {
            emit q->error(QMediaPlaylist::FormatError, QMediaPlaylist::tr("invalid line in playlist file"));
            q->abort();
            break;
        }

        if (!m_stream->bytesAvailable() && (!m_source || !m_source->isFinished())) {
            //last line
            processLine(processedBytes, -1);
            break;
        }

        Q_ASSERT(m_buffer.length() == m_scanIndex);
        if (processedBytes == 0)
            continue;

        int copyLength = m_buffer.length() - processedBytes;
        if (copyLength > 0) {
            Q_ASSERT(copyLength <= READ_LIMIT);
            m_buffer = m_buffer.right(copyLength);
        } else {
            m_buffer.clear();
        }
        m_scanIndex = 0;
    }

    handleParserFinished();
}

QPlaylistFileParser::QPlaylistFileParser(QObject *parent)
    : QObject(parent)
    , d_ptr(new QPlaylistFileParserPrivate(this))
{

}

QPlaylistFileParser::~QPlaylistFileParser() = default;

QPlaylistFileParser::FileType QPlaylistFileParser::findByMimeType(const QString &mime)
{
    if (mime == QLatin1String("text/uri-list") || mime == QLatin1String("audio/x-mpegurl") || mime == QLatin1String("audio/mpegurl"))
        return QPlaylistFileParser::M3U;

    if (mime == QLatin1String("application/x-mpegURL") || mime == QLatin1String("application/vnd.apple.mpegurl"))
        return QPlaylistFileParser::M3U8;

    if (mime == QLatin1String("audio/x-scpls"))
        return QPlaylistFileParser::PLS;

    return QPlaylistFileParser::UNKNOWN;
}

QPlaylistFileParser::FileType QPlaylistFileParser::findBySuffixType(const QString &suffix)
{
    const QString &s = suffix.toLower();

    if (s == QLatin1String("m3u"))
        return QPlaylistFileParser::M3U;

    if (s == QLatin1String("m3u8"))
        return QPlaylistFileParser::M3U8;

    if (s == QLatin1String("pls"))
        return QPlaylistFileParser::PLS;

    return QPlaylistFileParser::UNKNOWN;
}

QPlaylistFileParser::FileType QPlaylistFileParser::findByDataHeader(const char *data, quint32 size)
{
    if (!data || size == 0)
        return QPlaylistFileParser::UNKNOWN;

    if (size >= 7 && strncmp(data, "#EXTM3U", 7) == 0)
        return QPlaylistFileParser::M3U;

    if (size >= 10 && strncmp(data, "[playlist]", 10) == 0)
        return QPlaylistFileParser::PLS;

    return QPlaylistFileParser::UNKNOWN;
}

QPlaylistFileParser::FileType QPlaylistFileParser::findPlaylistType(const QString& suffix,
                                                                    const QString& mime,
                                                                    const char *data,
                                                                    quint32 size)
{

    FileType dataHeaderType = findByDataHeader(data, size);
    if (dataHeaderType != UNKNOWN)
        return dataHeaderType;

    FileType mimeType = findByMimeType(mime);
    if (mimeType != UNKNOWN)
        return mimeType;

    mimeType = findBySuffixType(mime);
    if (mimeType != UNKNOWN)
        return mimeType;

    FileType suffixType = findBySuffixType(suffix);
    if (suffixType != UNKNOWN)
        return suffixType;

    return UNKNOWN;
}

/*
 * Delegating
 */
void QPlaylistFileParser::start(const QUrl &media, QIODevice *stream, const QString &mimeType)
{
    if (stream)
        start(stream, mimeType);
    else
        start(media, mimeType);
}

void QPlaylistFileParser::start(QIODevice *stream, const QString &mimeType)
{
    Q_D(QPlaylistFileParser);
    const bool validStream = stream ? (stream->isOpen() && stream->isReadable()) : false;

    if (!validStream) {
        Q_EMIT error(QMediaPlaylist::AccessDeniedError, QMediaPlaylist::tr("Invalid stream"));
        return;
    }

    if (!d->m_currentParser.isNull()) {
        abort();
        d->m_pendingJob = { stream, QUrl(), mimeType };
        return;
    }

    playlist.clear();
    d->reset();
    d->m_mimeType = mimeType;
    d->m_stream = stream;
    connect(d->m_stream, SIGNAL(readyRead()), this, SLOT(handleData()));
    d->handleData();
}

void QPlaylistFileParser::start(const QUrl& request, const QString &mimeType)
{
    Q_D(QPlaylistFileParser);
    const QUrl &url = request.url();

    if (url.isLocalFile() && !QFile::exists(url.toLocalFile())) {
        emit error(QMediaPlaylist::AccessDeniedError, QString(QMediaPlaylist::tr("%1 does not exist")).arg(url.toString()));
        return;
    }

    if (!d->m_currentParser.isNull()) {
        abort();
        d->m_pendingJob = { nullptr, request, mimeType };
        return;
    }

    d->reset();
    d->m_root = url;
    d->m_mimeType = mimeType;
    d->m_source.reset(d->m_mgr.get(QNetworkRequest(request)));
    d->m_stream = d->m_source.get();
    connect(d->m_source.data(), SIGNAL(readyRead()), this, SLOT(handleData()));
    connect(d->m_source.data(), SIGNAL(finished()), this, SLOT(handleData()));
    connect(d->m_source.data(), SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, SLOT(handleError()));

    if (url.isLocalFile())
        d->handleData();
}

void QPlaylistFileParser::abort()
{
    Q_D(QPlaylistFileParser);
    d->abort();

    if (d->m_source)
        d->m_source->disconnect();

    if (d->m_stream)
        disconnect(d->m_stream, SIGNAL(readyRead()), this, SLOT(handleData()));

    playlist.clear();
}

void QPlaylistFileParser::handleData()
{
    Q_D(QPlaylistFileParser);
    d->handleData();
}

void QPlaylistFileParserPrivate::handleParserFinished()
{
    Q_Q(QPlaylistFileParser);
    const bool isParserValid = !m_currentParser.isNull();
    if (!isParserValid && !m_aborted)
        emit q->error(QMediaPlaylist::FormatNotSupportedError, QMediaPlaylist::tr("Empty file provided"));

    if (isParserValid && !m_aborted) {
        m_currentParser.reset();
        emit q->finished();
    }

    if (!m_aborted)
        q->abort();

    if (!m_source.isNull())
        m_source.reset();

    if (m_pendingJob.isValid())
        q->start(m_pendingJob.m_media, m_pendingJob.m_stream, m_pendingJob.m_mimeType);
}

void QPlaylistFileParserPrivate::abort()
{
    m_aborted = true;
    if (!m_currentParser.isNull())
        m_currentParser->abort();
}

void QPlaylistFileParserPrivate::reset()
{
    Q_ASSERT(m_currentParser.isNull());
    Q_ASSERT(m_source.isNull());
    m_buffer.clear();
    m_root.clear();
    m_mimeType.clear();
    m_stream = nullptr;
    m_type = QPlaylistFileParser::UNKNOWN;
    m_scanIndex = 0;
    m_lineIndex = -1;
    m_utf8 = false;
    m_aborted = false;
    m_pendingJob.reset();
}

void QPlaylistFileParser::handleError()
{
    Q_D(QPlaylistFileParser);
    const QString &errorString = d->m_source->errorString();
    Q_EMIT error(QMediaPlaylist::NetworkError, errorString);
    abort();
}

QT_END_NAMESPACE
