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

#include "trydanmodbustcpmaster.h"

#include <modbustcpmaster.h>

#include <QtSerialBus>

#include "extern-plugininfo.h"

TrydanModbusTcpMaster::TrydanModbusTcpMaster(const QHostAddress &address,
                                             quint16 port,
                                             quint16 slaveId,
                                             QObject *parent)
    : TrydanModbusMaster(parent),
      m_slaveId(slaveId)
{
    m_modbusTcpMaster = new ModbusTcpMaster(address, port, this);
    // No retry per individual read: abort fast on first timeout so the 30s poll
    // cycle is not blocked for minutes on a dropped connection.
    m_modbusTcpMaster->setNumberOfRetries(0);
    // 5 s tolerates the WiFi latency spikes observed on the Trydan ESP32
    // without waiting forever when the charger is genuinely unreachable.
    m_modbusTcpMaster->setTimeout(5000);

    connect(m_modbusTcpMaster, &ModbusTcpMaster::connectionStateChanged,
            this, &TrydanModbusTcpMaster::onConnectionStateChanged);
}

TrydanModbusTcpMaster::~TrydanModbusTcpMaster()
{
    // m_modbusTcpMaster is a child QObject and will be deleted automatically.
}

ModbusTcpMaster *TrydanModbusTcpMaster::modbusTcpMaster() const
{
    return m_modbusTcpMaster;
}

void TrydanModbusTcpMaster::setHostAddress(const QHostAddress &address)
{
    m_modbusTcpMaster->setHostAddress(address);
}

void TrydanModbusTcpMaster::connectDevice()
{
    // Guard: calling connectDevice() on an already-connected QModbusTcpClient
    // logs a warning AND closes+reopens the TCP socket, generating a SYN burst
    // that saturates the Trydan ESP32 WiFi stack.  Skip if already connected.
    if (m_modbusTcpMaster->connected())
        return;
    m_modbusTcpMaster->connectDevice();
}

void TrydanModbusTcpMaster::disconnectDevice()
{
    m_modbusTcpMaster->disconnectDevice();
    setReachable(false);
}

bool TrydanModbusTcpMaster::initialize()
{
    // Also guard against an ongoing poll: init and update share the same sequential
    // read pipeline; running both concurrently would interleave FC3 transactions
    // and corrupt both sequences.
    if (!m_modbusTcpMaster->connected() || m_initializing || m_updating)
        return false;

    m_initializing = true;
    runReadSequence(buildInitSteps(), [this](bool ok) {
        m_initializing = false;
        if (ok) {
            // qBound to IEC 61851 absolute limits [6, 32].  The real installation
            // ceiling comes from MaxIntensity read above (a 16 A mono Trydan reports
            // 16, not 32).  The lower guard (qMax 6 was wrong for aberrant 0 returns:
            // qMin(32, 0)=0 → maxIntensity()==0 → every setpoint gets clamped to 0
            // and then the <6 A path pauses charging silently).  qBound floors to 6
            // on a spurious 0 and caps to 32 on a spurious 65535.
            m_minIntensity = qBound(6, m_minIntensity, 32);
            m_maxIntensity = qBound(6, m_maxIntensity, 32);
            // TCP is live and init succeeded — mark reachable so the plugin can
            // update states.  This is the ONLY place setReachable(true) is called
            // from the connection path; onConnectionStateChanged(true) no longer
            // does it directly, avoiding a re-entrant initialize() call via the
            // reachableChanged signal.
            setReachable(true);
        }
        emit initializationFinished(ok);
    });
    return true;
}

bool TrydanModbusTcpMaster::update()
{
    // Guard against concurrent init: both use the same sequential FC3 pipeline.
    if (!m_modbusTcpMaster->connected() || m_updating || m_initializing)
        return false;

    m_updating = true;
    runReadSequence(buildPollSteps(), [this](bool ok) {
        m_updating = false;
        if (ok) {
            m_errorCount = 0;
            setReachable(true);
            emit updateFinished();
        } else {
            handleError();
        }
    });
    return true;
}

void TrydanModbusTcpMaster::writePauseState(quint16 value)
{
    writeFC6(WRegPauseState, value,
             [this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
}

void TrydanModbusTcpMaster::writeLock(quint16 value)
{
    writeFC6(WRegLock, value,
             [this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
}

void TrydanModbusTcpMaster::writeIntensity(quint16 amps)
{
    writeFC6(WRegIntensity, amps,
             [this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
}

void TrydanModbusTcpMaster::writePauseDynamic(quint16 value)
{
    // PauseDynamic is also emitted via writeCompleted so the plugin can update
    // the optimizerSuspended state if needed; action handlers must filter by address.
    writeFC6(WRegPauseDynamic, value,
             [this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
}

// --- Private implementation ---

QList<TrydanModbusTcpMaster::ReadStep> TrydanModbusTcpMaster::buildInitSteps()
{
    return {
        { RegMinIntensity, [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_minIntensity = decodeIntFromFloat32(h, l);
        }},
        { RegMaxIntensity, [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_maxIntensity = decodeIntFromFloat32(h, l);
        }},
    };
}

QList<TrydanModbusTcpMaster::ReadStep> TrydanModbusTcpMaster::buildPollSteps()
{
    // Each register pair is a separate FC3 transaction (2 registers, float32 Big/Big).
    // Order matters: Dynamic must always be read (last) to reflect the most current
    // state before the plugin applies the conflict-management logic.
    return {
        { RegChargeState,  [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_chargeState  = decodeIntFromFloat32(h, l);
        }},
        { RegChargePower,  [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_chargePower  = decodeFloat32BB(h, l);
        }},
        { RegChargeEnergy, [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_chargeEnergy = decodeFloat32BB(h, l);
        }},
        { RegSlaveError,   [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_slaveError   = decodeIntFromFloat32(h, l);
        }},
        { RegHousePower,   [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_housePower   = decodeFloat32BB(h, l);
        }},
        { RegPowerFV,      [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_powerFV      = decodeFloat32BB(h, l);
        }},
        { RegPauseState,   [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_pauseState   = decodeIntFromFloat32(h, l);
        }},
        { RegLock,         [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_lock         = decodeIntFromFloat32(h, l);
        }},
        { RegIntensity,    [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_intensity    = decodeIntFromFloat32(h, l);
        }},
        { RegDynamic,      [this](bool ok, quint16 h, quint16 l) {
            // Critical: must be read every poll — the V2C app can toggle this at any time.
            if (ok) m_dynamic      = decodeIntFromFloat32(h, l);
        }},
        { RegPauseDynamic, [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_pauseDynamic = decodeIntFromFloat32(h, l);
        }},
        { RegPower_L1, [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_powerL1 = decodeFloat32BB(h, l);
        }},
        { RegPower_L2, [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_powerL2 = decodeFloat32BB(h, l);
        }},
        { RegPower_L3, [this](bool ok, quint16 h, quint16 l) {
            if (ok) m_powerL3 = decodeFloat32BB(h, l);
        }},
    };
}

void TrydanModbusTcpMaster::runReadSequence(QList<ReadStep> steps,
                                            std::function<void(bool)> onDone)
{
    doNextRead(std::move(steps), 0, std::move(onDone));
}

void TrydanModbusTcpMaster::doNextRead(QList<ReadStep> steps,
                                       int index,
                                       std::function<void(bool)> onDone)
{
    if (index >= steps.count()) {
        onDone(true);
        return;
    }

    const quint16     address  = steps.at(index).first;
    const ReadCallback callback = steps.at(index).second;

    // Individual FC3 read of exactly 2 registers (one float32 value).
    // Never read a range spanning multiple values — overlapping windows would
    // return garbage for addresses beyond the first. cf. modbus.py _read_register.
    QModbusReply *reply = m_modbusTcpMaster->sendReadRequest(
        QModbusDataUnit(QModbusDataUnit::HoldingRegisters, address, 2),
        static_cast<int>(m_slaveId));

    if (!reply) {
        qCWarning(dcV2C()) << "TrydanModbusTcpMaster: sendReadRequest returned null for reg" << Qt::hex << address;
        onDone(false);
        return;
    }

    connect(reply, &QModbusReply::finished, this,
            [this, reply, steps, index, callback, onDone]() mutable {
        reply->deleteLater();

        if (reply->error() != QModbusDevice::NoError) {
            qCWarning(dcV2C()) << "TrydanModbusTcpMaster: read error for reg"
                               << Qt::hex << steps.at(index).first
                               << ":" << reply->errorString();
            callback(false, 0, 0);
            onDone(false);
            return;
        }

        const auto values = reply->result().values();
        callback(true, values.value(0), values.value(1));
        doNextRead(std::move(steps), index + 1, std::move(onDone));
    });
}

void TrydanModbusTcpMaster::writeFC6(quint16 address, quint16 value,
                                     std::function<void(quint16, bool)> callback)
{
    // FC6 = Write Single Register; QModbusTcpClient uses FC6 for single-word writes.
    QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address, 1);
    unit.setValue(0, value);

    QModbusReply *reply = m_modbusTcpMaster->sendWriteRequest(unit, static_cast<int>(m_slaveId));
    if (!reply) {
        qCWarning(dcV2C()) << "TrydanModbusTcpMaster: sendWriteRequest returned null for reg" << Qt::hex << address;
        callback(address, false);
        return;
    }

    connect(reply, &QModbusReply::finished, this, [reply, address, callback]() {
        reply->deleteLater();
        callback(address, reply->error() == QModbusDevice::NoError);
    });
}

void TrydanModbusTcpMaster::onConnectionStateChanged(bool connected)
{
    if (connected) {
        m_errorCount = 0;
        // Start init directly — do NOT call setReachable(true) here.
        // setReachable(true) is deferred to initialize()'s success callback so the
        // plugin's reachableChanged handler can no longer trigger a second initialize()
        // call via signal re-entry.  The connection sequence is:
        //   TCP up → initialize() → setReachable(true) → reachableChanged(true) → UI update
        initialize();
    } else {
        // TCP actually dropped: abort any in-flight sequence and go offline.
        // The 30s poll timer will call connectDevice() → new TCP handshake.
        m_updating     = false;
        m_initializing = false;
        setReachable(false);
    }
}

void TrydanModbusTcpMaster::handleError()
{
    m_errorCount++;
    if (m_errorCount >= k_errorLimit) {
        qCWarning(dcV2C()) << "TrydanModbusTcpMaster: error limit reached, marking unreachable";
        setReachable(false);
    }
}
