// 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 "rulebasedstrategy.h"

#include <QLoggingCategory>
#include <algorithm>
#include <cmath>

Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)

RuleBasedStrategy::RuleBasedStrategy(QObject *parent)
    : ISchedulingStrategy(parent)
{
}

QString RuleBasedStrategy::strategyId() const
{
    return QStringLiteral("rule-based");
}

QString RuleBasedStrategy::displayName() const
{
    return QStringLiteral("Règles déterministes");
}

QString RuleBasedStrategy::description() const
{
    return QStringLiteral("Stratégie déterministe en 3 passes : charges critiques, "
                          "stockages (prix + solaire), charges flexibles (avant deadline).");
}

QList<EnergyTimeSlot> RuleBasedStrategy::computeSchedule(
    const QList<EnergyTimeSlot> &forecast,
    const QList<FlexibleLoad>   &loads,
    const SchedulerConfig       &config)
{
    // Start from a copy of the forecast so predictions are preserved
    QList<EnergyTimeSlot> timeline = forecast;

    // Clear previous allocations so we start fresh
    for (int i = 0; i < timeline.size(); ++i) {
        EnergyTimeSlot &slot = timeline[i];
        if (slot.manualOverride)
            continue; // Respect human decisions
        slot.allocatedToEV      = 0;
        slot.allocatedToHP      = 0;
        slot.allocatedToDHW     = 0;
        slot.allocatedToBattery = 0;
        slot.allocatedToFeedIn  = 0;
        slot.decisionReason.clear();
        slot.decisionRules.clear();
    }

    passInflexibleLoads(timeline, loads);
    passStorageLoads(timeline, loads, config);
    passShiftableLoads(timeline, loads, config);

    // Compute net grid power, cost, and self-sufficiency for every slot
    for (int i = 0; i < timeline.size(); ++i)
        computeSlotResults(timeline[i]);

    fillMissingReasons(timeline);

    return timeline;
}

QString RuleBasedStrategy::explainDecision(
    const EnergyTimeSlot &slot,
    const FlexibleLoad   &load) const
{
    if (slot.manualOverride)
        return QStringLiteral("Décision manuelle : ") + slot.overrideReason;

    switch (load.source) {
    case LoadSource::SmartCharging:
        if (slot.allocatedToEV > 0) {
            if (slot.solarForecastW - slot.baseConsumptionW > 0)
                return QString("Surplus solaire prévu +%1 kW — recharge VE gratuite")
                    .arg((slot.solarForecastW - slot.baseConsumptionW) / 1000.0, 0, 'f', 1);
            return QString("Prix spot %1€/kWh (optimal avant deadline) — recharge VE planifiée")
                .arg(slot.electricityPrice, 0, 'f', 3);
        }
        return QStringLiteral("VE non chargé dans ce créneau");

    case LoadSource::Battery:
        if (slot.allocatedToBattery > 0)
            return QString("Prix spot %1€/kWh (seuil %2€) — charge batterie")
                .arg(slot.electricityPrice, 0, 'f', 3)
                .arg(0.08, 0, 'f', 2);
        if (slot.allocatedToBattery < 0)
            return QStringLiteral("Pic de prix — décharge batterie pour réduire l'import réseau");
        return QStringLiteral("Batterie en veille");

    case LoadSource::HeatPump:
        if (slot.allocatedToHP > 0)
            return QString("Besoin chauffage %1 kW — PAC activée")
                .arg(load.currentPowerW / 1000.0, 0, 'f', 1);
        return QStringLiteral("PAC non requise dans ce créneau");

    case LoadSource::DHW:
        if (slot.allocatedToDHW > 0)
            return QStringLiteral("Recharge ECS planifiée");
        return QStringLiteral("ECS en veille");

    default:
        break;
    }

    return slot.decisionReason;
}

// ---------------------------------------------------------------------------
// Pass 1 — Inflexible / critical loads
// ---------------------------------------------------------------------------
void RuleBasedStrategy::passInflexibleLoads(
    QList<EnergyTimeSlot> &timeline,
    const QList<FlexibleLoad> &loads)
{
    // Inflexible loads (e.g. base consumption) are already accounted for in
    // baseConsumptionW. We iterate them here only to flag slots where critical
    // loads are active, and potentially to add heating/DHW to HP/DHW allocations.
    foreach (const FlexibleLoad &load, loads) {
        if (load.type != LoadType::Inflexible)
            continue;

        for (int i = 0; i < timeline.size(); ++i) {
            EnergyTimeSlot &slot = timeline[i];
            if (slot.manualOverride)
                continue;

            // Critical HP loads go into heating allocation
            if (load.source == LoadSource::HeatPump && load.priority >= 0.9) {
                slot.allocatedToHP = qMax(slot.allocatedToHP, load.currentPowerW);
                if (slot.decisionReason.isEmpty())
                    slot.decisionReason = QStringLiteral("Chauffage critique — PAC toujours active");
                if (!slot.decisionRules.contains("CriticalHeating"))
                    slot.decisionRules.append("CriticalHeating");
            }
            // Critical DHW (légionellose)
            if (load.source == LoadSource::DHW && load.priority >= 0.9) {
                slot.allocatedToDHW = qMax(slot.allocatedToDHW, load.currentPowerW);
                if (slot.decisionReason.isEmpty())
                    slot.decisionReason = QStringLiteral("ECS critique (sécurité légionellose)");
                if (!slot.decisionRules.contains("CriticalDHW"))
                    slot.decisionRules.append("CriticalDHW");
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Pass 2 — Storage loads (battery, DHW tank with modulation)
// ---------------------------------------------------------------------------
void RuleBasedStrategy::passStorageLoads(
    QList<EnergyTimeSlot> &timeline,
    const QList<FlexibleLoad> &loads,
    const SchedulerConfig &config)
{
    foreach (const FlexibleLoad &load, loads) {
        if (load.type != LoadType::Storage)
            continue;
        // Skip if already at target (SOC/temperature)
        if (load.currentValue >= load.targetValue && load.targetValue > 0)
            continue;

        for (int i = 0; i < timeline.size(); ++i) {
            EnergyTimeSlot &slot = timeline[i];
            if (slot.manualOverride)
                continue;

            double surplus = slot.solarForecastW - slot.baseConsumptionW
                             - slot.allocatedToHP - slot.allocatedToDHW;
            bool cheapPrice  = slot.electricityPrice > 0
                               && slot.electricityPrice < config.chargePriceThreshold;
            bool solarSurplus = surplus > config.solarSurplusThresholdW;

            if (!cheapPrice && !solarSurplus)
                continue;

            double powerToAllocate = qMin(load.maxPowerW, qMax(load.minPowerW, surplus > 0 ? surplus : load.maxPowerW));

            if (load.source == LoadSource::Battery) {
                slot.allocatedToBattery += powerToAllocate;
                if (cheapPrice && !solarSurplus) {
                    slot.decisionReason = QString("Prix spot %1€/kWh (seuil %2€) — charge batterie")
                        .arg(slot.electricityPrice, 0, 'f', 3)
                        .arg(config.chargePriceThreshold, 0, 'f', 2);
                    slot.decisionRules.append("PriceBelow" + QString::number(config.chargePriceThreshold));
                } else {
                    slot.decisionReason = QString("Surplus solaire +%1 kW — recharge batterie")
                        .arg(surplus / 1000.0, 0, 'f', 1);
                    slot.decisionRules.append("SolarSurplus");
                }
            } else if (load.source == LoadSource::DHW) {
                slot.allocatedToDHW += powerToAllocate;
                slot.decisionReason = QString("Surplus solaire +%1 kW — chauffe-eau solaire")
                    .arg(surplus / 1000.0, 0, 'f', 1);
                slot.decisionRules.append("SolarSurplus");
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Pass 3 — Shiftable loads (EV, washing machine)
// ---------------------------------------------------------------------------
void RuleBasedStrategy::passShiftableLoads(
    QList<EnergyTimeSlot> &timeline,
    const QList<FlexibleLoad> &loads,
    const SchedulerConfig &config)
{
    foreach (const FlexibleLoad &load, loads) {
        if (load.type != LoadType::Shiftable)
            continue;

        // Determine how many slots we need to reach targetValue from currentValue.
        // For EV: assume kWh capacity is encoded in maxPowerW * (targetValue - currentValue)/100
        // For a simple model: count how many 1h-equivalent slots at maxPowerW we need.
        // Use a conservative estimate: plan for ~half the remaining need.
        double remainingPct = qMax(0.0, load.targetValue - load.currentValue);
        if (remainingPct <= 0)
            continue;

        // Slot duration in hours (assume slots are uniform)
        double slotHours = 1.0;
        if (!timeline.isEmpty()) {
            qint64 dur = timeline.first().start.secsTo(timeline.first().end);
            if (dur > 0)
                slotHours = dur / 3600.0;
        }

        // Estimate slots needed: remainingPct% of capacity / (maxPowerW * slotHours)
        // Capacity (Wh) = maxPowerW * (full charge duration in hours) — use targetValue as capacity hint
        // Simplified: energyNeeded = remainingPct/100 * (load.maxPowerW * 4h) -- rough 4h estimate
        double energyNeededWh = (remainingPct / 100.0) * load.maxPowerW * 4.0;
        int slotsNeeded = qMax(1, static_cast<int>(std::ceil(energyNeededWh / (load.maxPowerW * slotHours))));

        // Collect eligible slots before deadline
        QList<int> eligibleIndices;
        QDateTime deadline = load.deadline.isValid() ? load.deadline
                                                      : (timeline.isEmpty() ? QDateTime()
                                                                             : timeline.last().end);
        for (int i = 0; i < timeline.size(); ++i) {
            const EnergyTimeSlot &slot = timeline.at(i);
            if (slot.manualOverride)
                continue;
            if (deadline.isValid() && slot.start >= deadline)
                continue;
            // Only future slots (or current slot)
            eligibleIndices.append(i);
        }

        // Sort eligible slots by composite score (cheapest + most solar first)
        std::sort(eligibleIndices.begin(), eligibleIndices.end(),
                  [&](int a, int b) {
                      return slotScore(timeline.at(a), config) < slotScore(timeline.at(b), config);
                  });

        // Pick the best slotsNeeded slots
        int scheduled = 0;
        foreach (int idx, eligibleIndices) {
            if (scheduled >= slotsNeeded)
                break;

            EnergyTimeSlot &slot = timeline[idx];
            double surplus = slot.solarForecastW - slot.baseConsumptionW
                             - slot.allocatedToHP - slot.allocatedToDHW - slot.allocatedToBattery;
            bool hasSurplus  = surplus > config.solarSurplusThresholdW;
            bool cheapPrice  = slot.electricityPrice > 0
                               && slot.electricityPrice < config.chargePriceThreshold;

            if (load.source == LoadSource::SmartCharging) {
                slot.allocatedToEV = load.maxPowerW;

                QStringList rules;
                if (hasSurplus) {
                    rules.append("SolarSurplus");
                    slot.decisionReason = QString("Surplus solaire prévu +%1 kW — recharge VE gratuite")
                        .arg(surplus / 1000.0, 0, 'f', 1);
                } else if (cheapPrice) {
                    rules.append("PriceBelow" + QString::number(config.chargePriceThreshold));
                    slot.decisionReason = QString("Prix spot %1€/kWh (seuil %2€) — recharge VE planifiée")
                        .arg(slot.electricityPrice, 0, 'f', 3)
                        .arg(config.chargePriceThreshold, 0, 'f', 2);
                } else {
                    rules.append("EVDeadlineOk");
                    slot.decisionReason = QString("Créneau optimal avant deadline — recharge VE planifiée (%1€/kWh)")
                        .arg(slot.electricityPrice, 0, 'f', 3);
                }
                if (load.deadline.isValid())
                    rules.append("EVDeadlineOk");
                slot.decisionRules.append(rules);

            } else {
                // Generic shiftable: allocate to EV slot as fallback
                slot.allocatedToEV += load.maxPowerW;
                slot.decisionReason = QString("Charge flexible planifiée (%1) avant deadline")
                    .arg(load.displayName);
                slot.decisionRules.append("Shiftable");
            }

            scheduled++;
        }
    }
}

void RuleBasedStrategy::computeSlotResults(EnergyTimeSlot &slot) const
{
    double totalLoad = slot.baseConsumptionW
                       + slot.allocatedToEV
                       + slot.allocatedToHP
                       + slot.allocatedToDHW
                       + qMax(0.0, slot.allocatedToBattery)  // only charging
                       + slot.allocatedToFeedIn;

    double totalGeneration = slot.solarForecastW
                             + qMax(0.0, -slot.allocatedToBattery); // battery discharge

    slot.netGridPowerW = totalLoad - totalGeneration;

    double slotHours = 1.0;
    if (slot.start.isValid() && slot.end.isValid()) {
        qint64 secs = slot.start.secsTo(slot.end);
        if (secs > 0)
            slotHours = secs / 3600.0;
    }

    double importKwh = qMax(0.0, slot.netGridPowerW) * slotHours / 1000.0;
    double exportKwh = qMax(0.0, -slot.netGridPowerW) * slotHours / 1000.0;
    slot.estimatedCostEUR = importKwh * slot.electricityPrice
                            - exportKwh * slot.electricitySellPrice;

    if (totalLoad > 0)
        slot.selfSufficiencyPct = qMin(100.0, totalGeneration / totalLoad * 100.0);
    else
        slot.selfSufficiencyPct = 100.0;
}

void RuleBasedStrategy::fillMissingReasons(QList<EnergyTimeSlot> &timeline) const
{
    for (int i = 0; i < timeline.size(); ++i) {
        EnergyTimeSlot &slot = timeline[i];
        if (!slot.decisionReason.isEmpty())
            continue;

        // Build a generic reason from available data
        double surplus = slot.solarForecastW - slot.baseConsumptionW;
        if (surplus > 200)
            slot.decisionReason = QString("Surplus solaire +%1 kW — aucune charge flexible disponible")
                .arg(surplus / 1000.0, 0, 'f', 1);
        else if (slot.electricityPrice > 0 && slot.electricityPrice < 0.05)
            slot.decisionReason = QString("Prix très bas %1€/kWh — aucune charge flexible configurée")
                .arg(slot.electricityPrice, 0, 'f', 3);
        else
            slot.decisionReason = QStringLiteral("Créneau passif — toutes les charges flexibles satisfaites");
    }
}

double RuleBasedStrategy::slotScore(const EnergyTimeSlot &slot, const SchedulerConfig &config) const
{
    // Lower score = better slot for scheduling
    // Combine: low price (good), high solar surplus (good)
    double priceScore  = slot.electricityPrice; // lower is better
    double solarScore  = -(slot.solarForecastW - slot.baseConsumptionW)
                         / qMax(1.0, config.solarSurplusThresholdW); // negative = better (higher surplus)
    return priceScore + solarScore * 0.01; // price dominates, solar is tiebreaker
}
