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

#include "integrationpluginv2c.h"
#include "v2ctcpdiscovery.h"
#include "plugininfo.h"

#include <integrations/thing.h>
#include <integrations/thingactioninfo.h>
#include <integrations/thingdescriptor.h>
#include <integrations/thingdiscoveryinfo.h>
#include <integrations/thingsetupinfo.h>
#include <hardwaremanager.h>
#include <network/networkdevicediscovery.h>

#include <QDateTime>

IntegrationPluginV2c::IntegrationPluginV2c()
{}

void IntegrationPluginV2c::discoverThings(ThingDiscoveryInfo *info)
{
    V2cTcpDiscovery *discovery = new V2cTcpDiscovery(hardwareManager()->networkDeviceDiscovery(), info);

    connect(discovery, &V2cTcpDiscovery::discoveryFinished, info, [this, info, discovery]() {
        for (const V2cTcpDiscovery::Result &result : discovery->results()) {
            ThingDescriptor descriptor(trydanThingClassId,
                                       QStringLiteral("V2C Trydan"),
                                       result.ipAddress);
            ParamList params;
            params.append(Param(trydanThingMacAddressParamTypeId,
                                result.networkDeviceInfo.thingParamValueMacAddress()));
            params.append(Param(trydanThingHostNameParamTypeId,
                                result.networkDeviceInfo.thingParamValueHostName()));
            params.append(Param(trydanThingAddressParamTypeId,
                                result.networkDeviceInfo.thingParamValueAddress()));
            params.append(Param(trydanThingPortParamTypeId, 502));
            descriptor.setParams(params);

            if (Thing *existing = myThings().findByParams(params)) {
                descriptor.setThingId(existing->id());
            }
            info->addThingDescriptor(descriptor);
        }
        info->finish(Thing::ThingErrorNoError);
    });

    discovery->startDiscovery();
}

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

    if (m_tcpMasters.contains(thing)) {
        m_tcpMasters.take(thing)->deleteLater();
    }

    // Pre-req: the charger must have a static (DHCP-reserved) IP.
    // NetworkDeviceMonitor is intentionally absent: its periodic ICMP pings
    // saturate the Trydan ESP32 WiFi stack (observed latency > 14 s, causing
    // Modbus timeouts).  Connectivity is derived from Modbus poll results only.
    const QHostAddress address(thing->paramValue(trydanThingAddressParamTypeId).toString());
    const quint16 port    = static_cast<quint16>(thing->paramValue(trydanThingPortParamTypeId).toUInt());
    const quint16 slaveId = 1; // V2C Trydan always uses unit ID 1

    TrydanModbusTcpMaster *master = new TrydanModbusTcpMaster(address, port, slaveId, thing);

    connect(info, &ThingSetupInfo::aborted, master, &TrydanModbusTcpMaster::deleteLater);

    // Reachability is driven by Modbus poll results (not TCP state directly).
    // initialize() is now called from TrydanModbusTcpMaster::onConnectionStateChanged(true)
    // so we no longer need to call it here — this handler only updates the UI.
    connect(master, &TrydanModbusMaster::reachableChanged, thing,
            [this, thing](bool reachable) {
        if (!reachable) {
            setDisconnectedState(thing, QStringLiteral("timeout Modbus — vérifier signal WiFi de la borne"));
        }
        // reachable=true: UI cleared by next successful poll (updateThingStates).
    });

    // After init (first time or reconnect): expose current range and kick an immediate poll
    // so states are refreshed without waiting up to 30 s for the next timer tick.
    connect(master, &TrydanModbusMaster::initializationFinished, thing,
            [this, thing, master](bool success) {
        if (!success) {
            return;
        }
        thing->setStateValue(trydanConnectedStateTypeId, true);
        thing->setStateValue(trydanStatusMessageStateTypeId, QString());
        thing->setStateMinMaxValues(trydanMaxChargingCurrentStateTypeId,
                                    master->minIntensity(),
                                    master->maxIntensity());
        // Immediate first poll.  For setup the info handler below also calls update();
        // the m_updating guard makes the second call a no-op.
        master->update();
    });

    connect(master, &TrydanModbusMaster::initializationFinished, info,
            [this, info, thing, master](bool success) {
        if (!success) {
            master->deleteLater();
            info->finish(Thing::ThingErrorHardwareFailure,
                         QT_TR_NOOP("Could not communicate with the V2C Trydan charger."));
            return;
        }
        m_tcpMasters.insert(thing, master);
        master->update();
        info->finish(Thing::ThingErrorNoError);
    });

    // Each completed poll triggers a full state refresh + conflict handling.
    connect(master, &TrydanModbusMaster::updateFinished, thing,
            [this, thing, master]() {
        updateThingStates(thing, master);
        handleDynamicConflict(thing, master);
    });

    master->connectDevice();
}

void IntegrationPluginV2c::postSetupThing(Thing *thing)
{
    Q_UNUSED(thing)

    if (m_pluginTimer)
        return;

    m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(30);
    connect(m_pluginTimer, &PluginTimer::timeout, this, [this]() {
        for (TrydanModbusTcpMaster *master : m_tcpMasters) {
            // update() returns false only when the TCP socket is not connected
            // (or a poll/init is already running).  connectDevice() is a no-op
            // if the socket is already connected — it will NOT close and reopen
            // the connection, avoiding the SYN bursts that saturate the ESP32 WiFi.
            // Normal path: TCP stays open, only Modbus frames exchanged every 30 s.
            if (!master->update()) {
                master->connectDevice();
            }
        }
    });
    m_pluginTimer->start();
}

void IntegrationPluginV2c::thingRemoved(Thing *thing)
{
    delete m_tcpMasters.take(thing);

    if (myThings().isEmpty() && m_pluginTimer) {
        hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer);
        m_pluginTimer = nullptr;
    }
}

void IntegrationPluginV2c::executeAction(ThingActionInfo *info)
{
    Thing *thing = info->thing();
    TrydanModbusTcpMaster *master = m_tcpMasters.value(thing);

    if (!master || !master->reachable()) {
        info->finish(Thing::ThingErrorHardwareNotAvailable,
                     QT_TR_NOOP("The V2C Trydan charger is not reachable."));
        return;
    }

    if (info->action().actionTypeId() == trydanPowerActionTypeId) {
        const bool enable = info->action().paramValue(trydanPowerActionPowerParamTypeId).toBool();

        // setChargingEnabled — always mirror PauseState and Lock together.
        // cf. evcc trydan.go Enable(): both registers must be written consistently.
        // PauseState: 1=paused(disabled) / 0=active(enabled)
        // Lock:       1=locked(disabled) / 0=unlocked(enabled)
        const quint16 pauseVal = enable ? 0 : 1;
        const quint16 lockVal  = enable ? 0 : 1;

        // writeCompleted carries the originating register address so we can ignore
        // concurrent background writes (e.g. PauseDynamic from conflict management).
        master->writePauseState(pauseVal);
        connect(master, &TrydanModbusMaster::writeCompleted, info,
                [this, info, thing, master, lockVal, enable]
                (quint16 addr, bool ok) {
            if (addr != TrydanModbusMaster::WRegPauseState) return; // ignore others
            disconnect(master, &TrydanModbusMaster::writeCompleted, info, nullptr);
            if (!ok) { info->finish(Thing::ThingErrorHardwareFailure); return; }

            master->writeLock(lockVal);
            connect(master, &TrydanModbusMaster::writeCompleted, info,
                    [this, info, thing, master, enable](quint16 addr2, bool ok2) {
                if (addr2 != TrydanModbusMaster::WRegLock) return; // ignore others
                disconnect(master, &TrydanModbusMaster::writeCompleted, info, nullptr);
                if (ok2) {
                    thing->setStateValue(trydanPowerStateTypeId, enable);
                    // Fire-and-forget: apply PauseDynamic conflict suppression.
                    // Does not block the action — next poll will reflect the result.
                    if (master->dynamicMode() == 1) {
                        const bool suspend = thing->setting(
                            trydanSettingsSuspendInternalOptimizerParamTypeId).toBool();
                        if (suspend)
                            master->writePauseDynamic(enable ? 1 : 0);
                    }
                    master->update();
                    info->finish(Thing::ThingErrorNoError);
                } else {
                    info->finish(Thing::ThingErrorHardwareFailure);
                }
            });
        });
        return;
    }

    if (info->action().actionTypeId() == trydanMaxChargingCurrentActionTypeId) {
        double current = info->action().paramValue(
            trydanMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toDouble();

        // Clamp to IEC 61851 minimum.  Below 6 A → pause rather than send invalid setpoint.
        if (current < 6.0) {
            master->writePauseState(1);
            master->writeLock(1);
            thing->setStateValue(trydanPowerStateTypeId, false);
            info->finish(Thing::ThingErrorNoError);
            return;
        }
        current = qMin(current, static_cast<double>(master->maxIntensity()));

        const quint16 amps = static_cast<quint16>(qRound(current));
        master->writeIntensity(amps);
        connect(master, &TrydanModbusMaster::writeCompleted, info,
                [this, info, thing, master, current](quint16 addr, bool ok) {
            if (addr != TrydanModbusMaster::WRegIntensity) return;
            disconnect(master, &TrydanModbusMaster::writeCompleted, info, nullptr);
            if (ok) {
                thing->setStateValue(trydanMaxChargingCurrentStateTypeId, current);
                master->update();
                info->finish(Thing::ThingErrorNoError);
            } else {
                info->finish(Thing::ThingErrorHardwareFailure);
            }
        });
        return;
    }

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

// --- Private ---

/*!
 * SlaveError register (0x0BC5) firmware table, confirmed on Trydan fw2.4.6.
 * Returns empty string for code 0 (no error).
 * Code 4 also triggers a statusMessage update in updateThingStates().
 */
static QString decodeSlaveError(int code)
{
    switch (code) {
    case 0:  return QString();
    case 1:  return QStringLiteral("Erreur communication Modbus");
    case 2:  return QStringLiteral("Erreur lecture registre");
    case 3:  return QStringLiteral("Erreur esclave");
    case 4:  return QStringLiteral("Borne en reconnexion WiFi");
    case 5:  return QStringLiteral("Attente communication");
    case 6:  return QStringLiteral("Mauvaise IP");
    case 7:  return QStringLiteral("Esclave introuvable");
    case 8:  return QStringLiteral("Mauvais esclave");
    case 9:  return QStringLiteral("Pas de réponse");
    case 10: return QStringLiteral("Pince non connectée");
    default: return QStringLiteral("Code erreur inconnu (%1)").arg(code);
    }
}

void IntegrationPluginV2c::updateThingStates(Thing *thing, TrydanModbusMaster *master)
{
    thing->setStateValue(trydanConnectedStateTypeId, master->reachable());

    // Poll succeeded → clear any previous offline message.
    thing->setStateValue(trydanStatusMessageStateTypeId, QString());

    // ChargeState: 0=A(disconnected), 1=B(connected, not charging), 2=C(charging).
    // cf. IEC 61851 and evcc trydan.go Status().
    const int cs = master->chargeState();
    thing->setStateValue(trydanPluggedInStateTypeId,  cs >= 1);
    thing->setStateValue(trydanChargingStateTypeId,   cs == 2);

    // chargingEnabled = PauseState==0 AND Lock==0 (cf. evcc trydan.go Enabled()).
    thing->setStateValue(trydanPowerStateTypeId,
                         master->pauseState() == 0 && master->lock() == 0);

    thing->setStateValue(trydanCurrentPowerStateTypeId,   static_cast<double>(master->chargePower()));
    thing->setStateValue(trydanHousePowerStateTypeId,     static_cast<double>(master->housePower()));
    thing->setStateValue(trydanSolarPowerStateTypeId,     static_cast<double>(master->powerFV()));
    thing->setStateValue(trydanChargeEnergyStateTypeId,   static_cast<double>(master->chargeEnergy()));
    thing->setStateValue(trydanIntensityStateTypeId,      static_cast<double>(master->intensity()));
    thing->setStateValue(trydanPowerL1StateTypeId,        static_cast<double>(master->powerL1()));
    thing->setStateValue(trydanPowerL2StateTypeId,        static_cast<double>(master->powerL2()));
    thing->setStateValue(trydanPowerL3StateTypeId,        static_cast<double>(master->powerL3()));

    const int slaveErr = master->slaveError();
    thing->setStateValue(trydanSlaveErrorStateTypeId,      static_cast<uint>(slaveErr));
    const QString errMsg = decodeSlaveError(slaveErr);
    thing->setStateValue(trydanSlaveErrorMessageStateTypeId, errMsg);
    // Code 4: borne drops WiFi mid-session and reconnects — surface it prominently.
    if (slaveErr == 4) {
        thing->setStateValue(trydanStatusMessageStateTypeId,
                             QStringLiteral("Borne en reconnexion WiFi"));
    }

    // Dynamic==1 means the V2C internal PV optimizer is configured and running.
    const bool conflict = (master->dynamicMode() == 1);
    thing->setStateValue(trydanConflictDetectedStateTypeId,   conflict);
    thing->setStateValue(trydanOptimizerSuspendedStateTypeId, master->pauseDynamic() == 1);

    if (conflict) {
        qCDebug(dcV2C()) << thing->name()
                         << ": V2C internal optimizer is active (Dynamic==1). "
                            "HEMS will suppress it via PauseDynamic if suspendInternalOptimizer==true.";
    }
}

void IntegrationPluginV2c::handleDynamicConflict(Thing *thing, TrydanModbusMaster *master)
{
    // The V2C Trydan embeds its own solar-tracking PID ("Dynamic mode").
    // Two controllers must not drive the same charger simultaneously.
    //
    // Correct suppression: write PauseDynamic=1 to freeze the internal PID.
    // WRONG approach: write Dynamic=0 — this silences ChargePower telemetry.
    // cf. evcc charger/trydan.go and github.com/evcc-io/evcc/issues/28047.
    //
    // PauseDynamic is only relevant when Dynamic==1 (optimizer configured on device).
    // We re-evaluate every poll because the V2C app can toggle Dynamic at any time.

    if (master->dynamicMode() != 1)
        return; // No internal optimizer — nothing to manage.

    const bool suspendAllowed = thing->setting(trydanSettingsSuspendInternalOptimizerParamTypeId).toBool();
    if (!suspendAllowed) {
        qCWarning(dcV2C()) << thing->name()
                           << ": V2C internal optimizer active but suspendInternalOptimizer==false. "
                              "HEMS and V2C PID may fight — expect oscillations.";
        return;
    }

    // HEMS is in control when chargingEnabled is true.
    const bool hemsInControl = (master->pauseState() == 0 && master->lock() == 0);
    const int  desired       = hemsInControl ? 1 : 0;

    // Only write on transition to avoid unnecessary flash wear.
    if (master->pauseDynamic() != desired) {
        qCDebug(dcV2C()) << thing->name()
                         << ": writing PauseDynamic =" << desired
                         << (hemsInControl ? "(HEMS taking control)" : "(releasing to V2C PID)");
        master->writePauseDynamic(static_cast<quint16>(desired));
    }
}

void IntegrationPluginV2c::setDisconnectedState(Thing *thing, const QString &cause)
{
    thing->setStateValue(trydanConnectedStateTypeId, false);
    thing->setStateValue(trydanChargingStateTypeId,  false);
    thing->setStateValue(trydanPluggedInStateTypeId, false);
    thing->setStateValue(trydanCurrentPowerStateTypeId, 0.0);

    // Inform the user with a timestamp so they know when the outage started,
    // and a hint pointing to the most likely cause (WiFi signal on this charger).
    const QString ts  = QDateTime::currentDateTime().toString(QStringLiteral("dd/MM HH:mm"));
    const QString msg = cause.isEmpty()
        ? QStringLiteral("Hors ligne depuis ") + ts
        : QStringLiteral("Hors ligne depuis ") + ts + QStringLiteral(" — ") + cause;
    thing->setStateValue(trydanStatusMessageStateTypeId, msg);
}
