// 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-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s 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-energy-plugin-nymea.s 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-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */

#include "spotmarketmanager.h"
#include "../plugininfo.h"
#include "../energysettings.h"

#include "spotmarketdataproviderawattar.h"

SpotMarketManager::SpotMarketManager(QNetworkAccessManager *networkManager, QObject *parent) :
    QObject{parent},
    m_networkManager{networkManager}
{
    // Init known spot market providers
    registerProvider(new SpotMarketDataProviderAwattar(m_networkManager, SpotMarketDataProviderAwattar::AwattarCountryAustria, this));
    registerProvider(new SpotMarketDataProviderAwattar(m_networkManager, SpotMarketDataProviderAwattar::AwattarCountryGermany, this));

    // Load settings
    EnergySettings settings;
    settings.beginGroup("SpotMarket");
    setEnabled(settings.value("enabled", false).toBool());
    settings.endGroup(); // SpotMarket
}

bool SpotMarketManager::enabled() const
{
    return m_enabled;
}

void SpotMarketManager::setEnabled(bool enabled)
{
    EnergySettings settings;
    settings.beginGroup("SpotMarket");

    if (enabled) {
        qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Enable spot market manager";

        QUuid providerId = settings.value("providerId").toUuid();
        if (providerId.isNull()) {
            qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Currently no spot market provider configured. Selecting the first available.";
            changeProvider(m_availableProviders.keys().first());
        } else {
            changeProvider(providerId);
        }

        m_currentProvider->enable();
        qCInfo(dcNymeaEnergy()) << "SpotMarketManager: Enabled using" << m_currentProvider;

    } else {
        qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Disable spot market manager.";
        if (m_currentProvider) {
            m_currentProvider->disable();
        }
    }

    if (m_enabled != enabled) {
        m_enabled = enabled;
        settings.setValue("enabled", m_enabled);
        emit enabledChanged(m_enabled);
    }

    settings.endGroup(); // SpotMarket
}

bool SpotMarketManager::available() const
{
    if (!m_currentProvider)
        return false;

    return m_currentProvider->available();
}

SpotMarketDataProvider *SpotMarketManager::currentProvider() const
{
    return m_currentProvider;
}

QUuid SpotMarketManager::currentProviderId() const
{
    if (m_currentProvider)
        return m_currentProvider->providerId();

    return QUuid();
}

SpotMarketProviderInfos SpotMarketManager::availableProviders() const
{
    return m_availableProviderInfos;
}

bool SpotMarketManager::changeProvider(const QUuid &providerId)
{
    if (providerId.isNull() || !m_availableProviders.contains(providerId)) {
        qCWarning(dcNymeaEnergy()) << "SpotMarketManager: Requested to change provider to" << providerId.toString() << "but there is no such provider available.";
        return false;
    }

    if (m_currentProvider) {
        qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Unset current provider" << m_currentProvider;
        m_currentProvider->disable();

        // Disconnect everything from availabeChanged
        disconnect(m_currentProvider, &SpotMarketDataProvider::availableChanged, this, &SpotMarketManager::availableChanged);
        disconnect(m_currentProvider, &SpotMarketDataProvider::enabledChanged, this, &SpotMarketManager::enabledChanged);
        disconnect(m_currentProvider, &SpotMarketDataProvider::scoreEntriesChanged, this, &SpotMarketManager::onProviderScoreEntriesChanged);

        m_currentProvider = nullptr;
    }

    m_weightedScores.clear();

    qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Changing provider to" << m_availableProviders.value(providerId);
    m_currentProvider = m_availableProviders.value(providerId);
    emit currentProviderChanged(m_currentProvider);

    connect(m_currentProvider, &SpotMarketDataProvider::availableChanged, this, &SpotMarketManager::availableChanged);
    connect(m_currentProvider, &SpotMarketDataProvider::enabledChanged, this, &SpotMarketManager::enabledChanged);
    connect(m_currentProvider, &SpotMarketDataProvider::scoreEntriesChanged, this, &SpotMarketManager::onProviderScoreEntriesChanged);

    EnergySettings settings;
    settings.beginGroup("SpotMarket");
    settings.setValue("providerId", m_currentProvider->providerId());
    settings.endGroup(); // SpotMarket
    return true;
}

bool SpotMarketManager::registerProvider(SpotMarketDataProvider *provider)
{
    if (m_availableProviders.contains(provider->providerId())) {
        qCWarning(dcNymeaEnergy()) << "SpotMarketManager: Try to register already registered provider. Ignoring request.";
        return false;
    }

    m_availableProviders.insert(provider->providerId(), provider);
    m_availableProviderInfos.append(provider->info());
    emit availableProvidersChanged();
    qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Registered" << provider;
    return true;
}

ScoreEntries SpotMarketManager::weightedScoreEntries(const QDate &date) const
{    
    if (!m_currentProvider)
        return ScoreEntries();

    if (!date.isValid())
        return weightScoreEntries(m_currentProvider->scoreEntries());

    return m_weightedScores.value(date);
}

ScoreEntries SpotMarketManager::weightScoreEntries(const ScoreEntries &scoreEntries)
{
    bool dataInitialized = false; double bestPrice = 0; double worstPrice = 0;
    foreach (const ScoreEntry &entry, scoreEntries) {
        if (!dataInitialized) {
            bestPrice = entry.value();
            worstPrice = entry.value();
            dataInitialized = true;
            continue;
        }

        if (entry.value() < bestPrice) {
            bestPrice = entry.value();
        }

        if (entry.value() > worstPrice) {
            worstPrice = entry.value();
        }
    }

    ScoreEntries weigtedEntries;
    foreach (const ScoreEntry &entry, scoreEntries) {
        ScoreEntry newEntry = entry;
        newEntry.setWeighting((newEntry.value() - worstPrice) / (bestPrice - worstPrice));
        weigtedEntries.append(newEntry);
    }

    weigtedEntries.sortByStartDateTime();

    if (weigtedEntries.isEmpty()) {
        qCDebug(dcNymeaEnergy()) << "Weigted" << scoreEntries.count() << "score entries";
    } else {
        qCDebug(dcNymeaEnergy()) << "Weigted" << scoreEntries.count() << "score entries"
                                 << weigtedEntries.first().startDateTime().toString("dd.MM.yyyy hh:mm")
                                 << "-"
                                 << weigtedEntries.last().endDateTime().toString("dd.MM.yyyy hh:mm")
                                 << "Best price:" << bestPrice << "(1.0)"
                                 << "Worst price:" << worstPrice << "(0.0)";
    }

    return weigtedEntries;
}

TimeFrames SpotMarketManager::scheduleCharingTimeForToday(const QDateTime &currentDateTime, const uint minutes, uint minimumScheduleDuration, bool currentFrameLocked)
{    
    const ScoreEntries weightedScores = m_weightedScores.value(currentDateTime.date());
    return scheduleChargingTime(currentDateTime, weightedScores, minutes, minimumScheduleDuration, currentFrameLocked);
}

TimeFrames SpotMarketManager::scheduleChargingTime(const QDateTime &currentDateTime, const ScoreEntries weightedScores, const uint minutes, uint minimumScheduleDuration, bool currentFrameLocked)
{
    TimeFrames timeFrames;
    ScoreEntries availableScores;

    for (int i = 0; i < weightedScores.count(); i++) {
        const ScoreEntry scoreEntry = weightedScores.at(i);
        // We can not change the past, even if we would like to that some times...
        if (scoreEntry.endDateTime() < currentDateTime)
            continue;

        // If this is the current hour, let's update the start time so we have later the correct timeframe available
        if (scoreEntry.isActive(currentDateTime)) {
            ScoreEntry newEntry = scoreEntry;
            newEntry.setStartDateTime(currentDateTime);
            availableScores.append(newEntry);
        } else {
            availableScores.append(scoreEntry);
        }
    }

    uint minutesLeftToSchedule = 0;
    foreach (const ScoreEntry &score, availableScores) {
        minutesLeftToSchedule += score.durationMinutes();
    }

    // Make sure we have enougth schedules left, otherwise return what we have...
    if (minutes >= minutesLeftToSchedule) {
        qCDebug(dcNymeaEnergy()) << "Not enought score entries left in order to schedule" << minutes << "minutes. Using the leftover schedules beeing a total of" << minutesLeftToSchedule << "minutes.";
        foreach(const ScoreEntry &scoreEntry, availableScores) {
            timeFrames.append(TimeFrame(scoreEntry.startDateTime(), scoreEntry.endDateTime()));
        }
    } else {
        // Sort the schedules by weighting
        availableScores.sortByWeighting();
        //qCDebug(dcNymeaEnergy()) << "Sorted by weighting" << availableScores;

        uint minutesLeft = minutes;
        int currentIndex = 0;
        while(minutesLeft > 0 && currentIndex < availableScores.count()) {
            const ScoreEntry currentScore = availableScores.at(currentIndex);
            TimeFrame newFrame;
            if (minutesLeft >= 60) {
                newFrame = TimeFrame(currentScore.startDateTime(), currentScore.endDateTime());
            } else {
                // Partial hour, let's see how many minutes we have left within this hour
                uint minutesDuration = 60 - currentScore.startDateTime().time().minute();
                if (minutesDuration < minutesLeft) {
                    newFrame = TimeFrame(currentScore.startDateTime(), currentScore.startDateTime().addSecs(minutesDuration * 60));
                } else {
                    newFrame = TimeFrame(currentScore.startDateTime(), currentScore.startDateTime().addSecs(minutesLeft * 60));
                }
            }

            timeFrames.append(newFrame);
            minutesLeft -= newFrame.durationMinutes();
            currentIndex++;
        }
    }



    TimeFrames fusedFrames = fuseTimeFrames(timeFrames);

    // Now make sure all frames respect the minimum schedule duration.
    // The reason for a minimum duration might be the fact that we want to
    // charge as continuouse as possible. We don't want to switch the charging on and off
    // in less i.e. than 10 min. We append or prepend the rest minutes on an existing frame
    // using the best possible side.

    // This is the price we have to pay for continuouse charging

    foreach (const TimeFrame &frame, fusedFrames) {

        // If we are currently loading and the frame has moved less than 10 minutes into the future, we move the window to now in otder to prevent jutter
        if (currentFrameLocked && currentDateTime.msecsTo(frame.startDateTime()) < 600000) {
            int secondsOffset = qRound((frame.startDateTime().toMSecsSinceEpoch() - currentDateTime.toMSecsSinceEpoch()) / 1000.0);
            // Move the current frame by the offset to now since we where already charging
            TimeFrame movedFrame = frame;
            movedFrame.setStartDateTime(frame.startDateTime().addSecs(-secondsOffset));
            movedFrame.setEndDateTime(frame.endDateTime().addSecs(-secondsOffset));
            fusedFrames.removeAll(frame);
            fusedFrames.append(movedFrame);
            qCDebug(dcNymeaEnergy()) << "Moving frame to current date time" << secondsOffset / 60.0 << "minutes";
            // We are done here...
            break;
        }

        if (minimumScheduleDuration > 1 && frame.durationMinutes() < minimumScheduleDuration) {

            // If this is the current frame, and it is not locked (because we are currently charging), allow to finish it
            if (frame.isActive(currentDateTime) && currentFrameLocked) {
                // Keep this schedule even it does not meet the minimum schedule durarion because the current frame is locked
                break;
            }

            // This frame does not meet our minimum duration, distribute the rest to the next best schedule
            fusedFrames.removeAll(frame);

            // Possible option: for now, we just append the remaining time to the shortest time frame
            // in favour of append/prepend it to the best price hour. A small price we are willing to pay in favour of
            // a more constant charging, a car battery is more expensice than this small amount of energy.

            int shortestFrameIndex = -1;
            uint shortestFrameDuration = 0;
            for (int i = 0; i < fusedFrames.count(); i++) {
                const TimeFrame currentFrame = fusedFrames.at(i);

                // The sooner the better if 2 have the same duration
                if (currentFrame.durationMinutes() < shortestFrameDuration || shortestFrameIndex < 0) {
                    shortestFrameDuration = currentFrame.durationMinutes();
                    shortestFrameIndex = i;
                }
            }

            // There is no other frame we can append, let's re-add it...
            if (shortestFrameIndex < 0) {
                // FIXME: maybe try to add this one into the next day, it's not the current locked frame so do not toggle charging
                fusedFrames.append(frame);
                break;
            }

            // Add the to short frame to the shortest time frame in order to be as continoiuse as possible regarding charging changes.
            fusedFrames[shortestFrameIndex].setEndDateTime(fusedFrames.value(shortestFrameIndex).endDateTime().addSecs(frame.durationMinutes() * 60));

            // Note: there can only be one schedule which might has to be distributed...we are done
            break;
        }
    }

    // Return them sorted by date
    std::sort(fusedFrames.begin(), fusedFrames.end(), [](const TimeFrame &a, const TimeFrame &b) -> bool {
        return a.startDateTime() < b.startDateTime();
    });

    return fusedFrames;
}

TimeFrames SpotMarketManager::fuseTimeFrames(const TimeFrames &timeFrames)
{
    if (timeFrames.count() <= 1)
        return timeFrames;

    TimeFrames fusedFrames, partialHours;

    // First fuse all full hours
    for (int i = 0; i < timeFrames.count(); i++) {
        const TimeFrame currentFrame = timeFrames.at(i);
        int frameDurationMinutes = (currentFrame.endDateTime().toMSecsSinceEpoch() - currentFrame.startDateTime().toMSecsSinceEpoch()) / 60000;

        // We take care about the partial hours later
        if (frameDurationMinutes < 60) {
            partialHours.append(currentFrame);
            continue;
        }

        // First full hour...just add, nothing to fuse
        if (fusedFrames.isEmpty()) {
            fusedFrames.append(currentFrame);
            continue;
        }

        // Check if we can extend an existing frame
        bool extended = false;
        for (int j = 0; j < fusedFrames.count(); j++) {
            if (currentFrame.endDateTime() == fusedFrames.at(j).startDateTime()) {
                // Prepend hour
                fusedFrames[j].setStartDateTime(currentFrame.startDateTime());
                extended = true;
                break;
            } else if (currentFrame.startDateTime() == fusedFrames.at(j).endDateTime()) {
                // Append hour
                fusedFrames[j].setEndDateTime(currentFrame.endDateTime());
                extended = true;
                break;
            }
        }

        if (!extended) {
            // Add the full hour to the fused frames
            fusedFrames.append(currentFrame);
            continue;
        }
    }


    TimeFrames partialHoursUnfused;

    // Now try to fuse the partial hours into the fused full hours ...
    for (int i = 0; i < partialHours.count(); i++) {
        const TimeFrame currentFrame = partialHours.at(i);
        TimeFrame currentFrameMovedToEnd;
        if (currentFrame.endDateTime().time().minute() == 0) {
            // This time frame is already at the end. No need to move
            currentFrameMovedToEnd = currentFrame;
        } else {
            int endtimeOffset = 60 - currentFrame.endDateTime().time().minute();
            QDateTime endDateTime = currentFrame.endDateTime().addSecs(endtimeOffset * 60);
            currentFrameMovedToEnd.setEndDateTime(endDateTime);
            currentFrameMovedToEnd.setStartDateTime(endDateTime.addSecs(static_cast<int>(currentFrame.durationMinutes()) * -60));
        }

        // Check if we can extend an existing frame
        bool extended = false;

        // Try to fuse the start partial frame
        for (int j = 0; j < fusedFrames.count(); j++) {
            if (currentFrame.endDateTime() == fusedFrames.at(j).startDateTime()) {
                // Prepend partial hour
                fusedFrames[j].setStartDateTime(currentFrame.startDateTime());
                extended = true;
                break;
            } else if (currentFrame.startDateTime() == fusedFrames.at(j).endDateTime()) {
                // Append partial hour
                fusedFrames[j].setEndDateTime(currentFrame.endDateTime());
                extended = true;
                break;
            }
        }

        if (!extended && currentFrame != currentFrameMovedToEnd) {
            // Try to fuse the end partial frame
            for (int j = 0; j < fusedFrames.count(); j++) {

                if (currentFrameMovedToEnd.endDateTime() == fusedFrames.at(j).startDateTime()) {
                    // Prepend partial hour
                    fusedFrames[j].setStartDateTime(currentFrameMovedToEnd.startDateTime());
                    extended = true;
                    break;
                } else if (currentFrameMovedToEnd.startDateTime() == fusedFrames.at(j).endDateTime()) {
                    // Append partial hour
                    fusedFrames[j].setEndDateTime(currentFrameMovedToEnd.endDateTime());
                    extended = true;
                    break;
                }
            }
        }

        if (!extended) {
            // Note: we have a standalone partial hour...add it as is,
            // it might overlap into an other hour or bet the current,
            // we don't know here, so let's not change it. Do not use
            // the moved to end frame since that destroyes the upper logic.
            partialHoursUnfused.append(currentFrame);
            continue;
        }
    }

    if (!partialHoursUnfused.isEmpty()) {
        TimeFrames fusedPartialFrames = fusePartialTimeFrames(partialHoursUnfused);
        fusedFrames.append(fusedPartialFrames);
    }

    // Return them sorted by date
    std::sort(fusedFrames.begin(), fusedFrames.end(), [](const TimeFrame &a, const TimeFrame &b) -> bool {
        return a.startDateTime() < b.startDateTime();
    });

    return fusedFrames;
}

TimeFrames SpotMarketManager::fusePartialTimeFrames(const TimeFrames &timeFrames)
{
    TimeFrames fusedPartialFrames;
    QList<int> handledIndices;

    // If we have any partial hours left which could not be extended yet, try to extend with any other partially unfused hour
    for (int i = 0; i < timeFrames.count(); i++) {

        if (handledIndices.contains(i))
            continue;

        const TimeFrame currentFrame = timeFrames.at(i);
        TimeFrame currentFrameMovedToEnd;
        if (currentFrame.endDateTime().time().minute() == 0) {
            // This time frame is already at the end. No need to move
            currentFrameMovedToEnd = currentFrame;
        } else {
            int endtimeOffset = 60 - currentFrame.endDateTime().time().minute();
            QDateTime endDateTime = currentFrame.endDateTime().addSecs(endtimeOffset * 60);
            currentFrameMovedToEnd.setEndDateTime(endDateTime);
            currentFrameMovedToEnd.setStartDateTime(endDateTime.addSecs(static_cast<int>(currentFrame.durationMinutes()) * -60));
        }


        // Check if we can extend an existing frame
        bool extended = false;

        // Try to fuse the start partial frame with any other partial frame
        for (int j = 0; j < timeFrames.count(); j++) {

            // We don't want to fuse with our selfs or already fused frames
            if (j == i || handledIndices.contains(j))
                continue;

            if (currentFrame.endDateTime() == timeFrames.at(j).startDateTime()) {
                // Prepend partial hour
                TimeFrame extendedFrame = timeFrames.at(j);
                extendedFrame.setStartDateTime(currentFrame.startDateTime());
                fusedPartialFrames.append(extendedFrame);
                handledIndices << j << i;
                extended = true;
                break;
            } else if (currentFrame.startDateTime() == timeFrames.at(j).endDateTime()) {
                // Append partial hour
                TimeFrame extendedFrame = timeFrames.at(j);
                extendedFrame.setEndDateTime(currentFrame.endDateTime());
                fusedPartialFrames.append(extendedFrame);
                handledIndices << j << i;
                extended = true;
                break;
            }
        }

        if (!extended && currentFrame != currentFrameMovedToEnd) {
            // Try to fuse the end partial frame
            for (int j = 0; j < timeFrames.count(); j++) {

                // We don't want to fuse with our selfs
                if (j == i || handledIndices.contains(j))
                    continue;

                if (currentFrameMovedToEnd.endDateTime() == timeFrames.at(j).startDateTime()) {
                    // Prepend partial hour
                    TimeFrame extendedFrame = timeFrames.at(j);
                    extendedFrame.setStartDateTime(currentFrameMovedToEnd.startDateTime());
                    fusedPartialFrames.append(extendedFrame);
                    handledIndices << j << i;
                    extended = true;
                    break;
                } else if (currentFrameMovedToEnd.startDateTime() == timeFrames.at(j).endDateTime()) {
                    // Append partial hour
                    TimeFrame extendedFrame = timeFrames.at(j);
                    extendedFrame.setEndDateTime(currentFrameMovedToEnd.endDateTime());
                    fusedPartialFrames.append(extendedFrame);
                    handledIndices << j << i;
                    extended = true;
                    break;
                }
            }
        }

        if (!extended) {
            // Note: we have a standalone partial hour...add it as is,
            // it might overlap into an other hour or bet the current,
            // we don't know here, so let's not change it. Do not use
            // the moved to end frame since that destroyes the upper logic.
            fusedPartialFrames.append(currentFrame);
            handledIndices << i;
            continue;
        }
    }

    // Note: these frames must all be partial, we fuse them if possible, otherwise return them as single partial slots...

    return fusedPartialFrames;
}

void SpotMarketManager::onProviderScoreEntriesChanged(const ScoreEntries &scoreEntries)
{
    // Received new score entries, check if we habe to update the day based weighted scores.
    QHash<QDate, ScoreEntries> groupedScores;
    foreach (const ScoreEntry &scoreEntry, scoreEntries) {
        groupedScores[scoreEntry.startDateTime().date()].append(scoreEntry);
    }

    // Update all received score entries in the persistent hash (cache may contain partial data).
    foreach (const QDate &date, groupedScores.keys()) {
        const ScoreEntries weightedEntries = weightScoreEntries(groupedScores.value(date));
        m_weightedScores[date] = weightedEntries;
        qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Weighted day"
                                 << date.toString("dd.MM.yyyy")
                                 << "with" << groupedScores.value(date).count()
                                 << "entries (provider total" << scoreEntries.count()
                                 << "weighted total" << weightedEntries.count() << ")";
    }

    // Clean up old
    QList<QDate> datesToRemove;
    if (!scoreEntries.isEmpty()) {
        const QDate earliestDate = scoreEntries.first().startDateTime().date();
        foreach (const QDate &date, m_weightedScores.keys()) {
            if (date < earliestDate) {
                datesToRemove.append(date);
                continue;
            }

            // Weight the current data
            //m_weightedScores[date] = weightScoreEntries(m_weightedScores.value(date));
        }
    }

    // Remove the past... live goes on
    foreach (const QDate &date, datesToRemove) {
        m_weightedScores.remove(date);
    }

    qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Score entries updated";
    emit scoreEntriesUpdated();
}
