// 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 "testscheduler.h"
#include <QSignalSpy>
#include <algorithm>

// Typical winter day electricity prices (EUR/kWh), hours 00:00–23:00
// Cheap night: 00–06, morning peak: 07–09, midday dip: 10–14, evening peak: 16–20
const double TestScheduler::winterPrices[24] = {
    0.05, 0.05, 0.04, 0.04, 0.04, 0.05,  // 00–05 cheap night
    0.12, 0.16, 0.14, 0.11, 0.09, 0.08,  // 06–11 morning peak → midday dip
    0.07, 0.07, 0.08, 0.09, 0.14, 0.16,  // 12–17 midday → evening peak
    0.18, 0.17, 0.15, 0.12, 0.10, 0.08   // 18–23 evening peak → falling
};

TestScheduler::TestScheduler(QObject *parent)
    : QObject(parent)
{
}

// ---------------------------------------------------------------------------
// Helper: build 24h winter forecast starting at 'start' (hourly slots)
// ---------------------------------------------------------------------------
QList<EnergyTimeSlot> TestScheduler::buildWinterForecast(const QDateTime &start) const
{
    // Solar bell curve peaking at noon (~4 kW, 0 during night)
    static const double solarW[24] = {
        0, 0, 0, 0, 0, 0,              // 00–05: no sun
        200, 600, 1200, 2400, 3600, 4000, // 06–11: rising
        4200, 4000, 3600, 2800, 1600, 600, // 12–17: peak then falling
        0, 0, 0, 0, 0, 0               // 18–23: no sun
    };
    static const double baseW = 1200.0; // constant base load

    QList<EnergyTimeSlot> forecast;
    for (int h = 0; h < 24; ++h) {
        EnergyTimeSlot slot;
        slot.start = start.addSecs(h * 3600);
        slot.end   = start.addSecs((h + 1) * 3600);
        // Index by actual UTC hour so solar peaks at noon and prices reflect time-of-day
        int utcHour = slot.start.toUTC().time().hour();
        slot.solarForecastW   = solarW[utcHour];
        slot.baseConsumptionW = baseW;
        slot.electricityPrice = winterPrices[utcHour];
        slot.electricitySellPrice = 0.04;
        forecast.append(slot);
    }
    return forecast;
}

bool TestScheduler::allActiveSlotsHaveReason(const QList<EnergyTimeSlot> &timeline) const
{
    foreach (const EnergyTimeSlot &slot, timeline) {
        // An "active" slot is one that has any non-zero allocation
        bool active = slot.allocatedToEV > 0
                   || slot.allocatedToHP > 0
                   || slot.allocatedToDHW > 0
                   || qAbs(slot.allocatedToBattery) > 0;
        if (active && slot.decisionReason.isEmpty())
            return false;
        // All slots (even passive ones) must have a reason — enforced by fillMissingReasons
        if (slot.decisionReason.isEmpty())
            return false;
    }
    return true;
}

int TestScheduler::countSlotsInCheapestN(const QList<EnergyTimeSlot> &timeline,
                                         const QDateTime &deadline,
                                         int n) const
{
    // Find all eligible slots before deadline
    QList<double> prices;
    foreach (const EnergyTimeSlot &slot, timeline) {
        if (slot.start < deadline && slot.electricityPrice > 0)
            prices.append(slot.electricityPrice);
    }
    std::sort(prices.begin(), prices.end());
    if (n > prices.size())
        n = prices.size();
    double threshold = (n > 0) ? prices.at(n - 1) : 999.0;

    int charged = 0;
    foreach (const EnergyTimeSlot &slot, timeline) {
        if (slot.allocatedToEV > 0 && slot.start < deadline) {
            if (slot.electricityPrice <= threshold)
                charged++;
        }
    }
    return charged;
}

// ---------------------------------------------------------------------------
// Test 1 — RuleBasedStrategy, typical winter day
// ---------------------------------------------------------------------------
void TestScheduler::testRuleBasedStrategy_winterDay()
{
    // Scenario: EV plugged in at 18:00, deadline next day 07:00, SOC 40%, target 80%
    // → Charging should be scheduled in cheap night slots 00:00–06:00 and/or 23:00

    // Start the forecast at "today 18:00 UTC"
    QDateTime base = QDateTime(QDate::currentDate(), QTime(18, 0, 0), Qt::UTC);
    QList<EnergyTimeSlot> forecast = buildWinterForecast(base);

    // EV load: Shiftable, SmartCharging, 7400W (typical 32A/1ph), SOC 40→80%
    FlexibleLoad evLoad;
    evLoad.thingId      = ThingId::createThingId();
    evLoad.displayName  = QStringLiteral("Test EV");
    evLoad.type         = LoadType::Shiftable;
    evLoad.source       = LoadSource::SmartCharging;
    evLoad.minPowerW    = 1400;
    evLoad.maxPowerW    = 7400;
    evLoad.currentValue = 40;
    evLoad.targetValue  = 80;
    evLoad.priority     = 0.7;
    evLoad.deadline     = base.addSecs(13 * 3600); // 07:00 next day

    SchedulerConfig config;
    config.chargePriceThreshold   = 0.08;
    config.solarSurplusThresholdW = 200.0;

    RuleBasedStrategy strategy;
    QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {evLoad}, config);

    // Verify: 24 slots returned
    QCOMPARE(result.size(), 24);

    // Verify: all slots have a non-empty decisionReason
    QVERIFY2(allActiveSlotsHaveReason(result),
             "Some active slots are missing a decisionReason");

    // Verify: EV is scheduled somewhere
    int evChargingSlots = 0;
    foreach (const EnergyTimeSlot &slot, result)
        if (slot.allocatedToEV > 0)
            evChargingSlots++;
    QVERIFY2(evChargingSlots > 0, "EV was never scheduled for charging");

    // Verify: at least half of charged slots fall within the cheapest hours before deadline
    int cheapSlots = countSlotsInCheapestN(result, evLoad.deadline, 6);
    QVERIFY2(cheapSlots >= evChargingSlots / 2,
             QString("Only %1/%2 EV slots fall in the 6 cheapest hours")
             .arg(cheapSlots).arg(evChargingSlots).toUtf8());

    // Verify: decisionReason for EV slots is not generic fallback
    foreach (const EnergyTimeSlot &slot, result) {
        if (slot.allocatedToEV > 0) {
            QVERIFY2(!slot.decisionReason.isEmpty(), "EV charging slot has no reason");
            QVERIFY2(!slot.decisionRules.isEmpty(),  "EV charging slot has no rules");
        }
    }
}

// ---------------------------------------------------------------------------
// Test 2 — Manual override is respected
// ---------------------------------------------------------------------------
void TestScheduler::testManualOverrideRespected()
{
    QDateTime base = QDateTime(QDate::currentDate(), QTime(18, 0, 0), Qt::UTC);
    QList<EnergyTimeSlot> forecast = buildWinterForecast(base);

    // Override slot at 23:00 (index 5 from 18h = hour 5)
    QDateTime overrideStart = base.addSecs(5 * 3600); // 23:00
    for (int i = 0; i < forecast.size(); ++i) {
        if (forecast.at(i).start == overrideStart) {
            forecast[i].manualOverride  = true;
            forecast[i].overrideReason  = QStringLiteral("Départ annulé — ne pas charger ce soir");
            forecast[i].allocatedToEV   = 0;
            forecast[i].decisionReason  = QStringLiteral("Décision manuelle : Départ annulé");
            forecast[i].decisionRules   = QStringList() << QStringLiteral("ManualOverride");
            break;
        }
    }

    FlexibleLoad evLoad;
    evLoad.thingId      = ThingId::createThingId();
    evLoad.displayName  = QStringLiteral("Test EV");
    evLoad.type         = LoadType::Shiftable;
    evLoad.source       = LoadSource::SmartCharging;
    evLoad.minPowerW    = 1400;
    evLoad.maxPowerW    = 7400;
    evLoad.currentValue = 40;
    evLoad.targetValue  = 80;
    evLoad.priority     = 0.7;
    evLoad.deadline     = base.addSecs(13 * 3600);

    SchedulerConfig config;
    config.chargePriceThreshold = 0.08;

    RuleBasedStrategy strategy;
    QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {evLoad}, config);

    // Verify: overridden slot is unchanged
    foreach (const EnergyTimeSlot &slot, result) {
        if (slot.start == overrideStart) {
            QVERIFY2(slot.manualOverride, "Manual override flag was cleared by Scheduler");
            QCOMPARE(slot.allocatedToEV, 0.0);
            QVERIFY2(slot.decisionRules.contains("ManualOverride"),
                     "ManualOverride rule was removed");
        }
    }

    // Verify: other slots are still computed (EV is charged elsewhere)
    int evChargingSlots = 0;
    foreach (const EnergyTimeSlot &slot, result) {
        if (slot.start != overrideStart && slot.allocatedToEV > 0)
            evChargingSlots++;
    }
    QVERIFY2(evChargingSlots > 0,
             "No EV charging scheduled in non-overridden slots");
}

// ---------------------------------------------------------------------------
// Test 3 — Fallback when all prices = 0 (no tariff data)
// ---------------------------------------------------------------------------
void TestScheduler::testFallbackEmptyTariffData()
{
    QDateTime base = QDateTime(QDate::currentDate(), QTime(0, 0, 0), Qt::UTC);

    // Build forecast with all prices = 0 (TariffManager returned nothing)
    QList<EnergyTimeSlot> forecast = buildWinterForecast(base);
    for (int i = 0; i < forecast.size(); ++i)
        forecast[i].electricityPrice = 0.0;

    // Critical HP load (inflexible, priority ≥ 0.9)
    FlexibleLoad criticalHP;
    criticalHP.thingId      = ThingId::createThingId();
    criticalHP.displayName  = QStringLiteral("Critical Heating");
    criticalHP.type         = LoadType::Inflexible;
    criticalHP.source       = LoadSource::HeatPump;
    criticalHP.minPowerW    = 2000;
    criticalHP.maxPowerW    = 2000;
    criticalHP.currentPowerW= 2000;
    criticalHP.priority     = 0.95;

    SchedulerConfig config;
    config.chargePriceThreshold = 0.08; // No slot qualifies (all prices = 0)

    RuleBasedStrategy strategy;
    QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {criticalHP}, config);

    // Verify: plan is non-empty
    QCOMPARE(result.size(), 24);

    // Verify: critical HP is allocated in every slot
    foreach (const EnergyTimeSlot &slot, result) {
        QVERIFY2(slot.allocatedToHP >= criticalHP.currentPowerW,
                 QString("Critical HP not allocated in slot %1")
                 .arg(slot.start.toString("hh:mm")).toUtf8());
    }

    // Verify: all slots have a reason (no crashes, no empty reasons)
    QVERIFY2(allActiveSlotsHaveReason(result),
             "Some slots are missing decisionReason when tariff data is empty");
}

// ---------------------------------------------------------------------------
// Test 4 — Hot strategy swap (RuleBasedStrategy → AIStrategy stub)
// ---------------------------------------------------------------------------
void TestScheduler::testHotStrategySwap()
{
    QDateTime base = QDateTime(QDate::currentDate(), QTime(0, 0, 0), Qt::UTC);
    QList<EnergyTimeSlot> forecast = buildWinterForecast(base);

    FlexibleLoad evLoad;
    evLoad.thingId      = ThingId::createThingId();
    evLoad.displayName  = QStringLiteral("Test EV");
    evLoad.type         = LoadType::Shiftable;
    evLoad.source       = LoadSource::SmartCharging;
    evLoad.minPowerW    = 1400;
    evLoad.maxPowerW    = 7400;
    evLoad.currentValue = 40;
    evLoad.targetValue  = 80;
    evLoad.priority     = 0.7;
    evLoad.deadline     = base.addSecs(13 * 3600);

    SchedulerConfig config;
    config.chargePriceThreshold = 0.08;

    // Step 1: compute with RuleBasedStrategy
    RuleBasedStrategy ruleStrategy;
    QList<EnergyTimeSlot> ruleResult = ruleStrategy.computeSchedule(forecast, {evLoad}, config);
    QCOMPARE(ruleResult.size(), 24);

    // Step 2: swap to AIStrategy (stub)
    AIStrategy aiStrategy;
    QVERIFY2(!aiStrategy.isAvailable(), "AI strategy should report unavailable");

    QSignalSpy unavailableSpy(&aiStrategy, &ISchedulingStrategy::strategyUnavailable);
    QList<EnergyTimeSlot> aiResult = aiStrategy.computeSchedule(forecast, {evLoad}, config);

    // Verify: AI returns a plan of correct size (no crash)
    QCOMPARE(aiResult.size(), 24);

    // Verify: AI emitted strategyUnavailable signal
    QVERIFY2(unavailableSpy.count() > 0, "AIStrategy did not emit strategyUnavailable");

    // Verify: every AI slot has a non-empty decisionReason
    foreach (const EnergyTimeSlot &slot, aiResult) {
        QVERIFY2(!slot.decisionReason.isEmpty(),
                 "AI stub returned slot without decisionReason");
    }

    // Verify: AI slot reason contains "model" keyword (fallback message)
    const EnergyTimeSlot &firstSlot = aiResult.first();
    QVERIFY2(firstSlot.decisionReason.contains("IA", Qt::CaseInsensitive)
             || firstSlot.decisionReason.contains("AI", Qt::CaseInsensitive)
             || firstSlot.decisionReason.contains("modèle", Qt::CaseInsensitive)
             || firstSlot.decisionReason.contains("model", Qt::CaseInsensitive),
             "AI stub reason does not mention model/fallback");

    // Verify: AI result has AIModelNotLoaded rule
    QVERIFY2(firstSlot.decisionRules.contains("AIModelNotLoaded"),
             "AI stub missing AIModelNotLoaded rule tag");
}

// ---------------------------------------------------------------------------
// Test 5 — ManualStrategy: slot configured → allocations applied exactly
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_basicSlot()
{
    // Slot: today 22:00 UTC → tomorrow 06:00 UTC, ev=2000W, battery=1000W
    QDate today = QDate::currentDate();
    QDateTime slotStart = QDateTime(today, QTime(22, 0, 0), Qt::UTC);
    QDateTime slotEnd   = QDateTime(today.addDays(1), QTime(6, 0, 0), Qt::UTC);

    ManualSlotConfig cfg;
    cfg.start = slotStart;
    cfg.end   = slotEnd;
    cfg.label = QStringLiteral("Recharge VE nuit");
    cfg.repeating = false;
    cfg.powerAllocations.insert(LoadSource::SmartCharging, 2000.0);
    cfg.powerAllocations.insert(LoadSource::Battery,       1000.0);

    ManualStrategy strategy;
    strategy.setManualSlot(cfg);

    // Build a 2-slot forecast covering 22:00 and 23:00
    QList<EnergyTimeSlot> forecast;
    for (int h = 0; h < 2; ++h) {
        EnergyTimeSlot s;
        s.start = slotStart.addSecs(h * 3600);
        s.end   = slotStart.addSecs((h + 1) * 3600);
        forecast.append(s);
    }

    SchedulerConfig config;
    QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);

    QCOMPARE(result.size(), 2);

    // Both slots fall within 22:00–06:00 → allocations must match exactly
    foreach (const EnergyTimeSlot &slot, result) {
        QCOMPARE(slot.allocatedToEV,      2000.0);
        QCOMPARE(slot.allocatedToBattery, 1000.0);
        QVERIFY2(!slot.decisionReason.isEmpty(), "decisionReason must not be empty");
        QVERIFY2(slot.decisionReason.contains("manuel", Qt::CaseInsensitive) ||
                 slot.decisionReason.contains("Recharge VE nuit"),
                 "decisionReason should mention manual or label");
        QVERIFY2(slot.decisionRules.contains("ManualSlot"),
                 "decisionRules must contain ManualSlot");
    }
}

// ---------------------------------------------------------------------------
// Test 6 — ManualStrategy: no config → fallback with safe reason
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_noConfig_fallback()
{
    // No manual slot configured at all
    ManualStrategy strategy;

    QDateTime base = QDateTime(QDate::currentDate(), QTime(10, 0, 0), Qt::UTC);

    QList<EnergyTimeSlot> forecast;
    for (int h = 0; h < 3; ++h) {
        EnergyTimeSlot s;
        s.start = base.addSecs(h * 3600);
        s.end   = base.addSecs((h + 1) * 3600);
        forecast.append(s);
    }

    SchedulerConfig config;
    QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);

    QCOMPARE(result.size(), 3);
    foreach (const EnergyTimeSlot &slot, result) {
        QCOMPARE(slot.allocatedToEV,      0.0);
        QCOMPARE(slot.allocatedToBattery, 0.0);
        QVERIFY2(!slot.decisionReason.isEmpty(),
                 "decisionReason must never be empty");
        QVERIFY2(slot.decisionReason.contains("Aucune configuration",
                                              Qt::CaseInsensitive) ||
                 slot.decisionRules.contains("ManualDefault"),
                 "Slot without config must report ManualDefault");
    }
}

// ---------------------------------------------------------------------------
// Test 7 — ManualStrategy: expired slot is ignored, reason set correctly
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_expiredSlot()
{
    QDate today = QDate::currentDate();
    QDateTime slotStart = QDateTime(today, QTime(14, 0, 0), Qt::UTC);
    QDateTime slotEnd   = QDateTime(today, QTime(16, 0, 0), Qt::UTC);

    ManualSlotConfig cfg;
    cfg.start     = slotStart;
    cfg.end       = slotEnd;
    cfg.label     = QStringLiteral("Créneau dépassé");
    cfg.repeating = false;
    // Expiry = yesterday → already expired
    cfg.expiresAt = QDateTime::currentDateTimeUtc().addDays(-1);
    cfg.powerAllocations.insert(LoadSource::SmartCharging, 5000.0);

    ManualStrategy strategy;
    strategy.setManualSlot(cfg);

    QList<EnergyTimeSlot> forecast;
    EnergyTimeSlot s;
    s.start = slotStart;
    s.end   = slotStart.addSecs(3600);
    forecast.append(s);

    SchedulerConfig config;
    QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);

    QCOMPARE(result.size(), 1);
    const EnergyTimeSlot &slot = result.first();

    // Expired slot: EV must NOT be charged
    QCOMPARE(slot.allocatedToEV, 0.0);

    // Reason must clearly state expiry
    QVERIFY2(slot.decisionReason.contains("expiré", Qt::CaseInsensitive),
             "decisionReason must mention expiry");
    QVERIFY2(slot.decisionRules.contains("ExpiredSlot"),
             "decisionRules must contain ExpiredSlot");
}

// ---------------------------------------------------------------------------
// Test 8 — ManualStrategy: repeating weekly slot applied to next recurrence
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_repeatingSlot()
{
    // Find the most recent Monday as our anchor
    QDate anchor = QDate::currentDate();
    while (anchor.dayOfWeek() != 1) // Qt: 1 = Monday
        anchor = anchor.addDays(-1);

    ManualSlotConfig cfg;
    cfg.start     = QDateTime(anchor,              QTime(22, 0, 0), Qt::UTC);
    cfg.end       = QDateTime(anchor.addDays(1),   QTime(6,  0, 0), Qt::UTC); // overnight
    cfg.label     = QStringLiteral("Recharge hebdomadaire");
    cfg.repeating = true;
    // No expiresAt — repeats indefinitely
    cfg.powerAllocations.insert(LoadSource::SmartCharging, 3000.0);

    ManualStrategy strategy;
    strategy.setManualSlot(cfg);

    // Next Monday 23:00 — should match the repeating window Mon 22:00 → Tue 06:00
    QDate nextMonday = anchor.addDays(7);
    QDateTime testSlot = QDateTime(nextMonday, QTime(23, 0, 0), Qt::UTC);

    QList<EnergyTimeSlot> forecast;
    EnergyTimeSlot s;
    s.start = testSlot;
    s.end   = testSlot.addSecs(3600);
    forecast.append(s);

    SchedulerConfig config;
    QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);

    QCOMPARE(result.size(), 1);
    QVERIFY2(result.first().allocatedToEV > 0,
             "Repeating slot must be applied to next weekly recurrence");
    QCOMPARE(result.first().allocatedToEV, 3000.0);
    QVERIFY2(result.first().decisionReason.contains("Recharge hebdomadaire"),
             "decisionReason must contain the slot label");
}

// ---------------------------------------------------------------------------
// Test 9 — ManualStrategy: JSON round-trip (persistence simulation)
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_persistence()
{
    QDate today = QDate::currentDate();

    ManualSlotConfig original;
    original.start     = QDateTime(today, QTime(22, 0, 0), Qt::UTC);
    original.end       = QDateTime(today.addDays(1), QTime(6, 0, 0), Qt::UTC);
    original.label     = QStringLiteral("Recharge VE nuit");
    original.repeating = false;
    original.expiresAt = QDateTime::currentDateTimeUtc().addDays(7); // future
    original.powerAllocations.insert(LoadSource::SmartCharging, 2000.0);
    original.powerAllocations.insert(LoadSource::Battery,       1000.0);

    // Serialize → deserialize
    QVariantMap json     = original.toJson();
    ManualSlotConfig restored = ManualSlotConfig::fromJson(json);

    QCOMPARE(restored.start,     original.start);
    QCOMPARE(restored.end,       original.end);
    QCOMPARE(restored.label,     original.label);
    QCOMPARE(restored.repeating, original.repeating);
    QCOMPARE(restored.expiresAt, original.expiresAt);
    QCOMPARE(restored.powerAllocations.value(LoadSource::SmartCharging), 2000.0);
    QCOMPARE(restored.powerAllocations.value(LoadSource::Battery),       1000.0);

    // Verify the restored config is functional: apply it in a new strategy instance
    ManualStrategy strategy;
    strategy.setManualSlot(restored);

    QList<EnergyTimeSlot> forecast;
    EnergyTimeSlot s;
    s.start = original.start;
    s.end   = original.start.addSecs(3600);
    forecast.append(s);

    SchedulerConfig config;
    QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);

    QCOMPARE(result.size(), 1);
    QCOMPARE(result.first().allocatedToEV,      2000.0);
    QCOMPARE(result.first().allocatedToBattery, 1000.0);
    QVERIFY2(result.first().decisionRules.contains("ManualSlot"),
             "Restored slot must produce ManualSlot rule after round-trip");
}

// ---------------------------------------------------------------------------
// Test runner
// ---------------------------------------------------------------------------
QTEST_MAIN(TestScheduler)
