/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright 2013 - 2020, nymea GmbH
* Contact: contact@nymea.io
*
* This file is part of nymea.
* This project including source code and documentation is protected by
* copyright law, and remains the property of nymea GmbH. All rights, including
* reproduction, publication, editing and translation, are reserved. The use of
* this project is subject to the terms of a license agreement to be concluded
* with nymea GmbH in accordance with the terms of use of nymea GmbH, available
* under https://nymea.io/license
*
* For any further details and any questions please contact us under
* contact@nymea.io or see our FAQ/Licensing Information on
* https://nymea.io/license/faq
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */

#include "integrationpluginstreamunlimited.h"
#include "streamunlimiteddevice.h"
#include "artworkcolorprovider.h"

#include <platform/platformzeroconfcontroller.h>
#include <network/zeroconf/zeroconfservicebrowser.h>
#include <network/networkaccessmanager.h>
#include <types/browseritem.h>
#include <loggingcategories.h>

#include <QDebug>
#include <QNetworkInterface>
#include <QJsonDocument>
#include <QRegularExpression>

NYMEA_LOGGING_CATEGORY(dcStreamUnlimited, "StreamUnlimited")


IntegrationPluginStreamUnlimited::IntegrationPluginStreamUnlimited(QHash<QString, QUuid> ids):
    m_ids(ids)
{
}

void IntegrationPluginStreamUnlimited::init()
{
    setupZeroConfBrowser("_sues800device._tcp");
}

void IntegrationPluginStreamUnlimited::startMonitoringAutoThings()
{
    QList<ZeroConfServiceEntry> allEntries = m_zeroConfBrowser->serviceEntries();

    foreach (const ZeroConfServiceEntry &entry, allEntries) {

        QString id = entry.txt("uuid");
        QString name = entry.txt("name");
        QString model = entry.txt("model");
        QString manufacturer = entry.txt("manufacturer");
        QHostAddress address = entry.hostAddress();
        if (!filterZeroConfEntry(entry)) {
//            qCDebug(dcStreamUnlimited()) << "Skipping device" << id << name << model << manufacturer << address;
            continue;
        }

        ParamList params;
        params << Param(m_ids.value("idParamTypeId"), id);


        if (isLocalStreamSDK(entry.hostAddress()) && myThings().findByParams(params) == nullptr) {
            ThingDescriptor descriptor(m_ids.value("thingClassId"), name, entry.hostAddress().toString());
            descriptor.setParams(params);
            emit autoThingsAppeared({descriptor});
        }
    }
}

void IntegrationPluginStreamUnlimited::discoverThings(ThingDiscoveryInfo *info)
{
    qCDebug(dcStreamUnlimited()) << "Discovery" << m_zeroConfBrowser->serviceEntries().count();
    QHash<QString, ZeroConfServiceEntry> results;

    // Only use one result per host. preferrably the loopback one.
    QList<ZeroConfServiceEntry> allEntries = m_zeroConfBrowser->serviceEntries();
    foreach (const ZeroConfServiceEntry &entry, allEntries) {
        // Disabling IPv6 for now, it seems to be not really reliable with those devices
        // Additionally, older StreamSDK models (e.g. Citation) claim they're IPv6 while in reality they aren't
        // So let's check the actual address protocol instead of entry.protocol()
        if (entry.hostAddress().protocol() != QAbstractSocket::IPv4Protocol) {
            continue;
        }
        qCDebug(dcStreamUnlimited) << "Zeroconf entry:" << entry;
        QString id = entry.txt("uuid");

        if (!results.contains(id) || entry.hostAddress().isLoopback()) {
            results[id] = entry;
        }
    }

    // Now build the Discovery results
    foreach (const ZeroConfServiceEntry &entry, results) {

        QString id = entry.txt("uuid");
        QString name = entry.txt("name");
        QString model = entry.txt("model");
        QString manufacturer = entry.txt("manufacturer");
        QHostAddress address = entry.hostAddress();

        if (!filterZeroConfEntry(entry)) {
            qCDebug(dcStreamUnlimited()) << "Skipping device" << id << name << model << manufacturer << address;
            continue;
        }

        qCDebug(dcStreamUnlimited()) << "Found device:" << id << name << model << manufacturer << address;

        ParamList params;
        params << Param(m_ids.value("idParamTypeId"), id);

        ThingDescriptor descriptor(info->thingClassId(), name, model.isEmpty() ? manufacturer : model + " (" + manufacturer + ")");
        descriptor.setParams(params);

        foreach (Thing *thing, myThings()) {
            if (thing->paramValue(m_ids.value("idParamTypeId")).toString() == id) {
                descriptor.setThingId(thing->id());
                break;
            }
        }

        info->addThingDescriptor(descriptor);
    }

    info->finish(Thing::ThingErrorNoError);
}

void IntegrationPluginStreamUnlimited::setupThing(ThingSetupInfo *info)
{
    Thing *thing = info->thing();

    QString id = thing->paramValue(m_ids.value("idParamTypeId")).toString();
    qCDebug(dcStreamUnlimited()) << "Setting up StreamSDK with ID" << id;

    StreamUnlimitedDevice *device = createStreamUnlimitedDevice();
    m_devices.insert(thing, device);
    ArtworkColorProvider *artworkColorProvider = new ArtworkColorProvider(hardwareManager()->networkManager(), this);
    m_artworkColorProviders.insert(thing, artworkColorProvider);

    ZeroConfServiceEntry entry = findBestConnection(id);
    if (entry.isValid()) {
        qCDebug(dcStreamUnlimited()) << "Found StreamSDK with" << id << "on mDNS:" << entry.hostAddress() << entry.port();
        device->setHost(entry.hostAddress(), entry.port());
    } else if (pluginStorage()->childGroups().contains(id)) {
        pluginStorage()->beginGroup(id);
        QHostAddress address(pluginStorage()->value("address").toString());
        int port = pluginStorage()->value("port").toInt();
        pluginStorage()->endGroup();
        qCDebug(dcStreamUnlimited()) << "Could not find StreamSDK with" << id << "on mDNS. Cached address:" << address << port;
        device->setHost(address, port);
    } else {
        qCDebug(dcStreamUnlimited()) << "Could not find StreamSDK with" << id << "neither on mDNS nor in cache. Cannot connect at this point.";
    }

    connect(device, &StreamUnlimitedDevice::connectionStatusChanged, thing, [=](StreamUnlimitedDevice::ConnectionStatus status){
        thing->setStateValue(m_ids.value("connectedStateTypeId"), status == StreamUnlimitedDevice::ConnectionStatusConnected);
        // Cache address information on successful connect
        if (status == StreamUnlimitedDevice::ConnectionStatusConnected) {
            pluginStorage()->beginGroup(id);
            pluginStorage()->setValue("address", device->address().toString());
            pluginStorage()->setValue("port", device->port());
            pluginStorage()->endGroup();
        } else if (status == StreamUnlimitedDevice::ConnectionStatusDisconnected) {
            ZeroConfServiceEntry entry = findBestConnection(thing->paramValue(m_ids.value("idParamTypeId")).toString());
            if (entry.isValid()) {
                device->setHost(entry.hostAddress(), entry.port());
            }
        }
    });
    connect(device, &StreamUnlimitedDevice::playbackStatusChanged, thing, [this, thing](StreamUnlimitedDevice::PlayStatus state){
        QHash<StreamUnlimitedDevice::PlayStatus, QString> stateMap;
        stateMap.insert(StreamUnlimitedDevice::PlayStatusStopped, "Stopped");
        stateMap.insert(StreamUnlimitedDevice::PlayStatusPaused, "Paused");
        stateMap.insert(StreamUnlimitedDevice::PlayStatusPlaying, "Playing");
        thing->setStateValue(m_ids.value("playbackStatusStateTypeId"), stateMap.value(state));
    });
    connect(device, &StreamUnlimitedDevice::durationChanged, thing, [this, thing](uint duration){
        thing->setStateValue(m_ids.value("playDurationStateTypeId"), duration / 1000);
    });
    connect(device, &StreamUnlimitedDevice::playTimeChanged, thing, [this, thing](uint playTime){
        thing->setStateValue(m_ids.value("playTimeStateTypeId"), playTime / 1000);
    });
    connect(device, &StreamUnlimitedDevice::volumeChanged, thing, [this, thing](uint volume){
        thing->setStateValue(m_ids.value("volumeStateTypeId"), volume);
    });
    connect(device, &StreamUnlimitedDevice::muteChanged, thing, [this, thing](bool mute){
        thing->setStateValue(m_ids.value("muteStateTypeId"), mute);
    });
    connect(device, &StreamUnlimitedDevice::titleChanged, thing, [this, thing](const QString &title){
        thing->setStateValue(m_ids.value("titleStateTypeId"), title);
    });
    connect(device, &StreamUnlimitedDevice::artistChanged, thing, [this, thing](const QString &artist){
        thing->setStateValue(m_ids.value("artistStateTypeId"), artist);
    });
    connect(device, &StreamUnlimitedDevice::albumChanged, thing, [this, thing](const QString &album){
        thing->setStateValue(m_ids.value("collectionStateTypeId"), album);
    });
    connect(device, &StreamUnlimitedDevice::artworkChanged, thing, [this, thing](const QString &artwork){
        thing->setStateValue(m_ids.value("artworkStateTypeId"), artwork);
        m_artworkColorProviders.value(thing)->setArtworkUrl(artwork);
    });
    connect(device, &StreamUnlimitedDevice::shuffleChanged, thing, [this, thing](bool shuffle){
        thing->setStateValue(m_ids.value("shuffleStateTypeId"), shuffle);
    });
    connect(device, &StreamUnlimitedDevice::powerChanged, thing, [this, thing](bool power) {
        thing->setStateValue(m_ids.value("powerStateTypeId"), power);
    });
    connect(device, &StreamUnlimitedDevice::repeatChanged, thing, [this, thing](StreamUnlimitedDevice::Repeat repeat){
        QHash<StreamUnlimitedDevice::Repeat, QString> stateMap;
        stateMap.insert(StreamUnlimitedDevice::RepeatNone, "None");
        stateMap.insert(StreamUnlimitedDevice::RepeatOne, "One");
        stateMap.insert(StreamUnlimitedDevice::RepeatAll, "All");
        thing->setStateValue(m_ids.value("repeatStateTypeId"), stateMap.value(repeat));
    });

    info->finish(Thing::ThingErrorNoError);
}

void IntegrationPluginStreamUnlimited::executeAction(ThingActionInfo *info)
{
    StreamUnlimitedDevice *device = m_devices.value(info->thing());
    QUuid commandId;

    qCDebug(dcStreamUnlimited()) << "Execute action:" << info->action().actionTypeId();

    // Writable state actions
    if (info->action().actionTypeId() == m_ids.value("volumeStateTypeId")) {
        commandId = device->setVolume(info->action().param(m_ids.value("volumeStateTypeId")).value().toUInt());

    } else if (info->action().actionTypeId() == m_ids.value("muteStateTypeId")) {
        commandId = device->setMute(info->action().param(m_ids.value("muteStateTypeId")).value().toBool());

    } else if (info->action().actionTypeId() == m_ids.value("playTimeStateTypeId")) {
        commandId = device->setPlayTime(info->action().param(m_ids.value("playTimeStateTypeId")).value().toUInt() * 1000);

    } else if (info->action().actionTypeId() == m_ids.value("repeatStateTypeId")) {
        QString repeatString = info->action().param(m_ids.value("repeatStateTypeId")).value().toString();
        qCDebug(dcStreamUnlimited()) << "Repeat action:" << repeatString;
        QHash<StreamUnlimitedDevice::Repeat, QString> stateMap;
        stateMap.insert(StreamUnlimitedDevice::RepeatNone, "None");
        stateMap.insert(StreamUnlimitedDevice::RepeatOne, "One");
        stateMap.insert(StreamUnlimitedDevice::RepeatAll, "All");
        commandId = device->setRepeat(stateMap.key(repeatString));

    } else if (info->action().actionTypeId() == m_ids.value("shuffleStateTypeId")) {
        commandId = device->setShuffle(info->action().param(m_ids.value("shuffleStateTypeId")).value().toBool());

    } else if (info->action().actionTypeId() == m_ids.value("powerStateTypeId")) {
        commandId = device->setPower(info->action().param(m_ids.value("powerStateTypeId")).value().toBool());

    // Regular actions
    } else if (info->action().actionTypeId() == m_ids.value("playActionTypeId")) {
        commandId = device->play();

    } else if (info->action().actionTypeId() == m_ids.value("pauseActionTypeId")) {
        commandId = device->pause();

    } else if (info->action().actionTypeId() == m_ids.value("stopActionTypeId")) {
        commandId = device->stop();

    } else if (info->action().actionTypeId() == m_ids.value("skipBackActionTypeId")) {
        commandId = device->skipBack();

    } else if (info->action().actionTypeId() == m_ids.value("skipNextActionTypeId")) {
        commandId = device->skipNext();

    } else if (info->action().actionTypeId() == m_ids.value("increaseVolumeActionTypeId")) {
        int step = 5;
        if (info->action().param(m_ids.value("increaseVolumeActionStepParamTypeId")).isValid()) {
            step = info->action().param(m_ids.value("increaseVolumeActionStepParamTypeId")).value().toUInt();
        }
        commandId = device->setVolume(qMin<uint>(100, info->thing()->stateValue(m_ids.value("volumeStateTypeId")).toUInt() + step));

     } else if (info->action().actionTypeId() == m_ids.value("decreaseVolumeActionTypeId")) {
        int step = 5;
        if (info->action().param(m_ids.value("decreaseVolumeActionStepParamTypeId")).isValid()) {
            step = info->action().param(m_ids.value("decreaseVolumeActionStepParamTypeId")).value().toUInt();
        }
        commandId = device->setVolume(qMax<uint>(0, info->thing()->stateValue(m_ids.value("volumeStateTypeId")).toUInt() - step));
    }


    if (commandId.isNull()) {
        Q_ASSERT_X(false, "IntegrationPluginStreamUnlimited", "Unhandled action?");
        info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("An unexpected error happened."));
    }

    connect(device, &StreamUnlimitedDevice::commandCompleted, info, [=](const QUuid &replyCommandId, bool success){
        if (replyCommandId == commandId) {
            info->finish(success ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure);
        }
    });
}

void IntegrationPluginStreamUnlimited::thingRemoved(Thing *thing)
{
    m_devices.take(thing)->deleteLater();
    m_artworkColorProviders.take(thing)->deleteLater();
}

void IntegrationPluginStreamUnlimited::browseThing(BrowseResult *result)
{
    StreamUnlimitedDevice *device = m_devices.value(result->thing());

    if (device->language() != result->locale()) {
        qCDebug(dcStreamUnlimited()) << "Setting language on device:" << result->locale();

        QUuid commandId = device->setLocaleOnBoard(result->locale());
        connect(device, &StreamUnlimitedDevice::commandCompleted, result, [=](const QUuid &replyId){
            if (replyId != commandId) {
                return;
            }

            // We'll call browsing in any case, regardless if setLocale succeeded or not
            browseThingInternal(result);
        });

    } else {
        browseThingInternal(result);
    }
}

void IntegrationPluginStreamUnlimited::executeBrowserItem(BrowserActionInfo *info)
{
    StreamUnlimitedDevice *device = m_devices.value(info->thing());
    QUuid commandId = device->playBrowserItem(info->browserAction().itemId());
    connect(device, &StreamUnlimitedDevice::commandCompleted, info, [=](const QUuid &replyId, bool success){
        if (replyId != commandId) {
            return;
        }
        if (!success) {
            info->finish(Thing::ThingErrorHardwareFailure);
            return;
        }

        info->finish(Thing::ThingErrorNoError);
    });
}

void IntegrationPluginStreamUnlimited::executeBrowserItemAction(BrowserItemActionInfo *info)
{
    qCDebug(dcStreamUnlimited()) << "Executing browser item action:" << info->browserItemAction().actionTypeId() << info->browserItemAction().itemId();
    StreamUnlimitedDevice *device = m_devices.value(info->thing());
    QUuid commandId = device->executeContextMenu(info->browserItemAction().itemId(), info->browserItemAction().actionTypeId());
    connect(device, &StreamUnlimitedDevice::commandCompleted, info, [=](const QUuid &replyId, bool success){
        if (replyId != commandId) {
            return;
        }
        info->finish(success ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure);
    });
}

void IntegrationPluginStreamUnlimited::setupZeroConfBrowser(const QString &serviceType, const QString &manufacturer, const QString &model, const QString &uuidPart)
{
    qCDebug(dcStreamUnlimited()) << "Creating service browser for" << serviceType;
     m_zeroConfBrowser = hardwareManager()->zeroConfController()->createServiceBrowser(serviceType);
     connect(m_zeroConfBrowser, &ZeroConfServiceBrowser::serviceEntryAdded, this, &IntegrationPluginStreamUnlimited::zeroconfServiceEntryAdded);

     m_zeroConfManufacturerFilter = manufacturer;
     m_zeroConfModelFilter = model;
     m_zeroConfUuidPartFilter = uuidPart;
}

StreamUnlimitedDevice *IntegrationPluginStreamUnlimited::createStreamUnlimitedDevice()
{
    return new StreamUnlimitedDevice(hardwareManager()->networkManager(), m_ids, "ui:", this);
}

void IntegrationPluginStreamUnlimited::browserItem(BrowserItemResult *result)
{
    StreamUnlimitedDevice *device = m_devices.value(result->thing());

    if (device->language() != result->locale()) {
        qCDebug(dcStreamUnlimited()) << "Setting locale on board:" << result->locale();

        QUuid commandId = device->setLocaleOnBoard(result->locale());
        connect(device, &StreamUnlimitedDevice::commandCompleted, result, [=](const QUuid &replyId){
            if (replyId != commandId) {
                return;
            }

            // We'll call browsing in any case, regardless if setLocale succeeded or not
            browserItemInternal(result);
        });

    } else {
        browserItemInternal(result);
    }
}

void IntegrationPluginStreamUnlimited::zeroconfServiceEntryAdded(const ZeroConfServiceEntry &entry)
{
    // Disabling IPv6 for now, it seems to be not really reliable with those devices
    // Additionally, older StreamSDK models (e.g. Citation) claim they're IPv6 while in reality they aren't
    // So let's check the actual address protocol instead of entry.protocol()
    if (entry.hostAddress().protocol() != QAbstractSocket::IPv4Protocol) {
        return;
    }

    foreach (Thing *thing, m_devices.keys()) {
        QString thingId = thing->paramValue(m_ids.value("idParamTypeId")).toString();
        if (entry.txt("uuid") == thingId) {
            StreamUnlimitedDevice *device = m_devices.value(thing);
            // Update the address if we're not connected or the new one is a loopback while the old isn't
            if (device->connectionStatus() != StreamUnlimitedDevice::ConnectionStatusConnected ||
                    (!device->address().isLoopback() && entry.hostAddress().isLoopback())) {
                qCDebug(dcStreamUnlimited()) << "Updating host configuration for" << thing->name() << "to" << entry.hostAddress().toString();
                device->setHost(entry.hostAddress(), entry.port());
            }
            return;
        }
    }

    // We haven't found a matching thing for this zeroconf entry. If it's on localhost, let's auto-add it
    if (filterZeroConfEntry(entry) && isLocalStreamSDK(entry.hostAddress())) {
        ThingDescriptor descriptor(m_ids.value("thingClassId"), entry.txt("name"));
        Param thingIdParam(m_ids.value("idParamTypeId"), entry.txt("uuid"));
        descriptor.setParams(ParamList() << thingIdParam);
        qCDebug(dcStreamUnlimited()) << "Detected local stream SDK" << entry;
        emit autoThingsAppeared({descriptor});
    }

}

void IntegrationPluginStreamUnlimited::browseThingInternal(BrowseResult *result)
{
    StreamUnlimitedDevice *device = m_devices.value(result->thing());

    QUuid commandId = device->browseDevice(result->itemId());
    connect(device, &StreamUnlimitedDevice::browseResults, result, [=](const QUuid &replyId, bool success, const BrowserItems &items){
        if (replyId != commandId) {
            return;
        }
        if (!success) {
            result->finish(Thing::ThingErrorHardwareFailure);
            return;
        }

        result->addItems(items);
        result->finish(Thing::ThingErrorNoError);
    });
}

void IntegrationPluginStreamUnlimited::browserItemInternal(BrowserItemResult *result)
{
    StreamUnlimitedDevice *device = m_devices.value(result->thing());

    QUuid commandId = device->browserItem(result->itemId());
    connect(device, &StreamUnlimitedDevice::browserItemResult, result, [=](const QUuid &replyId, bool success, const BrowserItem &item){
        if (replyId != commandId) {
            return;
        }
        if (!success) {
            result->finish(Thing::ThingErrorHardwareFailure);
            return;
        }

        result->finish(item);
    });
}

ZeroConfServiceEntry IntegrationPluginStreamUnlimited::findBestConnection(const QString &id)
{
    ZeroConfServiceEntry ret;
    QList<ZeroConfServiceEntry> allEntries = m_zeroConfBrowser->serviceEntries();
    foreach (const ZeroConfServiceEntry &entry, allEntries) {
        if (entry.protocol() == QAbstractSocket::IPv4Protocol && entry.txt("uuid") == id) {
            if (!ret.isValid() || entry.hostAddress().isLoopback()) {
                ret = entry;
            }
        }
    }
    return ret;
}

bool IntegrationPluginStreamUnlimited::isLocalStreamSDK(const QHostAddress &streamSDKAddress) const
{
    if (streamSDKAddress.isLoopback()) {
        return true;
    }
    foreach (const QHostAddress &localAddress, QNetworkInterface::allAddresses()) {
        if (streamSDKAddress == localAddress) {
            return true;
        }
    }
    return false;
}

bool IntegrationPluginStreamUnlimited::filterZeroConfEntry(const ZeroConfServiceEntry &entry)
{
    QString id = entry.txt("uuid");
    QString name = entry.txt("name");
    QString model = entry.txt("model");
    QString manufacturer = entry.txt("manufacturer");

    if (!m_zeroConfManufacturerFilter.isEmpty() && !QRegularExpression(m_zeroConfManufacturerFilter).match(manufacturer).hasMatch()) {
//        qCDebug(dcStreamUnlimited()) << "manu" << m_zeroConfManufacturerFilter << manufacturer;
        return false;
    }
    if (!m_zeroConfModelFilter.isEmpty() && !QRegularExpression(m_zeroConfModelFilter).match(model).hasMatch()) {
//        qCDebug(dcStreamUnlimited()) << "model" << m_zeroConfModelFilter << model;
        return false;
    }
    if (!m_zeroConfUuidPartFilter.isEmpty() && !QRegularExpression(m_zeroConfUuidPartFilter).match(id).hasMatch()) {
//        qCDebug(dcStreamUnlimited()) << "uuid" << m_zeroConfUuidPartFilter << id;
        return false;
    }

    return true;
}
