/******************************************************************************\
 * Copyright (c) 2020
 *
 * Author(s):
 *  pljones
 *
 ******************************************************************************
 *
 * This program is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 2 of the License, or (at your option) any later
 * version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
 * details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 *
\******************************************************************************/

#include "jamrecorder.h"

using namespace recorder;

/* ********************************************************************************************************
 * CJamClient
 * ********************************************************************************************************/

/**
 * @brief CJamClient::CJamClient
 * @param frame Start frame of the client within the session
 * @param numChannels 1 for mono, 2 for stereo
 * @param name The client's current name
 * @param address IP and Port
 * @param recordBaseDir Session recording directory
 *
 * Creates a file for the raw PCM data and sets up a QDataStream to which to write received frames.
 * The data is stored Little Endian.
 */
CJamClient::CJamClient(const qint64 frame, const int _numChannels, const QString name, const CHostAddress address, const QDir recordBaseDir) :
    startFrame (frame),
    numChannels (static_cast<uint16_t>(_numChannels)),
    name (name),
    address (address)
{
    // At this point we may not have much of a name
    QString fileName = ClientName() + "-" + QString::number(frame) + "-" + QString::number(_numChannels);
    QString affix = "";
    while (recordBaseDir.exists(fileName + affix + ".wav"))
    {
        affix = affix.length() == 0 ? "_1" : "_" + QString::number(affix.remove(0, 1).toInt() + 1);
    }
    fileName = fileName + affix + ".wav";

    wavFile = new QFile(recordBaseDir.absoluteFilePath(fileName));
    if (!wavFile->open(QFile::OpenMode(QIODevice::OpenModeFlag::ReadWrite))) // need to allow rewriting headers
    {
        throw new std::runtime_error( ("Could not write to WAV file "  + wavFile->fileName()).toStdString() );
    }
    out = new CWaveStream(wavFile, numChannels);

    filename = wavFile->fileName();
}

/**
 * @brief CJamClient::Frame Handle a frame of PCM data from a client connected to the server
 * @param _name The client's current name
 * @param pcm The PCM data
 */
void CJamClient::Frame(const QString _name, const CVector<int16_t>& pcm, int iServerFrameSizeSamples)
{
    name = _name;

    for(int i = 0; i < numChannels * iServerFrameSizeSamples; i++)
    {
        *out << pcm[i];
    }

    frameCount++;
}

/**
 * @brief CJamClient::Disconnect Clean up after a disconnected client
 */
void CJamClient::Disconnect()
{
    static_cast<CWaveStream*>(out)->finalise();
    out = nullptr;

    wavFile->close();

    delete wavFile;
    wavFile = nullptr;
}

/* ********************************************************************************************************
 * CJamSession
 * ********************************************************************************************************/

/**
 * @brief CJamSession::CJamSession Construct a new jam recording session
 * @param recordBaseDir The recording base directory
 *
 * Each session is stored into its own subdirectory of the recording base directory.
 */
CJamSession::CJamSession(QDir recordBaseDir) :
    sessionDir (QDir(recordBaseDir.absoluteFilePath("Jam-" + QDateTime().currentDateTimeUtc().toString("yyyyMMdd-HHmmsszzz")))),
    currentFrame (0),
    chIdDisconnected (-1),
    vecptrJamClients (MAX_NUM_CHANNELS),
    jamClientConnections()
{
    QFileInfo fi(sessionDir.absolutePath());
    fi.setCaching(false);

    if (!fi.exists() && !QDir().mkpath(sessionDir.absolutePath()))
    {
        throw std::runtime_error( (sessionDir.absolutePath() + " does not exist but could not be created").toStdString() );
    }
    if (!fi.isDir())
    {
        throw std::runtime_error( (sessionDir.absolutePath() + " exists but is not a directory").toStdString() );
    }
    if (!fi.isWritable())
    {
        throw std::runtime_error( (sessionDir.absolutePath() + " is a directory but cannot be written to").toStdString() );
    }

    // Explicitly set all the pointers to "empty"
    vecptrJamClients.fill(nullptr);
}

/**
 * @brief CJamSession::DisconnectClient Capture details of the departing client's connection
 * @param iChID the channel id of the client that disconnected
 */
void CJamSession::DisconnectClient(int iChID)
{
    vecptrJamClients[iChID]->Disconnect();

    jamClientConnections.append(new CJamClientConnection(vecptrJamClients[iChID]->NumAudioChannels(),
                                                         vecptrJamClients[iChID]->StartFrame(),
                                                         vecptrJamClients[iChID]->FrameCount(),
                                                         vecptrJamClients[iChID]->ClientName(),
                                                         vecptrJamClients[iChID]->FileName()));

    delete vecptrJamClients[iChID];
    vecptrJamClients[iChID] = nullptr;
    chIdDisconnected = iChID;
}

/**
 * @brief CJamSession::Frame Process a frame emitted for a client by the server
 * @param iChID the client channel id
 * @param name the client name
 * @param address the client IP and port number
 * @param numAudioChannels the client number of audio channels
 * @param data the frame data
 *
 * Manages changes that affect how the recording is stored - i.e. if the number of audio channels changes, we need a new file.
 * Files are grouped by IP and port number, so if either of those change for a connection, we also start a new file.
 *
 * Also manages the overall current frame counter for the session.
 */
void CJamSession::Frame(const int iChID, const QString name, const CHostAddress address, const int numAudioChannels, const CVector<int16_t> data, int iServerFrameSizeSamples)
{
    if ( iChID == chIdDisconnected )
    {
        // DisconnectClient has just been called for this channel - this frame is "too late"
        chIdDisconnected = -1;
        return;
    }

    if (vecptrJamClients[iChID] == nullptr)
    {
        // then we have not seen this client this session
        vecptrJamClients[iChID] = new CJamClient(currentFrame, numAudioChannels, name, address, sessionDir);
    }
    else if (numAudioChannels != vecptrJamClients[iChID]->NumAudioChannels()
             || address.InetAddr != vecptrJamClients[iChID]->ClientAddress().InetAddr
             || address.iPort != vecptrJamClients[iChID]->ClientAddress().iPort)
    {
        DisconnectClient(iChID);
        if (numAudioChannels == 0)
        {
            vecptrJamClients[iChID] = nullptr;
        }
        else
        {
            vecptrJamClients[iChID] = new CJamClient(currentFrame, numAudioChannels, name, address, sessionDir);
        }
    }

    if (vecptrJamClients[iChID] == nullptr)
    {
        // Frame allegedly from iChID but unable to establish client details
        return;
    }

    vecptrJamClients[iChID]->Frame(name, data, iServerFrameSizeSamples);

    // If _any_ connected client frame steps past currentFrame, increase currentFrame
    if (vecptrJamClients[iChID]->StartFrame() + vecptrJamClients[iChID]->FrameCount() > currentFrame)
    {
        currentFrame++;
    }
}

/**
 * @brief CJamSession::End Clean up any "hanging" clients when the server thinks they all left
 */
void CJamSession::End()
{
    for (int iChID = 0; iChID < vecptrJamClients.size(); iChID++)
    {
        if (vecptrJamClients[iChID] != nullptr)
        {
            DisconnectClient(iChID);
            vecptrJamClients[iChID] = nullptr;
        }
    }
}

/**
 * @brief CJamSession::Tracks Retrieve a map of (latest) client name to connection items
 * @return a map of (latest) client name to connection items
 */
QMap<QString, QList<STrackItem>> CJamSession::Tracks()
{
    QMap<QString, QList<STrackItem>> tracks;

    for (int i = 0; i < jamClientConnections.count(); i++ )
    {
        STrackItem track (
            jamClientConnections[i]->Format(),
            jamClientConnections[i]->StartFrame(),
            jamClientConnections[i]->Length(),
            jamClientConnections[i]->FileName()
        );

        if (!tracks.contains(jamClientConnections[i]->Name()))
        {
            tracks.insert(jamClientConnections[i]->Name(), { });
        }

        tracks[jamClientConnections[i]->Name()].append(track);
    }

    return tracks;
}

/**
 * @brief CJamSession::TracksFromSessionDir Replica of CJamSession::Tracks but using the directory contents to construct the track item map
 * @param sessionDirName the directory name to scan
 * @return a map of (latest) client name to connection items
 */
QMap<QString, QList<STrackItem>> CJamSession::TracksFromSessionDir(const QString& sessionDirName, int iServerFrameSizeSamples)
{
    QMap<QString, QList<STrackItem>> tracks;

    const QDir sessionDir(sessionDirName);
    foreach(auto entry, sessionDir.entryList({ "*.pcm" }))
    {

        auto split = entry.split(".")[0].split("-");
        QString name = split[0];
        QString hostPort = split[1];
        QString frame = split[2];
        QString tail = split[3]; //numChannels may have _nnn
        QString numChannels = tail.count("_") > 0 ? tail.split("_")[0] : tail;

        QString trackName = name + "-" + hostPort;
        if (!tracks.contains(trackName))
        {
            tracks.insert(trackName, { });
        }

        QFileInfo fiEntry(sessionDir.absoluteFilePath(entry));
        qint64 length = fiEntry.size() / numChannels.toInt() / iServerFrameSizeSamples;

        STrackItem track (
                    numChannels.toInt(),
                    frame.toLongLong(),
                    length,
                    sessionDir.absoluteFilePath(entry)
                    );

        tracks[trackName].append(track);
    }

    return tracks;
}

/* ********************************************************************************************************
 * CJamRecorder
 * ********************************************************************************************************/

/**
 * @brief CJamRecorder::Init Create recording directory, if necessary, and connect signal handlers
 * @param server Server object emitting signals
 * @return QString::null on success else the failure reason
 */
QString CJamRecorder::Init()
{
    QString errmsg = QString::null;
    QFileInfo fi ( recordBaseDir.absolutePath() );
    fi.setCaching ( false );

    if ( !fi.exists() && !QDir().mkpath ( recordBaseDir.absolutePath() ) )
    {
        errmsg = recordBaseDir.absolutePath() + " does not exist but could not be created";
#if QT_VERSION >= QT_VERSION_CHECK(5, 5, 0)
// TODO we should use the ConsoleWriterFactory() instead of qCritical()
        qCritical() << errmsg;
#endif
        return errmsg;
    }
    if (!fi.isDir())
    {
        errmsg = recordBaseDir.absolutePath() + " exists but is not a directory";
#if QT_VERSION >= QT_VERSION_CHECK(5, 5, 0)
// TODO we should use the ConsoleWriterFactory() instead of qCritical()
        qCritical() << errmsg;
#endif
        return errmsg;
    }
    if (!fi.isWritable())
    {
        errmsg = recordBaseDir.absolutePath() + " is a directory but cannot be written to";
#if QT_VERSION >= QT_VERSION_CHECK(5, 5, 0)
// TODO we should use the ConsoleWriterFactory() instead of qCritical()
        qCritical() << errmsg;
#endif
        return errmsg;
    }

    return errmsg;
}

/**
 * @brief CJamRecorder::Start Start up tasks for a new session
 */
void CJamRecorder::Start() {
    // Ensure any previous cleaning up has been done.
    OnEnd();

    // needs to be after OnEnd() as that also locks
    ChIdMutex.lock();
    {
        currentSession = new CJamSession( recordBaseDir );
        isRecording = true;
    }
    ChIdMutex.unlock();

    emit RecordingSessionStarted ( currentSession->SessionDir().path() );
}


/**
 * @brief CJamRecorder::OnEnd Finalise the recording and write the Reaper RPP file
 */
void CJamRecorder::OnEnd()
{
    ChIdMutex.lock(); // iChId used in currentSession->End()
    {
        if ( isRecording )
        {
            isRecording = false;
            currentSession->End();

            ReaperProjectFromCurrentSession();
            AudacityLofFromCurrentSession();

            delete currentSession;
            currentSession = nullptr;
        }
    }
    ChIdMutex.unlock();
}


/**
 * @brief CJamRecorder::OnTriggerSession End one session and start a new one
 */
void CJamRecorder::OnTriggerSession()
{
    // This should magically get everything right...
    if ( isRecording )
    {
        Start();
    }
}

/**
 * @brief CJamRecorder::OnAboutToQuit End any recording and exit thread
 */
void CJamRecorder::OnAboutToQuit()
{
    OnEnd();

    QThread::currentThread()->exit();
}

void CJamRecorder::ReaperProjectFromCurrentSession()
{
    QString reaperProjectFileName = currentSession->SessionDir().filePath(currentSession->Name().append(".rpp"));
    const QFileInfo fi(reaperProjectFileName);

    if (fi.exists())
    {
        qWarning() << "CJamRecorder::ReaperProjectFromCurrentSession():" << fi.absolutePath() << "exists and will not be overwritten.";
    }
    else
    {
        QFile outf (reaperProjectFileName);
        if ( outf.open(QFile::WriteOnly) )
        {
            QTextStream out(&outf);
            out << CReaperProject( currentSession->Tracks(), iServerFrameSizeSamples ).toString() << endl;
            qDebug() << "Session RPP:" << reaperProjectFileName;
        }
        else
        {
            qWarning() << "CJamRecorder::ReaperProjectFromCurrentSession():" << fi.absolutePath() << "could not be created, no RPP written.";
        }
    }
}

void CJamRecorder::AudacityLofFromCurrentSession()
{
    QString audacityLofFileName = currentSession->SessionDir().filePath(currentSession->Name().append(".lof"));
    const QFileInfo fi(audacityLofFileName);

    if (fi.exists())
    {
        qWarning() << "CJamRecorder::AudacityLofFromCurrentSession():" << fi.absolutePath() << "exists and will not be overwritten.";
    }
    else
    {
        QFile outf (audacityLofFileName);
        if ( outf.open(QFile::WriteOnly) )
        {
            QTextStream sOut(&outf);

            foreach ( auto trackName, currentSession->Tracks().keys() )
            {
                foreach ( auto item, currentSession->Tracks()[trackName] ) {
                    QFileInfo fi ( item.fileName );
                    sOut << "file " << '"' << fi.fileName() << '"';
                    sOut << " offset " << secondsAt48K( item.startFrame, iServerFrameSizeSamples ) << endl;
                }
            }

            sOut.flush();
            qDebug() << "Session LOF:" << audacityLofFileName;
        }
        else
        {
            qWarning() << "CJamRecorder::AudacityLofFromCurrentSession():" << fi.absolutePath() << "could not be created, no LOF written.";
        }
    }
}

/**
 * @brief CJamRecorder::SessionDirToReaper Replica of CJamRecorder::OnEnd() but using the directory contents to construct the CReaperProject object
 * @param strSessionDirName
 */
void CJamRecorder::SessionDirToReaper(QString& strSessionDirName, int serverFrameSizeSamples)
{
    const QFileInfo fiSessionDir(QDir::cleanPath(strSessionDirName));
    if (!fiSessionDir.exists() || !fiSessionDir.isDir())
    {
        throw std::runtime_error( (fiSessionDir.absoluteFilePath() + " does not exist or is not a directory.  Aborting.").toStdString() );
    }

    const QDir dSessionDir(fiSessionDir.absoluteFilePath());
    const QString reaperProjectFileName = dSessionDir.absoluteFilePath(fiSessionDir.baseName().append(".rpp"));
    const QFileInfo fiRPP(reaperProjectFileName);
    if (fiRPP.exists())
    {
        throw std::runtime_error( (fiRPP.absoluteFilePath() + " exists and will not be overwritten.  Aborting.").toStdString() );
    }

    QFile outf (fiRPP.absoluteFilePath());
    if (!outf.open(QFile::WriteOnly)) {
        throw std::runtime_error( (fiRPP.absoluteFilePath() + " could not be written.  Aborting.").toStdString() );
    }
    QTextStream out(&outf);

    out << CReaperProject( CJamSession::TracksFromSessionDir( fiSessionDir.absoluteFilePath(), serverFrameSizeSamples ), serverFrameSizeSamples ).toString() << endl;

    qDebug() << "Session RPP:" << reaperProjectFileName;
}

/**
 * @brief CJamRecorder::OnDisconnected Handle disconnection of a client
 * @param iChID the client channel id
 */
void CJamRecorder::OnDisconnected(int iChID)
{
    ChIdMutex.lock();
    {
        if ( !isRecording )
        {
            qWarning() << "CJamRecorder::OnDisconnected: channel" << iChID << "disconnected but not recording";
        }
        if ( currentSession == nullptr )
        {
            qWarning() << "CJamRecorder::OnDisconnected: channel" << iChID << "disconnected but no currentSession";
            return;
        }

        currentSession->DisconnectClient ( iChID );
    }
    ChIdMutex.unlock();
}

/**
 * @brief CJamRecorder::OnFrame Handle a frame emitted for a client by the server
 * @param iChID the client channel id
 * @param name the client name
 * @param address the client IP and port number
 * @param numAudioChannels the client number of audio channels
 * @param data the frame data
 *
 * Ensures recording has started.
 */
void CJamRecorder::OnFrame(const int iChID, const QString name, const CHostAddress address, const int numAudioChannels, const CVector<int16_t> data)
{
    // Make sure we are ready
    if ( !isRecording )
    {
        Start();
    }

    // needs to be after Start() as that also locks
    ChIdMutex.lock();
    {
        currentSession->Frame ( iChID, name, address, numAudioChannels, data, iServerFrameSizeSamples );
    }
    ChIdMutex.unlock();
}
