// SPDX-License-Identifier: GPL-3.0-or-later

/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-plugins.
*
* nymea-plugins 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 3 of the License, or
* (at your option) any later version.
*
* nymea-plugins 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 nymea-plugins. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */

#include "integrationpluginunifi.h"
#include "plugininfo.h"

#include <QNetworkReply>
#include <QNetworkRequest>
#include <QJsonDocument>
#include <QNetworkCookieJar>

#include <hardwaremanager.h>
#include <network/networkaccessmanager.h>
#include <plugintimer.h>

IntegrationPluginUnifi::IntegrationPluginUnifi(QObject *parent) : IntegrationPlugin(parent)
{

}

IntegrationPluginUnifi::~IntegrationPluginUnifi()
{

}

void IntegrationPluginUnifi::init()
{
}

void IntegrationPluginUnifi::discoverThings(ThingDiscoveryInfo *info)
{
    Q_ASSERT_X(info->thingClassId() == clientThingClassId, "discoverDevices", "Invalid thing class in discovery");

    Things controllers = myThings().filterByThingClassId(controllerThingClassId);
    if (controllers.isEmpty()) {
        info->finish(Thing::ThingErrorHardwareNotAvailable, QT_TR_NOOP("Please configure a UniFi controller first."));
        return;
    }

    connect(info, &ThingDiscoveryInfo::aborted, this, [this, info](){
        m_pendingDiscoveries.remove(info);
    });

    foreach (Thing *controller, controllers) {
        m_pendingDiscoveries[info].append(controller);
        QNetworkRequest request = createRequest(controller, "/api/self/sites");
        QNetworkReply *sitesReply = hardwareManager()->networkManager()->get(request);
        connect(sitesReply, &QNetworkReply::finished, sitesReply, &QNetworkReply::deleteLater);
        connect(sitesReply, &QNetworkReply::finished, info, [this, info, sitesReply, controller](){
            if (sitesReply->error() != QNetworkReply::NoError) {
                qCWarning(dcUnifi()) << "Error fetching sites";
                m_pendingDiscoveries[info].removeAll(controller);
                if (m_pendingDiscoveries[info].isEmpty()) {
                    info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Fetching sites from controller failed."));
                }
                return;
            }
            QByteArray data = sitesReply->readAll();
            QJsonParseError error;
            QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
            if (error.error != QJsonParseError::NoError) {
                qCWarning(dcUnifi()) << "Error parsing data" << data;
                m_pendingDiscoveries[info].removeAll(controller);
                if (m_pendingDiscoveries[info].isEmpty()) {
                    info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Error communicating with the controller."));
                }
                return;
            }

            if (jsonDoc.toVariant().toMap().value("meta").toMap().value("rc").toString() != "ok") {
                qCWarning(dcUnifi()) << "Controller did not responde with OK" << qUtf8Printable(jsonDoc.toJson());
                m_pendingDiscoveries[info].removeAll(controller);
                if (m_pendingDiscoveries[info].isEmpty()) {
                    info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Fetching sites from controller failed."));
                }
                return;
            }

            qCDebug(dcUnifi()) << "**** sites reply finished" << data;
            foreach (const QVariant &siteVariant, jsonDoc.toVariant().toMap().value("data").toList()) {
                qCDebug(dcUnifi()) << "Have site:" << siteVariant.toMap().value("_id").toString() << siteVariant.toMap().value("name").toString() << siteVariant.toMap().value("desc").toString();

                QString site = siteVariant.toMap().value("_id").toString();
                QString siteName = siteVariant.toMap().value("name").toString();
                QString siteDescription = siteVariant.toMap().value("desc").toString();
                QNetworkRequest request = createRequest(controller, QString("/api/s/%1/stat/sta/").arg(siteName));

                qCDebug(dcUnifi()) << "Fetching clients for site" << site << siteName << request.url();

                m_pendingSiteDiscoveries[controller].append(siteName);

                QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
                connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
                connect(reply, &QNetworkReply::finished, info, [this, info, reply, controller, siteName](){
                    m_pendingSiteDiscoveries[controller].removeAll(siteName);
                    if (m_pendingSiteDiscoveries[controller].isEmpty()) {
                        m_pendingDiscoveries[info].removeAll(controller);
                    }

                    bool hasError = false;

                    if (reply->error() != QNetworkReply::NoError) {
                        qCWarning(dcUnifi()) << "Error fetching clients from site" << reply->error() << reply->errorString();
                        hasError = true;
                    } else {
                        QByteArray data = reply->readAll();
                        QJsonParseError error;
                        QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
                        if (error.error != QJsonParseError::NoError) {
                            qCWarning(dcUnifi()) << "Error parsing json for clients reply:" << error.errorString() << data;
                            hasError = true;
                        } else {
                            QVariantMap response = jsonDoc.toVariant().toMap();
                            if (response.value("meta").toMap().value("rc").toString() != "ok") {
                                qCWarning(dcUnifi()) << "Error response from controller:" << qUtf8Printable(jsonDoc.toJson());
                                hasError = true;
                            } else {
                                QVariantList clients = response.value("data").toList();
                                foreach (const QVariant &clientVariant, clients) {
                                    // qCDebug(dcUnifi()) << "client:" << qUtf8Printable(QJsonDocument::fromVariant(clientVariant).toJson());

                                    QString name = clientVariant.toMap().value("name").toString();
                                    if (name.isEmpty()) {
                                        name = clientVariant.toMap().value("hostname").toString();
                                    }
                                    if (name.isEmpty()) {
                                        name = clientVariant.toMap().value("oui").toString();
                                    }
                                    ThingDescriptor d(clientThingClassId, name, clientVariant.toMap().value("mac").toString());
                                    ParamList params;
                                    params << Param(clientThingMacParamTypeId, clientVariant.toMap().value("mac").toString());
                                    params << Param(clientThingSiteParamTypeId, siteName);
                                    d.setParams(params);

                                    Thing *existingThing = myThings().findByParams(params);
                                    if (existingThing) {
                                        d.setThingId(existingThing->id());
                                    }

                                    d.setParentId(controller->id());

                                    info->addThingDescriptor(d);
                                }
                            }
                        }
                    }


                    if (m_pendingDiscoveries[info].isEmpty()) {
                        info->finish(hasError ? Thing::ThingErrorHardwareFailure : Thing::ThingErrorNoError);
                    }
                });
            }

        });
    }
}

void IntegrationPluginUnifi::startPairing(ThingPairingInfo *info)
{
    info->finish(Thing::ThingErrorNoError, QT_TR_NOOP("Please enter your login credentials for the UniFi controller."));
}

void IntegrationPluginUnifi::confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret)
{
    QString host = info->params().paramValue(controllerThingIpAddressParamTypeId).toString();
    uint port = info->params().paramValue(controllerThingPortParamTypeId).toUInt();
    QString path;
    if (info->params().paramValue(controllerThingModeParamTypeId).toString() == "UniFi OS") {
        path = "/api/auth/login";
    } else {
        path = "/api/login";
    }
    QNetworkRequest request = createRequest(host, port, path);
    request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
    QVariantMap login;
    login.insert("username", username);
    login.insert("password", secret);
    qCDebug(dcUnifi()) << "ConfirmPairing: Sending request:" << request.url().toString() << QJsonDocument::fromVariant(login).toJson();
    QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(login).toJson());
    connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
    connect(reply, &QNetworkReply::finished, info, [this, info, reply, username, secret](){
        if (reply->error() != QNetworkReply::NoError) {
            qCWarning(dcUnifi()) << "ConfirmPairing: Network request error:" << reply->error() << reply->errorString();
            info->finish(Thing::ThingErrorHardwareFailure);
            return;
        }

        QByteArray data = reply->readAll();
        QJsonParseError error;
        QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
        if (error.error != QJsonParseError::NoError) {
            qCWarning(dcUnifi()) << "ConfirmPairing: Error parsing JSON response from controller:" << error.errorString() << data;
            info->finish(Thing::ThingErrorHardwareFailure);
            return;
        }

        qCDebug(dcUnifi()) << "ConfirmPairing succeeded";
        pluginStorage()->beginGroup(info->thingId().toString());
        pluginStorage()->setValue("username", username);
        pluginStorage()->setValue("password", secret);
        pluginStorage()->endGroup();
        info->finish(Thing::ThingErrorNoError);
    });
}

void IntegrationPluginUnifi::setupThing(ThingSetupInfo *info)
{
    if (info->thing()->thingClassId() == controllerThingClassId) {

        // If the user just completed the pairing process we already have a valid cookie in the networkAccessManager.
        if (info->isInitialSetup()) {
            info->finish(Thing::ThingErrorNoError);
            info->thing()->setStateValue(controllerConnectedStateTypeId, true);
            return;
        }

        // After a nymea startup, we'll have to login to obtain a cookie.
        QString host = info->thing()->params().paramValue(controllerThingIpAddressParamTypeId).toString();
        uint port = info->thing()->params().paramValue(controllerThingPortParamTypeId).toUInt();
        QString path;
        if (info->thing()->paramValue(controllerThingModeParamTypeId).toString() == "UniFi OS") {
            path = "/api/auth/login";
        } else {
            path = "/api/login";
        }
        QNetworkRequest request = createRequest(host, port, path);
        request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
        QVariantMap login;
        pluginStorage()->beginGroup(info->thing()->id().toString());
        login.insert("username", pluginStorage()->value("username").toString());
        login.insert("password", pluginStorage()->value("password").toString());
        pluginStorage()->endGroup();
        qCDebug(dcUnifi()) << "SetupThing: Sending request:" << request.url().toString() << QJsonDocument::fromVariant(login).toJson();
        QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(login).toJson());

        connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
        connect(reply, &QNetworkReply::finished, info, [info, reply](){
            if (reply->error() != QNetworkReply::NoError) {
                qCWarning(dcUnifi()) << "SetupThing: Network request error:" << reply->error() << reply->errorString();
                info->finish(Thing::ThingErrorHardwareFailure);
                return;
            }

            QByteArray data = reply->readAll();
            QJsonParseError error;
            QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
            if (error.error != QJsonParseError::NoError) {
                qCWarning(dcUnifi()) << "SetupThing: Error parsing JSON response from controller:" << error.errorString() << data;
                info->finish(Thing::ThingErrorHardwareFailure);
                return;
            }

            qCDebug(dcUnifi()) << "SetupThing succeded";

            info->thing()->setStateValue(controllerConnectedStateTypeId, true);
            info->finish(Thing::ThingErrorNoError);

        });
    }

    if (info->thing()->thingClassId() == clientThingClassId) {
        info->finish(Thing::ThingErrorNoError);
    }
}

void IntegrationPluginUnifi::postSetupThing(Thing *thing)
{
    if (thing->thingClassId() == controllerThingClassId && !m_loginTimer) {
        // Let's refresh the login every minute
        m_loginTimer = hardwareManager()->pluginTimerManager()->registerTimer();
        connect(m_loginTimer, &PluginTimer::timeout, this, [this](){
            foreach (Thing *controller, myThings().filterByThingClassId(controllerThingClassId)) {
                QString host = controller->paramValue(controllerThingIpAddressParamTypeId).toString();
                uint port = controller->paramValue(controllerThingPortParamTypeId).toUInt();
                QString path;
                if (controller->paramValue(controllerThingModeParamTypeId).toString() == "UniFi OS") {
                    path = "/api/auth/login";
                } else {
                    path = "/api/login";
                }
                QNetworkRequest request = createRequest(host, port, path);
                request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
                QVariantMap login;
                pluginStorage()->beginGroup(controller->id().toString());
                login.insert("username", pluginStorage()->value("username"));
                login.insert("password", pluginStorage()->value("password"));
                pluginStorage()->endGroup();
                qCDebug(dcUnifi()) << "Cookie KeepAlive: Sending request:" << request.url().toString();
                QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(login).toJson());
                connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
            }
        });
    }

    if (thing->thingClassId() == clientThingClassId && !m_pollTimer) {
        m_pollTimer = hardwareManager()->pluginTimerManager()->registerTimer(1);
        connect(m_pollTimer, &PluginTimer::timeout, this, [this](){
            foreach (Thing *client, myThings().filterByThingClassId(clientThingClassId)) {
                Thing *controller = myThings().findById(client->parentId());
                QString mac = client->paramValue(clientThingMacParamTypeId).toString();
                QString site = client->paramValue(clientThingSiteParamTypeId).toString();
                QNetworkRequest request = createRequest(controller, QString("/api/s/%1/stat/sta/%2").arg(site).arg(mac));
                QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
                connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
                connect(reply, &QNetworkReply::finished, client, [this, client, reply](){
                    if (reply->error() != QNetworkReply::NoError) {
                        // If the thing is not present we'll get an InvalidOperationError, silence that as it's expected but print other failures
                        if (reply->error() != QNetworkReply::ProtocolInvalidOperationError) {
                            qCDebug(dcUnifi()) << "Error fetching thing state from controller" << reply->error() << reply->errorString();
                        }
                        markOffline(client);
                        return;
                    }
                    QByteArray data = reply->readAll();
                    QJsonParseError error;
                    QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
                    if (error.error != QJsonParseError::NoError) {
                        qCWarning(dcUnifi()) << "Error parsing json from controller:" << error.error << error.errorString() << "\n" << data;
                        markOffline(client);
                        return;
                    }

                    //                    qCDebug(dcUnifi()) << "Client is present reply" << qUtf8Printable(jsonDoc.toJson());
                    QVariantList clientEntries = jsonDoc.toVariant().toMap().value("data").toList();
                    if (clientEntries.count() != 1) {
                        qCWarning(dcUnifi()) << "Client data not found in controller reply";
                        markOffline(client);
                        return;
                    }

                    QVariantMap clientData = clientEntries.first().toMap();

                    client->setStateValue(clientLastSeenTimeStateTypeId, clientData.value("last_seen").toInt());
                    client->setStateValue(clientIsPresentStateTypeId, true);
                });

            }
        });
    }
}

void IntegrationPluginUnifi::thingRemoved(Thing *thing)
{
    Q_UNUSED(thing)
    if (myThings().filterByThingClassId(controllerThingClassId).isEmpty() && m_loginTimer) {
        hardwareManager()->pluginTimerManager()->unregisterTimer(m_loginTimer);
        m_loginTimer = nullptr;
    }
    if (myThings().filterByThingClassId(clientThingClassId).isEmpty() && m_pollTimer) {
        hardwareManager()->pluginTimerManager()->unregisterTimer(m_pollTimer);
        m_pollTimer = nullptr;
    }
}

QNetworkRequest IntegrationPluginUnifi::createRequest(const QString &address, uint port, const QString &path, const QString &prefix)
{
    QUrl url;
    url.setScheme("https");
    url.setHost(address);
    url.setPort(port);
    url.setPath(prefix + path);

    QNetworkRequest request(url);
    request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
    QSslConfiguration config = QSslConfiguration::defaultConfiguration();
    config.setPeerVerifyMode(QSslSocket::VerifyNone);
    request.setSslConfiguration(config);
    return request;
}

QNetworkRequest IntegrationPluginUnifi::createRequest(Thing *thing, const QString &path)
{
    QString ipAddress = thing->paramValue(controllerThingIpAddressParamTypeId).toString();
    uint port = thing->paramValue(controllerThingPortParamTypeId).toUInt();
    bool prefix = thing->paramValue(controllerThingModeParamTypeId).toString() == "UniFi OS";
    return createRequest(ipAddress, port, path, prefix ? "/proxy/network" : "");
}

void IntegrationPluginUnifi::markOffline(Thing *thing)
{
    uint gracePeriod = thing->setting(clientSettingsGracePeriodParamTypeId).toUInt();
    QDateTime lastSeenTime = QDateTime::fromMSecsSinceEpoch(thing->stateValue(clientLastSeenTimeStateTypeId).toInt() * 1000);
    if (lastSeenTime.addSecs(gracePeriod * 60) < QDateTime::currentDateTime()) {
        thing->setStateValue(clientIsPresentStateTypeId, false);
    }
}

